scripted() 把一个「返回步骤列表的函数」变成 useChat({ fetcher }) 收的 ChatFetcher。
每一步翻成 AG-UI 事件、按节奏交给客户端,所以 Transcript、部件、审批、用量统计看到的
与 /api/ai/chat 回来的一模一样。本分区的 demo 全部用它,包的单测也用它。
使用
import { useChat } from '@gedatou/cadenza-ai'
import { reasoning, scripted, text, tool, usage } from '@gedatou/cadenza-ai/mock'const [fetcher] = useState(() => scripted(() => [
reasoning('Loudest work last; harps move once.'),
tool('get_time', { tz: 'Europe/Paris' }, { output: { iso: '2026-10-14T19:30:00+02:00' } }),
text('Ravel opens; La Mer follows; interval after La Mer.'),
usage({ inputTokens: 412, outputTokens: 96 }),
]))
const chat = useChat({ fetcher })./mock 是独立子入口,只依赖 @tanstack/ai/client——进浏览器包不会带上任何 provider adapter。
fetcher 放在 useState(() => scripted(...)) 里,让它随组件生命周期存在(turn 计数挂在这个实例上)。
思路
- fetcher 形态。TanStack AI 的
useChat收connection或fetcher;ChatFetcher是(input, { signal, headers }) => Response | AsyncIterable<StreamChunk>。scripted()返回的就是 这个签名,与fetchServerSentEvents('/api/ai/chat')对useChat是同一层——换一行就切到真实服务。 - 事件级 DSL。步骤是纯数据(
{ kind: 'text', content }),scripted()内部把它翻成 AG-UI 事件:TEXT_MESSAGE_START / CONTENT / END、REASONING_*、TOOL_CALL_START / ARGS / END / RESULT、CUSTOM、RUN_FINISHED。它模拟的是线路协议,不是 UI:ToolCallPart.state的七态、审批中断的 绑定元数据、RUN_FINISHED.usage都按引擎的形状造。 - 同一条客户端管线。chunk 与真实 route 的一样,走同一个 stream processor;
onChunk/onFinish/useUsageTracker/PartRenderersProvider都不知道底下是 mock。demo 里证明的东西, 接上真模型后同样成立。 - 不模拟的。不跑模型、不执行服务端工具(
output是你写死的);approval/client类工具 停在中断上,由下一轮脚本读ctx.resume决定后续;./mock只有 fetcher 形态——需要服务端hydrate或持久化重建的场景用真 route。
本站所有 demo 共用一份固定模式——docs/demos/ai/mock.ts 里的 mockFetcher:
sse: true(真的以 text/event-stream 作答,客户端跑它的 SSE 解析器),chunk: 'char'
(文本与推理逐字到达),pace: 12(每字 12 ms),argsChunk: 4(工具参数每 4 个字符一片)。
每个 demo 只换脚本,不换节奏;你看到的流式形态就是生产环境的形态。
import { scripted } from '@gedatou/cadenza-ai/mock'
export const MOCK = { sse: true, chunk: 'char', pace: 12, argsChunk: 4 } as const
export function mockFetcher(script: Script, options: ScriptedOptions = {}): ChatFetcher {
return scripted(script, { ...MOCK, ...options })
}步骤
每个构造器各来一次——页顶的 demo 就是这份脚本。
节奏由两个开关决定:pace 是每个分片之间的毫秒数(默认 24,'instant' 跳过计时器),
chunk 是切片方式(默认 'word';'char' 或一个字符数)。两者都能在 text / reasoning
的第二个参数上按步覆盖。stop() 触发 signal,脚本从当前分片处停下,不再发 RUN_FINISHED——
客户端把它当作正常停止,status 回到 'ready'。
多轮
脚本收到一个 ScriptContext:最后一条用户消息、合并后的 data、这是第几轮、上一轮中断的
决议。四个助手覆盖常见的分支方式。
respond(rules, fallback?):按lastUserText挑第一条命中的规则——RegExp用test, 字符串用includes,函数收整个ctx;都不命中走fallback(默认echo())。sequence(turns):第 n 轮回turns[n],过了末尾一直回最后一项。echo({ chunk?, pace? }):复述最后一条用户消息,附上附件的 MIME 类型与data.model(有的话)——附件、模型选择 的 demo 用它证明东西确实送到了。approvalOf(ctx, toolCallId)/clientResultOf(ctx, toolCallId):读上一轮中断的决议。approval: true的工具第二轮拿到{ approved, editedArgs?, payload? };client: true的工具 拿到浏览器端的输出。
审批要跑通,客户端得注册同名工具,且 inputSchema 与脚本使用的宽松 schema 一致
(客户端按 schema 哈希绑定 tool-approval 中断):
import { approvalOf, scripted, sequence, text, tool } from '@gedatou/cadenza-ai/mock'
import { toolDefinition, useChat } from '@gedatou/cadenza-ai'
const move = toolDefinition({ name: 'move', description: 'Move the meeting', inputSchema: { type: 'object', additionalProperties: true }, needsApproval: true })
const fetcher = scripted(sequence([
[tool('move', { day: 'Fri' }, { approval: true })],
ctx => (approvalOf(ctx, 'call-1')?.approved
? [tool.result('call-1', { moved: true }), text('Moved.')]
: [text('Left alone.')]),
]), { toolCallId: () => 'call-1' })
const chat = useChat({ fetcher, tools: [move] })脚本也可以直接返回一个 Response,客户端按真实响应处理。byokMissing('openai') 造的就是
route 在缺 key 时返回的那个 401:客户端读出 byok_missing,调 byok.request('openai', 'missing'),
ByokKeyDialog 随之打开。
测试
同一个 fetcher 可以直接接在 ChatClient 上,不需要 React 与 DOM——packages/ai/test/scripted.test.ts
就是这样跑的:
import { ChatClient } from '@tanstack/ai-client'
import { expect, it } from 'vitest'
import { reasoning, scripted, text, tool, usage } from '@gedatou/cadenza-ai/mock'
function settle(client: ChatClient): Promise<void> {
return new Promise((resolve) => {
const tick = (): void => (client.getIsLoading() ? setTimeout(tick, 5) : resolve())
tick()
})
}
it('streams text, reasoning and a tool call into parts', async () => {
const fetcher = scripted(() => [
reasoning('Think.'),
tool('get_time', { tz: 'UTC' }, { output: { iso: '2026-08-28' } }),
text('Done.'),
usage({ inputTokens: 12, outputTokens: 3 }),
], { pace: 'instant' })
const client = new ChatClient({ fetcher })
client.attach()
await client.sendMessage('hi')
await settle(client)
const types = client.getMessages().at(-1)!.parts.map(p => p.type)
expect(types).toContain('thinking')
expect(types).toContain('tool-call')
expect(types).toContain('text')
expect(client.getStatus()).toBe('ready')
})pace: 'instant' 去掉计时器;attach() 之后才会处理流;settle 轮询 getIsLoading() 等一轮结束。
审批用 client.getInterruptState().interrupts[0].resolveInterrupt(true) 推进;停止用 client.stop()。
API
@gedatou/cadenza-ai/mock 的完整导出。
传输与脚本
步骤构造器
ScriptContext
Script 的唯一参数:(ctx: ScriptContext) => Step[] | Iterable<Step> | AsyncIterable<Step> | Promise<Step[]> | Response。
ScriptedOptions
6 个类型一并导出:Step / Script / ScriptContext / ScriptedOptions / RespondRule / ApprovalDecision。