@xinghunm/ai-chat
v1.4.2
Published
AI chat React component library
Downloads
956
Readme
@xinghunm/ai-chat
提供完整 AI 对话 UI 的 React 组件库,支持会话管理、流式响应、Agent 模式和图片附件。
安装
npm install @xinghunm/ai-chat
# 或
pnpm add @xinghunm/ai-chatPeer Dependencies
需在项目中单独安装:
npm install @emotion/react @emotion/styled @xinghunm/compass-ui axios react react-dom react-markdown rehype-katex remark-gfm remark-math zustand快速开始
推荐通过 transport 接入,这样 ai-chat 只负责通用 UI,后端协议由接入层决定:
import { AiChat, createDefaultChatTransport } from '@xinghunm/ai-chat'
const transport = createDefaultChatTransport({
apiBaseUrl: '/ai-api',
authToken: 'Bearer your-token-here',
})
export const App = () => <AiChat transport={transport} />显示会话列表侧边栏:
<AiChat transport={transport} showConversationList />接入历史会话列表时,ai-chat 不请求后端 API。宿主应用负责分页获取会话列表,并把已加载的数据传给组件;列表滚动到底部时,组件会触发 onLoadMoreSessions:
import { useState } from 'react'
import type { ChatMessage, ChatSession } from '@xinghunm/ai-chat'
const [sessions, setSessions] = useState<ChatSession[]>([])
const [isLoading, setIsLoading] = useState(false)
const [cursor, setCursor] = useState<string | null>(null)
const loadMoreSessions = async () => {
if (isLoading) return
setIsLoading(true)
const response = await fetch(`/chat/sessions?cursor=${cursor ?? ''}`)
const data = (await response.json()) as {
sessions: ChatSession[]
nextCursor: string | null
}
setSessions((current) => [...current, ...data.sessions])
setCursor(data.nextCursor)
setIsLoading(false)
}
const loadSessionMessages = async (session: ChatSession): Promise<ChatMessage[]> => {
const response = await fetch(`/chat/sessions/${session.sessionId}/messages`)
const data = (await response.json()) as { messages: ChatMessage[] }
return data.messages
}
export const App = () => (
<AiChat
transport={transport}
showConversationList
historySessionList={{
sessions,
isLoading,
hasMore: cursor !== null,
error: null,
}}
onLoadMoreSessions={loadMoreSessions}
onSelectHistorySession={loadSessionMessages}
/>
)如果你还在使用内置默认协议,也可以继续传 apiBaseUrl 和 authToken。AiChatProvider 会在内部自动创建默认 transport,属于兼容模式。
如果只是路径不同,也不需要自己重写整个 transport,可以只覆盖默认 adapter 的 endpoints:
const transport = createDefaultChatTransport({
apiBaseUrl: '/ai-api',
authToken: 'Bearer your-token-here',
endpoints: {
models: '/catalog/models',
completions: '/chat/run',
terminate: '/chat/stop',
},
})如果后端协议大体一致,只是模型列表或 /chat/completions 请求体需要调整,也可以继续复用默认 transport,只覆盖局部扩展点:
const transport = createDefaultChatTransport({
apiBaseUrl: '/ai-api',
authToken: 'Bearer your-token-here',
resolveModels: async () => ({
data: [{ id: 'deepseek-chat', object: 'model' }],
}),
buildRequestBody: ({ model, mode, content }) => ({
model: 'deepseek-chat',
base_url: 'https://api.deepseek.com/v1',
api_key: 'sk-xxxxx',
mode,
stream: true,
messages: [{ role: 'user', content }],
}),
})默认 transport 在检测到图片附件时,会自动把用户输入组装成多模态 messages[].content 数组,并将图片文件序列化为 Data URL 后发送;如果你自定义 buildRequestBody,也会同时收到 attachments 参数,可按后端协议自行处理。
完整用法
如需最大灵活性,可在 AiChatProvider 内手动组合子组件:
import { AiChatProvider, ChatConversationList, ChatThread, ChatComposer } from '@xinghunm/ai-chat'
import type { ChatTransport } from '@xinghunm/ai-chat'
const transport: ChatTransport = {
async getModels() {
return {
data: [{ id: 'my-model', object: 'model' }],
}
},
async startStream({
sessionId,
model,
mode,
content,
attachments,
onSessionId,
onUpdate,
onDone,
onError,
signal,
}) {
try {
const response = await fetch('/custom-chat/stream', {
method: 'POST',
signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, model, mode, content, attachments }),
})
const data = await response.json()
onSessionId?.(data.sessionId)
onUpdate({ content: data.answer })
onDone?.()
} catch (error) {
onError?.(error instanceof Error ? error : new Error('Unknown stream error'))
}
},
async terminateStream(sessionId) {
await fetch('/custom-chat/terminate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId }),
})
return { terminated: true }
},
}
export const CustomChat = () => (
<AiChatProvider
transport={transport}
defaultMode="agent"
labels={{
sendButton: '发送',
placeholder: '输入你的问题…',
newChat: '新建对话',
}}
>
<div style={{ display: 'flex', height: '100vh' }}>
<ChatConversationList />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<ChatThread />
<ChatComposer />
</div>
</div>
</AiChatProvider>
)Props API
AiChatProps
一体化 AiChat 组件的 Props,继承全部 AiChatProviderProps(不含 children)。
| 属性 | 类型 | 默认值 | 说明 |
| ------------------------ | ---------------------------------------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------- |
| transport | ChatTransport | — | 推荐。由接入方提供的传输适配器。 |
| apiBaseUrl | string | — | 兼容模式。创建默认内置 transport。 |
| authToken | string | — | 兼容模式下默认 transport 使用的鉴权头。 |
| defaultMode | ChatAgentMode | "agent" | 新会话的初始 Agent 模式。 |
| labels | AiChatLabels | — | 可选的 UI 文案覆盖。 |
| showConversationList | boolean | false | 为 true 时渲染会话列表侧边栏。 |
| historySessionList | ChatHistorySessionListState | — | 受控历史会话列表状态。宿主应用负责请求后端并传入已加载会话。 |
| onLoadMoreSessions | () => void \| Promise<void> | — | 会话列表滚动到底部且 hasMore 为 true 时触发,由宿主应用加载下一页。 |
| onSelectHistorySession | (session) => ChatMessage[] \| void \| Promise<ChatMessage[] \| void> | — | 点击未加载过的历史会话时触发。返回消息数组时组件会写入内部 store;返回 void 时宿主自行控制。 |
| messageRenderOrder | ChatMessageRenderOrder | "blocks-first" | 混合纯文本与结构化 block 时的渲染顺序。默认结构化卡片在前;设为 "timeline" 时按时间线优先保留文本在前。 |
| enableImageAttachments | boolean | true | 为 false 时隐藏上传按钮、禁用粘贴图片,并使程序化调用 pickImages/pasteImages 变为 no-op。 |
AiChatProviderProps
AiChatProvider 上下文提供者组件的 Props。
AiChatProvider 有两种接入方式,二选一:
| 属性 | 类型 | 必填 | 说明 |
| ------------------------ | ---------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------ |
| transport | ChatTransport | 是 | 推荐。完全自定义的传输层。 |
| defaultMode | ChatAgentMode | 否 | 新会话的初始 Agent 模式。 |
| labels | AiChatLabels | 否 | 可选的 UI 文案覆盖。 |
| renderMessageBlock | ChatMessageBlockRenderer | 否 | 自定义消息 block 渲染器,用于承接 type: "custom" 等扩展。 |
| messageRenderOrder | ChatMessageRenderOrder | 否 | 混合纯文本与结构化 block 时的渲染顺序。默认 "blocks-first",设为 "timeline" 可按时间线优先渲染文本片段。 |
| historySessionList | ChatHistorySessionListState | 否 | 受控历史会话列表状态。 |
| onLoadMoreSessions | () => void \| Promise<void> | 否 | 会话列表滚动到底部且仍有下一页时触发。 |
| onSelectHistorySession | (session) => ChatMessage[] \| void \| Promise<ChatMessage[] \| void> | 否 | 点击未加载过的历史会话时触发。 |
| enableImageAttachments | boolean | 否 | 为 false 时隐藏上传按钮、禁用粘贴图片,并使程序化调用 pickImages/pasteImages 变为 no-op。默认 true。 |
| children | ReactNode | 是 | Provider 内部渲染的子元素。 |
兼容模式:
| 属性 | 类型 | 必填 | 说明 |
| ------------------------ | ---------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------ |
| apiBaseUrl | string | 是 | 默认 adapter 的基础 URL。 |
| authToken | string | 是 | 默认 adapter 使用的 Authorization 请求头值。 |
| transformStreamPacket | TransformChatStreamPacket | 否 | 默认 adapter 的流式包归一化扩展点。 |
| defaultMode | ChatAgentMode | 否 | 新会话的初始 Agent 模式。 |
| labels | AiChatLabels | 否 | 可选的 UI 文案覆盖。 |
| renderMessageBlock | ChatMessageBlockRenderer | 否 | 自定义消息 block 渲染器,用于承接 type: "custom" 等扩展。 |
| messageRenderOrder | ChatMessageRenderOrder | 否 | 混合纯文本与结构化 block 时的渲染顺序。默认 "blocks-first",设为 "timeline" 可按时间线优先渲染文本片段。 |
| historySessionList | ChatHistorySessionListState | 否 | 受控历史会话列表状态。 |
| onLoadMoreSessions | () => void \| Promise<void> | 否 | 会话列表滚动到底部且仍有下一页时触发。 |
| onSelectHistorySession | (session) => ChatMessage[] \| void \| Promise<ChatMessage[] \| void> | 否 | 点击未加载过的历史会话时触发。 |
| enableImageAttachments | boolean | 否 | 为 false 时隐藏上传按钮、禁用粘贴图片,并使程序化调用 pickImages/pasteImages 变为 no-op。默认 true。 |
| children | ReactNode | 是 | Provider 内部渲染的子元素。 |
ChatTransport
ChatTransport 是通用化后的核心扩展点:
| 方法 | 说明 |
| ------------------- | -------------------------------------------------------- |
| getModels() | 返回模型列表,用于填充模型选择器。 |
| startStream() | 发送用户消息并通过 onUpdate 推送归一化后的流式 patch。 |
| terminateStream() | 请求终止当前会话的流式响应。 |
库内同时导出了 createDefaultChatTransport(),用于快速复用当前 /models、/chat/completions、/chat/terminate 协议。
createDefaultChatTransport() 同时支持 endpoints 覆盖:
| 键 | 默认值 | 说明 |
| ------------- | --------------------- | ---------------------- |
| models | "/models" | 模型列表接口路径。 |
| completions | "/chat/completions" | 流式聊天接口路径。 |
| terminate | "/chat/terminate" | 停止流式响应接口路径。 |
此外还支持两个轻量扩展点:
| 键 | 类型 | 说明 |
| ------------------ | ----------------------------------- | ------------------------------------------------------------------------------ |
| resolveModels | () => Promise<ChatModelsResponse> | 覆盖默认的 /models 请求,直接返回模型列表。 |
| buildRequestBody | (args) => unknown | 覆盖默认的 /chat/completions 请求体构造逻辑;args 同时包含 attachments。 |
AiChatLabels
所有字段均为可选,未指定的字段回退到 DEFAULT_AI_CHAT_LABELS 中的英文默认值。
| 键 | 默认值 | 说明 |
| ----------------------- | ------------------------------ | ------------------------------ |
| sendButton | "Send" | 发送按钮文案。 |
| stopButton | "Stop" | 停止/中止按钮文案。 |
| placeholder | "Ask something..." | 输入框占位文本。 |
| modeLabelAsk | "Ask" | Ask 模式标签。 |
| modeLabelPlan | "Plan" | Plan 模式标签。 |
| modeLabelAgent | "Agent" | Agent 模式标签。 |
| newChat | "New Chat" | 新建对话按钮文案。 |
| emptyStateTitle | "How can I help you?" | 空消息状态的主标题。 |
| emptyStateSubtitle | "Start a conversation" | 空消息状态的副标题。 |
| attachmentLimitNotice | "Images exceeded the limit…" | 达到附件数量上限时的提示文案。 |
Store
在高级场景下(如读取流式状态、以编程方式切换会话),可在 AiChatProvider 的子孙组件内通过内置 hooks 访问底层 Zustand store:
import { useChatStore, useChatContext } from '@xinghunm/ai-chat'
// 选取 store 的某个切片(仅在该切片变化时触发重渲染)
const activeSessionId = useChatStore((s) => s.activeSessionId)
// 获取完整上下文,包括 transport 和合并后的 labels
const { labels, transport } = useChatContext()两个 hooks 在 AiChatProvider 外部调用时均会抛出异常。
扩展自定义 Block
ai-chat 现在支持把“通用聊天壳子”和“业务工作流卡片”解耦。推荐做法是:
- 用
transformStreamPacket把后端的自定义 packet 转成blocks - 用
renderMessageBlock渲染type: "custom"的 block
import type { ChatMessageBlockRendererProps, TransformChatStreamPacket } from '@xinghunm/ai-chat'
const transformStreamPacket: TransformChatStreamPacket = ({ packet, defaultUpdate }) => {
if (
packet.type === 'message_complete' &&
typeof packet.data === 'object' &&
packet.data !== null &&
'widget' in packet.data
) {
return {
...defaultUpdate,
blocks: [
{ type: 'custom', kind: 'widget', data: (packet.data as { widget: unknown }).widget },
],
}
}
return defaultUpdate
}
const renderMessageBlock = ({ block }: ChatMessageBlockRendererProps) => {
if (block.type !== 'custom' || block.kind !== 'widget') {
return null
}
return <pre>{JSON.stringify(block.data, null, 2)}</pre>
}这样业务卡片可以放在接入层或扩展包里,基础包继续只负责通用聊天体验。
控制消息渲染顺序
默认情况下,ai-chat 采用 blocks-first 语义:一条消息同时包含 content 和结构化 blocks 时,会先渲染卡片类 block,再渲染正文。这适合参数卡、确认卡、结果卡等“先看结构化信息,再看解释文本”的场景。
如果你的接入层会在流式文本中途插入审批卡片、工作流卡片或其他结构化 block,可以把 messageRenderOrder 设为 "timeline"。这样在消息同时存在纯文本和非 markdown block 时,组件会优先保留文本在前,再接上 block,更接近真实到达顺序。
从当前版本开始,timeline 模式还会自动记录结构化 block 第一次出现时对应的文本位置。也就是说:
- 当正文已经流出一部分时,后续才收到审批卡或其他自定义 block,
ai-chat会把这张卡片锚定在它首次出现的位置。 - 卡片出现之后继续流出的新文本,会自动渲染到卡片后面。
- 业务侧不需要再为了“卡片停留在触发点”手动把前文转成
markdown block。
这尤其适合下面这种真实流式场景:
- assistant 先输出一段解释文本
- 中途触发
approval_required或其他自定义卡片 - assistant 在卡片之后继续输出补充说明
在这种情况下,timeline 会自动把消息排成 “前文 -> 卡片 -> 后文”。
import { AiChatProvider, ChatThread, ChatComposer } from '@xinghunm/ai-chat'
export const CustomChat = () => (
<AiChatProvider
transport={transport}
messageRenderOrder="timeline"
renderMessageBlock={({ block }) => {
if (block.type !== 'custom' || block.kind !== 'tool-approval') {
return null
}
return <ToolApprovalCard data={block.data} />
}}
>
<ChatThread />
<ChatComposer />
</AiChatProvider>
)选择建议:
- 继续使用默认值
"blocks-first":适合静态消息排版,结构化信息应始终优先展示。 - 使用
"timeline":适合流式响应中途插卡,希望卡片停留在触发事件位置,且不想在业务侧维护额外文本冻结逻辑的场景。
