npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.js
import 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(lang prop 可覆盖,如 "en-US");语种、识别均由浏览器内置完成,仅需 Chrome / Edge(需 HTTPS 或 localhost);Safari / Firefox 不支持 SpeechRecognition,激活时会给出提示、面板可正常关闭;
  • 识别过程实时回调 onResult(text, isFinal)(中间结果),可用来做输入框实时预览等;
  • 放置要求:外层容器须为定位祖先(Chat 的输入卡片即为 position: absolute,放进 inputRight 插槽即可)——面板用 position: absolute; inset: 0 铺满该容器。

依赖要求

  • React ^19(peerDependency)