Spaces:
Running
Running
| import { useEffect, useMemo, useRef, useState } from 'react'; | |
| import { | |
| BrowserEngineClient, | |
| EngineClientError, | |
| evaluateModelGate, | |
| isShardDownloadFailureDetails, | |
| loadModelManifestV2, | |
| type EngineCapabilities, | |
| type BackendReport, | |
| type EngineChatMessage, | |
| type EngineChatToolCall, | |
| type EngineEvent, | |
| type ManifestModelV2, | |
| type ModelManifestV2, | |
| type ModelTierId, | |
| type RequestedBackend, | |
| } from '../engine'; | |
| import { executeAgentTool, type ArtifactDocument } from '../lib/agent-tools'; | |
| import type { | |
| ChatMessage, | |
| ChatSession, | |
| ComposerSubmission, | |
| ModelLoadProgress, | |
| ModelId, | |
| ModelOption, | |
| RuntimeSettings, | |
| ShardRetryNotice, | |
| StorageStatus, | |
| StreamState, | |
| TelemetrySnapshot, | |
| ToolEvent, | |
| ToolRun, | |
| } from '../lib/contracts'; | |
| import { formatBytes } from '../lib/format'; | |
| import { DEFAULT_CONTEXT_SIZE, DEFAULT_MAX_TOKENS } from '../lib/runtime-defaults'; | |
| import { | |
| conversationTitle, | |
| normalizeConversationModelId, | |
| planMessageRegeneration, | |
| upsertConversation, | |
| } from '../lib/conversations'; | |
| import { deleteConversation, listConversations, saveConversation } from '../lib/idb'; | |
| import { parseThoughtStream } from '../lib/think-parser'; | |
| import { parseToolCalls } from '../lib/tool-protocol'; | |
| import { planClientTools } from '../lib/tool-routing'; | |
| import { BonsaiShell } from './components/BonsaiShell'; | |
| const MANIFEST_PATH = 'manifest/models.json'; | |
| const MAX_TOOL_ROUNDS = 5; | |
| const MAX_TOOL_CALLS_PER_ROUND = 4; | |
| const MAX_TOOL_CALLS_TOTAL = 12; | |
| export const DEFAULT_MODEL_ID: ModelId = 'bonsai-27b'; | |
| export { DEFAULT_CONTEXT_SIZE, DEFAULT_MAX_TOKENS } from '../lib/runtime-defaults'; | |
| const MODEL_DECOR: Record<ModelTierId, { | |
| id: ModelId; | |
| parameters: string; | |
| }> = { | |
| '1_7b': { | |
| id: 'bonsai-1.7b', | |
| parameters: '1.7B', | |
| }, | |
| '4b': { | |
| id: 'bonsai-4b', | |
| parameters: '4B', | |
| }, | |
| '8b': { | |
| id: 'bonsai-8b', | |
| parameters: '8B', | |
| }, | |
| '27b': { | |
| id: 'bonsai-27b', | |
| parameters: '27B', | |
| }, | |
| }; | |
| export function modelArchitectureLabel(architecture: string): string { | |
| if (architecture === 'qwen3') return 'Qwen3 dense'; | |
| if (architecture === 'qwen35') return 'Qwen3.5 hybrid'; | |
| return architecture; | |
| } | |
| const UI_TO_TIER: Record<ModelId, ModelTierId> = Object.fromEntries( | |
| Object.entries(MODEL_DECOR).map(([tier, decor]) => [decor.id, tier]), | |
| ) as Record<ModelId, ModelTierId>; | |
| const INITIAL_SETTINGS: RuntimeSettings = { | |
| backend: 'auto', | |
| contextSize: DEFAULT_CONTEXT_SIZE, | |
| temperature: 0.7, | |
| maxTokens: DEFAULT_MAX_TOKENS, | |
| systemPrompt: 'You are Bonsai, a private browser-local assistant. Be concise and evidence-led. Use a client tool only when it materially improves the answer, and never claim a tool succeeded before its response arrives.', | |
| }; | |
| const INITIAL_TELEMETRY: TelemetrySnapshot = { | |
| backend: 'Unavailable', | |
| device: 'Inspecting browser capabilities…', | |
| modelMemoryBytes: 0, | |
| kvMemoryBytes: 0, | |
| storageUsedBytes: 0, | |
| contextUsed: 0, | |
| contextLimit: DEFAULT_CONTEXT_SIZE, | |
| tokensPerSecond: 0, | |
| prefillTokensPerSecond: 0, | |
| decodeTokensPerSecond: 0, | |
| inferencePhase: 'idle', | |
| promptProcessed: 0, | |
| promptTotal: 0, | |
| completionTokens: 0, | |
| timeToFirstTokenMs: 0, | |
| gpuLayers: '—', | |
| graphSplits: null, | |
| }; | |
| const INITIAL_STORAGE: StorageStatus = { | |
| usedBytes: 0, | |
| quotaBytes: 0, | |
| persistent: false, | |
| downloads: [], | |
| }; | |
| const INITIAL_MODEL_LOAD_PROGRESS: ModelLoadProgress = { | |
| modelId: null, | |
| modelLabel: '', | |
| stage: 'idle', | |
| stageProgress: 0, | |
| downloadedBytes: 0, | |
| totalBytes: 0, | |
| residentBytes: 0, | |
| nativeStage: null, | |
| shards: [], | |
| }; | |
| let sharedClient: BrowserEngineClient | null = null; | |
| function getEngineClient(): BrowserEngineClient { | |
| sharedClient ??= new BrowserEngineClient(); | |
| return sharedClient; | |
| } | |
| function manifestUrl(): string { | |
| return new URL(`${import.meta.env.BASE_URL}${MANIFEST_PATH}`, document.baseURI).href; | |
| } | |
| function clock(): string { | |
| return new Date().toLocaleTimeString('en-GB', { hour12: false }); | |
| } | |
| function eventClock(): string { | |
| return new Date().toISOString().slice(11, 23); | |
| } | |
| function createConversationId(): string { | |
| return crypto.randomUUID(); | |
| } | |
| function errorMessage(error: unknown): string { | |
| if (error instanceof EngineClientError) return `${error.code}: ${error.message}`; | |
| return error instanceof Error ? error.message : String(error); | |
| } | |
| function isAborted(error: unknown, signal: AbortSignal): boolean { | |
| return signal.aborted | |
| || (error instanceof DOMException && error.name === 'AbortError') | |
| || (error instanceof EngineClientError && error.code === 'ABORTED'); | |
| } | |
| export function requiresModelReload(error: unknown): error is EngineClientError { | |
| return error instanceof EngineClientError && [ | |
| 'WEBGPU_DEVICE_LOST', | |
| 'ENGINE_WORKER_FAILED', | |
| 'MODEL_NOT_LOADED', | |
| ].includes(error.code); | |
| } | |
| export function reportsModelWeightProgress( | |
| phase: Extract<EngineEvent, { event: 'progress' }>['phase'], | |
| ): boolean { | |
| return phase !== 'manifest'; | |
| } | |
| export function reconcileCompletionTokens( | |
| engineCompletionTokens: number | undefined, | |
| streamedTokenEvents: number, | |
| ): number { | |
| const engineCount = typeof engineCompletionTokens === 'number' | |
| && Number.isSafeInteger(engineCompletionTokens) | |
| && engineCompletionTokens >= 0 | |
| ? engineCompletionTokens | |
| : 0; | |
| const streamCount = Number.isSafeInteger(streamedTokenEvents) && streamedTokenEvents >= 0 | |
| ? streamedTokenEvents | |
| : 0; | |
| return Math.max(engineCount, streamCount); | |
| } | |
| export function reconcileTotalTokens( | |
| engineTotalTokens: number | undefined, | |
| enginePromptTokens: number | undefined, | |
| completionTokens: number, | |
| ): number { | |
| const totalCount = typeof engineTotalTokens === 'number' | |
| && Number.isSafeInteger(engineTotalTokens) | |
| && engineTotalTokens >= 0 | |
| ? engineTotalTokens | |
| : 0; | |
| const promptCount = typeof enginePromptTokens === 'number' | |
| && Number.isSafeInteger(enginePromptTokens) | |
| && enginePromptTokens >= 0 | |
| ? enginePromptTokens | |
| : 0; | |
| return Math.max(totalCount, promptCount + completionTokens); | |
| } | |
| export function publishBackendReport(report: BackendReport): void { | |
| globalThis.__bonsaiBackendReport = report; | |
| } | |
| function appendSegment(current: string, next: string): string { | |
| const trimmed = next.trim(); | |
| if (!trimmed) return current; | |
| return current ? `${current}\n\n${trimmed}` : trimmed; | |
| } | |
| function replaceMessage( | |
| setter: React.Dispatch<React.SetStateAction<ChatMessage[]>>, | |
| id: string, | |
| update: (message: ChatMessage) => ChatMessage, | |
| ): void { | |
| setter((current) => current.map((message) => message.id === id ? update(message) : message)); | |
| } | |
| function buildEngineHistory(messages: ChatMessage[], systemPrompt: string): EngineChatMessage[] { | |
| const history: EngineChatMessage[] = systemPrompt.trim() | |
| ? [{ role: 'system', content: systemPrompt.trim() }] | |
| : []; | |
| for (const message of messages) { | |
| if (message.role === 'user' && message.content.trim() && !message.error) { | |
| history.push({ role: 'user', content: message.content }); | |
| } else if (message.role === 'assistant' && !message.error) { | |
| const completedTools = (message.tools ?? []).filter((tool) => tool.output !== undefined); | |
| if (message.content.trim() || completedTools.length > 0) { | |
| history.push({ | |
| role: 'assistant', | |
| content: message.content || null, | |
| ...(completedTools.length > 0 ? { | |
| tool_calls: completedTools.map((tool) => ({ | |
| id: tool.id, | |
| type: 'function' as const, | |
| function: { name: tool.name, arguments: tool.input }, | |
| })), | |
| } : {}), | |
| }); | |
| for (const tool of completedTools) { | |
| history.push({ role: 'tool', content: tool.output ?? '', tool_call_id: tool.id }); | |
| } | |
| } | |
| } | |
| } | |
| return history; | |
| } | |
| function fallbackToolCalls(text: string): EngineChatToolCall[] { | |
| return parseToolCalls(text).calls.map((call) => ({ | |
| id: crypto.randomUUID(), | |
| type: 'function', | |
| function: { | |
| name: call.name, | |
| arguments: JSON.stringify(call.arguments), | |
| }, | |
| })); | |
| } | |
| export function App() { | |
| const [client] = useState(getEngineClient); | |
| const [manifest, setManifest] = useState<ModelManifestV2 | null>(null); | |
| const [capabilities, setCapabilities] = useState<EngineCapabilities | null>(null); | |
| const [activeModelId, setActiveModelId] = useState<ModelId>(DEFAULT_MODEL_ID); | |
| const [loadedModelId, setLoadedModelId] = useState<ModelId | null>(null); | |
| const [loadingModelId, setLoadingModelId] = useState<ModelId | null>(null); | |
| const [messages, setMessages] = useState<ChatMessage[]>([]); | |
| const [conversations, setConversations] = useState<ChatSession[]>([]); | |
| const [activeConversationId, setActiveConversationId] = useState(createConversationId); | |
| const [conversationListOpen, setConversationListOpen] = useState(false); | |
| const [streamState, setStreamState] = useState<StreamState>('idle'); | |
| const [modelLoadProgress, setModelLoadProgress] = useState<ModelLoadProgress>(INITIAL_MODEL_LOAD_PROGRESS); | |
| const [toolsEnabled, setToolsEnabled] = useState(false); | |
| const [settings, setSettings] = useState<RuntimeSettings>(INITIAL_SETTINGS); | |
| const [settingsOpen, setSettingsOpen] = useState(false); | |
| const [storage, setStorage] = useState<StorageStatus>(INITIAL_STORAGE); | |
| const [telemetry, setTelemetry] = useState<TelemetrySnapshot>(INITIAL_TELEMETRY); | |
| const [toolEvents, setToolEvents] = useState<ToolEvent[]>([]); | |
| const [artifact, setArtifact] = useState<ArtifactDocument | null>(null); | |
| const [error, setError] = useState<string | null>(null); | |
| const [shardRetry, setShardRetry] = useState<ShardRetryNotice | null>(null); | |
| const [bootStatus, setBootStatus] = useState('Inspecting local runtime…'); | |
| const operationRef = useRef<AbortController | null>(null); | |
| const loadedConfigurationRef = useRef<{ id: ModelId; backend: RequestedBackend; contextSize: number } | null>(null); | |
| const historyReadyRef = useRef(false); | |
| const loadProgressFrameRef = useRef<number | null>(null); | |
| const pendingLoadProgressRef = useRef<ModelLoadProgress | null>(null); | |
| const modelLoadProgressRef = useRef<ModelLoadProgress>(INITIAL_MODEL_LOAD_PROGRESS); | |
| const inferenceFrameRef = useRef<number | null>(null); | |
| const pendingInferenceRef = useRef<Extract<EngineEvent, { event: 'generation' }> | null>(null); | |
| const publishModelLoadProgress = (next: ModelLoadProgress): void => { | |
| modelLoadProgressRef.current = next; | |
| pendingLoadProgressRef.current = next; | |
| if (loadProgressFrameRef.current !== null) return; | |
| loadProgressFrameRef.current = window.requestAnimationFrame(() => { | |
| loadProgressFrameRef.current = null; | |
| const pending = pendingLoadProgressRef.current; | |
| pendingLoadProgressRef.current = null; | |
| if (pending) setModelLoadProgress(pending); | |
| }); | |
| }; | |
| const publishInferenceProgress = ( | |
| next: Extract<EngineEvent, { event: 'generation' }>, | |
| ): void => { | |
| pendingInferenceRef.current = next; | |
| if (inferenceFrameRef.current !== null) return; | |
| inferenceFrameRef.current = window.requestAnimationFrame(() => { | |
| inferenceFrameRef.current = null; | |
| const pending = pendingInferenceRef.current; | |
| pendingInferenceRef.current = null; | |
| if (!pending) return; | |
| const liveRate = pending.phase === 'prefill' | |
| ? pending.promptTokensPerSecond | |
| : pending.decodeTokensPerSecond; | |
| setTelemetry((current) => ({ | |
| ...current, | |
| inferencePhase: pending.phase, | |
| tokensPerSecond: liveRate, | |
| prefillTokensPerSecond: pending.promptTokensPerSecond || current.prefillTokensPerSecond, | |
| decodeTokensPerSecond: pending.decodeTokensPerSecond || current.decodeTokensPerSecond, | |
| promptProcessed: pending.promptProcessed, | |
| promptTotal: pending.promptTotal, | |
| completionTokens: pending.completionTokens, | |
| contextUsed: Math.min( | |
| current.contextLimit, | |
| Math.max(current.contextUsed, pending.promptProcessed + pending.completionTokens), | |
| ), | |
| })); | |
| }); | |
| }; | |
| const clearPendingInferenceProgress = (): void => { | |
| if (inferenceFrameRef.current !== null) { | |
| window.cancelAnimationFrame(inferenceFrameRef.current); | |
| inferenceFrameRef.current = null; | |
| } | |
| pendingInferenceRef.current = null; | |
| }; | |
| const handleModelInvalidation = (runtimeError: unknown): boolean => { | |
| if (!requiresModelReload(runtimeError)) return false; | |
| setLoadedModelId(null); | |
| loadedConfigurationRef.current = null; | |
| setTelemetry((current) => ({ | |
| ...current, | |
| backend: 'Unavailable', | |
| modelMemoryBytes: 0, | |
| kvMemoryBytes: 0, | |
| contextUsed: 0, | |
| tokensPerSecond: 0, | |
| prefillTokensPerSecond: 0, | |
| decodeTokensPerSecond: 0, | |
| inferencePhase: 'idle', | |
| promptProcessed: 0, | |
| promptTotal: 0, | |
| completionTokens: 0, | |
| gpuLayers: '—', | |
| graphSplits: null, | |
| })); | |
| setError(errorMessage(runtimeError)); | |
| setBootStatus(runtimeError.code === 'WEBGPU_DEVICE_LOST' | |
| ? 'WebGPU device lost · model unloaded · run again to reload' | |
| : 'Browser engine restarted · model unloaded · run again to reload'); | |
| return true; | |
| }; | |
| const persistenceAttemptedRef = useRef(false); | |
| useEffect(() => () => { | |
| if (loadProgressFrameRef.current !== null) { | |
| window.cancelAnimationFrame(loadProgressFrameRef.current); | |
| } | |
| if (inferenceFrameRef.current !== null) { | |
| window.cancelAnimationFrame(inferenceFrameRef.current); | |
| } | |
| }, []); | |
| useEffect(() => { | |
| let cancelled = false; | |
| const bootstrap = async () => { | |
| let loadedManifest: ModelManifestV2 | null = null; | |
| try { | |
| loadedManifest = await loadModelManifestV2(manifestUrl()); | |
| } catch (bootstrapError) { | |
| if (!cancelled) setError(`Manifest unavailable: ${errorMessage(bootstrapError)}`); | |
| } | |
| try { | |
| const nextCapabilities = await client.capabilities(); | |
| if (!cancelled) { | |
| setCapabilities(nextCapabilities); | |
| setStorage({ | |
| usedBytes: nextCapabilities.storage.usageBytes ?? 0, | |
| quotaBytes: nextCapabilities.storage.quotaBytes ?? 0, | |
| persistent: nextCapabilities.storage.persisted, | |
| downloads: [], | |
| }); | |
| setTelemetry((current) => ({ | |
| ...current, | |
| device: nextCapabilities.webgpu.name || (nextCapabilities.webgpu.available ? 'WebGPU adapter' : 'CPU · WebAssembly'), | |
| storageUsedBytes: nextCapabilities.storage.usageBytes ?? 0, | |
| })); | |
| setBootStatus(nextCapabilities.webgpu.available | |
| ? `${nextCapabilities.webgpu.name || 'WebGPU'} ready · model runs after local load` | |
| : 'WebGPU unavailable · dense tiers can use CPU-WASM'); | |
| } | |
| } catch (capabilityError) { | |
| if (!cancelled) setError(`Capability inspection failed: ${errorMessage(capabilityError)}`); | |
| } | |
| try { | |
| const storedConversations = (await listConversations<ChatMessage>()).map((conversation) => ({ | |
| ...conversation, | |
| modelId: normalizeConversationModelId(conversation.modelId), | |
| systemPrompt: typeof conversation.systemPrompt === 'string' | |
| ? conversation.systemPrompt | |
| : INITIAL_SETTINGS.systemPrompt, | |
| })); | |
| const latest = storedConversations[0]; | |
| if (!cancelled) { | |
| setConversations(storedConversations); | |
| if (latest) { | |
| setActiveConversationId(latest.id); | |
| setActiveModelId(latest.modelId); | |
| setMessages(latest.messages); | |
| setSettings((current) => ({ ...current, systemPrompt: latest.systemPrompt })); | |
| } | |
| } | |
| } catch { | |
| // Private browsing may disable IndexedDB. Chat remains usable for the tab. | |
| if (!cancelled) setBootStatus((current) => `${current} · history is session-only`); | |
| } finally { | |
| historyReadyRef.current = true; | |
| if (!cancelled && loadedManifest) setManifest(loadedManifest); | |
| } | |
| }; | |
| void bootstrap(); | |
| return () => { cancelled = true; }; | |
| }, [client]); | |
| useEffect(() => { | |
| if (!historyReadyRef.current) return; | |
| const alreadySaved = conversations.some((conversation) => conversation.id === activeConversationId); | |
| if (messages.length === 0 && settings.systemPrompt === INITIAL_SETTINGS.systemPrompt && !alreadySaved) return; | |
| const nextConversation: ChatSession = { | |
| id: activeConversationId, | |
| title: conversationTitle(messages), | |
| messages, | |
| modelId: activeModelId, | |
| systemPrompt: settings.systemPrompt, | |
| updatedAt: Date.now(), | |
| }; | |
| const timer = window.setTimeout(() => { | |
| setConversations((current) => upsertConversation(current, nextConversation)); | |
| void saveConversation(nextConversation).catch(() => setBootStatus((current) => current.includes('history is session-only') | |
| ? current | |
| : `${current} · history is session-only`)); | |
| }, 250); | |
| return () => window.clearTimeout(timer); | |
| }, [activeConversationId, activeModelId, messages, settings.systemPrompt]); | |
| const models = useMemo<ModelOption[]>(() => { | |
| if (!manifest) return []; | |
| return manifest.models.map((model) => { | |
| const decor = MODEL_DECOR[model.id]; | |
| const configured = loadedConfigurationRef.current; | |
| const cachedDownload = storage.downloads.find((download) => ( | |
| download.modelId === decor.id && download.state === 'complete' | |
| )); | |
| const configuredContext = model.id === '27b' | |
| ? Math.min(settings.contextSize, model.defaultContext) | |
| : settings.contextSize; | |
| const isLoaded = loadedModelId === decor.id | |
| && configured?.id === decor.id | |
| && configured.backend === settings.backend | |
| && configured.contextSize === configuredContext; | |
| let availability: ModelOption['availability'] = isLoaded | |
| ? 'loaded' | |
| : loadingModelId === decor.id ? 'loading' : cachedDownload ? 'cached' : 'remote'; | |
| let limitReason: string | undefined; | |
| if (!isLoaded && capabilities) { | |
| const gate = evaluateModelGate( | |
| model, | |
| settings.backend, | |
| capabilities.webgpu, | |
| capabilities.storage, | |
| cachedDownload?.totalBytes ?? 0, | |
| ); | |
| if (!gate.allowed) { | |
| availability = 'limited'; | |
| limitReason = gate.reasons.join(' '); | |
| } | |
| } | |
| return { | |
| id: decor.id, | |
| manifestId: model.id, | |
| label: model.displayName, | |
| architectureLabel: modelArchitectureLabel(model.architecture), | |
| parameters: decor.parameters, | |
| runtimeLabel: model.cpuFallback ? 'WebGPU · WASM fallback' : 'WebGPU required', | |
| footprint: formatBytes(model.downloadBytes), | |
| availability, | |
| contextLimit: model.contextLength, | |
| defaultContext: model.defaultContext, | |
| ...(limitReason ? { limitReason } : {}), | |
| }; | |
| }); | |
| }, [capabilities, loadedModelId, loadingModelId, manifest, settings.backend, settings.contextSize, storage.downloads]); | |
| const activeTier = UI_TO_TIER[activeModelId]; | |
| const refreshStorage = async (): Promise<void> => { | |
| const estimate = await client.storageEstimate(); | |
| setStorage((current) => ({ | |
| ...current, | |
| usedBytes: estimate.usageBytes ?? 0, | |
| quotaBytes: estimate.quotaBytes ?? 0, | |
| persistent: estimate.persisted, | |
| })); | |
| setCapabilities((current) => current ? { ...current, storage: estimate } : current); | |
| setTelemetry((current) => ({ ...current, storageUsedBytes: estimate.usageBytes ?? 0 })); | |
| }; | |
| const requestPersistentStorage = async (force = false): Promise<void> => { | |
| if (storage.persistent || (!force && persistenceAttemptedRef.current)) return; | |
| persistenceAttemptedRef.current = true; | |
| setBootStatus('Requesting persistent browser storage before download…'); | |
| try { | |
| const estimate = await client.storagePersist(); | |
| setStorage((current) => ({ | |
| ...current, | |
| usedBytes: estimate.usageBytes ?? 0, | |
| quotaBytes: estimate.quotaBytes ?? 0, | |
| persistent: estimate.persisted, | |
| })); | |
| setCapabilities((current) => current ? { ...current, storage: estimate } : current); | |
| setTelemetry((current) => ({ ...current, storageUsedBytes: estimate.usageBytes ?? 0 })); | |
| setBootStatus(estimate.granted | |
| ? 'Persistent browser storage granted' | |
| : 'Persistent storage was not granted · continuing with browser-managed storage'); | |
| } catch (persistError) { | |
| if (handleModelInvalidation(persistError)) return; | |
| setBootStatus('Persistent storage request unavailable · continuing with browser-managed storage'); | |
| } | |
| }; | |
| const loadModel = async ( | |
| modelId: ModelId, | |
| controller: AbortController, | |
| contextSize: number, | |
| ): Promise<void> => { | |
| if (!manifest) throw new Error('The pinned model manifest is still loading.'); | |
| const tier = UI_TO_TIER[modelId]; | |
| const model = manifest.models.find((candidate) => candidate.id === tier); | |
| if (!model) throw new Error(`Model ${tier} is absent from the pinned manifest.`); | |
| setLoadedModelId(null); | |
| loadedConfigurationRef.current = null; | |
| setLoadingModelId(modelId); | |
| setStreamState('loading'); | |
| setError(null); | |
| setShardRetry(null); | |
| setBootStatus(`Preparing ${model.displayName}…`); | |
| const initialProgress: ModelLoadProgress = { | |
| ...INITIAL_MODEL_LOAD_PROGRESS, | |
| modelId, | |
| modelLabel: model.displayName, | |
| stage: 'manifest', | |
| totalBytes: model.downloadBytes, | |
| }; | |
| modelLoadProgressRef.current = initialProgress; | |
| setModelLoadProgress(initialProgress); | |
| try { | |
| const result = await client.loadModel({ | |
| manifestUrl: manifestUrl(), | |
| modelId: tier, | |
| backend: settings.backend, | |
| contextSize, | |
| }, { | |
| signal: controller.signal, | |
| onProgress: (progress) => { | |
| const received = Math.min(progress.loadedBytes, model.downloadBytes); | |
| const shard = progress.shardIndex === null ? '' : ` · shard ${progress.shardIndex + 1}/${progress.shardCount}`; | |
| const stageProgress = progress.stageProgress | |
| ?? (progress.totalBytes > 0 ? progress.loadedBytes / progress.totalBytes : 0); | |
| publishModelLoadProgress({ | |
| modelId, | |
| modelLabel: model.displayName, | |
| stage: progress.phase, | |
| stageProgress: Math.min(1, Math.max(0, stageProgress)), | |
| downloadedBytes: received, | |
| totalBytes: model.downloadBytes, | |
| residentBytes: progress.residentBytes ?? 0, | |
| nativeStage: progress.nativeStage, | |
| shards: progress.shards.map((item) => ({ ...item })), | |
| }); | |
| if (progress.residentBytes !== null) { | |
| setTelemetry((current) => ({ ...current, modelMemoryBytes: progress.residentBytes ?? 0 })); | |
| } | |
| if (!reportsModelWeightProgress(progress.phase)) { | |
| setBootStatus('Reading pinned model manifest…'); | |
| return; | |
| } | |
| const stageLabel = progress.phase === 'cache' | |
| ? 'Inspecting cache' | |
| : progress.phase === 'verify' | |
| ? 'Verifying shards' | |
| : progress.phase === 'load' | |
| ? 'Allocating model' | |
| : 'Downloading model'; | |
| setBootStatus(`${stageLabel}${shard} · ${Math.round(stageProgress * 100)}%`); | |
| if (progress.phase !== 'load') { | |
| setStorage((current) => ({ | |
| ...current, | |
| downloads: [ | |
| ...current.downloads.filter((download) => download.modelId !== modelId), | |
| { | |
| modelId, | |
| label: model.displayName, | |
| receivedBytes: received, | |
| totalBytes: model.downloadBytes, | |
| state: received >= model.downloadBytes ? 'complete' : 'downloading', | |
| }, | |
| ], | |
| })); | |
| } | |
| }, | |
| }); | |
| setLoadedModelId(modelId); | |
| setShardRetry(null); | |
| loadedConfigurationRef.current = { id: modelId, backend: settings.backend, contextSize }; | |
| setTelemetry((current) => ({ | |
| ...current, | |
| backend: result.backend === 'webgpu' ? 'WebGPU' : 'WASM', | |
| device: result.backend === 'webgpu' | |
| ? capabilities?.webgpu.name || 'WebGPU adapter' | |
| : `CPU · ${capabilities?.hardwareConcurrency ?? navigator.hardwareConcurrency ?? 1} logical cores`, | |
| modelMemoryBytes: result.backendReport.modelBufferBytes ?? model.downloadBytes, | |
| kvMemoryBytes: result.backendReport.webgpuKvBufferBytes ?? 0, | |
| contextUsed: 0, | |
| contextLimit: result.context.size, | |
| gpuLayers: result.backendReport.layersGpu | |
| ? `${result.backendReport.layersGpu.offloaded} / ${result.backendReport.layersGpu.total}` | |
| : result.backend === 'wasm' ? 'CPU' : 'unreported', | |
| graphSplits: result.backendReport.nGraphSplits, | |
| })); | |
| publishModelLoadProgress({ | |
| ...modelLoadProgressRef.current, | |
| modelId, | |
| modelLabel: model.displayName, | |
| stage: 'ready', | |
| stageProgress: 1, | |
| downloadedBytes: model.downloadBytes, | |
| totalBytes: model.downloadBytes, | |
| residentBytes: result.backendReport.allocatedBufferBytes ?? result.backendReport.modelBufferBytes ?? model.downloadBytes, | |
| nativeStage: null, | |
| }); | |
| setBootStatus(`${model.displayName} loaded · ${result.backend.toUpperCase()} · ${result.context.size.toLocaleString()} ctx`); | |
| await refreshStorage(); | |
| } catch (loadError) { | |
| const operatorAborted = controller.signal.aborted; | |
| const retryDetails = !operatorAborted && loadError instanceof EngineClientError | |
| && isShardDownloadFailureDetails(loadError.details) | |
| ? loadError.details | |
| : null; | |
| if (operatorAborted) setShardRetry(null); | |
| if (retryDetails) { | |
| setShardRetry({ | |
| modelId, | |
| shardIndex: retryDetails.shardIndex, | |
| shardCount: retryDetails.shardCount, | |
| shardPath: retryDetails.shardPath, | |
| error: errorMessage(loadError), | |
| }); | |
| } | |
| publishModelLoadProgress({ | |
| ...modelLoadProgressRef.current, | |
| stage: operatorAborted ? 'idle' : 'error', | |
| nativeStage: null, | |
| }); | |
| setStorage((current) => ({ | |
| ...current, | |
| downloads: operatorAborted | |
| ? current.downloads.filter((download) => download.modelId !== modelId) | |
| : current.downloads.map((download) => download.modelId === modelId | |
| ? { | |
| ...download, | |
| label: retryDetails | |
| ? `${model.displayName} · shard ${retryDetails.shardIndex + 1}/${retryDetails.shardCount} · ${retryDetails.shardPath}` | |
| : download.label, | |
| state: 'error', | |
| } | |
| : download), | |
| })); | |
| throw loadError; | |
| } finally { | |
| setLoadingModelId(null); | |
| } | |
| }; | |
| const targetContext = (model: ManifestModelV2): number => { | |
| if (model.id === activeTier) { | |
| const provenLimit = model.id === '27b' ? model.defaultContext : model.contextLength; | |
| return Math.min(settings.contextSize, provenLimit); | |
| } | |
| return Math.min(DEFAULT_CONTEXT_SIZE, model.defaultContext); | |
| }; | |
| const handleModelSelect = (modelId: ModelId) => { | |
| if (operationRef.current) return; | |
| setShardRetry(null); | |
| setActiveModelId(modelId); | |
| const model = manifest?.models.find((candidate) => candidate.id === UI_TO_TIER[modelId]); | |
| if (model) { | |
| setSettings((current) => ({ | |
| ...current, | |
| backend: model.cpuFallback ? current.backend : current.backend === 'wasm' ? 'auto' : current.backend, | |
| contextSize: Math.min(DEFAULT_CONTEXT_SIZE, model.defaultContext), | |
| })); | |
| } | |
| }; | |
| const handleLoadModel = (modelId: ModelId) => { | |
| if (operationRef.current || !manifest) return; | |
| setActiveModelId(modelId); | |
| const model = manifest.models.find((candidate) => candidate.id === UI_TO_TIER[modelId]); | |
| if (!model) return; | |
| const contextSize = targetContext(model); | |
| if (model.id !== activeTier) setSettings((current) => ({ ...current, contextSize })); | |
| const controller = new AbortController(); | |
| operationRef.current = controller; | |
| void requestPersistentStorage() | |
| .then(() => { | |
| if (controller.signal.aborted) throw new DOMException('Model loading was aborted.', 'AbortError'); | |
| return loadModel(modelId, controller, contextSize); | |
| }) | |
| .then(() => setStreamState('complete')) | |
| .catch((loadError) => { | |
| if (handleModelInvalidation(loadError)) { | |
| setStreamState('error'); | |
| return; | |
| } | |
| if (!isAborted(loadError, controller.signal)) { | |
| const steer = model.id === '27b' ? ' Choose Bonsai 8B for the supported fallback path.' : ''; | |
| setError(`${errorMessage(loadError)}${steer}`); | |
| setStreamState('error'); | |
| setBootStatus('Model load failed safely'); | |
| } else { | |
| setStreamState('idle'); | |
| setBootStatus('Load stopped by operator'); | |
| } | |
| }) | |
| .finally(() => { | |
| if (operationRef.current === controller) operationRef.current = null; | |
| }); | |
| }; | |
| const startSubmission = ( | |
| { prompt, toolsEnabled: submissionToolsEnabled }: ComposerSubmission, | |
| baseMessages: ChatMessage[], | |
| existingUserMessage?: ChatMessage, | |
| ): boolean => { | |
| if (operationRef.current || !manifest) return false; | |
| const tier = UI_TO_TIER[activeModelId]; | |
| const selectedModel = manifest.models.find((candidate) => candidate.id === tier); | |
| if (!selectedModel) { | |
| setError(`Model ${tier} is absent from the pinned manifest.`); | |
| return false; | |
| } | |
| const requestedContext = targetContext(selectedModel); | |
| const configured = loadedConfigurationRef.current; | |
| const needsLoad = loadedModelId !== activeModelId | |
| || configured?.backend !== settings.backend | |
| || configured.contextSize !== requestedContext; | |
| if (needsLoad) { | |
| setError(`Load ${selectedModel.displayName} before running this prompt.`); | |
| setBootStatus('Explicit model load required'); | |
| return false; | |
| } | |
| const controller = new AbortController(); | |
| operationRef.current = controller; | |
| const nonce = crypto.randomUUID(); | |
| const userMessage: ChatMessage = existingUserMessage ?? { | |
| id: `user-${nonce}`, | |
| role: 'user', | |
| content: prompt, | |
| timestamp: clock(), | |
| }; | |
| const assistantId = `assistant-${nonce}`; | |
| const assistantMessage: ChatMessage = { | |
| id: assistantId, | |
| role: 'assistant', | |
| content: '', | |
| reasoning: '', | |
| timestamp: clock(), | |
| state: 'streaming', | |
| tools: [], | |
| }; | |
| const historySnapshot = [...baseMessages, userMessage]; | |
| setMessages([...historySnapshot, assistantMessage]); | |
| setError(null); | |
| const run = async () => { | |
| setStreamState('streaming'); | |
| setBootStatus(`Generating with ${selectedModel.displayName}…`); | |
| const initialToolPlan = planClientTools(prompt, submissionToolsEnabled, 0); | |
| const routedSystemPrompt = [settings.systemPrompt, initialToolPlan.systemInstruction] | |
| .filter((value): value is string => Boolean(value?.trim())) | |
| .join('\n\n'); | |
| const engineMessages = buildEngineHistory(historySnapshot, routedSystemPrompt); | |
| let visibleText = ''; | |
| let reasoningText = ''; | |
| let totalCompletionTokens = 0; | |
| let totalPromptTokens = 0; | |
| let lastTotalTokens = 0; | |
| let tokensPerSecond = 0; | |
| let prefillTokensPerSecond = 0; | |
| let totalPromptSeconds = 0; | |
| let totalDecodeSeconds = 0; | |
| let firstTokenAt = 0; | |
| let outputError: string | null = null; | |
| const generationStartedAt = performance.now(); | |
| let toolRounds = 0; | |
| let totalToolCalls = 0; | |
| clearPendingInferenceProgress(); | |
| setTelemetry((current) => ({ | |
| ...current, | |
| inferencePhase: 'prefill', | |
| tokensPerSecond: 0, | |
| prefillTokensPerSecond: 0, | |
| decodeTokensPerSecond: 0, | |
| promptProcessed: 0, | |
| promptTotal: 0, | |
| completionTokens: 0, | |
| timeToFirstTokenMs: 0, | |
| })); | |
| while (true) { | |
| const toolPlan = planClientTools(prompt, submissionToolsEnabled, toolRounds); | |
| let streamedText = ''; | |
| let streamedReasoning = ''; | |
| let streamedTokenEvents = 0; | |
| const completionOffset = totalCompletionTokens; | |
| const result = await client.generate({ | |
| messages: engineMessages, | |
| maxTokens: Math.max(settings.maxTokens, toolPlan.minimumMaxTokens), | |
| temperature: settings.temperature, | |
| topK: settings.temperature === 0 ? 1 : 40, | |
| topP: settings.temperature === 0 ? 1 : 0.9, | |
| ...(toolPlan.tools ? { tools: toolPlan.tools } : {}), | |
| toolChoice: toolPlan.toolChoice, | |
| cachePrompt: true, | |
| }, { | |
| signal: controller.signal, | |
| onGenerationProgress: (progress) => { | |
| publishInferenceProgress({ | |
| ...progress, | |
| completionTokens: completionOffset + progress.completionTokens, | |
| }); | |
| }, | |
| onToken: (delta, reasoningDelta) => { | |
| if (!firstTokenAt && (delta || reasoningDelta)) firstTokenAt = performance.now(); | |
| if (delta || reasoningDelta) streamedTokenEvents += 1; | |
| streamedText += delta; | |
| streamedReasoning += reasoningDelta ?? ''; | |
| const thought = parseThoughtStream(streamedText); | |
| const toolText = parseToolCalls(thought.content); | |
| const currentReasoning = streamedReasoning || thought.reasoning; | |
| replaceMessage(setMessages, assistantId, (message) => ({ | |
| ...message, | |
| content: appendSegment(visibleText, toolText.content), | |
| reasoning: appendSegment(reasoningText, currentReasoning), | |
| isThinking: thought.isThinking || Boolean(reasoningDelta), | |
| state: 'streaming', | |
| })); | |
| }, | |
| onToolCallProgress: (activity) => { | |
| if (!firstTokenAt) firstTokenAt = performance.now(); | |
| replaceMessage(setMessages, assistantId, (message) => ({ | |
| ...message, | |
| toolActivity: { | |
| name: activity.name || message.toolActivity?.name || 'tool call', | |
| argumentCharacters: activity.argumentCharacters, | |
| }, | |
| state: 'streaming', | |
| })); | |
| }, | |
| }); | |
| const roundCompletionTokens = reconcileCompletionTokens( | |
| result.usage?.completionTokens, | |
| streamedTokenEvents, | |
| ); | |
| const roundPromptTokens = result.usage?.promptTokens ?? 0; | |
| totalPromptTokens += roundPromptTokens; | |
| totalCompletionTokens += roundCompletionTokens; | |
| lastTotalTokens = Math.max( | |
| lastTotalTokens, | |
| reconcileTotalTokens( | |
| result.usage?.totalTokens, | |
| result.usage?.promptTokens, | |
| roundCompletionTokens, | |
| ), | |
| ); | |
| const roundPrefillRate = result.timings?.promptTokensPerSecond ?? 0; | |
| const roundDecodeRate = result.timings?.predictedTokensPerSecond ?? 0; | |
| if (roundPromptTokens > 0 && roundPrefillRate > 0) { | |
| totalPromptSeconds += roundPromptTokens / roundPrefillRate; | |
| } | |
| if (roundCompletionTokens > 0 && roundDecodeRate > 0) { | |
| totalDecodeSeconds += roundCompletionTokens / roundDecodeRate; | |
| } | |
| const thought = parseThoughtStream(result.text); | |
| const textual = parseToolCalls(thought.content); | |
| if (textual.incomplete || textual.errors.length > 0) { | |
| throw new Error(`Malformed textual tool call: ${textual.errors.join('; ') || 'closing </tool_call> is missing'}`); | |
| } | |
| visibleText = appendSegment(visibleText, textual.content); | |
| reasoningText = appendSegment(reasoningText, streamedReasoning || thought.reasoning); | |
| const calls = result.toolCalls.length > 0 ? result.toolCalls : fallbackToolCalls(thought.content); | |
| if (toolPlan.requiredToolName && calls.length === 0) { | |
| outputError = (result.toolCallError || result.finishReason === 'length') | |
| ? `The ${toolPlan.requiredToolName} call ended before its arguments were complete. Regenerate, shorten the request, or clear earlier turns so the ${DEFAULT_MAX_TOKENS.toLocaleString()}-token completion ceiling fits the context.` | |
| : `The model did not call the required ${toolPlan.requiredToolName} tool for this artifact request.`; | |
| } | |
| if (toolPlan.requiredToolName && calls.some((call) => call.function.name !== toolPlan.requiredToolName)) { | |
| throw new Error(`The model returned a different tool when ${toolPlan.requiredToolName} was required.`); | |
| } | |
| replaceMessage(setMessages, assistantId, (message) => ({ | |
| ...message, | |
| content: visibleText, | |
| reasoning: reasoningText, | |
| isThinking: false, | |
| toolActivity: undefined, | |
| })); | |
| if (outputError) break; | |
| engineMessages.push({ | |
| role: 'assistant', | |
| content: textual.content || null, | |
| ...(calls.length > 0 ? { tool_calls: calls } : {}), | |
| }); | |
| if (calls.length === 0) break; | |
| if (!submissionToolsEnabled) throw new Error('The model requested a tool while tools were disabled; nothing was executed.'); | |
| if (toolRounds >= MAX_TOOL_ROUNDS) throw new Error(`Tool loop stopped at the ${MAX_TOOL_ROUNDS}-round safety limit.`); | |
| if (calls.length > MAX_TOOL_CALLS_PER_ROUND) { | |
| throw new Error(`Tool round requested ${calls.length} calls; the limit is ${MAX_TOOL_CALLS_PER_ROUND}.`); | |
| } | |
| totalToolCalls += calls.length; | |
| if (totalToolCalls > MAX_TOOL_CALLS_TOTAL) { | |
| throw new Error(`Tool loop requested more than the ${MAX_TOOL_CALLS_TOTAL}-call session limit.`); | |
| } | |
| toolRounds += 1; | |
| for (const call of calls) { | |
| const toolRun: ToolRun = { | |
| id: call.id, | |
| name: call.function.name, | |
| input: call.function.arguments, | |
| state: 'running', | |
| }; | |
| const event: ToolEvent = { | |
| id: call.id, | |
| timestamp: eventClock(), | |
| label: call.function.name, | |
| detail: `round ${toolRounds} · executing locally`, | |
| state: 'running', | |
| }; | |
| replaceMessage(setMessages, assistantId, (message) => ({ | |
| ...message, | |
| tools: [...(message.tools ?? []), toolRun], | |
| })); | |
| setToolEvents((current) => [...current.filter((item) => item.id !== call.id), event].slice(-20)); | |
| const execution = await executeAgentTool(call, controller.signal); | |
| if (controller.signal.aborted) throw new DOMException('The tool operation was aborted.', 'AbortError'); | |
| if (execution.artifact) setArtifact(execution.artifact); | |
| replaceMessage(setMessages, assistantId, (message) => ({ | |
| ...message, | |
| tools: (message.tools ?? []).map((item) => item.id === call.id ? { | |
| ...item, | |
| output: execution.output, | |
| state: execution.failed ? 'error' : 'complete', | |
| } : item), | |
| })); | |
| setToolEvents((current) => current.map((item) => item.id === call.id ? { | |
| ...item, | |
| timestamp: eventClock(), | |
| detail: execution.failed ? 'tool returned a bounded error' : 'local result returned to model', | |
| state: execution.failed ? 'error' : 'complete', | |
| } : item)); | |
| engineMessages.push({ role: 'tool', content: execution.output, tool_call_id: call.id }); | |
| } | |
| } | |
| const generationElapsedSeconds = Math.max(0.001, (performance.now() - generationStartedAt) / 1000); | |
| tokensPerSecond = totalDecodeSeconds > 0 | |
| ? totalCompletionTokens / totalDecodeSeconds | |
| : totalCompletionTokens / generationElapsedSeconds; | |
| prefillTokensPerSecond = totalPromptSeconds > 0 ? totalPromptTokens / totalPromptSeconds : 0; | |
| const ttft = firstTokenAt ? Math.round(firstTokenAt - generationStartedAt) : 0; | |
| const report = await client.backendReport(); | |
| publishBackendReport(report); | |
| clearPendingInferenceProgress(); | |
| replaceMessage(setMessages, assistantId, (message) => ({ | |
| ...message, | |
| state: outputError ? 'error' : 'complete', | |
| isThinking: false, | |
| toolActivity: undefined, | |
| ...(outputError ? { error: outputError } : {}), | |
| metrics: { | |
| tokens: totalCompletionTokens, | |
| tokensPerSecond, | |
| promptTokensPerSecond: prefillTokensPerSecond, | |
| timeToFirstTokenMs: ttft, | |
| }, | |
| })); | |
| setTelemetry((current) => ({ | |
| ...current, | |
| contextUsed: Math.min(current.contextLimit, Math.max(lastTotalTokens, totalCompletionTokens)), | |
| tokensPerSecond, | |
| prefillTokensPerSecond, | |
| decodeTokensPerSecond: tokensPerSecond, | |
| inferencePhase: 'complete', | |
| promptProcessed: totalPromptTokens, | |
| promptTotal: totalPromptTokens, | |
| completionTokens: totalCompletionTokens, | |
| timeToFirstTokenMs: ttft, | |
| graphSplits: report.nGraphSplits, | |
| gpuLayers: report.layersGpu | |
| ? `${report.layersGpu.offloaded} / ${report.layersGpu.total}` | |
| : current.gpuLayers, | |
| })); | |
| setStreamState(outputError ? 'error' : 'complete'); | |
| setBootStatus(outputError | |
| ? `${selectedModel.displayName} stopped at an incomplete tool call` | |
| : `${selectedModel.displayName} complete · ${tokensPerSecond.toFixed(1)} tok/s`); | |
| await refreshStorage(); | |
| }; | |
| void run() | |
| .catch((runError) => { | |
| clearPendingInferenceProgress(); | |
| if (handleModelInvalidation(runError)) { | |
| const message = errorMessage(runError); | |
| replaceMessage(setMessages, assistantId, (current) => ({ | |
| ...current, | |
| state: 'error', | |
| isThinking: false, | |
| toolActivity: undefined, | |
| error: message, | |
| })); | |
| setStreamState('error'); | |
| return; | |
| } | |
| if (isAborted(runError, controller.signal)) { | |
| replaceMessage(setMessages, assistantId, (message) => ({ | |
| ...message, | |
| state: 'complete', | |
| isThinking: false, | |
| toolActivity: undefined, | |
| })); | |
| setStreamState('complete'); | |
| setTelemetry((current) => ({ ...current, inferencePhase: 'idle' })); | |
| setBootStatus('Generation stopped by operator'); | |
| return; | |
| } | |
| const message = errorMessage(runError); | |
| replaceMessage(setMessages, assistantId, (current) => ({ | |
| ...current, | |
| state: 'error', | |
| isThinking: false, | |
| toolActivity: undefined, | |
| error: message, | |
| })); | |
| setError(message); | |
| setStreamState('error'); | |
| setTelemetry((current) => ({ ...current, inferencePhase: 'idle' })); | |
| setBootStatus('Runtime stopped safely'); | |
| }) | |
| .finally(() => { | |
| if (operationRef.current === controller) operationRef.current = null; | |
| }); | |
| return true; | |
| }; | |
| const handleSend = (submission: ComposerSubmission) => { | |
| startSubmission(submission, messages); | |
| }; | |
| const handleRegenerate = (messageId: string) => { | |
| if (operationRef.current) return; | |
| const plan = planMessageRegeneration(messages, messageId); | |
| if (!plan) return; | |
| const started = startSubmission( | |
| { prompt: plan.userMessage.content, toolsEnabled }, | |
| plan.baseMessages, | |
| plan.userMessage, | |
| ); | |
| if (!started) return; | |
| const retainedToolIds = new Set(plan.baseMessages.flatMap((message) => ( | |
| message.tools?.map((tool) => tool.id) ?? [] | |
| ))); | |
| setToolEvents((current) => current.filter((event) => retainedToolIds.has(event.id))); | |
| setArtifact(null); | |
| }; | |
| const handleCancel = () => { | |
| operationRef.current?.abort(); | |
| }; | |
| const handlePersistStorage = () => { | |
| void requestPersistentStorage(true); | |
| }; | |
| const handleClearStorage = () => { | |
| if (operationRef.current) return; | |
| if (!window.confirm('Delete every cached Bonsai model from this browser profile? Conversation and memory notes are kept.')) return; | |
| void client.storageClear() | |
| .then((estimate) => { | |
| setLoadedModelId(null); | |
| loadedConfigurationRef.current = null; | |
| setStorage({ | |
| usedBytes: estimate.usageBytes ?? 0, | |
| quotaBytes: estimate.quotaBytes ?? 0, | |
| persistent: estimate.persisted, | |
| downloads: [], | |
| }); | |
| setTelemetry((current) => ({ ...INITIAL_TELEMETRY, device: current.device })); | |
| setBootStatus('Cached model files deleted'); | |
| }) | |
| .catch((clearError) => { | |
| if (!handleModelInvalidation(clearError)) setError(errorMessage(clearError)); | |
| }); | |
| }; | |
| const handleClearConversation = () => { | |
| if (operationRef.current) return; | |
| const clearedId = activeConversationId; | |
| setConversations((current) => current.filter((conversation) => conversation.id !== clearedId)); | |
| setActiveConversationId(createConversationId()); | |
| setMessages([]); | |
| setToolEvents([]); | |
| setArtifact(null); | |
| setBootStatus('Conversation cleared · model remains local'); | |
| void deleteConversation(clearedId).catch(() => setBootStatus('Conversation cleared for this tab · local history is unavailable')); | |
| }; | |
| const handleOpenConversationList = () => { | |
| if (operationRef.current) return; | |
| const alreadySaved = conversations.some((conversation) => conversation.id === activeConversationId); | |
| if (messages.length > 0 || settings.systemPrompt !== INITIAL_SETTINGS.systemPrompt || alreadySaved) { | |
| const nextConversation: ChatSession = { | |
| id: activeConversationId, | |
| title: conversationTitle(messages), | |
| messages, | |
| modelId: activeModelId, | |
| systemPrompt: settings.systemPrompt, | |
| updatedAt: Date.now(), | |
| }; | |
| setConversations((current) => upsertConversation(current, nextConversation)); | |
| void saveConversation(nextConversation).catch(() => undefined); | |
| } | |
| setConversationListOpen(true); | |
| }; | |
| const handleNewConversation = () => { | |
| if (operationRef.current) return; | |
| setActiveConversationId(createConversationId()); | |
| setMessages([]); | |
| setToolEvents([]); | |
| setArtifact(null); | |
| setError(null); | |
| setStreamState('idle'); | |
| setSettings((current) => ({ ...current, systemPrompt: INITIAL_SETTINGS.systemPrompt })); | |
| setConversationListOpen(false); | |
| setBootStatus('New local chat · model remains available'); | |
| }; | |
| const handleOpenConversation = (conversationId: string) => { | |
| if (operationRef.current) return; | |
| const conversation = conversations.find((candidate) => candidate.id === conversationId); | |
| if (!conversation) return; | |
| setActiveConversationId(conversation.id); | |
| setMessages(conversation.messages); | |
| setToolEvents([]); | |
| setArtifact(null); | |
| setError(null); | |
| setStreamState('idle'); | |
| setSettings((current) => ({ ...current, systemPrompt: conversation.systemPrompt })); | |
| handleModelSelect(conversation.modelId); | |
| setConversationListOpen(false); | |
| setBootStatus(`Opened local chat · ${conversation.title}`); | |
| }; | |
| const handleOpenConversationSettings = (conversationId: string) => { | |
| if (operationRef.current) return; | |
| handleOpenConversation(conversationId); | |
| setSettingsOpen(true); | |
| }; | |
| const handleDeleteConversation = (conversationId: string) => { | |
| if (operationRef.current) return; | |
| const conversation = conversations.find((candidate) => candidate.id === conversationId); | |
| if (!conversation || !window.confirm(`Delete “${conversation.title}” from this browser?`)) return; | |
| setConversations((current) => current.filter((candidate) => candidate.id !== conversationId)); | |
| if (conversationId === activeConversationId) { | |
| setActiveConversationId(createConversationId()); | |
| setMessages([]); | |
| setToolEvents([]); | |
| setArtifact(null); | |
| setSettings((current) => ({ ...current, systemPrompt: INITIAL_SETTINGS.systemPrompt })); | |
| } | |
| void deleteConversation(conversationId) | |
| .then(() => setBootStatus('Local chat deleted')) | |
| .catch(() => setBootStatus('Chat removed for this tab · local history is unavailable')); | |
| }; | |
| const effectiveLoadedModelId = (() => { | |
| const configured = loadedConfigurationRef.current; | |
| const loadedModel = manifest?.models.find((model) => model.id === UI_TO_TIER[loadedModelId ?? activeModelId]); | |
| const configuredContext = loadedModel?.id === '27b' | |
| ? Math.min(settings.contextSize, loadedModel.defaultContext) | |
| : settings.contextSize; | |
| return configured | |
| && configured.id === loadedModelId | |
| && configured.backend === settings.backend | |
| && configured.contextSize === configuredContext | |
| ? loadedModelId | |
| : null; | |
| })(); | |
| if (models.length === 0) { | |
| return ( | |
| <div className="boot-screen" role="status"> | |
| <span className="brand-mark" aria-hidden="true"><span /></span> | |
| <div><p className="eyebrow">Local WebGPU chat</p><h1>Reading pinned manifest</h1><p>{error ?? bootStatus}</p></div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <BonsaiShell | |
| models={models} | |
| activeModelId={activeModelId} | |
| loadedModelId={effectiveLoadedModelId} | |
| messages={messages} | |
| conversations={conversations} | |
| activeConversationId={activeConversationId} | |
| conversationListOpen={conversationListOpen} | |
| streamState={streamState} | |
| modelLoadProgress={modelLoadProgress} | |
| telemetry={telemetry} | |
| toolEvents={toolEvents} | |
| storage={storage} | |
| artifact={artifact} | |
| toolsEnabled={toolsEnabled} | |
| settings={settings} | |
| settingsOpen={settingsOpen} | |
| runtimeStatus={bootStatus} | |
| error={error} | |
| shardRetry={shardRetry} | |
| onModelSelect={handleModelSelect} | |
| onLoadModel={handleLoadModel} | |
| onSend={handleSend} | |
| onRegenerate={handleRegenerate} | |
| onCancel={handleCancel} | |
| onToolsEnabledChange={setToolsEnabled} | |
| onOpenSettings={() => { if (!operationRef.current) setSettingsOpen(true); }} | |
| onCloseSettings={() => setSettingsOpen(false)} | |
| onSettingsChange={(nextSettings) => { if (!operationRef.current) setSettings(nextSettings); }} | |
| onPersistStorage={handlePersistStorage} | |
| onClearStorage={handleClearStorage} | |
| onClearConversation={handleClearConversation} | |
| onOpenConversationList={handleOpenConversationList} | |
| onNewConversation={handleNewConversation} | |
| onOpenConversation={handleOpenConversation} | |
| onOpenConversationSettings={handleOpenConversationSettings} | |
| onDeleteConversation={handleDeleteConversation} | |
| onDismissError={() => setError(null)} | |
| onRetryShard={handleLoadModel} | |
| onCloseArtifact={() => setArtifact(null)} | |
| /> | |
| ); | |
| } | |