@yumiai/chat-widget 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## [0.1.1] - 2025-03-17
4
+
5
+ ### Fixed
6
+
7
+ - **子 Agent 状态与 notification_turns 对齐**:子 Agent 卡片的「已完成」改为优先依据后端下发的 `agent_end` 通知(与 jetagents notification_turns 的 agent start/stop 一致),不再仅凭「已有文字」误判为已停止;未收到 `agent_end` 时仍回退为「有内容且无进行中工具」则已完成。
8
+ - 聚合器新增对 `agent_end` 的处理与每轮 `endedAgentInstanceIds` 的维护,Round 与 ChildAgentCard 支持 `endedAgentInstanceIds` 用于展示状态。
package/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # @yumiai/chat-widget
2
+
3
+ Agent 对话 UI 组件 — 为 AI Agent 系统提供开箱即用的聊天界面。
4
+
5
+ ## 特性
6
+
7
+ - **SSE 实时流式渲染** — POST-based Server-Sent Events,支持认证、自动重连
8
+ - **Streamdown Markdown** — 代码高亮、Mermaid 图表、LaTeX 数学公式、CJK 排版优化
9
+ - **多轮对话管理** — 轮次分隔、清屏滚动、动态置顶(带防抖与滞后阈值)
10
+ - **子 Agent 嵌套** — 折叠式子 Agent 卡片,支持 `call_agent` 工具链路可视化
11
+ - **工具调用卡片** — MCP 工具生命周期(生成 → 执行 → 完成),buffering 动画
12
+ - **Todo/Checklist** — 实时任务进度面板,按 Phase → Group → Task 三级结构展示
13
+ - **HITL 表单** — Human-in-the-Loop 交互,支持 JSON Schema / HTML Schema 动态表单
14
+ - **产出物查看器** — 文件 Artifact 点击预览,支持文本/二进制类型识别
15
+ - **打字机效果** — 基于 `requestAnimationFrame` 的逐字展开,自动对齐 Markdown 安全边界
16
+ - **主题支持** — `light` / `dark` / `auto` 三种主题,CSS 变量驱动
17
+ - **Adapter 模式** — 认证、API 地址、Token 刷新完全由消费方注入,零耦合
18
+
19
+ ## 安装
20
+
21
+ ```bash
22
+ npm install @yumiai/chat-widget
23
+ ```
24
+
25
+ **Peer Dependencies:**
26
+
27
+ ```json
28
+ {
29
+ "react": "^18.0.0 || ^19.0.0",
30
+ "react-dom": "^18.0.0 || ^19.0.0",
31
+ "antd": "^5.0.0 || ^6.0.0"
32
+ }
33
+ ```
34
+
35
+ ## 快速开始
36
+
37
+ ```tsx
38
+ import { ChatWidget, DefaultJetAgentsAdapter } from '@yumiai/chat-widget'
39
+ import '@yumiai/chat-widget/style.css'
40
+
41
+ const adapter = new DefaultJetAgentsAdapter({
42
+ baseUrl: 'https://your-jetagents-server.com',
43
+ getToken: () => localStorage.getItem('access_token'),
44
+ refreshToken: async () => {
45
+ const res = await fetch('/api/auth/refresh', { method: 'POST' })
46
+ const { token } = await res.json()
47
+ localStorage.setItem('access_token', token)
48
+ return token
49
+ },
50
+ onAuthFailure: () => window.location.href = '/login',
51
+ })
52
+
53
+ function App() {
54
+ return (
55
+ <ChatWidget
56
+ adapter={adapter}
57
+ sessionId={123}
58
+ config={{
59
+ theme: 'light',
60
+ showPinnedArea: true,
61
+ enableTypewriter: true,
62
+ typewriterSpeed: 30,
63
+ displayLevels: [0, 1, 2],
64
+ }}
65
+ onReady={(api) => console.log('Widget ready', api)}
66
+ onError={(err) => console.error(err)}
67
+ height="100vh"
68
+ renderFooter={(api) => (
69
+ <input
70
+ onKeyDown={(e) => {
71
+ if (e.key === 'Enter' && api) {
72
+ api.sendMessage(e.currentTarget.value, 'agent-666')
73
+ e.currentTarget.value = ''
74
+ }
75
+ }}
76
+ placeholder="输入消息..."
77
+ />
78
+ )}
79
+ />
80
+ )
81
+ }
82
+ ```
83
+
84
+ ## 自定义 Adapter
85
+
86
+ `DefaultJetAgentsAdapter` 适配 JetAgents 后端 API。对于其他后端系统,实现 `ChatWidgetAdapter` 接口即可:
87
+
88
+ ```tsx
89
+ import type { ChatWidgetAdapter } from '@yumiai/chat-widget'
90
+
91
+ class MyAdapter implements ChatWidgetAdapter {
92
+ async createSession(agentId: string) { /* ... */ }
93
+ async getSessionDetail(sessionId: number) { /* ... */ }
94
+ async getSessionHistory(sessionId: number) { /* ... */ }
95
+ async getResourceContent(sessionId: number, resourceId: string) { /* ... */ }
96
+ async submitHITLResponse(response) { /* ... */ }
97
+ getStreamUrl(): string { return '/api/stream' }
98
+ getAuthHeaders(): Record<string, string> { return {} }
99
+ }
100
+ ```
101
+
102
+ 详细接口定义见 [docs/02-adapter-interface.md](docs/02-adapter-interface.md)。
103
+
104
+ ## API 参考
105
+
106
+ ### ChatWidgetProps
107
+
108
+ | Prop | 类型 | 默认值 | 说明 |
109
+ |------|------|--------|------|
110
+ | `adapter` | `ChatWidgetAdapter` | **必填** | 后端适配器实例 |
111
+ | `sessionId` | `string \| number` | — | 会话 ID,传入后自动加载历史 |
112
+ | `config` | `ChatWidgetConfig` | `DEFAULT_CONFIG` | UI 配置 |
113
+ | `renderHeaderExtra` | `(sessionId) => ReactNode` | — | 头部右侧额外内容插槽 |
114
+ | `renderFooter` | `(api) => ReactNode` | — | 底部输入区插槽 |
115
+ | `onReady` | `(api: ChatWidgetAPI) => void` | — | Widget 就绪回调 |
116
+ | `onArtifactClick` | `(artifact: Artifact) => void` | — | 产出物点击回调 |
117
+ | `onHITLSubmit` | `(response) => Promise<void>` | — | HITL 表单提交回调 |
118
+ | `onStatusChange` | `(status: ExecutionStatus) => void` | — | 执行状态变化回调 |
119
+ | `onError` | `(error: Error) => void` | — | 错误回调 |
120
+ | `height` | `number \| string` | `600` | 容器高度 |
121
+ | `theme` | `'light' \| 'dark' \| 'auto'` | `'auto'` | 主题 |
122
+
123
+ ### ChatWidgetAPI
124
+
125
+ 通过 `onReady` 或 `renderFooter` 获取的 API 对象:
126
+
127
+ | 方法 | 签名 | 说明 |
128
+ |------|------|------|
129
+ | `sendMessage` | `(message: string, agentId?: string) => void` | 发送消息并建立 SSE 连接 |
130
+ | `getCurrentRound` | `() => Round \| null` | 获取当前轮次 |
131
+ | `getAllRounds` | `() => Round[]` | 获取全部轮次 |
132
+ | `scrollToBottom` | `() => void` | 滚动到底部 |
133
+ | `getStatus` | `() => ExecutionStatus` | 获取执行状态 |
134
+
135
+ ## 构建
136
+
137
+ ```bash
138
+ npm run build # 生产构建(ESM + CJS + DTS)
139
+ npm run dev # 开发模式(watch)
140
+ npm run test # 运行测试
141
+ npm run typecheck # TypeScript 类型检查
142
+ ```
143
+
144
+ ## 发布
145
+
146
+ ```bash
147
+ # 确保 .npmrc 中配置了 npm token
148
+ npm version patch # 升版本
149
+ npm publish --access public
150
+ ```
151
+
152
+ ## 目录结构
153
+
154
+ ```
155
+ chat-widget/
156
+ ├── src/
157
+ │ ├── index.ts # 公共导出
158
+ │ ├── types.ts # 类型定义
159
+ │ ├── errors.ts # 错误类型
160
+ │ ├── ChatWidget.tsx # 主组件
161
+ │ ├── ChatWidget.css # 主样式
162
+ │ ├── hooks/
163
+ │ │ ├── useSSE.ts # SSE 连接 Hook
164
+ │ │ └── useMessageAggregator.ts # 消息聚合 Hook
165
+ │ ├── adapters/
166
+ │ │ ├── ChatWidgetAdapter.ts # Adapter 接口
167
+ │ │ └── DefaultJetAgentsAdapter.ts # JetAgents 默认适配器
168
+ │ └── components/
169
+ │ ├── PinnedArea.tsx # 置顶区域
170
+ │ ├── RoundHeader.tsx # 轮次标题
171
+ │ ├── MessageContent.tsx # 消息内容渲染
172
+ │ ├── ChildAgentCard.tsx # 子 Agent 卡片
173
+ │ ├── ToolCardBuffering.tsx # 工具调用卡片
174
+ │ ├── TodoCard.tsx # Todo 进度面板
175
+ │ ├── PlanCard.tsx # 计划面板
176
+ │ ├── SchemaFormRenderer.tsx # HITL 表单渲染器
177
+ │ └── renderers/ # 工具结果渲染器
178
+ ├── tests/ # 集成测试
179
+ ├── docs/ # 设计文档
180
+ ├── tsup.config.ts # 构建配置
181
+ ├── vitest.config.ts # 测试配置
182
+ └── package.json
183
+ ```
184
+
185
+ ## License
186
+
187
+ Proprietary — YumiAI
package/dist/index.cjs CHANGED
@@ -107,7 +107,8 @@ function extractTaskPurposeFromPreview(preview) {
107
107
  function useMessageAggregator() {
108
108
  const [state, setState] = (0, import_react.useState)({
109
109
  rounds: /* @__PURE__ */ new Map(),
110
- currentInteractionId: null
110
+ currentInteractionId: null,
111
+ endedAgentInstanceIdsByRound: /* @__PURE__ */ new Map()
111
112
  });
112
113
  const [todoMap, setTodoMap] = (0, import_react.useState)(/* @__PURE__ */ new Map());
113
114
  const todoMapRef = (0, import_react.useRef)(/* @__PURE__ */ new Map());
@@ -117,6 +118,7 @@ function useMessageAggregator() {
117
118
  const roundIndexRef = (0, import_react.useRef)(/* @__PURE__ */ new Map());
118
119
  const processedChunksRef = (0, import_react.useRef)(/* @__PURE__ */ new Set());
119
120
  const callBatchToRoundRef = (0, import_react.useRef)(/* @__PURE__ */ new Map());
121
+ const endedAgentInstanceIdsByRoundRef = (0, import_react.useRef)(/* @__PURE__ */ new Map());
120
122
  const parsePlan = (0, import_react.useCallback)((content) => {
121
123
  try {
122
124
  const parsed = JSON.parse(content);
@@ -258,6 +260,18 @@ function useMessageAggregator() {
258
260
  } else if (!existingRoundForBatch) {
259
261
  callBatchToRoundRef.current.set(call_batch_id, interaction_id);
260
262
  }
263
+ if (msg.notification_type === "agent_end") {
264
+ const iid = interaction_id;
265
+ const aid = msg.agent_instance_id;
266
+ if (iid != null && aid != null) {
267
+ const prev = endedAgentInstanceIdsByRoundRef.current;
268
+ const set = new Set(prev.get(iid) || []);
269
+ set.add(aid);
270
+ endedAgentInstanceIdsByRoundRef.current = new Map(prev).set(iid, set);
271
+ }
272
+ latestInteractionId = interaction_id;
273
+ continue;
274
+ }
261
275
  let round = roundIndexRef.current.get(interaction_id);
262
276
  if (!round) {
263
277
  round = {
@@ -405,9 +419,14 @@ function useMessageAggregator() {
405
419
  }
406
420
  setState(() => {
407
421
  const newRounds = new Map(roundIndexRef.current);
422
+ const endedByRound = endedAgentInstanceIdsByRoundRef.current;
423
+ const endedAgentInstanceIdsByRound = new Map(
424
+ [...endedByRound.entries()].map(([k, v]) => [k, new Set(v)])
425
+ );
408
426
  return {
409
427
  rounds: newRounds,
410
- currentInteractionId: latestInteractionId
428
+ currentInteractionId: latestInteractionId,
429
+ endedAgentInstanceIdsByRound
411
430
  };
412
431
  });
413
432
  if (todoMapChanged) {
@@ -422,8 +441,12 @@ function useMessageAggregator() {
422
441
  }
423
442
  }, [flushUpdates]);
424
443
  const getRoundsList = (0, import_react.useCallback)(() => {
425
- return Array.from(state.rounds.values()).sort((a, b) => a.index - b.index);
426
- }, [state.rounds]);
444
+ const endedByRound = state.endedAgentInstanceIdsByRound;
445
+ return Array.from(state.rounds.values()).map((r) => ({
446
+ ...r,
447
+ endedAgentInstanceIds: endedByRound.get(r.interactionId) || /* @__PURE__ */ new Set()
448
+ })).sort((a, b) => a.index - b.index);
449
+ }, [state.rounds, state.endedAgentInstanceIdsByRound]);
427
450
  const getCurrentRound = (0, import_react.useCallback)(() => {
428
451
  if (!state.currentInteractionId) return null;
429
452
  return state.rounds.get(state.currentInteractionId) || null;
@@ -443,7 +466,8 @@ function useMessageAggregator() {
443
466
  newRounds.set(interactionId, round);
444
467
  return {
445
468
  rounds: newRounds,
446
- currentInteractionId: interactionId
469
+ currentInteractionId: interactionId,
470
+ endedAgentInstanceIdsByRound: prevState.endedAgentInstanceIdsByRound
447
471
  };
448
472
  });
449
473
  }, []);
@@ -452,6 +476,7 @@ function useMessageAggregator() {
452
476
  messageIndexRef.current.clear();
453
477
  callBatchToRoundRef.current.clear();
454
478
  processedChunksRef.current.clear();
479
+ endedAgentInstanceIdsByRoundRef.current.clear();
455
480
  const newRounds = /* @__PURE__ */ new Map();
456
481
  for (const round of rounds) {
457
482
  newRounds.set(round.interactionId, round);
@@ -464,7 +489,7 @@ function useMessageAggregator() {
464
489
  }
465
490
  }
466
491
  const lastId = rounds.length > 0 ? rounds[rounds.length - 1].interactionId : null;
467
- setState({ rounds: newRounds, currentInteractionId: lastId });
492
+ setState({ rounds: newRounds, currentInteractionId: lastId, endedAgentInstanceIdsByRound: /* @__PURE__ */ new Map() });
468
493
  }, []);
469
494
  const finalizeRound = (0, import_react.useCallback)((interactionId) => {
470
495
  const round = roundIndexRef.current.get(interactionId);
@@ -480,9 +505,11 @@ function useMessageAggregator() {
480
505
  }
481
506
  }
482
507
  if (changed) {
508
+ const endedByRound = endedAgentInstanceIdsByRoundRef.current;
483
509
  setState(() => ({
484
510
  rounds: new Map(roundIndexRef.current),
485
- currentInteractionId: interactionId
511
+ currentInteractionId: interactionId,
512
+ endedAgentInstanceIdsByRound: new Map([...endedByRound.entries()].map(([k, v]) => [k, new Set(v)]))
486
513
  }));
487
514
  }
488
515
  }, []);
@@ -492,6 +519,7 @@ function useMessageAggregator() {
492
519
  roundIndexRef.current.clear();
493
520
  processedChunksRef.current.clear();
494
521
  callBatchToRoundRef.current.clear();
522
+ endedAgentInstanceIdsByRoundRef.current.clear();
495
523
  todoMapRef.current.clear();
496
524
  if (rafId.current) {
497
525
  cancelAnimationFrame(rafId.current);
@@ -499,7 +527,8 @@ function useMessageAggregator() {
499
527
  }
500
528
  setState({
501
529
  rounds: /* @__PURE__ */ new Map(),
502
- currentInteractionId: null
530
+ currentInteractionId: null,
531
+ endedAgentInstanceIdsByRound: /* @__PURE__ */ new Map()
503
532
  });
504
533
  setTodoMap(/* @__PURE__ */ new Map());
505
534
  }, []);
@@ -2280,14 +2309,20 @@ var ChildAgentCard = ({
2280
2309
  config,
2281
2310
  onArtifactClick,
2282
2311
  defaultExpanded = false,
2283
- parentTaskPurpose
2312
+ parentTaskPurpose,
2313
+ endedAgentInstanceIds
2284
2314
  }) => {
2285
2315
  const [isExpanded, setIsExpanded] = (0, import_react20.useState)(defaultExpanded);
2286
2316
  const contentRef = (0, import_react20.useRef)(null);
2287
- const hasActiveContent = children.some(
2317
+ const allMessages = (0, import_react20.useMemo)(() => [message, ...children], [message, children]);
2318
+ const hasExplicitEnd = endedAgentInstanceIds != null && endedAgentInstanceIds.has(message.agentInstanceId);
2319
+ const hasActiveContent = allMessages.some(
2288
2320
  (m) => m.contentType === "text" && m.contentChunks.join("").trim()
2289
2321
  );
2290
- const status = hasActiveContent ? "completed" : "running";
2322
+ const hasInProgressTool = allMessages.some(
2323
+ (m) => m.toolPhase === "generating" || m.toolPhase === "executing"
2324
+ );
2325
+ const status = hasExplicitEnd ? "completed" : hasActiveContent && !hasInProgressTool ? "completed" : "running";
2291
2326
  (0, import_react20.useEffect)(() => {
2292
2327
  if (isExpanded && contentRef.current) {
2293
2328
  contentRef.current.scrollTop = contentRef.current.scrollHeight;
@@ -2763,7 +2798,8 @@ var ChatWidget = ({
2763
2798
  config,
2764
2799
  onArtifactClick: handleArtifactClick,
2765
2800
  defaultExpanded: true,
2766
- parentTaskPurpose: msg.taskPurpose
2801
+ parentTaskPurpose: msg.taskPurpose,
2802
+ endedAgentInstanceIds: round.endedAgentInstanceIds
2767
2803
  },
2768
2804
  `child-${childKey}-${agentInstanceId}`
2769
2805
  )