Agent Chat
AI 问答前端:左侧会话历史 + 中间问答流 + 右侧过程/文档/溯源分栏,按场景(scene)驱动的多实例可嵌入组件。
核心特点:
- 按 Agent 步骤编排 UI:一条 assistant 消息内部拆 thinking → tool_call → answer;流式过程中逐步展开,完成后过程区折叠成一行摘要(步数 / 工具数 / 耗时),可点开回看,回答正文常驻
- 回答正文
/ 过程双区 + 文档呈现区:正文里的链接按需打开右侧面板,支持 Markdown / Word / PPT 内联预览、溯源清单,宽度可拖拽持久化 - 单流控制:每个实例同一时刻只允许一条流在跑,启动新的会先中断旧的并标记
interrupted;支持用户中断、复制回答、失败重试、重新生成、溯源查看 - 完全隔离的 scope:内部自建 pinia / router / api / 流控制器,不
app.use(),不占用宿主 URL(默认 memory history),同一页面可放多个实例互不干扰
快速开始
npm install
npm run dev # http://localhost:5173
Demo(playground/APP.vue)复现宿主 src/views/chatView.vue 的接入方式(单实例 + 跨窗口 postMessage 协议),无需宿主工程即可联调。
Mock 开关走环境变量 VITE_USE_MOCK,默认 false(走真实接口):
$env:VITE_USE_MOCK = "true"; npm run dev # PowerShell
VITE_USE_MOCK=true npm run dev # bash / zsh
打开 Mock 后接口走本地假数据,右侧呈现面板、过程动画、失败注入都可以脱离后端直接验证。
会话写接口用 mock、其余走真实后端
后端往往先出列表 / 详情 / 流式,而新建、重命名、删除后补。这时把 config.mockSessionApi 打开(默认 true):
新建 / 重命名 / 删除走本地假数据,会话列表(POST /dsh/session/pageList)、
详情(GET /dsh/session/upstreamHistory 历史回放)与提问流(POST dsh/chat/stream)都走真实后端。
后端写接口就绪后把它置为 false 即可,无需改代码。
<AgentChat scene="default" :config="{ mock: false, mockSessionApi: true }" />
本地联调示例(playground)
包内自带一个可独立运行的联调 demo(playground/),完整复现宿主 src/views/chatView.vue 的接入方式,便于不启动宿主工程就验证跨窗口协议、AgentChatHandle 会话桥与样式。该目录已纳入 files 白名单随包发布,安装后可在 node_modules/@cnki_frontend/agent-chat/playground/ 看到源码。
运行
npm run dev # 启动 playground,默认 http://localhost:5173
npm run dev 走 vite.config.ts(已挂 @vitejs/plugin-vue 且根目录设为 playground),无需宿主工程。
两种调试视图
playground 通过同一个 import 语句 import AgentChat, { type AgentChatHandle } from '@cnki_frontend/agent-chat' 切换视图,由环境变量 AGENT_CHAT_USE_DIST 控制:
| 模式 | 命令 | 解析到 | 用途 |
|---|---|---|---|
| 源码(默认) | npm run dev | 别名 → ./index.ts | 改 src/ 实时热更,调试最新实现 |
| 打包产物 | AGENT_CHAT_USE_DIST=1 npm run dev | 别名 → ./dist | 验证「安装后的包」真实行为(需先 npm run build 出 dist) |
与宿主
vite.config.ts的AGENT_CHAT_USE_DIST开关一致:开发态引源码、生产态引 dist。
目录内容
playground/APP.vue:chatView.vue的接入骨架——agentChatRef接组件defineExpose句柄、挂载后写入agentChatHandle、消费pendingSessionId、监听message(onHostMessage)、向父页postMessage('ready')、401 时postMessage('needLogin')。playground/main.ts:引入@cnki_frontend/agent-chat/style.css(别名已覆盖 source / dist 两种来源)。playground/config/scenes/jgxkdmx.json:示例场景配置。
agentChatBridge的两个变量(agentChatHandle/pendingSessionId)在 playground 里是就地内联的ref,并未从宿主导入——它们只是两个响应式句柄,移交给宿主桥接层即可。
作为使用者如何运行
从 npm 安装本包后想直接跑这个 demo,需自行补上运行依赖(它们是包的 devDependencies,不随安装带入),二选一:
# 方案 A:在包目录内补装并运行
cd node_modules/@cnki_frontend/agent-chat
npm i -D vite @vitejs/plugin-vue
npm run dev
# 方案 B:把 playground/ 拷到自己的项目,再补装上述依赖后运行
开发约定(改本仓库前必读)
1. 引用文件必须使用相对路径
import type { AgentStep } from '../../types/session' // ✅
import { useScopedSessionStore } from '../../scope/useScope'
import type { AgentStep } from '@/types/session' // ❌ 不许用别名
import { XXX } from '@pkg/scope/useScope' // ❌ 同上
具体写法:
- 同一子树内部:
./MessageList.vue、./useScope - 跨顶层目录(
types/scope/stores/composables/api/utils/mocks):按文件所在深度取./../../../,最深只有两层 - 不要绕到根再绕回来:
components/chat/里引文档面板应写../document/DocumentPanel.vue,而不是../../components/document/DocumentPanel.vue - 代价是移动或新建文件要同步改导入,改完跑一次
npm run build
2. agent-chat 的业务代码一律在本仓库内修改
本仓库是唯一真源。 任何问答、过程、文档呈现、调用链路相关的改动,都在本仓库源码里做,改完再同步给宿主。 不要把
src/拷进宿主后就地改一版了事——下次同步会把这些改动直接覆盖掉。
宿主侧允许做的事只有三件:安装依赖、引入样式、通过 props(scene / config / routerMode / storage)做定制。场景级差异优先走 props 与 src/scope/scenes.ts 里新增场景配置,而不是在宿主里 fork 一份组件改。
嵌入到宿主项目
宿主工程只通过安装 @cnki_frontend/agent-chat 包的方式引入 agent-chat:npm i @cnki_frontend/agent-chat 后,在宿主侧 import AgentChat from '@cnki_frontend/agent-chat',并按下方「2)引入样式」在宿主入口 main.ts 引入样式即可。不要把 src/ 整坨拷进宿主——包内已自包含全部代码与资源,宿主零源码改造。
1)安装依赖(包本身不内置运行时依赖,相关库作为对等依赖由宿主提供):
npm i @cnki_frontend/agent-chat
# 以下为对等依赖,宿主需自行安装(版本见 @cnki_frontend/agent-chat 的 peerDependencies)
npm i vue pinia vue-router axios @microsoft/fetch-event-source markstream-vue vditor docx-vue-viewer pptx-vue-viewer
2)引入样式(宿主需自行安装 @cnki_frontend/agent-chat 包):
// 宿主 src/main.ts
import '@cnki_frontend/agent-chat/style.css'
切勿用
vite-plugin-lib-inject-css做运行时内联注入:它对懒加载 chunk 的 CSS 会迟于首帧应用,导致 pptx-vue-viewer 这类「按容器尺寸自适应」的 viewer 在首帧 无样式状态下测量布局、触发 ResizeObserver 循环 → 页面持续闪烁(word 不受影响, 因其为静态文档、不做 fit-to-container)。本包的exports已暴露./style.css。
3)使用组件:
<script setup lang="ts">
import AgentChat from '@cnki_frontend/agent-chat'
</script>
<template>
<!-- 父容器需要给定高度:组件根是 height:100% -->
<div style="height: 100vh">
<AgentChat scene="analysis" :config="{ placeholder: '输入你的问题' }" />
</div>
</template>
不要在宿主 app 上
app.use(pinia)/app.use(router)来"接管"组件:scope 内部的 pinia 与 router 都下发在其组件子树上,宿主全局实例与它互不感知,这是刻意设计。
参数(AgentChatOptions)
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
scene | string | 必填 | 业务场景标识,决定接口参数、文案与 mock 数据 |
scopeId | string | 取 scene | 同场景同屏多实例时用于区分(影响 localStorage key) |
routerMode | 'memory' | 'web' | 'memory' | memory 不占用宿主 URL(默认,多实例安全);web 读写地址栏,同页面只允许一个 web 实例(window.history 是浏览器单例,base 冲突时开发模式会告警) |
base | string | web 模式为 /<scene>,否则 / | router 基路径 |
config | Partial<SceneConfig> | — | 覆盖该场景的 title / placeholder / samples / apiBase / mock / mockSessionApi |
storage | Storage | localStorage | 呈现区宽度等本地持久化介质的注入点 |
request | RequestOptions | — | 请求定制:headers()(可 async)/ withCredentials / onError(统一异常出口,见「统一鉴权」) |
api | Partial<ChatApi> | — | 数据端口逃生舱,按 key 覆盖任意方法(见「接口契约」) |
scene 是构造参数而非响应属性:运行时切换请给组件加 :key 重建实例。
宿主操作句柄(AgentChatHandle)
通过组件 ref 拿到操作句柄,只暴露方法、不暴露内部 store,宿主据此驱动会话而不耦合 agent-chat 的 Pinia 实例:
<script setup lang="ts">
import { ref } from 'vue'
import AgentChat, { type AgentChatHandle } from '@cnki_frontend/agent-chat' // 也可直接引 './packages/agent-chat/AgentChat.vue'
const agentChatRef = ref<AgentChatHandle | null>(null)
// 组件挂载后即可通过 ref 操作;方法内部自行同步 store / 路由
</script>
<template>
<div style="height: 100vh">
<AgentChat ref="agentChatRef" scene="analysis" />
</div>
</template>
| 方法 | 返回 | 说明 |
|---|---|---|
getSessions() | Ref<Session[]> | 会话列表(响应式;宿主读 .value 随 agent-chat 内部变更实时更新) |
getCurrentId() | Ref<string | null> | 当前选中会话 id(null = 首页 / 新对话态) |
getGeneratingIds() | Ref<Set<string>> | 生成中会话 id 集合(远端轮询 ∪ 本地流 的并集);宿主侧边栏据此显示「忙」图标。问答 finish/fail/stop 时会立即触发一次轮询刷新,无需等下一周期 |
openSession(id) | void | 打开指定会话(走 agent-chat 内部路由跳转) |
startNew() | void | 回首页 / 新对话态,清空当前会话 |
createSession(title?) | Promise<Session | null> | 立即新建空会话并返回 |
loadMoreSessions() | Promise<void> | 加载下一页(内部有 loading/hasMore 保护,重复调用无副作用) |
deleteSession(id, title?) | Promise<void> | 删除会话(调后端 + 本地清理 + 提示,同一套逻辑) |
renameSession(id, title) | Promise<void> | 重命名会话(同一套逻辑) |
ask(input) | void | 在当前会话发起一轮提问(与输入框同一条流式链路;sending 中调用被内部守卫忽略) |
askNew(prompt, title?, model?) | Promise<Session | null> | 传提示词自动新建会话(后端建会话须带 prompt + model)并进入该会话、流式补全首轮回答(链路同首页首问:createSession(prompt, model) → setPendingPrompt → 跳转由 ChatView 拉流)。model 省略用场景默认模型;可选 title 建完用 renameSession 补标题(库无「建会话带标题」API)。每次新建互不干扰,返回新建会话(失败 null) |
getPaging() | SessionPaging | 分页状态(hasMore / loading),用于「加载中 / 没有更多了」 |
setSearchKeyword(keyword) | void | 设置会话列表标题筛选关键字;空串取消筛选、回到「最近生成」 |
跨窗口下发提示词:agent-chat 库不内置入站
postMessage监听;iframe / 新窗口场景下,宿主应在自己的message处理器里,对拿到的AgentChatHandle调用askNew(prompt)(参考playground/APP.vue的onHostMessage写法)。
跨窗口消息协议(iframe 嵌入)
当 agent-chat 被放进 iframe 嵌入宿主页面时,双方用 window.postMessage 通信(不依赖 $bus/$emit,跨 window 无效)。
agent-chat / 嵌入页 → 宿主(父窗口):以下消息一律以 JSON 字符串 发出(JSON.stringify),宿主 window 监听后需 JSON.parse。注意宿主既有 _onMessage 对 typeof === 'object' 的消息直接 return,故 agent-chat 侧必须发字符串。
| cmd | payload | 含义 / 宿主动作 |
|---|---|---|
agentChatSidebar | { cmd:'agentChatSidebar', source:'agent-chat', collapsed: boolean } | agent-chat 内部会话侧边栏收起 / 展开;宿主可据此隐藏 / 显示自己的会话侧边栏,避免两套并列 |
needLogin | { source:'agent-chat', cmd:'needLogin' } | 401 时由嵌入页 dealError 发出;宿主应触发登录流程 |
ready | { source:'agent-chat', cmd:'ready' } | 嵌入页 onMounted 注册完 message 监听后发出;宿主收到后才可安全下发指令(见下) |
宿主 → agent-chat(经嵌入页监听器):以下消息可为对象或字符串(嵌入页 onHostMessage 两者都解析);字段约定 source + cmd。
| cmd | payload | 含义 / 动作 |
|---|---|---|
goHome | { source:'chat_web', cmd:'goHome' } | 触发 agentChatRef.startNew(),回首页(不重载 iframe,保留内存态) |
握手(避免竞态):本应用 src/main.ts 有顶层 await loadRuntimeConfig(),app.mount() 在其后才执行,故嵌入页 onMounted(message 监听注册)晚于 iframe 的 load 事件。若宿主在自身 onMounted / @load 直接 postMessage,消息会在嵌入页监听就绪之前到达并被丢弃(postMessage 不缓存)。推荐做法:宿主监听 ready,收到 ready 后再下发 goHome;未就绪前的指令先排队,收到 ready 后补发——无论 iframe 加载多慢都 100% 收到。
关键约定
接口契约
组件对外部世界的唯一依赖是 ChatApi 这 6 个方法(定义在 src/api/index.ts),不含任何 HTTP / URL / 鉴权细节:
interface ChatApi {
listSessions: () => Promise<Session[]>
createSession: (title?: string) => Promise<Session>
renameSession: (id: string, title: string) => Promise<void>
deleteSession: (id: string) => Promise<void>
fetchSessionDetail: (id: string) => Promise<SessionDetail>
streamChat: (req: StreamChatRequest) => StreamHandle
}
默认实现:REST 走 axios(src/api/http.ts),流式走 @microsoft/fetch-event-source(src/api/sse.ts)。scene 不拼进 URL 路径,一律作参数(GET 走 query、写操作与流式走 body):
| 方法 | 路径 | 参数 | 说明 |
|---|---|---|---|
POST | {apiBase}/dsh/session/pageList | { pageNum, pageSize } | 会话列表 |
POST | {apiBase}/sessions | { title } | 新建会话,由后端返回 id |
PATCH | {apiBase}/sessions/:id | { title } | 重命名 |
DELETE | {apiBase}/sessions/:id | — | 删除 |
GET | {apiBase}/dsh/session/upstreamHistory | sessionId / pageNum / pageSize | 详情 = 历史回放:一行一轮(question + answer),翻页合并后还原成 messages |
POST | {apiBase}/dsh/chat/stream | multipart:prompt / sessionId / moduleCode / featureCode | 流式问答,返回 SSE 流 |
历史回放以 messages[].evens 为渲染源:里面存的是当时那帧 SSE(与实时流返回一致),逐条喂给与实时流同一个解析器(src/api/agentStream.ts),因此历史消息的步骤构成、步骤 id 与实时一致。
同行的 answer / artifacts 只是兜底:只有当 evens 缺失或一条都没回放出来(老会话没存事件)时才使用,避免用字段值覆盖掉事件里更完整的思考 / 工具过程。
流式请求走 SSE,事件名在 event: 上(服务端不发 event 名时退回 payload 的 type 字段)。解析器(src/api/agentStream.ts)先把三种帧形态归一成 {type, data} 再分发:旧 {payload:{event:{type,data}}}、整体序列化 {type,data}、新 event:<名称> + 帧体即负载。
| event | data | 落到哪里 |
|---|---|---|
start | { session_id } | 顶层:记录会话 id |
assistant/message | { step, message:{content:[…]} } | 一条 assistant 快照:reasoning/thinking → 思考 step、text → 回答正文、tool-call → 工具调用 step |
tool/call | { step, callId, name, arguments } | 工具调用(补全 assistant/message 里缺的工具名 / 入参) |
tool/result | { callId, result|result_summary, isError } | 工具回包:落到对应 tool_call step 的 toolOutput,isError 时置 error |
todo/write | { todos:[{ content, status }] } | 任务清单 → todo step |
subagent/catalog | { childId, mode, label } | 子代理身份,挂到最近一次 subagent 工具调用上 |
session/title | { title } | 会话标题 |
step/end | { step } | 步骤边界:把该 step 分组的 step 标记 done |
compaction/start | compaction/end | — | 上下文过长自动压缩的提示(notice) |
make-answer | MakeAnswerData | 结构化确认卡 → make_question step(见「make-answer 结构化确认交互」) |
done | { final_response?, artifacts|deliverables? } | 顶层:final_response 为最终答案,产物名单写入 message 级注册表,随后收尾 |
error | { code, detail, http_status } | 顶层:置 error 态,出现重试入口 |
server/queued | — | 顶层:实例繁忙排队提示 |
另有仅识别、不产出 step 的流水事件:user/message、system/message、agent/inbox/spliced、request/header、request/context、session.status、turn/start、turn/end、step/start、tool/error(工具错误走 tool/result 的 isError)。
后端形状对不上时,用 props 注入自己的实现,而不是把源码拷过去改:
<AgentChat
scene="analysis"
:request="{
headers: async () => ({ Authorization: `Bearer ${await getToken()}` }),
withCredentials: true,
onError: (err) =>
(err.status === 401 || err.status === 403) && router.push('/login'),
}"
:api="{ listSessions: myList, streamChat: mySse }"
/>
api 是按 key 覆盖(partial),未列出的方法仍走默认 HTTP 实现;流式要回调的领域事件见 src/api/sse.ts:onStep、onStepUpdate、onDocuments、onTraces、onDone、onError。
请求 / 流失败统一归一为 ApiError(带 status / url / payload),不需要宿主认识 axios / SSE 的结构。
store 侧的写操作统一走「乐观更新 → 调接口 → 失败回滚」:重命名/删除失败会自动还原,新建失败不会跳进一个后端并不存在的会话 URL。
统一鉴权
组件不内置任何凭证逻辑,鉴权全部由 request props 注入,而且只有一个出口:
interface RequestOptions {
/** 每次请求前调用,返回要附加的 header;支持 async(便于过期再取 / 刷新 token) */
headers?: () => Record<string, string> | Promise<Record<string, string>>
/** 跨域请求携带 cookie */
withCredentials?: boolean
/**
* 统一异常出口:所有请求 / 流失败(含 401 / 403、网络失败、服务端 error 帧)
* 归一为 ApiError 后回调,由宿主统一处理(跳登录 / 刷新凭证 / 全局提示)。
*/
onError?: (err: ApiError) => void
}
- REST:axios 请求拦截器注入(见
src/api/http.ts) - SSE:
fetch-event-source内部自建 fetch、不过拦截器,但取的是同一个buildHeaders()钩子
因此不会出现「REST 带了 token、流式没带」这类漂移。每个 scope 一个 axios 实例(不用全局默认实例),多实例之间互不污染。
文档链接 doc://<id>
assistant 正文中的链接会被事件委托拦截并打开右侧呈现区,支持三种写法(src/utils/docLink.ts):
doc://<id>:从本轮消息的documents注册表取实体- 带扩展名的 URL:按扩展名推断类型(
.md/.txt→ markdown,.docx→ word,.pptx→ ppt) - 其它:降级为「打开/下载」
回答末尾的产物列表每行带「预览 / 下载」两个按钮(components/chat/AssistantMessage.vue):预览走与正文链接同一条路(锁定该产物后打开右侧呈现区),下载走 utils/download.ts(先 fetch 成 blob 再落盘,保证用原始文件名;地址不可 fetch 时退回直接打开)。后端没返回 url 的产物两个按钮都禁用。
make-answer 结构化确认交互
后端可在流里发一个 make-answer 事件,让助手在生成前先与用户确认细节。前端把它渲染成一张确认卡(src/components/chat/MakeAnswerCard.vue),常驻在正文区、不进折叠过程区:
- 载荷
MakeAnswerData:title(引导文案)、fields:[{label,value}](课程/章节/题型/知识点等键值明细)、selectLabel(可选项标题)、options:[{id,label}](可选项)、skipText(动作按钮文案)、multiple(是否多选,默认 true)、answerPrefix(确认提问前缀)、skipPrompt(跳过提问文案) - 交互:chips 多选 + 单个动作按钮(无独立确认按钮)。点按钮即发起一轮新问答:有选中 → 新问题为
{answerPrefix}选中{label、label}(如知识点选中需求曲线);未选 → 新问题为{skipPrompt}(默认跳过)。answerPrefix缺省时从selectLabel推导(去括号、去尾部「范围」,如知识点范围(可多选)→知识点) - 接线:
make-answer在src/api/agentStream.ts解析为kind:'make_question'的 step(载荷落AgentStep.makeAnswer);AssistantMessage.vue渲染卡片并经ask()以 prompt 文本通道发起新一轮(后端当前只有 prompt 这一个文本通道) - Mock:
src/mocks/stream.json的makeAnswer即该卡演示数据,首轮提问会出现
路由即真相源
/ 首页,/chat/:id? 问答页(id 为空时自动落到会话并 replace 成带 id 的 URL)。store 单向跟随路由同步,不反向决定路由。
文档渲染器(按类型分流,不混用)
| 位置 | 渲染器 | 原因 |
|---|---|---|
| 回答正文(历史消息) | markstream-vue | 需要流式增量渲染 |
| 呈现区 Markdown 文档 | vditor(ir 即时渲染) | 所见即所得,可直接在渲染结果上编辑 |
| 呈现区 Word 文档 | docx-vue-viewer 的 WordEditor | DOCX 内联预览 / 编辑 |
| 呈现区 PPT 文档 | pptx-vue-viewer 的 PowerPointViewer | PPTX 内联预览 |
| 呈现区 JSON | <pre> 纯文本(JsonPreview.vue) | 缩进后原样铺开,不做树形 / 高亮 |
常用命令
npm run dev # 开发服务器
npm run build # vue-tsc --noEmit && vite build
npm run preview # 预览构建产物