@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,305 @@
1
+ /**
2
+ * ChatPage(单一模板统一化 W2-1:自 mall 模板 pages/index/index.tsx 抽取通用页)。
3
+ *
4
+ * - 自组 KitChatProvider(store/cards/rawCards/slots 注入位;应用单点装配);
5
+ * - 应用形态由 home 槽存在性派生(D7):注册 home → 空态渲染槽(home-first,会话态与
6
+ * 空态恒挂载——域自持其显隐);缺席 → chat 流(空态为空体,landing 槽已退役);
7
+ * - 域自持段(首屏数据/门禁/详情弹层/活动预热)经槽渲染函数自带——本页零域语义。
8
+ */
9
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
10
+ import { consumeAutoSendQuery, runAutoSend } from '@tbox.cn/app-sdk/client';
11
+ import { usePullDownHistory } from './hooks/use-pull-down-history';
12
+ import type { TboxCardPayload } from '@tbox.cn/app-contracts';
13
+ import { CardRenderer } from './components/CardRenderer';
14
+ import { ChatInput } from './components/ChatInput';
15
+ import HistoryEntry from './components/HistoryEntry';
16
+ import { MessageBubble } from './components/MessageBubble';
17
+ import { QuickCommands } from './components/QuickCommands';
18
+ import ScrollToBottomButton from './components/ScrollToBottomButton';
19
+ import { useChatConfigStore } from './chat-config';
20
+ import {
21
+ KitChatProvider,
22
+ useChatStore,
23
+ useClientSlots,
24
+ useInlineCardFilter,
25
+ type KitChatContextValue,
26
+ } from './store-context';
27
+ import type { ChatInputMessage } from './types';
28
+ import './ChatPage.less';
29
+ import './theme/tokens.less';
30
+
31
+ export interface ChatPageProps {
32
+ /** 应用 chat store(zustand 绑定 hook + API;如 `useChatStore`) */
33
+ store: KitChatContextValue['store'];
34
+ /** 聊天入口卡片注册表(模块注册 + 组合层覆盖) */
35
+ cards: KitChatContextValue['cards'];
36
+ /** 原始形态卡片注册表(缺省 = cards——无覆盖应用零配置) */
37
+ rawCards?: KitChatContextValue['rawCards'];
38
+ /** 客户端槽注册表(home 首期唯一槽;缺席 = chat 流形态) */
39
+ slots?: KitChatContextValue['slots'];
40
+ /**
41
+ * 旁路卡内联流过滤(域注入;缺省恒等):域可声明哪些 orphan 卡不进内联流
42
+ * (由域浮层自持,如 mall 会员门禁卡系)——过滤结果同时参与 home-first 形态判定。
43
+ */
44
+ filterInlineCards?: (cards: readonly TboxCardPayload[]) => TboxCardPayload[];
45
+ }
46
+
47
+ export function ChatPage({ store, cards, rawCards, slots, filterInlineCards }: ChatPageProps) {
48
+ return (
49
+ <KitChatProvider
50
+ store={store}
51
+ cards={cards}
52
+ rawCards={rawCards ?? cards}
53
+ {...(slots ? { slots } : {})}
54
+ {...(filterInlineCards ? { filterInlineCards } : {})}
55
+ >
56
+ <ChatPageInner />
57
+ </KitChatProvider>
58
+ );
59
+ }
60
+
61
+ function ChatPageInner() {
62
+ const messages = useChatStore((s) => s.messages);
63
+ const historyMessages = useChatStore((s) => s.historyMessages);
64
+ const historyHasMore = useChatStore((s) => s.historyHasMore);
65
+ const isLoadingHistory = useChatStore((s) => s.isLoadingHistory);
66
+ const loadMoreHistory = useChatStore((s) => s.loadMoreHistory);
67
+ const isGenerating = useChatStore((s) => s.isGenerating);
68
+ const runtimeStatus = useChatStore((s) => s.runtimeStatus);
69
+ const authBlocked = useChatStore((s) => s.authBlocked);
70
+ const orphanCards = useChatStore((s) => s.orphanCards);
71
+ const cardsByMessageId = useChatStore((s) => s.cardsByMessageId);
72
+ const initConversation = useChatStore((s) => s.initConversation);
73
+ const destroy = useChatStore((s) => s.destroy);
74
+ const sendMessage = useChatStore((s) => s.sendMessage);
75
+ const cancelGeneration = useChatStore((s) => s.cancelGeneration);
76
+ const newConversation = useChatStore((s) => s.newConversation);
77
+ const retryBootstrap = useChatStore((s) => s.retryBootstrap);
78
+ // 品牌名 = HELLO_ACK.mallScope.mallName(服务端 buildScope 回显;存在即可渲染的 kit 同源约定)
79
+ const mallName = useChatStore((s) => s.helloAck?.mallScope?.mallName);
80
+ // chat settings 水合:HELLO_ACK 服务端真源 → 静态 store(ack 未达/键缺席保留构建期快照;
81
+ // 重连/新会话产生新 helloAck 再水合——applyServerSettings 幂等)
82
+ const serverSettings = useChatStore((s) => s.helloAck?.chatConfig?.settings);
83
+ const applyServerSettings = useChatConfigStore((s) => s.applyServerSettings);
84
+ useEffect(() => {
85
+ applyServerSettings(serverSettings);
86
+ }, [serverSettings, applyServerSettings]);
87
+
88
+ const slots = useClientSlots();
89
+ const homeSlot = slots?.resolve('home');
90
+
91
+ const [showScrollToBottom, setShowScrollToBottom] = useState(false);
92
+ const [voiceActive, setVoiceActive] = useState(false);
93
+ const bodyRef = useRef<HTMLDivElement>(null);
94
+ // 底部输入区(QuickCommands + ChatInput)高度测量:底部避让消费(如半屏 sheet)
95
+ const chatBottomRef = useRef<HTMLDivElement>(null);
96
+ // 历史前插视口锚:home 槽位占位(历史插入发生在其上,保持用户看到的位置)
97
+ const historyAnchorRef = useRef<HTMLDivElement>(null);
98
+ const historyLoadInFlightRef = useRef<Promise<void> | null>(null);
99
+ const pendingHistoryViewportRef = useRef<{ anchorTop: number } | null>(null);
100
+ const isAtTopRef = useRef(true);
101
+ const isNearBottomRef = useRef(true);
102
+
103
+ const startNewConversation = useCallback(() => {
104
+ void newConversation();
105
+ const el = bodyRef.current;
106
+ if (el) el.scrollTop = 0;
107
+ isAtTopRef.current = true;
108
+ isNearBottomRef.current = false;
109
+ setShowScrollToBottom(false);
110
+ }, [newConversation]);
111
+
112
+ const send = useCallback((input: string | ChatInputMessage) => {
113
+ // 主动发送代表用户要回到最新对话;待消息 DOM 提交后由下方 effect 落底。
114
+ // 生成中 store 会拒绝新消息,此时不改变用户正在查看的滚动位置。
115
+ if (isGenerating) return;
116
+ isNearBottomRef.current = true;
117
+ setShowScrollToBottom(false);
118
+ sendMessage(input);
119
+ }, [isGenerating, sendMessage]);
120
+
121
+ // 连接终态阻断(登录失败/scope 拒绝):禁用输入 + 提示条 + 重试。
122
+ // 门控仅 runtimeStatus=failed 或 authBlocked——bootstrapping 等过渡态不闪现横幅
123
+ const blocked =
124
+ runtimeStatus === 'failed' || authBlocked
125
+ ? {
126
+ message: authBlocked ?? '登录失败,暂时无法对话',
127
+ onRetry: () => void retryBootstrap(true),
128
+ }
129
+ : undefined;
130
+
131
+ const scrollToBottom = useCallback(() => {
132
+ const el = bodyRef.current;
133
+ if (el) el.scrollTop = el.scrollHeight;
134
+ isNearBottomRef.current = true;
135
+ setShowScrollToBottom(false);
136
+ }, []);
137
+
138
+ const NEAR_BOTTOM = 150;
139
+ const onScroll = useCallback(() => {
140
+ const el = bodyRef.current;
141
+ if (!el) return;
142
+ isAtTopRef.current = el.scrollTop <= 5;
143
+ const distanceToBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
144
+ isNearBottomRef.current = distanceToBottom < NEAR_BOTTOM;
145
+ setShowScrollToBottom(!isNearBottomRef.current);
146
+ }, []);
147
+
148
+ // 顶部入口与轻量下拉共用同一加载任务;同一周期复用 Promise,避免点击/手势重复请求。
149
+ const onLoadHistory = useCallback(() => {
150
+ if (historyLoadInFlightRef.current) return historyLoadInFlightRef.current;
151
+ const anchor = historyAnchorRef.current;
152
+ if (anchor) {
153
+ pendingHistoryViewportRef.current = {
154
+ anchorTop: anchor.getBoundingClientRect().top,
155
+ };
156
+ }
157
+ const task = loadMoreHistory().finally(() => {
158
+ if (historyLoadInFlightRef.current === task) historyLoadInFlightRef.current = null;
159
+ });
160
+ historyLoadInFlightRef.current = task;
161
+ return task;
162
+ }, [loadMoreHistory]);
163
+
164
+ // 历史插入发生在当前内容上方:绘制前只补一次新增高度,保持用户原本看到的位置。
165
+ // 不设置定时器、不钉住 scrollTop;补偿后下一次滚动立即由用户接管。
166
+ useLayoutEffect(() => {
167
+ const pending = pendingHistoryViewportRef.current;
168
+ const anchor = historyAnchorRef.current;
169
+ if (!pending || !anchor) return;
170
+ pendingHistoryViewportRef.current = null;
171
+ const anchorDelta = anchor.getBoundingClientRect().top - pending.anchorTop;
172
+ if (Math.abs(anchorDelta) > 0.5) {
173
+ const el = bodyRef.current;
174
+ if (el) el.scrollTop += anchorDelta;
175
+ }
176
+ }, [historyMessages]);
177
+
178
+ const filterInlineCards = useInlineCardFilter();
179
+ const inlineOrphanCards = useMemo(
180
+ () => filterInlineCards(orphanCards),
181
+ [orphanCards, filterInlineCards],
182
+ );
183
+
184
+ // 是否仍为空会话(home-first 形态判定);home 槽缺席时始终为对话形态
185
+ const showHome = !!homeSlot
186
+ && historyMessages.length === 0
187
+ && messages.length === 0
188
+ && inlineOrphanCards.length === 0;
189
+
190
+ const liveCardSignature = useMemo(
191
+ () => messages
192
+ .map((message) => `${message.id}:${(cardsByMessageId[message.id] ?? []).map((card) => card.id).join(',')}`)
193
+ .join('|'),
194
+ [messages, cardsByMessageId],
195
+ );
196
+
197
+ const showHistoryEntry = historyHasMore && historyMessages.length === 0;
198
+
199
+ const pull = usePullDownHistory({
200
+ isAtTopRef,
201
+ isLoadingHistory,
202
+ hasMore: historyHasMore,
203
+ onLoad: onLoadHistory,
204
+ disabled: runtimeStatus !== 'ready',
205
+ });
206
+
207
+ useEffect(() => {
208
+ // 深链自动发送:同步消费(StrictMode 双挂载/刷新安全),引导完成后等连接就绪发送
209
+ const autoQuery = consumeAutoSendQuery();
210
+ void initConversation().then(async () => {
211
+ if (autoQuery) await runAutoSend(autoQuery, sendMessage);
212
+ });
213
+ return () => {
214
+ destroy();
215
+ };
216
+ // eslint-disable-next-line react-hooks/exhaustive-deps
217
+ }, []);
218
+
219
+ // 底部输入区高度 → CSS 变量 --tbox-chat-bottom-height,供半屏 sheet(portal 到 body)避让消费。
220
+ useEffect(() => {
221
+ const el = chatBottomRef.current;
222
+ if (!el) return;
223
+ const update = () =>
224
+ document.documentElement.style.setProperty('--tbox-chat-bottom-height', `${el.offsetHeight}px`);
225
+ update();
226
+ const ro = new ResizeObserver(update);
227
+ ro.observe(el);
228
+ return () => ro.disconnect();
229
+ }, []);
230
+
231
+ // 仅在已有对话内容且贴近底部时自动滚动,避免打断用户上翻查看历史。
232
+ // 依赖含 cardsByMessageId:主卡在文字完成后挂载、消息行增高,需补滚避免卡片截出视口
233
+ useEffect(() => {
234
+ const hasLiveContent = messages.length > 0 || orphanCards.length > 0;
235
+ if (hasLiveContent && isNearBottomRef.current) {
236
+ const el = bodyRef.current;
237
+ if (el) el.scrollTop = el.scrollHeight;
238
+ }
239
+ }, [messages, orphanCards, liveCardSignature]);
240
+
241
+ return (
242
+ <div className={`chat-page${showHome ? '' : ' chat-page--conversation'}`}>
243
+ <div className="chat-page__bg" />
244
+
245
+ <div className="chat-page__body-wrapper">
246
+ <div
247
+ className="chat-page__body"
248
+ ref={bodyRef}
249
+ onScroll={onScroll}
250
+ onWheel={pull.handleWheel}
251
+ onTouchStart={pull.handleTouchStart}
252
+ onTouchMove={pull.handleTouchMove}
253
+ onTouchEnd={pull.handleTouchEnd}
254
+ onTouchCancel={pull.handleTouchCancel}
255
+ >
256
+ <HistoryEntry
257
+ visible={showHistoryEntry}
258
+ loading={isLoadingHistory}
259
+ disabled={runtimeStatus !== 'ready' || isLoadingHistory}
260
+ pullStatus={pull.pullStatus}
261
+ onLoad={() => void onLoadHistory()}
262
+ />
263
+
264
+ <div className="chat-page__history">
265
+ {historyMessages.map((msg) => (
266
+ <MessageBubble key={msg.id} message={msg} />
267
+ ))}
268
+ </div>
269
+
270
+ {/* 历史前插视口锚 + home 槽渲染位(域自持显隐;缺席则不渲染) */}
271
+ <div className="chat-page__home-slot" ref={historyAnchorRef}>
272
+ {homeSlot?.({ onSend: send, ...(mallName ? { mallName } : {}) })}
273
+ </div>
274
+
275
+ {/* 业务旁路 emit 的卡片(如欢迎卡) */}
276
+ {inlineOrphanCards.map((card) => (
277
+ <div key={card.id} className="chat-page__orphan-card">
278
+ <CardRenderer card={card} />
279
+ </div>
280
+ ))}
281
+
282
+ {/* 消息(每条消息下的卡片由 MessageBubble 内部渲染) */}
283
+ {messages.map((msg) => (
284
+ <MessageBubble key={msg.id} message={msg} />
285
+ ))}
286
+ </div>
287
+
288
+ <ScrollToBottomButton visible={showScrollToBottom} onClick={scrollToBottom} />
289
+ </div>
290
+
291
+ {/* 首页与对话态都保留快捷指令;语音活跃时只隐去胶囊并保留灰底占位,阻断时才卸载。 */}
292
+ <div ref={chatBottomRef}>
293
+ {!blocked && <QuickCommands hidden={voiceActive} onCommand={send} />}
294
+ <ChatInput
295
+ generating={isGenerating}
296
+ blocked={blocked}
297
+ onSend={send}
298
+ onCancel={cancelGeneration}
299
+ onNewChat={startNewConversation}
300
+ onVoiceActivityChange={setVoiceActive}
301
+ />
302
+ </div>
303
+ </div>
304
+ );
305
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * chat-config store 水合 action(chat settings 服务端下发真源):
3
+ * applyServerSettings 五键整组语义——undefined 键保留构建期快照,禁部分水合。
4
+ */
5
+ import { describe, it, expect, beforeEach } from 'vitest';
6
+ import { configureChatConfigDefaults, useChatConfigStore } from './chat-config';
7
+
8
+ const snapshot = () => useChatConfigStore.getState();
9
+
10
+ describe('applyServerSettings:HELLO_ACK settings 水合', () => {
11
+ beforeEach(() => {
12
+ // 重置回构建期快照(快照值来自模板 config/chat.json 静态 import)
13
+ useChatConfigStore.setState({
14
+ enableImageUpload: true,
15
+ enableVideoUpload: true,
16
+ enableTTS: true,
17
+ enableVoiceInput: true,
18
+ defaultInputMode: 'TEXT',
19
+ });
20
+ });
21
+
22
+ it('五键全覆盖 → 全部覆盖快照值', () => {
23
+ snapshot().applyServerSettings({
24
+ enableImageUpload: false,
25
+ enableVideoUpload: false,
26
+ enableTTS: false,
27
+ enableVoiceInput: false,
28
+ defaultInputMode: 'VOICE',
29
+ });
30
+ expect(snapshot().enableImageUpload).toBe(false);
31
+ expect(snapshot().enableVideoUpload).toBe(false);
32
+ expect(snapshot().enableTTS).toBe(false);
33
+ expect(snapshot().enableVoiceInput).toBe(false);
34
+ expect(snapshot().defaultInputMode).toBe('VOICE');
35
+ });
36
+
37
+ it('部分键(undefined 保留快照值——禁部分水合导致快照丢失)', () => {
38
+ snapshot().applyServerSettings({ enableTTS: false });
39
+ expect(snapshot().enableTTS).toBe(false);
40
+ expect(snapshot().enableImageUpload).toBe(true);
41
+ expect(snapshot().enableVideoUpload).toBe(true);
42
+ expect(snapshot().enableVoiceInput).toBe(true);
43
+ expect(snapshot().defaultInputMode).toBe('TEXT');
44
+ });
45
+
46
+ it('整包 undefined(旧 server / ack 未达)→ no-op 快照保留', () => {
47
+ snapshot().applyServerSettings(undefined);
48
+ expect(snapshot().enableImageUpload).toBe(true);
49
+ expect(snapshot().enableTTS).toBe(true);
50
+ expect(snapshot().defaultInputMode).toBe('TEXT');
51
+ });
52
+
53
+ it('同值幂等:两次 apply 后五键深等价(防第二次 apply 丢键/翻转)', () => {
54
+ const settings = { enableTTS: false, defaultInputMode: 'VOICE' as const };
55
+ snapshot().applyServerSettings(settings);
56
+ const after1 = snapshot();
57
+ snapshot().applyServerSettings(settings);
58
+ // toEqual 全状态深比较——单键 toBe 对原始值恒真,钉不住实现回归
59
+ expect(snapshot()).toEqual(after1);
60
+ expect(snapshot().enableTTS).toBe(false);
61
+ expect(snapshot().defaultInputMode).toBe('VOICE');
62
+ });
63
+ });
64
+
65
+ describe('configureChatConfigDefaults:构建期快照注入(W2-1 kit 化)', () => {
66
+ it('快照键覆盖内建缺省;缺席键保留;undefined no-op', () => {
67
+ configureChatConfigDefaults({
68
+ enableTTS: false,
69
+ quickCommands: [{ label: 'L', query: 'Q' }],
70
+ });
71
+ expect(snapshot().enableTTS).toBe(false);
72
+ expect(snapshot().quickCommands).toEqual([{ label: 'L', query: 'Q' }]);
73
+ // 缺席键保留
74
+ expect(snapshot().enableImageUpload).toBe(true);
75
+
76
+ configureChatConfigDefaults(undefined);
77
+ expect(snapshot().enableTTS).toBe(false); // no-op:不因 undefined 回翻
78
+
79
+ // 复位(防污染后续用例)
80
+ useChatConfigStore.setState({ enableTTS: true, quickCommands: [] });
81
+ });
82
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * chat 设置静态 store(自 mall 模板 store/chat-config.ts 迁入,单一模板统一化 W2-1)。
3
+ *
4
+ * - 构建期快照:应用 boot 时经 `configureChatConfigDefaults(chat.json 内容)` 注入
5
+ * (createRoot 前调用保证首帧即快照值;kit 不 import 应用 config 路径);
6
+ * - HELLO_ACK settings 水合:服务端单一真源(applyServerSettings;五键整组语义,禁部分水合)。
7
+ */
8
+ import { create } from 'zustand';
9
+ import type { ChatUiSettings } from '@tbox.cn/app-sdk/client';
10
+
11
+ export interface QuickCommand {
12
+ /** 纯文案;前导星标/图标由 QuickCommands 组件渲染,不要写进 label */
13
+ label: string;
14
+ query: string;
15
+ icon?: string;
16
+ }
17
+
18
+ interface ChatConfigStore {
19
+ enableImageUpload: boolean;
20
+ enableVideoUpload: boolean;
21
+ enableTTS: boolean;
22
+ enableVoiceInput: boolean;
23
+ defaultInputMode: 'TEXT' | 'VOICE';
24
+ quickCommands: QuickCommand[];
25
+ /** HELLO_ACK settings 水合:服务端单一真源;undefined 键保留构建期快照(五键整组语义,禁部分水合) */
26
+ applyServerSettings: (settings: ChatUiSettings | undefined) => void;
27
+ }
28
+
29
+ export const useChatConfigStore = create<ChatConfigStore>()((set) => ({
30
+ // kit 内建缺省(应用快照经 configureChatConfigDefaults 覆盖)
31
+ enableImageUpload: true,
32
+ enableVideoUpload: true,
33
+ enableTTS: true,
34
+ enableVoiceInput: true,
35
+ defaultInputMode: 'TEXT',
36
+ quickCommands: [],
37
+ applyServerSettings: (settings) =>
38
+ set((cur) => ({
39
+ enableImageUpload: settings?.enableImageUpload ?? cur.enableImageUpload,
40
+ enableVideoUpload: settings?.enableVideoUpload ?? cur.enableVideoUpload,
41
+ enableTTS: settings?.enableTTS ?? cur.enableTTS,
42
+ enableVoiceInput: settings?.enableVoiceInput ?? cur.enableVoiceInput,
43
+ defaultInputMode: settings?.defaultInputMode ?? cur.defaultInputMode,
44
+ })),
45
+ }));
46
+
47
+ /**
48
+ * 构建期快照注入(应用 boot 时调用一次;createRoot 前调用保证首帧即快照值)。
49
+ * 键缺席保留内建缺省;键在场以快照为准。
50
+ */
51
+ export function configureChatConfigDefaults(snapshot: Record<string, unknown> | undefined): void {
52
+ if (!snapshot) return;
53
+ useChatConfigStore.setState((cur) => ({
54
+ enableImageUpload: (snapshot['enableImageUpload'] as boolean | undefined) ?? cur.enableImageUpload,
55
+ enableVideoUpload: (snapshot['enableVideoUpload'] as boolean | undefined) ?? cur.enableVideoUpload,
56
+ enableTTS: (snapshot['enableTTS'] as boolean | undefined) ?? cur.enableTTS,
57
+ enableVoiceInput: (snapshot['enableVoiceInput'] as boolean | undefined) ?? cur.enableVoiceInput,
58
+ defaultInputMode:
59
+ (snapshot['defaultInputMode'] as 'TEXT' | 'VOICE' | undefined) ?? cur.defaultInputMode,
60
+ quickCommands: (snapshot['quickCommands'] as QuickCommand[] | undefined) ?? cur.quickCommands,
61
+ }));
62
+ }
@@ -2,7 +2,7 @@ import { createLogger } from '@tbox.cn/app-sdk/client';
2
2
 
3
3
  const debugLogger = createLogger('DebugButton');
4
4
 
5
- import debugIcon from '../../../assets/debug.png';
5
+ import { Bug } from '@phosphor-icons/react';
6
6
 
7
7
  /**
8
8
  * 调试按钮:点击把本轮 requestId / conversationId / messageId / messageCreatedAt
@@ -46,7 +46,7 @@ export function DebugButton({
46
46
 
47
47
  return (
48
48
  <div className="tbox-debug-btn" onClick={onClick}>
49
- <img className="tbox-debug-btn__icon" src={debugIcon} alt="" />
49
+ <Bug className="tbox-debug-btn__icon" size={22} />
50
50
  <span className="tbox-debug-btn__text">调试</span>
51
51
  </div>
52
52
  );
@@ -1,3 +1,10 @@
1
+ /**
2
+ * 助手消息操作栏(mall-vis 蓝系 token 化)。
3
+ * - 图标 phosphor 矢量(currentColor):默认灰(--tbox-text-muted)、active 态蓝(--tbox-primary)
4
+ * - active 底 / TTS 播放底:--tbox-primary-light(蓝系浅底)
5
+ * - 调试文字 / 波形条:--tbox-text-secondary
6
+ * - 按压底:--tbox-bg-subtle
7
+ */
1
8
  .tbox-action-bar {
2
9
  display: flex;
3
10
  align-items: center;
@@ -7,7 +14,7 @@
7
14
  .tbox-action-btn {
8
15
  padding: 0;
9
16
  border: 0;
10
- color: inherit;
17
+ color: var(--tbox-text-muted);
11
18
  font: inherit;
12
19
  cursor: pointer;
13
20
  width: 52px;
@@ -18,15 +25,16 @@
18
25
  flex-shrink: 0;
19
26
  border-radius: 26px;
20
27
  background: #fff;
21
- transition: background-color 0.12s ease, transform 0.12s ease;
28
+ transition: background-color var(--tbox-motion-duration-fast) ease, transform var(--tbox-motion-duration-fast) ease;
22
29
 
23
30
  &:active {
24
- background: #f5f7fa;
31
+ background: var(--tbox-bg-subtle);
25
32
  transform: scale(0.97);
26
33
  }
27
34
 
28
35
  &--active {
29
- background: #eef4ff;
36
+ color: var(--tbox-primary);
37
+ background: var(--tbox-primary-light);
30
38
  }
31
39
 
32
40
  &__icon-wrap {
@@ -37,12 +45,6 @@
37
45
  justify-content: center;
38
46
  flex-shrink: 0;
39
47
  }
40
-
41
- &__icon {
42
- width: 28px;
43
- height: 28px;
44
- object-fit: contain;
45
- }
46
48
  }
47
49
 
48
50
  /* 调试按钮:独立 pill(icon + “调试” 文字) */
@@ -55,10 +57,11 @@
55
57
  padding: 0 22px;
56
58
  border-radius: 26px;
57
59
  background: #fff;
58
- transition: background-color 0.12s ease, transform 0.12s ease;
60
+ color: var(--tbox-text-secondary);
61
+ transition: background-color var(--tbox-motion-duration-fast) ease, transform var(--tbox-motion-duration-fast) ease;
59
62
 
60
63
  &:active {
61
- background: #f5f7fa;
64
+ background: var(--tbox-bg-subtle);
62
65
  transform: scale(0.97);
63
66
  }
64
67
 
@@ -71,11 +74,10 @@
71
74
  &__text {
72
75
  font-size: 21px;
73
76
  line-height: 1;
74
- color: #405475;
75
77
  }
76
78
  }
77
79
 
78
- /* TTS 播放波形 */
80
+ /* TTS 播放波形(红系主色,随 token 走) */
79
81
  .tbox-tts-wave {
80
82
  display: flex;
81
83
  align-items: center;
@@ -87,7 +89,7 @@
87
89
  &__bar {
88
90
  width: 3px;
89
91
  border-radius: 999px;
90
- background: #405475;
92
+ background: var(--tbox-primary);
91
93
  transform-origin: center bottom;
92
94
  animation: tbox-tts-wave 0.8s ease-in-out infinite;
93
95
  }
@@ -106,7 +108,8 @@
106
108
  }
107
109
 
108
110
  .tbox-tts-btn--playing {
109
- background: #eef4ff;
111
+ color: var(--tbox-primary);
112
+ background: var(--tbox-primary-light);
110
113
  }
111
114
 
112
115
  @keyframes tbox-tts-wave {
@@ -2,17 +2,14 @@ import { useEffect, useState, useSyncExternalStore } from 'react';
2
2
  import { createLogger } from '@tbox.cn/app-sdk/client';
3
3
 
4
4
  const ttsActionLogger = createLogger('tts');
5
- import { useChatStore } from '@/store/chat';
6
- import { useChatConfigStore } from '@/store/chat-config';
5
+ import { Copy, SpeakerHigh, ThumbsDown, ThumbsUp } from '@phosphor-icons/react';
6
+ import { useChatStore } from '../../store-context';
7
+ import { useChatConfigStore } from '../../chat-config';
7
8
  import { useFeedbackAdapter, useRefreshSession, useTTSAdapter } from '@tbox.cn/app-sdk/client';
8
- import type { ChatMessage } from '@/types/chat';
9
- import { copyText } from '@/utils/copyText';
10
- import { toast } from '@/utils/toast';
9
+ import type { ChatMessage } from '../../types';
10
+ import { copyText } from '../../utils/copyText';
11
+ import { toast } from '../../utils/toast';
11
12
  import { DebugButton } from './DebugButton';
12
- import copyIcon from '../../../assets/copy@2x.png';
13
- import likeIcon from '../../../assets/like@2x.png';
14
- import dislikeIcon from '../../../assets/dislike@2x.png';
15
- import speakerIcon from '../../../assets/speaker@2x.png';
16
13
  import './index.less';
17
14
 
18
15
  /**
@@ -50,7 +47,7 @@ export function CopyButton({ text }: { text: string }) {
50
47
  return (
51
48
  <button type="button" className="tbox-action-btn" onClick={copy} aria-label="复制">
52
49
  <span className="tbox-action-btn__icon-wrap">
53
- <img className="tbox-action-btn__icon" src={copyIcon} alt="" />
50
+ <Copy size={22} />
54
51
  </span>
55
52
  </button>
56
53
  );
@@ -123,7 +120,7 @@ export function ActionBar({ message }: { message: ChatMessage }) {
123
120
  </div>
124
121
  ) : (
125
122
  <div className="tbox-action-btn__icon-wrap">
126
- <img className="tbox-action-btn__icon" src={speakerIcon} alt="朗读" />
123
+ <SpeakerHigh size={22} />
127
124
  </div>
128
125
  )}
129
126
  </div>
@@ -138,7 +135,7 @@ export function ActionBar({ message }: { message: ChatMessage }) {
138
135
  onClick={() => onFeedback('like')}
139
136
  >
140
137
  <div className="tbox-action-btn__icon-wrap">
141
- <img className="tbox-action-btn__icon" src={likeIcon} alt="赞" />
138
+ <ThumbsUp size={22} />
142
139
  </div>
143
140
  </div>
144
141
  <div
@@ -146,7 +143,7 @@ export function ActionBar({ message }: { message: ChatMessage }) {
146
143
  onClick={() => onFeedback('dislike')}
147
144
  >
148
145
  <div className="tbox-action-btn__icon-wrap">
149
- <img className="tbox-action-btn__icon" src={dislikeIcon} alt="踩" />
146
+ <ThumbsDown size={22} />
150
147
  </div>
151
148
  </div>
152
149
  </>
@@ -0,0 +1,36 @@
1
+ /**
2
+ * 卡片渲染入口(kit 版,自 mall 模板 cards/CardRenderer.tsx 迁入):
3
+ * 注入 context 提供的注册表后委托 SDK 的 CardRenderer;
4
+ * 动作按钮 → 注入 store 的 sendUiAction(token 来自 payload.actionTokens)。
5
+ *
6
+ * - CardRenderer:聊天入口(组合层覆盖注册表 cards);
7
+ * - RawCardRenderer:原始形态(rawCards,无覆盖)——首页详情弹层 body 等防嵌套入口。
8
+ */
9
+ import { CardRenderer as SdkCardRenderer } from '@tbox.cn/app-sdk/client';
10
+ import type { TboxCardPayload, UiAction } from '@tbox.cn/app-contracts';
11
+ import { useChatCards, useChatRawCards, useChatStore } from '../store-context';
12
+
13
+ export function CardRenderer({ card }: { card: TboxCardPayload }) {
14
+ const sendUiAction = useChatStore((s) => s.sendUiAction);
15
+ const cards = useChatCards();
16
+ return (
17
+ <SdkCardRenderer
18
+ card={card}
19
+ registry={cards}
20
+ onAction={(action: UiAction) => sendUiAction(card.id, action)}
21
+ />
22
+ );
23
+ }
24
+
25
+ /** 原始形态渲染(无组合层覆盖):详情弹层 body 专用——弹层自身已提供 hero+主题容器 */
26
+ export function RawCardRenderer({ card }: { card: TboxCardPayload }) {
27
+ const sendUiAction = useChatStore((s) => s.sendUiAction);
28
+ const rawCards = useChatRawCards();
29
+ return (
30
+ <SdkCardRenderer
31
+ card={card}
32
+ registry={rawCards}
33
+ onAction={(action: UiAction) => sendUiAction(card.id, action)}
34
+ />
35
+ );
36
+ }