mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
d24db65cb8
- 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
738 lines
30 KiB
TypeScript
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),
|
|
};
|
|
});
|