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:
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 与两个本地态,全是名称型属性;其余部件各有一两个。
className 在这些部件上都是 string:Composer 的落在 <form> 上,ComposerTextarea / ComposerToolbar /
ComposerSubmit / ComposerAttach / ComposerDictate 的落在各自的 InputGroup 部件上,ComposerAttachments 的落在
AttachmentGroup 上,Suggestions 的落在 ScrollArea 根上,QueueList 的落在 ItemGroup 上,
两个选择器的落在触发器按钮上。
键盘交互
Props
顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className。
Composer
根 <form> + InputGroup,持有草稿文本并向下发 context(useComposer() 可读)。
原生的 onSubmit / onChange / value / defaultValue 被去掉,换成下面带 details 的版本。
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,另加:
ComposerAttachments
待发附件条,AttachmentGroup 里每项一个 Attachment size="sm";items 为空时不渲染任何东西。
ComposerDictate
InputGroupButton size="icon-xs"(aria-label="Dictate"),按一下开始、再按停止;
不支持录音时禁用。收 InputGroupButton 的全部 props,另加:
Suggestions
横向滚动的 chip 行,role="group"。
SuggestionsItem
Button variant="outline" size="sm";收 Button 除 value 外的全部 props,另加:
QueueList
排队消息列表,ItemGroup。
ModelPicker
按 provider 分组、可搜索的模型 Combobox;值是 provider/model。
ThinkingToggle
深度思考开关;不支持推理的模型返回 null。
SearchToggle
联网搜索开关;model.search 不为真时返回 null。
ThinkingLevelPicker
模型支持的思考档位 Select;无可选时返回 null。
类型一并导出: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。