@tbox.cn/app-template-agent 0.7.5 → 0.8.0

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.
Files changed (152) hide show
  1. package/.gitignore +5 -0
  2. package/.tbox/app.json +9 -1
  3. package/AGENTS.md +12 -4
  4. package/ENV.md +102 -17
  5. package/README.md +36 -34
  6. package/apps/client/VISUAL_RESTORE.md +11 -11
  7. package/apps/client/index.html +3 -1
  8. package/apps/client/package.json +9 -8
  9. package/apps/client/src/business/App.tsx +4 -4
  10. package/apps/client/src/business/README.md +5 -11
  11. package/apps/client/src/cards/registry.ts +23 -8
  12. package/apps/client/src/main.tsx +36 -2
  13. package/apps/client/src/modules.ts +7 -4
  14. package/apps/client/src/pages/index/index.tsx +18 -162
  15. package/apps/client/src/platform/AgentChatApp.tsx +3 -3
  16. package/apps/client/src/platform/agent-runtime.ts +5 -0
  17. package/apps/client/src/store/chat.ts +47 -185
  18. package/apps/client/src/test/setup.ts +7 -0
  19. package/apps/client/vite.config.ts +16 -1
  20. package/apps/server/package.json +0 -1
  21. package/apps/server/src/app-tools.ts +2 -1
  22. package/apps/server/src/app.ts +178 -110
  23. package/apps/server/src/config/integration.ts +2 -6
  24. package/apps/server/src/dev.ts +0 -1
  25. package/apps/server/src/intent-routes.ts +3 -3
  26. package/apps/server/src/modules.ts +4 -4
  27. package/apps/server/src/pre-tools.ts +4 -3
  28. package/apps/server/tests/agent-prompt.test.ts +81 -0
  29. package/apps/server/tests/cards-registry.test.ts +1 -3
  30. package/apps/server/tests/deployment-loader.test.ts +35 -56
  31. package/apps/server/tests/tool-runtime.test.ts +25 -61
  32. package/package.json +5 -4
  33. package/packages/agent-ui-kit/LICENSE +21 -0
  34. package/packages/agent-ui-kit/README.md +17 -0
  35. package/packages/agent-ui-kit/package.json +58 -0
  36. package/packages/agent-ui-kit/src/ChatPage.less +62 -0
  37. package/packages/agent-ui-kit/src/ChatPage.tsx +305 -0
  38. package/packages/agent-ui-kit/src/chat-config.test.ts +82 -0
  39. package/packages/agent-ui-kit/src/chat-config.ts +62 -0
  40. package/{apps/client → packages/agent-ui-kit}/src/components/ActionBar/DebugButton.tsx +2 -2
  41. package/{apps/client → packages/agent-ui-kit}/src/components/ActionBar/index.less +19 -16
  42. package/{apps/client → packages/agent-ui-kit}/src/components/ActionBar/index.tsx +10 -13
  43. package/packages/agent-ui-kit/src/components/CardRenderer.tsx +36 -0
  44. package/packages/agent-ui-kit/src/components/ChatHeader/index.less +148 -0
  45. package/packages/agent-ui-kit/src/components/ChatHeader/index.test.tsx +74 -0
  46. package/{apps/client → packages/agent-ui-kit}/src/components/ChatHeader/index.tsx +27 -15
  47. package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/index.less +47 -39
  48. package/packages/agent-ui-kit/src/components/ChatInput/index.test.tsx +156 -0
  49. package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/index.tsx +13 -4
  50. package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/voice/useVoicePress.ts +2 -2
  51. package/packages/agent-ui-kit/src/components/HistoryEntry/index.less +49 -0
  52. package/packages/agent-ui-kit/src/components/HistoryEntry/index.tsx +42 -0
  53. package/{apps/client → packages/agent-ui-kit}/src/components/MediaList/index.less +3 -3
  54. package/{apps/client → packages/agent-ui-kit}/src/components/MediaList/index.tsx +6 -6
  55. package/{apps/client → packages/agent-ui-kit}/src/components/MessageBubble/index.less +25 -20
  56. package/packages/agent-ui-kit/src/components/MessageBubble/index.test.tsx +112 -0
  57. package/{apps/client → packages/agent-ui-kit}/src/components/MessageBubble/index.tsx +22 -15
  58. package/{apps/client → packages/agent-ui-kit}/src/components/PullDownIndicator/index.less +2 -2
  59. package/{apps/client → packages/agent-ui-kit}/src/components/PullDownIndicator/index.tsx +2 -2
  60. package/packages/agent-ui-kit/src/components/QuickCommands/index.less +63 -0
  61. package/packages/agent-ui-kit/src/components/QuickCommands/index.test.tsx +99 -0
  62. package/packages/agent-ui-kit/src/components/QuickCommands/index.tsx +52 -0
  63. package/{apps/client → packages/agent-ui-kit}/src/components/ScrollToBottomButton/index.less +1 -1
  64. package/packages/agent-ui-kit/src/components/ToolCallBlock/detail.test.ts +346 -0
  65. package/packages/agent-ui-kit/src/components/ToolCallBlock/detail.ts +204 -0
  66. package/packages/agent-ui-kit/src/components/ToolCallBlock/index.less +312 -0
  67. package/packages/agent-ui-kit/src/components/ToolCallBlock/index.test.tsx +261 -0
  68. package/packages/agent-ui-kit/src/components/ToolCallBlock/index.tsx +212 -0
  69. package/packages/agent-ui-kit/src/components/ToolCallBlock/titles.ts +102 -0
  70. package/packages/agent-ui-kit/src/hooks/use-drag-gesture.test.ts +66 -0
  71. package/packages/agent-ui-kit/src/hooks/use-drag-gesture.ts +129 -0
  72. package/packages/agent-ui-kit/src/hooks/use-pull-down-history.ts +241 -0
  73. package/packages/agent-ui-kit/src/hooks/use-pull-down-wheel-native.test.ts +64 -0
  74. package/{apps/client → packages/agent-ui-kit}/src/hooks/useMediaUpload.ts +3 -3
  75. package/{apps/client → packages/agent-ui-kit}/src/hooks/useTTSAutoPlay.ts +7 -5
  76. package/packages/agent-ui-kit/src/index.ts +69 -0
  77. package/packages/agent-ui-kit/src/store-context.tsx +92 -0
  78. package/packages/agent-ui-kit/src/test/setup.ts +7 -0
  79. package/packages/agent-ui-kit/src/theme/contract.json +72 -0
  80. package/packages/agent-ui-kit/src/theme/global.less +99 -0
  81. package/packages/agent-ui-kit/src/theme/motion.less +19 -0
  82. package/packages/agent-ui-kit/src/theme/theme-contract.test.ts +69 -0
  83. package/packages/agent-ui-kit/src/theme/theme.ts +17 -0
  84. package/packages/agent-ui-kit/src/theme/tokens.less +79 -0
  85. package/packages/agent-ui-kit/src/utils/haptics.test.ts +51 -0
  86. package/packages/agent-ui-kit/src/utils/haptics.ts +44 -0
  87. package/packages/agent-ui-kit/src/utils/pull-down-machine.test.ts +188 -0
  88. package/packages/agent-ui-kit/src/utils/pull-down-machine.ts +157 -0
  89. package/packages/agent-ui-kit/src/utils/tool-call-display.test.ts +240 -0
  90. package/packages/agent-ui-kit/src/utils/tool-call-display.ts +146 -0
  91. package/packages/agent-ui-kit/src/vite-env.d.ts +1 -0
  92. package/packages/agent-ui-kit/tsconfig.json +8 -0
  93. package/packages/agent-ui-kit/vitest.config.ts +7 -0
  94. package/pnpm-lock.template.yaml +177 -83
  95. package/pnpm-workspace.template.yaml +12 -4
  96. package/profiles/mall/config/agent.jsonc +33 -0
  97. package/profiles/mall/config/chat.json +21 -0
  98. package/profiles/mall/config/knowledge.json +5 -0
  99. package/profiles/mall/config/llm_provider.json +5 -0
  100. package/profiles/mall/config/plugins.json +3 -0
  101. package/profiles/mall.json +16 -0
  102. package/scripts/check-workspace.mjs +3 -2
  103. package/apps/client/assets/copy@2x.png +0 -0
  104. package/apps/client/assets/debug.png +0 -0
  105. package/apps/client/assets/dislike@2x.png +0 -0
  106. package/apps/client/assets/like@2x.png +0 -0
  107. package/apps/client/assets/speaker@2x.png +0 -0
  108. package/apps/client/src/cards/CardRenderer.tsx +0 -20
  109. package/apps/client/src/components/ChatHeader/index.less +0 -104
  110. package/apps/client/src/components/ErrorBoundary.tsx +0 -2
  111. package/apps/client/src/components/QuickCommands/index.less +0 -48
  112. package/apps/client/src/components/QuickCommands/index.tsx +0 -29
  113. package/apps/client/src/components/ToolCallBlock/index.less +0 -131
  114. package/apps/client/src/components/ToolCallBlock/index.tsx +0 -125
  115. package/apps/client/src/examples/tourism/TourismLanding.tsx +0 -96
  116. package/apps/client/src/examples/tourism/index.less +0 -192
  117. package/apps/client/src/pages/index/index.less +0 -43
  118. package/apps/client/src/store/chat-config.ts +0 -40
  119. package/apps/client/src/styles/global.less +0 -60
  120. package/apps/client/src/styles/tokens.less +0 -60
  121. package/apps/server/src/config/deployment.ts +0 -47
  122. package/packages/module-weather/package.json +0 -36
  123. package/packages/module-weather/skills/weather-guide/SKILL.md +0 -22
  124. package/packages/module-weather/skills/weather-guide/references/dressing-tips.md +0 -5
  125. package/packages/module-weather/src/client/cards/weather/index.less +0 -22
  126. package/packages/module-weather/src/client/cards/weather/index.tsx +0 -14
  127. package/packages/module-weather/src/client/index.ts +0 -19
  128. package/packages/module-weather/src/index.ts +0 -2
  129. package/packages/module-weather/src/server/cards/weather/meta.ts +0 -27
  130. package/packages/module-weather/src/server/index.ts +0 -45
  131. package/packages/module-weather/src/server/pre-tools.ts +0 -20
  132. package/packages/module-weather/src/server/tool.ts +0 -29
  133. package/packages/module-weather/tbox.module.json +0 -27
  134. package/packages/module-weather/tsconfig.json +0 -14
  135. /package/{apps/client → packages/agent-ui-kit}/assets/keyboard-circle.svg +0 -0
  136. /package/{apps/client → packages/agent-ui-kit}/assets/more-circle.svg +0 -0
  137. /package/{apps/client → packages/agent-ui-kit}/assets/new-chat.svg +0 -0
  138. /package/{apps/client → packages/agent-ui-kit}/assets/quick-command-star.svg +0 -0
  139. /package/{apps/client → packages/agent-ui-kit}/assets/send-gradient.svg +0 -0
  140. /package/{apps/client → packages/agent-ui-kit}/assets/stop@2x.png +0 -0
  141. /package/{apps/client → packages/agent-ui-kit}/assets/voice-circle.svg +0 -0
  142. /package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/voice/VoicePressField.tsx +0 -0
  143. /package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/voice/VoiceRecordingOverlay.tsx +0 -0
  144. /package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/voice/machine.ts +0 -0
  145. /package/{apps/client → packages/agent-ui-kit}/src/components/ReasoningBlock/index.less +0 -0
  146. /package/{apps/client → packages/agent-ui-kit}/src/components/ReasoningBlock/index.tsx +0 -0
  147. /package/{apps/client → packages/agent-ui-kit}/src/components/ScrollToBottomButton/index.tsx +0 -0
  148. /package/{apps/client/src/types/chat.ts → packages/agent-ui-kit/src/types.ts} +0 -0
  149. /package/{apps/client → packages/agent-ui-kit}/src/utils/copyText.ts +0 -0
  150. /package/{apps/client → packages/agent-ui-kit}/src/utils/imagePreview.ts +0 -0
  151. /package/{apps/client → packages/agent-ui-kit}/src/utils/session-error.ts +0 -0
  152. /package/{apps/client → packages/agent-ui-kit}/src/utils/toast.ts +0 -0
@@ -0,0 +1,261 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { createRoot, type Root } from 'react-dom/client';
4
+ import { flushSync } from 'react-dom';
5
+ import {
6
+ buildMemoryToolCallPayload,
7
+ buildPreToolArgsPayload,
8
+ buildPreToolResultPayload,
9
+ buildSkillToolCallPayload,
10
+ MEMORY_TOOL_CALL_NAME,
11
+ PRE_TOOL_CALL_NAME,
12
+ SKILL_TOOL_CALL_NAME,
13
+ } from '@tbox.cn/app-contracts';
14
+ import type { ToolCallInfo } from '../../types';
15
+ import ToolCallBlock from './index';
16
+
17
+ const mocks = vi.hoisted(() => ({
18
+ copyText: vi.fn(async () => true),
19
+ toast: vi.fn(async () => {}),
20
+ }));
21
+
22
+ vi.mock('../../utils/copyText', () => ({ copyText: mocks.copyText }));
23
+ vi.mock('../../utils/toast', () => ({ toast: mocks.toast }));
24
+
25
+ /** 夹具:1 preTool(done) + 1 skill(带 description) + 1 normal(calling),载荷经契约 builder 同构 */
26
+ function makeCalls(): ToolCallInfo[] {
27
+ const skill = buildSkillToolCallPayload({ name: 'member-guide', description: '会员导购技能' });
28
+ return [
29
+ {
30
+ id: 'pre-1',
31
+ name: PRE_TOOL_CALL_NAME,
32
+ args: buildPreToolArgsPayload({
33
+ toolName: 'member-balance',
34
+ description: '会员卡余额查询',
35
+ toolArgs: { cardNo: 'C001' },
36
+ }),
37
+ result: buildPreToolResultPayload({
38
+ toolName: 'member-balance',
39
+ status: 'done',
40
+ result: { balance: 1200 },
41
+ }),
42
+ status: 'done',
43
+ },
44
+ {
45
+ id: 'skill-1',
46
+ name: SKILL_TOOL_CALL_NAME,
47
+ args: skill.args,
48
+ result: skill.result,
49
+ status: 'done',
50
+ },
51
+ { id: 'tool-1', name: 'search_stores', status: 'calling' },
52
+ ];
53
+ }
54
+
55
+ /** 018:memory 伪工具夹具(hit + degraded),载荷经契约 builder 同构 */
56
+ function makeMemoryCalls(): ToolCallInfo[] {
57
+ const hit = buildMemoryToolCallPayload({
58
+ query: '推荐美食',
59
+ status: 'hit',
60
+ records: [{ id: 'm1', kind: 'preference', content: '用户爱吃辣' }],
61
+ });
62
+ const degraded = buildMemoryToolCallPayload({
63
+ query: '今天活动',
64
+ status: 'degraded',
65
+ error: '记忆召回超时',
66
+ });
67
+ return [
68
+ { id: 'mem-hit', name: MEMORY_TOOL_CALL_NAME, args: hit.args, result: hit.result, status: 'done' },
69
+ { id: 'mem-deg', name: MEMORY_TOOL_CALL_NAME, args: degraded.args, result: degraded.result, status: 'done' },
70
+ ];
71
+ }
72
+
73
+ function renderBlock(
74
+ calls: ToolCallInfo[],
75
+ opts: { debug?: boolean } = {},
76
+ ): {
77
+ container: HTMLElement;
78
+ rerender: (next: ToolCallInfo[]) => void;
79
+ unmount: () => void;
80
+ } {
81
+ const container = document.createElement('div');
82
+ document.body.appendChild(container);
83
+ const root: Root = createRoot(container);
84
+ flushSync(() => root.render(<ToolCallBlock toolCalls={calls} debug={opts.debug} />));
85
+ return {
86
+ container,
87
+ rerender: (next) => flushSync(() => root.render(<ToolCallBlock toolCalls={next} debug={opts.debug} />)),
88
+ unmount: () => flushSync(() => root.unmount()),
89
+ };
90
+ }
91
+
92
+ function expandSummary(container: HTMLElement): void {
93
+ flushSync(() => container.querySelector<HTMLButtonElement>('.tool-calls__summary')?.click());
94
+ }
95
+
96
+ beforeEach(() => {
97
+ vi.clearAllMocks();
98
+ });
99
+
100
+ afterEach(() => {
101
+ document.body.innerHTML = '';
102
+ });
103
+
104
+ describe('ToolCallBlock prod 态(018 I4:友好聚合、无出入参展开)', () => {
105
+ it('默认收起为语义按钮,主文案显示当前执行中的中文任务标题', () => {
106
+ const { container, unmount } = renderBlock(makeCalls());
107
+ const summary = container.querySelector<HTMLButtonElement>('.tool-calls__summary');
108
+ expect(summary?.tagName).toBe('BUTTON');
109
+ expect(summary?.type).toBe('button');
110
+ expect(summary?.getAttribute('aria-expanded')).toBe('false');
111
+ expect(summary?.textContent).toContain('正在搜索商场门店');
112
+ expect(summary?.textContent).not.toContain('search_stores');
113
+ expect(container.querySelector('.tool-calls__list')).toBeNull();
114
+ unmount();
115
+ });
116
+
117
+ it('展开后活动行仅状态/中文名/徽标——零详情触发按钮、零技术名(I4)', () => {
118
+ const { container, unmount } = renderBlock(makeCalls());
119
+ expandSummary(container);
120
+
121
+ expect(container.querySelectorAll('.tool-calls__item')).toHaveLength(3);
122
+ expect(container.querySelector('.tool-calls__list')?.textContent).toContain('前置工具');
123
+ expect(container.querySelector('.tool-calls__list')?.textContent).toContain('会员卡余额查询');
124
+ expect(container.querySelector('.tool-calls__list')?.textContent).toContain('搜索商场门店');
125
+ // I4:无任何出入参展开入口(触发按钮/chevron/复制全部不渲染)
126
+ expect(container.querySelectorAll('button.tool-calls__item-trigger')).toHaveLength(0);
127
+ expect(container.querySelector('.tool-calls__item-chevron')).toBeNull();
128
+ expect(container.querySelector('.tool-calls__detail')).toBeNull();
129
+ expect(container.querySelector('.tool-calls__copy')).toBeNull();
130
+ expect(container.querySelector('.tool-calls__item-tech')).toBeNull();
131
+ unmount();
132
+ });
133
+
134
+ it('preTool payload 失败保持失败聚合与失败活动行', () => {
135
+ const failedPreTool: ToolCallInfo = {
136
+ id: 'pre-error',
137
+ name: PRE_TOOL_CALL_NAME,
138
+ args: buildPreToolArgsPayload({ toolName: 'parking-fee', description: '停车费查询' }),
139
+ result: buildPreToolResultPayload({ toolName: 'parking-fee', status: 'error', error: '接口超时' }),
140
+ status: 'done',
141
+ };
142
+ const { container, unmount } = renderBlock([failedPreTool]);
143
+ expect(container.querySelector('.tool-calls__summary')?.textContent).toContain('已调用工具 1 个');
144
+ expect(container.querySelector('.tool-calls__failed')?.textContent).toContain('1 个失败');
145
+
146
+ expandSummary(container);
147
+ expect(container.querySelector('.tool-calls__item--error')).not.toBeNull();
148
+ unmount();
149
+ });
150
+
151
+ it('聚合按钮可由浏览器原生键盘激活', () => {
152
+ const { container, unmount } = renderBlock(makeCalls());
153
+ const summary = container.querySelector<HTMLButtonElement>('.tool-calls__summary');
154
+ summary?.focus();
155
+ expect(document.activeElement).toBe(summary);
156
+ flushSync(() => summary?.click());
157
+ expect(container.querySelector('.tool-calls__list')).not.toBeNull();
158
+ unmount();
159
+ });
160
+ });
161
+
162
+ describe('ToolCallBlock dev 态(018 tool-debug:紧凑详情 + 技术后缀)', () => {
163
+ it('展开后行标题附技术名后缀(协议技术名 dev 可见)', () => {
164
+ const { container, unmount } = renderBlock(makeCalls(), { debug: true });
165
+ expandSummary(container);
166
+
167
+ const tech = container.querySelector('.tool-calls__item-tech');
168
+ expect(tech?.textContent).toBe(' · member-balance');
169
+ expect(container.querySelectorAll('button.tool-calls__item-trigger')).toHaveLength(2);
170
+ unmount();
171
+ });
172
+
173
+ it('详情在消息流内展开:屏显走紧凑单行格式(formatCompactToolIo),双栏输入输出', () => {
174
+ const { container, unmount } = renderBlock(makeCalls(), { debug: true });
175
+ expandSummary(container);
176
+ flushSync(() => container.querySelector<HTMLButtonElement>('.tool-calls__item-trigger')?.click());
177
+
178
+ const detail = container.querySelector('.tool-calls__detail');
179
+ expect(detail).not.toBeNull();
180
+ const bodies = detail?.querySelectorAll('.tool-calls__detail-body');
181
+ // 紧凑单行(无 pretty 缩进空格)
182
+ expect(bodies?.[0].textContent).toContain('{"cardNo":"C001"}');
183
+ expect(bodies?.[1].textContent).toContain('{"balance":1200}');
184
+ expect(bodies?.[0].textContent).not.toContain('"cardNo": "C001"');
185
+ // 输入/输出双栏标签
186
+ expect(detail?.textContent).toContain('输入');
187
+ expect(detail?.textContent).toContain('输出');
188
+ unmount();
189
+ });
190
+
191
+ it('输出复制仍为美化全文(屏显紧凑、粘贴友好分工)', async () => {
192
+ const { container, unmount } = renderBlock(makeCalls(), { debug: true });
193
+ expandSummary(container);
194
+ flushSync(() => container.querySelector<HTMLButtonElement>('.tool-calls__item-trigger')?.click());
195
+ // 第二个复制按钮 = 输出(第一个是输入)
196
+ container.querySelectorAll<HTMLButtonElement>('.tool-calls__copy')[1]?.click();
197
+
198
+ await vi.waitFor(() => expect(mocks.copyText).toHaveBeenCalledTimes(1));
199
+ expect(mocks.copyText).toHaveBeenCalledWith(expect.stringContaining('"balance": 1200'));
200
+ expect(mocks.toast).toHaveBeenCalledWith('输出已复制', 'success');
201
+ unmount();
202
+ });
203
+
204
+ it('聚合与行详情展开状态在流式 rerender 后保持稳定', () => {
205
+ const calls = makeCalls();
206
+ const { container, rerender, unmount } = renderBlock(calls, { debug: true });
207
+ expandSummary(container);
208
+ flushSync(() => container.querySelector<HTMLButtonElement>('.tool-calls__item-trigger')?.click());
209
+ expect(container.querySelector('.tool-calls__detail')).not.toBeNull();
210
+
211
+ rerender(calls.map((item) => item.id === 'tool-1'
212
+ ? { ...item, status: 'done' as const, args: '{"keyword":"咖啡"}', result: '{"total":3}' }
213
+ : item));
214
+
215
+ expect(container.querySelector('.tool-calls__summary')?.getAttribute('aria-expanded')).toBe('true');
216
+ expect(container.querySelector('.tool-calls__list')).not.toBeNull();
217
+ expect(container.querySelector('.tool-calls__detail')).not.toBeNull();
218
+ expect(container.querySelector('.tool-calls__summary')?.textContent).toContain('已调用工具 2 个');
219
+ unmount();
220
+ });
221
+
222
+ it('详情按钮可由浏览器原生键盘激活', () => {
223
+ const { container, unmount } = renderBlock(makeCalls(), { debug: true });
224
+ expandSummary(container);
225
+ const detailTrigger = container.querySelector<HTMLButtonElement>('.tool-calls__item-trigger');
226
+ detailTrigger?.focus();
227
+ expect(document.activeElement).toBe(detailTrigger);
228
+ expect(detailTrigger?.getAttribute('aria-expanded')).toBe('false');
229
+ flushSync(() => detailTrigger?.click());
230
+ expect(detailTrigger?.getAttribute('aria-expanded')).toBe('true');
231
+ unmount();
232
+ });
233
+ });
234
+
235
+ describe('ToolCallBlock memory 伪工具(018)', () => {
236
+ it('hit/degraded 活动行:记忆召回徽标、降级为失败态、dev 详情含记录预览', () => {
237
+ const { container, unmount } = renderBlock(makeMemoryCalls(), { debug: true });
238
+ expandSummary(container);
239
+
240
+ const items = container.querySelectorAll('.tool-calls__item');
241
+ expect(items).toHaveLength(2);
242
+ expect(container.querySelector('.tool-calls__list')?.textContent).toContain('记忆召回 · 1 条命中');
243
+ expect(container.querySelector('.tool-calls__list')?.textContent).toContain('记忆召回降级');
244
+ expect(container.querySelector('.tool-calls__item--error')).not.toBeNull();
245
+ expect(container.querySelector('.tool-calls__list')?.textContent).toContain('记忆召回');
246
+
247
+ flushSync(() => container.querySelector<HTMLButtonElement>('.tool-calls__item-trigger')?.click());
248
+ const bodies = container.querySelectorAll('.tool-calls__detail-body');
249
+ expect(bodies[0].textContent).toBe('推荐美食');
250
+ expect(bodies[1].textContent).toContain('[preference] 用户爱吃辣');
251
+ unmount();
252
+ });
253
+
254
+ it('prod 态 memory 行无展开入口(与其它工具一致)', () => {
255
+ const { container, unmount } = renderBlock(makeMemoryCalls());
256
+ expandSummary(container);
257
+ expect(container.querySelectorAll('button.tool-calls__item-trigger')).toHaveLength(0);
258
+ expect(container.querySelector('.tool-calls__detail')).toBeNull();
259
+ unmount();
260
+ });
261
+ });
@@ -0,0 +1,212 @@
1
+ import { useId, useMemo, useState } from 'react';
2
+ import { CaretDown, Check, Copy, X } from '@phosphor-icons/react';
3
+ import type { ToolCallInfo } from '../../types';
4
+ import { formatCompactToolIo } from '../../utils/tool-call-display';
5
+ import { buildSummaryText, deriveSummary, deriveToolCallItem, prettyDetailText } from './detail';
6
+ import type { ToolCallViewItem } from './detail';
7
+ import { copyText } from '../../utils/copyText';
8
+ import { toast } from '../../utils/toast';
9
+ import './index.less';
10
+
11
+ const EMPTY_TEXT = '(无)';
12
+
13
+ const STATUS_TEXT: Record<ToolCallViewItem['status'], string> = {
14
+ done: '成功',
15
+ error: '失败',
16
+ calling: '执行中',
17
+ };
18
+
19
+ /**
20
+ * Codex 风格工具活动日志:紧凑聚合行 / 流内活动列表 / 行内完整 IO。
21
+ * 语义判定仍在 SDK describeToolCall + detail.ts 视图模型,本组件只管理展示状态;
22
+ * debug 门控在渲染层(018 tool-debug):prod 恒无出入参展开入口(I4),
23
+ * dev 行标题附技术名、详情走紧凑单行格式(复制仍为美化全文);
24
+ * 图标颜色不传 color prop(phosphor 落 SVG fill 属性,var() 低版本 web-view 不可靠),
25
+ * 统一走 currentColor + less token 类。
26
+ */
27
+ export default function ToolCallBlock({
28
+ toolCalls,
29
+ debug = false,
30
+ }: {
31
+ toolCalls: ToolCallInfo[];
32
+ /** 018 tool-debug:dev 视图(出入参展开 + 技术名 + 紧凑格式);prod 缺省 false */
33
+ debug?: boolean;
34
+ }) {
35
+ const [expanded, setExpanded] = useState(false);
36
+ const [expandedDetailIds, setExpandedDetailIds] = useState<Set<string>>(() => new Set());
37
+ const listId = useId();
38
+ const items = useMemo(() => toolCalls.map(deriveToolCallItem), [toolCalls]);
39
+ const summary = useMemo(() => deriveSummary(items), [items]);
40
+
41
+ const toggleDetail = (id: string) => {
42
+ setExpandedDetailIds((current) => {
43
+ const next = new Set(current);
44
+ if (next.has(id)) next.delete(id);
45
+ else next.add(id);
46
+ return next;
47
+ });
48
+ };
49
+
50
+ return (
51
+ <div className="tool-calls">
52
+ <button
53
+ type="button"
54
+ className={`tool-calls__summary${summary.calling ? ' tool-calls__summary--calling' : ''}`}
55
+ aria-expanded={expanded}
56
+ aria-controls={listId}
57
+ onClick={() => setExpanded((current) => !current)}
58
+ >
59
+ <span className="tool-calls__left">
60
+ {summary.calling ? (
61
+ <span className="tool-calls__spinner" aria-hidden="true" />
62
+ ) : summary.failedCount > 0 ? (
63
+ <X className="tool-calls__icon tool-calls__icon--error" size={16} strokeWidth={2.5} />
64
+ ) : (
65
+ <Check className="tool-calls__icon tool-calls__icon--success" size={16} strokeWidth={2.5} />
66
+ )}
67
+ <span className="tool-calls__summary-text">
68
+ {buildSummaryText(summary).join(' · ')}
69
+ {summary.failedCount > 0 && (
70
+ <span className="tool-calls__failed"> · {summary.failedCount} 个失败</span>
71
+ )}
72
+ </span>
73
+ </span>
74
+ <CaretDown
75
+ size={14}
76
+ className={`tool-calls__chevron${expanded ? ' tool-calls__chevron--up' : ''}`}
77
+ />
78
+ </button>
79
+
80
+ {expanded && (
81
+ <div id={listId} className="tool-calls__list">
82
+ {items.map((item) => {
83
+ // 018 I4:prod(debug=false)恒无出入参展开入口;dev 才有详情交互
84
+ const hasDetail = debug && !!item.detail;
85
+ const detailExpanded = hasDetail && expandedDetailIds.has(item.id);
86
+ const detailId = `${listId}-${item.id}`;
87
+ const itemContent = (
88
+ <>
89
+ <span className="tool-calls__item-left">
90
+ {item.status === 'calling' && (
91
+ <span className="tool-calls__spinner tool-calls__spinner--sm" />
92
+ )}
93
+ {item.status === 'done' && (
94
+ <Check className="tool-calls__icon tool-calls__icon--success" size={14} strokeWidth={2.5} />
95
+ )}
96
+ {item.status === 'error' && (
97
+ <X className="tool-calls__icon tool-calls__icon--error" size={14} strokeWidth={2.5} />
98
+ )}
99
+ <span className="tool-calls__item-name">
100
+ {item.label}
101
+ {debug && item.techName && (
102
+ <span className="tool-calls__item-tech"> · {item.techName}</span>
103
+ )}
104
+ </span>
105
+ {item.badge && (
106
+ <span className="tool-calls__badge">· {item.badge}</span>
107
+ )}
108
+ </span>
109
+ {hasDetail && (
110
+ <CaretDown
111
+ size={12}
112
+ className={`tool-calls__item-chevron${detailExpanded ? ' tool-calls__item-chevron--up' : ''}`}
113
+ />
114
+ )}
115
+ </>
116
+ );
117
+
118
+ return (
119
+ <div key={item.id} className={`tool-calls__item tool-calls__item--${item.status}`}>
120
+ {hasDetail ? (
121
+ <button
122
+ type="button"
123
+ className="tool-calls__item-trigger"
124
+ aria-expanded={detailExpanded}
125
+ aria-controls={detailId}
126
+ aria-label={`${item.label},${STATUS_TEXT[item.status]},${detailExpanded ? '收起详情' : '查看详情'}`}
127
+ onClick={() => toggleDetail(item.id)}
128
+ >
129
+ {itemContent}
130
+ </button>
131
+ ) : (
132
+ <div
133
+ className="tool-calls__item-trigger tool-calls__item-trigger--static"
134
+ role="status"
135
+ aria-label={`${item.label},${STATUS_TEXT[item.status]}`}
136
+ >
137
+ {itemContent}
138
+ </div>
139
+ )}
140
+ {detailExpanded && item.detail && (
141
+ <ToolCallInlineDetail id={detailId} item={item} />
142
+ )}
143
+ </div>
144
+ );
145
+ })}
146
+ </div>
147
+ )}
148
+ </div>
149
+ );
150
+ }
151
+
152
+ /**
153
+ * 018 dev 详情区:屏显走 formatCompactToolIo(单行紧凑 + 头尾保留);
154
+ * 复制走 prettyDetailText(美化全文)——屏显紧凑、粘贴友好分工。
155
+ */
156
+ function ToolCallInlineDetail({ id, item }: { id: string; item: ToolCallViewItem }) {
157
+ const formatted = useMemo(
158
+ () => ({
159
+ input: formatCompactToolIo(item.detail?.inputText ?? ''),
160
+ output: formatCompactToolIo(item.detail?.outputText ?? ''),
161
+ inputPretty: prettyDetailText(item.detail?.inputText ?? ''),
162
+ outputPretty: prettyDetailText(item.detail?.outputText ?? ''),
163
+ }),
164
+ [item],
165
+ );
166
+
167
+ const copyIo = async (text: string, label: string) => {
168
+ const copied = await copyText(text);
169
+ void toast(copied ? `${label}已复制` : '复制失败,请重试', copied ? 'success' : 'error');
170
+ };
171
+
172
+ return (
173
+ <div id={id} className="tool-calls__detail">
174
+ <div className="tool-calls__detail-section">
175
+ <div className="tool-calls__detail-heading">
176
+ <span className="tool-calls__detail-label">输入</span>
177
+ <button
178
+ type="button"
179
+ className="tool-calls__copy"
180
+ aria-label={`复制${item.label}的输入`}
181
+ disabled={!formatted.inputPretty}
182
+ onClick={() => void copyIo(formatted.inputPretty, '输入')}
183
+ >
184
+ <Copy size={13} />
185
+ <span>复制</span>
186
+ </button>
187
+ </div>
188
+ <pre className="tool-calls__detail-body">{formatted.input || EMPTY_TEXT}</pre>
189
+ </div>
190
+ <div className="tool-calls__detail-section">
191
+ <div className="tool-calls__detail-heading">
192
+ <span className="tool-calls__detail-label">输出</span>
193
+ <button
194
+ type="button"
195
+ className="tool-calls__copy"
196
+ aria-label={`复制${item.label}的输出`}
197
+ disabled={!formatted.outputPretty}
198
+ onClick={() => void copyIo(formatted.outputPretty, '输出')}
199
+ >
200
+ <Copy size={13} />
201
+ <span>复制</span>
202
+ </button>
203
+ </div>
204
+ <pre
205
+ className={`tool-calls__detail-body${item.detail?.isError ? ' tool-calls__detail-body--error' : ''}`}
206
+ >
207
+ {formatted.output || EMPTY_TEXT}
208
+ </pre>
209
+ </div>
210
+ </div>
211
+ );
212
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Mall 工具活动标题:只改变展示,不改协议中的技术 name。
3
+ * 当前模块工具使用精确中文标题;未知英文工具使用中文通用兜底,避免把技术标识泄漏到用户标题面。
4
+ */
5
+ const TOOL_TITLE_BY_NAME: Readonly<Record<string, string>> = {
6
+ // 会员
7
+ queryPoints: '查询会员积分',
8
+ queryMemberInfo: '查询会员信息',
9
+ queryMemberCard: '查询会员卡',
10
+ queryEnrollmentForm: '查询入会状态',
11
+ queryEnrollmentCta: '准备入会指引',
12
+ queryLoginCta: '准备登录指引',
13
+
14
+ // 商场导购
15
+ recommend: '推荐商品',
16
+ 'search-products': '搜索商品',
17
+ 'product-facets': '查询商品筛选项',
18
+ 'get-product': '查询商品详情',
19
+ 'compare-products': '对比商品',
20
+ 'search-shops': '搜索商场门店',
21
+ 'get-shop': '查询门店详情',
22
+ 'mall-directory': '查询商场楼层',
23
+ 'get-location': '查询门店位置',
24
+ 'list-human-guides': '查询人工导购',
25
+ 'list-browsing-history': '查询浏览记录',
26
+ 'list-nearby-malls': '查询附近商场',
27
+ 'list-categories': '查询商场业态',
28
+ 'search-knowledge': '检索商场知识',
29
+ 'customer-service-faq': '查询客服问题',
30
+ 'mall-context': '加载商场信息',
31
+ 'mall-info': '查询商场信息',
32
+
33
+ // 停车
34
+ queryParkingFee: '查询停车费用',
35
+ listVehicles: '查询已绑车辆',
36
+ listPaymentRecords: '查询停车缴费记录',
37
+ queryParkingInfo: '查询停车场信息',
38
+ queryApplicableBenefits: '查询停车权益',
39
+ locateVehicle: '查找车辆位置',
40
+ listInvoiceableOrders: '查询可开票订单',
41
+ queryParkingPaymentStatus: '查询停车支付状态',
42
+ queryParkingPaymentDetail: '查询停车订单详情',
43
+
44
+ // 优惠活动
45
+ listActivityCategories: '查询活动分类',
46
+ listActivities: '查询商场活动',
47
+ getActivityDetail: '查询活动详情',
48
+ listActivitySessions: '查询活动场次',
49
+ getActivityGuide: '查询活动参与指南',
50
+ searchOffers: '搜索优惠',
51
+ getOfferDetail: '查询优惠详情',
52
+ listEntitlements: '查询我的优惠',
53
+ listPromotions: '查询促销活动',
54
+ getPromotionDetail: '查询促销详情',
55
+ getParticipationGuide: '查询参与步骤',
56
+ listCoupons: '查询优惠券',
57
+ getPackageDetail: '查询礼包详情',
58
+ recommendOffers: '推荐可用优惠',
59
+ calculateBestDeal: '测算最佳优惠',
60
+
61
+ // 碰出惊喜与平台前置工具
62
+ getBumpInActivity: '查询碰出惊喜活动',
63
+ getBumpInActivityBanner: '加载碰出惊喜活动',
64
+ getCurrentTime: '获取当前时间',
65
+
66
+ // 历史命名与确定性 Gallery 别名
67
+ 'member-balance': '查询会员余额',
68
+ 'parking-fee': '查询停车费用',
69
+ 'parking-fee-context': '加载停车费用信息',
70
+ search_stores: '搜索商场门店',
71
+ search_mall_services: '搜索商场服务',
72
+ get_member_points: '查询会员积分',
73
+ load_parking_order: '查询停车订单',
74
+ search_nearby_stores: '搜索附近门店',
75
+ read_store_opening_hours: '查询门店营业时间',
76
+ search_shops_with_member_benefits_and_parking_validation_rules_for_current_mall: '搜索会员权益门店',
77
+ pay_order: '提交支付订单',
78
+ };
79
+
80
+ const HAN_TEXT = /[\u3400-\u9fff]/u;
81
+ const MAX_TITLE_LENGTH = 28;
82
+
83
+ function compactChineseTitle(value: string | undefined): string | undefined {
84
+ if (!value || !HAN_TEXT.test(value)) return undefined;
85
+ const firstClause = value.trim().split(/[\n。;;]/u, 1)[0]?.trim();
86
+ if (!firstClause) return undefined;
87
+ return firstClause.length > MAX_TITLE_LENGTH
88
+ ? `${firstClause.slice(0, MAX_TITLE_LENGTH - 1)}…`
89
+ : firstClause;
90
+ }
91
+
92
+ /** 中文描述 > 已登记中文标题 > 原始中文名 > 中文通用兜底。 */
93
+ export function resolveToolTitle(
94
+ technicalName: string,
95
+ preferredText?: string,
96
+ fallback = '处理工具任务',
97
+ ): string {
98
+ return compactChineseTitle(preferredText)
99
+ ?? TOOL_TITLE_BY_NAME[technicalName]
100
+ ?? compactChineseTitle(technicalName)
101
+ ?? fallback;
102
+ }
@@ -0,0 +1,66 @@
1
+ // @vitest-environment happy-dom
2
+ /**
3
+ * 手势通道用例。核心契约:触摸/笔参与拖拽,鼠标不参与——桌面的下拉意图由触控板双指滑动
4
+ * 经 wheel 表达,按住鼠标拖页面在桌面上是选中文本的惯例,不该触发下拉。
5
+ */
6
+ import { describe, expect, it, vi } from 'vitest';
7
+ import { createRoot } from 'react-dom/client';
8
+ import { flushSync } from 'react-dom';
9
+ import { createElement } from 'react';
10
+ import { useDragGesture } from './use-drag-gesture';
11
+
12
+ function pointer(type: string, clientY: number, name: string) {
13
+ const e = new Event(name, { bubbles: true, cancelable: true }) as PointerEvent;
14
+ Object.defineProperties(e, {
15
+ pointerType: { value: type },
16
+ clientY: { value: clientY },
17
+ });
18
+ return e;
19
+ }
20
+
21
+ /** 极简 hook 宿主:仓库内既有写法(createRoot + flushSync),不引入额外测试依赖。 */
22
+ function setup() {
23
+ const el = document.createElement('div');
24
+ document.body.appendChild(el);
25
+ const handlers = { onStart: vi.fn(), onMove: vi.fn(() => true), onEnd: vi.fn() };
26
+ const Host = () => {
27
+ useDragGesture({ scrollRef: { current: el }, ...handlers });
28
+ return null;
29
+ };
30
+ const host = document.createElement('div');
31
+ document.body.appendChild(host);
32
+ flushSync(() => createRoot(host).render(createElement(Host)));
33
+ return { el, handlers };
34
+ }
35
+
36
+ describe('useDragGesture', () => {
37
+ it('D1 触摸指针参与拖拽', () => {
38
+ const { el, handlers } = setup();
39
+ el.dispatchEvent(pointer('touch', 100, 'pointerdown'));
40
+ el.dispatchEvent(pointer('touch', 160, 'pointermove'));
41
+ expect(handlers.onStart).toHaveBeenCalledWith({ clientY: 100 });
42
+ expect(handlers.onMove).toHaveBeenCalledWith({ clientY: 160 });
43
+ });
44
+
45
+ it('D2 鼠标拖拽被忽略(桌面单指拖拽不响应,交给 wheel 通道)', () => {
46
+ const { el, handlers } = setup();
47
+ el.dispatchEvent(pointer('mouse', 100, 'pointerdown'));
48
+ el.dispatchEvent(pointer('mouse', 160, 'pointermove'));
49
+ expect(handlers.onStart).not.toHaveBeenCalled();
50
+ expect(handlers.onMove).not.toHaveBeenCalled();
51
+ });
52
+
53
+ it('D3 笔同触摸(手写笔在触屏设备上是等价输入)', () => {
54
+ const { el, handlers } = setup();
55
+ el.dispatchEvent(pointer('pen', 50, 'pointerdown'));
56
+ el.dispatchEvent(pointer('pen', 90, 'pointermove'));
57
+ expect(handlers.onStart).toHaveBeenCalledTimes(1);
58
+ expect(handlers.onMove).toHaveBeenCalledTimes(1);
59
+ });
60
+
61
+ it('D4 未按下时的移动不算拖拽', () => {
62
+ const { el, handlers } = setup();
63
+ el.dispatchEvent(pointer('touch', 160, 'pointermove'));
64
+ expect(handlers.onMove).not.toHaveBeenCalled();
65
+ });
66
+ });