Files
TREK/client/src/store/assistantStore.ts
T
Maurice d24db65cb8 fix(assistant): green CI round, trip notes the assistant can keep, calmer change cards
- the assistant keeps facts for a trip (remember_for_trip) as a change card,
  applied into the user's trip notes; existing expenses can be changed
- change card fields as a few icon chips with the rest folded under Details,
  the day band marks only what changes, the week grid as compact tiles
- no map button on receipts of things that have no place on the map
- tests follow the composer's accessible name, the receipt's status icon and
  the GL map's container; the hanging stream helper honours an early abort
- Postgres probe: qualified columns in the upsert updates; entities restored
  to the generator's output; strict ratchet reads the SDK client as untyped
2026-10-22 21:30:26 +00:00

738 lines
30 KiB
TypeScript

import {
ASSISTANT_ERROR_CODES,
type AiStatusResponse,
type AssistantActivity,
type AssistantApplied,
type AssistantDecision,
type AssistantErrorCode,
type AssistantFocus,
type AssistantMapView,
type AssistantMention,
type AssistantMessage,
type AssistantMessagePart,
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 {
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 trip assistant: whether it can run, whether its window is open, and one
* conversation per trip.
*
* 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<string, true>;
/** "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[];
}
/** 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<number, Assignment[]>;
/** 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 };
byTrip: Record<number, AssistantTripChat>;
/** The change applied last, 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' } | null;
/** A found place the pointer rests on, drawn on the map until it leaves. */
preview: { tripId: number; ref: string; lat: number; lng: number; name: 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;
loadStatus: () => Promise<void>;
ackPrivacy: () => Promise<void>;
open: () => void;
close: () => void;
toggle: () => void;
setMinimized: (minimized: boolean) => void;
/** Opens the window on a trip with a context chip ("Ask" on a place). */
openWith: (tripId: number, focus: AssistantFocus | null) => void;
setFocus: (tripId: number, focus: AssistantFocus | null) => void;
setDraft: (tripId: number, draft: string) => void;
/** Loads the thread list and, when nothing is open yet, the latest thread. */
resume: (tripId: number) => Promise<void>;
loadThreads: (tripId: number) => Promise<void>;
openThread: (tripId: number, threadId: number) => Promise<void>;
newThread: (tripId: number) => void;
deleteThread: (tripId: number, threadId: number) => Promise<void>;
send: (tripId: number, text: string) => Promise<void>;
/** Hands a booking file to the chat, which proposes what it finds in it. */
attach: (tripId: number, file: File) => Promise<void>;
decide: (tripId: number, decisions: AssistantDecision[]) => Promise<void>;
undo: (tripId: number, toolCallId: string) => Promise<UndoOutcome>;
cancel: (tripId: number) => void;
/** Sends the last question again: the refused message, or the one whose answer broke off. */
retry: (tripId: number) => Promise<void>;
/** Stops the turn running in another window, then sends here. */
continueHere: (tripId: number) => Promise<void>;
dismissError: (tripId: number) => void;
/** Takes back the applied changes of one block, the last one first. */
undoBlock: (tripId: number, toolCallIds: readonly string[]) => Promise<UndoBlockResult>;
/** Shows the plan before or after the last applied block, on the map. */
setCompare: (tripId: number, mode: 'before' | 'after') => void;
/** Suggests a place a search found for a day; the server holds it as a change to decide like any other. */
addFoundPlace: (tripId: number, ref: string, dayId: number) => Promise<void>;
addMention: (tripId: number, mention: AssistantMentionItem) => void;
setMapHover: (tripId: number, hoveredId: string | null, source: AssistantMapSource) => void;
requestCamera: (tripId: number, points: LatLng[], mode: 'fit' | 'pan', why: 'user' | 'auto') => void;
setPreviewPin: (preview: AssistantState['preview']) => void;
setWindowRect: (rect: WindowRect | null) => void;
setMapView: (mapView: AssistantState['mapView']) => void;
}
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: [],
};
/** A trip's chat as the store holds it, or the empty one; stable, so a selector can return it. */
export function chatOf(state: Pick<AssistantState, 'byTrip'>, tripId: number): AssistantTripChat {
return state.byTrip[tripId] ?? EMPTY_CHAT;
}
function httpStatus(err: unknown): number | undefined {
return (err as { response?: { status?: number } } | null)?.response?.status;
}
const KNOWN_CODES: ReadonlySet<string> = 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<typeof setTimeout> | 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<typeof setTimeout>);
handle = null;
},
};
}
interface Ctx {
set: StoreApi<AssistantState>['setState'];
get: StoreApi<AssistantState>['getState'];
}
const chat = (ctx: Ctx, tripId: number) => chatOf(ctx.get(), tripId);
function patch(ctx: Ctx, tripId: number, next: Partial<AssistantTripChat>) {
ctx.set((s) => ({ byTrip: { ...s.byTrip, [tripId]: { ...chatOf(s, tripId), ...next } } }));
}
/** What the store takes from a chunk beside the messages. */
function onChunk(ctx: Ctx, tripId: number, chunk: UiStreamChunk) {
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') {
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 });
}
}
/** 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<AsyncGenerator<UiStreamChunk>>;
}
/** Opens a stream and folds it into the trip's chat until it ends. */
async function runTurn(ctx: Ctx, tripId: number, turn: Turn): Promise<void> {
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<UiStreamChunk>;
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);
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<void> {
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) => assistantRepo.send(tripId, body, signal),
});
}
async function attachFile(ctx: Ctx, tripId: number, file: File): Promise<void> {
const c = chat(ctx, tripId);
if (c.isRunning) 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<number, AssistantTripChat>): Record<number, AssistantTripChat> {
const next: Record<number, AssistantTripChat> = {};
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 } });
},
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,
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(tripId) });
} catch (err) {
patch(ctx, tripId, { error: errorCodeOf(err) });
}
};
const openThread = async (tripId: number, threadId: number) => {
if (chat(ctx, tripId).isRunning) return;
patch(ctx, tripId, { threadId, loading: true, error: null, unsent: null });
try {
const { messages } = await assistantRepo.thread(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();
// 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(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<UndoOutcome> {
let outcome: UndoOutcome;
try {
await assistantRepo.undo(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<UndoBlockResult> => {
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) => {
const threadId = chat(ctx, tripId).threadId;
if (chat(ctx, tripId).isRunning || threadId === null) return;
try {
const { message } = await assistantRepo.addFoundPlace(tripId, threadId, { ...clientContext(), ref, dayId });
const c = chat(ctx, tripId);
// The thread may have changed while the server answered. The server sends back the whole answer the
// card joined, under that answer's id, so it replaces the message it already holds.
if (c.threadId !== threadId) return;
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 });
} catch (err) {
patch(ctx, tripId, { error: errorCodeOf(err) });
}
},
};
}
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 } });
}
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),
setPreviewPin: (preview: AssistantState['preview']) => ctx.set({ preview }),
setWindowRect: (rect: WindowRect | null) => ctx.set({ windowRect: rect }),
setMapView: (mapView: AssistantState['mapView']) => ctx.set({ mapView }),
};
}
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;
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: (signal) => assistantRepo.decide(tripId, threadId, body, 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(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<AssistantState>((set, get) => {
const ctx: Ctx = { set, get };
return {
status: null,
window: { open: false, minimized: false },
byTrip: {},
lastApplied: null,
mapLink: null,
camera: null,
preview: null,
windowRect: null,
mapView: null,
...shellActions(ctx),
...threadActions(ctx),
...turnActions(ctx),
...compareActions(ctx),
...mapActions(ctx),
};
});