SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%

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 13 days ago (Sep 11, 2026) parent 8668939

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 3 import Link from "next/link";
4 4 import { Download, Trash2, Info } from "lucide-react";
5 5 import { Card, CardHeader, CardBody } from "@/components/ui/misc";
6 +import { ShareLinksList } from "@/components/share/share-sheet";
6 7 import { Button } from "@/components/ui/button";
7 8 import { Textarea, Field } from "@/components/ui/input";
8 9 import { toast } from "@/components/ui/toast";
@@ -111,6 +112,13 @@ export default function DataSettingsPage() {
111 112 </CardBody>
112 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 122 <Card>
115 123 <CardHeader title="Retention" description="What we keep and for how long." />
116 124 <CardBody>
modified src/components/chat/chat-view.tsx +65 −15
@@ -1,7 +1,7 @@
1 1 "use client";
2 2 import * as React from "react";
3 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 5 import Link from "next/link";
6 6 import { useApp, invalidateConversations, AUTO_MODEL_KEY } from "@/components/app/store";
7 7 import { providerName } from "@/lib/client/providers";
@@ -23,6 +23,11 @@ import { CompareInline } from "./compare-inline";
23 23 import { ModelPickerLauncher, type ModelPickerLauncherHandle } from "./model-launcher";
24 24 import { StructuredOutputSheet, SystemPromptSheet, ToolsSheet } from "./composer-sheets";
25 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 31 import { ActionSheet, type ActionSheetItem } from "@/components/ui/sheet";
27 32 import { Button } from "@/components/ui/button";
28 33 import { Badge } from "@/components/ui/badge";
@@ -190,15 +195,6 @@ export function ChatView({ conversationId, initial }: Props) {
190 195 })().catch(() => {});
191 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 199 const isAuto = modelKey === AUTO_MODEL_KEY;
204 200 const model: PolyModel | undefined = modelKey && !isAuto ? modelsByKey.get(modelKey) : undefined;
@@ -275,6 +271,60 @@ export function ChatView({ conversationId, initial }: Props) {
275 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 328 const updateSettings = (s: ChatSettings) => {
279 329 setSettings(s);
280 330 if (conversation) void persistConversationMeta({ settings: s });
@@ -621,12 +671,9 @@ export function ChatView({ conversationId, initial }: Props) {
621 671 toast.success(`Continuing with ${modelsByKey.get(key)?.displayName ?? key}`);
622 672 };
623 673
624 const share = async () => {
674 + const share = () => {
625 675 if (!conversation) return;
626 const res = await api<{ id: string }>(`/api/conversations/${conversation.id}/actions`, { method: "POST", json: { action: "share" } });
627 const url = `${window.location.origin}/share/${res.id}`;
628 await navigator.clipboard.writeText(url).catch(() => {});
629 toast.success("Share link copied", url);
676 + openShareSheet({ conversationId: conversation.id, title: conversation.title, messages });
630 677 };
631 678 const deleteConversation = async () => {
632 679 if (!conversation) return;
@@ -711,6 +758,8 @@ export function ChatView({ conversationId, initial }: Props) {
711 758 ? ([
712 759 { key: "pin", label: conversation.pinned ? "Unpin" : "Pin", icon: <Pin />, onSelect: () => persistConversationMeta({ pinned: !conversation.pinned }) },
713 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 763 ] as ActionSheetItem[])
715 764 : []),
716 765 "separator",
@@ -769,6 +818,7 @@ export function ChatView({ conversationId, initial }: Props) {
769 818 <Share2 />
770 819 </Button>
771 820 </Tooltip>
821 + <ExportMenu conversationId={conversation.id} title={conversation.title} />
772 822 <Tooltip content="Delete conversation">
773 823 <Button variant="ghost" size="icon-sm" aria-label="Delete conversation" onClick={() => setDeleteOpen(true)}>
774 824 <Trash2 />
modified src/lib/arena/service.ts +8 −5
@@ -6,6 +6,7 @@ import { ApiError } from "@/lib/api";
6 6 import { getModel, touchRecent } from "@/lib/ai/registry";
7 7 import { getAdapter } from "@/lib/ai/providers";
8 8 import { getDecryptedKey, recordProviderOutcome } from "@/lib/providers/keys";
9 +import { isCustomModelKey, resolveCustomEndpoint } from "@/lib/endpoints/service";
9 10 import { estimateCost } from "@/lib/ai/core/pricing";
10 11 import { filterSettings } from "@/lib/ai/core/normalize";
11 12 import { StreamAccumulator } from "@/lib/ai/core/stream-utils";
@@ -23,9 +24,10 @@ export async function createArenaSession(userId: string, input: { prompt: string
23 24 // validate models + keys up front so the UI can show a clear error before streaming
24 25 const missing: string[] = [];
25 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 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 32 if (missing.length) throw new ApiError(400, `No API key for ${[...new Set(missing)].join(", ")}.`, "NO_PROVIDER_KEY", { providers: [...new Set(missing)] });
31 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 246 .limit(1);
245 247 if (!session) throw new ApiError(404, "Arena session not found", "NOT_FOUND");
246 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 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 253 if (!apiKey) throw new ApiError(400, `No API key for ${model.provider}`, "NO_PROVIDER_KEY");
251 254
252 255 const stored = (session.settings ?? {}) as Record<string, unknown>;
@@ -273,7 +276,7 @@ export async function runArenaModel(ctx: { userId: string; requestId: string },
273 276 let error: PolyProviderErrorShape | undefined;
274 277 let usage: Usage | undefined;
275 278 let exactCost: number | null = null;
276 const adapter = getAdapter(model.provider);
279 + const adapter = custom?.adapter ?? getAdapter(model.provider);
277 280 try {
278 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 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 7 import { getModel, touchRecent } from "@/lib/ai/registry";
8 8 import { getAdapter } from "@/lib/ai/providers";
9 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 12 import { estimateCost } from "@/lib/ai/core/pricing";
11 13 import { StreamAccumulator } from "@/lib/ai/core/stream-utils";
12 14 import { filterSettings } from "@/lib/ai/core/normalize";
@@ -110,6 +112,8 @@ export interface PreparedTurn {
110 112 conversation: Conversation;
111 113 model: PolyModel;
112 114 apiKey: string;
115 + /** Custom endpoint adapter; undefined = registry provider adapter. */
116 + adapter?: AIProviderAdapter;
113 117 history: UnifiedMessage[];
114 118 userMessage: Message | null;
115 119 assistantMessage: Message;
@@ -161,10 +165,13 @@ function toUnifiedSettings(s: GenerationSettingsInput | undefined): { settings:
161 165
162 166 export async function prepareTurn(ctx: ChatContext, input: ChatRequestInput): Promise<PreparedTurn> {
163 167 const db = getDb();
164 const model = await getModel(input.modelKey);
165 if (!model) throw new ApiError(404, "Unknown model. Refresh your model list.", "MODEL_NOT_FOUND");
166 const apiKey = await getDecryptedKey(ctx.userId, model.provider);
168 + // Custom OpenAI-compatible endpoints (Settings → Endpoints) resolve to their own adapter + key.
169 + const custom = isCustomModelKey(input.modelKey) ? await resolveCustomEndpoint(ctx.userId, input.modelKey) : null;
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 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 176 const { settings: rawSettings, toolIds } = toUnifiedSettings(input.settings);
170 177 const { settings } = filterSettings(rawSettings, model);
@@ -205,7 +212,9 @@ export async function prepareTurn(ctx: ChatContext, input: ChatRequestInput): Pr
205 212 const userMessage = syntheticMessage(ctx.userId, conversation.id, "user", input.message.text, parts);
206 213 const assistantMessage = syntheticMessage(ctx.userId, conversation.id, "assistant", "", [], { status: "streaming", modelKey: model.key, provider: model.provider, settings: input.settings ?? null });
207 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 220 // --- conversation --------------------------------------------------------
@@ -276,7 +285,9 @@ export async function prepareTurn(ctx: ChatContext, input: ChatRequestInput): Pr
276 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 286 .returning();
278 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 291 } else if (input.action === "continue") {
281 292 if (!input.targetMessageId) throw new ApiError(400, "targetMessageId is required", "BAD_REQUEST");
282 293 const target = rows[rows.length - 1];
@@ -285,7 +296,9 @@ export async function prepareTurn(ctx: ChatContext, input: ChatRequestInput): Pr
285 296 await db.update(messages).set({ status: "streaming", updatedAt: now }).where(eq(messages.id, target.id));
286 297 const history = await toUnifiedMessages(ctx.userId, rows, model);
287 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 304 const [am] = await db
@@ -293,7 +306,9 @@ export async function prepareTurn(ctx: ChatContext, input: ChatRequestInput): Pr
293 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 307 .returning();
295 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 314 export interface TurnOutcome {
@@ -314,7 +329,7 @@ export interface TurnOutcome {
314 329 */
315 330 export async function runTurn(ctx: ChatContext, turn: PreparedTurn, emit: (ev: unknown) => void, signal: AbortSignal): Promise<TurnOutcome> {
316 331 const db = getDb();
317 const adapter = getAdapter(turn.model.provider);
332 + const adapter = turn.adapter ?? getAdapter(turn.model.provider);
318 333 const tools = resolveTools(turn.toolIds).filter(() => turn.model.capabilities.tools);
319 334 const t0 = Date.now();
320 335 const previousParts = turn.continuation ? (turn.assistantMessage.parts as StoredPart[]) : [];
321 336