feat(assistant): trip assistant chat with your own AI connection

Adds an in-app assistant that reads and plans the open trip. Users connect
their own provider (Sign in with ChatGPT, or an Anthropic, OpenAI, Gemini or
OpenRouter key) or use the one the admin set up, including a custom
OpenAI-compatible server. It is an addon and only shows while a connection
is usable.

The chat runs on the existing MCP tools, pinned to the trip. Every change is
held as a change card the user applies or discards, and applied changes can
be undone. On the desktop it is a floating window that can be moved and
resized; on the phone it is fullscreen. Search results show as place cards
that can go onto a day, weather lookups as a strip, proposed places as
ghost markers on the map with a before/after switch, and an answer can be
shared into the trip chat. Booking files can be dropped in and are read
into change cards.
This commit is contained in:
Maurice
2026-10-21 18:52:44 +02:00
parent 31859651d7
commit b64da26651
505 changed files with 51841 additions and 227 deletions
+111
View File
@@ -0,0 +1,111 @@
import { UI_STREAM_DONE, uiStreamChunkSchema, type UiStreamChunk } from '@trek/shared';
/**
* Reads the assistant's server-sent events into typed chunks.
*
* The wire format is the `@trek/shared` stream contract: one `data: <json>`
* event per chunk, closed by `data: [DONE]`. Our own parser rather than the
* `assistant-stream` one, because that one drops `tool-approval-request`, the
* chunk a held change hangs on, and it would pull the chat runtime into every
* chunk that only needs the data layer.
*
* What it tolerates, because proxies and the network do these things: events
* split across reads at any byte, CRLF line ends, `:` keep-alive comments,
* and fields other than `data`. A chunk that does not match the contract,
* a type this client does not know among them, is skipped, so the server can
* add chunk types without breaking an older tab. An event longer than
* MAX_EVENT_CHARS ends the stream with an error instead of growing the buffer
* without bound.
*
* The server always closes with `[DONE]` and sends a keep-alive every 15
* seconds, so a stream that ends without the marker, or stays silent for
* STALL_MS, lost its server (a restart, a dropped proxy) and fails instead of
* leaving the chat waiting for an answer that will not come.
*/
export const MAX_EVENT_CHARS = 1024 * 1024;
export const STALL_MS = 45_000;
/** The next read, or a failure once nothing came for STALL_MS. */
async function readOrStall(reader: ReadableStreamDefaultReader<Uint8Array>) {
let timer: ReturnType<typeof setTimeout> | undefined;
const stalled = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('Assistant stream stalled')), STALL_MS);
});
try {
return await Promise.race([reader.read(), stalled]);
} finally {
clearTimeout(timer);
}
}
export async function* parseUiMessageStream(body: ReadableStream<Uint8Array>): AsyncGenerator<UiStreamChunk> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let finished = false;
try {
for (;;) {
const { done, value } = await readOrStall(reader);
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
// A CR at the very end may be the first half of a CRLF; it waits for the next read.
const carry = !done && buffer.endsWith('\r') ? '\r' : '';
if (carry) buffer = buffer.slice(0, -1);
buffer = buffer.replace(/\r\n?/g, '\n');
let boundary = buffer.indexOf('\n\n');
while (boundary >= 0) {
const data = dataOf(buffer.slice(0, boundary));
buffer = buffer.slice(boundary + 2);
if (data === UI_STREAM_DONE) {
finished = true;
return;
}
const chunk = data === null ? null : chunkOf(data);
if (chunk) yield chunk;
boundary = buffer.indexOf('\n\n');
}
buffer += carry;
if (buffer.length > MAX_EVENT_CHARS) throw new Error('Assistant stream event too large');
// The connection closed before [DONE]: the server went away mid-answer.
if (done) {
finished = true;
throw new Error('Assistant stream broke off');
}
}
} finally {
// Leaving early (the consumer stopped, or an error) releases the connection.
if (!finished) {
try {
await reader.cancel();
} catch {
// The stream already failed; the error that brought us here is the one that counts.
}
}
}
}
/** The joined `data` lines of one event, or null for an event without any (a comment). */
function dataOf(event: string): string | null {
const lines: string[] = [];
for (const line of event.split('\n')) {
if (line === 'data' || line.startsWith('data:')) {
const value = line.slice(5);
lines.push(value.startsWith(' ') ? value.slice(1) : value);
}
}
return lines.length ? lines.join('\n') : null;
}
function chunkOf(data: string): UiStreamChunk | null {
let json: unknown;
try {
json = JSON.parse(data);
} catch {
// A torn or foreign event; the next one is still worth reading.
return null;
}
const parsed = uiStreamChunkSchema.safeParse(json);
return parsed.success ? parsed.data : null;
}