SPB Git

spb/polyllm

Public

Integration: custom endpoints in chat/arena services, prompt & library hand-offs, palette events, share sheet/export in chat, share links in settings

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 11, 2026) parent 8668939 Browse files

4 changed files +104 −28

modified src/app/app/settings/data/page.tsx +8 −0
@@ -3,6 +3,7 @@ import * as React from "react";
3 import Link from "next/link"; 3 import Link from "next/link";
4 import { Download, Trash2, Info } from "lucide-react"; 4 import { Download, Trash2, Info } from "lucide-react";
5 import { Card, CardHeader, CardBody } from "@/components/ui/misc"; 5 import { Card, CardHeader, CardBody } from "@/components/ui/misc";
6+import { ShareLinksList } from "@/components/share/share-sheet";
6 import { Button } from "@/components/ui/button"; 7 import { Button } from "@/components/ui/button";
7 import { Textarea, Field } from "@/components/ui/input"; 8 import { Textarea, Field } from "@/components/ui/input";
8 import { toast } from "@/components/ui/toast"; 9 import { toast } from "@/components/ui/toast";
@@ -111,6 +112,13 @@ export default function DataSettingsPage() {
111 </CardBody> 112 </CardBody>
112 </Card> 113 </Card>
113 114
115+ <Card>
116+ <CardHeader title="Active share links" description="Public read-only links you created. Revoke any of them at any time." />
117+ <CardBody>
118+ <ShareLinksList />
119+ </CardBody>
120+ </Card>
121+
114 <Card> 122 <Card>
115 <CardHeader title="Retention" description="What we keep and for how long." /> 123 <CardHeader title="Retention" description="What we keep and for how long." />
116 <CardBody> 124 <CardBody>
modified src/components/chat/chat-view.tsx +65 −15
@@ -1,7 +1,7 @@
1 "use client"; 1 "use client";
2 import * as React from "react"; 2 import * as React from "react";
3 import { useRouter, useSearchParams } from "next/navigation"; 3 import { useRouter, useSearchParams } from "next/navigation";
4−import { AlertTriangle, ArrowDown, Braces, Columns3, EyeOff, Globe, Menu, MoreHorizontal, Pin, Settings2, Share2, Swords, Terminal, Trash2, Wrench } from "lucide-react"; 4+import { AlertTriangle, ArrowDown, Braces, Columns3, Download, EyeOff, Globe, Menu, MoreHorizontal, Pin, Settings2, Share2, Swords, Terminal, Trash2, Wrench } from "lucide-react";
5 import Link from "next/link"; 5 import Link from "next/link";
6 import { useApp, invalidateConversations, AUTO_MODEL_KEY } from "@/components/app/store"; 6 import { useApp, invalidateConversations, AUTO_MODEL_KEY } from "@/components/app/store";
7 import { providerName } from "@/lib/client/providers"; 7 import { providerName } from "@/lib/client/providers";
@@ -23,6 +23,11 @@ import { CompareInline } from "./compare-inline";
23 import { ModelPickerLauncher, type ModelPickerLauncherHandle } from "./model-launcher"; 23 import { ModelPickerLauncher, type ModelPickerLauncherHandle } from "./model-launcher";
24 import { StructuredOutputSheet, SystemPromptSheet, ToolsSheet } from "./composer-sheets"; 24 import { StructuredOutputSheet, SystemPromptSheet, ToolsSheet } from "./composer-sheets";
25 import { ConfirmDialog } from "@/components/common/confirm-dialog"; 25 import { ConfirmDialog } from "@/components/common/confirm-dialog";
26+import { openShareSheet } from "@/components/share/share-sheet";
27+import { ExportMenu } from "@/components/share/export-menu";
28+import { exportConversation } from "@/components/share/export";
29+import { usePromptInsert, consumePendingPromptInsert, decodeVars, type PromptInsertDetail } from "@/components/prompts/insert";
30+import { consumePendingAttachments } from "@/components/library/use-in-chat";
26 import { ActionSheet, type ActionSheetItem } from "@/components/ui/sheet"; 31 import { ActionSheet, type ActionSheetItem } from "@/components/ui/sheet";
27 import { Button } from "@/components/ui/button"; 32 import { Button } from "@/components/ui/button";
28 import { Badge } from "@/components/ui/badge"; 33 import { Badge } from "@/components/ui/badge";
@@ -190,15 +195,6 @@ export function ChatView({ conversationId, initial }: Props) {
190 })().catch(() => {}); 195 })().catch(() => {});
191 }, [conversationId, search, modelsByKey]); 196 }, [conversationId, search, modelsByKey]);
192 197
193− // Prompt library → composer insertion event (see docs/upgrade-notes/A-chat.md).
194− React.useEffect(() => {
195− const onInsert = (e: Event) => {
196− const text = (e as CustomEvent<{ text?: string }>).detail?.text;
197− if (text) composerRef.current?.insert(text);
198− };
199− window.addEventListener("polyllm:prompt-insert", onInsert);
200− return () => window.removeEventListener("polyllm:prompt-insert", onInsert);
201− }, []);
202 198
203 const isAuto = modelKey === AUTO_MODEL_KEY; 199 const isAuto = modelKey === AUTO_MODEL_KEY;
204 const model: PolyModel | undefined = modelKey && !isAuto ? modelsByKey.get(modelKey) : undefined; 200 const model: PolyModel | undefined = modelKey && !isAuto ? modelsByKey.get(modelKey) : undefined;
@@ -275,6 +271,60 @@ export function ChatView({ conversationId, initial }: Props) {
275 [conversation, persistConversationMeta, setSelectedModelKey], 271 [conversation, persistConversationMeta, setSelectedModelKey],
276 ); 272 );
277 273
274+ // --- cross-area integrations (prompt library, file library, onboarding, command palette) ------------
275+ const applyPromptInsert = React.useCallback(
276+ (d: PromptInsertDetail) => {
277+ if (d.kind === "system") {
278+ setSystemPrompt(d.systemPrompt ?? d.text ?? "");
279+ toast.info(`System prompt “${d.name}” applied`);
280+ } else if (d.text) {
281+ composerRef.current?.insert(d.text);
282+ }
283+ if (d.kind === "structured" && d.schema) setSettings((s) => ({ ...s, responseFormat: { type: "json_schema", schema: d.schema!, schemaName: d.name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) } }));
284+ if (d.defaultModelKey && modelsByKey.has(d.defaultModelKey) && connectedProviders.has(modelsByKey.get(d.defaultModelKey)!.provider)) changeModel(d.defaultModelKey);
285+ },
286+ [modelsByKey, connectedProviders, changeModel],
287+ );
288+ usePromptInsert(applyPromptInsert);
289+ const integrationsApplied = React.useRef(false);
290+ React.useEffect(() => {
291+ if (integrationsApplied.current) return;
292+ integrationsApplied.current = true;
293+ // Onboarding / palette: prefill the draft.
294+ const q = search.get("q");
295+ // eslint-disable-next-line react-hooks/set-state-in-effect -- one-shot URL hand-off
296+ if (q && !conversationId) setDraft(q);
297+ if (conversationId) return;
298+ // Prompt library hand-off (sessionStorage fast path, URL fallback for reloads/shared links).
299+ const pending = consumePendingPromptInsert();
300+ const promptInsert = search.get("promptInsert");
301+ if (pending) applyPromptInsert(pending);
302+ else if (promptInsert) {
303+ api<{ prompt: { id: string; name: string }; kind: PromptInsertDetail["kind"]; text: string; systemPrompt: string | null; schema: Record<string, unknown> | null; defaultModelKey: string | null }>(`/api/prompts/${promptInsert}/use`, { method: "POST", json: { variables: decodeVars(search.get("vars")) } })
304+ .then((res) => applyPromptInsert({ promptId: res.prompt.id, name: res.prompt.name, kind: res.kind, text: res.text, systemPrompt: res.systemPrompt, schema: res.schema, defaultModelKey: res.defaultModelKey, variables: {}, at: Date.now() }))
305+ .catch(() => toast.warning("Prompt not found", "It may have been deleted from your library."));
306+ }
307+ // Library files → composer chips.
308+ if (search.get("attachments")) {
309+ const list = consumePendingAttachments();
310+ if (list?.length) setAttachments((prev) => [...prev, ...list.filter((a) => !prev.some((p) => p.id === a.id))]);
311+ else toast.warning("Attachments expired", "Pick the files again from the library.");
312+ }
313+ }, [search, conversationId, applyPromptInsert]);
314+ React.useEffect(() => {
315+ const onAttach = () => composerRef.current?.openFilePicker("document");
316+ const onSwitch = (e: Event) => {
317+ const key = (e as CustomEvent<{ modelKey?: string }>).detail?.modelKey;
318+ if (key) changeModel(key);
319+ };
320+ window.addEventListener("polyllm:open-attach", onAttach);
321+ window.addEventListener("polyllm:switch-model", onSwitch);
322+ return () => {
323+ window.removeEventListener("polyllm:open-attach", onAttach);
324+ window.removeEventListener("polyllm:switch-model", onSwitch);
325+ };
326+ }, [changeModel]);
327+
278 const updateSettings = (s: ChatSettings) => { 328 const updateSettings = (s: ChatSettings) => {
279 setSettings(s); 329 setSettings(s);
280 if (conversation) void persistConversationMeta({ settings: s }); 330 if (conversation) void persistConversationMeta({ settings: s });
@@ -621,12 +671,9 @@ export function ChatView({ conversationId, initial }: Props) {
621 toast.success(`Continuing with ${modelsByKey.get(key)?.displayName ?? key}`); 671 toast.success(`Continuing with ${modelsByKey.get(key)?.displayName ?? key}`);
622 }; 672 };
623 673
624− const share = async () => { 674+ const share = () => {
625 if (!conversation) return; 675 if (!conversation) return;
626− const res = await api<{ id: string }>(`/api/conversations/${conversation.id}/actions`, { method: "POST", json: { action: "share" } }); 676+ openShareSheet({ conversationId: conversation.id, title: conversation.title, messages });
627− const url = `${window.location.origin}/share/${res.id}`;
628− await navigator.clipboard.writeText(url).catch(() => {});
629− toast.success("Share link copied", url);
630 }; 677 };
631 const deleteConversation = async () => { 678 const deleteConversation = async () => {
632 if (!conversation) return; 679 if (!conversation) return;
@@ -711,6 +758,8 @@ export function ChatView({ conversationId, initial }: Props) {
711 ? ([ 758 ? ([
712 { key: "pin", label: conversation.pinned ? "Unpin" : "Pin", icon: <Pin />, onSelect: () => persistConversationMeta({ pinned: !conversation.pinned }) }, 759 { key: "pin", label: conversation.pinned ? "Unpin" : "Pin", icon: <Pin />, onSelect: () => persistConversationMeta({ pinned: !conversation.pinned }) },
713 { key: "share", label: "Share…", icon: <Share2 />, onSelect: share }, 760 { key: "share", label: "Share…", icon: <Share2 />, onSelect: share },
761+ { key: "export-md", label: "Export Markdown", icon: <Download />, onSelect: () => void exportConversation(conversation.id, "markdown") },
762+ { key: "export-pdf", label: "Export PDF (print)", icon: <Download />, onSelect: () => void exportConversation(conversation.id, "pdf") },
714 ] as ActionSheetItem[]) 763 ] as ActionSheetItem[])
715 : []), 764 : []),
716 "separator", 765 "separator",
@@ -769,6 +818,7 @@ export function ChatView({ conversationId, initial }: Props) {
769 <Share2 /> 818 <Share2 />
770 </Button> 819 </Button>
771 </Tooltip> 820 </Tooltip>
821+ <ExportMenu conversationId={conversation.id} title={conversation.title} />
772 <Tooltip content="Delete conversation"> 822 <Tooltip content="Delete conversation">
773 <Button variant="ghost" size="icon-sm" aria-label="Delete conversation" onClick={() => setDeleteOpen(true)}> 823 <Button variant="ghost" size="icon-sm" aria-label="Delete conversation" onClick={() => setDeleteOpen(true)}>
774 <Trash2 /> 824 <Trash2 />
modified src/lib/arena/service.ts +8 −5
@@ -6,6 +6,7 @@ import { ApiError } from "@/lib/api";
6 import { getModel, touchRecent } from "@/lib/ai/registry"; 6 import { getModel, touchRecent } from "@/lib/ai/registry";
7 import { getAdapter } from "@/lib/ai/providers"; 7 import { getAdapter } from "@/lib/ai/providers";
8 import { getDecryptedKey, recordProviderOutcome } from "@/lib/providers/keys"; 8 import { getDecryptedKey, recordProviderOutcome } from "@/lib/providers/keys";
9+import { isCustomModelKey, resolveCustomEndpoint } from "@/lib/endpoints/service";
9 import { estimateCost } from "@/lib/ai/core/pricing"; 10 import { estimateCost } from "@/lib/ai/core/pricing";
10 import { filterSettings } from "@/lib/ai/core/normalize"; 11 import { filterSettings } from "@/lib/ai/core/normalize";
11 import { StreamAccumulator } from "@/lib/ai/core/stream-utils"; 12 import { StreamAccumulator } from "@/lib/ai/core/stream-utils";
@@ -23,9 +24,10 @@ export async function createArenaSession(userId: string, input: { prompt: string
23 // validate models + keys up front so the UI can show a clear error before streaming 24 // validate models + keys up front so the UI can show a clear error before streaming
24 const missing: string[] = []; 25 const missing: string[] = [];
25 for (const key of input.modelKeys) { 26 for (const key of input.modelKeys) {
26− const m = await getModel(key); 27+ const custom = isCustomModelKey(key) ? await resolveCustomEndpoint(userId, key) : null;
28+ const m = custom?.model ?? (await getModel(key));
27 if (!m) throw new ApiError(404, `Unknown model ${key}`, "MODEL_NOT_FOUND"); 29 if (!m) throw new ApiError(404, `Unknown model ${key}`, "MODEL_NOT_FOUND");
28− if (!(await getDecryptedKey(userId, m.provider))) missing.push(m.provider); 30+ if (!custom && !(await getDecryptedKey(userId, m.provider))) missing.push(m.provider);
29 } 31 }
30 if (missing.length) throw new ApiError(400, `No API key for ${[...new Set(missing)].join(", ")}.`, "NO_PROVIDER_KEY", { providers: [...new Set(missing)] }); 32 if (missing.length) throw new ApiError(400, `No API key for ${[...new Set(missing)].join(", ")}.`, "NO_PROVIDER_KEY", { providers: [...new Set(missing)] });
31 const settings: Record<string, unknown> = { ...(input.settings ?? {}), attachmentIds: input.attachmentIds ?? [] }; 33 const settings: Record<string, unknown> = { ...(input.settings ?? {}), attachmentIds: input.attachmentIds ?? [] };
@@ -244,9 +246,10 @@ export async function runArenaModel(ctx: { userId: string; requestId: string },
244 .limit(1); 246 .limit(1);
245 if (!session) throw new ApiError(404, "Arena session not found", "NOT_FOUND"); 247 if (!session) throw new ApiError(404, "Arena session not found", "NOT_FOUND");
246 if (!session.modelKeys.includes(modelKey)) throw new ApiError(400, "Model is not part of this session", "BAD_REQUEST"); 248 if (!session.modelKeys.includes(modelKey)) throw new ApiError(400, "Model is not part of this session", "BAD_REQUEST");
247− const model = await getModel(modelKey); 249+ const custom = isCustomModelKey(modelKey) ? await resolveCustomEndpoint(ctx.userId, modelKey) : null;
250+ const model = custom?.model ?? (await getModel(modelKey));
248 if (!model) throw new ApiError(404, "Unknown model", "MODEL_NOT_FOUND"); 251 if (!model) throw new ApiError(404, "Unknown model", "MODEL_NOT_FOUND");
249− const apiKey = await getDecryptedKey(ctx.userId, model.provider); 252+ const apiKey = custom?.apiKey ?? (await getDecryptedKey(ctx.userId, model.provider));
250 if (!apiKey) throw new ApiError(400, `No API key for ${model.provider}`, "NO_PROVIDER_KEY"); 253 if (!apiKey) throw new ApiError(400, `No API key for ${model.provider}`, "NO_PROVIDER_KEY");
251 254
252 const stored = (session.settings ?? {}) as Record<string, unknown>; 255 const stored = (session.settings ?? {}) as Record<string, unknown>;
@@ -273,7 +276,7 @@ export async function runArenaModel(ctx: { userId: string; requestId: string },
273 let error: PolyProviderErrorShape | undefined; 276 let error: PolyProviderErrorShape | undefined;
274 let usage: Usage | undefined; 277 let usage: Usage | undefined;
275 let exactCost: number | null = null; 278 let exactCost: number | null = null;
276− const adapter = getAdapter(model.provider); 279+ const adapter = custom?.adapter ?? getAdapter(model.provider);
277 try { 280 try {
278 const stream = adapter.streamChat({ provider: model.provider, model: model.id, apiKey, system: session.systemPrompt ?? undefined, messages: [{ role: "user", content: parts }], settings, modelInfo: model, signal, requestId: ctx.requestId }); 281 const stream = adapter.streamChat({ provider: model.provider, model: model.id, apiKey, system: session.systemPrompt ?? undefined, messages: [{ role: "user", content: parts }], settings, modelInfo: model, signal, requestId: ctx.requestId });
279 for await (const ev of stream as AsyncIterable<UnifiedStreamEvent>) { 282 for await (const ev of stream as AsyncIterable<UnifiedStreamEvent>) {
modified src/lib/chat/service.ts +23 −8
@@ -7,6 +7,8 @@ import { ApiError } from "@/lib/api";
7 import { getModel, touchRecent } from "@/lib/ai/registry"; 7 import { getModel, touchRecent } from "@/lib/ai/registry";
8 import { getAdapter } from "@/lib/ai/providers"; 8 import { getAdapter } from "@/lib/ai/providers";
9 import { getDecryptedKey, recordProviderOutcome } from "@/lib/providers/keys"; 9 import { getDecryptedKey, recordProviderOutcome } from "@/lib/providers/keys";
10+import { isCustomModelKey, resolveCustomEndpoint } from "@/lib/endpoints/service";
11+import type { AIProviderAdapter } from "@/lib/ai/core/types";
10 import { estimateCost } from "@/lib/ai/core/pricing"; 12 import { estimateCost } from "@/lib/ai/core/pricing";
11 import { StreamAccumulator } from "@/lib/ai/core/stream-utils"; 13 import { StreamAccumulator } from "@/lib/ai/core/stream-utils";
12 import { filterSettings } from "@/lib/ai/core/normalize"; 14 import { filterSettings } from "@/lib/ai/core/normalize";
@@ -110,6 +112,8 @@ export interface PreparedTurn {
110 conversation: Conversation; 112 conversation: Conversation;
111 model: PolyModel; 113 model: PolyModel;
112 apiKey: string; 114 apiKey: string;
115+ /** Custom endpoint adapter; undefined = registry provider adapter. */
116+ adapter?: AIProviderAdapter;
113 history: UnifiedMessage[]; 117 history: UnifiedMessage[];
114 userMessage: Message | null; 118 userMessage: Message | null;
115 assistantMessage: Message; 119 assistantMessage: Message;
@@ -161,10 +165,13 @@ function toUnifiedSettings(s: GenerationSettingsInput | undefined): { settings:
161 165
162 export async function prepareTurn(ctx: ChatContext, input: ChatRequestInput): Promise<PreparedTurn> { 166 export async function prepareTurn(ctx: ChatContext, input: ChatRequestInput): Promise<PreparedTurn> {
163 const db = getDb(); 167 const db = getDb();
164− const model = await getModel(input.modelKey); 168+ // Custom OpenAI-compatible endpoints (Settings → Endpoints) resolve to their own adapter + key.
165− if (!model) throw new ApiError(404, "Unknown model. Refresh your model list.", "MODEL_NOT_FOUND"); 169+ const custom = isCustomModelKey(input.modelKey) ? await resolveCustomEndpoint(ctx.userId, input.modelKey) : null;
166− const apiKey = await getDecryptedKey(ctx.userId, model.provider); 170+ const model = custom?.model ?? (await getModel(input.modelKey));
171+ if (!model) throw new ApiError(404, isCustomModelKey(input.modelKey) ? "This custom endpoint no longer exists. Check Settings → Endpoints." : "Unknown model. Refresh your model list.", "MODEL_NOT_FOUND");
172+ const apiKey = custom?.apiKey ?? (await getDecryptedKey(ctx.userId, model.provider));
167 if (!apiKey) throw new ApiError(400, `No API key configured for ${model.provider}. Add one in Settings → Providers.`, "NO_PROVIDER_KEY", { provider: model.provider }); 173 if (!apiKey) throw new ApiError(400, `No API key configured for ${model.provider}. Add one in Settings → Providers.`, "NO_PROVIDER_KEY", { provider: model.provider });
174+ const adapter = custom?.adapter;
168 175
169 const { settings: rawSettings, toolIds } = toUnifiedSettings(input.settings); 176 const { settings: rawSettings, toolIds } = toUnifiedSettings(input.settings);
170 const { settings } = filterSettings(rawSettings, model); 177 const { settings } = filterSettings(rawSettings, model);
@@ -205,7 +212,9 @@ export async function prepareTurn(ctx: ChatContext, input: ChatRequestInput): Pr
205 const userMessage = syntheticMessage(ctx.userId, conversation.id, "user", input.message.text, parts); 212 const userMessage = syntheticMessage(ctx.userId, conversation.id, "user", input.message.text, parts);
206 const assistantMessage = syntheticMessage(ctx.userId, conversation.id, "assistant", "", [], { status: "streaming", modelKey: model.key, provider: model.provider, settings: input.settings ?? null }); 213 const assistantMessage = syntheticMessage(ctx.userId, conversation.id, "assistant", "", [], { status: "streaming", modelKey: model.key, provider: model.provider, settings: input.settings ?? null });
207 const history = await toUnifiedMessages(ctx.userId, [...rows, userMessage], model); 214 const history = await toUnifiedMessages(ctx.userId, [...rows, userMessage], model);
208− return { conversation, model, apiKey, history, userMessage, assistantMessage, settings, toolIds, systemPrompt: input.systemPrompt ?? undefined, isNewConversation: false, continuation: false, ephemeral: true }; 215+ return {
216+ adapter,
217+ conversation, model, apiKey, history, userMessage, assistantMessage, settings, toolIds, systemPrompt: input.systemPrompt ?? undefined, isNewConversation: false, continuation: false, ephemeral: true };
209 } 218 }
210 219
211 // --- conversation -------------------------------------------------------- 220 // --- conversation --------------------------------------------------------
@@ -276,7 +285,9 @@ export async function prepareTurn(ctx: ChatContext, input: ChatRequestInput): Pr
276 .values({ id: ids.message(), conversationId: conversation.id, userId: ctx.userId, role: "assistant", content: "", parts: [], status: "streaming", modelKey: model.key, provider: model.provider, settings: input.settings ?? null, parentMessageId: target.parentMessageId ?? target.id, version: target.version + 1 }) 285 .values({ id: ids.message(), conversationId: conversation.id, userId: ctx.userId, role: "assistant", content: "", parts: [], status: "streaming", modelKey: model.key, provider: model.provider, settings: input.settings ?? null, parentMessageId: target.parentMessageId ?? target.id, version: target.version + 1 })
277 .returning(); 286 .returning();
278 const history = await toUnifiedMessages(ctx.userId, rows, model); 287 const history = await toUnifiedMessages(ctx.userId, rows, model);
279− return { conversation, model, apiKey, history, userMessage: null, assistantMessage: am, settings, toolIds, systemPrompt, isNewConversation: false, continuation: false, ephemeral: false }; 288+ return {
289+ adapter,
290+ conversation, model, apiKey, history, userMessage: null, assistantMessage: am, settings, toolIds, systemPrompt, isNewConversation: false, continuation: false, ephemeral: false };
280 } else if (input.action === "continue") { 291 } else if (input.action === "continue") {
281 if (!input.targetMessageId) throw new ApiError(400, "targetMessageId is required", "BAD_REQUEST"); 292 if (!input.targetMessageId) throw new ApiError(400, "targetMessageId is required", "BAD_REQUEST");
282 const target = rows[rows.length - 1]; 293 const target = rows[rows.length - 1];
@@ -285,7 +296,9 @@ export async function prepareTurn(ctx: ChatContext, input: ChatRequestInput): Pr
285 await db.update(messages).set({ status: "streaming", updatedAt: now }).where(eq(messages.id, target.id)); 296 await db.update(messages).set({ status: "streaming", updatedAt: now }).where(eq(messages.id, target.id));
286 const history = await toUnifiedMessages(ctx.userId, rows, model); 297 const history = await toUnifiedMessages(ctx.userId, rows, model);
287 history.push({ role: "user", content: [{ type: "text", text: "Continue exactly where you left off, without repeating anything you already wrote." }] }); 298 history.push({ role: "user", content: [{ type: "text", text: "Continue exactly where you left off, without repeating anything you already wrote." }] });
288− return { conversation, model, apiKey, history, userMessage: null, assistantMessage: target, settings, toolIds, systemPrompt, isNewConversation: false, continuation, ephemeral: false }; 299+ return {
300+ adapter,
301+ conversation, model, apiKey, history, userMessage: null, assistantMessage: target, settings, toolIds, systemPrompt, isNewConversation: false, continuation, ephemeral: false };
289 } 302 }
290 303
291 const [am] = await db 304 const [am] = await db
@@ -293,7 +306,9 @@ export async function prepareTurn(ctx: ChatContext, input: ChatRequestInput): Pr
293 .values({ id: ids.message(), conversationId: conversation.id, userId: ctx.userId, role: "assistant", content: "", parts: [], status: "streaming", modelKey: model.key, provider: model.provider, settings: input.settings ?? null }) 306 .values({ id: ids.message(), conversationId: conversation.id, userId: ctx.userId, role: "assistant", content: "", parts: [], status: "streaming", modelKey: model.key, provider: model.provider, settings: input.settings ?? null })
294 .returning(); 307 .returning();
295 const history = await toUnifiedMessages(ctx.userId, rows, model); 308 const history = await toUnifiedMessages(ctx.userId, rows, model);
296− return { conversation, model, apiKey, history, userMessage, assistantMessage: am, settings, toolIds, systemPrompt, isNewConversation: isNew, continuation, ephemeral: false }; 309+ return {
310+ adapter,
311+ conversation, model, apiKey, history, userMessage, assistantMessage: am, settings, toolIds, systemPrompt, isNewConversation: isNew, continuation, ephemeral: false };
297 } 312 }
298 313
299 export interface TurnOutcome { 314 export interface TurnOutcome {
@@ -314,7 +329,7 @@ export interface TurnOutcome {
314 */ 329 */
315 export async function runTurn(ctx: ChatContext, turn: PreparedTurn, emit: (ev: unknown) => void, signal: AbortSignal): Promise<TurnOutcome> { 330 export async function runTurn(ctx: ChatContext, turn: PreparedTurn, emit: (ev: unknown) => void, signal: AbortSignal): Promise<TurnOutcome> {
316 const db = getDb(); 331 const db = getDb();
317− const adapter = getAdapter(turn.model.provider); 332+ const adapter = turn.adapter ?? getAdapter(turn.model.provider);
318 const tools = resolveTools(turn.toolIds).filter(() => turn.model.capabilities.tools); 333 const tools = resolveTools(turn.toolIds).filter(() => turn.model.capabilities.tools);
319 const t0 = Date.now(); 334 const t0 = Date.now();
320 const previousParts = turn.continuation ? (turn.assistantMessage.parts as StoredPart[]) : []; 335 const previousParts = turn.continuation ? (turn.assistantMessage.parts as StoredPart[]) : [];
321 336
Menu

Theme