试玩是一个全屏应用:左边线程列表,右边会话,站头之下整个视口都归它。
本分区其余 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
route 的 options 全表见 接入提供者。
createByok
createByok(options?): ByokClient——带本库默认值的 defineByok(),直接给 useChat({ byok })。
useServerCoverage
useServerCoverage(byok, url?): ServerCoverage——url 默认 '/api/ai/catalog',挂载时请求一次,
成功后调 byok.setServerCoverage()。
fetchServerSentEvents
fetchServerSentEvents(url, options?) 是 TanStack AI 的连接适配器,从 root 入口原样转发:
返回 ResumableConnectConnectionAdapter,给 useChat({ connection })。url 可以是函数,
options 可以是函数或异步函数(按次请求算 headers);BYOK 头由 ChatClient 在每次
send 时合进去,不用你自己写。