import { ASSISTANT_ERROR_CODES, type AiStatusResponse, type AssistantActivity, type AssistantApplied, type AssistantDecision, type AssistantErrorCode, type AssistantFocus, type AssistantFoundPlace, type AssistantMapView, type AssistantMention, type AssistantMessage, type AssistantMessagePart, type AssistantPageRender, type AssistantThreadSummary, type UiStreamChunk, } from '@trek/shared'; import { create, type StoreApi } from 'zustand'; import { AssistantRequestError } from '../api/assistant'; import { aiConnectionsRepo } from '../repo/aiConnectionsRepo'; import { assistantRepo } from '../repo/assistantRepo'; import { OnlineOnlyError } from '../repo/onlineOnly'; import type { Assignment } from '../types'; import { randomId } from '../utils/randomId'; import type { RouteProfileFocus } from '../utils/routeGeometry'; import { assistantScopeOf, assistantTargetOf, type AssistantChatKey } from './assistantChatKey'; import { blockDays, blockKeyOf, changeEffects, dayPoints, snapshotDays, type LatLng, type WindowRect, } from './assistantMapModel'; import { applyChunk, closeUndo, markDecisions } from './assistantMessages'; import { useSettingsStore } from './settingsStore'; import { useTripStore } from './tripStore'; /** * The assistant: whether it can run, whether its window is open, and one * conversation per trip and one per journey (the chat in TREK Studio), each * under its `AssistantChatKey`. The actions both kinds of chat have take that * key; the ones that only make sense on a trip (files, found places, * proposals, the before and after on the map) take a trip's id and do nothing * for a journey's key. * * Online-only like the repos it calls; nothing here reaches Dexie. Messages * are kept in the server's stored shape (`AssistantMessage`), and the stream * is folded into them by `applyChunk`, so a streamed answer and a reloaded * one are the same data. The view converts them for @assistant-ui; this file * does not import it. * * Streaming writes reach the store at most once per animation frame: a fast * model sends hundreds of deltas a second, and each set() re-renders the * thread. */ interface AssistantTripChat { /** The open thread; null for a new one the first message creates. */ threadId: number | null; /** The user's latest threads in this trip; null until loaded. */ threads: AssistantThreadSummary[] | null; messages: AssistantMessage[]; isRunning: boolean; /** A thread is being loaded. */ loading: boolean; error: AssistantErrorCode | null; /** What the next message is about: a place the user asked from, or the selected day. */ focus: AssistantFocus | null; /** What is typed but not sent, kept while the phone screen hops to the map. */ draft: string; /** The text of a message the server refused before answering, sent again by retry(). */ unsent: string | null; /** Ends the running stream. */ abort: AbortController | null; /** What the running step is doing (the place it searches near, the area); streamed only, never stored. */ live: { activity: AssistantActivity | null }; /** The applied changes taken back in this session, by call. */ undone: Record; /** "Before" and "after" of the last block of changes applied here; null when there is none to compare. */ compare: AssistantCompare | null; /** The days, places and bookings named with @ in the draft. */ mentions: AssistantMentionItem[]; /** The way picked among the ways a block of cards offers, by block. */ picks: Record; } /** A mention as the composer picked it, with the label it wrote into the text. */ export type AssistantMentionItem = AssistantMention & { label: string }; /** * The plan of the days one applied block of changes touched, as it was just * before, so the map can show either side. Lives only in this session. */ interface AssistantCompare { /** The block it belongs to, `blockKeyOf` its calls. */ blockKey: string; mode: 'before' | 'after'; /** The affected days' stops before the block ran, by day. */ snapshot: Record; /** The calls of the block that make a new place. */ newPlaceCalls: string[]; /** The places those calls made, hidden while "before" is on. */ addedPlaceIds: number[]; } /** Where a hover on a change came from: its row in the card, its pin on the map, a found place in the strip. */ type AssistantMapSource = 'card' | 'map' | 'strip'; type UndoOutcome = 'undone' | 'stale' | 'failed'; /** How taking back a whole block went, counted per change. */ interface UndoBlockResult { done: number; stale: number; failed: number; } interface AssistantState { /** What the assistant runs on for this user; null until loaded, and the assistant stays hidden until then. */ status: AiStatusResponse | null; window: { open: boolean; minimized: boolean }; /** The chats by `AssistantChatKey`: a trip's id, or a journey's key. */ byTrip: Record; /** The change applied last in a trip's chat, for the planner to jump to it. */ lastApplied: { tripId: number; applied: AssistantApplied } | null; /** * The change under the pointer, shared by its card row and its pin on the * map, and the side the pointer is on. One planner is on screen at a time, * so this is global and names its trip. */ mapLink: { tripId: number; hoveredId: string | null; source: AssistantMapSource } | null; /** The latest request to move the map; `seq` tells a new one from the same points asked again. */ camera: { tripId: number; seq: number; points: LatLng[]; mode: 'fit' | 'pan'; why: 'user' | 'auto'; /** When it was asked for, so a map that mounts right after (the chat switched to it) still moves. */ at: number; } | null; /** * A spot a card points at (a found place, a row), drawn on the map until * the pointer leaves; a place of the trip pulses its own pin instead. */ preview: { tripId: number; ref: string; lat: number; lng: number; name: string; placeId?: number } | null; /** The point of a route a card and the map both show, from the card's profile or from the line on the map. */ routeFocus: { tripId: number; ref: string; focus: RouteProfileFocus; source: 'card' | 'map' } | null; /** A line a card asks the map to draw while the pointer rests on it (a day of a drive, a saved tour). */ routePreview: { tripId: number; ref: string; lines: LatLng[][]; color: string } | null; /** A found place to open in the planner's place form, filled in; `seq` tells a new one from the same place asked again. */ placeDraft: { tripId: number; seq: number; place: AssistantFoundPlace; dayId: number | null } | null; /** The route card the phone shows over the map. */ routePeek: { tripId: number; toolCallId: string } | null; /** Where the floating window sits after its last move or resize, for the map to frame around it. */ windowRect: WindowRect | null; /** The part of the planner's map on screen, sent with a question so "here" means it. */ mapView: { tripId: number; view: AssistantMapView } | null; /** The TREK Studio hosting a journey's chat, by the chat's key, while that chat is open there. */ studioHosts: Record; /** What the cards of a chat still send before its decisions go out (a book design's pages), by the chat's key. */ decisionHolds: Partial>; loadStatus: () => Promise; ackPrivacy: () => Promise; /** The user let the effective connection receive images of their Studio pages. */ ackStudioImages: () => Promise; /** Hosts a journey's chat in the Studio; the returned function ends it, unless another host took the key since. */ hostStudio: (key: AssistantChatKey, host: AssistantStudioHost) => () => void; /** Makes the decisions of a chat wait for `settle` first; the returned function ends it. */ holdDecisions: (key: AssistantChatKey, settle: AssistantDecisionHold) => () => void; open: () => void; close: () => void; toggle: () => void; setMinimized: (minimized: boolean) => void; /** Opens the window on a chat with a context chip ("Ask" on a place). */ openWith: (key: AssistantChatKey, focus: AssistantFocus | null) => void; setFocus: (key: AssistantChatKey, focus: AssistantFocus | null) => void; setDraft: (key: AssistantChatKey, draft: string) => void; /** Loads the thread list and, when nothing is open yet, the latest thread. */ resume: (key: AssistantChatKey) => Promise; loadThreads: (key: AssistantChatKey) => Promise; openThread: (key: AssistantChatKey, threadId: number) => Promise; newThread: (key: AssistantChatKey) => void; deleteThread: (key: AssistantChatKey, threadId: number) => Promise; send: (key: AssistantChatKey, text: string) => Promise; /** Hands a booking file to a trip's chat, which proposes what it finds in it; does nothing under a journey key. */ attach: (key: AssistantChatKey, file: File) => Promise; decide: (key: AssistantChatKey, decisions: AssistantDecision[]) => Promise; undo: (key: AssistantChatKey, toolCallId: string) => Promise; cancel: (key: AssistantChatKey) => void; /** Sends the last question again: the refused message, or the one whose answer broke off. */ retry: (key: AssistantChatKey) => Promise; /** Stops the turn running in another window, then sends here. */ continueHere: (key: AssistantChatKey) => Promise; dismissError: (key: AssistantChatKey) => void; /** Takes back the applied changes of one block, the last one first. */ undoBlock: (key: AssistantChatKey, toolCallIds: readonly string[]) => Promise; /** Shows the plan before or after the last applied block, on the map; a journey chat has none to show. */ setCompare: (key: AssistantChatKey, mode: 'before' | 'after') => void; /** Suggests a found place for a day, held as a change to decide; skipped under a journey key. */ addFoundPlace: (key: AssistantChatKey, ref: string, dayId: number) => Promise; addMention: (key: AssistantChatKey, mention: AssistantMentionItem) => void; setMapHover: (tripId: number, hoveredId: string | null, source: AssistantMapSource) => void; requestCamera: (tripId: number, points: LatLng[], mode: 'fit' | 'pan', why: 'user' | 'auto') => void; /** Opens a found place in the planner's place form; the planner takes it with `takePlaceDraft`. */ openPlaceDraft: (tripId: number, place: AssistantFoundPlace, dayId: number | null) => void; takePlaceDraft: (seq: number) => void; setPreviewPin: (preview: AssistantState['preview']) => void; setWindowRect: (rect: WindowRect | null) => void; setMapView: (mapView: AssistantState['mapView']) => void; setRouteFocus: (routeFocus: AssistantState['routeFocus']) => void; setRoutePreview: (routePreview: AssistantState['routePreview']) => void; setRoutePeek: (routePeek: AssistantState['routePeek']) => void; /** Picks one of the ways a block of cards offers. */ pick: (key: AssistantChatKey, blockKey: string, toolCallId: string) => void; /** * Takes a proposal an answer showed as a card ("Plan this"); the server holds it as a change to decide. * Says what came of it: held, refused (the chat's error says why), or skipped while a turn ran or the * thread changed under it, and always skipped under a journey key. */ takeProposal: (key: AssistantChatKey, toolCallId: string) => Promise; } /** What came of a change the server was asked to hold without a model turn. */ export type HoldOutcome = 'held' | 'refused' | 'skipped'; /** * What the TREK Studio holding a journey's chat does for its turns: it writes * the work still sitting in its autosave before a turn or an apply goes out, * so the assistant edits the book the user sees, and it draws the pages a * turn asks to look at (journey-studio.md 3.1). A chat with no host sends text only. */ export interface AssistantStudioHost { /** Draws the page a running turn asked for and uploads it; never throws, a page it cannot send stays text. */ render: (request: AssistantPageRender, signal: AbortSignal) => void; /** Writes the local work of the book; false when it could not land (a conflict, a viewer, offline). */ flush: () => Promise; } /** * What a card sends before a decision of its chat goes out, a choice the * decision reads on the server (the pages a book design applies): it sends * what still waits and settles once that landed. A request already on its * way holds the chat by itself until it lands, past the card that sent it. */ type AssistantDecisionHold = () => Promise; /** * Asks every hold of the chat under `key` at once and settles when they did; a failed one lets the decision go on. * The holds are read once, before any is asked: one a hold adds while it sends is not asked, so a hold answers for * what it sends itself. */ async function settleHolds(ctx: Ctx, key: AssistantChatKey): Promise { const holds = ctx.get().decisionHolds[key] ?? []; await Promise.allSettled(holds.map((settle) => settle())); } /** The Studio hosting the chat under `key`, if one does. */ const studioHostOf = (ctx: Ctx, key: AssistantChatKey): AssistantStudioHost | undefined => ctx.get().studioHosts[key]; /** A turn of a hosted chat writes the book first and says the Studio draws pages; any other turn opens as it is. */ async function hostedOpen(ctx: Ctx, key: AssistantChatKey, open: (renders: { renders?: true }) => Promise) { const host = studioHostOf(ctx, key); if (!host) return open({}); try { await host.flush(); } catch { // The Studio's save indicator shows its own failure; the turn goes on against the book the server holds. } return open({ renders: true }); } const NO_LIVE: AssistantTripChat['live'] = { activity: null }; const EMPTY_CHAT: AssistantTripChat = { threadId: null, threads: null, messages: [], isRunning: false, loading: false, error: null, focus: null, draft: '', unsent: null, abort: null, live: NO_LIVE, undone: {}, compare: null, mentions: [], picks: {}, }; /** What the map shows for the cards of a chat, cleared when the chat closes or changes. */ const NO_ROUTE = { routeFocus: null, routePreview: null, routePeek: null } as const; /** A chat as the store holds it, or the empty one; stable, so a selector can return it. */ export function chatOf(state: Pick, key: AssistantChatKey): AssistantTripChat { return state.byTrip[key] ?? EMPTY_CHAT; } function httpStatus(err: unknown): number | undefined { return (err as { response?: { status?: number } } | null)?.response?.status; } const KNOWN_CODES: ReadonlySet = new Set(ASSISTANT_ERROR_CODES); /** The code a failure shows as: the one the server named in its body, else what the status says. */ function errorCodeOf(err: unknown): AssistantErrorCode { if (err instanceof AssistantRequestError) return err.code; if (err instanceof OnlineOnlyError) return 'offline'; const named = (err as { response?: { data?: { code?: unknown } } } | null)?.response?.data?.code; if (typeof named === 'string' && KNOWN_CODES.has(named)) return named as AssistantErrorCode; const status = httpStatus(err); if (status === 429) return 'too_many_requests'; if (status === 409) return 'busy'; return 'generic'; } /** The language to answer in, and the zone "today" is read in. */ function clientContext() { return { locale: useSettingsStore.getState().settings.language || 'en', timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', }; } /** Runs `fn` once in the next frame, however often it is asked for until then. */ function frameBatcher(fn: () => void) { const hasFrames = typeof requestAnimationFrame === 'function'; let handle: ReturnType | number | null = null; const fire = () => { handle = null; fn(); }; return { schedule() { if (handle === null) handle = hasFrames ? requestAnimationFrame(fire) : setTimeout(fire, 16); }, cancel() { if (handle === null) return; if (hasFrames) cancelAnimationFrame(handle as number); else clearTimeout(handle as ReturnType); handle = null; }, }; } interface Ctx { set: StoreApi['setState']; get: StoreApi['getState']; } const chat = (ctx: Ctx, tripId: number) => chatOf(ctx.get(), tripId); function patch(ctx: Ctx, tripId: number, next: Partial) { ctx.set((s) => ({ byTrip: { ...s.byTrip, [tripId]: { ...chatOf(s, tripId), ...next } } })); } /** What the store takes from a chunk beside the messages; `signal` ends with the turn. */ function onChunk(ctx: Ctx, tripId: number, chunk: UiStreamChunk, signal: AbortSignal) { if (chunk.type === 'data-trek-thread') { const { threadId, title } = chunk.data; const threads = chat(ctx, tripId).threads; const summary: AssistantThreadSummary = { id: threadId, title, lastMessageAt: new Date().toISOString() }; patch(ctx, tripId, { threadId, threads: threads ? [summary, ...threads.filter((t) => t.id !== threadId)] : null }); } else if (chunk.type === 'data-trek-quota') { const status = ctx.get().status; if (status) ctx.set({ status: { ...status, quota: chunk.data } }); } else if (chunk.type === 'data-trek-applied') { // The planner jumps to a trip's change; the Studio hears a book change through its broadcast. if (assistantScopeOf(tripId).kind === 'trip') { ctx.set({ lastApplied: { tripId, applied: chunk.data } }); noteAddedPlace(ctx, tripId, chunk.data); } // A kept note lives in the user's settings; read them again so the notes dialog shows it. if (chunk.data.entity === 'memory') void useSettingsStore.getState().loadSettings(); } else if (chunk.type === 'data-trek-activity') { patch(ctx, tripId, { live: { activity: chunk.data } }); } else if (chunk.type === 'tool-output-available' || chunk.type === 'tool-output-error') { // The step the line spoke of is done; the next one says what it does itself. if (chat(ctx, tripId).live.activity) patch(ctx, tripId, { live: NO_LIVE }); } else if (chunk.type === 'data-trek-render-request') { // The Studio holding this chat draws the page; without one the turn waits out its time and goes on as text. studioHostOf(ctx, tripId)?.render(chunk.data, signal); } } /** A place the compared block made, so "before" can leave it off the map. */ function noteAddedPlace(ctx: Ctx, tripId: number, applied: AssistantApplied) { const compare = chat(ctx, tripId).compare; const placeId = applied.ids[0]; if (!compare || placeId === undefined || !compare.newPlaceCalls.includes(applied.toolCallId)) return; patch(ctx, tripId, { compare: { ...compare, addedPlaceIds: [...compare.addedPlaceIds, placeId] } }); } interface Turn { /** The messages while the turn runs: with the new question, or with the decisions marked. */ messages: AssistantMessage[]; /** What they go back to when the server refuses before answering. */ before: AssistantMessage[]; /** The question's text, kept for retry() when the server refuses it. */ unsent: string | null; open: (signal: AbortSignal) => Promise>; } /** Opens a stream and folds it into the trip's chat until it ends. */ async function runTurn(ctx: Ctx, tripId: number, turn: Turn): Promise { const abort = new AbortController(); patch(ctx, tripId, { messages: turn.messages, isRunning: true, error: null, unsent: null, abort }); // "New chat" replaces this turn's controller; from then on the turn writes nothing. const live = () => chat(ctx, tripId).abort === abort; let current = turn.messages; let error: AssistantErrorCode | null = null; const commit = frameBatcher(() => { if (live()) patch(ctx, tripId, { messages: current }); }); try { let stream: AsyncGenerator; try { stream = await turn.open(abort.signal); } catch (err) { // A question stays on screen to be sent again; held changes go back to undecided. current = turn.unsent === null ? turn.before : turn.messages; if (!abort.signal.aborted) { error = errorCodeOf(err); if (live()) patch(ctx, tripId, { unsent: turn.unsent }); } return; } for await (const chunk of stream) { current = applyChunk(current, chunk); if (chunk.type === 'error') error = chunk.errorText; if (live()) onChunk(ctx, tripId, chunk, abort.signal); commit.schedule(); } } catch (err) { // The stream broke off: stopped here, or the connection dropped. current = applyChunk(current, { type: 'abort' }); if (!abort.signal.aborted) error = errorCodeOf(err); } finally { commit.cancel(); if (live()) patch(ctx, tripId, { messages: current, isRunning: false, error, abort: null, live: NO_LIVE }); } } /** The focus a question goes out with: the chip's, and the part of this trip's map on screen. */ function focusWithView(ctx: Ctx, tripId: number, focus: AssistantFocus | null): { focus?: AssistantFocus } { const mapView = ctx.get().mapView; const view = mapView?.tripId === tripId ? mapView.view : undefined; if (!focus && !view) return {}; return { focus: { ...(focus ?? {}), ...(view ? { view } : {}) } }; } async function sendMessage(ctx: Ctx, tripId: number, text: string): Promise { const c = chat(ctx, tripId); const trimmed = text.trim(); if (c.isRunning || !trimmed) return; const mentions = sentMentions(c.mentions, trimmed, c.focus); const parts: AssistantMessagePart[] = [{ type: 'text', text: trimmed }]; if (mentions.length > 0) parts.push({ type: 'data-trek-mentions', data: { items: mentions } }); const question: AssistantMessage = { id: `local-${randomId()}`, role: 'user', parts, status: 'complete', createdAt: new Date().toISOString(), }; const body = { ...clientContext(), threadId: c.threadId, text: trimmed, ...focusWithView(ctx, tripId, c.focus), ...(mentions.length > 0 ? { mentions: mentions.map(({ kind, id }) => ({ kind, id })) } : {}), }; // A new question looks at the plan as it is now. patch(ctx, tripId, { draft: '', mentions: [], compare: c.compare && { ...c.compare, mode: 'after' } }); await runTurn(ctx, tripId, { messages: [...c.messages, question], before: c.messages, unsent: trimmed, open: (signal) => hostedOpen(ctx, tripId, (renders) => assistantRepo.send(assistantTargetOf(tripId), { ...body, ...renders }, signal) ), }); } async function attachFile(ctx: Ctx, tripId: number, file: File): Promise { const c = chat(ctx, tripId); // Files are read into a trip; a journey's chat has no route for them. if (c.isRunning || assistantScopeOf(tripId).kind !== 'trip') return; const question: AssistantMessage = { id: `local-${randomId()}`, role: 'user', parts: [{ type: 'attachment', name: file.name, mime: file.type || 'application/octet-stream', size: file.size }], status: 'complete', createdAt: new Date().toISOString(), }; const fields = { ...clientContext(), threadId: c.threadId }; await runTurn(ctx, tripId, { messages: [...c.messages, question], before: c.messages, unsent: null, open: (signal) => assistantRepo.attach(tripId, file, fields, signal), }); } /** Mentions the server takes at most per message. */ const MAX_MENTIONS = 10; const MAX_MENTION_LABEL = 120; /** * The mentions a question carries: those whose `@label` is still in its text * (the text is the truth; deleting the words drops the mention), without the * day or places the focus already names. */ function sentMentions( mentions: readonly AssistantMentionItem[], text: string, focus: AssistantFocus | null ): AssistantMentionItem[] { return mentions .filter((m) => text.includes(`@${m.label}`)) .filter((m) => !(m.kind === 'day' && focus?.dayId === m.id)) .filter((m) => !(m.kind === 'place' && focus?.placeIds?.includes(m.id))) .slice(0, MAX_MENTIONS) .map((m) => ({ kind: m.kind, id: m.id, label: m.label.slice(0, MAX_MENTION_LABEL) })); } /** The text of the latest question in the chat. */ function lastQuestion(messages: readonly AssistantMessage[]): string | null { for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i]; if (m?.role !== 'user') continue; const part = m.parts.find((p) => p.type === 'text'); return part?.type === 'text' ? part.text : null; } return null; } /** Every trip's comparison back on "after". */ function backToAfter(byTrip: Record): Record { const next: Record = {}; for (const [id, c] of Object.entries(byTrip)) { next[Number(id)] = c.compare ? { ...c, compare: { ...c.compare, mode: 'after' } } : c; } return next; } function shellActions(ctx: Ctx) { return { loadStatus: async () => { try { ctx.set({ status: await aiConnectionsRepo.status() }); } catch (err) { // Offline keeps the last answer; any other failure hides the assistant (fail closed). if (!(err instanceof OnlineOnlyError)) ctx.set({ status: null }); } }, ackPrivacy: async () => { const effective = ctx.get().status?.effective; if (!effective) return; await aiConnectionsRepo.ackPrivacy({ kind: effective.kind, host: effective.host }); const status = ctx.get().status; if (status) ctx.set({ status: { ...status, privacyAcked: true } }); }, ackStudioImages: async () => { const effective = ctx.get().status?.effective; if (!effective) return; await aiConnectionsRepo.ackPrivacy({ kind: effective.kind, host: effective.host, scope: 'studio_images' }); const status = ctx.get().status; if (status) ctx.set({ status: { ...status, studioImagesAcked: true } }); }, hostStudio: (key: AssistantChatKey, host: AssistantStudioHost) => { ctx.set((s) => ({ studioHosts: { ...s.studioHosts, [key]: host } })); return () => { if (ctx.get().studioHosts[key] !== host) return; ctx.set((s) => ({ studioHosts: Object.fromEntries(Object.entries(s.studioHosts).filter(([k]) => Number(k) !== key)), })); }; }, holdDecisions: (key: AssistantChatKey, settle: AssistantDecisionHold) => { ctx.set((s) => ({ decisionHolds: { ...s.decisionHolds, [key]: [...(s.decisionHolds[key] ?? []), settle] } })); return () => ctx.set((s) => ({ decisionHolds: { ...s.decisionHolds, [key]: (s.decisionHolds[key] ?? []).filter((held) => held !== settle) }, })); }, open: () => ctx.set({ window: { open: true, minimized: false } }), // Closing leaves the map as the plan is now: "after", nothing hovered, no preview pin. close: () => ctx.set((s) => ({ window: { open: false, minimized: false }, mapLink: null, preview: null, ...NO_ROUTE, byTrip: backToAfter(s.byTrip), })), // Closed or minimized opens it; open closes it. toggle: () => ctx.set((s) => ({ window: { open: !s.window.open || s.window.minimized, minimized: false } })), setMinimized: (minimized: boolean) => ctx.set((s) => ({ window: { ...s.window, minimized } })), openWith: (tripId: number, focus: AssistantFocus | null) => { patch(ctx, tripId, { focus }); ctx.set({ window: { open: true, minimized: false } }); }, setFocus: (tripId: number, focus: AssistantFocus | null) => patch(ctx, tripId, { focus }), setDraft: (tripId: number, draft: string) => patch(ctx, tripId, { draft }), }; } function threadActions(ctx: Ctx) { const loadThreads = async (tripId: number) => { try { patch(ctx, tripId, { threads: await assistantRepo.threads(assistantTargetOf(tripId)) }); } catch (err) { patch(ctx, tripId, { error: errorCodeOf(err) }); } }; const openThread = async (tripId: number, threadId: number) => { if (chat(ctx, tripId).isRunning) return; ctx.set(NO_ROUTE); patch(ctx, tripId, { threadId, loading: true, error: null, unsent: null }); try { const { messages } = await assistantRepo.thread(assistantTargetOf(tripId), threadId); // Another thread may have been opened while this one loaded. if (chat(ctx, tripId).threadId === threadId) patch(ctx, tripId, { messages, loading: false }); } catch (err) { if (httpStatus(err) !== 404) { patch(ctx, tripId, { loading: false, error: errorCodeOf(err) }); return; } // Deleted elsewhere: start fresh and forget it. const threads = chat(ctx, tripId).threads; patch(ctx, tripId, { threadId: null, messages: [], loading: false, threads: threads ? threads.filter((t) => t.id !== threadId) : null, }); } }; const newThread = (tripId: number) => { chat(ctx, tripId).abort?.abort(); ctx.set(NO_ROUTE); // The block a comparison belonged to leaves the screen with its thread. patch(ctx, tripId, { threadId: null, messages: [], isRunning: false, error: null, unsent: null, abort: null, live: NO_LIVE, compare: null, }); }; return { loadThreads, openThread, newThread, resume: async (tripId: number) => { await loadThreads(tripId); const c = chat(ctx, tripId); const latest = c.threads?.[0]; if (latest && c.threadId === null && c.messages.length === 0 && !c.isRunning) await openThread(tripId, latest.id); }, deleteThread: async (tripId: number, threadId: number) => { await assistantRepo.deleteThread(assistantTargetOf(tripId), threadId); const c = chat(ctx, tripId); patch(ctx, tripId, { threads: c.threads ? c.threads.filter((t) => t.id !== threadId) : null }); if (c.threadId === threadId) newThread(tripId); }, }; } async function undoOne(ctx: Ctx, tripId: number, toolCallId: string): Promise { let outcome: UndoOutcome; try { await assistantRepo.undo(assistantTargetOf(tripId), toolCallId); outcome = 'undone'; } catch (err) { const status = httpStatus(err); // Changed since, or the window closed: the button goes, the change stays. outcome = status === 409 || status === 410 ? 'stale' : 'failed'; } if (outcome === 'failed') return outcome; const c = chat(ctx, tripId); patch(ctx, tripId, { messages: closeUndo(c.messages, toolCallId), ...(outcome === 'undone' ? { undone: { ...c.undone, [toolCallId]: true as const } } : {}), }); return outcome; } /** * The comparison a decision sets up: the plan of every day the applied * changes touch, taken from the trip store just before they run. Only while * the planner holds this very trip, since the store holds one trip at a time. */ function compareFor( tripId: number, messages: readonly AssistantMessage[], decisions: readonly AssistantDecision[] ): AssistantCompare | null { const trip = useTripStore.getState(); if (trip.trip?.id !== tripId) return null; const effects = changeEffects(messages); const applied = decisions.filter((d) => d.approved).map((d) => d.toolCallId); const touched = applied.flatMap((id) => { const effect = effects.get(id); return effect ? [effect] : []; }); if (touched.length === 0) return null; return { blockKey: blockKeyOf(decisions.map((d) => d.toolCallId)), mode: 'after', snapshot: snapshotDays(trip.assignments, blockDays(touched)), newPlaceCalls: applied.filter((id) => effects.get(id)?.place != null), addedPlaceIds: [], }; } function compareActions(ctx: Ctx) { return { undoBlock: async (tripId: number, toolCallIds: readonly string[]): Promise => { const result: UndoBlockResult = { done: 0, stale: 0, failed: 0 }; // One after the other and the last first: a later change may stand on an // earlier one (a stop moved onto a day the block had just added it to). // A refusal stops the run, so no earlier change falls while a later one // that stands on it is still in place. for (const toolCallId of [...toolCallIds].reverse()) { const outcome = await undoOne(ctx, tripId, toolCallId); if (outcome === 'undone') result.done += 1; else if (outcome === 'stale') result.stale += 1; else { result.failed += 1; break; } } // The before/after switch stays while any of the block is still applied. if (result.failed === 0 && result.stale === 0) patch(ctx, tripId, { compare: null }); return result; }, setCompare: (tripId: number, mode: 'before' | 'after') => { const compare = chat(ctx, tripId).compare; if (!compare || compare.mode === mode) return; patch(ctx, tripId, { compare: { ...compare, mode } }); // Switching is asking to see it: frame the block's days on both sides. const days = Object.keys(compare.snapshot).map(Number); const points = [...dayPoints(compare.snapshot, days), ...dayPoints(useTripStore.getState().assignments, days)]; requestCameraOn(ctx, tripId, points, 'fit', 'user'); }, addFoundPlace: async (tripId: number, ref: string, dayId: number) => { await holdWithoutTurn(ctx, tripId, (threadId) => assistantRepo.addFoundPlace(tripId, threadId, { ...clientContext(), ref, dayId }) ); }, takeProposal: (tripId: number, toolCallId: string) => holdWithoutTurn(ctx, tripId, (threadId) => assistantRepo.takeProposal(tripId, threadId, { ...clientContext(), toolCallId }) ), }; } /** * A change the server holds without a model turn (a found place for a day, a * proposal a card showed), joined to the answer it belongs to. The server * sends back that whole answer under its id, so it replaces the message the * chat already holds. */ async function holdWithoutTurn( ctx: Ctx, tripId: number, hold: (threadId: number) => Promise<{ message: AssistantMessage }> ): Promise { const threadId = chat(ctx, tripId).threadId; // Found places and proposals are held for a trip's days; a journey's chat has neither. if (assistantScopeOf(tripId).kind !== 'trip') return 'skipped'; if (chat(ctx, tripId).isRunning || threadId === null) return 'skipped'; try { const { message } = await hold(threadId); const c = chat(ctx, tripId); // The thread may have changed while the server answered. if (c.threadId !== threadId) return 'skipped'; const known = c.messages.some((m) => m.id === message.id); const messages = known ? c.messages.map((m) => (m.id === message.id ? message : m)) : [...c.messages, message]; patch(ctx, tripId, { messages, error: null }); return 'held'; } catch (err) { patch(ctx, tripId, { error: errorCodeOf(err) }); return 'refused'; } } function requestCameraOn(ctx: Ctx, tripId: number, points: LatLng[], mode: 'fit' | 'pan', why: 'user' | 'auto') { if (points.length === 0) return; const seq = (ctx.get().camera?.seq ?? 0) + 1; ctx.set({ camera: { tripId, seq, points, mode, why, at: Date.now() } }); } function mapActions(ctx: Ctx) { return { addMention: (tripId: number, mention: AssistantMentionItem) => { const mentions = chat(ctx, tripId).mentions; if (mentions.some((m) => m.kind === mention.kind && m.id === mention.id)) return; patch(ctx, tripId, { mentions: [...mentions, mention] }); }, setMapHover: (tripId: number, hoveredId: string | null, source: AssistantMapSource) => { const link = ctx.get().mapLink; // Pointer moves repeat the same hover; only a change reaches the subscribers. if (link?.tripId === tripId && link.hoveredId === hoveredId && link.source === source) return; ctx.set({ mapLink: { tripId, hoveredId, source } }); }, requestCamera: (tripId: number, points: LatLng[], mode: 'fit' | 'pan', why: 'user' | 'auto') => requestCameraOn(ctx, tripId, points, mode, why), openPlaceDraft: (tripId: number, place: AssistantFoundPlace, dayId: number | null) => ctx.set({ placeDraft: { tripId, seq: (ctx.get().placeDraft?.seq ?? 0) + 1, place, dayId } }), takePlaceDraft: (seq: number) => { if (ctx.get().placeDraft?.seq === seq) ctx.set({ placeDraft: null }); }, setPreviewPin: (preview: AssistantState['preview']) => ctx.set({ preview }), setWindowRect: (rect: WindowRect | null) => ctx.set({ windowRect: rect }), setMapView: (mapView: AssistantState['mapView']) => ctx.set({ mapView }), }; } /** Whether two route focuses point at the same spot of the same route from the same side. */ function sameFocus(a: AssistantState['routeFocus'], b: AssistantState['routeFocus']): boolean { if (a === null || b === null) return a === b; return ( a.tripId === b.tripId && a.ref === b.ref && a.source === b.source && a.focus.sampleIndex === b.focus.sampleIndex && a.focus.distanceMeters === b.focus.distanceMeters ); } function routeActions(ctx: Ctx) { return { // Scrubbing repeats the same spot; only a change reaches the subscribers. setRouteFocus: (routeFocus: AssistantState['routeFocus']) => { if (!sameFocus(ctx.get().routeFocus, routeFocus)) ctx.set({ routeFocus }); }, setRoutePreview: (routePreview: AssistantState['routePreview']) => { const current = ctx.get().routePreview; if (current?.tripId === routePreview?.tripId && current?.ref === routePreview?.ref) return; ctx.set({ routePreview }); }, setRoutePeek: (routePeek: AssistantState['routePeek']) => ctx.set({ routePeek }), pick: (tripId: number, blockKey: string, toolCallId: string) => patch(ctx, tripId, { picks: { ...chat(ctx, tripId).picks, [blockKey]: toolCallId } }), }; } function turnActions(ctx: Ctx) { const retry = async (tripId: number) => { const c = chat(ctx, tripId); if (c.isRunning) return; if (c.unsent === null) { // The server has the question but the answer broke off: ask it again. const text = lastQuestion(c.messages); if (text) await sendMessage(ctx, tripId, text); return; } // The local copy of the refused question goes; sending puts it back. const last = c.messages[c.messages.length - 1]; const refused = last?.role === 'user' && last.id.startsWith('local-'); patch(ctx, tripId, { messages: refused ? c.messages.slice(0, -1) : c.messages, unsent: null, error: null }); await sendMessage(ctx, tripId, c.unsent); }; return { send: (tripId: number, text: string) => sendMessage(ctx, tripId, text), attach: (tripId: number, file: File) => attachFile(ctx, tripId, file), decide: async (tripId: number, decisions: AssistantDecision[]) => { const c = chat(ctx, tripId); const threadId = c.threadId; if (c.isRunning || threadId === null || decisions.length === 0) return; // Asked before anything re-renders: a card's choice still on its way lands first, so the decision reads it. const settled = settleHolds(ctx, tripId); const body = { ...clientContext(), decisions }; patch(ctx, tripId, { compare: compareFor(tripId, c.messages, decisions) }); await runTurn(ctx, tripId, { messages: markDecisions(c.messages, decisions), before: c.messages, unsent: null, open: async (signal) => { await settled; return hostedOpen(ctx, tripId, (renders) => assistantRepo.decide(assistantTargetOf(tripId), threadId, { ...body, ...renders }, signal) ); }, }); // Refused before anything ran: there is no "after" to compare with. if (chat(ctx, tripId).error) patch(ctx, tripId, { compare: null }); }, undo: (tripId: number, toolCallId: string) => undoOne(ctx, tripId, toolCallId), cancel: (tripId: number) => chat(ctx, tripId).abort?.abort(), retry, continueHere: async (tripId: number) => { try { await assistantRepo.stop(assistantTargetOf(tripId)); } catch (err) { patch(ctx, tripId, { error: errorCodeOf(err) }); return; } await retry(tripId); }, dismissError: (tripId: number) => patch(ctx, tripId, { error: null }), }; } export const useAssistantStore = create((set, get) => { const ctx: Ctx = { set, get }; return { status: null, window: { open: false, minimized: false }, byTrip: {}, lastApplied: null, mapLink: null, camera: null, preview: null, placeDraft: null, windowRect: null, mapView: null, studioHosts: {}, decisionHolds: {}, ...NO_ROUTE, ...shellActions(ctx), ...threadActions(ctx), ...turnActions(ctx), ...compareActions(ctx), ...mapActions(ctx), ...routeActions(ctx), }; });