@tbox.cn/app-template-agent 0.7.4 → 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,157 @@
1
+ /**
2
+ * 下拉加载历史手势状态机(纯逻辑,零 React 依赖,node 可测)。
3
+ * 核心修正:touchStart 不再锁定 isAtTop(旧实现在未滚动容器上初始 false → 全新手势死区),
4
+ * 门控移到 touchMove 实时判定——未到顶时每次重锚,到顶后从最近锚点测距;
5
+ * wheel 为桌面回退(顶部向上滚累积,越阈即发,发后归零)。
6
+ * 阈值三分:PULL_DOWN_THRESHOLD 只管指示器视觉,PULL_TRIGGER_OFFSET 管触摸触发,
7
+ * WHEEL_TRIGGER_ACCUM 管滚轮触发——共用一个常量会让「看得见」等同于「要加载」。
8
+ * wrapper(use-pull-down-history)职责:状态镜像 / 定时器 / disabled 短路;
9
+ * 业务防抖权威在 store(isLoadingHistory),机器内 isLoading 忽略为双保险。
10
+ */
11
+
12
+ /** 指示器完全露出所需的阻尼后位移——纯视觉基准(PullDownIndicator 的 marginTop 同源)。 */
13
+ export const PULL_DOWN_THRESHOLD = 60;
14
+ /**
15
+ * 触发加载所需的**内容位移**,对齐平台惯例:iOS UIRefreshControl 实际在 60~80pt 触发
16
+ * (控件自身高约 60pt),Android SwipeRefreshLayout 默认 64dp(DEFAULT_CIRCLE_TARGET)。
17
+ * 取 64 落在两者交集;阻尼 0.5 下对应手指行程 128px。
18
+ * 注意这些惯例量的是内容位移而非手指行程——按手指行程设阈值会得到四五倍的错值。
19
+ */
20
+ export const PULL_TRIGGER_OFFSET = 64;
21
+ /**
22
+ * 桌面滚轮累积触发阈值(单独于触摸):一次滚轮事件 deltaY 常见 100+,
23
+ * 与触摸共用 60 会「一滚即加载」;这里要求连续向上滚约三次。
24
+ */
25
+ export const WHEEL_TRIGGER_ACCUM = 128;
26
+ /** wheel 累积 → 拖拽尺度的换算比,使两条路径的位移与文案切换点重合。 */
27
+ export const WHEEL_DISTANCE_SCALE = PULL_TRIGGER_OFFSET / WHEEL_TRIGGER_ACCUM;
28
+ /** wheel 停止多久后复位视觉(ms)——触控板惯性滚动的间隔通常远小于此。 */
29
+ export const WHEEL_IDLE_RESET_MS = 220;
30
+ /**
31
+ * 拖拽阻尼:原生下拉在小位移区间接近 1:1 跟手,0.4 会显得「沉」(手指 100px 内容才动 40px)。
32
+ * 0.5 兼顾跟手感与「越拉越沉」的物理暗示。
33
+ */
34
+ export const DAMPING = 0.5;
35
+ /** noMore 展示保持时长(wrapper 定时后 reset;机器内部无定时器保 node 可测) */
36
+ export const NO_MORE_HOLD_MS = 1500;
37
+
38
+ export type PullStatus = 'idle' | 'pulling' | 'ready' | 'loading' | 'noMore';
39
+
40
+ export interface PullDownState {
41
+ status: PullStatus;
42
+ distance: number;
43
+ }
44
+
45
+ export function createPullDownMachine() {
46
+ let status: PullStatus = 'idle';
47
+ let distance = 0;
48
+ let startY = 0;
49
+ let wheelAccum = 0;
50
+
51
+ /** 同步置 loading(touchEnd 越阈触发;离开由 wrapper 在 onLoad 完成后 reset()) */
52
+ const enterLoading = () => {
53
+ status = 'loading';
54
+ distance = PULL_DOWN_THRESHOLD;
55
+ };
56
+
57
+ const resetDistance = () => {
58
+ if (status !== 'idle' && status !== 'noMore') {
59
+ status = 'idle';
60
+ }
61
+ if (status !== 'noMore') distance = 0;
62
+ };
63
+
64
+ return {
65
+ getState(): PullDownState {
66
+ return { status, distance };
67
+ },
68
+
69
+ touchStart(clientY: number): void {
70
+ startY = clientY;
71
+ },
72
+
73
+ touchMove(i: { clientY: number; isAtTop: boolean; hasMore: boolean; isLoading: boolean }): { preventDefault: boolean } {
74
+ if (i.isLoading) return { preventDefault: false };
75
+ // 未到顶:重锚(原生滚动照常),状态回 idle(noMore 保留——顶部已无更多的展示态)
76
+ if (!i.isAtTop) {
77
+ startY = i.clientY;
78
+ resetDistance();
79
+ return { preventDefault: false };
80
+ }
81
+ const deltaY = i.clientY - startY;
82
+ if (deltaY <= 0) {
83
+ resetDistance();
84
+ return { preventDefault: false };
85
+ }
86
+ if (!i.hasMore) {
87
+ status = 'noMore';
88
+ distance = Math.min(deltaY * DAMPING, PULL_DOWN_THRESHOLD);
89
+ return { preventDefault: true };
90
+ }
91
+ distance = deltaY * DAMPING;
92
+ status = distance > PULL_TRIGGER_OFFSET ? 'ready' : 'pulling';
93
+ return { preventDefault: true };
94
+ },
95
+
96
+ touchEnd(i: { hasMore: boolean }): { shouldLoad: boolean; holdNoMore: boolean } {
97
+ if (status === 'ready' && i.hasMore) {
98
+ enterLoading();
99
+ return { shouldLoad: true, holdNoMore: false };
100
+ }
101
+ if (status === 'noMore') {
102
+ return { shouldLoad: false, holdNoMore: true }; // wrapper 定时后 reset
103
+ }
104
+ status = 'idle';
105
+ distance = 0;
106
+ return { shouldLoad: false, holdNoMore: false };
107
+ },
108
+
109
+ /**
110
+ * 触控板双指滑动 / 滚轮。与拖拽的差别是没有「松手」这一刻,故越阈即发、不经 ready 等释放。
111
+ * 但累积过程同样驱动 status/distance——否则双指滑动全程无反馈、加载突然发生,
112
+ * 与拖拽的观感对不上。distance 按触发线之比换算到与拖拽同一尺度,胶囊位移与文案才同步。
113
+ */
114
+ wheel(i: { deltaY: number; isAtTop: boolean; hasMore: boolean; isLoading?: boolean }): { shouldLoad: boolean } {
115
+ // 加载中全忽略:触控板惯性会在加载期间继续送 wheel,若放行会把 status 从 loading
116
+ // 改回 pulling、distance 反复增减——表现为动画弹来弹去(touchMove 同守卫)。
117
+ if (i.isLoading || status === 'loading') return { shouldLoad: false };
118
+ // 离顶 / 向下滚:累积与视觉一并复位
119
+ if (!i.isAtTop || i.deltaY >= 0) {
120
+ wheelAccum = 0;
121
+ resetDistance();
122
+ return { shouldLoad: false };
123
+ }
124
+ wheelAccum -= i.deltaY;
125
+ if (!i.hasMore) {
126
+ status = 'noMore';
127
+ distance = Math.min(wheelAccum * WHEEL_DISTANCE_SCALE, PULL_DOWN_THRESHOLD);
128
+ return { shouldLoad: false };
129
+ }
130
+ if (wheelAccum > WHEEL_TRIGGER_ACCUM) {
131
+ wheelAccum = 0;
132
+ enterLoading();
133
+ return { shouldLoad: true };
134
+ }
135
+ distance = wheelAccum * WHEEL_DISTANCE_SCALE;
136
+ status = 'pulling';
137
+ return { shouldLoad: false };
138
+ },
139
+
140
+ /**
141
+ * wheel 停止后由 wrapper 定时调用:清掉累积与视觉,避免文案卡在拉动态。
142
+ * loading 期间只清累积不动视觉——此刻的 loading 由加载完成时的 reset 收尾,
143
+ * 在这里复位会把「加载中」文案打回「历史记录」。
144
+ */
145
+ wheelIdle(): void {
146
+ wheelAccum = 0;
147
+ if (status !== 'loading') resetDistance();
148
+ },
149
+
150
+ reset(): void {
151
+ status = 'idle';
152
+ distance = 0;
153
+ startY = 0;
154
+ wheelAccum = 0;
155
+ },
156
+ };
157
+ }
@@ -0,0 +1,240 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { describeToolCall, formatCompactToolIo } from './tool-call-display';
3
+ import type { ToolCallInfo } from '@tbox.cn/app-sdk/client';
4
+ import { buildPreToolArgsPayload, buildPreToolResultPayload } from '@tbox.cn/app-contracts';
5
+ import {
6
+ buildMemoryToolCallPayload,
7
+ buildSkillToolCallPayload,
8
+ MEMORY_TOOL_CALL_NAME,
9
+ } from '@tbox.cn/app-contracts';
10
+
11
+ const normal = (over: Partial<ToolCallInfo> = {}): ToolCallInfo => ({
12
+ id: 't1',
13
+ name: 'getWeather',
14
+ status: 'done',
15
+ result: JSON.stringify({ temp: 22 }),
16
+ ...over,
17
+ });
18
+
19
+ describe('describeToolCall(010 下沉 S5:展示语义三分支)', () => {
20
+ it('skill:label 取 skillName,无详情', () => {
21
+ const { args } = buildSkillToolCallPayload({ name: 'weather-guide', description: '天气' });
22
+ const d = describeToolCall({
23
+ id: 's1',
24
+ name: '__tbox_skill',
25
+ args,
26
+ result: '{}',
27
+ status: 'done',
28
+ });
29
+ expect(d).toEqual({ kind: 'skill', label: 'weather-guide', hasDetail: false });
30
+ });
31
+
32
+ it('preTool done:label 取 description、详情 = 结果文本', () => {
33
+ const args = buildPreToolArgsPayload({
34
+ toolName: 'getWeather',
35
+ description: '天气查询',
36
+ toolArgs: { city: '杭州' },
37
+ });
38
+ const result = buildPreToolResultPayload({
39
+ toolName: 'getWeather',
40
+ status: 'done',
41
+ result: { temp: 22 },
42
+ });
43
+ const d = describeToolCall({
44
+ id: 'p1',
45
+ name: '__tbox_pretool',
46
+ args,
47
+ result,
48
+ status: 'done',
49
+ });
50
+ expect(d.kind).toBe('preTool');
51
+ if (d.kind === 'preTool') {
52
+ expect(d.label).toBe('天气查询');
53
+ expect(d.status).toBe('done');
54
+ expect(d.hasDetail).toBe(true);
55
+ expect(d.resultText).toContain('"temp": 22');
56
+ }
57
+ });
58
+
59
+ it('preTool calling:label 可用(ARGS 先行)、无详情', () => {
60
+ const args = buildPreToolArgsPayload({ toolName: 'getWeather', description: '天气查询' });
61
+ const d = describeToolCall({
62
+ id: 'p2',
63
+ name: '__tbox_pretool',
64
+ args,
65
+ status: 'calling',
66
+ });
67
+ expect(d.kind).toBe('preTool');
68
+ if (d.kind === 'preTool') {
69
+ expect(d.status).toBe('calling');
70
+ expect(d.hasDetail).toBe(false);
71
+ expect(d.label).toBe('天气查询');
72
+ }
73
+ });
74
+
75
+ it('preTool error:经 result payload status 判定(ToolCallInfo.status 恒 done,R 语义)', () => {
76
+ const args = buildPreToolArgsPayload({ toolName: 'getWeather', description: '天气查询' });
77
+ const result = buildPreToolResultPayload({
78
+ toolName: 'getWeather',
79
+ status: 'error',
80
+ error: '超时',
81
+ });
82
+ const d = describeToolCall({
83
+ id: 'p3',
84
+ name: '__tbox_pretool',
85
+ args,
86
+ result,
87
+ status: 'done',
88
+ });
89
+ expect(d.kind).toBe('preTool');
90
+ if (d.kind === 'preTool') {
91
+ expect(d.status).toBe('error');
92
+ expect(d.errorText).toBe('超时');
93
+ }
94
+ });
95
+
96
+ it('preTool 容错:ARGS 非法 → label 降级空串、状态不崩', () => {
97
+ const d = describeToolCall({
98
+ id: 'p4',
99
+ name: '__tbox_pretool',
100
+ args: 'not-json',
101
+ result: 'not-json',
102
+ status: 'done',
103
+ });
104
+ expect(d.kind).toBe('preTool');
105
+ if (d.kind === 'preTool') {
106
+ expect(d.label).toBe('');
107
+ expect(d.status).toBe('done');
108
+ }
109
+ });
110
+
111
+ it('normal:label = 工具名,详情 = 有 result/error', () => {
112
+ expect(describeToolCall(normal())).toEqual({
113
+ kind: 'normal',
114
+ label: 'getWeather',
115
+ hasDetail: true,
116
+ });
117
+ expect(describeToolCall(normal({ status: 'calling', result: undefined }))).toEqual({
118
+ kind: 'normal',
119
+ label: 'getWeather',
120
+ hasDetail: false,
121
+ });
122
+ });
123
+ });
124
+
125
+ describe('describeToolCall memory 分支(018 tool-debug)', () => {
126
+ const memoryTc = (over: Partial<ToolCallInfo> = {}): ToolCallInfo => {
127
+ const payload = buildMemoryToolCallPayload({
128
+ query: '推荐美食',
129
+ status: 'hit',
130
+ records: [
131
+ { id: 'm1', kind: 'preference', content: '用户爱吃辣' },
132
+ { id: 'm2', kind: 'fact', content: '用户是会员' },
133
+ ],
134
+ durMs: 120,
135
+ });
136
+ return {
137
+ id: 'mem1',
138
+ name: MEMORY_TOOL_CALL_NAME,
139
+ args: payload.args,
140
+ result: payload.result,
141
+ status: 'done',
142
+ ...over,
143
+ };
144
+ };
145
+
146
+ it('hit:label 带命中数,详情 = records 预览', () => {
147
+ const d = describeToolCall(memoryTc());
148
+ expect(d.kind).toBe('memory');
149
+ if (d.kind === 'memory') {
150
+ expect(d.status).toBe('hit');
151
+ expect(d.hits).toBe(2);
152
+ expect(d.label).toBe('记忆召回 · 2 条命中');
153
+ expect(d.hasDetail).toBe(true);
154
+ expect(d.detailText).toContain('[preference] 用户爱吃辣');
155
+ expect(d.detailText).toContain('[fact] 用户是会员');
156
+ }
157
+ });
158
+
159
+ it('miss:无详情(I3/S1——miss 仅 label 表达)', () => {
160
+ const payload = buildMemoryToolCallPayload({ query: 'x', status: 'miss' });
161
+ const d = describeToolCall(
162
+ memoryTc({ args: payload.args, result: payload.result }),
163
+ );
164
+ expect(d.kind).toBe('memory');
165
+ if (d.kind === 'memory') {
166
+ expect(d.status).toBe('miss');
167
+ expect(d.hits).toBe(0);
168
+ expect(d.hasDetail).toBe(false);
169
+ expect(d.label).toBe('记忆召回');
170
+ }
171
+ });
172
+
173
+ it('degraded:详情 = 错误信息(调试载荷即错误本身)', () => {
174
+ const payload = buildMemoryToolCallPayload({
175
+ query: 'x',
176
+ status: 'degraded',
177
+ error: '记忆召回超时(600ms deadline)',
178
+ });
179
+ const d = describeToolCall(
180
+ memoryTc({ args: payload.args, result: payload.result }),
181
+ );
182
+ expect(d.kind).toBe('memory');
183
+ if (d.kind === 'memory') {
184
+ expect(d.status).toBe('degraded');
185
+ expect(d.hasDetail).toBe(true);
186
+ expect(d.detailText).toBe('记忆召回超时(600ms deadline)');
187
+ }
188
+ });
189
+
190
+ it('skipped:reason 意图命中进 label、无详情', () => {
191
+ const payload = buildMemoryToolCallPayload({ query: 'x', status: 'skipped', reason: 'intent' });
192
+ const d = describeToolCall(
193
+ memoryTc({ args: payload.args, result: payload.result }),
194
+ );
195
+ expect(d.kind).toBe('memory');
196
+ if (d.kind === 'memory') {
197
+ expect(d.status).toBe('skipped');
198
+ expect(d.label).toBe('记忆召回 · 跳过(意图命中)');
199
+ expect(d.hasDetail).toBe(false);
200
+ }
201
+ });
202
+
203
+ it('容错:result 非法 JSON → miss 降级不崩', () => {
204
+ const d = describeToolCall(memoryTc({ result: 'not-json' }));
205
+ expect(d.kind).toBe('memory');
206
+ if (d.kind === 'memory') {
207
+ expect(d.status).toBe('miss');
208
+ expect(d.hasDetail).toBe(false);
209
+ }
210
+ });
211
+ });
212
+
213
+ describe('formatCompactToolIo(018 dev 紧凑格式)', () => {
214
+ it('合法 JSON → 单行化', () => {
215
+ expect(formatCompactToolIo('{"a": 1, "b": [2, 3]}')).toBe('{"a":1,"b":[2,3]}');
216
+ });
217
+
218
+ it('短文本原样返回', () => {
219
+ expect(formatCompactToolIo('plain text')).toBe('plain text');
220
+ expect(formatCompactToolIo('')).toBe('');
221
+ });
222
+
223
+ it('超长 → 头尾保留 + 中段省略标记(tail 300 保尾部 cards/错误)', () => {
224
+ const head = 'h'.repeat(900);
225
+ const middle = 'm'.repeat(500);
226
+ const tail = 't'.repeat(300);
227
+ const out = formatCompactToolIo(head + middle + tail);
228
+ expect(out).toContain(head.slice(0, 100));
229
+ expect(out).toContain(tail.slice(-100));
230
+ expect(out).toContain('已省略 500 字符');
231
+ expect(out).not.toContain(middle.slice(0, 50));
232
+ });
233
+
234
+ it('自定义截断参数', () => {
235
+ const out = formatCompactToolIo('a'.repeat(100), { head: 20, tail: 10 });
236
+ expect(out).toContain('已省略 70 字符');
237
+ expect(out.startsWith('a'.repeat(20))).toBe(true);
238
+ expect(out.endsWith('a'.repeat(10))).toBe(true);
239
+ });
240
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * 工具展示语义判定(010 下沉 S5):「什么伪工具怎么显示」是框架语义,不该散在 UI 组件。
3
+ * - describeToolCall:ToolCallInfo → ToolCallDisplay 四分支(skill / preTool / normal / memory);
4
+ * - 纯函数零 UI 语义:徽标/颜色/展开交互留装配层(模板 ToolCallBlock 纯渲染);
5
+ * - preTool error 态经 result payload 的 status 判定(preTool 通道的 RESULT 恒置 done——
6
+ * isError 仅模型路径工具失败终态,R''8)——新增伪工具类型只需在此加 case;
7
+ * - memory(018 tool-debug):状态从 result envelope 派生(合成路径 RESULT 恒 done,
8
+ * ToolCallInfo.status 不承载语义,preTool 同款 R''8)。
9
+ */
10
+ import {
11
+ MEMORY_TOOL_CALL_NAME,
12
+ PRE_TOOL_CALL_NAME,
13
+ SKILL_TOOL_CALL_NAME,
14
+ parseMemoryToolCallResult,
15
+ parsePreToolToolCallArgs,
16
+ parsePreToolToolCallResult,
17
+ parseSkillToolCallArgs,
18
+ } from '@tbox.cn/app-contracts';
19
+ import type { ToolCallInfo } from '@tbox.cn/app-sdk/client';
20
+
21
+ export type ToolCallDisplay =
22
+ | { kind: 'skill'; label: string; hasDetail: false }
23
+ | {
24
+ kind: 'preTool';
25
+ label: string;
26
+ status: 'calling' | 'done' | 'error';
27
+ hasDetail: boolean;
28
+ resultText?: string;
29
+ errorText?: string;
30
+ }
31
+ | { kind: 'normal'; label: string; hasDetail: boolean }
32
+ | {
33
+ kind: 'memory';
34
+ label: string;
35
+ /** 召回结果语义(result envelope 派生) */
36
+ status: 'hit' | 'miss' | 'degraded' | 'skipped';
37
+ /** 命中条数(hit ≥1,其余 0) */
38
+ hits: number;
39
+ hasDetail: boolean;
40
+ /** 详情文本(hit: records 预览;degraded: 错误信息);miss/skipped 缺省 */
41
+ detailText?: string;
42
+ };
43
+
44
+ function prettyJson(value: unknown): string {
45
+ try {
46
+ return JSON.stringify(JSON.parse(JSON.stringify(value)), null, 2);
47
+ } catch {
48
+ return String(value);
49
+ }
50
+ }
51
+
52
+ /** skipped reason 机器值 → 展示文案(未知值降级省略) */
53
+ const MEMORY_SKIP_REASON_TEXT: Record<string, string> = { intent: '意图命中' };
54
+
55
+ export function describeToolCall(tc: ToolCallInfo): ToolCallDisplay {
56
+ if (tc.name === SKILL_TOOL_CALL_NAME) {
57
+ const args = parseSkillToolCallArgs(tc.args);
58
+ return { kind: 'skill', label: args?.skillName ?? '', hasDetail: false };
59
+ }
60
+
61
+ if (tc.name === PRE_TOOL_CALL_NAME) {
62
+ const args = parsePreToolToolCallArgs(tc.args);
63
+ const result = parsePreToolToolCallResult(tc.result);
64
+ const status: 'calling' | 'done' | 'error' =
65
+ tc.status === 'calling' ? 'calling' : result?.status === 'error' ? 'error' : 'done';
66
+ return {
67
+ kind: 'preTool',
68
+ label: args?.description ?? args?.toolName ?? '',
69
+ status,
70
+ // 详情:done → 结果(有用户价值);error → 错误文案;calling → 无
71
+ hasDetail: status !== 'calling' && !!tc.result,
72
+ ...(status === 'done' && result?.result !== undefined
73
+ ? { resultText: prettyJson(result.result) }
74
+ : {}),
75
+ ...(status === 'error' ? { errorText: result?.error ?? '预执行失败' } : {}),
76
+ };
77
+ }
78
+
79
+ if (tc.name === MEMORY_TOOL_CALL_NAME) {
80
+ const result = parseMemoryToolCallResult(tc.result);
81
+ const status = result?.status ?? 'miss';
82
+ const hits = status === 'hit' ? (result?.hits ?? 0) : 0;
83
+ const skipText =
84
+ status === 'skipped' && result?.reason
85
+ ? MEMORY_SKIP_REASON_TEXT[result.reason]
86
+ : undefined;
87
+ const label =
88
+ status === 'hit'
89
+ ? `记忆召回 · ${hits} 条命中`
90
+ : status === 'degraded'
91
+ ? '记忆召回降级'
92
+ : status === 'skipped'
93
+ ? `记忆召回 · 跳过${skipText ? `(${skipText})` : ''}`
94
+ : '记忆召回';
95
+ const detailText =
96
+ status === 'degraded'
97
+ ? (result?.error ?? '记忆召回超时或存储异常')
98
+ : status === 'hit' && result?.records && result.records.length > 0
99
+ ? result.records.map((r) => `[${r.kind}] ${r.text}`).join('\n')
100
+ : undefined;
101
+ return {
102
+ kind: 'memory',
103
+ label,
104
+ status,
105
+ hits,
106
+ hasDetail: !!detailText,
107
+ ...(detailText !== undefined ? { detailText } : {}),
108
+ };
109
+ }
110
+
111
+ return {
112
+ kind: 'normal',
113
+ label: tc.name,
114
+ hasDetail: !!(tc.result || tc.error),
115
+ };
116
+ }
117
+
118
+ // ===== 紧凑 IO 格式化(018 dev 视图;纯函数单源——双模板共用) =====
119
+
120
+ const COMPACT_IO_HEAD = 900;
121
+ const COMPACT_IO_TAIL = 300;
122
+
123
+ /**
124
+ * 工具出入参紧凑格式化(018 dev 视图):
125
+ * - 合法 JSON → 单行化(无缩进 stringify);超长 → 头尾保留 + 中段省略标记
126
+ * (head > tail 刻意为之:cards 字段与错误信息恒在尾部——修正 500 头截断截掉要害的缺陷);
127
+ * - 非法 JSON 原文同策略截断;
128
+ * - 仅服务屏显紧凑;复制场景请用原文/美化全文,不经本函数。
129
+ */
130
+ export function formatCompactToolIo(
131
+ raw: string,
132
+ opts?: { head?: number; tail?: number },
133
+ ): string {
134
+ if (!raw) return '';
135
+ const head = opts?.head ?? COMPACT_IO_HEAD;
136
+ const tail = opts?.tail ?? COMPACT_IO_TAIL;
137
+ let text = raw;
138
+ try {
139
+ text = JSON.stringify(JSON.parse(raw));
140
+ } catch {
141
+ // 非 JSON:原文截断
142
+ }
143
+ if (text.length <= head + tail) return text;
144
+ const omitted = text.length - head - tail;
145
+ return `${text.slice(0, head)}\n…[已省略 ${omitted} 字符]…\n${text.slice(-tail)}`;
146
+ }
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "jsx": "react-jsx",
5
+ "types": ["node", "vite/client"]
6
+ },
7
+ "include": ["src"]
8
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ setupFiles: ['src/test/setup.ts'],
6
+ },
7
+ });