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>
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 | |