@zhenryx/react-chat-component
v0.1.0
Published
A chat UI component for React 19
Readme
react-chat-component
React 19 轻量组件库(tsdown 构建,SCSS 样式):Chat 消息对话、Layout 页面布局容器、Anchor 锚点目录、VoiceInput 语音听写、AttachmentList 附件列表、DragOverlay + useDropZone 拖拽上传浮层、usePasteFiles 粘贴文件(参考 OpenTiny TinyRobot Attachments / drag-overlay)。
开发
npm install
npm run playground # 启动 Vite playground(http://localhost:5173)
npm run build # 产物输出到 dist/(esm + cjs + d.ts + style.css + sourcemap)
npm run typecheck # TS 类型检查
npm run dev # tsdown 监听模式playground 目录为本地调试页,通过别名 react-chat-component 直接引用 src/ 源码,改动实时生效。
playground 聊天支持接入真实 AI(DeepSeek 最小示例):点顶栏设置按钮粘贴 API Key(仅存本机浏览器 localStorage,纯演示用途——生产环境 key 必须放服务端),发送即走官方流式接口并带完整历史,重新生成按钮会重跑该条;不填 Key 时回落到内置模拟回复。接入代码见 playground/deepseek.ts(SSE 解析与错误处理可直接照抄到服务端代理)。
使用
import { Chat } from "react-chat-component"
import "react-chat-component/style.css" // 组件样式必须引入
<Chat
title="在线客服"
messages={messages}
bubbleStyle="corner" // "rounded" 全圆角(默认) | "corner" 头像侧收口 | "none" 直角
transparentReply={true} // AI 回复气泡透明(去底色去边框,仅保留文字)
fullWidthReply={true} // AI 回复占满整条消息列宽(默认 false,长文/表格排版更舒服)
showAvatar={true} // 是否展示头像
avatar="https://xxx/avatar.png" // 自定义头像:URL 字符串或 ReactNode
renderContent={(content, msg) => md(content)} // 可选:消息内容自定义渲染(典型:AI 回复渲染 Markdown,见下节)
renderThinking={(thinking, msg) => md(thinking)} // 可选:思考过程自定义渲染(配合消息 thinking 字段,见「思考过程」章节)
loading={true} // AI 回复前渲染三圆点跳动气泡
autoScroll={true} // 消息输出时自动贴底跟随(默认 true,见「自动贴底跟随」章节)
disabled={busy} // 回复进行中等场景禁用发送
placeholder="输入消息,回车发送"
onSend={(content) => send(content)} // 组件自带输入区,点发送/回车触发后自动清空
onStop={stopReply} // 可选:回复进行中(disabled)发送按钮变「暂停」,点击停止生成
onCopy={(m) => customCopy(m)} // 可选:自定义复制行为;不传则内置写剪贴板并短暂提示「已复制」
onRegenerate={(m) => rerun(m)} // 可选:传入后 AI 回复下方出现「重新生成」(截断重跑由调用方实现)
hasMore={hasMore} // 可选:触顶分页——还有更早历史可加载(见下文专节)
loadingEarlier={loadingEarlier} // 可选:加载更早历史中(消息列顶部显示细加载指示)
onLoadEarlier={loadPage} // 可选:滚到顶部触发(拉取更早一页插到 messages 头部,组件自动视口锚定)
onAdd={onPickFile} // 上传附件回调:默认「+ 添加」弹层选文件后带出(见下)
addItems={[{ key, label, icon, onClick }]} // 可选:自定义 + 弹层菜单项(默认只含「上传附件」)
attachments={attachList} // 可选:待发送附件条——非空时渲染在输入卡顶部(见「附件列表」章节)
onRemoveAttachment={onRemove} // 可选:附件条目删除回调
onRetryAttachment={onRetry} // 可选:附件条目「重试」回调(error 条目显示)
onPreviewAttachment={onPreview} // 可选:覆盖内置的图片预览(默认内置大图浮层)
onDownloadAttachment={onDownload} // 可选:覆盖内置下载(rawFile → Blob URL / url → a[download])
userAnchor={true} // 右侧「我的提问」锚点:每条用户消息一个「文本+横条」条目,悬停出白卡列表、再悬停行内文本看全文(见「我的提问锚点」章节)
actions={<技能快捷按钮 />} // 操作行中间区域,由调用方传入
inputLeft={<表情按钮 />} // 输入框左侧插槽(紧贴输入框)
inputRight={<VoiceInput />} // 输入框右侧插槽(如语音听写)
composerHeader={<模式切换 tab 行 />} // 输入卡顶部整行插槽(在附件条之上,左右出血到卡片边缘)
inputBottom={<参考图区 />} // 输入行正下方整行插槽(在操作行之上)
composerFooter={<提示文案 />} // 输入区卡片最底部插槽
value={draft} // 受控草稿:传了就以调用方为准(组件不清草稿),可按住模式各存一份
onChange={setDraft} // 受控草稿变更回调(与 value 配套)
pasteOptions={{ accept, onFiles, onError }} // 粘贴文件即加入附件(库内挂 usePasteFiles,见「粘贴文件」章节)
showAdd={false} // 不要内置「+ 添加」(宿主自绘附件入口时)
attachWrap // 附件条换行铺开(默认单行横向滚动)
composerStacked // 强制堆叠形态(输入行独占一行、操作行下沉)
composerInputMaxHeight={220} // 输入框自增高上限(px,默认 120)
composerBlurOnSend // 发送后让输入框失焦(移动端顺手收键盘)
composerDisabled={false} // 回复中仍允许继续打草稿(消息区的「重新生成」仍由 disabled 管)
composerCanSend={canSubmit} // 覆盖内置「可发送」判定(如「只有附件也能发」)
renderSend={({ canSend, disabled, submit, stop }) => <发送钮 onClick={submit} />} // 自绘发送格(见下)
/>需要从外部向输入框注入文本时(如语音听写确认后回显),给 Chat 传 ref 使用命令式句柄 ChatHandle:
const chatRef = useRef<ChatHandle>(null)
<Chat ref={chatRef} />
chatRef.current?.setDraft("识别出的文字") // 追加到当前草稿末尾、聚焦输入框并光标置尾组件自带底部输入区,composer 为圆角卡片并绝对定位悬浮固定在 Chat 底部,不挤占消息空间——消息区占满整个高度独立滚动(需父容器给 Chat 一个确定高度)。main 全宽场景无需给外层限宽:Chat 铺满容器后,消息列与输入卡会在组件内部自动收敛居中(原理见下文「Chat 全宽布局」章节)。输入区随宽度与输入内容自动切换两种形态:
- 单行形态(宽屏且输入只有一行):
+添加、输入行、发送按钮并排同一行;输入框两侧可分别用inputLeft/inputRight插槽放表情、语音等按钮; - 堆叠形态(输入内容超过一行,或窗口 ≤ 640px 的移动/窄屏):输入行独占一行(不含两侧按钮),
+添加、inputLeft/inputRight插槽、actions中间区、发送按钮全部下沉为下方操作行。
+ 添加按钮常驻:点击后在输入卡上方浮出一个弹层盒子(外点/Esc 关闭)。弹层默认只含「上传附件」一项——内部隐藏文件选择器选中文件后回调 onAdd(file)(不传 onAdd 也能选文件,只是无后续动作);弹层内容可用 addItems 完全替换({ key, label, icon?, onClick? }[],如拍照、从相册选等入口)。
actions 与 composerFooter 传入时各自占输入区下方一行。回车发送、Shift+Enter 换行;输入框随内容自动增高(最高约 4 行,上限见 composerInputMaxHeight),中英文输入法组合输入不误触发送。
输入卡插槽:九个位置,一个 Composer
输入卡的每一格都是插槽,布局由 grid 模板固定,插槽空着就整行高度为 0(卡片自己需要多少高度由内容决定,实测高度会写到根节点的 --rcc-composer-h 上,消息区据此留出底部空白):
| 插槽 | 位置 | 典型用途 |
|---|---|---|
| composerHeader | 卡片顶部整行(附件条之上) | 模式切换 tab 行,可自带底部分隔线整宽贯通 |
| attachments | 顶部附件条 | 待发送附件(图片墙 / 文件卡片,见「附件列表」章节) |
| showAdd / addItems | 输入行左端「+」 | 默认「上传附件」弹层,可完全替换菜单项 |
| inputLeft / inputRight | 输入框左右 | 表情、工具、语音听写(单行形态贴输入框,堆叠形态随操作行下沉) |
| inputBottom | 输入行正下方整行 | 绘图参考图区等,与输入框文字左对齐 |
| actions | 操作行中间 | 模型 / 参数 / 技能等 pills |
| renderSend | 发送格 | 自绘发送按钮(费用胶囊、积分不足等) |
| composerFooter | 卡片最底部整行 | 「内容由 AI 生成」提示条 |
renderSend 拿到的是内置状态与动作,发送务必调 submit()(它负责贴底跟随、onSend(trimmed)、非受控时清草稿;直接调 onSend 会丢掉这些):
<Chat
renderSend={({ canSend, disabled, submit, stop }) =>
disabled ? <button onClick={stop}>停止</button> : <button disabled={!canSend} onClick={submit}>发送</button>
}
/>不传 renderSend 时是内置的发送/暂停双态按钮:disabled 为真时切暂停图标,传了 onStop 则可点、点了调它。
受控草稿:传 value + onChange 后输入框完全由调用方保管(组件不再自己清空),适合「按模式/按会话各存一份草稿」;不传则用组件内部状态。粘附件走 pasteOptions(与单独使用 usePasteFiles 同一套选项,库内自动接到输入框上)。想整套自绘(连卡片壳都不用)时传 showComposer={false} 并自己用 <Composer>:
import { Composer, usePasteFiles } from "react-chat-component"
<Composer
value={draft} onChange={setDraft} onSend={send} // 或都不传,组件自持草稿
attachments={list} onRemoveAttachment={remove} // 附件条 + 操作回调
pasteOptions={{ accept, onFiles }} // 粘贴入库
header={<tab 行 />} inputBottom={<参考图区 />} // 插槽与 Chat 同形
actions={<pills />} renderSend={({ submit }) => <发送 onClick={submit} />}
heightTarget={myRef} // 把实测高度写到该元素的 --rcc-composer-h
/>命令式句柄除了 setDraft(text)(追加文本、聚焦、光标置尾)还有 focus()(只聚焦置尾,外部先改完草稿再让用户接着编辑时用)。
想直接看效果:playground 右上角「组件设置」→ 勾选**「插槽用法示例」**(默认关闭,不影响默认视图),
同一份 Chat 会换上卡顶 tab 行(composerHeader)、参考图区(inputBottom)、宿主自绘的附件与参数
入口(showAdd={false} + actions)、费用胶囊发送格(renderSend)与按模式各记一份的受控草稿
(value / onChange),代码在 playground/App.tsx 的 SlotTabHeader / SlotRefRow / SlotActions / SlotSend。
附件列表(AttachmentList)
参考 OpenTiny TinyRobot Attachments 的待发送附件条:往 Chat 传 attachments 后,输入卡顶部渲染附件条——全部为图片时自动切成图片墙(缩略图流,点击缩略图进入全屏预览:大图随视口等比放大居中,头部含下载 / 关闭;缩略图悬停浮现角部移除钮);混有文档时渲染文件卡片(文件类型彩色图标 + 文件名 + 大小,悬停卡片时:预览图标按钮浮现,大小文案原位切换为「下载」文字按钮;触屏设备下载按钮与预览常驻)。组件也单独导出、可在任意场景复用:
import { AttachmentList, type AttachmentItem } from "react-chat-component"
const items: AttachmentItem[] = [
{ id: "1", name: "需求文档.pdf", size: 1_471_000, rawFile: file, status: "uploading", message: "上传中 45%" },
{ id: "2", name: "报价单.xlsx", size: 263_000, rawFile: file2 }, // 默认 success
{ id: "3", name: "会议纪要.pdf", size: 892_000, status: "error", message: "网络超时,请重试" },
]
<AttachmentList
items={items}
variant="auto" // "auto"(默认) 全图切图片墙 | "picture" | "card"
wrap={false} // true 时换行;false(默认)单行横向滚动
disabled={false} // 禁用移除/重试/预览/下载
onRemove={(item) => remove(item)} // 删除按钮触发
onRetry={(item) => retry(item)} // error 条目的「重试」触发
onPreview={customPreview} // 覆盖内置图片预览浮层
onDownload={customDownload} // 覆盖内置下载
/>条目按 item.status 自动切换内容:success 显示大小;uploading 显示旋转圈 + message 提示(如百分比);error 显示失败原因 + 「重试」按钮。文件类型彩色图标按扩展名 + MIME 推断(内联 SVG,图形取自 iconfont 文件类型图标集):图片(卡片态直接嵌缩略图)/ PDF / Word / Excel(含 csv)/ PPT / 压缩包 / 音频 / 视频 / 文本类(txt、md、json、html、代码、配置文件等)/ 其他兜底。注意:组件只负责展示与操作回调,条目的上传进度 / 失败状态更新、以及发送后的清空需调用方维护(onSend 触发时附件条保持原样)。
playground 内置三组附件演示(空态下点击建议按钮):文档卡片(含上传中)、图片墙(点击大图预览)、上传失败重试;用「+ 添加」选真实文件也会进入附件条并模拟上传进度。
actions 与 composerFooter 传入时各自占输入区下方一行。
拖拽浮层(DragOverlay + useDropZone)
把文件从系统拖进窗口时显示「松开即可上传」提示浮层(参考 TinyRobot drag-overlay)。由两部分协作:
useDropZone(options):把任意元素变成文件拖放区(对应 TinyRobot 的v-dropzone指令)。返回{ dragging, props }——props展开到目标容器(拖拽事件冒泡,绑外层即可覆盖整个区域);只响应文件拖拽,文本/链接拖拽不干扰。<DragOverlay />:纯展示层,isDragging为 true 时显示图标 + 标题 + 描述行的提示卡片。本身pointer-events: none,永远不挡鼠标,放下文件的 drop 事件落在下层目标上。
import { Chat, DragOverlay, useDropZone } from "react-chat-component"
const chatAreaRef = useRef<HTMLDivElement | null>(null)
const { dragging, props } = useDropZone({
accept: ".png,.jpg,.jpeg,.pdf,.docx", // 扩展名 / MIME / "image/*";空 = 全部
multiple: true,
maxSize: 10 * 1024 * 1024, // 单文件上限(字节,默认 10 MB)
maxFiles: 6, // 一次最多文件数(默认 3)
onDrop: (files) => files.forEach(addToAttachments), // 通过校验的文件
onError: ({ code, message, files }) => console.warn(message),
})
<div className="chat-area" ref={chatAreaRef} {...props}>
<Chat … />
</div>
<DragOverlay
isDragging={dragging}
dragTarget={chatAreaRef.current} // 浮层吸附在聊天区;省略 / null = 全屏覆盖
title="松开即可上传文件"
description={["支持图片与 PDF", "单个不超过 10 MB"]}
/>选项与回调:
| 选项 | 说明 |
| --- | --- |
| accept | 逗号分隔的 .png,.pdf(推荐)/ MIME / image/* 通配;空字符串接受全部 |
| multiple / maxFiles | 是否允许多文件(默认 true)与一次上限(默认 3),超出的文件以 too-many-files 拒绝 |
| maxSize | 单文件字节上限(默认 10 MB),超限以 file-too-large 拒绝 |
| disabled | 禁用拖放(拖拽中切为禁用会自动收起浮层) |
| onDrop(files) | 通过校验的文件(按原顺序) |
| onError(rejection) | 被拒文件按原因分组回调:{ code, message, files },code 为 file-invalid-type / file-too-large / too-many-files |
| onDraggingChange(dragging) | 拖入 / 拖离状态变化(通常直接用返回的 dragging 即可) |
DragOverlay 常用 props:isDragging(必填)、dragTarget(吸附目标元素,局部模式)、fullscreen(强制全屏,不吸附 dragTarget)、title / description: string[](每项一行)、loading(切为加载圈 + 「正在上传…」,如放下文件后开启、处理完关闭)、children(完全自定义浮层内容)。浮层自带半透明遮罩(蒙住下层内容,可经 --rcc-do-overlay 覆盖,默认 rgba(15,23,42,.1)),样式沿用 --rcc-accent / --rcc-accent-soft / --rcc-font 主题变量。
与附件条打通:onDrop 里把文件加进 attachments 状态即可让拖入的文件直接进入 Chat 输入卡顶部的附件条(playground 即此用法——把文件拖进聊天窗口试试)。拖拽 / 粘贴 / 调用方自己的文件选择框共用同一份校验策略(validateFiles,见下节),要调规则只改一处。
粘贴文件(usePasteFiles)
截图、复制的图片、从资源管理器复制的文件,在输入框里 Ctrl/⌘+V 直接进附件条——不用先存盘再点上传。纯文本粘贴完全不受影响(剪贴板里没有文件时不拦截,输入框按默认行为插入文本)。
import { usePasteFiles } from "react-chat-component"
const paste = usePasteFiles({
accept: ".png,.jpg,.pdf",
maxFiles: 6,
existingCount: attachments.length, // 上限按「已有 + 本次」算
onFiles: (files) => setAttachments((prev) => [...prev, ...files.map(toItem)]),
onError: ({ message }) => toast(message), // 与 useDropZone 的 onError 同一个函数即可
})
<textarea {...paste.props} placeholder="粘贴截图试试" />选项与回调:
| 选项 | 说明 |
| --- | --- |
| accept | 与 useDropZone 同一套规则:扩展名 / MIME / image/* 通配;空字符串接受全部 |
| multiple / maxFiles | 是否允许多文件(默认 true)与一次上限(默认 3),超出的以 too-many-files 拒绝 |
| maxSize | 单文件字节上限(默认 10 MB),超限以 file-too-large 拒绝 |
| existingCount | 已占用名额(默认 0):传附件条当前条数,上限即按「已有 + 本次」算 |
| disabled | 禁用(默认 false):禁用期间粘贴完全不接管(不 preventDefault、不回调任何东西) |
| rename | 是否给剪贴板通用名补时间戳(默认 true):image.png → 粘贴图片_20260918-070509.png;资源管理器复制来的真实文件名保持不动 |
| onFiles(files) | 通过校验的文件(已完成改名,按原顺序) |
| onError(rejection) | 被拒文件按原因分组回调:{ code, message, files },形状与 useDropZone 的完全一致 |
挂载位置:推荐直接展开到 textarea(<textarea {...paste.props} />)——精确命中,不会接管同一张卡片里别的输入框;展开到外层容器也可用(粘贴事件会从输入框冒泡上来)。
取文件的兼容处理:先读 clipboardData.files,为空时回退遍历 items 里 kind === "file" 的条目(部分来源的截图只把文件放在 items 里)。
与附件条打通:onFiles 里把文件塞进 attachments 即可。校验策略本身也单独导出,供调用方自己的文件选择框复用(拖拽 / 粘贴 / 文件框三处一套规则):
import { validateFiles } from "react-chat-component"
const { accepted, rejections } = validateFiles(files, {
accept, // ".png,.pdf" / "image/*" / ""(全部)
multiple: true,
maxSize: 10 * 1024 * 1024,
maxFiles: 6,
existingCount: attachments.length, // 可选,默认 0
})
// rejections: [{ code: "file-invalid-type" | "file-too-large" | "too-many-files", message, files }]每条消息气泡下方提供悬停操作按钮(纯图标 + hover 原生 tooltip,触屏设备常驻显示):复制(自己的消息和 AI 回复都有,点击复制文本、图标短暂变为勾且主色高亮);重新生成(仅 AI 回复,需传入 onRegenerate 才显示,行为由调用方实现)。
主题覆盖:在 .rcc-chat 容器外层通过 CSS 变量改主题色,支持 --rcc-accent、--rcc-bubble-user 等(变量清单见 src/chat.scss)。
AI 回复默认与用户消息同为「气泡」形态(最宽 70%、右/左对齐);传 fullWidthReply 后 AI
回复的气泡与思考卡改撑满整条消息列(上限即消息列宽 --rcc-content-w),长文、宽表格、
代码块的排版更接近主流 AI 聊天——配合 transparentReply 即为无气泡底的整列流式排版
(playground 默认即此组合)。用户消息不受影响,仍保持原气泡形态。
气泡内渲染 Markdown(AI 回复)
库不内置 markdown 解析依赖,保持零运行时依赖——通过 renderContent 注入点交给调用方实现(谁需要 markdown 谁装解析器),气泡排版的 CSS 则由库内置(传 renderContent 后气泡自动切换 --rich 模式,提供标题 / 列表 / 行内代码 / 代码块 / 引用 / 表格 / 任务列表勾选框的样式,长代码块与宽表格在气泡内横向滚动)。推荐做法——react-markdown + remark-gfm,依赖与组件库平级、装在你的项目里:
# 一条命令装齐:Markdown 解析 + GFM(表格 / 任务列表 / 删除线)+ 代码块语法高亮
# highlight.js 只为引用高亮主题 CSS 文件(rehype-highlight 的传递依赖,显式安装保证路径可解析)
npm i react-markdown remark-gfm rehype-highlight highlight.jsimport Markdown from "react-markdown" // 与组件库同级的依赖,加到你的项目里;注意:v10 根入口仅 default 导出(Markdown 组件)
import remarkGfm from "remark-gfm"
import rehypeHighlight from "rehype-highlight" // 可选:代码块语法高亮(highlight.js 内核,需自己安装)
import "highlight.js/styles/atom-one-light.css" // 浅色主题;深色气泡场景换 github-dark.css
function MarkdownContent({ text }: { text: string }) {
return (
<Markdown
remarkPlugins={[remarkGfm]} // GFM:表格 / 任务列表 / 删除线
rehypePlugins={[rehypeHighlight]} // 仅高亮带 language-* 的 fenced 代码块,行内 code 不受影响
components={{
a(props) {
const { node, ...rest } = props
void node // 剔除 react-markdown 注入的 node 字段,勿透传到真实 <a>
return <a target="_blank" rel="noreferrer" {...rest} />
},
}}
>
{text}
</Markdown>
)
}
<Chat
renderContent={(content, msg) =>
msg.role === "assistant" ? <MarkdownContent text={content} /> : content
}
/>- 语法高亮是可选项:装
rehype-highlight后按上面代码引入即可(高亮 CSS 主题文件在highlight.js/styles/,深浅气泡各选其一);不装则代码块退回纯文本样式,其余不受影响; - 代码块头部(代码图标 + 语言标签 + 复制按钮)也是可选项:样式随
style.css内置(结构类.rcc-codeblock+__bar/__lang/__copy),在渲染器里把pre覆写成该结构即生效——语言取 fenced 块的language-*,无语言的代码块(缩进代码块 / 裸 fenced)不套头部、退化为普通代码块卡片;复制写浏览器剪贴板(需 HTTPS / localhost),完整实现见playground/markdown.tsx;不覆写则代码块保持无头部样式; - 不需要开启
rehype-raw:react-markdown 默认不渲染原始 HTML,链接协议也有 URL 白名单过滤,AI 输出的不可信内容无需再 sanitize; - 流式输出同样适用:每个增量片段都会走
renderContent,未闭合的代码块 / 表格在解析器下能正常累积渲染; - 复制 / 重新生成等操作仍作用于消息的原始纯文本
content,不受渲染影响。
我的提问锚点(userAnchor)
长对话里想一眼看到自己发过哪些问题时,给 Chat 传 userAnchor={true}:消息列右侧垂直居中悬着一条
无背景的竖列指示器——对话里每条用户消息对应一个「文本 + 短圆角横条」同行条目
(条数 = 用户消息数,AI 回复不计入):
- 常态只见横条:条目平时完全透明,只露出右侧那根灰色短圆角横条(无胶囊底、无边框,
横条与聊天区域之间留出空隙);消息多、放不下时轨道在固定高度内(
min(220px, 42vh))纵向 滚动,滚动条隐藏; - 两级悬停:① 悬停锚点区域任一条横条 → 轨道浮现白色圆角列表卡片(白底 + 细边框 +
阴影,默认背景即白色),每条横条左侧显示该条用户消息的单行省略缩略文本(一个文本
只对应自己的横条,逐行对齐、整行可点,高亮的那条文本转深色加粗);② 在某行
缩略文本上停留满 1s → 才在该行左侧弹出黑色半透明浮层
(
rgba(17,24,39,.88)白字,行中心垂直定位、轨道外渲染不受滚动裁剪,可跨到浮层上 细读、限高内部滚动)显示完整内容——1s 计时防快速划过列表或打算直接点击时误弹, 换行悬停会立即收掉旧浮层并重新计时;指针离开文本后 220ms 延迟收起(键盘聚焦则立即 弹出,无悬停概念); - 高亮只由点击决定:点第 4 条就只有第 4 条是高亮态(主题色
--rcc-accent+ 拉长, 其余仍是灰色短条)。没有「跟滚高亮」——滚动消息列、流式输出新内容,都不会自动 换高亮(以前的滚动观察线策略已去掉:滚动时逐条自动亮起会看着像一路扫过去); 进页面时没有任何高亮,直到你点第一条; - 点击 = 换高亮 + 瞬移消息列:点击条目(文字或横条)把高亮切到这一条,同时把消息列
一步到位地滚到这条提问(顶部对齐、上方留 16px 空白,
ASK_JUMP_TOP_PAD)——不平滑 滚动(scrollTop赋值而非scrollTo({behavior:"smooth"}),一路滑过去又慢又容易看花), 目标超出可滚范围时浏览器自动夹到最底;这次滚动会顺带把「贴底跟随」标记置 false,所以你 在看历史时流式输出不会把你拽回底部(锚点轨道与页面本身不动);刚点的条目闪两下主题色 光环,对应气泡缓慢闪烁两次(白色描边脉冲)提示是哪条; - 边界:只收录非空用户消息;≤ 640px 窄屏自动隐藏;触屏(无 hover)设备白色卡片与 全文浮层禁用、只保留横条点击选中与气泡闪烁。
面板本体实现在 src/AskAnchor.tsx(库内部组件,未对外导出):它只负责渲染条目列表、
两级悬停与黑底浮层,高亮归属(active)和点选回调(onSelect)由 Chat 传入
(Chat 只在点选时改 active,不做滚动观察线);样式仍在 chat.scss 的
.rcc-chat__ask* 下。所以对外只要给 Chat 传 userAnchor 这一个开关,不用单独引组件。
<Chat messages={messages} userAnchor={true} /* 右侧「我的提问」指示器(默认关闭) */ />思考过程(Thinking)
AI 在回答前先输出一段内部推理(如 DeepSeek 流式接口的 reasoning_content)是常见形态。
消息带上 thinking 字段后,Chat 会在 AI 正文上方渲染一个可折叠的「已思考」小卡片
(样式参考 DeepSeek 网页端思考消息:浅色小卡 + 四角星图标 + 「已思考(用时 X 秒)」+ 展开箭头):
const messages: ChatMessage[] = [
{
id: "a1",
role: "assistant",
content: "这是正文回复。",
thinking: "这是思考过程,可能含 **Markdown** 结构。",
thinkingTime: 2.4, // 思考耗时(秒,可选);有值时头部显示「已思考(用时 2 秒)」
},
]
<Chat messages={messages} renderContent={renderContent} />- 自动展开策略:消息只有 thinking、正文还没开始(content 为空且未给
thinkingTime)时 视为「思考进行中」——卡片自动展开,头部文案「正在思考…」并跳动小圆点;正文开始(content 非空,或调用方补上thinkingTime表示思考已完成)后自动收起成一行小标题。用户手动点过 折叠头的消息以用户选择为准;新一轮生成清空旧内容后自动遗忘旧选择,重新走自动策略; - 流式接入:每个增量片段用
{ ...m, thinking: 累积文本 }更新消息即可,思考文本与正文 分开累积;正文首个片段到达时在同一份消息上补thinkingTime,思考卡即自动收尾成 「已思考(用时 X 秒)」。完整示例见 playground:deepseek.ts解析reasoning_content、App.tsx的mergePiece(真实 / 模拟双路径共用); - 思考正文的渲染:默认按纯文本渲染(保留换行)。传
renderThinking={(thinking, message) => …}可自定义渲染——思考正文的文字排版 (标题 / 列表 / 行内代码 / 引用等)与气泡共用同一套 Markdown 规则,传入后自动套用, 示例直接复用正文的MarkdownContent渲染器; - 复制 / 重新生成等操作仍只作用于消息的正文
content,不受思考卡影响; - 思考卡主题可覆盖变量:
--rcc-think-bg(卡片底色,默认#f7f8fa)、--rcc-think-text(思考正文文字色,默认#4b5563)。
消息图片(用户发图 / AI 回复图片)
消息里带图片时(如用户上传照片提问、AI 回复里给出生成的图),给 ChatMessage 加
images 字段即可——无需任何新 props,数据驱动渲染:
import type { ChatMessage, MessageImage } from "react-chat-component"
const messages: ChatMessage[] = [
{
id: "u1",
role: "user",
content: "帮我看看这几张构图", // 文字与图片可同条共存;content 为空则为纯图消息
images: [
{ url: "https://example.com/pic1.png", name: "构图 A" },
{ url: "https://example.com/pic2.png", name: "构图 B" },
],
},
{
id: "a1",
role: "assistant",
content: "基于你的图做了 4 版封面:",
images: [{ url: "https://example.com/cover1.png" }, /* … */],
},
]- MessageImage:
{ url?: string; name?: string; status?: "loading" },url 支持 http(s) / blob / data URL;图片渲染在正文气泡下方整块,随所在消息列靠齐(用户右侧 / AI 左侧); - 生成中占位(骨架屏):AI 文生图是异步的——先给消息
images塞一批{ status: "loading" }(不带 url)的占位条目:图片区顶部显示「正在生成图片…」 提示行,每张占位渲染成闪烁骨架屏(斜向高光扫过,与就绪缩略图同格同尺寸); 模型逐张出图后原地把对应条目换成带 url 的就绪对象即可——数组下标不变, 缩略格不跳动、提示行在最后一张就绪时自动消失。单张占位为 400×400 方形区 (默认 1:1 出图与真图基本零跳动,其它画幅随后按自然比例排布); - 排版:单张真图无固定框——宽撑满容器(上限 400px)、高度按原图宽高比 自适应,不裁切、无底色 letterbox 留边(多张网格仍是 1:1 方形裁切缩略格、 约 170px)——2/4 张排 2 列(4 张呈 2×2),其余 3 列自动换行(末行可不满), 容器总宽上限 360/516px、消息列更窄时随容器等比收缩;
- 下载:hover 就绪图片(或键盘聚焦进图片容器)时右上角浮现下载按钮——blob /
同源地址用
<a download>直存;跨域临时图床(如百炼)先 fetch 成 blob 再下载, fetch 读不回来时退化为新窗口打开右键另存; - 全屏预览:点击任一张缩略图弹出全屏大图(portal 到 body,z-index 1500,同附件
浮层档位):同一消息多张时两侧箭头 /
←→循环切换,Esc/ 点遮罩 / 点图 / 点 右上角 × 关闭,预览期间锁定页面滚动;头部左侧显示「x / y」与图名; - 纯图消息(content 为空)不渲染空气泡、图片区独立成消息主体;开启
userAnchor时这类提问在「我的提问」锚点里的条目文案回退为「N 张图片」; - 缩略图是按钮元素(键盘可达、focus 有主题色描边),
img懒加载。
playground 空态「图片消息 · 用户发图 + AI 回图」演示了上述全部形态;真实发送时把
待发附件中的图片快照进消息 images 即可(见 App.tsx 的 send)。
真实出图(可选):设置面板「图像生成(qwen-image-3.0-pro)」粘贴阿里云百炼
(DashScope)API Key 后,输入提问带「画 / 图 / 生成 / 海报 / 封面」等词会走真实
qwen-image-3.0-pro 出图:先落骨架屏占位(「正在生成图片…」闪烁),请求返回后原地替换
成真图(客户端见 playground/zimage.ts——DashScope 原生 multimodal-generation 端点,
与 z-image 同族,模型名可传 model 切换;浏览器跨域直连可用;返回的是约 24 小时
有效的临时 URL,key 仅存本地,生产请服务端转发)。未配置 Key 时自动回落到内置模拟
流程:提问落消息 → 骨架屏闪烁 → 逐张出图(mockImageReply,提问带「一张」等词出
单图、否则 4 版方案)。
自动贴底跟随(输出时滚动到底)
回复流式输出 / loading 三圆点占位会让消息列表在尾部持续增长,Chat 默认(autoScroll,
关闭可传 autoScroll={false})自动跟随滚到底部,让最新输出始终可见。跟随是有条件
的,不是无条件强制滚动:
- 只在用户正处于底部附近(距底部 64px 内)时才跟随;期间向上翻历史(读前面的 内容)不会被强拉下去,滚回底部附近后自动恢复跟随;
- 用户主动发送消息后无条件恢复贴底(自己发的消息必须看到);
- 触顶分页把更早历史插到头部时不走本逻辑——那是独立的视口锚定(见下节),互不干扰;
- 滚动发生在消息区内部(
.rcc-chat__body是唯一滚动容器),跟随只作用于该容器, 页面外层滚动不受影响。
历史消息分页(触顶加载)
长会话历史按需向前翻页:滚到消息列表顶部自动触发加载更早一页;新消息插到头部时视口自动锚定——正在读历史不会跳动,本来就贴底(等新回复)则不受影响、继续贴底。
- 数据获取在调用方:
onLoadEarlier触发时拉取更早一页、把新消息插到messages头部即可(组件按首条消息 id 变化识别「头部插入」并补偿滚动位置); - 防重入两种方式任选:请求期间保持
loadingEarlier=true(顶部显示细加载指示),或让onLoadEarlier返回 Promise(resolve / reject 后组件自动解锁); hasMore=false时触顶不再触发(已无更多历史);- 完整可跑示例见 playground:空态点「载入超长历史 · 演示触顶分页」后上滚翻页,
loadEarlierHistory/seedHistory即接入范例。
Chat 全宽布局:消息列与输入卡的自适应居中
"限宽居中"不放在外层容器(无需 .page__col 之类的 width: min() 包裹层),而是内置于组件:Chat 自己铺满任意宽度容器,消息列与底部输入卡在内部自动收敛到目标宽度并共享中线居中;容器变窄时自动退化为贴边铺满(保留安全边距)。全程纯 CSS,无媒体查询、无 JS、无额外 DOM。
两个可覆盖变量控制宽度(默认均 760px,任意外层祖先声明即可整体调整,也可分别设不同值制造「输入卡比消息列窄」的收口效果):
| 变量 | 作用 | 默认 |
|---|---|---|
| --rcc-content-w | 消息列(气泡内容区)宽度 | 760px |
| --rcc-composer-w | 底部输入卡片宽度 | 760px |
| --rcc-composer-radius | 输入卡圆角 | 18px |
| --rcc-composer-h | 输入卡实测高度(组件写入,只读):消息区底部留白按它计算,插槽撑高卡片时自动多让出空间 | — |
消息列:padding 公式收拢
消息区是唯一滚动容器,所以不额外包一层"列",而是让它的水平 padding 自适应:
.rcc-chat__body {
padding: 16px max(16px, calc((100% - var(--rcc-content-w, 760px)) / 2))
max(176px, calc(var(--rcc-composer-h, 0px) + 24px));
}底部留白不再写死:输入卡(绝对定位浮层)把自己的实测高度写到根节点的 --rcc-composer-h(ResizeObserver 跟随),消息区取 max(176px, 实测 + 24px)——卡不高时仍是老的 176px 基数,插槽把卡撑高(多行输入、附件条、卡顶 tab 行……)时留白自动跟上,最后一条消息永远能完整滚到卡片上方。带附件时的 240px 基数同理。
推导:设容器宽 W,内容要恰好 --rcc-content-w 并居中,左右各需留白 (W − 760) / 2——padding 本身就是留白,设成该值后内容区宽恒为 W − 2×(W − 760)/2 = 760px。外层的 max(16px, …) 负责窄屏兜底:
| 容器宽度 | (W − 760) / 2 | 取 padding | 效果 | |---|---|---|---| | 1400px | 320px | 320px | 消息列恒 760px 居中 | | 800px | 20px | 20px | 消息列仍 760px | | 500px | −130px | 16px(max 兜底) | 消息列 = 容器宽 − 32 |
宽屏"手风琴式"固定列宽居中,窄屏自动回落到 16px 安全边距——没有断点,全靠 max() 数学。
输入卡:min 限宽 + 50% 平移居中
composer 是绝对定位悬浮卡片(position: absolute),原先靠 left/right: 12px 撑满宽度,现在改为:
.rcc-chat__composer {
position: absolute;
left: 50%;
width: min(calc(100% - 24px), var(--rcc-composer-w, 760px));
transform: translateX(-50%);
}width: min(容器宽 − 24, 760):宽屏取 760,窄屏取容器宽 − 24(即原来左右各 12px 边距的行为),min 自动选小者,无需断点;- 绝对定位元素无法用
margin: 0 auto居中,所以用经典技巧:left: 50%把卡片左缘放到容器中线,transform: translateX(-50%)再向左平移自身宽度的一半回正——对任意宽度都成立,容器怎么变都保持居中。
两段公式为何天然对齐
消息列靠 padding 左右对称、输入卡靠 50% 平移对称,两者的对称轴都是容器中线,因此共享同一条中线;默认宽度又相同,左缘右缘也对齐,视觉上是上下贯通的一条对话列。
附带收益:气泡比例恢复合理
气泡 max-width: 70% 的基准是"所在内容区宽度"。限宽放在外层时,全宽容器(如 1400px)会把气泡上限算成 ~980px,长文本行无法阅读;限宽下沉到组件内部后内容列恒 760px,气泡上限约 524px,行宽回到合理范围。
兼容性
max() / min() 等 CSS 数学函数(参数内可直接书写表达式,无需再套 calc())属 2023 年后的浏览器基线特性,与本库使用的 :has() 同级,无需前缀。
Layout 页面布局容器
左右两栏纵贯整个容器高度,中间一列内部自含 header / 滚动内容区 / footer:
import { Layout, Anchor } from "react-chat-component"
<Layout
header={<顶栏 />} // 中间列顶部(固定)
left={{ // 左栏两种形态:配置对象(内置菜单)或任意 ReactNode
items: [ // 图标 + 文本菜单:宽屏收起后自动退化为纯图标栏
{ key: "chat", label: "AI 对话", icon: <ChatIcon />, active: true, onClick: () => {} },
{ key: "theme", label: "主题", icon: <SunIcon />, iconOnCollapsed: false },
// iconOnCollapsed=false 或没配 icon 的项,收起成图标栏时整体隐藏
],
}}
right={<Anchor containerRef={mainRef} />} // 右栏(如锚点目录)
footer={<底部 />} // 中间列底部(固定)
contentRef={mainRef} // 暴露中间滚动区 DOM,供 Anchor 等使用
>
<article>{/* 正文:唯一滚动区域 */}</article>
</Layout>- 滚动只发生在中间内容区,其余区域固定;不传的区域自动不渲染
- 依赖父容器提供确定高度(内部
height: 100%) - 左右栏宽度可用变量覆盖:
--rcc-layout-left-w(默认 260px)、--rcc-layout-right-w(默认 220px)、--rcc-layout-left-collapsed-w(宽屏收起后的图标栏宽,默认 64px)
左侧抽屉(传 left 自动启用)
传了 left 后,Layout 自动启用左侧抽屉,开关(SVG 圆钮,Lucide panel-left 风格)常态位于左栏右上角;左栏有两种形态:
- 传配置对象
{ items: [{ key, label, icon?, active?, onClick?, iconOnCollapsed? }] }:按内置「图标 + 文本」菜单渲染——每条一行图标加文本(SVG 图标由调用方提供,库不内置图标集),文本溢出省略;宽屏收起成图标栏后只留图标、悬浮 title 提示 - 传任意 ReactNode:原样渲染(收起后左栏仍收窄为图标栏宽度,自定义内容需自行适配 64px)
收起行为按屏幕宽度分两种:
- 宽屏(> 768px):点击开关左栏平滑收窄成图标栏(默认 64px,
--rcc-layout-left-collapsed-w可调,0.22s 过渡;网格 auto 列带动内容区同步扩展)。iconOnCollapsed逐项控制:默认true(需配了icon),设false或没配icon的项在收起态整体隐藏 - 窄屏(≤ 768px):左栏变悬浮抽屉——展开时从左侧滑出盖在内容上(阴影分隔)并铺半透明遮罩,点遮罩或开关关闭;跨入窄屏断点自动收起;收起态开关随栏滑出屏幕,组件在头部左侧渲染一个同款兜底开关重新展开(浮在顶栏上,顶栏左侧有内容时自行留 ~56px 内边距,playground 即如此处理)
- 不传
left时开关与整个抽屉机制不渲染
Anchor 锚点目录
自动扫描滚动容器内的 h1~h6 生成右侧竖排目录:滚动时高亮当前章节(内容区上方 20% 为观察线)、点击平滑滚动定位;标题缺 id 自动补、内容动态增删可跟随(MutationObserver),不写 URL hash。
const mainRef = useRef<HTMLElement | null>(null)
<Anchor
containerRef={mainRef} // 滚动容器 ref(配合 Layout 的 contentRef)
title="本页目录" // 顶部小标题,不传则不显示
selector="h2,h3,h4,h5,h6" // 标题选择器,默认扫描全部 h1~h6
emptyText="暂无目录"
/>VoiceInput 语音听写
浏览器内置语音识别(Web Speech API)组件,不依赖任何云服务;与 Chat 的 ref 句柄配合实现「勾选确认 → 文本回显到输入框,可编辑后再发送」:
import { useRef } from "react"
import { Chat, VoiceInput, type ChatHandle } from "react-chat-component"
const chatRef = useRef<ChatHandle>(null)
<Chat
ref={chatRef}
inputRight={
<VoiceInput
onConfirm={(text) => chatRef.current?.setDraft(text)} // 回显到输入框(可继续编辑)
/>
}
/>- 常态是输入框右侧的一个 mic 圆钮;点击后识别面板以绝对定位接管整个输入卡片——模拟输入框的胶囊内实时显示识别文本,整卡只剩右侧浮动的关闭与确认两个按钮可点;
- 确认:把最终识别文本交给
onConfirm(本示例回显进输入框),面板收起;关闭:放弃本次识别; - 聆听反馈:识别期间提示位常驻实时波形图,识别文字不实时回显(只内部累积)——SpeechRecognition 不暴露音频流,组件会另开一路
getUserMedia采集同一麦克风(Chromium 允许两者并存),用 Web Audio API(AudioContext+AnalyserNode取时域采样)逐帧绘制对称音量条,随说话起伏、静音回落,canvas 按devicePixelRatio高清渲染、颜色跟随--rcc-accent;用户点「确认」后识别文本才经onConfirm一次带出(调用方回显到输入框 / 直接发送)。采集仅用于可视化不播放,权限被拒 / 无 AudioContext 时静默降级,识别流程不受影响; - 识别语言默认跟随浏览器
navigator.language(langprop 可覆盖,如"en-US");语种、识别均由浏览器内置完成,仅需 Chrome / Edge(需 HTTPS 或 localhost);Safari / Firefox 不支持 SpeechRecognition,激活时会给出提示、面板可正常关闭; - 识别过程实时回调
onResult(text, isFinal)(中间结果),可用来做输入框实时预览等; - 放置要求:外层容器须为定位祖先(Chat 的输入卡片即为
position: absolute,放进inputRight插槽即可)——面板用position: absolute; inset: 0铺满该容器。
依赖要求
- React ^19(peerDependency)
