Cadenza
EN

用你自己的 API key 和真实模型对话 —— 同一套 Transcript / Composer,传输换成 /api/ai/chat

试玩是一个全屏应用:左边线程列表,右边会话,站头之下整个视口都归它。

打开试玩 →

本分区其余 demo 都在回放脚本;这一页接真模型。本站不持有任何 provider 的 key:你贴进来的 key 用这台设备的 passkey 加密后存在浏览器的 IndexedDB 里(createByok({ persistent: true })), 每次请求以 x-byok-<provider> 头发给 /api/ai/chat,route 只在这一次请求里用它建 adapter。 刷新后密钥环是锁着的:第一次发送会弹出对话框让你 Unlock(一次 Touch ID / PIN),解锁即续发。 浏览器不支持 passkey 时退回内存(对话框会写明),刷新即失。

使用

没有 key 就直接发消息也行:消息留在记录里、下面出一条「Missing … API key」,密钥对话框自己打开并把焦点放在这家的输入框上; 填好按 Enter(或 Confirm)保存并关闭,对话框一关,Playground 就把那条消息重新发出去(onOpenChange 关闭时调 chat.reload())。 听写同理:缺 OpenAI key 时录下的那段音频会在 key 填好后补转写。错误条里也有 Retry。

左栏底部的 Pin / Follow 切换记录区的滚动方式:Pin 是 ChatGPT 的做法——发出的消息钉到视口顶部、回复在下方展开;Follow 是经典跟底。选择存在 localStorage。

三步:贴 key → 选模型 → 发送。没 key 直接发送也行——route 回 401,对话框自己弹出来要 key, 保存后再发一次。OpenRouter 那一行还有「Sign in with OpenRouter」:走 PKCE 去 openrouter.ai 登录,回来 key 自动填入——需要一个 OpenRouter 账号,本站没法替你验证这一步。

要在自己的站点里复现这一页,客户端换掉的只有传输与密钥两处:

import { ByokKeyDialog, createByok, defaultCatalog, fetchServerSentEvents, useChat, useModelSelection, useServerCoverage } from '@gedatou/cadenza-ai'
const [byok] = useState(() => createByok({ catalog: defaultCatalog }))
const { coverage } = useServerCoverage(byok)
const sel = useModelSelection()
const chat = useChat({
  connection: fetchServerSentEvents('/api/ai/chat'),
  forwardedProps: sel.forwardedProps,
  byok,
  byokProvider: () => sel.selection.provider,
})
 
<ByokKeyDialog byok={byok} catalog={defaultCatalog} coverage={coverage} />

服务端是 Route handler 一节里的那份 docs/app/api/ai/chat/route.ts。 createByok() 默认 memoryStorage()——key 只在内存;persistent: true 换成 passkeyStorage(), 用 WebAuthn 解锁、跨刷新保留。发送前客户端调 byok.prepare(provider),再把这一家的 key 写成请求头;byokProvider 决定「这一家」是谁,缺省从 body 的 provider 字段读。

听写

composer 工具栏最左边是 ComposerDictate:按一下开始录音,再按一下停止,录音以 data URL 发给 /api/ai/transcription,转写结果追加到草稿末尾(已经打了字不会被覆盖)。转写走 OpenAI, byokProvider: () => 'openai',所以需要 OpenAI 的 key;转写期间按钮禁用,出错时错误条显示在 输入框上方。草稿由页面持有——ChatShell 的 value / onValueChange 通道——转写只是往里写字:

const [draft, setDraft] = useState('')
const transcription = useTranscription({
  connection: fetchServerSentEvents('/api/ai/transcription'),
  byok,
  byokProvider: () => 'openai',
  onResult: r => setDraft(d => (d === '' ? r.text : `${d} ${r.text}`)),
})
 
<ComposerDictate
  disabled={transcription.isLoading}
  onRecording={part => void transcription.generate({ audio: `data:${part.source.mimeType};base64,${part.source.value}` })}
/>

按模型出现的开关

工具栏是 ComposerDictate → ModelPicker → ThinkingToggle → ThinkingLevelPicker → SearchToggle → 密钥, 后三个都按当前模型的能力显隐,所以换模型时工具栏自己会变:

  • DeepThink(ThinkingToggle):不支持推理的模型不渲染;开启落到 'high' 按模型 clamp 后的档。
  • 档位下拉(ThinkingLevelPicker):只在思考已开启、且模型有多于一档可选时出现(DeepSeek 两个型号都会露出来)。
  • Search(SearchToggle):只有 Model.search 为真的模型才渲染,目前是 DeepSeek 的两个 V4。 打开它,forwardedProps.search 变成 true,/api/ai/chat 才把 deepseekWebSearch() 挂进那一轮的 tools;关着就完全不发那把工具。选择连同开关一起存在 localStorage 的 docs-playground:selection, 换到没有该能力的模型时会被清掉。

搜索在 DeepSeek 服务端执行,记在用户自己那把 key 上,我们这边不跑任何东西——但每一轮会以一张 web_search 工具卡片出现在回答里,输入是它记录的那次动作。剩下的缺口是 url 引用仍拿不到, 原因见 providers。

自动标题

照 ChatGPT 的做法:新线程在列表里先叫 New chat(ThreadList 的 untitled 文案——库里没有默认文案, 由页面给),threadPersistence(index, base, { title: false }) 关掉「取首条消息前 40 字」的派生标题; 第一条 assistant 回复完成时(useChat({ onFinish }))把这一问一答发给 /api/ai/title, 由这条线程自己选的模型(同一个 BYOK 头,thinking 关)起一个 ≤ 6 词、与用户同语言、无引号无句号的标题, index.rename(threadId, title);只起一次,已经改过名的线程不动;请求失败才退回首条消息派生的标题。

const summary = useSummarize({
  connection: fetchServerSentEvents('/api/ai/title'),
  byok,
  byokProvider: () => sel.selection.provider,
  onResult: r => index.rename(threadId, r.summary.trim()),
  onError: () => index.rename(threadId, threadTitleFrom(messages)),
})

组件检索

助手多了一个服务端工具 search_components:问「哪个组件 / 部件 / prop 能做 X」时,它先去 组件文档里做一次语义检索,再带着命中的小节和链接回答。索引在 Upstash Vector (Vercel Marketplace 的 Native 集成)里,建索引时选的是 Upstash 托管的 text-embedding-3-small(经 Vercel 建索引只有它和「不选」两个选项),所以 入库和查询都直接传文本,本站不需要任何 embedding 模型的 key。服务端唯一持有的凭据是 集成注入的 UPSTASH_VECTOR_REST_URL / UPSTASH_VECTOR_REST_TOKEN;没有它们,工具不注册。

索引由 docs/scripts/index-components.ts 生成:读 content/docs/components/*.mdx,按 H2 切块(超长的再按 H3 切);再把每个 demo 的源码整块入库,挂在展示它的那一页那一节的锚点上(靠 页面里的 <ComponentPreview name> 反查),所以「给我看 X 的示例源码」也能命中;再把 packages/ui/src 的库源码按空行分块入库,链接指向 GitHub 上对应的行;中英文各一个 namespace;vercel.json 的 buildCommand 在 next build 之后跑它,只有 production 构建会写索引,preview 与 CI 跳过。本地重建:

cd docs && vercel env pull              # 拿到集成注入的两个变量
pnpm --filter docs index:components --dry-run   # 只切块、报数量
pnpm --filter docs index:components

思路

  • 纯 BYOK 是选出来的。 getByokKey 头优先、env 兜底,所以配不配服务端 key 只是部署时 设不设环境变量。本站不设:没有 key 的请求得到 401 { error: { type: 'byok_missing', provider } }, 客户端调 byok.request(provider, 'missing'),ByokKeyDialog 打开并聚焦那一家的输入框。 费用、限流、模型权限都跟着你的 key 走,本站不做代理配额。
  • useServerCoverage 把服务端的情况告诉客户端。 它 GET /api/ai/catalog 一次,拿回 providers(目录里的纯数据)与 coverage(每家是否有服务端 key:keyRequired: false 的恒为 true,其余看 env),然后调 byok.setServerCoverage(coverage),prepare() 对被覆盖的 provider 不再要 key;同一份 coverage 传给对话框,它给那一行标「Server key」。在纯 BYOK 部署里四家 全是 false。
  • maxDuration 决定一条回复能流多久。 route 里 export const maxDuration = 300:流式回复 受 serverless 函数时长上限约束,按你的 Vercel 计划调。

限制

  • 四家 provider。 route 接了 openai / anthropic / gemini / openrouter (@gedatou/cadenza-ai/providers/* 目前就这四个 preset);目录里其余 provider 在 模型目录 里可见,但这一页选不到。
  • 附件每条 3 MiB。 DEFAULT_MAX_ATTACHMENT_BYTES 是 base64 进 JSON body 的安全线;route 的 maxBodyBytes 默认 4 MiB,超过回 413。
  • 时长按计划。 Vercel Hobby 60 s、Pro 300 s;长推理的回复可能在上限处被切断, TranscriptError 会报出来,重试即可。
  • 本站零费用。 没有服务端 key,就没有任何请求能记到本站头上;记到你 key 头上的费用 可以在 用量与费用 的部件里看到估算。
  • 工具。 route 注册了两个服务端工具:get_time 与 search_components(后者只在有 Upstash 凭据时注册,见 组件检索);客户端声明的工具经 mergeAgentTools 合并,与 客户端工具 的 demo 同一套机制。

API

这一页用到的四条 route 与三个客户端导出。adapter 由 route 传入(createOpenaiTranscription / createOpenaiSummarize),库的 server 入口不 import 任何 adapter 包。

Route

路由方法说明
/api/ai/chatPOSTcreateChatHandler({ providers, defaultModel, systemPrompts, tools }):体积门禁 → 解析 → pickSelection(只信 forwardedProps 的 provider / model / thinking)→ onSelect → BYOK key → adapter → thinking → chat() → SSE
/api/ai/chatGET只在配了 persistence(?threadId=)或 durability(?runId= / last-event-id)时有内容,否则 404
/api/ai/catalogGETcreateCatalogHandler(presets):{ providers, coverage, generatedAt }
/api/ai/transcriptionPOSTcreateTranscriptionHandler({ adapter, byok, defaultModel }):体积门禁(默认 8 MiB)→ generationParamsFromRequest('transcription') → forwardedProps.model ?? defaultModel → BYOK key → generateTranscription({ stream: true }) → SSE,结果在 generation:result 事件里
/api/ai/summarizePOSTcreateSummarizeHandler({ adapter, byok, defaultModel }):同一生命周期,body 取 { data: { text, style, maxLength, focus } } → summarize({ stream: true }) → SSE
/api/ai/titlePOSTcreateTitleHandler({ providers, defaultModel, maxWords?, prompt? }):同一 envelope,但按 forwardedProps 选线程自己的 preset(thinking 关),用 ChatGPT 式提示词让模型起标题,cleanTitle 去掉引号 / 围栏 / 句尾标点,回复仍是 summarize 形态的 SSE,所以客户端还是 useSummarize

route 的 options 全表见 接入提供者。

createByok

createByok(options?): ByokClient——带本库默认值的 defineByok(),直接给 useChat({ byok })。

选项类型默认值说明
persistentbooleanfalsetrue 用 passkeyStorage()(passkey 解锁、跨刷新);否则 memoryStorage()
catalogCatalog—传了就把 keyRequired: false 的 provider 预先标成服务端覆盖

useServerCoverage

useServerCoverage(byok, url?): ServerCoverage——url 默认 '/api/ai/catalog',挂载时请求一次, 成功后调 byok.setServerCoverage()。

字段类型说明
coverageRecord<string, boolean> | undefined每家是否有服务端 key;请求完成前 undefined
providersreadonly Provider[] | undefined服务端接了哪些 provider(纯数据)
errorError | undefined非 2xx 或网络错误;cadenza-ai: <url> answered <status>

fetchServerSentEvents

fetchServerSentEvents(url, options?) 是 TanStack AI 的连接适配器,从 root 入口原样转发: 返回 ResumableConnectConnectionAdapter,给 useChat({ connection })。url 可以是函数, options 可以是函数或异步函数(按次请求算 headers);BYOK 头由 ChatClient 在每次 send 时合进去,不用你自己写。