"use client"; /** * TASK-0122 — Ask AI client. * * Two surfaces: * - `aiApi` — plain JSON REST (conversations CRUD, messages list). Mirrors * the `searchApi` / `notificationApi` shape (apiFetch + ApiError). * - `sendAiMessage` — the SSE stream (the one fetch in this file that does * NOT use apiFetch, because the response is `text/event-stream`, not * JSON). It parses the documented frame contract (see docs/api/ai.md): * `data: {…}\n\n` frames of type delta / tool_call / usage / error / done, * plus `: keepalive` comment lines. Pre-stream failures (404/402/429) * arrive as a normal JSON error body BEFORE any SSE byte → surfaced as an * `AiPreStreamError` (the caller renders the same banner). */ import { ApiError, apiFetch, buildQuery } from "./api"; /* --------------------------------------------------------------- types */ export interface AiConversationSummary { id: string; title: string | null; context?: { pageId?: string; blockId?: string; databaseId?: string } | null; createdAt: string; updatedAt: string; messageCount: number; } export interface AiMessage { id: string; role: "user" | "assistant"; content: string; model?: string | null; tokensIn?: number | null; tokensOut?: number | null; createdAt: string; metadata?: { feature?: string; truncated?: boolean; error?: string } | null; } export interface AiContextRef { pageId?: string; blockId?: string; databaseId?: string; /** * Display-only label (the page/database title) for the panel's context * badge. NEVER sent to the server — the client sends IDs only, the title * is re-derived server-side (spec: "the client never sends raw content for * context binding, it sends IDs"). Stripped by `pickContextIds`. */ label?: string; } /** The ID fields the server accepts (never the display-only `label`). */ export function pickContextIds(c: AiContextRef): { pageId?: string; blockId?: string; databaseId?: string } { return { ...(c.pageId ? { pageId: c.pageId } : {}), ...(c.blockId ? { blockId: c.blockId } : {}), ...(c.databaseId ? { databaseId: c.databaseId } : {}) }; } export function hasContextIds(c: AiContextRef | null | undefined): boolean { return !!c && (!!c.pageId || !!c.blockId || !!c.databaseId); } /** One citation in a RAG `sources` frame (the server-side retrieval result). */ export interface RagSource { /** 1-based citation number (the inline `[n]` marker in the answer). */ n: number; docId: string; /** The parent resource id (page/record/…). */ id: string; /** The public content type (chunk hits surface as their parent's kind). */ type: string; title: string; /** The deep link the citation chip navigates to. */ url: string; snippet: string; source: "bm25" | "vec" | "bm25+vec"; /** Deep-link metadata (chunk hits carry their parent's ids). */ pageId?: string; blockId?: string; databaseId?: string; recordId?: string; } /** One parsed SSE frame (the wire contract). */ export type AiSseFrame = | { type: "sources"; sources: RagSource[] } | { type: "delta"; text: string } | { type: "tool_call"; id: string; name: string; arguments: Record } | { type: "usage"; tokensIn: number; tokensOut: number } | { type: "error"; code: string; message: string; retryable?: boolean; retryAfterSeconds?: number } | { type: "done"; assistantMessageId?: string; userMessageId?: string; reason?: "ok" | "aborted" | "error" }; /** Pre-stream (JSON) failure — 404 ownership / 402 budget / 429 rate limit. */ export class AiPreStreamError extends Error { readonly code: string; readonly status: number; readonly retryAfterSeconds?: number; constructor(err: ApiError) { super(err.message); this.name = "AiPreStreamError"; this.code = err.code; this.status = err.status; const d = (err.details as Array> | undefined)?.[0]; if (d && typeof d.retryAfterSeconds === "number") this.retryAfterSeconds = d.retryAfterSeconds; } } /* --------------------------------------------------------- SSE parser */ /** * Parse accumulated `text/event-stream` bytes into frames. * * The wire contract (docs/api/ai.md): frames are separated by a blank line * (`\n\n`); each frame is one or more lines, and only `data:` lines carry a * JSON payload (`{type, …}`). `: keepalive` comment lines (and any other * non-`data:` line) are skipped. A partial trailing frame (no closing blank * line yet) is returned in `rest` for the next chunk. * * Pure + synchronous so it is unit-testable without a network * (TASK-0122 DoD: the frontend SSE parser round-trips all delta types * including `tool_call` and `error`). */ export function parseSseFrames(buf: string): { frames: AiSseFrame[]; rest: string } { const frames: AiSseFrame[] = []; let rest = buf; let idx: number; while ((idx = rest.indexOf("\n\n")) !== -1) { const frame = rest.slice(0, idx); rest = rest.slice(idx + 2); for (const line of frame.split("\n")) { if (!line.startsWith("data:")) continue; // `: keepalive` / comments try { frames.push(JSON.parse(line.slice(5).trim()) as AiSseFrame); } catch { // malformed frame — skip it, keep the stream alive } } } return { frames, rest }; } /* --------------------------------------------------------- conversations */ export const aiApi = { listConversations(workspaceId: string, signal?: AbortSignal): Promise { return apiFetch<{ data: AiConversationSummary[] }>( `/workspaces/${workspaceId}/ai/conversations`, { signal } ).then((r) => r.data); }, createConversation( workspaceId: string, body: { title?: string; context?: AiContextRef }, signal?: AbortSignal ): Promise { const ctx = body.context && hasContextIds(body.context) ? pickContextIds(body.context) : undefined; return apiFetch(`/workspaces/${workspaceId}/ai/conversations`, { method: "POST", body: { ...(body.title ? { title: body.title } : {}), ...(ctx ? { context: ctx } : {}) }, signal }); }, getConversation(id: string, signal?: AbortSignal): Promise { return apiFetch(`/ai/conversations/${id}`, { signal }); }, renameConversation(id: string, title: string): Promise { return apiFetch(`/ai/conversations/${id}`, { method: "PATCH", body: { title } }); }, deleteConversation(id: string): Promise { return apiFetch(`/ai/conversations/${id}`, { method: "DELETE" }); }, listMessages(conversationId: string, signal?: AbortSignal): Promise { return apiFetch<{ data: AiMessage[] }>( `/ai/conversations/${conversationId}/messages${buildQuery({ limit: 50 })}`, { signal } ).then((r) => r.data); } }; /* ------------------------------------------------------------ SSE stream */ export interface SendAiMessageParams { workspaceId: string; conversationId: string; content: string; context?: AiContextRef; /** Re-send an already-persisted user message (the Retry button). */ retryOf?: string; /** The caller's auth token — the raw fetch below bypasses apiFetch. */ signal?: AbortSignal; } /** * POST one message and stream the assistant reply. Callbacks are invoked on * the main thread as frames arrive. Returns when the stream terminates * (a `done` frame, a terminal `error`, or a fetch/network failure). * * Resolves with the final `done` frame's reason when the stream ends cleanly * (or with "aborted" if the caller's signal fired); throws `AiPreStreamError` * for the JSON pre-stream failures and a plain `Error` for network errors. */ export async function sendAiMessage( params: SendAiMessageParams, onFrame: (frame: AiSseFrame) => void ): Promise<"ok" | "aborted" | "error"> { // The auth token lives in memory (lib/auth-store) — read it here because // this fetch bypasses apiFetch (SSE needs the raw body stream). const { authStore } = await import("./auth-store"); const { API_BASE } = await import("./constants"); const token = authStore.accessToken; const res = await fetch( `${API_BASE}/workspaces/${params.workspaceId}/ai/conversations/${params.conversationId}/messages`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "text/event-stream", ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify({ content: params.content, // The server validates context as a strict ID shape (zod strips // unknown keys) — but strip the display-only `label` anyway: the // client sends IDs only, the title is re-derived server-side. ...(params.context && hasContextIds(params.context) ? { context: pickContextIds(params.context) } : {}), ...(params.retryOf ? { retryOf: params.retryOf } : {}) }), signal: params.signal, credentials: "include" } ); // Pre-stream failure: a JSON error body (content-type application/json) — // no SSE bytes were written, so parse the canonical envelope. if (!res.ok || !res.body) { let err: ApiError; try { const json = (await res.json()) as { error?: { code: string; message: string; details?: unknown } }; err = new ApiError(json.error?.code ?? "UNKNOWN", res.status, json.error?.message ?? "Request failed", json.error?.details); } catch { err = new ApiError("UNKNOWN", res.status, "Request failed"); } throw new AiPreStreamError(err); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ""; let result: "ok" | "aborted" | "error" = "error"; try { for (;;) { const chunk = await reader.read(); if (chunk.done) break; buf += decoder.decode(chunk.value, { stream: true }); const { frames, rest } = parseSseFrames(buf); buf = rest; for (const parsed of frames) { onFrame(parsed); if (parsed.type === "done") { result = parsed.reason === "aborted" ? "aborted" : parsed.reason === "error" ? "error" : "ok"; } else if (parsed.type === "error") { result = "error"; } } } // Flush any trailing frame (the server always ends with a blank line, // but be lenient about a final frame without one). const { frames } = parseSseFrames(buf + "\n\n"); for (const parsed of frames) { onFrame(parsed); if (parsed.type === "done") { result = parsed.reason === "aborted" ? "aborted" : parsed.reason === "error" ? "error" : "ok"; } else if (parsed.type === "error") { result = "error"; } } } catch (err) { // A client-initiated abort (panel close / Stop) → an AbortError from the // read loop. That is a clean cancellation, not a failure. if (err instanceof Error && err.name === "AbortError") { result = "aborted"; } else { throw err; } } finally { try { reader.releaseLock(); } catch { /* already released on abort */ } } return result; } /* --------------------------------------------------------- TASK-0123 writing */ /** * Extract the plain text of a block's content for the diff's OLD side. The * content model is `{ text }` for text blocks (and a raw string in a few * legacy/inline paths); `title` is the page-title fallback. Non-string / * non-object content yields "". */ export function blockPlainText(content: unknown): string { if (typeof content === "string") return content; if (content && typeof content === "object") { const c = content as { text?: unknown; title?: unknown }; if (typeof c.text === "string") return c.text; if (typeof c.title === "string") return c.title; } return ""; } /** The four writing actions (docs/tasks/phase-12-ai.md §Requirements). */ export const WRITING_ACTIONS = ["summarize", "rewrite", "improve", "translate"] as const; export type WritingAction = (typeof WRITING_ACTIONS)[number]; /** The fixed language list for `translate` (spec: EN/ES/DE/FR/JA/PT/ZH). */ export const WRITING_LANGUAGES = [ { id: "en", label: "English" }, { id: "es", label: "Spanish" }, { id: "de", label: "German" }, { id: "fr", label: "French" }, { id: "ja", label: "Japanese" }, { id: "pt", label: "Portuguese" }, { id: "zh", label: "Chinese" } ] as const; /** * One block in a writing suggestion. The SSE stream's `tool_call` carries * ONLY `{ blockId, newText }` (the server's `generateBlocks` output, * re-validated against the requested set — unknown IDs never reach the * client). The diff's OLD side is the local block's current text (the UI * already has it) and the version is the server's business (the persisted * suggestion carries the suggest-time version; apply re-sends it as * `baseVersion` — a stale version is the 409 rebase path). `text`/`version` * document that persisted shape; the stream result leaves them undefined. */ export interface WritingSuggestionBlock { blockId: string; newText: string; /** Original plain text — persisted suggestion only (not in the stream; * the client joins it from the local block list). */ text?: string; /** Suggest-time block version — persisted suggestion only. */ version?: number; } export interface WritingSuggestion { action: WritingAction; language?: string; pageId: string; blockIds: string[]; blocks: WritingSuggestionBlock[]; /** blockIds the model emitted that were not in the request (dropped + noted). */ droppedIds: string[]; } /** The apply result row (per-block failure isolation — the UI lists failures). */ export interface WritingApplyResult { blockId: string; status: "applied" | "failed"; code?: string; message?: string; actualVersion?: number; } export const writingApi = { /** * Apply the suggestion (or a subset — `blockIds` = "accept one"). The server * re-validates every pair against the STORED suggestion (no smuggling) and * re-checks EDIT per block; 200 always (per-block failures in `results`), * 404 = unknown/foreign suggestion, 422 = smuggled blockIds. */ apply( workspaceId: string, body: { suggestionId: string; blockIds?: string[] } ): Promise<{ results: WritingApplyResult[] }> { return apiFetch<{ results: WritingApplyResult[] }>( `/workspaces/${workspaceId}/ai/writing/apply`, { method: "POST", body } ); } }; export interface SendWritingParams { workspaceId: string; pageId: string; blockIds: string[]; action: WritingAction; language?: string; signal?: AbortSignal; /** Called with the parsed tool_call arguments as soon as they arrive. */ onFrame?: (frame: AiSseFrame) => void; } /** * POST a writing request and stream the suggestion (SSE). Returns the * parsed suggestion (from the `tool_call` frame) + the suggestion id (the * `done.assistantMessageId` — apply takes this). Pre-stream failures (422/413/ * 402/429/403) throw `AiPreStreamError`; an in-band `error` frame resolves * with `reason: "error"` (the caller shows the banner). */ export async function sendWriting( params: SendWritingParams ): Promise<{ reason: "ok" | "aborted" | "error"; suggestion?: WritingSuggestion; suggestionId?: string; usage?: { tokensIn: number; tokensOut: number }; notes?: string; }> { const { authStore } = await import("./auth-store"); const { API_BASE } = await import("./constants"); const token = authStore.accessToken; const res = await fetch(`${API_BASE}/workspaces/${params.workspaceId}/ai/writing`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "text/event-stream", ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify({ action: params.action, pageId: params.pageId, blockIds: params.blockIds, ...(params.language ? { language: params.language } : {}) }), signal: params.signal, credentials: "include" }); if (!res.ok || !res.body) { let err: ApiError; try { const json = (await res.json()) as { error?: { code: string; message: string; details?: unknown } }; err = new ApiError(json.error?.code ?? "UNKNOWN", res.status, json.error?.message ?? "Request failed", json.error?.details); } catch { err = new ApiError("UNKNOWN", res.status, "Request failed"); } throw new AiPreStreamError(err); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ""; let reason: "ok" | "aborted" | "error" = "error"; let suggestion: WritingSuggestion | undefined; let suggestionId: string | undefined; let usage: { tokensIn: number; tokensOut: number } | undefined; const notes: string[] = []; const handle = (frame: AiSseFrame): void => { params.onFrame?.(frame); if (frame.type === "tool_call" && frame.name === "generateBlocks") { const args = frame.arguments as { blocks?: Array<{ blockId: string; newText: string }> }; const raw = Array.isArray(args?.blocks) ? args.blocks : []; // The suggestion's `text` (original) + `version` come from the SELECTED // blocks the caller already has; the server's tool call only carries // {blockId, newText}. The UI merges them (the caller re-supplies). const requested = new Set(params.blockIds); const emitted = raw.filter( (b): b is { blockId: string; newText: string } => !!b && typeof b.blockId === "string" ); // `droppedIds` (the server-side "unknown ID → drop + note" record) is // persisted server-side; the stream's tool_call is ALREADY the // validated output, so anything emitted-but-not-requested is the only // case the client can see (normally none). suggestion = { action: params.action, ...(params.language ? { language: params.language } : {}), pageId: params.pageId, blockIds: params.blockIds, blocks: emitted.map((b) => ({ blockId: b.blockId, newText: b.newText })), droppedIds: emitted.filter((b) => !requested.has(b.blockId)).map((b) => b.blockId) }; } else if (frame.type === "usage") { usage = { tokensIn: frame.tokensIn, tokensOut: frame.tokensOut }; } else if (frame.type === "done") { reason = frame.reason === "aborted" ? "aborted" : frame.reason === "error" ? "error" : "ok"; suggestionId = frame.assistantMessageId; } else if (frame.type === "error") { reason = "error"; } else if (frame.type === "delta" && frame.text) { // The server sends informational notes (missing / non-text / dropped // pages) as trailing deltas — surfaced as a caption, not content. notes.push(frame.text); } }; try { for (;;) { const chunk = await reader.read(); if (chunk.done) break; buf += decoder.decode(chunk.value, { stream: true }); const { frames, rest } = parseSseFrames(buf); buf = rest; for (const f of frames) handle(f); } const { frames } = parseSseFrames(buf + "\n\n"); for (const f of frames) handle(f); } catch (err) { if (err instanceof Error && err.name === "AbortError") reason = "aborted"; else throw err; } finally { try { reader.releaseLock(); } catch { /* already released on abort */ } } return { reason, ...(suggestion ? { suggestion } : {}), ...(suggestionId ? { suggestionId } : {}), ...(usage ? { usage } : {}), ...(notes.length > 0 ? { notes: notes.join(" ").trim() } : {}) }; } /* ------------------------------------------------------------ TASK-0125 RAG */ export interface SendRagParams { workspaceId: string; query: string; /** Optional metadata filters (the search page's type chips). */ types?: string[]; signal?: AbortSignal; /** Called with every parsed frame as it arrives (streaming render). */ onFrame?: (frame: AiSseFrame) => void; } /** * POST a RAG question and stream the cited answer (SSE). The FIRST frame is * `sources` (the server-side citations — the UI's chips + sources list render * from it regardless of the model); then the answer `delta`s. Pre-stream * failures (404/403/402/429/400) throw `AiPreStreamError`; an in-band `error` * frame resolves with `reason: "error"` (the caller shows the banner). * * Returns the aggregated answer + the citation list + the usage. */ export async function askRag( params: SendRagParams ): Promise<{ reason: "ok" | "aborted" | "error"; answer: string; sources: RagSource[]; usage?: { tokensIn: number; tokensOut: number }; error?: { code: string; message: string }; }> { const { authStore } = await import("./auth-store"); const { API_BASE } = await import("./constants"); const token = authStore.accessToken; const res = await fetch(`${API_BASE}/workspaces/${params.workspaceId}/ai/search`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "text/event-stream", ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify({ query: params.query, ...(params.types && params.types.length > 0 ? { context: { types: params.types } } : {}) }), signal: params.signal, credentials: "include" }); if (!res.ok || !res.body) { let err: ApiError; try { const json = (await res.json()) as { error?: { code: string; message: string; details?: unknown } }; err = new ApiError(json.error?.code ?? "UNKNOWN", res.status, json.error?.message ?? "Request failed", json.error?.details); } catch { err = new ApiError("UNKNOWN", res.status, "Request failed"); } throw new AiPreStreamError(err); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ""; let reason: "ok" | "aborted" | "error" = "error"; let answer = ""; let sources: RagSource[] = []; let usage: { tokensIn: number; tokensOut: number } | undefined; let error: { code: string; message: string } | undefined; const handle = (frame: AiSseFrame): void => { params.onFrame?.(frame); if (frame.type === "sources") sources = frame.sources ?? []; else if (frame.type === "delta" && frame.text) answer += frame.text; else if (frame.type === "usage") usage = { tokensIn: frame.tokensIn, tokensOut: frame.tokensOut }; else if (frame.type === "error") { reason = "error"; error = { code: frame.code, message: frame.message }; } else if (frame.type === "done") { reason = frame.reason === "aborted" ? "aborted" : frame.reason === "error" ? "error" : "ok"; } }; try { for (;;) { const chunk = await reader.read(); if (chunk.done) break; buf += decoder.decode(chunk.value, { stream: true }); const { frames, rest } = parseSseFrames(buf); buf = rest; for (const f of frames) handle(f); } const { frames } = parseSseFrames(buf + "\n\n"); for (const f of frames) handle(f); } catch (err) { if (err instanceof Error && err.name === "AbortError") reason = "aborted"; else throw err; } finally { try { reader.releaseLock(); } catch { /* already released on abort */ } } return { reason, answer, sources, ...(usage ? { usage } : {}), ...(error ? { error } : {}) }; } /* ------------------------------------------------------ generate (TASK-0126) */ /** * TASK-0126 — the page-generation wire contract. Distinct from the chat/writing/ * RAG frames above: progress is a live *tool* checklist (the executor running * the structured plan through the domain services) and the terminal frame is a * `done { pageId, created }` (or `cancelled` / `error`). docs/api/ai.md §10. */ export type GenerateToolName = "createPage" | "insertBlocks" | "createDatabase" | "createRelation"; export type GenerateFrame = | { type: "tool"; name: GenerateToolName; status: "started" | "done"; ref?: string } | { type: "done"; pageId: string; created: { pages: string[]; blocks: number; databases: string[]; relations: number }; tokensIn?: number; tokensOut?: number; } | { type: "cancelled" } | { type: "error"; code: string; message: string; retryable?: boolean }; export interface GenerateAiPageParams { workspaceId: string; prompt: string; parentId?: string | null; /** Server-side idempotency — a retry of a cancelled generation must not * double-create (the same mechanism as TASK-0127). */ idempotencyKey?: string; signal?: AbortSignal; /** Called with each parsed frame as it arrives (the live checklist). */ onFrame?: (frame: GenerateFrame) => void; } export interface GenerateResult { reason: "ok" | "aborted" | "error" | "cancelled"; pageId?: string; created?: { pages: string[]; blocks: number; databases: string[]; relations: number }; usage?: { tokensIn: number; tokensOut: number }; error?: { code: string; message: string }; } /** * POST a generation request and stream the progress (SSE). The LLM emits a * structured plan; the executor runs it through the domain services and streams * a `tool` frame per step, then a terminal `done` (with the generated pageId) / * `cancelled` / `error`. Pre-stream failures (404 ownership / 403 no-EDIT / * 402 budget / 429 rate-limit / 400-422 validation) throw `AiPreStreamError`; * an in-band `error` frame resolves with `reason: "error"`. */ export async function generateAiPage(params: GenerateAiPageParams): Promise { const { authStore } = await import("./auth-store"); const { API_BASE } = await import("./constants"); const token = authStore.accessToken; const res = await fetch(`${API_BASE}/workspaces/${params.workspaceId}/ai/generate`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "text/event-stream", ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(params.idempotencyKey ? { "Idempotency-Key": params.idempotencyKey } : {}) }, body: JSON.stringify({ prompt: params.prompt, ...(params.parentId ? { parentId: params.parentId } : {}) }), signal: params.signal, credentials: "include" }); if (!res.ok || !res.body) { let err: ApiError; try { const json = (await res.json()) as { error?: { code: string; message: string; details?: unknown } }; err = new ApiError(json.error?.code ?? "UNKNOWN", res.status, json.error?.message ?? "Request failed", json.error?.details); } catch { err = new ApiError("UNKNOWN", res.status, "Request failed"); } throw new AiPreStreamError(err); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ""; let result: GenerateResult = { reason: "error" }; // Idle watchdog: the model's thinking phase can be silent for minutes, so // the server emits a `: keepalive` comment frame every 15 s (the SSE parser // ignores non-`data:` lines, but the BYTES below — any chunk — reset this // timer). If NOTHING arrives for STREAM_IDLE_MS the stream is genuinely // dead (proxy black-hole, dropped connection) and the read loop would // otherwise hang on "Drafting the page…" forever — cancel the reader and // surface a retryable error. const STREAM_IDLE_MS = 150_000; let lastActivity = Date.now(); let stalled = false; const watchdog = setInterval(() => { if (Date.now() - lastActivity > STREAM_IDLE_MS) { stalled = true; clearInterval(watchdog); void reader.cancel().catch(() => { /* stream already closed */ }); } }, 5_000); const handle = (frame: GenerateFrame): void => { params.onFrame?.(frame); if (frame.type === "done") { result = { reason: "ok", pageId: frame.pageId, created: frame.created, ...(frame.tokensIn != null || frame.tokensOut != null ? { usage: { tokensIn: frame.tokensIn ?? 0, tokensOut: frame.tokensOut ?? 0 } } : {}) }; } else if (frame.type === "cancelled") { result = { ...result, reason: "cancelled" }; } else if (frame.type === "error") { result = { ...result, reason: "error", error: { code: frame.code, message: frame.message } }; } }; try { for (;;) { const chunk = await reader.read(); if (chunk.done) break; lastActivity = Date.now(); buf += decoder.decode(chunk.value, { stream: true }); const { frames, rest } = parseSseFrames(buf); buf = rest; for (const f of frames as GenerateFrame[]) handle(f); } if (!stalled) { const { frames } = parseSseFrames(buf + "\n\n"); for (const f of frames as GenerateFrame[]) handle(f); } } catch (err) { if (err instanceof Error && err.name === "AbortError") { result = { ...result, reason: "aborted" }; } else if (!stalled) { throw err; } } finally { clearInterval(watchdog); try { reader.releaseLock(); } catch { /* already released on abort */ } } if (stalled) { result = { reason: "error", error: { code: "STREAM_TIMEOUT", message: "Generation stalled — no progress from the server. Try again." } }; } return result; } /* ------------------------------------------- T-172…T-175 knowledge assistant */ /** * T-172…T-175 (A-010) — the five AI knowledge-assistant capabilities. Plain * JSON one-shots (no SSE, no conversation panel): each POST is * `POST /workspaces/:id/ai/assistant/` and resolves with the * structured result (or throws `ApiError` — 403 no-EDIT, 422 invalid model * output, 429 rate limit, 402 budget). The per-call conversations are hidden * from the panel (server-side `assistant:*` filter) — nothing to list here. */ export interface AssistantExtractTask { id: string; title: string; dueDate?: string | null; } export interface AssistantExtractResult { tasks: AssistantExtractTask[]; tokensIn: number; tokensOut: number; } export interface AssistantFaqResult { page: { id: string; title: string }; faqCount: number; tokensIn: number; tokensOut: number; } export interface AssistantDuplicatePage { pageId: string; title: string; updatedAt: string; } export interface AssistantDuplicateGroup { similarity: number; reason: string; pages: AssistantDuplicatePage[]; } export interface AssistantDuplicatesResult { groups: AssistantDuplicateGroup[]; considered: number; tokensIn: number; tokensOut: number; } export interface AssistantStalePage { pageId: string; title: string; updatedAt: string; reason: string; } export interface AssistantStaleResult { pages: AssistantStalePage[]; considered: number; tokensIn: number; tokensOut: number; } export interface AssistantSuggestion { title?: string; description?: string; } export interface AssistantSuggestResult { suggestionId: string; pageId: string; current: { title: string; description: string | null }; suggested: AssistantSuggestion; tokensIn: number; tokensOut: number; } export interface AssistantApplyResult { pageId: string; applied: string[]; } export const assistantApi = { /** T-172 — extract action items from free text → real tasks (EDIT-gated). */ extractActions( workspaceId: string, text: string, signal?: AbortSignal ): Promise { return apiFetch(`/workspaces/${workspaceId}/ai/assistant/extract-actions`, { method: "POST", body: { text }, signal }); }, /** T-173 — build an FAQ draft page from the given visible pages. */ generateFaqs( workspaceId: string, pageIds: string[], signal?: AbortSignal ): Promise { return apiFetch(`/workspaces/${workspaceId}/ai/assistant/generate-faqs`, { method: "POST", body: { pageIds }, signal }); }, /** T-174 — find likely-duplicate pages across the workspace. */ findDuplicates( workspaceId: string, params: { query?: string; limit?: number } = {}, signal?: AbortSignal ): Promise { return apiFetch(`/workspaces/${workspaceId}/ai/assistant/find-duplicates`, { method: "POST", body: { ...(params.query ? { query: params.query } : {}), ...(params.limit ? { limit: params.limit } : {}) }, signal }); }, /** T-174 — find stale pages (not updated in `days`). */ findStale( workspaceId: string, params: { days?: number; limit?: number } = {}, signal?: AbortSignal ): Promise { return apiFetch(`/workspaces/${workspaceId}/ai/assistant/find-stale`, { method: "POST", body: { ...(params.days ? { days: params.days } : {}), ...(params.limit ? { limit: params.limit } : {}) }, signal }); }, /** T-175 — suggest a title/description for a page (suggestion only). */ suggestMetadata( workspaceId: string, pageId: string, signal?: AbortSignal ): Promise { return apiFetch(`/workspaces/${workspaceId}/ai/assistant/suggest-metadata`, { method: "POST", body: { pageId }, signal }); }, /** T-175 — apply a previously-suggested field subset (EDIT re-checked). */ applyMetadata( workspaceId: string, suggestionId: string, fields: AssistantSuggestion, signal?: AbortSignal ): Promise { return apiFetch(`/workspaces/${workspaceId}/ai/assistant/apply-metadata`, { method: "POST", body: { suggestionId, fields }, signal }); } };