From 29e340fd08cbcb21b0b1664f9b6dd6a46abdce57 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 2 May 2026 23:48:01 -0700 Subject: [PATCH] quick question feature --- docs/api/rest.md | 22 +- docs/api/streaming-chat.md | 26 +- server/src/llm/streaming.ts | 144 +++--- server/src/llm/types.ts | 1 + server/src/routes.ts | 87 +++- web/src/App.tsx | 482 +++++++++++++++++- .../components/chat/chat-messages-panel.tsx | 75 ++- web/src/lib/api.ts | 17 +- 8 files changed, 748 insertions(+), 106 deletions(-) diff --git a/docs/api/rest.md b/docs/api/rest.md index 9678b95..daf0c54 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -45,9 +45,29 @@ Chat upload limits: - Response: `{ "chats": ChatSummary[] }` ### `POST /v1/chats` -- Body: `{ "title"?: string }` +- Body: +```json +{ + "title": "optional title", + "provider": "optional openai|anthropic|xai", + "model": "optional model id", + "messages": [ + { + "role": "system|user|assistant|tool", + "content": "string", + "name": "optional", + "attachments": [] + } + ] +} +``` - Response: `{ "chat": ChatSummary }` +Behavior notes: +- `provider` and `model` must be supplied together when present. +- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`. +- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages. + ### `PATCH /v1/chats/:chatId` - Body: `{ "title": string }` - Response: `{ "chat": ChatSummary }` diff --git a/docs/api/streaming-chat.md b/docs/api/streaming-chat.md index 2c08034..cd2e2dc 100644 --- a/docs/api/streaming-chat.md +++ b/docs/api/streaming-chat.md @@ -19,6 +19,7 @@ Authentication: ```json { "chatId": "optional-chat-id", + "persist": true, "provider": "openai|anthropic|xai", "model": "string", "messages": [ @@ -53,10 +54,12 @@ Authentication: ``` Notes: -- If `chatId` is omitted, backend creates a new chat. +- `persist` defaults to `true`. +- If `persist` is `true` and `chatId` is omitted, backend creates a new chat. - If `chatId` is provided, backend validates it exists. -- Backend stores only new non-assistant input history rows to avoid duplicates. -- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages. +- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata. +- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates. +- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`. ## Event Stream Contract @@ -71,13 +74,15 @@ Event order: ```json { "type": "meta", - "chatId": "chat-id", - "callId": "llm-call-id", + "chatId": "chat-id-or-null", + "callId": "llm-call-id-or-null", "provider": "openai", "model": "gpt-4.1-mini" } ``` +For `persist: false` streams, `chatId` and `callId` are `null`. + ### `delta` ```json @@ -148,17 +153,22 @@ Tool-enabled streaming notes (`openai`/`xai`): Backend database remains source of truth. -During stream: +For persisted streams: - Client may optimistically render accumulated `delta` text. - Backend persists each completed tool call as a `tool` message before emitting its `tool_call` SSE event, so chat detail refreshes can show completed tool calls while the assistant response is still running. -On successful completion: +On successful persisted completion: - Backend persists assistant `Message` and updates `LlmCall` usage/latency in a transaction. - Backend then emits `done`. -On failure: +On persisted failure: - Backend records call error and emits `error`. +For `persist: false` streams: +- Client may render the same `meta`, `tool_call`, `delta`, and terminal events. +- Backend does not write any chat, message, tool-call log, assistant output, or call metadata rows. +- `done.text` is the canonical assistant text if the client later imports the result into a saved chat. + Client recommendation (for iOS/web): 1. Render deltas in real time for UX. 2. On `done`, refresh chat detail from REST (`GET /v1/chats/:chatId`) and use DB-backed data as canonical. diff --git a/server/src/llm/streaming.ts b/server/src/llm/streaming.ts index 9f23573..cbf0ac7 100644 --- a/server/src/llm/streaming.ts +++ b/server/src/llm/streaming.ts @@ -10,11 +10,17 @@ import { import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js"; import type { MultiplexRequest, Provider } from "./types.js"; +type StreamUsage = { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; +}; + export type StreamEvent = - | { type: "meta"; chatId: string; callId: string; provider: Provider; model: string } + | { type: "meta"; chatId: string | null; callId: string | null; provider: Provider; model: string } | { type: "tool_call"; event: ToolExecutionEvent } | { type: "delta"; text: string } - | { type: "done"; text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } } + | { type: "done"; text: string; usage?: StreamUsage } | { type: "error"; message: string }; function getChatIdOrCreate(chatId?: string) { @@ -24,39 +30,45 @@ function getChatIdOrCreate(chatId?: string) { export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator { const t0 = performance.now(); - const chatId = await getChatIdOrCreate(req.chatId); + const shouldPersist = req.persist !== false; + const chatId = shouldPersist ? await getChatIdOrCreate(req.chatId) : null; - const call = await prisma.llmCall.create({ - data: { - chatId, - provider: req.provider as any, - model: req.model, - request: req as any, - }, - select: { id: true }, - }); + const call = + shouldPersist && chatId + ? await prisma.llmCall.create({ + data: { + chatId, + provider: req.provider as any, + model: req.model, + request: req as any, + }, + select: { id: true }, + }) + : null; - await prisma.$transaction([ - prisma.chat.update({ - where: { id: chatId }, - data: { - lastUsedProvider: req.provider as any, - lastUsedModel: req.model, - }, - }), - prisma.chat.updateMany({ - where: { id: chatId, initiatedProvider: null }, - data: { - initiatedProvider: req.provider as any, - initiatedModel: req.model, - }, - }), - ]); + if (shouldPersist && chatId) { + await prisma.$transaction([ + prisma.chat.update({ + where: { id: chatId }, + data: { + lastUsedProvider: req.provider as any, + lastUsedModel: req.model, + }, + }), + prisma.chat.updateMany({ + where: { id: chatId, initiatedProvider: null }, + data: { + initiatedProvider: req.provider as any, + initiatedModel: req.model, + }, + }), + ]); + } - yield { type: "meta", chatId, callId: call.id, provider: req.provider, model: req.model }; + yield { type: "meta", chatId, callId: call?.id ?? null, provider: req.provider, model: req.model }; let text = ""; - let usage: StreamEvent extends any ? any : never; + let usage: StreamUsage | undefined; let raw: unknown = { streamed: true }; try { @@ -73,7 +85,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator logContext: { provider: req.provider, model: req.model, - chatId, + chatId: chatId ?? undefined, }, }) : runToolAwareChatCompletionsStream({ @@ -85,7 +97,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator logContext: { provider: req.provider, model: req.model, - chatId, + chatId: chatId ?? undefined, }, }); for await (const ev of streamEvents) { @@ -96,16 +108,18 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator } if (ev.type === "tool_call") { - const toolMessage = buildToolLogMessageData(chatId, ev.event); - await prisma.message.create({ - data: { - chatId: toolMessage.chatId, - role: toolMessage.role as any, - content: toolMessage.content, - name: toolMessage.name, - metadata: toolMessage.metadata as any, - }, - }); + if (shouldPersist && chatId) { + const toolMessage = buildToolLogMessageData(chatId, ev.event); + await prisma.message.create({ + data: { + chatId: toolMessage.chatId, + role: toolMessage.role as any, + content: toolMessage.content, + name: toolMessage.name, + metadata: toolMessage.metadata as any, + }, + }); + } yield { type: "tool_call", event: ev.event }; continue; } @@ -156,32 +170,36 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator const latencyMs = Math.round(performance.now() - t0); - await prisma.$transaction(async (tx) => { - await tx.message.create({ - data: { chatId, role: "assistant" as any, content: text }, + if (shouldPersist && chatId && call) { + await prisma.$transaction(async (tx) => { + await tx.message.create({ + data: { chatId, role: "assistant" as any, content: text }, + }); + await tx.llmCall.update({ + where: { id: call.id }, + data: { + response: raw as any, + latencyMs, + inputTokens: usage?.inputTokens, + outputTokens: usage?.outputTokens, + totalTokens: usage?.totalTokens, + }, + }); }); - await tx.llmCall.update({ - where: { id: call.id }, - data: { - response: raw as any, - latencyMs, - inputTokens: usage?.inputTokens, - outputTokens: usage?.outputTokens, - totalTokens: usage?.totalTokens, - }, - }); - }); + } yield { type: "done", text, usage }; } catch (e: any) { const latencyMs = Math.round(performance.now() - t0); - await prisma.llmCall.update({ - where: { id: call.id }, - data: { - error: e?.message ?? String(e), - latencyMs, - }, - }); + if (shouldPersist && call) { + await prisma.llmCall.update({ + where: { id: call.id }, + data: { + error: e?.message ?? String(e), + latencyMs, + }, + }); + } yield { type: "error", message: e?.message ?? String(e) }; } } diff --git a/server/src/llm/types.ts b/server/src/llm/types.ts index 516c8c5..97490e7 100644 --- a/server/src/llm/types.ts +++ b/server/src/llm/types.ts @@ -30,6 +30,7 @@ export type ChatMessage = { export type MultiplexRequest = { chatId?: string; + persist?: boolean; provider: Provider; model: string; messages: ChatMessage[]; diff --git a/server/src/routes.ts b/server/src/routes.ts index dc25184..c61aedb 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -327,10 +327,50 @@ export async function registerRoutes(app: FastifyInstance) { app.post("/v1/chats", async (req) => { requireAdmin(req); - const Body = z.object({ title: z.string().optional() }); - const body = Body.parse(req.body ?? {}); + const Body = z + .object({ + title: z.string().optional(), + provider: z.enum(["openai", "anthropic", "xai"]).optional(), + model: z.string().trim().min(1).optional(), + messages: z.array(CompletionMessageSchema).optional(), + }) + .superRefine((value, ctx) => { + if (value.provider && !value.model) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "model is required when provider is supplied", + path: ["model"], + }); + } + if (!value.provider && value.model) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "provider is required when model is supplied", + path: ["provider"], + }); + } + }); + const parsed = Body.safeParse(req.body ?? {}); + if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message); + const body = parsed.data; const chat = await prisma.chat.create({ - data: { title: body.title }, + data: { + title: body.title, + initiatedProvider: body.provider as any, + initiatedModel: body.model, + lastUsedProvider: body.provider as any, + lastUsedModel: body.model, + messages: body.messages?.length + ? { + create: body.messages.map((message) => ({ + role: message.role as any, + content: message.content, + name: message.name, + metadata: message.attachments?.length ? ({ attachments: message.attachments } as any) : undefined, + })), + } + : undefined, + }, select: { id: true, title: true, @@ -838,7 +878,9 @@ export async function registerRoutes(app: FastifyInstance) { }); const { chatId } = Params.parse(req.params); - const body = Body.parse(req.body); + const parsed = Body.safeParse(req.body); + if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message); + const body = parsed.data; const msg = await prisma.message.create({ data: { @@ -866,7 +908,9 @@ export async function registerRoutes(app: FastifyInstance) { maxTokens: z.number().int().positive().optional(), }); - const body = Body.parse(req.body); + const parsed = Body.safeParse(req.body); + if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message); + const body = parsed.data; // ensure chat exists if provided if (body.chatId) { @@ -891,16 +935,29 @@ export async function registerRoutes(app: FastifyInstance) { app.post("/v1/chat-completions/stream", async (req, reply) => { requireAdmin(req); - const Body = z.object({ - chatId: z.string().optional(), - provider: z.enum(["openai", "anthropic", "xai"]), - model: z.string().min(1), - messages: z.array(CompletionMessageSchema), - temperature: z.number().min(0).max(2).optional(), - maxTokens: z.number().int().positive().optional(), - }); + const Body = z + .object({ + chatId: z.string().optional(), + persist: z.boolean().optional(), + provider: z.enum(["openai", "anthropic", "xai"]), + model: z.string().min(1), + messages: z.array(CompletionMessageSchema), + temperature: z.number().min(0).max(2).optional(), + maxTokens: z.number().int().positive().optional(), + }) + .superRefine((value, ctx) => { + if (value.persist === false && value.chatId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "chatId must be omitted when persist is false", + path: ["chatId"], + }); + } + }); - const body = Body.parse(req.body); + const parsed = Body.safeParse(req.body); + if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message); + const body = parsed.data; // ensure chat exists if provided if (body.chatId) { @@ -909,7 +966,7 @@ export async function registerRoutes(app: FastifyInstance) { } // Store only new non-assistant messages to avoid duplicate history entries. - if (body.chatId) { + if (body.persist !== false && body.chatId) { await storeNonAssistantMessages(body.chatId, body.messages); } diff --git a/web/src/App.tsx b/web/src/App.tsx index c65efe9..e0f5d5a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "preact/hooks"; -import { Check, ChevronDown, Globe2, Menu, MessageSquare, Paperclip, Plus, Search, SendHorizontal, Trash2 } from "lucide-preact"; +import { Check, ChevronDown, Globe2, Menu, MessageSquare, Paperclip, Plus, Rabbit, Search, SendHorizontal, Trash2, X } from "lucide-preact"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Separator } from "@/components/ui/separator"; @@ -92,9 +92,15 @@ const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = { }; const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider"; +const QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY = "sybil:quickQuestionModelSelection"; type ProviderModelPreferences = Record; +type QuickQuestionModelSelection = { + provider: Provider; + modelPreferences: ProviderModelPreferences; +}; + const EMPTY_MODEL_PREFERENCES: ProviderModelPreferences = { openai: null, anthropic: null, @@ -292,6 +298,37 @@ function loadStoredModelPreferences() { } } +function normalizeStoredProvider(value: unknown): Provider { + return value === "anthropic" || value === "xai" || value === "openai" ? value : "openai"; +} + +function normalizeStoredModelPreferences(value: unknown): ProviderModelPreferences { + if (!value || typeof value !== "object" || Array.isArray(value)) return EMPTY_MODEL_PREFERENCES; + const parsed = value as Partial>; + return { + openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null, + anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null, + xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null, + }; +} + +function loadStoredQuickQuestionModelSelection(): QuickQuestionModelSelection { + if (typeof window === "undefined") { + return { provider: "openai", modelPreferences: EMPTY_MODEL_PREFERENCES }; + } + try { + const raw = window.localStorage.getItem(QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY); + if (!raw) return { provider: "openai", modelPreferences: EMPTY_MODEL_PREFERENCES }; + const parsed = JSON.parse(raw) as { provider?: unknown; modelPreferences?: unknown }; + return { + provider: normalizeStoredProvider(parsed.provider), + modelPreferences: normalizeStoredModelPreferences(parsed.modelPreferences), + }; + } catch { + return { provider: "openai", modelPreferences: EMPTY_MODEL_PREFERENCES }; + } +} + function pickProviderModel(options: string[], preferred: string | null) { if (preferred?.trim()) return preferred.trim(); return options[0] ?? ""; @@ -620,6 +657,22 @@ export default function App() { const stored = loadStoredModelPreferences(); return stored.openai ?? PROVIDER_FALLBACK_MODELS.openai[0]; }); + const [quickProvider, setQuickProvider] = useState(() => loadStoredQuickQuestionModelSelection().provider); + const [quickProviderModelPreferences, setQuickProviderModelPreferences] = useState( + () => loadStoredQuickQuestionModelSelection().modelPreferences + ); + const [quickModel, setQuickModel] = useState(() => { + const stored = loadStoredQuickQuestionModelSelection(); + return stored.modelPreferences[stored.provider] ?? PROVIDER_FALLBACK_MODELS[stored.provider][0]; + }); + const [isQuickQuestionOpen, setIsQuickQuestionOpen] = useState(false); + const [quickPrompt, setQuickPrompt] = useState(""); + const [quickSubmittedPrompt, setQuickSubmittedPrompt] = useState(null); + const [quickSubmittedModelSelection, setQuickSubmittedModelSelection] = useState<{ provider: Provider; model: string } | null>(null); + const [quickQuestionMessages, setQuickQuestionMessages] = useState([]); + const [isQuickQuestionSending, setIsQuickQuestionSending] = useState(false); + const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false); + const [quickQuestionError, setQuickQuestionError] = useState(null); const [error, setError] = useState(null); const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP); const transcriptContainerRef = useRef(null); @@ -631,6 +684,7 @@ export default function App() { const selectedItemRef = useRef(null); const pendingTitleGenerationRef = useRef>(new Set()); const searchRunAbortRef = useRef(null); + const quickQuestionAbortRef = useRef(null); const searchRunCounterRef = useRef(0); const shouldAutoScrollRef = useRef(true); const wasSendingRef = useRef(false); @@ -713,6 +767,12 @@ export default function App() { setPendingChatState(null); setComposer(""); setPendingAttachments([]); + setIsQuickQuestionOpen(false); + setQuickPrompt(""); + setQuickSubmittedPrompt(null); + setQuickSubmittedModelSelection(null); + setQuickQuestionMessages([]); + setQuickQuestionError(null); setError(null); }; @@ -846,6 +906,7 @@ export default function App() { }, [isAuthenticated, selectedItem]); const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]); + const quickProviderModelOptions = useMemo(() => getModelOptions(modelCatalog, quickProvider), [modelCatalog, quickProvider]); useEffect(() => { if (model.trim()) return; @@ -859,6 +920,46 @@ export default function App() { window.localStorage.setItem(MODEL_PREFERENCES_STORAGE_KEY, JSON.stringify(providerModelPreferences)); }, [providerModelPreferences]); + useEffect(() => { + if (quickModel.trim()) return; + setQuickModel((current) => { + return current.trim() || pickProviderModel(quickProviderModelOptions, quickProviderModelPreferences[quickProvider]); + }); + }, [quickModel, quickProvider, quickProviderModelOptions, quickProviderModelPreferences]); + + useEffect(() => { + if (typeof window === "undefined") return; + window.localStorage.setItem( + QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY, + JSON.stringify({ + provider: quickProvider, + modelPreferences: quickProviderModelPreferences, + } satisfies QuickQuestionModelSelection) + ); + }, [quickProvider, quickProviderModelPreferences]); + + useEffect(() => { + if (!isQuickQuestionOpen || typeof window === "undefined") return; + window.requestAnimationFrame(() => { + const textarea = document.getElementById("quick-question-input") as HTMLTextAreaElement | null; + if (!textarea) return; + textarea.focus(); + textarea.style.height = "0px"; + textarea.style.height = `${textarea.scrollHeight}px`; + if (textarea.value.length > 0) { + textarea.select(); + } + }); + }, [isQuickQuestionOpen]); + + useEffect(() => { + if (typeof document === "undefined") return; + const textarea = document.getElementById("quick-question-input") as HTMLTextAreaElement | null; + if (!textarea) return; + textarea.style.height = "0px"; + textarea.style.height = `${textarea.scrollHeight}px`; + }, [quickPrompt, isQuickQuestionOpen]); + const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null; const isChatReplyStreamingInView = isSending && @@ -933,6 +1034,8 @@ export default function App() { return () => { searchRunAbortRef.current?.abort(); searchRunAbortRef.current = null; + quickQuestionAbortRef.current?.abort(); + quickQuestionAbortRef.current = null; }; }, []); @@ -960,6 +1063,18 @@ export default function App() { } return (isSearchMode ? messages : pendingChatState.messages).filter(isDisplayableMessage); }, [isSearchMode, messages, pendingChatState, selectedItem]); + const quickAnswerText = useMemo(() => { + for (let index = quickQuestionMessages.length - 1; index >= 0; index -= 1) { + const message = quickQuestionMessages[index]; + if (message.role === "assistant") return message.content; + } + return ""; + }, [quickQuestionMessages]); + const canConvertQuickQuestion = + Boolean(quickSubmittedPrompt?.trim()) && + Boolean(quickSubmittedModelSelection?.model.trim()) && + Boolean(quickAnswerText.trim()) && + !isQuickQuestionSending; const selectedChatSummary = useMemo(() => { if (!selectedItem || selectedItem.kind !== "chat") return null; @@ -1028,6 +1143,12 @@ export default function App() { setIsMobileSidebarOpen(false); }; + const handleOpenQuickQuestion = () => { + setQuickQuestionError(null); + setIsQuickQuestionOpen(true); + setIsMobileSidebarOpen(false); + }; + const handleCreateSearch = () => { setError(null); setContextMenu(null); @@ -1068,6 +1189,15 @@ export default function App() { if (!hasPrimaryModifier || event.altKey) return; const key = event.key.toLowerCase(); + if (key === "i" && !event.shiftKey) { + event.preventDefault(); + setQuickQuestionError(null); + setIsQuickQuestionOpen((current) => !current); + return; + } + + if (isQuickQuestionOpen) return; + if (key === "j") { event.preventDefault(); if (event.shiftKey) { @@ -1087,7 +1217,7 @@ export default function App() { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [filteredSidebarItems, isAuthenticated]); + }, [filteredSidebarItems, isAuthenticated, isQuickQuestionOpen]); const openContextMenu = (event: MouseEvent, item: SidebarSelection) => { event.preventDefault(); @@ -1138,6 +1268,17 @@ export default function App() { }; }, [contextMenu]); + useEffect(() => { + if (!isQuickQuestionOpen) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + setIsQuickQuestionOpen(false); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isQuickQuestionOpen]); + const handleOpenAttachmentPicker = () => { fileInputRef.current?.click(); }; @@ -1587,6 +1728,182 @@ export default function App() { } }; + const handleSendQuickQuestion = async () => { + const content = quickPrompt.trim(); + if (!content || isQuickQuestionSending || isConvertingQuickQuestion) return; + + const selectedModel = quickModel.trim(); + if (!selectedModel) { + setQuickQuestionError("No model available for selected provider"); + return; + } + + const now = new Date().toISOString(); + const optimisticAssistantMessage: Message = { + id: `temp-assistant-quick-${Date.now()}`, + createdAt: now, + role: "assistant", + content: "", + name: null, + metadata: null, + }; + + quickQuestionAbortRef.current?.abort(); + const abortController = new AbortController(); + quickQuestionAbortRef.current = abortController; + + setQuickQuestionError(null); + setQuickSubmittedPrompt(content); + setQuickSubmittedModelSelection({ provider: quickProvider, model: selectedModel }); + setQuickQuestionMessages([optimisticAssistantMessage]); + setIsQuickQuestionSending(true); + + let streamErrorMessage: string | null = null; + + try { + await runCompletionStream( + { + persist: false, + provider: quickProvider, + model: selectedModel, + messages: [{ role: "user", content }], + }, + { + onToolCall: (payload) => { + setQuickQuestionMessages((current) => { + if ( + current.some( + (message) => + asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}` + ) + ) { + return current; + } + + const toolMessage = buildOptimisticToolMessage(payload); + const assistantIndex = current.findIndex( + (message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-quick-") + ); + if (assistantIndex < 0) return current.concat(toolMessage); + return [ + ...current.slice(0, assistantIndex), + toolMessage, + ...current.slice(assistantIndex), + ]; + }); + }, + onDelta: (payload) => { + if (!payload.text) return; + setQuickQuestionMessages((current) => { + let updated = false; + const nextMessages = current.map((message, index, all) => { + const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-quick-"); + if (!isTarget) return message; + updated = true; + return { ...message, content: message.content + payload.text }; + }); + return updated ? nextMessages : current; + }); + }, + onDone: (payload) => { + setQuickQuestionMessages((current) => { + let updated = false; + const nextMessages = current.map((message, index, all) => { + const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-quick-"); + if (!isTarget) return message; + updated = true; + return { ...message, content: payload.text }; + }); + return updated ? nextMessages : current; + }); + }, + onError: (payload) => { + streamErrorMessage = payload.message; + }, + }, + { signal: abortController.signal } + ); + + if (streamErrorMessage) { + throw new Error(streamErrorMessage); + } + } catch (err) { + if (abortController.signal.aborted) return; + const message = err instanceof Error ? err.message : String(err); + if (message.includes("bearer token")) { + handleAuthFailure(message); + } else { + setQuickQuestionError(message); + } + } finally { + if (quickQuestionAbortRef.current === abortController) { + quickQuestionAbortRef.current = null; + } + if (!abortController.signal.aborted) { + setIsQuickQuestionSending(false); + } + } + }; + + const handleConvertQuickQuestionToChat = async () => { + const question = quickSubmittedPrompt?.trim(); + const answer = quickAnswerText.trim(); + const selection = quickSubmittedModelSelection; + if (!question || !answer || !selection || isQuickQuestionSending || isConvertingQuickQuestion) return; + + setQuickQuestionError(null); + setIsConvertingQuickQuestion(true); + + try { + const title = question.split(/\r?\n/)[0]?.trim().slice(0, 48) || "Quick question"; + const chat = await createChat({ + title, + provider: selection.provider, + model: selection.model, + messages: [ + { role: "user", content: question }, + { role: "assistant", content: answer }, + ], + }); + + setDraftKind(null); + setPendingChatState(null); + setComposer(""); + setPendingAttachments([]); + setIsQuickQuestionOpen(false); + setProvider(selection.provider); + setModel(selection.model); + setChats((current) => { + const withoutExisting = current.filter((existing) => existing.id !== chat.id); + return [chat, ...withoutExisting]; + }); + setSelectedItem({ kind: "chat", id: chat.id }); + setSelectedChat({ + id: chat.id, + title: chat.title, + createdAt: chat.createdAt, + updatedAt: chat.updatedAt, + initiatedProvider: chat.initiatedProvider, + initiatedModel: chat.initiatedModel, + lastUsedProvider: chat.lastUsedProvider, + lastUsedModel: chat.lastUsedModel, + messages: [], + }); + setSelectedSearch(null); + await refreshCollections({ kind: "chat", id: chat.id }); + await refreshChat(chat.id); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes("bearer token")) { + handleAuthFailure(message); + } else { + setQuickQuestionError(message); + } + } finally { + setIsConvertingQuickQuestion(false); + } + }; + const handleSend = async () => { const content = composer.trim(); const attachments = pendingAttachments; @@ -1683,13 +2000,30 @@ export default function App() {
- +
+ +
+ + + {primaryShortcutModifier}+i + +
+
) : null} + {isQuickQuestionOpen ? ( +
{ + if (event.target === event.currentTarget) setIsQuickQuestionOpen(false); + }} + > +
+
+

+ Quick question +

+ +
+ +
+