diff --git a/webclient/src/stores/chat.js b/webclient/src/stores/chat.js index 7dcae74..f6f1455 100644 --- a/webclient/src/stores/chat.js +++ b/webclient/src/stores/chat.js @@ -1,1370 +1,6 @@ -import { defineStore } from 'pinia' -import { computed, ref, shallowRef } from 'vue' -import * as api from '@/api/index.js' -import { useSessionsStore } from '@/stores/sessions.js' -import { notifyNativeTurn } from '@/composables/nativeBridge.js' -import { t } from '@/i18n/index.js' - -// Guard against late completion of stale loadSession calls -let _lastLoadId = null - -async function _tryGenerateName(sessionId) { - try { - const sessionsStore = useSessionsStore() - const session = sessionsStore.sessions.find(s => s.session_id === sessionId) - if (session?.name) return // already has a name - const { name } = await api.generateSessionName(sessionId) - if (name) sessionsStore.updateName(sessionId, name) - } catch { - // fire-and-forget — ignore errors - } -} - -export const useChatStore = defineStore('chat', () => { - const sessionsStore = useSessionsStore() - const currentId = ref(null) - const currentProfileId = ref(null) - const messages = ref([]) - const streaming = ref(false) - const pendingImages = ref([]) // base64 strings - const pendingFiles = ref([]) // { name, path, size, content_type } - const artifacts = ref([]) // published session content - const files = ref([]) // session directory tree - const contextTokens = ref(0) - const maxContextTokens = ref(0) - // The model (and llm.json target) that answered the current turn, from the - // model_info event. `servedFallback` is true when it is NOT the route's - // primary — the one case the UI has to say out loud: a backup host is - // carrying the conversation, so answers differ in style and window. - const servedModel = ref(null) - const servedTarget = ref(null) - const servedFallback = ref(false) - // WebSocket liveness, mirrored from useWebSocket so the session-info dialog - // can show it without reaching into ChatArea's composable instance. - const wsConnected = ref(false) - const wsReconnecting = ref(false) - const loading = ref(false) - // Images currently being read/resized (FileReader + canvas) — no preview yet - const imagesProcessing = ref(0) - // Message sent over WS but the run hasn't started (stream_start not yet seen) - const awaitingStream = ref(false) - - // The in-progress streaming message (direct ref for perf) - const streamingMsg = shallowRef(null) - // True while replaying buffered events from a reconnect — suppresses animations - const replayMode = ref(false) - // Index of the message to scroll to after loading a session from search results - const scrollToMessageIndex = ref(null) - // Current scheduled recall for the active session (null if none) - const recall = ref(null) - // Persistent terminal output keyed by terminal name - const terminals = ref({}) - // Live background tasks keyed by task_id (from out-of-band task_update events) - const backgroundTasks = ref({}) - // tool_call_id → tool card. Non-reactive index for id-first matching — - // works even for events that arrive after stream_end closed the turn. - const toolIndex = new Map() - // Images Navi itself looked at (image_view and friends): captured live from - // tool_call metadata and from history. Rendered as a group in the artifacts - // panel, not as chat bubbles. - const viewedImages = ref([]) - const _viewedImageKeys = new Set() - - // Task ids whose terminal task_update (completed/failed/cancelled) was seen - // — a late duplicate 'running' must not resurrect the chip (out-of-order - // delivery would otherwise leave a "ghost" task running forever). - const _terminalTaskIds = new Set() - - // Archive (older messages) pagination state - const archiveLoading = ref(false) - const archiveHasMore = ref(false) - const archiveNextBefore = ref(null) - - // Message count from the last full history load — the cheap fingerprint that - // tells whether another device advanced the session. Compared against - // GET /sessions/{id}/meta when the tab comes back to the foreground. - const historyCount = ref(0) - // The user scrolled into older pages: a silent reload would throw them away - // (and jump the scroll position), so automatic catch-up stays off for the - // rest of this session view. - const archiveExtended = ref(false) - - async function loadSession(id, targetIndex = null) { - if (currentId.value === id && targetIndex == null) return - _lastLoadId = id - // Session switch: the viewed-images group belongs to the session's - // artifacts panel — start over from this session's history. - viewedImages.value = [] - _viewedImageKeys.clear() - loading.value = true - messages.value = [] - artifacts.value = [] - files.value = [] - streaming.value = false - awaitingStream.value = false - streamingMsg.value = null - contextTokens.value = 0 - maxContextTokens.value = 0 - servedModel.value = null - servedTarget.value = null - servedFallback.value = false - terminals.value = {} - backgroundTasks.value = {} - _terminalTaskIds.clear() - toolIndex.clear() - archiveExtended.value = false - scrollToMessageIndex.value = targetIndex - - try { - // Paged load: light meta + newest page of display history. Older pages - // arrive on scroll-up via getSessionHistory(before_seq). - const [session, page] = await Promise.all([ - api.getSessionMeta(id), - api.getSessionHistory(id, null, 200), - ]) - // Ignore stale responses — user may have switched to a different session while this loaded - if (_lastLoadId !== id) return - currentProfileId.value = session.profile_id ?? null - historyCount.value = session.message_count ?? 0 - // The paged session list may not include this session — keep its name - // in the persistent map for document.title lookups - sessionsStore.rememberName(id, session.name) - messages.value = buildMessageList(page.items ?? []) - // Set archive state from the page itself — hot messages below the tail - // are NOT archived yet, so archive_threshold alone can't tell. - archiveLoading.value = false - archiveHasMore.value = page.has_more ?? false - archiveNextBefore.value = page.next_before_seq ?? null - if (session.context_token_count) contextTokens.value = session.context_token_count - if (session.max_context_tokens) maxContextTokens.value = session.max_context_tokens - - // Search-jump target may sit below the loaded tail — pull pages until - // the target raw index is covered (bounded, then give up quietly). - if (targetIndex != null) { - let guard = 0 - while ( - !messages.value.some(m => m.rawIndices?.includes(targetIndex)) && - archiveHasMore.value && guard < 20 - ) { - await loadArchivedMessages() - guard++ - } - } - - await Promise.all([mergeFeedback(id), fetchArtifacts(id), fetchFiles(id)]) - // Set currentId and hash AFTER REST completes — this triggers ws.connect() via the - // watch in ChatArea. Doing it last guarantees messages are populated before WS replay - // starts, eliminating the race where WS replay arrives before REST and gets overwritten. - currentId.value = id - location.hash = id - // Tasks snapshot goes after currentId: fetchTasks refuses to apply a - // snapshot for a session other than the current one, and currentId was - // null during the REST block above. - fetchTasks(id) - } catch (err) { - console.error('[chat] loadSession failed', err) - } finally { - loading.value = false - } - } - - function clearSession() { - currentId.value = null - currentProfileId.value = null - messages.value = [] - viewedImages.value = [] - _viewedImageKeys.clear() - artifacts.value = [] - files.value = [] - streaming.value = false - awaitingStream.value = false - streamingMsg.value = null - contextTokens.value = 0 - maxContextTokens.value = 0 - servedModel.value = null - servedTarget.value = null - servedFallback.value = false - terminals.value = {} - backgroundTasks.value = {} - _terminalTaskIds.clear() - toolIndex.clear() - location.hash = '' - } - - // Force-reload session history from the server, bypassing the same-id guard. - // Called after WebSocket reconnect so the client sees the saved response - // even if streaming finished while the client was disconnected. - async function reloadSession(id) { - if (!id) return - // Guard: don't replace the messages array while a stream is active. - // If we do, streamingMsg becomes an orphan and Vue stops rendering - // further deltas / tool cards. - if (streaming.value) return - try { - const [session, page] = await Promise.all([ - api.getSessionMeta(id), - api.getSessionHistory(id, null, 200), - ]) - // The await above is a yield point — onStreamStart may have fired - // and set streaming to true while we were waiting. - if (streaming.value) return - currentProfileId.value = session.profile_id ?? null - historyCount.value = session.message_count ?? 0 - const built = buildMessageList(page.items ?? []) - messages.value = built - archiveHasMore.value = page.has_more ?? false - archiveNextBefore.value = page.next_before_seq ?? null - await Promise.all([mergeFeedback(id), fetchArtifacts(id), fetchFiles(id), fetchTasks(id)]) - if (session.context_token_count) contextTokens.value = session.context_token_count - if (session.max_context_tokens) maxContextTokens.value = session.max_context_tokens - streamingMsg.value = null - streaming.value = false - awaitingStream.value = false - } catch { - // Silently ignore — stale data is better than a crash - } - } - - // Called when the WebSocket reconnects. A turn that was streaming when the - // socket died has no ending event coming: if the run is still alive the - // server replays it from the start, and if it finished the only thing that - // arrives is session_sync — which reloadSession refuses while `streaming` - // is true. Dropping the orphan here is what lets the recovery through. - function dropStaleStream() { - const msg = streamingMsg.value - if (msg) { - const idx = messages.value.indexOf(msg) - if (idx !== -1) messages.value.splice(idx, 1) - } - streamingMsg.value = null - streaming.value = false - awaitingStream.value = false - replayMode.value = false - } - - // Cheap catch-up for a session that may have moved on elsewhere (another - // tab, the phone, a recall). Only the message count is fetched; history is - // reloaded only when it actually changed. - async function checkForUpdates(id = currentId.value) { - if (!id || streaming.value || loading.value || archiveExtended.value) return false - let meta - try { - meta = await api.getSessionMeta(id) - } catch { - return false - } - // The await is a yield point — the session may have changed under us. - if (currentId.value !== id || streaming.value) return false - if ((meta.message_count ?? 0) === historyCount.value) return false - await reloadSession(id) - return true - } - - // Pull stored ratings for this session and stamp them onto built messages. - // Each assistant block has id `h_` where index is its first raw position; - // that index is also the feedback key (session_id, message_index). - async function mergeFeedback(id) { - try { - const { feedback = [] } = await api.getFeedback(id) - if (!feedback.length) return - const map = new Map(feedback.map(f => [f.message_index, f.rating])) - for (const m of messages.value) { - if (m.role !== 'assistant') continue - const idx = m.id?.startsWith?.('h_') ? Number(m.id.slice(2)) : NaN - if (Number.isInteger(idx) && map.has(idx)) { - m.rating = map.get(idx) - } - } - } catch { - // Feedback is non-critical — ignore failures so chat still works. - } - } - - async function fetchArtifacts(id = currentId.value) { - if (!id) { - artifacts.value = [] - return - } - try { - const { content = [] } = await api.getSessionContent(id) - artifacts.value = content - } catch { - artifacts.value = [] - } - } - - async function fetchFiles(id = currentId.value) { - if (!id) { - files.value = [] - return - } - try { - const { files: list = [] } = await api.listSessionFiles(id) - files.value = list - } catch { - files.value = [] - } - } - - // task_update events are delivered live over the WS and are never replayed - // on (re)connect — fetch the server's snapshot to rebuild the backgrounds - // tab. Snapshot entries fill gaps; live entries (from task_update) are kept - // even if the snapshot doesn't know them yet. - async function fetchTasks(id = currentId.value) { - if (!id) { - backgroundTasks.value = {} - return - } - let tasks = [] - try { - tasks = (await api.getSessionTasks(id)).tasks ?? [] - } catch { - return - } - if (currentId.value !== id) return // user switched meanwhile - const next = {} - for (const t of tasks) { - next[t.task_id] = { - taskId: t.task_id, - tool: t.tool, - status: t.status, - startedAt: t.started_at ?? null, - finishedAt: t.finished_at ?? null, - argsSummary: t.args_summary ?? '', - preview: t.result_preview ?? '', - subagentTokens: t.subagent_tokens ?? null, - } - if (t.status !== 'running') _terminalTaskIds.add(t.task_id) - } - for (const live of Object.values(backgroundTasks.value)) { - next[live.taskId] = live - } - backgroundTasks.value = next - } - - function upsertArtifact(item) { - if (!item?.filename) return - const key = item.id || item.filename - const idx = artifacts.value.findIndex(a => (a.id || a.filename) === key) - const next = { ...item } - if (idx === -1) artifacts.value.unshift(next) - else artifacts.value.splice(idx, 1, { ...artifacts.value[idx], ...next }) - } - - // Toggle / set feedback on an assistant block. Optimistic — local state - // updates first, server call follows; rolls back on failure. - async function rateMessage(msg, rating) { - if (!currentId.value || !msg) return - const idx = msg.id?.startsWith?.('h_') ? Number(msg.id.slice(2)) : NaN - if (!Number.isInteger(idx)) return - // Toggle off if user clicks the active rating again - const next = msg.rating === rating ? 0 : rating - const prev = msg.rating ?? 0 - msg.rating = next - try { - await api.setFeedback(currentId.value, idx, next) - } catch (err) { - msg.rating = prev - throw err - } - } - - function saveDraft(text) { - if (!currentId.value) return - if (text) localStorage.setItem(`draft:${currentId.value}`, text) - else localStorage.removeItem(`draft:${currentId.value}`) - } - - function loadDraft(id) { - return localStorage.getItem(`draft:${id}`) ?? '' - } - - // ─── WS event handlers ────────────────────────────────────────────────── - - function onStreamStart() { - // On reconnect: remove the incomplete frozen message so replay rebuilds it cleanly. - // On a fresh start streamingMsg is null, so this branch is skipped. - if (streamingMsg.value) { - const idx = messages.value.indexOf(streamingMsg.value) - if (idx !== -1) messages.value.splice(idx, 1) - streamingMsg.value = null - } - streaming.value = true - awaitingStream.value = false - // A run (fresh or queued-drain) has started — the queued badge drains now. - // Messages queued in this tab are consumed in order, one per turn, so the - // oldest mark comes off here. - const nextQueued = messages.value.find(m => m.queued) - if (nextQueued) nextQueued.queued = false - toolIndex.clear() - const msg = { - id: `stream_${Date.now()}`, - role: 'assistant', - type: 'stream', - thinking: null, // { text, done } | null - tools: [], // tool card objects - text: '', - done: false, - time: new Date().toISOString(), - animate: !replayMode.value, // no cursor animation during replay - statusLabel: null, - } - messages.value.push(msg) - // Store the reactive proxy (from the array) so mutations trigger Vue updates - streamingMsg.value = messages.value[messages.value.length - 1] - } - - function onReplayStart() { - replayMode.value = true - } - - function onReplayEnd() { - replayMode.value = false - // Restore animate on the message now that live events will follow - if (streamingMsg.value) streamingMsg.value.animate = true - } - - function onThinkingDelta(delta) { - const msg = streamingMsg.value - if (!msg) return - if (!msg.thinking) msg.thinking = { text: '', done: false } - msg.thinking.text += delta - } - - function onThinkingEnd() { - const msg = streamingMsg.value - if (msg?.thinking) msg.thinking.done = true - } - - // Find the last spawn_agent card that's still collecting subagent events - function _lastSpawnCard(msg) { - // Custom findLast for older browser compatibility (ES2023 polyfill-free) - for (let i = msg.tools.length - 1; i >= 0; i--) { - const t = msg.tools[i] - if (t.kind === 'tool' && t.name === 'spawn_agent') return t - } - return undefined - } - - function onTurnThinking(data) { - const msg = streamingMsg.value - if (!msg) return - // Shape item so ThinkingCard can receive it directly as :msg (stable reference, no new object each render) - const item = { kind: 'turn_thinking', isSubagent: data.is_subagent ?? false, thinking: { text: data.thinking ?? '', done: true } } - if (data.is_subagent) { - const spawn = _lastSpawnCard(msg) - if (spawn) { spawn.steps.push(item); return } - } - msg.tools.push(item) - } - - function onPlanningStatus(data) { - const msg = streamingMsg.value - if (!msg) return - if (data.is_subagent) { - const spawn = _lastSpawnCard(msg) - if (spawn) spawn.planningLabel = data.label ?? '' - return // never bleed subagent planning into parent UI - } - msg.statusLabel = data.label ?? '' - } - - function onPlanReady(data) { - const msg = streamingMsg.value - if (!msg) return - if (data.is_subagent) { - const spawn = _lastSpawnCard(msg) - if (spawn) { - spawn.planningLabel = null - spawn.steps.push({ kind: 'plan', text: data.plan ?? '' }) - } - return // never bleed subagent plan into parent UI - } - msg.statusLabel = null - msg.tools.push({ kind: 'plan', text: data.plan ?? '' }) - } - - function onToolStarted(data) { - const msg = streamingMsg.value - if (!msg) return - const card = { - kind: 'tool', - id: `tool_${Date.now()}`, - toolCallId: data.tool_call_id ?? null, - name: data.tool, - args: data.args, - result: null, - success: null, - pending: true, - startedAt: Date.now(), - isSubagent: data.is_subagent ?? false, - steps: [], - terminalName: data.tool === 'terminal' ? (data.args?.terminal_name || null) : null, - } - card.parentMsg = msg - // The index holds the reactive proxy, not `card`: results are applied - // through this reference long after the card was pushed, and a write to - // the raw object never wakes a computed that read it through the proxy - // (a running content_publish card stayed an error card until the turn's - // message was rebuilt). - if (data.is_subagent) { - const spawn = _lastSpawnCard(msg) - if (spawn) { - spawn.steps.push(card) - if (card.toolCallId) toolIndex.set(card.toolCallId, spawn.steps[spawn.steps.length - 1]) - return - } - } - msg.tools.push(card) - if (card.toolCallId) toolIndex.set(card.toolCallId, msg.tools[msg.tools.length - 1]) - - // Pre-seed terminal metadata when a terminal open starts - if (data.tool === 'terminal' && data.args?.action === 'open') { - const name = data.args.terminal_name - if (name) { - const prev = terminals.value[name] || {} - terminals.value = { - ...terminals.value, - [name]: { - ...prev, - name, - description: data.args.description || '', - command: data.args.command || '', - background: data.args.background || false, - output: prev.output || '', - closed: false, - } - } - } - } - } - - function onToolCall(data) { - // A vision tool read an image (metadata carries the payload) — capture it - // live for the artifacts panel's viewed-images group. - if (data.metadata?.is_image && data.metadata?.base64) { - _addViewedImage([data.metadata.data_url ?? `data:image/jpeg;base64,${data.metadata.base64}`]) - } - // Sub-agent tool call → update inside spawn_agent steps. Legacy events - // without tool_call_id need the name+pending search. - if (data.is_subagent && !data.tool_call_id) { - const msg = streamingMsg.value ?? _lastAssistantMsg() - const spawn = msg ? _lastSpawnCard(msg) : null - if (spawn) { - for (let i = spawn.steps.length - 1; i >= 0; i--) { - const t = spawn.steps[i] - if (t.kind === 'tool' && t.name === data.tool && t.pending) { - _applyToolResult(t, data) - return - } - } - } - } - // Id-first: exact card match — works even for events arriving after the - // turn's stream_end (long tools finishing late). - let card = data.tool_call_id ? (toolIndex.get(data.tool_call_id) ?? null) : null - // Legacy fallback: name+pending reverse search in the live turn - if (!card && !data.tool_call_id) { - const msg = streamingMsg.value - if (msg) { - for (let i = msg.tools.length - 1; i >= 0; i--) { - const t = msg.tools[i] - if (t.kind === 'tool' && t.name === data.tool && t.pending) { card = t; break } - } - } - } - // Bare tool_call (no tool_started was seen — late event after the index - // was cleared, or a synthetic result): synthesize a card so it's visible. - if (!card) card = _synthesizeToolCard(data) - if (!card) return - _applyToolResult(card, data) - } - - // Shared tool-result application: completes the card and runs the side - // effects (UI components, artifacts, file refresh, terminal metadata, recall). - function _applyToolResult(card, data) { - { - card.result = data.result - card.success = data.success !== false - card.pending = false - if (data.metadata) card.metadata = data.metadata - // Any tool may hand the client a component — navi_ui is the general - // route, and a builtin like `skills` returns one directly for a protocol - // proposal. The client renders only names it knows. - if (card.success && data.metadata?.ui_component) { - // Render the UI component inline as part of the same assistant turn. - // The card knows the turn it belongs to (parentMsg set at creation); - // subagent cards stay nested inside their spawn card. - const parentMsg = card.parentMsg ?? (streamingMsg.value ?? _lastAssistantMsg()) - if (parentMsg && !card.isSubagent) { - parentMsg.tools.push({ - kind: 'ui_component', - component: data.metadata.ui_component.component ?? '', - payload: data.metadata.ui_component.payload ?? {}, - }) - } - } - if (data.tool === 'content_publish' && card.success && data.metadata) { - upsertArtifact(data.metadata) - } - if (data.tool === 'filesystem' && card.success && currentId.value) { - const action = typeof card.args === 'object' ? card.args?.action : null - if (['write', 'edit', 'append', 'mkdir', 'rm', 'cp', 'mv'].includes(action)) { - fetchFiles(currentId.value) - } - if (['write', 'edit'].includes(action)) fetchArtifacts(currentId.value) - } - if ((data.tool === 'terminal' || data.tool === 'code_exec') && card.success && currentId.value) { - fetchFiles(currentId.value) - } - // Merge terminal metadata from tool results into terminals store - if (data.tool === 'terminal' && card.success && currentId.value) { - const action = typeof card.args === 'object' ? card.args?.action : null - const name = card.args?.terminal_name - if (data.metadata) { - if (action === 'open' || action === 'status') { - if (name) { - const prev = terminals.value[name] || {} - terminals.value = { - ...terminals.value, - [name]: { ...prev, ...data.metadata, name, closed: false }, - } - } - } else if (action === 'list' && Array.isArray(data.metadata.terminals)) { - for (const t of data.metadata.terminals) { - if (!t.name) continue - const prev = terminals.value[t.name] || {} - terminals.value = { - ...terminals.value, - [t.name]: { ...prev, ...t, closed: false }, - } - } - } else if (action === 'close' && name) { - const prev = terminals.value[name] || {} - terminals.value = { - ...terminals.value, - [name]: { ...prev, closed: true, closeReason: 'explicit' }, - } - } - } - } - // Update recall banner live when scheduling or managing recalls - if (data.tool === 'schedule_recall' && card.success && currentId.value) { - loadRecall(currentId.value) - useSessionsStore().updateRecallStatus(currentId.value, true) - } - if (data.tool === 'manage_recall' && card.success && currentId.value) { - const action = card.args?.action - if (action === 'cancel') { - recall.value = null - useSessionsStore().updateRecallStatus(currentId.value, false) - } else if (action === 'skip') { - loadRecall(currentId.value) - } - } - } - } - - // Last assistant message in the transcript with a tools array — target for - // tool events that arrive after the live turn has ended. - function _lastAssistantMsg() { - for (let i = messages.value.length - 1; i >= 0; i--) { - const m = messages.value[i] - if (m.role === 'assistant' && Array.isArray(m.tools)) return m - } - return null - } - - // Create a card for a tool_call event with no matching tool_started. - function _synthesizeToolCard(data) { - const msg = streamingMsg.value ?? _lastAssistantMsg() - if (!msg) return null - const card = { - kind: 'tool', - id: `tool_${Date.now()}`, - toolCallId: data.tool_call_id ?? null, - name: data.tool, - args: null, - result: null, - success: null, - pending: false, - isSubagent: data.is_subagent ?? false, - steps: [], - } - // Keep the owning message on the card: a late tool_result can arrive - // after streamingMsg moved on, and side effects like inline ui_component - // still need to land in the turn that owns this card. - card.parentMsg = msg - // Return and index the array's proxy, so the result this card is about to - // receive goes through reactivity (see onToolStarted). - if (data.is_subagent) { - const spawn = _lastSpawnCard(msg) - if (spawn) { - spawn.steps.push(card) - const stored = spawn.steps[spawn.steps.length - 1] - if (card.toolCallId) toolIndex.set(card.toolCallId, stored) - return stored - } - } - msg.tools.push(card) - const stored = msg.tools[msg.tools.length - 1] - if (card.toolCallId) toolIndex.set(card.toolCallId, stored) - return stored - } - - // Out-of-band task_update: upsert/remove the background-task chip entry and - // append progress to the card that started the task (spawn/terminal/etc.). - function onTaskUpdate(data) { - const taskId = data.task_id - if (!taskId) return - // Out-of-band events can arrive in the narrow window after this store was - // reset for a new session but before the WS reconnects — ignore updates - // that belong to an older session. - if (data.session_id && currentId.value && data.session_id !== currentId.value) return - if (data.parent_tool_call_id) { - const card = toolIndex.get(data.parent_tool_call_id) - if (card) { - // A repeated 'running' publish must not duplicate the spinner row. - const last = card.steps[card.steps.length - 1] - const sameAsLast = - last?.kind === 'task_update' && - last.status === data.status && - last.preview === (data.result_preview ?? '') - if (!sameAsLast) { - card.steps.push({ - kind: 'task_update', - taskId, - tool: data.tool, - status: data.status, - preview: data.result_preview ?? '', - }) - } - if (data.subagent_tokens != null) card.subagentTokens = data.subagent_tokens - } - } - if (data.status !== 'running') { - _terminalTaskIds.add(taskId) - const prev = backgroundTasks.value[taskId] - if (prev) { - // Keep the entry — the Backgrounds tab shows recently finished tasks; - // _terminalTaskIds is what stops a late 'running' from resurrecting it. - backgroundTasks.value = { - ...backgroundTasks.value, - [taskId]: { - ...prev, - status: data.status, - preview: data.result_preview ?? prev.preview, - finishedAt: data.finished_at ?? prev.finishedAt ?? null, - subagentTokens: data.subagent_tokens ?? prev.subagentTokens ?? null, - }, - } - } - } else if (!_terminalTaskIds.has(taskId) && !backgroundTasks.value[taskId]) { - backgroundTasks.value = { - ...backgroundTasks.value, - [taskId]: { - taskId, - tool: data.tool, - status: data.status, - startedAt: data.started_at ?? null, - finishedAt: null, - argsSummary: data.args_summary ?? '', - preview: '', - subagentTokens: null, - }, - } - } - } - - // The server took a message from another tab into the queue. Nothing is drawn - // for it here: the queue shows on the message itself (msg.queued), and the - // separate counter that used to live in the input area is gone — two places - // describing one queue disagreed more than they helped. The only effect left - // is dropping the "sending" hint: the run will pick the message up on its own. - function onMessageQueued() { - awaitingStream.value = false - } - - function onTerminalOpened(data) { - const name = data.terminal_name - if (!name) return - const prev = terminals.value[name] || {} - terminals.value = { ...terminals.value, [name]: { ...prev, name, closed: false } } - } - - function onTerminalOutput(data) { - const name = data.terminal_name - if (!name) return - const prev = terminals.value[name] || { output: '' } - const next = { - ...prev, - output: (prev.output || '') + (data.delta || ''), - } - terminals.value = { ...terminals.value, [name]: next } - // Also try to attach live output to the matching pending tool card - const msg = streamingMsg.value - if (msg) { - for (let i = msg.tools.length - 1; i >= 0; i--) { - const t = msg.tools[i] - if (t.kind === 'tool' && t.name === 'terminal' && t.terminalName === name) { - t.terminalOutput = next.output - break - } - } - } - } - - function onTerminalClosed(data) { - const name = data.terminal_name - if (!name) return - const prev = terminals.value[name] || {} - const next = { - ...prev, - closed: true, - closeReason: data.reason || 'unknown', - } - terminals.value = { ...terminals.value, [name]: next } - // Mark matching pending tool card as no longer running - const msg = streamingMsg.value - if (msg) { - for (let i = msg.tools.length - 1; i >= 0; i--) { - const t = msg.tools[i] - if (t.kind === 'tool' && t.name === 'terminal' && t.terminalName === name && t.pending) { - t.pending = false - t.success = data.reason !== 'error' - break - } - } - } - } - - function onStreamDelta(delta) { - const msg = streamingMsg.value - if (!msg) return - if (msg.statusLabel) msg.statusLabel = null - msg.text += delta - } - - // A run can end without every started tool reporting: Stop cancels an - // in-flight tool, a frame is lost on a half-open socket, the server restarts - // mid-run, or the backend dies with an `error` frame. A card is drawn from - // tool_started and only its tool_call closes it, so whatever is still - // pending here would spin for the rest of the session — until a reload - // rebuilt the transcript from history (which always marks cards settled). - // Close them explicitly, sub-agent steps included, with a visible note. - function _settlePendingTools(msg) { - if (!msg) return - const settle = card => { - if (!card || card.kind !== 'tool' || !card.pending) return - card.pending = false - card.success = false - if (card.result == null) card.result = t('messages.toolInterrupted') - } - for (const item of msg.tools ?? []) { - settle(item) - for (const step of item.steps ?? []) settle(step) - } - } - - function onStreamEnd(data) { - const msg = streamingMsg.value - if (msg) { - msg.done = true - _settlePendingTools(msg) - msg.elapsed_seconds = data?.elapsed_seconds ?? null - msg.tool_call_count = data?.tool_call_count ?? null - msg.token_count = data?.token_count ?? null - // Assign stable h_ id so the rating UI appears and feedback can be mapped - const assistantIndex = messages.value.filter(m => m.role === 'assistant').length - 1 - msg.id = `h_${assistantIndex}` - streamingMsg.value = null - // Purge if nothing was ever written (avoids empty msg-assistant divs) - if (!msg.thinking && !msg.tools.length && !msg.text) { - messages.value = messages.value.filter(m => m !== msg) - } - } - streaming.value = false - awaitingStream.value = false - - if (data?.context_tokens != null) contextTokens.value = data.context_tokens - if (data?.max_context_tokens != null) maxContextTokens.value = data.max_context_tokens - - // Update session preview - if (currentId.value && msg?.text) { - useSessionsStore().updatePreview(currentId.value, msg.text.slice(0, 80)) - } - - // Android app: raise a native notification when the answer finished while - // the window was hidden (bridge notify() re-checks visibility + the flag). - if (msg?.text && currentId.value) { - notifyNativeTurn('Navi ответила', msg.text.slice(0, 140), `/#${currentId.value}`) - } - - // Try to generate session name in background (only if not named yet) - if (currentId.value) { - _tryGenerateName(currentId.value) - } - } - - function onStreamStopped() { - const msg = streamingMsg.value - if (msg) { - msg.done = true - _settlePendingTools(msg) - const assistantIndex = messages.value.filter(m => m.role === 'assistant').length - 1 - msg.id = `h_${assistantIndex}` - streamingMsg.value = null - if (!msg.thinking && !msg.tools.length && !msg.text) { - messages.value = messages.value.filter(m => m !== msg) - } - } - streaming.value = false - awaitingStream.value = false - } - - function onProfileSwitched(data) { - currentProfileId.value = data.profile_id - // The new profile may route elsewhere entirely. - servedModel.value = null - servedTarget.value = null - servedFallback.value = false - } - - // Emitted once per turn by the backend after it resolved which entry of the - // route answered. `target`/`fallback` are only present when the route engine - // stamped them (a bare OllamaBackend sends just the model). - function onModelInfo(data) { - if (!data?.model) return - servedModel.value = data.model - servedTarget.value = data.target ?? null - servedFallback.value = Boolean(data.fallback) - } - - function onCompressionStarted(data) { - if (data?.context_tokens != null) contextTokens.value = data.context_tokens - if (data?.max_context_tokens != null) maxContextTokens.value = data.max_context_tokens - // Insert a transient notice that will be replaced by the real compression card - messages.value.push({ - id: `compress_pending_${Date.now()}`, - role: 'system', - type: 'compression_pending' - }) - } - - function onContextCompressed(data) { - if (data?.context_tokens != null) contextTokens.value = data.context_tokens - if (data?.max_context_tokens != null) maxContextTokens.value = data.max_context_tokens - // Remove any pending compression notices — the real one is here now - messages.value = messages.value.filter(m => m.type !== 'compression_pending') - messages.value.push({ - id: `compress_${Date.now()}`, - role: 'system', - type: 'compression_notice', - before: data.messages_before, - after: data.messages_after, - summary: data.summary ?? '' - }) - } - - function onError(data) { - const msg = streamingMsg.value - if (msg) { - // The half-built turn stays in the transcript — its thinking and tool - // cards are what the run actually did — but it must stop looking live: - // mark it done and close anything the error left mid-flight. - msg.done = true - _settlePendingTools(msg) - if (!msg.thinking && !msg.tools.length && !msg.text) { - messages.value = messages.value.filter(m => m !== msg) - } - } - streaming.value = false - awaitingStream.value = false - streamingMsg.value = null - messages.value.push({ - id: `err_${Date.now()}`, - role: 'system', - type: 'error', - text: data.message ?? 'An error occurred' - }) - } - - async function loadRecall(id = currentId.value) { - if (!id) { - recall.value = null - return - } - try { - const data = await api.getSessionRecall(id) - recall.value = data?.recall ?? null - } catch { - recall.value = null - } - } - - // Cancelling and skipping live next to the banner and the dialog that both - // offer them, so neither component talks to the API on its own. - async function cancelRecall() { - if (!currentId.value) return false - try { - await api.cancelSessionRecall(currentId.value) - recall.value = null - sessionsStore.updateRecallStatus(currentId.value, false) - return true - } catch (e) { - console.error('cancel recall failed', e) - return false - } - } - - async function skipRecall() { - if (!currentId.value) return false - try { - await api.skipSessionRecall(currentId.value) - await loadRecall() - return true - } catch (e) { - console.error('skip recall failed', e) - return false - } - } - - async function loadArchivedMessages() { - if (!currentId.value || archiveLoading.value || !archiveHasMore.value) return - archiveLoading.value = true - // Older pages are in the list now — automatic catch-up would replace the - // whole list and pull the reader back to the tail. - archiveExtended.value = true - try { - // Full-history pagination (archive ∪ unarchived old messages) — the - // archive table alone misses sessions whose threshold never moved. - const data = await api.getSessionHistory(currentId.value, archiveNextBefore.value) - const built = buildMessageList(data.items ?? []) - // Prepend older messages to the beginning of the list - messages.value = [...built, ...messages.value] - archiveHasMore.value = data.has_more ?? false - archiveNextBefore.value = data.next_before_seq ?? null - } catch (err) { - console.error('[chat] loadArchivedMessages failed', err) - } finally { - archiveLoading.value = false - } - } - - function initArchiveState(hasMore = false, nextBefore = null) { - archiveHasMore.value = hasMore - archiveNextBefore.value = nextBefore - } - - function onRecallUpdate(data) { - if (!data.session_id) return - const sessionsStore = useSessionsStore() - const hasRecall = data.status === 'pending' - sessionsStore.updateRecallStatus(data.session_id, hasRecall) - if (data.session_id === currentId.value) { - if (hasRecall) { - // Partial update from event — refetch for full details - loadRecall(data.session_id) - } else { - recall.value = null - } - } - } - - // A protocol proposal the user accepted or rejected in the chat card. The - // server performed the catalog write; patching every card that shows this - // proposal is what turns the buttons into a decided state — including cards - // duplicated across turns. - function onProposalDecided(data) { - if (!data?.proposal_id) return - for (const msg of messages.value) { - for (const item of msg.tools || []) { - if ( - item.kind === 'ui_component' - && item.component === 'skill_proposal' - && item.payload?.proposal_id === data.proposal_id - ) { - item.payload.status = data.status - item.payload.skill_id = data.skill_id ?? null - item.payload.error = data.error || '' - } - } - } - } - - // `queued` marks a message the user sent while a turn was already running. - // It rides in the socket until the server finishes that turn and reads it — - // the same "write now, it is processed next" model navi-code uses. The mark - // disappears on the stream_start of the turn that consumes it. - function appendUserMessage(text, images, files, { queued = false } = {}) { - messages.value.push({ - id: `user_${Date.now()}`, - role: 'user', - text, - images: [...images], - files: [...files], - time: new Date().toISOString(), - queued, - animate: true - }) - } - - // ─── Helpers ──────────────────────────────────────────────────────────── - - // Add viewed-by-Navi images (data URIs), skipping ones already collected — - // live tool_call events and history reload both carry the same payload. - function _addViewedImage(imgs) { - for (const src of imgs) { - if (!src || _viewedImageKeys.has(src)) continue - _viewedImageKeys.add(src) - viewedImages.value.push(src) - } - } - - // The server returns a flat array: - // { role:'assistant', tool_calls:[{id,name,arguments}] } ← tool request (no content) - // { role:'tool', tool_call_id, name, content } ← tool result - // { role:'assistant', content } ← final text response - // - // We group them into single AssistantMessage objects with tools[] + text. - function buildMessageList(raw) { - const result = [] - let i = 0 - - // Global display position of raw item idx. Server pages (history endpoint) - // stamp each item with metadata.display_index = rank in the FULL display - // history; h_ ids and rawIndices must use that global rank so ids stay - // stable across paged loads (feedback keys, message jumps). - const gi = idx => { - const m = raw[idx] - return m?.metadata?.display_index ?? idx - } - - while (i < raw.length) { - const m = raw[i] - const base = gi(i) - - // Compression notice - if (m.is_compression) { - result.push({ id: `h_${base}`, role: 'system', type: 'compression_notice', summary: m.content ?? '' }) - i++; continue - } - - // Synapse reaction event — the service record of a platform event. It is a - // system-role message, but it is addressed to the reader (and to the model) - // rather than being infrastructure, so it gets its own card. - if (m.role === 'system' && m.metadata?.source === 'synapse_event') { - result.push({ - id: `h_${base}`, - role: 'system', - type: 'synapse_event', - text: m.content ?? '', - eventType: m.metadata?.event_type ?? '', - time: m.created_at ?? null, - rawIndices: [base], - }) - i++; continue - } - - // Skip bare system messages - if (m.role === 'system') { i++; continue } - - if (m.is_summary) { - result.push({ id: `h_${base}`, role: 'assistant', type: 'summary', text: m.content ?? '', rawIndices: [base] }) - i++; continue - } - - if (m.role === 'user') { - const imgs = (m.images ?? []).map(b => - (typeof b === 'string' && b.startsWith('data:')) ? b : `data:image/jpeg;base64,${String(b ?? '')}` - ) - // Synthetic "load this image" messages created by the server when a - // vision tool ran (tool_executor/subagent_runner). Not a real user - // turn — strip from the chat feed; the images land in the artifacts - // panel's viewed-images group instead. - if (m.content?.startsWith?.('[Image loaded via') && imgs.length) { - _addViewedImage(imgs) - i++; continue - } - result.push({ id: `h_${base}`, role: 'user', text: m.content ?? '', images: imgs, files: m.files ?? [], time: m.created_at ?? null, rawIndices: [base], isRecall: m.is_recall ?? false }) - i++; continue - } - - if (m.role === 'assistant') { - // Collect all tool-call rounds + final content into one message - const firstIdx = i - const firstG = base - const tools = [] - let thinking = null - let text = '' - let time = null - - while (i < raw.length && raw[i].role === 'assistant') { - const am = raw[i] - - // Plan card — stored as a separate is_plan message, inject into tools array - if (am.is_plan) { - tools.push({ kind: 'plan', text: am.content ?? '' }) - i++ - continue - } - - // Accumulate thinking from any assistant turn (first one wins for display) - if (am.thinking && !thinking) { - thinking = { text: am.thinking, done: true } - } - - if (am.tool_calls?.length) { - // Build a lookup map for this round's tool calls - const callMap = {} - for (const tc of am.tool_calls) { - const card = { - kind: 'tool', - id: tc.id, - name: tc.name, - args: tc.arguments ?? {}, - result: null, - success: true, - pending: false, - isSubagent: false, - steps: [] - } - tools.push(card) - callMap[tc.id] = card - } - i++ - // Collect matching tool results - while (i < raw.length && raw[i].role === 'tool') { - const tr = raw[i] - const card = callMap[tr.tool_call_id] - if (card) { - card.result = tr.content ?? '' - card.success = !tr.content?.startsWith('Error:') - card.metadata = tr.metadata ?? {} - // UI components are rendered from the tool result metadata so they - // stay inside the same assistant turn after page reload. Any tool - // can carry one; the registry decides whether it is known. - if (card.success && card.metadata?.ui_component) { - tools.push({ - kind: 'ui_component', - component: card.metadata.ui_component.component ?? '', - payload: card.metadata.ui_component.payload ?? {}, - }) - } - } - i++ - } - } else { - const content = am.content ?? '' - if (!time && am.created_at) time = am.created_at - i++ - if (content) { - // Non-empty text → this is the final response - text = content - break - } - // Empty content: if no more assistant messages follow, we're done - if (i >= raw.length || raw[i].role !== 'assistant') { - break - } - // Otherwise skip this empty intermediate message and keep accumulating - } - } - - // Only add the message if there's something to show - if (thinking || tools.length || text) { - const msgId = `h_${firstG}` - // Scan the group for metrics (saved on the final assistant turn) - let elapsed_seconds = null, tool_call_count = null, token_count = null - for (let j = firstIdx; j < i; j++) { - const am = raw[j] - if (!am || am.role !== 'assistant') continue - if (am.elapsed_seconds != null) elapsed_seconds = am.elapsed_seconds - if (am.tool_call_count != null) tool_call_count = am.tool_call_count - if (am.token_count != null) token_count = am.token_count - } - result.push({ id: msgId, role: 'assistant', type: 'history', thinking, tools, text, done: true, time, elapsed_seconds, tool_call_count, token_count, rawIndices: Array.from({ length: i - firstIdx }, (_, j) => gi(firstIdx + j)) }) - } - continue - } - - // Orphan tool message (shouldn't happen) - i++ - } - - return result - } - - return { - currentId, - currentProfileId, - messages, - streaming, - pendingImages, - pendingFiles, - artifacts, - files, - viewedImages, - contextTokens, - maxContextTokens, - servedModel, - servedTarget, - servedFallback, - wsConnected, - wsReconnecting, - loading, - imagesProcessing, - awaitingStream, - streamingMsg, - scrollToMessageIndex, - recall, - terminals, - backgroundTasks, - archiveLoading, - archiveHasMore, - archiveNextBefore, - loadSession, - clearSession, - reloadSession, - dropStaleStream, - checkForUpdates, - fetchArtifacts, - fetchFiles, - fetchTasks, - rateMessage, - saveDraft, - loadDraft, - appendUserMessage, - buildMessageList, - onStreamStart, - onReplayStart, - onReplayEnd, - onThinkingDelta, - onThinkingEnd, - onTurnThinking, - onPlanningStatus, - onPlanReady, - onToolStarted, - onToolCall, - onTerminalOutput, - onTerminalClosed, - onTerminalOpened, - onTaskUpdate, - onMessageQueued, - onStreamDelta, - onStreamEnd, - onStreamStopped, - onProfileSwitched, - onModelInfo, - onCompressionStarted, - onContextCompressed, - onError, - loadRecall, - cancelRecall, - skipRecall, - onRecallUpdate, - onProposalDecided, - loadArchivedMessages, - initArchiveState - } -}) +/** + * Compatibility entry point. The chat store now lives in `./chat/`, split by + * area (state, messages, session, turn, tools, tasks, terminals, content, + * recall, drafts); components keep importing `@/stores/chat.js` as before. + */ +export { useChatStore } from './chat/index.js' diff --git a/webclient/src/stores/chat/content.js b/webclient/src/stores/chat/content.js new file mode 100644 index 0000000..aa51098 --- /dev/null +++ b/webclient/src/stores/chat/content.js @@ -0,0 +1,85 @@ +import * as api from '@/api/index.js' + +/** + * Session content that lives beside the transcript: published artifacts, the + * session's file tree, and message feedback. + */ + +export function createContentActions(state) { + const { currentId, artifacts, files, messages } = state + + async function fetchArtifacts(id = currentId.value) { + if (!id) { + artifacts.value = [] + return + } + try { + const { content = [] } = await api.getSessionContent(id) + artifacts.value = content + } catch { + artifacts.value = [] + } + } + + async function fetchFiles(id = currentId.value) { + if (!id) { + files.value = [] + return + } + try { + const { files: list = [] } = await api.listSessionFiles(id) + files.value = list + } catch { + files.value = [] + } + } + + function upsertArtifact(item) { + if (!item?.filename) return + const key = item.id || item.filename + const idx = artifacts.value.findIndex(a => (a.id || a.filename) === key) + const next = { ...item } + if (idx === -1) artifacts.value.unshift(next) + else artifacts.value.splice(idx, 1, { ...artifacts.value[idx], ...next }) + } + + // Pull stored ratings for this session and stamp them onto built messages. + // Each assistant block has id `h_` where index is its first raw position; + // that index is also the feedback key (session_id, message_index). + async function mergeFeedback(id) { + try { + const { feedback = [] } = await api.getFeedback(id) + if (!feedback.length) return + const map = new Map(feedback.map(f => [f.message_index, f.rating])) + for (const m of messages.value) { + if (m.role !== 'assistant') continue + const idx = m.id?.startsWith?.('h_') ? Number(m.id.slice(2)) : NaN + if (Number.isInteger(idx) && map.has(idx)) { + m.rating = map.get(idx) + } + } + } catch { + // Feedback is non-critical — ignore failures so chat still works. + } + } + + // Toggle / set feedback on an assistant block. Optimistic — local state + // updates first, server call follows; rolls back on failure. + async function rateMessage(msg, rating) { + if (!currentId.value || !msg) return + const idx = msg.id?.startsWith?.('h_') ? Number(msg.id.slice(2)) : NaN + if (!Number.isInteger(idx)) return + // Toggle off if user clicks the active rating again + const next = msg.rating === rating ? 0 : rating + const prev = msg.rating ?? 0 + msg.rating = next + try { + await api.setFeedback(currentId.value, idx, next) + } catch (err) { + msg.rating = prev + throw err + } + } + + return { fetchArtifacts, fetchFiles, upsertArtifact, mergeFeedback, rateMessage } +} diff --git a/webclient/src/stores/chat/drafts.js b/webclient/src/stores/chat/drafts.js new file mode 100644 index 0000000..0010b4d --- /dev/null +++ b/webclient/src/stores/chat/drafts.js @@ -0,0 +1,17 @@ +/** Per-session input drafts kept in localStorage. */ + +export function createDraftActions(state) { + const { currentId } = state + + function saveDraft(text) { + if (!currentId.value) return + if (text) localStorage.setItem(`draft:${currentId.value}`, text) + else localStorage.removeItem(`draft:${currentId.value}`) + } + + function loadDraft(id) { + return localStorage.getItem(`draft:${id}`) ?? '' + } + + return { saveDraft, loadDraft } +} diff --git a/webclient/src/stores/chat/index.js b/webclient/src/stores/chat/index.js new file mode 100644 index 0000000..d8cd54f --- /dev/null +++ b/webclient/src/stores/chat/index.js @@ -0,0 +1,120 @@ +import { defineStore } from 'pinia' + +import { useSessionsStore } from '@/stores/sessions.js' + +import { createChatState } from './state.js' +import { createMessageHelpers } from './messages.js' +import { createDraftActions } from './drafts.js' +import { createContentActions } from './content.js' +import { createRecallActions } from './recall.js' +import { createTaskTracking } from './tasks.js' +import { createToolTracking } from './tools.js' +import { createTerminalTracking } from './terminals.js' +import { createTurnEvents } from './turn.js' +import { createSessionActions } from './session.js' + +/** + * The chat store, split by area: + * + * * `state` — every ref, map and set; + * * `messages` — transcript building, user messages, viewed images, proposals; + * * `session` — load/reload/clear, catch-up, archive pagination; + * * `turn` — stream lifecycle, thinking, planning, compression, errors; + * * `tools` — tool cards and their id-first matching; + * * `tasks` — background-task snapshot and live task_update; + * * `terminals` — terminal metadata and live output; + * * `content` — artifacts, files, feedback; + * * `recall` — the scheduled recall banner and its actions; + * * `drafts` — localStorage drafts; + * * `naming` — background session naming. + * + * The modules share one state object, so they work on the same refs and maps + * exactly as the former single closure did. Only the public surface is + * exposed here — helpers such as `_applyToolResult` stay internal. + */ +export const useChatStore = defineStore('chat', () => { + const sessionsStore = useSessionsStore() + const state = createChatState() + const messageHelpers = createMessageHelpers(state) + const drafts = createDraftActions(state) + const content = createContentActions(state) + const recallActions = createRecallActions(state) + const tasks = createTaskTracking(state) + const tools = createToolTracking({ state, content, recall: recallActions, messageHelpers }) + const terminals = createTerminalTracking(state) + const turn = createTurnEvents({ state, tools }) + const session = createSessionActions({ state, messageHelpers, content, tasks, sessionsStore }) + + return { + currentId: state.currentId, + currentProfileId: state.currentProfileId, + messages: state.messages, + streaming: state.streaming, + pendingImages: state.pendingImages, + pendingFiles: state.pendingFiles, + artifacts: state.artifacts, + files: state.files, + viewedImages: state.viewedImages, + contextTokens: state.contextTokens, + maxContextTokens: state.maxContextTokens, + servedModel: state.servedModel, + servedTarget: state.servedTarget, + servedFallback: state.servedFallback, + wsConnected: state.wsConnected, + wsReconnecting: state.wsReconnecting, + loading: state.loading, + imagesProcessing: state.imagesProcessing, + awaitingStream: state.awaitingStream, + streamingMsg: state.streamingMsg, + scrollToMessageIndex: state.scrollToMessageIndex, + recall: state.recall, + terminals: state.terminals, + backgroundTasks: state.backgroundTasks, + archiveLoading: state.archiveLoading, + archiveHasMore: state.archiveHasMore, + archiveNextBefore: state.archiveNextBefore, + loadSession: session.loadSession, + clearSession: session.clearSession, + reloadSession: session.reloadSession, + dropStaleStream: session.dropStaleStream, + checkForUpdates: session.checkForUpdates, + fetchArtifacts: content.fetchArtifacts, + fetchFiles: content.fetchFiles, + fetchTasks: tasks.fetchTasks, + rateMessage: content.rateMessage, + saveDraft: drafts.saveDraft, + loadDraft: drafts.loadDraft, + appendUserMessage: messageHelpers.appendUserMessage, + buildMessageList: messageHelpers.buildMessageList, + onStreamStart: turn.onStreamStart, + onReplayStart: turn.onReplayStart, + onReplayEnd: turn.onReplayEnd, + onThinkingDelta: turn.onThinkingDelta, + onThinkingEnd: turn.onThinkingEnd, + onTurnThinking: turn.onTurnThinking, + onPlanningStatus: turn.onPlanningStatus, + onPlanReady: turn.onPlanReady, + onToolStarted: tools.onToolStarted, + onToolCall: tools.onToolCall, + onTerminalOutput: terminals.onTerminalOutput, + onTerminalClosed: terminals.onTerminalClosed, + onTerminalOpened: terminals.onTerminalOpened, + onTaskUpdate: tasks.onTaskUpdate, + onMessageQueued: turn.onMessageQueued, + onStreamDelta: turn.onStreamDelta, + onStreamEnd: turn.onStreamEnd, + onStreamStopped: turn.onStreamStopped, + onProfileSwitched: turn.onProfileSwitched, + onModelInfo: turn.onModelInfo, + onCompressionStarted: turn.onCompressionStarted, + onContextCompressed: turn.onContextCompressed, + onError: turn.onError, + loadRecall: recallActions.loadRecall, + cancelRecall: recallActions.cancelRecall, + skipRecall: recallActions.skipRecall, + onRecallUpdate: recallActions.onRecallUpdate, + onProposalDecided: messageHelpers.onProposalDecided, + loadArchivedMessages: session.loadArchivedMessages, + initArchiveState: session.initArchiveState, + } +}) diff --git a/webclient/src/stores/chat/messages.js b/webclient/src/stores/chat/messages.js new file mode 100644 index 0000000..6589f59 --- /dev/null +++ b/webclient/src/stores/chat/messages.js @@ -0,0 +1,233 @@ +/** + * The message transcript: turning stored history into display messages, + * appending what this tab sent, collecting the images Navi looked at, and + * patching a protocol proposal once it is decided. + */ + +export function createMessageHelpers(state) { + const { messages, viewedImages, _viewedImageKeys } = state + + // Add viewed-by-Navi images (data URIs), skipping ones already collected — + // live tool_call events and history reload both carry the same payload. + function _addViewedImage(imgs) { + for (const src of imgs) { + if (!src || _viewedImageKeys.has(src)) continue + _viewedImageKeys.add(src) + viewedImages.value.push(src) + } + } + + // The server returns a flat array: + // { role:'assistant', tool_calls:[{id,name,arguments}] } ← tool request (no content) + // { role:'tool', tool_call_id, name, content } ← tool result + // { role:'assistant', content } ← final text response + // + // We group them into single AssistantMessage objects with tools[] + text. + function buildMessageList(raw) { + const result = [] + let i = 0 + + // Global display position of raw item idx. Server pages (history endpoint) + // stamp each item with metadata.display_index = rank in the FULL display + // history; h_ ids and rawIndices must use that global rank so ids stay + // stable across paged loads (feedback keys, message jumps). + const gi = idx => { + const m = raw[idx] + return m?.metadata?.display_index ?? idx + } + + while (i < raw.length) { + const m = raw[i] + const base = gi(i) + + // Compression notice + if (m.is_compression) { + result.push({ id: `h_${base}`, role: 'system', type: 'compression_notice', summary: m.content ?? '' }) + i++; continue + } + + // Synapse reaction event — the service record of a platform event. It is a + // system-role message, but it is addressed to the reader (and to the model) + // rather than being infrastructure, so it gets its own card. + if (m.role === 'system' && m.metadata?.source === 'synapse_event') { + result.push({ + id: `h_${base}`, + role: 'system', + type: 'synapse_event', + text: m.content ?? '', + eventType: m.metadata?.event_type ?? '', + time: m.created_at ?? null, + rawIndices: [base], + }) + i++; continue + } + + // Skip bare system messages + if (m.role === 'system') { i++; continue } + + if (m.is_summary) { + result.push({ id: `h_${base}`, role: 'assistant', type: 'summary', text: m.content ?? '', rawIndices: [base] }) + i++; continue + } + + if (m.role === 'user') { + const imgs = (m.images ?? []).map(b => + (typeof b === 'string' && b.startsWith('data:')) ? b : `data:image/jpeg;base64,${String(b ?? '')}` + ) + // Synthetic "load this image" messages created by the server when a + // vision tool ran (tool_executor/subagent_runner). Not a real user + // turn — strip from the chat feed; the images land in the artifacts + // panel's viewed-images group instead. + if (m.content?.startsWith?.('[Image loaded via') && imgs.length) { + _addViewedImage(imgs) + i++; continue + } + result.push({ id: `h_${base}`, role: 'user', text: m.content ?? '', images: imgs, files: m.files ?? [], time: m.created_at ?? null, rawIndices: [base], isRecall: m.is_recall ?? false }) + i++; continue + } + + if (m.role === 'assistant') { + // Collect all tool-call rounds + final content into one message + const firstIdx = i + const firstG = base + const tools = [] + let thinking = null + let text = '' + let time = null + + while (i < raw.length && raw[i].role === 'assistant') { + const am = raw[i] + + // Plan card — stored as a separate is_plan message, inject into tools array + if (am.is_plan) { + tools.push({ kind: 'plan', text: am.content ?? '' }) + i++ + continue + } + + // Accumulate thinking from any assistant turn (first one wins for display) + if (am.thinking && !thinking) { + thinking = { text: am.thinking, done: true } + } + + if (am.tool_calls?.length) { + // Build a lookup map for this round's tool calls + const callMap = {} + for (const tc of am.tool_calls) { + const card = { + kind: 'tool', + id: tc.id, + name: tc.name, + args: tc.arguments ?? {}, + result: null, + success: true, + pending: false, + isSubagent: false, + steps: [] + } + tools.push(card) + callMap[tc.id] = card + } + i++ + // Collect matching tool results + while (i < raw.length && raw[i].role === 'tool') { + const tr = raw[i] + const card = callMap[tr.tool_call_id] + if (card) { + card.result = tr.content ?? '' + card.success = !tr.content?.startsWith('Error:') + card.metadata = tr.metadata ?? {} + // UI components are rendered from the tool result metadata so they + // stay inside the same assistant turn after page reload. Any tool + // can carry one; the registry decides whether it is known. + if (card.success && card.metadata?.ui_component) { + tools.push({ + kind: 'ui_component', + component: card.metadata.ui_component.component ?? '', + payload: card.metadata.ui_component.payload ?? {}, + }) + } + } + i++ + } + } else { + const content = am.content ?? '' + if (!time && am.created_at) time = am.created_at + i++ + if (content) { + // Non-empty text → this is the final response + text = content + break + } + // Empty content: if no more assistant messages follow, we're done + if (i >= raw.length || raw[i].role !== 'assistant') { + break + } + // Otherwise skip this empty intermediate message and keep accumulating + } + } + + // Only add the message if there's something to show + if (thinking || tools.length || text) { + const msgId = `h_${firstG}` + // Scan the group for metrics (saved on the final assistant turn) + let elapsed_seconds = null, tool_call_count = null, token_count = null + for (let j = firstIdx; j < i; j++) { + const am = raw[j] + if (!am || am.role !== 'assistant') continue + if (am.elapsed_seconds != null) elapsed_seconds = am.elapsed_seconds + if (am.tool_call_count != null) tool_call_count = am.tool_call_count + if (am.token_count != null) token_count = am.token_count + } + result.push({ id: msgId, role: 'assistant', type: 'history', thinking, tools, text, done: true, time, elapsed_seconds, tool_call_count, token_count, rawIndices: Array.from({ length: i - firstIdx }, (_, j) => gi(firstIdx + j)) }) + } + continue + } + + // Orphan tool message (shouldn't happen) + i++ + } + + return result + } + + // `queued` marks a message the user sent while a turn was already running. + // It rides in the socket until the server finishes that turn and reads it — + // the same "write now, it is processed next" model navi-code uses. The mark + // disappears on the stream_start of the turn that consumes it. + function appendUserMessage(text, images, files, { queued = false } = {}) { + messages.value.push({ + id: `user_${Date.now()}`, + role: 'user', + text, + images: [...images], + files: [...files], + time: new Date().toISOString(), + queued, + animate: true + }) + } + + // A protocol proposal the user accepted or rejected in the chat card. The + // server performed the catalog write; patching every card that shows this + // proposal is what turns the buttons into a decided state — including cards + // duplicated across turns. + function onProposalDecided(data) { + if (!data?.proposal_id) return + for (const msg of messages.value) { + for (const item of msg.tools || []) { + if ( + item.kind === 'ui_component' + && item.component === 'skill_proposal' + && item.payload?.proposal_id === data.proposal_id + ) { + item.payload.status = data.status + item.payload.skill_id = data.skill_id ?? null + item.payload.error = data.error || '' + } + } + } + } + + return { _addViewedImage, buildMessageList, appendUserMessage, onProposalDecided } +} diff --git a/webclient/src/stores/chat/naming.js b/webclient/src/stores/chat/naming.js new file mode 100644 index 0000000..cfa51d1 --- /dev/null +++ b/webclient/src/stores/chat/naming.js @@ -0,0 +1,18 @@ +import * as api from '@/api/index.js' +import { useSessionsStore } from '@/stores/sessions.js' + +/** + * Give a finished session a name, in the background. Fire-and-forget: the + * answer is already on screen and a naming failure must not surface anywhere. + */ +export async function tryGenerateName(sessionId) { + try { + const sessionsStore = useSessionsStore() + const session = sessionsStore.sessions.find(s => s.session_id === sessionId) + if (session?.name) return // already has a name + const { name } = await api.generateSessionName(sessionId) + if (name) sessionsStore.updateName(sessionId, name) + } catch { + // fire-and-forget — ignore errors + } +} diff --git a/webclient/src/stores/chat/recall.js b/webclient/src/stores/chat/recall.js new file mode 100644 index 0000000..247eb43 --- /dev/null +++ b/webclient/src/stores/chat/recall.js @@ -0,0 +1,65 @@ +import * as api from '@/api/index.js' +import { useSessionsStore } from '@/stores/sessions.js' + +/** The scheduled recall of the active session: banner state and its actions. */ + +export function createRecallActions(state) { + const { currentId, recall } = state + + async function loadRecall(id = currentId.value) { + if (!id) { + recall.value = null + return + } + try { + const data = await api.getSessionRecall(id) + recall.value = data?.recall ?? null + } catch { + recall.value = null + } + } + + // Cancelling and skipping live next to the banner and the dialog that both + // offer them, so neither component talks to the API on its own. + async function cancelRecall() { + if (!currentId.value) return false + try { + await api.cancelSessionRecall(currentId.value) + recall.value = null + useSessionsStore().updateRecallStatus(currentId.value, false) + return true + } catch (e) { + console.error('cancel recall failed', e) + return false + } + } + + async function skipRecall() { + if (!currentId.value) return false + try { + await api.skipSessionRecall(currentId.value) + await loadRecall() + return true + } catch (e) { + console.error('skip recall failed', e) + return false + } + } + + function onRecallUpdate(data) { + if (!data.session_id) return + const sessionsStore = useSessionsStore() + const hasRecall = data.status === 'pending' + sessionsStore.updateRecallStatus(data.session_id, hasRecall) + if (data.session_id === currentId.value) { + if (hasRecall) { + // Partial update from event — refetch for full details + loadRecall(data.session_id) + } else { + recall.value = null + } + } + } + + return { loadRecall, cancelRecall, skipRecall, onRecallUpdate } +} diff --git a/webclient/src/stores/chat/session.js b/webclient/src/stores/chat/session.js new file mode 100644 index 0000000..2083a30 --- /dev/null +++ b/webclient/src/stores/chat/session.js @@ -0,0 +1,253 @@ +import * as api from '@/api/index.js' + +/** + * Session lifecycle: loading a session (paged history), clearing it, the + * forced reload used after a reconnect, the cheap "did another device move + * this session" check, and archive pagination. + */ + +// Guard against late completion of stale loadSession calls +let _lastLoadId = null + +export function createSessionActions({ state, messageHelpers, content, tasks, sessionsStore }) { + const { + currentId, + currentProfileId, + messages, + artifacts, + files, + streaming, + loading, + awaitingStream, + streamingMsg, + contextTokens, + maxContextTokens, + servedModel, + servedTarget, + servedFallback, + terminals, + backgroundTasks, + _terminalTaskIds, + toolIndex, + archiveLoading, + archiveHasMore, + archiveNextBefore, + historyCount, + archiveExtended, + scrollToMessageIndex, + viewedImages, + _viewedImageKeys, + replayMode, + } = state + const { buildMessageList } = messageHelpers + const { mergeFeedback, fetchArtifacts, fetchFiles } = content + const { fetchTasks } = tasks + + async function loadSession(id, targetIndex = null) { + if (currentId.value === id && targetIndex == null) return + _lastLoadId = id + // Session switch: the viewed-images group belongs to the session's + // artifacts panel — start over from this session's history. + viewedImages.value = [] + _viewedImageKeys.clear() + loading.value = true + messages.value = [] + artifacts.value = [] + files.value = [] + streaming.value = false + awaitingStream.value = false + streamingMsg.value = null + contextTokens.value = 0 + maxContextTokens.value = 0 + servedModel.value = null + servedTarget.value = null + servedFallback.value = false + terminals.value = {} + backgroundTasks.value = {} + _terminalTaskIds.clear() + toolIndex.clear() + archiveExtended.value = false + scrollToMessageIndex.value = targetIndex + + try { + // Paged load: light meta + newest page of display history. Older pages + // arrive on scroll-up via getSessionHistory(before_seq). + const [session, page] = await Promise.all([ + api.getSessionMeta(id), + api.getSessionHistory(id, null, 200), + ]) + // Ignore stale responses — user may have switched to a different session while this loaded + if (_lastLoadId !== id) return + currentProfileId.value = session.profile_id ?? null + historyCount.value = session.message_count ?? 0 + // The paged session list may not include this session — keep its name + // in the persistent map for document.title lookups + sessionsStore.rememberName(id, session.name) + messages.value = buildMessageList(page.items ?? []) + // Set archive state from the page itself — hot messages below the tail + // are NOT archived yet, so archive_threshold alone can't tell. + archiveLoading.value = false + archiveHasMore.value = page.has_more ?? false + archiveNextBefore.value = page.next_before_seq ?? null + if (session.context_token_count) contextTokens.value = session.context_token_count + if (session.max_context_tokens) maxContextTokens.value = session.max_context_tokens + + // Search-jump target may sit below the loaded tail — pull pages until + // the target raw index is covered (bounded, then give up quietly). + if (targetIndex != null) { + let guard = 0 + while ( + !messages.value.some(m => m.rawIndices?.includes(targetIndex)) && + archiveHasMore.value && guard < 20 + ) { + await loadArchivedMessages() + guard++ + } + } + + await Promise.all([mergeFeedback(id), fetchArtifacts(id), fetchFiles(id)]) + // Set currentId and hash AFTER REST completes — this triggers ws.connect() via the + // watch in ChatArea. Doing it last guarantees messages are populated before WS replay + // starts, eliminating the race where WS replay arrives before REST and gets overwritten. + currentId.value = id + location.hash = id + // Tasks snapshot goes after currentId: fetchTasks refuses to apply a + // snapshot for a session other than the current one, and currentId was + // null during the REST block above. + fetchTasks(id) + } catch (err) { + console.error('[chat] loadSession failed', err) + } finally { + loading.value = false + } + } + + function clearSession() { + currentId.value = null + currentProfileId.value = null + messages.value = [] + viewedImages.value = [] + _viewedImageKeys.clear() + artifacts.value = [] + files.value = [] + streaming.value = false + awaitingStream.value = false + streamingMsg.value = null + contextTokens.value = 0 + maxContextTokens.value = 0 + servedModel.value = null + servedTarget.value = null + servedFallback.value = false + terminals.value = {} + backgroundTasks.value = {} + _terminalTaskIds.clear() + toolIndex.clear() + location.hash = '' + } + + // Force-reload session history from the server, bypassing the same-id guard. + // Called after WebSocket reconnect so the client sees the saved response + // even if streaming finished while the client was disconnected. + async function reloadSession(id) { + if (!id) return + // Guard: don't replace the messages array while a stream is active. + // If we do, streamingMsg becomes an orphan and Vue stops rendering + // further deltas / tool cards. + if (streaming.value) return + try { + const [session, page] = await Promise.all([ + api.getSessionMeta(id), + api.getSessionHistory(id, null, 200), + ]) + // The await above is a yield point — onStreamStart may have fired + // and set streaming to true while we were waiting. + if (streaming.value) return + currentProfileId.value = session.profile_id ?? null + historyCount.value = session.message_count ?? 0 + const built = buildMessageList(page.items ?? []) + messages.value = built + archiveHasMore.value = page.has_more ?? false + archiveNextBefore.value = page.next_before_seq ?? null + await Promise.all([mergeFeedback(id), fetchArtifacts(id), fetchFiles(id), fetchTasks(id)]) + if (session.context_token_count) contextTokens.value = session.context_token_count + if (session.max_context_tokens) maxContextTokens.value = session.max_context_tokens + streamingMsg.value = null + streaming.value = false + awaitingStream.value = false + } catch { + // Silently ignore — stale data is better than a crash + } + } + + // Called when the WebSocket reconnects. A turn that was streaming when the + // socket died has no ending event coming: if the run is still alive the + // server replays it from the start, and if it finished the only thing that + // arrives is session_sync — which reloadSession refuses while `streaming` + // is true. Dropping the orphan here is what lets the recovery through. + function dropStaleStream() { + const msg = streamingMsg.value + if (msg) { + const idx = messages.value.indexOf(msg) + if (idx !== -1) messages.value.splice(idx, 1) + } + streamingMsg.value = null + streaming.value = false + awaitingStream.value = false + replayMode.value = false + } + + // Cheap catch-up for a session that may have moved on elsewhere (another + // tab, the phone, a recall). Only the message count is fetched; history is + // reloaded only when it actually changed. + async function checkForUpdates(id = currentId.value) { + if (!id || streaming.value || loading.value || archiveExtended.value) return false + let meta + try { + meta = await api.getSessionMeta(id) + } catch { + return false + } + // The await is a yield point — the session may have changed under us. + if (currentId.value !== id || streaming.value) return false + if ((meta.message_count ?? 0) === historyCount.value) return false + await reloadSession(id) + return true + } + + async function loadArchivedMessages() { + if (!currentId.value || archiveLoading.value || !archiveHasMore.value) return + archiveLoading.value = true + // Older pages are in the list now — automatic catch-up would replace the + // whole list and pull the reader back to the tail. + archiveExtended.value = true + try { + // Full-history pagination (archive ∪ unarchived old messages) — the + // archive table alone misses sessions whose threshold never moved. + const data = await api.getSessionHistory(currentId.value, archiveNextBefore.value) + const built = buildMessageList(data.items ?? []) + // Prepend older messages to the beginning of the list + messages.value = [...built, ...messages.value] + archiveHasMore.value = data.has_more ?? false + archiveNextBefore.value = data.next_before_seq ?? null + } catch (err) { + console.error('[chat] loadArchivedMessages failed', err) + } finally { + archiveLoading.value = false + } + } + + function initArchiveState(hasMore = false, nextBefore = null) { + archiveHasMore.value = hasMore + archiveNextBefore.value = nextBefore + } + + return { + loadSession, + clearSession, + reloadSession, + dropStaleStream, + checkForUpdates, + loadArchivedMessages, + initArchiveState, + } +} diff --git a/webclient/src/stores/chat/state.js b/webclient/src/stores/chat/state.js new file mode 100644 index 0000000..ffe9e71 --- /dev/null +++ b/webclient/src/stores/chat/state.js @@ -0,0 +1,81 @@ +import { ref, shallowRef } from 'vue' + +/** + * State of the chat store: the refs, maps and sets the area modules share. + * Created once per store instance. + * + * `toolIndex` and the two underscore sets are deliberately non-reactive. The + * index must hold the array's reactive proxy (a write to a raw copy never + * wakes a computed that read the card through the proxy), and a late event + * arriving after the turn closed must still find its card and its task. + */ +export function createChatState() { + return { + currentId: ref(null), + currentProfileId: ref(null), + messages: ref([]), + streaming: ref(false), + pendingImages: ref([]), // base64 strings + pendingFiles: ref([]), // { name, path, size, content_type } + artifacts: ref([]), // published session content + files: ref([]), // session directory tree + contextTokens: ref(0), + maxContextTokens: ref(0), + // The model (and llm.json target) that answered the current turn, from the + // model_info event. `servedFallback` is true when it is NOT the route's + // primary — the one case the UI has to say out loud: a backup host is + // carrying the conversation, so answers differ in style and window. + servedModel: ref(null), + servedTarget: ref(null), + servedFallback: ref(false), + // WebSocket liveness, mirrored from useWebSocket so the session-info dialog + // can show it without reaching into ChatArea's composable instance. + wsConnected: ref(false), + wsReconnecting: ref(false), + loading: ref(false), + // Images currently being read/resized (FileReader + canvas) — no preview yet + imagesProcessing: ref(0), + // Message sent over WS but the run hasn't started (stream_start not yet seen) + awaitingStream: ref(false), + + // The in-progress streaming message (direct ref for perf) + streamingMsg: shallowRef(null), + // True while replaying buffered events from a reconnect — suppresses animations + replayMode: ref(false), + // Index of the message to scroll to after loading a session from search results + scrollToMessageIndex: ref(null), + // Current scheduled recall for the active session (null if none) + recall: ref(null), + // Persistent terminal output keyed by terminal name + terminals: ref({}), + // Live background tasks keyed by task_id (from out-of-band task_update events) + backgroundTasks: ref({}), + // tool_call_id → tool card. Non-reactive index for id-first matching — + // works even for events that arrive after stream_end closed the turn. + toolIndex: new Map(), + // Images Navi itself looked at (image_view and friends): captured live from + // tool_call metadata and from history. Rendered as a group in the artifacts + // panel, not as chat bubbles. + viewedImages: ref([]), + _viewedImageKeys: new Set(), + + // Task ids whose terminal task_update (completed/failed/cancelled) was seen + // — a late duplicate 'running' must not resurrect the chip (out-of-order + // delivery would otherwise leave a "ghost" task running forever). + _terminalTaskIds: new Set(), + + // Archive (older messages) pagination state + archiveLoading: ref(false), + archiveHasMore: ref(false), + archiveNextBefore: ref(null), + + // Message count from the last full history load — the cheap fingerprint that + // tells whether another device advanced the session. Compared against + // GET /sessions/{id}/meta when the tab comes back to the foreground. + historyCount: ref(0), + // The user scrolled into older pages: a silent reload would throw them away + // (and jump the scroll position), so automatic catch-up stays off for the + // rest of this session view. + archiveExtended: ref(false), + } +} diff --git a/webclient/src/stores/chat/tasks.js b/webclient/src/stores/chat/tasks.js new file mode 100644 index 0000000..5e2b068 --- /dev/null +++ b/webclient/src/stores/chat/tasks.js @@ -0,0 +1,112 @@ +import * as api from '@/api/index.js' + +/** + * Background tasks: the server's snapshot plus the live task_update events, + * which are never replayed on (re)connect. + */ + +export function createTaskTracking(state) { + const { currentId, backgroundTasks, _terminalTaskIds, toolIndex } = state + + // task_update events are delivered live over the WS and are never replayed + // on (re)connect — fetch the server's snapshot to rebuild the backgrounds + // tab. Snapshot entries fill gaps; live entries (from task_update) are kept + // even if the snapshot doesn't know them yet. + async function fetchTasks(id = currentId.value) { + if (!id) { + backgroundTasks.value = {} + return + } + let tasks = [] + try { + tasks = (await api.getSessionTasks(id)).tasks ?? [] + } catch { + return + } + if (currentId.value !== id) return // user switched meanwhile + const next = {} + for (const t of tasks) { + next[t.task_id] = { + taskId: t.task_id, + tool: t.tool, + status: t.status, + startedAt: t.started_at ?? null, + finishedAt: t.finished_at ?? null, + argsSummary: t.args_summary ?? '', + preview: t.result_preview ?? '', + subagentTokens: t.subagent_tokens ?? null, + } + if (t.status !== 'running') _terminalTaskIds.add(t.task_id) + } + for (const live of Object.values(backgroundTasks.value)) { + next[live.taskId] = live + } + backgroundTasks.value = next + } + + // Out-of-band task_update: upsert/remove the background-task chip entry and + // append progress to the card that started the task (spawn/terminal/etc.). + function onTaskUpdate(data) { + const taskId = data.task_id + if (!taskId) return + // Out-of-band events can arrive in the narrow window after this store was + // reset for a new session but before the WS reconnects — ignore updates + // that belong to an older session. + if (data.session_id && currentId.value && data.session_id !== currentId.value) return + if (data.parent_tool_call_id) { + const card = toolIndex.get(data.parent_tool_call_id) + if (card) { + // A repeated 'running' publish must not duplicate the spinner row. + const last = card.steps[card.steps.length - 1] + const sameAsLast = + last?.kind === 'task_update' && + last.status === data.status && + last.preview === (data.result_preview ?? '') + if (!sameAsLast) { + card.steps.push({ + kind: 'task_update', + taskId, + tool: data.tool, + status: data.status, + preview: data.result_preview ?? '', + }) + } + if (data.subagent_tokens != null) card.subagentTokens = data.subagent_tokens + } + } + if (data.status !== 'running') { + _terminalTaskIds.add(taskId) + const prev = backgroundTasks.value[taskId] + if (prev) { + // Keep the entry — the Backgrounds tab shows recently finished tasks; + // _terminalTaskIds is what stops a late 'running' from resurrecting it. + backgroundTasks.value = { + ...backgroundTasks.value, + [taskId]: { + ...prev, + status: data.status, + preview: data.result_preview ?? prev.preview, + finishedAt: data.finished_at ?? prev.finishedAt ?? null, + subagentTokens: data.subagent_tokens ?? prev.subagentTokens ?? null, + }, + } + } + } else if (!_terminalTaskIds.has(taskId) && !backgroundTasks.value[taskId]) { + backgroundTasks.value = { + ...backgroundTasks.value, + [taskId]: { + taskId, + tool: data.tool, + status: data.status, + startedAt: data.started_at ?? null, + finishedAt: null, + argsSummary: data.args_summary ?? '', + preview: '', + subagentTokens: null, + }, + } + } + } + + return { fetchTasks, onTaskUpdate } +} diff --git a/webclient/src/stores/chat/terminals.js b/webclient/src/stores/chat/terminals.js new file mode 100644 index 0000000..3588027 --- /dev/null +++ b/webclient/src/stores/chat/terminals.js @@ -0,0 +1,63 @@ +/** + * Terminal metadata mirrored from terminal_* events: the terminals store the + * panel renders, plus live output attached to the matching pending tool card. + */ + +export function createTerminalTracking(state) { + const { terminals, streamingMsg } = state + + function onTerminalOpened(data) { + const name = data.terminal_name + if (!name) return + const prev = terminals.value[name] || {} + terminals.value = { ...terminals.value, [name]: { ...prev, name, closed: false } } + } + + function onTerminalOutput(data) { + const name = data.terminal_name + if (!name) return + const prev = terminals.value[name] || { output: '' } + const next = { + ...prev, + output: (prev.output || '') + (data.delta || ''), + } + terminals.value = { ...terminals.value, [name]: next } + // Also try to attach live output to the matching pending tool card + const msg = streamingMsg.value + if (msg) { + for (let i = msg.tools.length - 1; i >= 0; i--) { + const t = msg.tools[i] + if (t.kind === 'tool' && t.name === 'terminal' && t.terminalName === name) { + t.terminalOutput = next.output + break + } + } + } + } + + function onTerminalClosed(data) { + const name = data.terminal_name + if (!name) return + const prev = terminals.value[name] || {} + const next = { + ...prev, + closed: true, + closeReason: data.reason || 'unknown', + } + terminals.value = { ...terminals.value, [name]: next } + // Mark matching pending tool card as no longer running + const msg = streamingMsg.value + if (msg) { + for (let i = msg.tools.length - 1; i >= 0; i--) { + const t = msg.tools[i] + if (t.kind === 'tool' && t.name === 'terminal' && t.terminalName === name && t.pending) { + t.pending = false + t.success = data.reason !== 'error' + break + } + } + } + } + + return { onTerminalOpened, onTerminalOutput, onTerminalClosed } +} diff --git a/webclient/src/stores/chat/tools.js b/webclient/src/stores/chat/tools.js new file mode 100644 index 0000000..b3f92c0 --- /dev/null +++ b/webclient/src/stores/chat/tools.js @@ -0,0 +1,261 @@ +import { useSessionsStore } from '@/stores/sessions.js' + +/** + * Tool cards inside a turn: created from tool_started, resolved by tool_call, + * matched by tool_call_id through the non-reactive index (which is what lets a + * result land even after stream_end closed the turn). + */ + +export function createToolTracking({ state, content, recall: recallActions, messageHelpers }) { + const { messages, streamingMsg, toolIndex, terminals, currentId, recall } = state + const { upsertArtifact, fetchFiles, fetchArtifacts } = content + const { loadRecall } = recallActions + const { _addViewedImage } = messageHelpers + + // Find the last spawn_agent card that's still collecting subagent events + function _lastSpawnCard(msg) { + // Custom findLast for older browser compatibility (ES2023 polyfill-free) + for (let i = msg.tools.length - 1; i >= 0; i--) { + const t = msg.tools[i] + if (t.kind === 'tool' && t.name === 'spawn_agent') return t + } + return undefined + } + + function onToolStarted(data) { + const msg = streamingMsg.value + if (!msg) return + const card = { + kind: 'tool', + id: `tool_${Date.now()}`, + toolCallId: data.tool_call_id ?? null, + name: data.tool, + args: data.args, + result: null, + success: null, + pending: true, + startedAt: Date.now(), + isSubagent: data.is_subagent ?? false, + steps: [], + terminalName: data.tool === 'terminal' ? (data.args?.terminal_name || null) : null, + } + card.parentMsg = msg + // The index holds the reactive proxy, not `card`: results are applied + // through this reference long after the card was pushed, and a write to + // the raw object never wakes a computed that read it through the proxy + // (a running content_publish card stayed an error card until the turn's + // message was rebuilt). + if (data.is_subagent) { + const spawn = _lastSpawnCard(msg) + if (spawn) { + spawn.steps.push(card) + if (card.toolCallId) toolIndex.set(card.toolCallId, spawn.steps[spawn.steps.length - 1]) + return + } + } + msg.tools.push(card) + if (card.toolCallId) toolIndex.set(card.toolCallId, msg.tools[msg.tools.length - 1]) + + // Pre-seed terminal metadata when a terminal open starts + if (data.tool === 'terminal' && data.args?.action === 'open') { + const name = data.args.terminal_name + if (name) { + const prev = terminals.value[name] || {} + terminals.value = { + ...terminals.value, + [name]: { + ...prev, + name, + description: data.args.description || '', + command: data.args.command || '', + background: data.args.background || false, + output: prev.output || '', + closed: false, + } + } + } + } + } + + function onToolCall(data) { + // A vision tool read an image (metadata carries the payload) — capture it + // live for the artifacts panel's viewed-images group. + if (data.metadata?.is_image && data.metadata?.base64) { + _addViewedImage([data.metadata.data_url ?? `data:image/jpeg;base64,${data.metadata.base64}`]) + } + // Sub-agent tool call → update inside spawn_agent steps. Legacy events + // without tool_call_id need the name+pending search. + if (data.is_subagent && !data.tool_call_id) { + const msg = streamingMsg.value ?? _lastAssistantMsg() + const spawn = msg ? _lastSpawnCard(msg) : null + if (spawn) { + for (let i = spawn.steps.length - 1; i >= 0; i--) { + const t = spawn.steps[i] + if (t.kind === 'tool' && t.name === data.tool && t.pending) { + _applyToolResult(t, data) + return + } + } + } + } + // Id-first: exact card match — works even for events arriving after the + // turn's stream_end (long tools finishing late). + let card = data.tool_call_id ? (toolIndex.get(data.tool_call_id) ?? null) : null + // Legacy fallback: name+pending reverse search in the live turn + if (!card && !data.tool_call_id) { + const msg = streamingMsg.value + if (msg) { + for (let i = msg.tools.length - 1; i >= 0; i--) { + const t = msg.tools[i] + if (t.kind === 'tool' && t.name === data.tool && t.pending) { card = t; break } + } + } + } + // Bare tool_call (no tool_started was seen — late event after the index + // was cleared, or a synthetic result): synthesize a card so it's visible. + if (!card) card = _synthesizeToolCard(data) + if (!card) return + _applyToolResult(card, data) + } + + // Shared tool-result application: completes the card and runs the side + // effects (UI components, artifacts, file refresh, terminal metadata, recall). + function _applyToolResult(card, data) { + { + card.result = data.result + card.success = data.success !== false + card.pending = false + if (data.metadata) card.metadata = data.metadata + // Any tool may hand the client a component — navi_ui is the general + // route, and a builtin like `skills` returns one directly for a protocol + // proposal. The client renders only names it knows. + if (card.success && data.metadata?.ui_component) { + // Render the UI component inline as part of the same assistant turn. + // The card knows the turn it belongs to (parentMsg set at creation); + // subagent cards stay nested inside their spawn card. + const parentMsg = card.parentMsg ?? (streamingMsg.value ?? _lastAssistantMsg()) + if (parentMsg && !card.isSubagent) { + parentMsg.tools.push({ + kind: 'ui_component', + component: data.metadata.ui_component.component ?? '', + payload: data.metadata.ui_component.payload ?? {}, + }) + } + } + if (data.tool === 'content_publish' && card.success && data.metadata) { + upsertArtifact(data.metadata) + } + if (data.tool === 'filesystem' && card.success && currentId.value) { + const action = typeof card.args === 'object' ? card.args?.action : null + if (['write', 'edit', 'append', 'mkdir', 'rm', 'cp', 'mv'].includes(action)) { + fetchFiles(currentId.value) + } + if (['write', 'edit'].includes(action)) fetchArtifacts(currentId.value) + } + if ((data.tool === 'terminal' || data.tool === 'code_exec') && card.success && currentId.value) { + fetchFiles(currentId.value) + } + // Merge terminal metadata from tool results into terminals store + if (data.tool === 'terminal' && card.success && currentId.value) { + const action = typeof card.args === 'object' ? card.args?.action : null + const name = card.args?.terminal_name + if (data.metadata) { + if (action === 'open' || action === 'status') { + if (name) { + const prev = terminals.value[name] || {} + terminals.value = { + ...terminals.value, + [name]: { ...prev, ...data.metadata, name, closed: false }, + } + } + } else if (action === 'list' && Array.isArray(data.metadata.terminals)) { + for (const t of data.metadata.terminals) { + if (!t.name) continue + const prev = terminals.value[t.name] || {} + terminals.value = { + ...terminals.value, + [t.name]: { ...prev, ...t, closed: false }, + } + } + } else if (action === 'close' && name) { + const prev = terminals.value[name] || {} + terminals.value = { + ...terminals.value, + [name]: { ...prev, closed: true, closeReason: 'explicit' }, + } + } + } + } + // Update recall banner live when scheduling or managing recalls + if (data.tool === 'schedule_recall' && card.success && currentId.value) { + loadRecall(currentId.value) + useSessionsStore().updateRecallStatus(currentId.value, true) + } + if (data.tool === 'manage_recall' && card.success && currentId.value) { + const action = card.args?.action + if (action === 'cancel') { + recall.value = null + useSessionsStore().updateRecallStatus(currentId.value, false) + } else if (action === 'skip') { + loadRecall(currentId.value) + } + } + } + } + + // Last assistant message in the transcript with a tools array — target for + // tool events that arrive after the live turn has ended. + function _lastAssistantMsg() { + for (let i = messages.value.length - 1; i >= 0; i--) { + const m = messages.value[i] + if (m.role === 'assistant' && Array.isArray(m.tools)) return m + } + return null + } + + // Create a card for a tool_call event with no matching tool_started. + function _synthesizeToolCard(data) { + const msg = streamingMsg.value ?? _lastAssistantMsg() + if (!msg) return null + const card = { + kind: 'tool', + id: `tool_${Date.now()}`, + toolCallId: data.tool_call_id ?? null, + name: data.tool, + args: null, + result: null, + success: null, + pending: false, + isSubagent: data.is_subagent ?? false, + steps: [], + } + // Keep the owning message on the card: a late tool_result can arrive + // after streamingMsg moved on, and side effects like inline ui_component + // still need to land in the turn that owns this card. + card.parentMsg = msg + // Return and index the array's proxy, so the result this card is about to + // receive goes through reactivity (see onToolStarted). + if (data.is_subagent) { + const spawn = _lastSpawnCard(msg) + if (spawn) { + spawn.steps.push(card) + const stored = spawn.steps[spawn.steps.length - 1] + if (card.toolCallId) toolIndex.set(card.toolCallId, stored) + return stored + } + } + msg.tools.push(card) + const stored = msg.tools[msg.tools.length - 1] + if (card.toolCallId) toolIndex.set(card.toolCallId, stored) + return stored + } + + return { + _lastSpawnCard, + onToolStarted, + onToolCall, + _applyToolResult, + _lastAssistantMsg, + _synthesizeToolCard, + } +} diff --git a/webclient/src/stores/chat/turn.js b/webclient/src/stores/chat/turn.js new file mode 100644 index 0000000..a36bd6e --- /dev/null +++ b/webclient/src/stores/chat/turn.js @@ -0,0 +1,304 @@ +import { t } from '@/i18n/index.js' +import { notifyNativeTurn } from '@/composables/nativeBridge.js' +import { useSessionsStore } from '@/stores/sessions.js' + +import { tryGenerateName } from './naming.js' + +/** + * The turn's own event stream: stream start/end, thinking, planning, the text + * deltas, compression notices, errors and the model that answered. Tool cards + * and tasks live in their own modules; a few of these handlers only need + * `_lastSpawnCard` to route a sub-agent's thinking into its spawn card. + */ + +export function createTurnEvents({ state, tools }) { + const { + messages, + streaming, + awaitingStream, + streamingMsg, + replayMode, + currentId, + contextTokens, + maxContextTokens, + servedModel, + servedTarget, + servedFallback, + currentProfileId, + toolIndex, + } = state + const { _lastSpawnCard } = tools + + function onStreamStart() { + // On reconnect: remove the incomplete frozen message so replay rebuilds it cleanly. + // On a fresh start streamingMsg is null, so this branch is skipped. + if (streamingMsg.value) { + const idx = messages.value.indexOf(streamingMsg.value) + if (idx !== -1) messages.value.splice(idx, 1) + streamingMsg.value = null + } + streaming.value = true + awaitingStream.value = false + // A run (fresh or queued-drain) has started — the queued badge drains now. + // Messages queued in this tab are consumed in order, one per turn, so the + // oldest mark comes off here. + const nextQueued = messages.value.find(m => m.queued) + if (nextQueued) nextQueued.queued = false + toolIndex.clear() + const msg = { + id: `stream_${Date.now()}`, + role: 'assistant', + type: 'stream', + thinking: null, // { text, done } | null + tools: [], // tool card objects + text: '', + done: false, + time: new Date().toISOString(), + animate: !replayMode.value, // no cursor animation during replay + statusLabel: null, + } + messages.value.push(msg) + // Store the reactive proxy (from the array) so mutations trigger Vue updates + streamingMsg.value = messages.value[messages.value.length - 1] + } + + function onReplayStart() { + replayMode.value = true + } + + function onReplayEnd() { + replayMode.value = false + // Restore animate on the message now that live events will follow + if (streamingMsg.value) streamingMsg.value.animate = true + } + + function onThinkingDelta(delta) { + const msg = streamingMsg.value + if (!msg) return + if (!msg.thinking) msg.thinking = { text: '', done: false } + msg.thinking.text += delta + } + + function onThinkingEnd() { + const msg = streamingMsg.value + if (msg?.thinking) msg.thinking.done = true + } + + function onTurnThinking(data) { + const msg = streamingMsg.value + if (!msg) return + // Shape item so ThinkingCard can receive it directly as :msg (stable reference, no new object each render) + const item = { kind: 'turn_thinking', isSubagent: data.is_subagent ?? false, thinking: { text: data.thinking ?? '', done: true } } + if (data.is_subagent) { + const spawn = _lastSpawnCard(msg) + if (spawn) { spawn.steps.push(item); return } + } + msg.tools.push(item) + } + + function onPlanningStatus(data) { + const msg = streamingMsg.value + if (!msg) return + if (data.is_subagent) { + const spawn = _lastSpawnCard(msg) + if (spawn) spawn.planningLabel = data.label ?? '' + return // never bleed subagent planning into parent UI + } + msg.statusLabel = data.label ?? '' + } + + function onPlanReady(data) { + const msg = streamingMsg.value + if (!msg) return + if (data.is_subagent) { + const spawn = _lastSpawnCard(msg) + if (spawn) { + spawn.planningLabel = null + spawn.steps.push({ kind: 'plan', text: data.plan ?? '' }) + } + return // never bleed subagent plan into parent UI + } + msg.statusLabel = null + msg.tools.push({ kind: 'plan', text: data.plan ?? '' }) + } + + // The server took a message from another tab into the queue. Nothing is drawn + // for it here: the queue shows on the message itself (msg.queued), and the + // separate counter that used to live in the input area is gone — two places + // describing one queue disagreed more than they helped. The only effect left + // is dropping the "sending" hint: the run will pick the message up on its own. + function onMessageQueued() { + awaitingStream.value = false + } + + function onStreamDelta(delta) { + const msg = streamingMsg.value + if (!msg) return + if (msg.statusLabel) msg.statusLabel = null + msg.text += delta + } + + // A run can end without every started tool reporting: Stop cancels an + // in-flight tool, a frame is lost on a half-open socket, the server restarts + // mid-run, or the backend dies with an `error` frame. A card is drawn from + // tool_started and only its tool_call closes it, so whatever is still + // pending here would spin for the rest of the session — until a reload + // rebuilt the transcript from history (which always marks cards settled). + // Close them explicitly, sub-agent steps included, with a visible note. + function _settlePendingTools(msg) { + if (!msg) return + const settle = card => { + if (!card || card.kind !== 'tool' || !card.pending) return + card.pending = false + card.success = false + if (card.result == null) card.result = t('messages.toolInterrupted') + } + for (const item of msg.tools ?? []) { + settle(item) + for (const step of item.steps ?? []) settle(step) + } + } + + function onStreamEnd(data) { + const msg = streamingMsg.value + if (msg) { + msg.done = true + _settlePendingTools(msg) + msg.elapsed_seconds = data?.elapsed_seconds ?? null + msg.tool_call_count = data?.tool_call_count ?? null + msg.token_count = data?.token_count ?? null + // Assign stable h_ id so the rating UI appears and feedback can be mapped + const assistantIndex = messages.value.filter(m => m.role === 'assistant').length - 1 + msg.id = `h_${assistantIndex}` + streamingMsg.value = null + // Purge if nothing was ever written (avoids empty msg-assistant divs) + if (!msg.thinking && !msg.tools.length && !msg.text) { + messages.value = messages.value.filter(m => m !== msg) + } + } + streaming.value = false + awaitingStream.value = false + + if (data?.context_tokens != null) contextTokens.value = data.context_tokens + if (data?.max_context_tokens != null) maxContextTokens.value = data.max_context_tokens + + // Update session preview + if (currentId.value && msg?.text) { + useSessionsStore().updatePreview(currentId.value, msg.text.slice(0, 80)) + } + + // Android app: raise a native notification when the answer finished while + // the window was hidden (bridge notify() re-checks visibility + the flag). + if (msg?.text && currentId.value) { + notifyNativeTurn('Navi ответила', msg.text.slice(0, 140), `/#${currentId.value}`) + } + + // Try to generate session name in background (only if not named yet) + if (currentId.value) { + tryGenerateName(currentId.value) + } + } + + function onStreamStopped() { + const msg = streamingMsg.value + if (msg) { + msg.done = true + _settlePendingTools(msg) + const assistantIndex = messages.value.filter(m => m.role === 'assistant').length - 1 + msg.id = `h_${assistantIndex}` + streamingMsg.value = null + if (!msg.thinking && !msg.tools.length && !msg.text) { + messages.value = messages.value.filter(m => m !== msg) + } + } + streaming.value = false + awaitingStream.value = false + } + + function onProfileSwitched(data) { + currentProfileId.value = data.profile_id + // The new profile may route elsewhere entirely. + servedModel.value = null + servedTarget.value = null + servedFallback.value = false + } + + // Emitted once per turn by the backend after it resolved which entry of the + // route answered. `target`/`fallback` are only present when the route engine + // stamped them (a bare OllamaBackend sends just the model). + function onModelInfo(data) { + if (!data?.model) return + servedModel.value = data.model + servedTarget.value = data.target ?? null + servedFallback.value = Boolean(data.fallback) + } + + function onCompressionStarted(data) { + if (data?.context_tokens != null) contextTokens.value = data.context_tokens + if (data?.max_context_tokens != null) maxContextTokens.value = data.max_context_tokens + // Insert a transient notice that will be replaced by the real compression card + messages.value.push({ + id: `compress_pending_${Date.now()}`, + role: 'system', + type: 'compression_pending' + }) + } + + function onContextCompressed(data) { + if (data?.context_tokens != null) contextTokens.value = data.context_tokens + if (data?.max_context_tokens != null) maxContextTokens.value = data.max_context_tokens + // Remove any pending compression notices — the real one is here now + messages.value = messages.value.filter(m => m.type !== 'compression_pending') + messages.value.push({ + id: `compress_${Date.now()}`, + role: 'system', + type: 'compression_notice', + before: data.messages_before, + after: data.messages_after, + summary: data.summary ?? '' + }) + } + + function onError(data) { + const msg = streamingMsg.value + if (msg) { + // The half-built turn stays in the transcript — its thinking and tool + // cards are what the run actually did — but it must stop looking live: + // mark it done and close anything the error left mid-flight. + msg.done = true + _settlePendingTools(msg) + if (!msg.thinking && !msg.tools.length && !msg.text) { + messages.value = messages.value.filter(m => m !== msg) + } + } + streaming.value = false + awaitingStream.value = false + streamingMsg.value = null + messages.value.push({ + id: `err_${Date.now()}`, + role: 'system', + type: 'error', + text: data.message ?? 'An error occurred' + }) + } + + return { + onStreamStart, + onReplayStart, + onReplayEnd, + onThinkingDelta, + onThinkingEnd, + onTurnThinking, + onPlanningStatus, + onPlanReady, + onMessageQueued, + onStreamDelta, + onStreamEnd, + onStreamStopped, + onProfileSwitched, + onModelInfo, + onCompressionStarted, + onContextCompressed, + onError, + } +}