import { ASSISTANT_ERROR_CODES, type AiAdminConnectionResponse, type AiAdminSettings, type AiApiKeyConnectRequest, type AiAuthStartResponse, type AiChatgptFinishRequest, type AiClaudeConnectorResponse, type AiConnectionScope, type AiConnectionView, type AiInstanceConnectionRequest, type AiModelListResponse, type AiOpenrouterFinishRequest, type AiPrivacyAckRequest, type AiStatusResponse, type AssistantAddFoundPlaceRequest, type AssistantAddFoundPlaceResponse, type AssistantChatRequest, type AssistantDecisionsRequest, type AssistantErrorCode, type AssistantShareRequest, type AssistantThreadListResponse, type AssistantThreadResponse, type AssistantUndoResponse, type CollabMessageResponse, type SuccessResponse, type UiStreamChunk, } from '@trek/shared'; import apiClient from './client'; import { parseUiMessageStream } from './uiMessageStream'; import { getSocketId } from './websocket'; /** * The trip assistant and the AI connections behind it, in their own file * rather than in the `client.ts` god module. * * The two streaming calls (a new message, and the decisions on held changes) * go through `fetch`, not axios: the answer is a server-sent event stream that * runs for as long as the model works, and axios would buffer it whole behind * its 8 s timeout. Everything else is plain JSON through `apiClient`. */ /** A refusal the server answered before the stream opened, named by its `AssistantErrorCode`. */ export class AssistantRequestError extends Error { readonly code: AssistantErrorCode; readonly status: number; constructor(code: AssistantErrorCode, status: number) { super(`Assistant request refused: ${code}`); this.name = 'AssistantRequestError'; this.code = code; this.status = status; } } const KNOWN_CODES: ReadonlySet = new Set(ASSISTANT_ERROR_CODES); /** * The code of a refusal: the body's `code` when it names one, otherwise what * the status alone can say. */ async function refusalCode(res: Response): Promise { try { const body: unknown = await res.json(); const code = (body as { code?: unknown } | null)?.code; if (typeof code === 'string' && KNOWN_CODES.has(code)) return code as AssistantErrorCode; } catch { // Not JSON (a proxy's error page); the status below still says something. } // A bare 429 is TREK's own limit or a proxy's; a provider's limit always names its code. if (res.status === 429) return 'too_many_requests'; if (res.status === 409) return 'busy'; if (res.status === 502 || res.status === 503 || res.status === 504) return 'provider_unavailable'; return 'generic'; } async function openStream(path: string, body: unknown, signal: AbortSignal): Promise> { // A file goes as multipart, where the browser sets the content type with its boundary. const form = body instanceof FormData; const headers: Record = form ? { accept: 'text/event-stream' } : { 'content-type': 'application/json', accept: 'text/event-stream' }; const socketId = getSocketId(); if (socketId) headers['X-Socket-Id'] = socketId; const res = await fetch(`/api${path}`, { method: 'POST', credentials: 'include', headers, body: form ? body : JSON.stringify(body), signal, }); if (!res.ok) throw new AssistantRequestError(await refusalCode(res), res.status); if (!res.body) throw new AssistantRequestError('generic', res.status); return parseUiMessageStream(res.body); } const tripBase = (tripId: number) => `/trips/${tripId}/assistant`; /** * For the calls that make the server go and ask the provider (listing models, * testing a key, exchanging a sign-in code): the shared 8 s is TREK's own * budget and too short for a provider's. */ const viaProvider = { timeout: 30_000 }; export const assistantApi = { threads: (tripId: number): Promise => apiClient.get(`${tripBase(tripId)}/threads`).then((r) => r.data), thread: (tripId: number, threadId: number): Promise => apiClient.get(`${tripBase(tripId)}/threads/${threadId}`).then((r) => r.data), deleteThread: (tripId: number, threadId: number): Promise => apiClient.delete(`${tripBase(tripId)}/threads/${threadId}`).then((r) => r.data), undo: (tripId: number, toolCallId: string): Promise => apiClient.post(`${tripBase(tripId)}/tool-calls/${encodeURIComponent(toolCallId)}/undo`, {}).then((r) => r.data), // Ends this user's running turn, wherever it runs (another tab included). stop: (tripId: number): Promise => apiClient.post(`${tripBase(tripId)}/stop`, {}).then((r) => r.data), // A place a search found, suggested for a day: answers with the message holding the change. addFoundPlace: ( tripId: number, threadId: number, body: AssistantAddFoundPlaceRequest ): Promise => apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places`, body).then((r) => r.data), // An answer posted to the trip chat, marked as shared from the assistant; the client's socket id skips the echo. share: ( tripId: number, threadId: number, messageId: string, body: AssistantShareRequest ): Promise => apiClient .post(`${tripBase(tripId)}/threads/${threadId}/messages/${encodeURIComponent(messageId)}/share`, body) .then((r) => r.data), chat: (tripId: number, body: AssistantChatRequest, signal: AbortSignal) => openStream(`${tripBase(tripId)}/chat`, body, signal), decide: (tripId: number, threadId: number, body: AssistantDecisionsRequest, signal: AbortSignal) => openStream(`${tripBase(tripId)}/threads/${threadId}/decisions`, body, signal), // A booking file: the server reads it and proposes each booking it finds as a change. attach: (tripId: number, form: FormData, signal: AbortSignal) => openStream(`${tripBase(tripId)}/attachments`, form, signal), }; /** A user's own connection, and what the assistant runs on for them. */ export const aiApi = { status: (): Promise => apiClient.get('/ai/status').then((r) => r.data), models: (): Promise => apiClient.get('/ai/models', viaProvider).then((r) => r.data), // Saving tests the key against the provider first. connectKey: (body: AiApiKeyConnectRequest): Promise => apiClient.put('/ai/connection', body, viaProvider).then((r) => r.data), setModel: (model: string): Promise => apiClient.patch('/ai/connection/model', { model }).then((r) => r.data), disconnect: (): Promise => apiClient.delete('/ai/connection').then((r) => r.data), ackPrivacy: (body: AiPrivacyAckRequest): Promise => apiClient.post('/ai/privacy-ack', body).then((r) => r.data), chatgptStart: (scope: AiConnectionScope = 'user'): Promise => apiClient .post(scope === 'instance' ? '/admin/ai/connect/chatgpt/start' : '/ai/connect/chatgpt/start', {}) .then((r) => r.data), chatgptFinish: (body: AiChatgptFinishRequest): Promise => apiClient.post('/ai/connect/chatgpt/finish', body, viaProvider).then((r) => r.data), chatgptSignOut: (): Promise => apiClient.post('/ai/connect/chatgpt/sign-out', {}).then((r) => r.data), openrouterStart: (scope: AiConnectionScope): Promise => apiClient .post(scope === 'instance' ? '/admin/ai/connect/openrouter/start' : '/ai/connect/openrouter/start', { scope }) .then((r) => r.data), openrouterFinish: (body: AiOpenrouterFinishRequest): Promise => apiClient.post('/ai/connect/openrouter/finish', body, viaProvider).then((r) => r.data), }; /** The connection the instance provides for everyone, and the assistant's admin settings. */ export const aiAdminApi = { settings: (): Promise => apiClient.get('/admin/ai/settings').then((r) => r.data), saveSettings: (body: AiAdminSettings): Promise => apiClient.put('/admin/ai/settings', body).then((r) => r.data), connection: (): Promise => apiClient.get('/admin/ai/connection').then((r) => r.data), saveConnection: (body: AiInstanceConnectionRequest): Promise => apiClient.put('/admin/ai/connection', body, viaProvider).then((r) => r.data), removeConnection: (): Promise => apiClient.delete('/admin/ai/connection').then((r) => r.data), models: (): Promise => apiClient.get('/admin/ai/models', viaProvider).then((r) => r.data), }; export const claudeConnectorApi = { get: (): Promise => apiClient.get('/integrations/claude-connector').then((r) => r.data), };