多模态消息渲染与思考过程
第10章实现了文本消息的基本渲染,这节从两个方向扩展消息能力:输入侧支持图片/附件/组合键的多模态输入,渲染侧支持思考型模型的思考过程展示。组件族对齐真实仓库:输入是 prompt-input/PromptInput 系,消息渲染是 message/Message 系 + attachment 附件系 + thinking 思考系。
需求先行:五项交互需求
真实 AI 聊天产品的输入框早已不是"一个 textarea 加一个发送按钮"。对照豆包、ChatGPT 等主流产品,本节要完成的需求非常明确:
- 输入区新增图片 icon 和附件 icon,点击后分别上传图片与文件附件
- 图片及附件的预览列表显示在输入框上方;图片点击后进入大图预览
- 发送按钮移到输入框右侧
- 组合键发送:默认回车换行,Alt+Enter(Mac 上 Cmd+Enter 等价)发送——注意默认值与常见 IM 相反,因为用户输入代码/多行内容的频率远高于发送
- 支持从剪贴板粘贴图片(Ctrl+C/V)、支持拖拽文件到输入区上传,同样进入预览列表
这五条需求把交互和显示都写死了边界。经验:如果把需求写成"支持多模态输入",多轮对话也未必能收敛到确切方向;写成上面这样,一轮 Plan 模式就能对齐。
用 Plan 模式对齐复杂需求
多模态输入涉及类型扩展、组件拆分、键盘交互、剪贴板、拖拽、预览、上传状态机,属于"复杂需求"。做法是把预先写好的需求文档拖进对话,追加一句"使用计划模式规划多模态消息组件的设计",让 Claude Code 进入 Plan 模式出方案。
Plan 模式不是一次性出方案,而是不断用细化问题逼近需求。本节实际被问到的问题及选择:
- 是否废弃旧的 InputArea 组件——废弃,全新 PromptInput 组件族替代
- 图片点击预览用什么实现——选内置轻量 Lightbox,不引入第三方大图组件(对应仓库
preview/ImageLightbox.vue) - 附件发送流程同步还是异步——异步(上传走
beforeSend回调,见下文) - 文件超出 maxSize 如何反馈——用轻量 Toast 提示组件,默认自动关闭,不弹模态框
- 逻辑放哪——收进 prompt-input 模块内聚,不散落到页面层
方案确认后一句"使用多人 subagent 团队在后台并发完成并测试交付",模型会启动多个进程并行开发。本功能最终产生 19 个 feature commit——复杂度可见一斑。
两个协作细节值得记住:
- 随时可中断:Claude Code、Codex 这类工具支持 ESC 中断模型输出,发现方向偏离立即停下补充指令再继续。中断后重发不会双倍计费——历史消息会重新打包发给 API,首次请求建立 prompt cache(写缓存收费一次),后续读取缓存的费用极低,部分模型甚至免读缓存费用
- 截图对齐:模型产出与预期不符时,把目标效果截图发给模型解析再实现,通常一轮就能逼近
类型扩展:Attachment 的双地址设计
真实仓库的 Attachment(packages/core/src/types/index.ts):
export interface Attachment {
id: string
/** 本地预览地址(blob:),只给浏览器渲染用,模型读不到 */
url?: string
/** 模型可读的 base64 数据 URL(data:image/jpeg;base64,...),随消息发送给多模态模型 */
dataUrl?: string
name: string
mediaType: string
size?: number
}
typescript
关键设计决策有两个:
url与dataUrl分离:url是URL.createObjectURL生成的 blob 地址,渲染快但只在本地浏览器有效,模型看不见;dataUrl是 base64 数据,能随消息发给多模态模型。预览用前者、发送用后者,各司其职(base64 转换与发送链路在第18章多模态一节展开)- 不用
type: 'image' | 'file'判别字段:附件类别从mediaType(MIME)派生——image/*走图片预览,其余走文件图标。少一个冗余字段,判断逻辑收敛在一处
Message 通过可选的 attachments 字段渐进增强,现有文本渲染完全不受影响;content 始终是 string,存文本。
输入侧:PromptInput 组件族
真实仓库的 prompt-input 目录(组件名与职责一一对应):
容器 PromptInput.vue 只做三件事:provide 注入上下文(PROMPT_INPUT_KEY)、处理拖拽(dragenter/dragover/drop,用 dragDepth 计数避免子元素抖动)、挂 Toast。Props 覆盖多模态的全部配置面:
withDefaults(defineProps<{
disabled?: boolean
placeholder?: string
maxHeight?: number // 输入框最大高度,默认 200px
accept?: string // 文件类型过滤
multiple?: boolean
maxFiles?: number // 默认 5
maxSize?: number // 单文件字节上限,缺省不限
sendKey?: 'alt-enter' | 'enter' // 组合键策略,默认 alt-enter
beforeSend?: (files: File[]) => Promise<Attachment[]> // 异步上传回调
}>(), { /* ... */ })
typescript
组合键与粘贴:PromptInputTextarea
function handleKeydown(e: KeyboardEvent) {
if (e.isComposing || e.keyCode === 229) return // IME 组合中,交给输入法
if (e.key !== 'Enter') return
if (sendKey() === 'enter') {
if (!e.shiftKey) {
e.preventDefault()
submit()
}
return
}
// alt-enter 模式:Enter 原生换行不拦截;Alt/Cmd+Enter 发送
if ((e.altKey || e.metaKey) && !e.shiftKey) {
e.preventDefault()
submit()
}
}
function handlePaste(e: ClipboardEvent) {
const files = e.clipboardData?.files
if (files && files.length > 0) {
e.preventDefault()
addFiles(files)
}
}
typescript
三个易错点:
- IME 组合态守卫:中文输入法选词时按回车(
isComposing/keyCode 229)绝不能触发发送,否则每个中文词都被误发 - 粘贴分流:
clipboardData.files非空说明贴的是文件/截图,走附件;否则放行默认粘贴文本 - 高度自适应:
el.style.height = Math.min(el.scrollHeight, maxHeight()),超过 200px 后内部滚动不再长高,对齐 ChatGPT/豆包的手感
文件状态机:usePendingFiles
待发附件是一个小状态机(ready | uploading | error),composable 负责校验与预览地址生命周期:
export interface PendingFile {
id: string
file: File
/** 图片的 objectURL 预览;非图片为空串(走图标) */
previewUrl: string
status: 'ready' | 'uploading' | 'error'
error?: string
}
typescript
addFiles 按序做三重校验——accept 类型匹配(支持 .png 后缀、image/* 通配、完整 MIME 三种写法)、maxSize 大小上限、maxFiles 数量上限——被拒文件带 reason 返回,由容器聚合成一条 Toast("文件超过大小上限:xx.md 等 2 个文件被拒绝")并同时 emit error 事件给宿主。
objectURL 的生命周期纪律:创建时记入数组,移除/清空时 URL.revokeObjectURL,onScopeDispose 兜底释放——不清理会内存泄漏,这是第20章复盘过的真实事故类型。
异步上传:beforeSend 回调
组件不做真正的网络上传。宿主传入 beforeSend,发送时把待发文件交给它,返回 Attachment[](通常在此把 File 转 base64 dataUrl):
if (props.beforeSend && pending.length > 0) {
pending.forEach((p) => pf.setStatus(p.id, 'uploading'))
try {
const attachments = await props.beforeSend(files)
emit('send', { text, attachments })
pending.forEach((p) => pf.remove(p.id)) // 只移除本次快照内的文件
} catch (err) {
pending.forEach((p) => pf.setStatus(p.id, 'error', message))
toastMessage.value = `附件上传失败:${message}`
}
}
typescript
两个细节:快照必须是拷贝([...pendingFiles.value])——addFiles 原地 push,await 期间用户继续追加的文件不能混进本次上传集合;上传窗口内新增的文件保留——只移除快照内的 id,用户在慢上传期间排队的新附件不丢。不传 beforeSend 时退化为 emit('send', { text, files }),原始 File 交给宿主自行处理。
渲染侧:附件组件族与图片预览
消息侧的真实组件分工:message/MessageAttachments.vue 在消息内容前渲染附件区,attachment/ 目录提供 Attachments(列表)、Attachment(单条)、AttachmentPreview(图片缩略/文件图标,按 mediaType 派生 category)、AttachmentInfo(文件名/大小)、AttachmentRemove。图片点击进入 preview/ImageLightbox.vue 内置大图预览。
渲染优先级:附件在文本之前——用户发一张截图配一句"这个报错怎么解",图在上、文字在下,符合聊天视觉习惯。
视觉打磨:一个滚动条引发的连锁修复
AI 一次性交付后必然有视觉残留,本节真实经历了三轮:
- 滚动条遮住输入框右上圆角:textarea 超高后内部滚动条贴着 padding 右缘直角穿透外层 wrapper 的圆角。参照豆包的做法自定义滚动条:6px 宽胶囊 thumb +
border: 1px solid transparent+background-clip: padding-box,其中border-top-width: 10px让出顶部圆角区——绘制区内缩后不再压角 - 单行时光标与 placeholder 不垂直居中:textarea 上下 padding 改为对称的
12px 0,横向 inset 交给外层 Body 提供 - 反馈描述的精度决定修复质量:与其说"右上角有问题",不如复制 DOM 并写清"整个 input 区域右上角滚动条会覆盖右上角圆角"——描述越准,模型定位与修复越准,必要时截图佐证
这类"发现什么说什么"的调试循环因模型能力差异轮数不同,属于 AI 协作的常态成本。
思考过程渲染:ThinkingBlock
DeepSeek R1、Claude 等思考型模型会在正文前输出一段思考文本(reasoning),流式到达。类型层为 Message 增加可选 thinking 字段、为 StreamChunk 增加 thinking 帧:
export interface ThinkingInfo {
content: string
duration?: number // 思考耗时(毫秒)
startTime?: Date
/** 思考是否仍在进行:流式期间由 useChat 维护,首个非 thinking 内容帧到达即置 false */
active?: boolean
/** 结构化多步骤思维链(RAG/Agent 场景服务端组装,可选渐进增强) */
steps?: ThinkingStep[]
}
export type StreamChunk = {
type: 'text' | 'tool_call' | 'tool_result' | 'thinking' | 'error' | 'done'
content: string
metadata?: { duration?: number; [key: string]: unknown }
}
typescript
thinking/ThinkingBlock.vue 的核心交互(对照 DeepSeek 的样式):
- 触发器文案状态机:流式中显示"正在思考…"(图标 accent 色呼吸动画),结束且有耗时显示"已思考 X 秒"(
t('thinking.thoughtFor', { duration })走 i18n 插值),无耗时就显示"思考过程" - 展开态跟随流式起止,但用户优先:流式开始自动展开实时展示,结束自动折叠为触发器;但用户一旦点击过(
userTouched),状态完全归用户——流式中手动折叠必须立即生效,不能被 streaming 压制回去 - 时长格式化:<10 秒保留 1 位小数,≥10 秒取整
- 正文排版:左侧 2px 细线 + 缩进的引用式样式,视觉上与正文区分层级;
prefers-reduced-motion时关闭所有动画
多步骤思维链(RAG 检索→重排→生成这类分阶段过程)用 ThinkingChain 渲染 steps 数组,单步有 pending/active/complete/error 四态——ThinkingBlock 管单块文本,ThinkingChain 管结构化步骤,两者是渐进增强关系。
流式 Markdown 容错:必须写进需求
思考内容与正文都是逐 token 到达的,中间态 Markdown 语法必然不完整——**加粗 的后半颗星、未闭合的代码围栏。给模型下需求时必须明确:"间断式 Markdown 消息的渲染要保证正确——语法不完整时先降级为纯文本,下一块到达再重新解析",否则组件在假数据(完整字符串)下看起来正常,接上真实流式立刻碎掉。这也是 mock 服务要按 token 粒度回放剧本的原因(下一节展开)。
配套:A/B 回复对比
思考过程之外,这轮还落地了 comparison/ComparisonMessage.vue:服务端在 Message.comparison 载荷里给出左右两个回复(left/right/label),组件渲染为可滚动的双栏对比视图。它和思考块一样是"消息形态扩展"——Message 上加可选字段,渲染层按字段分流,不侵入基础消息链路。
文档站的 message 页最终支持了思考过程重放(流式回放思考逐字出现)与 A/B 对比滚动演示,这些示例由 AI 补齐——直接指明"文档哪个部分没补齐",比泛泛说"补文档"有效得多。
小结
- 输入侧 PromptInput 组件族:容器管拖拽与上下文,Textarea 管组合键(默认回车换行、Alt+Enter 发送、IME 守卫)与粘贴分流,usePendingFiles 管三重校验与 objectURL 生命周期
- Attachment 双地址设计:blob url 给浏览器预览,base64 dataUrl 给模型
- 上传逻辑通过 beforeSend 回调交给宿主,组件只管状态机与快照纪律
- 思考渲染:ThinkingBlock 触发器文案状态机 + 展开态用户优先;StreamChunk 增加 thinking 帧
- 流式 Markdown 容错(不完整语法降级纯文本)必须作为显式需求写进提示词
- AI 协作:Plan 模式细化对齐、ESC 中断 + prompt cache 缓存经济学、多人 subagent 并发(本功能 19 commits)、截图与精确 DOM 描述反馈
下一节实现工具调用面板——展示 AI 的 function call 过程。
↑