Cadenza
EN

一个 <form> 包一个 InputGroup——文本、附件、模型与思考强度、建议、排队、草稿、听写,全部只靠 useChat 的返回值接线

Composer 是会话的输入端:一个 <form> 包一个 InputGroup, 提交走 onValueCommitted(value, details),status 从 useChat 传进来决定发送按钮是发送还是停止。 其余部件都是这个表单里的插槽——附件条与附件按钮、听写按钮、模型与思考强度选择器—— 再加两个放在表单外面的:空态里的 Suggestions 与流式中的 QueueList。 附件、模型选择、草稿三件事各有一个只依赖 useChat 返回值的 hook:useAttachmentDraft、 useModelSelection、useStoredState。

本页 demo 全部走 脚本化传输,不需要密钥。

使用

import {
  Composer,
  ComposerAttach,
  ComposerAttachments,
  ComposerDictate,
  ComposerSubmit,
  ComposerTextarea,
  ComposerToolbar,
  ModelPicker,
  QueueList,
  Suggestions,
  SuggestionsItem,
  ThinkingLevelPicker,
  useAttachmentDraft,
  useModelSelection,
  useStoredState,
} from '@gedatou/cadenza-ai'
<Composer status={chat.status} onValueCommitted={text => void chat.sendMessage(text)} onStop={() => chat.stop()}>
  <ComposerTextarea placeholder="Ask about the programme…" />
  <ComposerToolbar>
    <ComposerSubmit />
  </ComposerToolbar>
</Composer>

组成

Composer                      <form> + InputGroup;data-submitted / data-streaming / data-error / data-dragging / data-editing
├─ ComposerAttachments        待发附件条(放在文本域之前)
├─ ComposerTextarea           InputGroupTextarea:自增高、Enter 提交、Shift+Enter 换行、Escape 停止或取消编辑
└─ ComposerToolbar            InputGroupAddon align="block-end"
   ├─ ComposerAttach          文件选择
   ├─ ComposerDictate         录音
   ├─ ModelPicker             模型(Combobox)
   ├─ ThinkingToggle          深度思考开关(DeepSeek 式胶囊;不支持推理的模型不渲染)
   ├─ ThinkingLevelPicker     思考强度(Select;只有一档时不渲染)
   ├─ SearchToggle            联网搜索开关(同款胶囊;模型没有 search 能力时不渲染)
   └─ ComposerSubmit          空闲时发送,运行中停止
 
Suggestions                   提示词 chip 行,通常放在 TranscriptEmpty 里
└─ SuggestionsItem
QueueList                     chat.queue 里排队的消息,各带取消

Composer 的 children 直接落在 InputGroup 里,所以文本域与工具条必须是它的直接子元素—— InputGroup 的自增高与底部工具条规则靠 > 选择器;附件条也放在同一层,文本域之前。 Composer 没有默认文案:占位符、工具条里的提示都由你写;图标按钮自带英文 aria-label,按 props 覆盖。

附件

文件从按钮、拖放或粘贴进来,先在附件条里等着,发送时变成消息的 content part。

三个入口、一个草稿、一次转换:

  • 入口:ComposerAttach 打开文件选择器,选中的文件走它的 onFiles(files, { reason: 'input-change' }); Composer 自己的 onFiles 收拖放('drag')与粘贴('input-paste')——不传它, 拖放与粘贴按浏览器默认处理,data-dragging 也不会出现。
  • 草稿:useAttachmentDraft({ maxBytes, accept }) 持有待发列表,字段见下表。
  • 附件条:ComposerAttachments items onRemove 把每一项画成 Attachment, item.state 直接落到它的 state(idle / uploading / done / error),图片有缩略图,其余是回形针图标。
  • 发送:await draft.toParts() 之后把文本与 part 合成 MultimodalContent 交给 sendMessage,再 draft.clear()。 Composer 只提交文本,合并由你做——demo 的 onCommit 就是这几行。

useAttachmentDraft 返回的 AttachmentDraft:

字段类型说明
itemsreadonly DraftAttachment[]{ id, kind, state, name, mimeType, size, file?, part?, error?, previewUrl? }
add(items: (File | ContentPart)[]) => void文件按 MIME(没有就按扩展名)归到 image / audio / video / document;不在 accept 里的进 state: 'error'、error: 'unsupported',超过 maxBytes 的 error: 'too-large';现成的 part(听写)直接 'done'
remove / clear(id) => void / () => void一并回收图片缩略图的 object URL
toParts() => Promise<ContentPart[]>跳过出错项,把文件读成 base64 的 data part
acceptstring给 <input type="file"> 的 accept,由 accept 选项算出

document 只认 application/pdf / text/plain / text/markdown。fileToContentPart(file, { maxBytes }) 是 toParts 用的那一步,可以单独调。

为什么默认 3 MiB:DEFAULT_MAX_ATTACHMENT_BYTES = 3 * 1024 * 1024。附件以 base64 内联在 JSON 请求体里, 体积膨胀到原文件的约 4/3;Vercel 函数的请求体上限是 4.5 MB,一个 3 MiB 的文件编码后约 4 MiB, 刚好留在线内。上限是逐文件的(fileToContentPart 逐个检查),多个附件加起来仍可能超过平台上限; 自建服务用 maxBytes 改,服务端另有 createChatHandler 的 maxBodyBytes(默认 4 MiB,超过回 413)兜底。

模型与思考强度

模型选择器按 provider 分组、可搜索,值是 provider/model;思考强度只列这个模型支持的档位。

  • ModelPicker 是一个 Combobox:搜索匹配模型名、id 与 provider 名; 条目右侧按 Model 的元数据显示推理图标、视觉图标与上下文窗口(128k / 1M)。 值是 modelRef(model) 的 provider/model 字符串,parseModelRef 拆回去。 传 byok 快照时,需要密钥但还没填的 provider 的分组标题带 data-key-missing 与钥匙图标; disabledProviders 把整组条目禁用。
  • ThinkingToggle 是 DeepSeek 那种「深度思考」胶囊:一个 Toggle,按下亮起、data-thinking 在场; 开启时档位落到 onLevel(默认 'high')按模型 clamp 后的值,关闭发 'off';不支持推理的模型返回 null, 关不掉推理的模型(没有 'off' 档)显示为按下且禁用。文案由 children 给。
  • ThinkingLevelPicker 是一个 Select,选项来自 supportedThinkingLevels(model); 只有一档(不支持推理的模型)时返回 null,所以切到 GPT-4.1 它就消失。试玩页把两者搭在一起: 开关常驻,下拉只在开启且模型有多于一档可选时出现。
  • SearchToggle 是同一枚胶囊的联网搜索版:按下亮起、data-search 在场,onValueChange 收到布尔。 Model.search 为真才渲染,所以它跟着模型出现和消失——目前只有 DeepSeek 的两个 V4 型号声明了这个能力。 搜索本身在厂商那边执行,这个开关只负责把答案送进 forwardedProps,由服务端 createChatHandler 的函数形式 tools 决定挂不挂那把内建工具(见 providers)。
  • useModelSelection({ catalog, key, initial }) 把它们串起来并持久化(localStorage,键默认 cadenza-ai:selection): selection / model / provider、setModel(ref)(把思考档位 clamp 到新模型支持的范围, 新模型没有搜索能力时把 search 一起清掉)、setThinking(level)、setSearch(on), 以及给 useChat({ forwardedProps }) 的稳定对象 forwardedProps——切完模型立刻发送也用新值, 服务端从 data.provider / data.model / data.thinking / data.search 读。 能力判定只认目录:setSearch(true) 给一个没有 search 的模型是 false,thinking 与 search 读存储时都会再 clamp 一次(早于某个字段存下的选择缺这个键,目录收窄档位之后旧值也会失效—— 一个不在 SelectItem 里的值会让 ThinkingLevelPicker 的触发器渲染空白)。 但目录不认识当前模型时不做裁剪:「目录里没这条」和「这个模型没这个能力」是两回事, 改名或删条目不该把用户选好的档位和开关抹掉。裁剪只作用于读出来的那份,写回存储的永远是原值, 存储里我们不认识的键也一并留着。

七个档位从弱到强:off / minimal / low / medium / high / xhigh / max。

supportedThinkingLevels(model):不支持推理 → ['off'];支持 → model.thinkingLevels, 缺省是全部七档。clampThinkingLevel(model, level):支持就原样返回,否则向下取最近的支持档; 目标低于模型下限(Claude Fable 5 不可关,最低 low)时取下限。每家 provider 把档位翻成什么参数, 见 接入提供者 → 思考强度。

建议

一排提示词 chip,按下即发送。

Suggestions 是一个横向 ScrollArea 里的 role="group", 值经根部的 onValueChange(value, { reason: 'item-press' }) 上来——同 Base UI 的 Menu / Select, 条目自己不带回调。SuggestionsItem 是 Button variant="outline" size="sm",value 通常就是提示词本身, 显示文案可以短一些;data-value 镜像它。放在 TranscriptEmpty 里,第一条消息一到它就跟着空态一起消失。

排队

运行中再发的消息默认排队,运行成功收尾后自动发出;QueueList 列出它们,各带一个取消。

const chat = useChat({ fetcher, queue: { whenBusy: 'queue', drain: 'fifo', maxSize: 3, onOverflow: 'drop-oldest' } })
 
<QueueList queue={chat.queue} onCancel={id => chat.cancelQueued(id)}>
  <p className="text-xs text-muted-foreground">Sends when the reply finishes.</p>
</QueueList>

队列是 TanStack 的:useChat({ queue }) 收 'queue' / 'drop' / 'interrupt'(等价于 { whenBusy })、 一个 QueueConfig(whenBusy / drain: 'fifo' | 'batch' / maxSize / onOverflow: 'reject' | 'drop-oldest'), 或一个按次决定的函数;单次 sendMessage(text, { whenBusy }) 覆盖策略。chat.queue 是 QueuedMessage[] ({ id, content, createdAt }),chat.cancelQueued(id) 在发出前撤回;stop()、出错、clear()、reload() 都会清空队列。QueueList 把每条画成一个 Item(size="xs"): 文本内容原样,多模态内容把文本 part 拼起来、其余写成 [image] 这样的占位; 右侧是 aria-label="Cancel" 的图标按钮,onCancel(id, { reason: 'none' })。children 排在行之后。

三种策略的差别在 demo 里按一遍就清楚:queue 排进列表等这轮结束;drop 静默丢弃(promise 照样 resolve,不抛错); interrupt 中止当前流、立刻发出——与 stop() 不同,它不清空已排队的消息,那些仍在打断的这轮成功收尾后依次发出。

草稿

输入到一半切走再切回来,草稿还在——把 Composer 的值放进 localStorage。

const [draft, setDraft] = useStoredState(`cadenza-ai:draft:${threadId}`, '')
 
<Composer
  status={chat.status}
  value={draft}
  onValueChange={setDraft}
  onValueCommitted={text => void chat.sendMessage(text)}
>
  <ComposerTextarea />
  <ComposerToolbar><ComposerSubmit /></ComposerToolbar>
</Composer>

useStoredState(key, initial) 是一个 useState,值经 JSON 往返 localStorage[key]: 同一个 key 的多个 hook 同步,跨标签页经 storage 事件同步;服务端渲染返回 initial, 没有 storage(隐私模式、被禁的站点数据)时退回内存副本,写入永不抛错。 Composer 提交后把值设成空串,草稿随之清掉。useModelSelection 的持久化用的也是它。 键按线程分:demo 切换线程时用 key 重挂整个会话,新挂上来的 hook 读自己那个键,草稿跟着线程回来;刷新页面也还在。

听写

按一下开始录音,再按一下停止;录好的音频作为附件进入附件条,随下一条消息发出。

ComposerDictate 包着 TanStack 的 useAudioRecorder():录音中 aria-pressed 与 data-recording 在场, 停止后 recording.part(一个 AudioPart)交给 onRecording(part, { reason: 'imperative-action' })—— draft.add([part]) 就把它放进附件条(state: 'done'),发送路径与文件附件相同。 需要麦克风权限;浏览器不支持 MediaRecorder 时按钮禁用(recorder.isSupported)。 包内不做转写,音频原样作为 part 发给模型。

状态与 className

Composer 的状态镜像 status 与两个本地态,全是名称型属性;其余部件各有一两个。

属性出现在值出现时机
data-submitted data-streaming data-errorcomposer空串镜像 status;ready 是三者都不在场
data-draggingcomposer空串传了 onFiles 且有文件拖在表单上方
data-editingcomposer空串传了 editing
data-recordingcomposer-dictate空串录音中(同时 aria-pressed="true")
data-valuesuggestions-itemstring镜像 value
data-key-missingmodel-picker 弹层里的分组标题空串传了 byok 且该 provider 需要密钥而状态为 empty
data-statecomposer-attachments 里的每个 Attachment"idle" | "uploading" | "done" | "error"镜像 item.state,见 Attachment
disabledcomposer-submit—空草稿(trim() 后为空,除非 allowEmpty)或 Composer disabled;运行中它是 type="button" 的停止键
data-slot是什么
composer根 <form>
composer-textareaInputGroupTextarea
composer-toolbarInputGroupAddon(align="block-end")
composer-submit发送 / 停止按钮(aria-label Send / Stop)
composer-attach附件按钮;它前面有一个 hidden 的 <input type="file">
composer-attachmentsAttachmentGroup
composer-dictate录音按钮
suggestions / suggestions-itemrole="group" 的行 / 一个 chip
queue-list / queue-list-itemItemGroup / 一行
model-pickerCombobox 的触发器按钮
thinking-level-pickerSelect 的触发器按钮

className 在这些部件上都是 string:Composer 的落在 <form> 上,ComposerTextarea / ComposerToolbar / ComposerSubmit / ComposerAttach / ComposerDictate 的落在各自的 InputGroup 部件上,ComposerAttachments 的落在 AttachmentGroup 上,Suggestions 的落在 ScrollArea 根上,QueueList 的落在 ItemGroup 上, 两个选择器的落在触发器按钮上。

键盘交互

按键效果
Enter(焦点在 ComposerTextarea)提交,onValueCommitted(value, { reason: 'keyboard' });输入法组合中(isComposing / key === 'Process')不触发;空草稿不触发
Shift + Enter换行
Escapeediting 时 onEditCancel;否则 submitted / streaming 时 onStop;其余无事
Tab文本域 → 工具条里的按钮与选择器,按 DOM 顺序
Enter / Space(焦点在 ComposerDictate)开始 / 停止录音
选择器内同 Combobox / Select

Props

顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className。

Composer

根 <form> + InputGroup,持有草稿文本并向下发 context(useComposer() 可读)。 原生的 onSubmit / onChange / value / defaultValue 被去掉,换成下面带 details 的版本。

Prop类型默认值说明
statusChatClientState—chat.status;决定发送 / 停止与 data-*
onValueCommitted(value: string, details: GenericEventDetails<'keyboard' | 'none'>) => void—Enter 或表单提交;之后草稿清空。disabled 或空白草稿不触发
childrenReactNode—附件条、文本域、工具条
defaultValuestring''草稿初值
valuestring—受控草稿
onValueChange(value, details: ChangeEventDetails<'input-change' | 'none'>) => void—输入是 'input-change',提交后清空是 'none';details.cancel() 可否决
onStop(details: GenericEventDetails<'escape-key' | 'none'>) => void—submitted / streaming 时按 Escape 或按停止键
onEditCancel(details: GenericEventDetails<'escape-key'>) => void—editing 时按 Escape,优先于 onStop
onFiles(files: File[], details: GenericEventDetails<'drag' | 'input-paste'>) => void—拖到表单上或粘贴进来的文件;不传则不拦截
editingbooleanfalse编辑重发态,写 data-editing
allowEmptybooleanfalse允许提交空草稿——附件条承载内容时打开;发送键随之可用
disabledbooleanfalse文本域、附件、听写、发送一并禁用
classNamestring—落在 <form> 上
其余ComponentProps<'form'> 去掉上述四个原生名—透传给 <form>

ComposerState:{ submitted, streaming, error, dragging, editing }。

ComposerTextarea

InputGroupTextarea,rows={1}、内容自增高(field-sizing-content)、最高 max-block-48。 value / onChange 由 context 接管;收 InputGroupTextarea 的其余 props, 自己的 onChange / onKeyDown 先调用,preventDefault() 后部件不再处理。

ComposerToolbar

InputGroupAddon align="block-end";收 InputGroupAddon 的全部 props。

ComposerSubmit

空闲时 type="submit" 的发送键(aria-label="Send",空草稿禁用),submitted / streaming 时变成 type="button" 的停止键(aria-label="Stop",调 onStop);size="icon-sm"。 收 Button 的全部 props,children 替换默认图标。

ComposerAttach

InputGroupButton size="icon-xs"(aria-label="Attach")+ 一个隐藏的 <input type="file">; 每次选择后清空 input,所以同一个文件可以再选一次。收 InputGroupButton 的全部 props,另加:

Prop类型默认值说明
onFiles(files: File[], details: GenericEventDetails<'input-change'>) => void—选中的文件
acceptstring—透传给 <input>;通常是 draft.accept
multiplebooleantrue—

ComposerAttachments

待发附件条,AttachmentGroup 里每项一个 Attachment size="sm";items 为空时不渲染任何东西。

Prop类型默认值说明
itemsreadonly DraftAttachment[]—draft.items
onRemove(id: string, details: GenericEventDetails<'none'>) => void—每项右侧 aria-label="Remove" 的按钮
classNamestring—落在 AttachmentGroup 上

ComposerDictate

InputGroupButton size="icon-xs"(aria-label="Dictate"),按一下开始、再按停止; 不支持录音时禁用。收 InputGroupButton 的全部 props,另加:

Prop类型默认值说明
onRecording(part: AudioPart, details: GenericEventDetails<'imperative-action'>) => void—停止后的录音 part

Suggestions

横向滚动的 chip 行,role="group"。

Prop类型默认值说明
onValueChange(value: string, details: ChangeEventDetails<'item-press'>) => void—被按下的 chip 的 value
childrenReactNode—若干 SuggestionsItem
classNamestring—落在 ScrollArea 根上

SuggestionsItem

Button variant="outline" size="sm";收 Button 除 value 外的全部 props,另加:

Prop类型默认值说明
valuestring—交给根的 onValueChange;写成 data-value

QueueList

排队消息列表,ItemGroup。

Prop类型默认值说明
queuereadonly QueuedMessage[]—chat.queue
onCancel(id: string, details: GenericEventDetails<'none'>) => void—每行的取消按钮;通常是 chat.cancelQueued
childrenReactNode—排在行之后的标题或提示
classNamestring—落在 ItemGroup 上

ModelPicker

按 provider 分组、可搜索的模型 Combobox;值是 provider/model。

Prop类型默认值说明
catalogCatalog—通常是 defaultCatalog
onValueChange(ref: string, details: ComboboxChangeEventDetails) => void—Combobox 自己的 details,实际为 item-press / none
defaultValuestring——
valuestring—受控
byokByokSnapshot—有它才标 data-key-missing
disabledProvidersreadonly string[]—这些 provider 的条目禁用
classNamestring—落在触发器按钮上

ThinkingToggle

深度思考开关;不支持推理的模型返回 null。

Prop类型默认值说明
onValueChange(level: ThinkingLevel, details: ToggleChangeEventDetails) => void—Toggle 自己的 details;开启时是 clamp 后的 onLevel,关闭时是 'off'
childrenReactNode—文案(DeepSeek 写的是「深度思考」)
modelModel—决定是否渲染与 clamp;缺省视为不支持推理
onLevelThinkingLevel'high'开启时落到的档位,按模型 clamp;clamp 落到 'off' 时取模型最强一档
defaultValueThinkingLevel'off'—
valueThinkingLevel—受控
classNamestring—落在 Toggle 上

SearchToggle

联网搜索开关;model.search 不为真时返回 null。

Prop类型默认值说明
onValueChange(on: boolean, details: ToggleChangeEventDetails) => void—Toggle 自己的 details
childrenReactNode—文案(DeepSeek 写的是「联网搜索」)
modelModel—决定是否渲染;缺省视为不支持
defaultValuebooleanfalse—
valueboolean—受控
classNamestring—落在 Toggle 上

ThinkingLevelPicker

模型支持的思考档位 Select;无可选时返回 null。

Prop类型默认值说明
onValueChange(level: ThinkingLevel, details: SelectChangeEventDetails) => void—Select 自己的 details
modelModel—决定档位;缺省视为不支持推理
defaultValueThinkingLevel——
valueThinkingLevel—受控
classNamestring—落在触发器按钮上

类型一并导出:ComposerProps / ComposerState / ComposerSubmitReason / ComposerChangeEventDetails / ComposerTextareaProps / ComposerToolbarProps / ComposerSubmitProps / ComposerAttachProps / ComposerAttachmentsProps / ComposerDictateProps / SuggestionsProps / SuggestionsItemProps / SuggestionsChangeEventDetails / QueueListProps / ModelPickerProps / ModelPickerChangeEventDetails / ThinkingLevelPickerProps / ThinkingLevelPickerChangeEventDetails / ThinkingToggleProps / ThinkingToggleChangeEventDetails / SearchToggleProps / SearchToggleChangeEventDetails;hook 一侧:AttachmentDraft / DraftAttachment / AttachmentKind / AttachmentState / UseAttachmentDraftOptions / ModelSelection / UseModelSelectionOptions / UseModelSelectionReturn,以及 DEFAULT_MAX_ATTACHMENT_BYTES、 attachmentKindOf、fileToContentPart、useComposer。