@tbox.cn/app-template-agent 0.1.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 (119) hide show
  1. package/.gitignore +13 -0
  2. package/.nvmrc +1 -0
  3. package/.tbox/app.json +4 -0
  4. package/AGENTS.md +101 -0
  5. package/ENV.md +77 -0
  6. package/LEGAL.md +7 -0
  7. package/LICENSE +21 -0
  8. package/README.md +62 -0
  9. package/apps/client/STYLE_GUIDE.md +81 -0
  10. package/apps/client/VISUAL_RESTORE.md +90 -0
  11. package/apps/client/assets/copy@2x.png +0 -0
  12. package/apps/client/assets/debug.png +0 -0
  13. package/apps/client/assets/dislike@2x.png +0 -0
  14. package/apps/client/assets/keyboard-circle.svg +12 -0
  15. package/apps/client/assets/like@2x.png +0 -0
  16. package/apps/client/assets/more-circle.svg +3 -0
  17. package/apps/client/assets/new-chat.svg +3 -0
  18. package/apps/client/assets/quick-command-star.svg +3 -0
  19. package/apps/client/assets/send-gradient.svg +14 -0
  20. package/apps/client/assets/speaker@2x.png +0 -0
  21. package/apps/client/assets/stop@2x.png +0 -0
  22. package/apps/client/assets/voice-circle.svg +3 -0
  23. package/apps/client/babel-plugins/code-inspect.cjs +110 -0
  24. package/apps/client/index.html +36 -0
  25. package/apps/client/package.json +34 -0
  26. package/apps/client/src/app.tsx +5 -0
  27. package/apps/client/src/business/App.tsx +11 -0
  28. package/apps/client/src/business/README.md +82 -0
  29. package/apps/client/src/cards/CardRenderer.tsx +10 -0
  30. package/apps/client/src/cards/registry.ts +22 -0
  31. package/apps/client/src/components/ActionBar/DebugButton.tsx +49 -0
  32. package/apps/client/src/components/ActionBar/index.less +120 -0
  33. package/apps/client/src/components/ActionBar/index.tsx +157 -0
  34. package/apps/client/src/components/ChatHeader/index.less +104 -0
  35. package/apps/client/src/components/ChatHeader/index.tsx +63 -0
  36. package/apps/client/src/components/ChatInput/index.less +332 -0
  37. package/apps/client/src/components/ChatInput/index.tsx +251 -0
  38. package/apps/client/src/components/ChatInput/voice/VoicePressField.tsx +34 -0
  39. package/apps/client/src/components/ChatInput/voice/VoiceRecordingOverlay.tsx +51 -0
  40. package/apps/client/src/components/ChatInput/voice/machine.ts +27 -0
  41. package/apps/client/src/components/ChatInput/voice/useVoicePress.ts +188 -0
  42. package/apps/client/src/components/ErrorBoundary.tsx +2 -0
  43. package/apps/client/src/components/MediaList/index.less +241 -0
  44. package/apps/client/src/components/MediaList/index.tsx +256 -0
  45. package/apps/client/src/components/MessageBubble/index.less +309 -0
  46. package/apps/client/src/components/MessageBubble/index.tsx +163 -0
  47. package/apps/client/src/components/PullDownIndicator/index.less +32 -0
  48. package/apps/client/src/components/PullDownIndicator/index.tsx +33 -0
  49. package/apps/client/src/components/QuickCommands/index.less +48 -0
  50. package/apps/client/src/components/QuickCommands/index.tsx +22 -0
  51. package/apps/client/src/components/ReasoningBlock/index.less +90 -0
  52. package/apps/client/src/components/ReasoningBlock/index.tsx +56 -0
  53. package/apps/client/src/components/ScrollToBottomButton/index.less +32 -0
  54. package/apps/client/src/components/ScrollToBottomButton/index.tsx +10 -0
  55. package/apps/client/src/components/ToolCallBlock/index.less +91 -0
  56. package/apps/client/src/components/ToolCallBlock/index.tsx +63 -0
  57. package/apps/client/src/examples/tourism/TourismLanding.tsx +96 -0
  58. package/apps/client/src/examples/tourism/index.less +192 -0
  59. package/apps/client/src/hooks/useMediaUpload.ts +237 -0
  60. package/apps/client/src/hooks/usePullDownHistory.ts +97 -0
  61. package/apps/client/src/hooks/useTTSAutoPlay.ts +80 -0
  62. package/apps/client/src/main.tsx +17 -0
  63. package/apps/client/src/modules.ts +30 -0
  64. package/apps/client/src/pages/index/index.less +43 -0
  65. package/apps/client/src/pages/index/index.tsx +145 -0
  66. package/apps/client/src/platform/AgentChatApp.tsx +39 -0
  67. package/apps/client/src/platform/agent-runtime.ts +15 -0
  68. package/apps/client/src/platform/state/createPersistedStore.ts +19 -0
  69. package/apps/client/src/shims/node-stub.ts +14 -0
  70. package/apps/client/src/store/chat-config.ts +29 -0
  71. package/apps/client/src/store/chat.ts +201 -0
  72. package/apps/client/src/styles/global.less +60 -0
  73. package/apps/client/src/styles/tokens.less +60 -0
  74. package/apps/client/src/types/chat.ts +29 -0
  75. package/apps/client/src/utils/copyText.ts +11 -0
  76. package/apps/client/src/utils/imagePreview.ts +10 -0
  77. package/apps/client/src/utils/session-error.ts +5 -0
  78. package/apps/client/src/utils/toast.ts +15 -0
  79. package/apps/client/src/vite-env.d.ts +3 -0
  80. package/apps/client/tsconfig.json +11 -0
  81. package/apps/client/vite.config.ts +40 -0
  82. package/apps/server/package.json +40 -0
  83. package/apps/server/src/cli/collect-registry.ts +26 -0
  84. package/apps/server/src/cli/plugin-doctor.ts +89 -0
  85. package/apps/server/src/cli/plugin-inspect.ts +49 -0
  86. package/apps/server/src/index.ts +230 -0
  87. package/apps/server/src/intent-routes.ts +23 -0
  88. package/apps/server/src/modules.ts +31 -0
  89. package/apps/server/src/tools/translate.ts +41 -0
  90. package/apps/server/tests/cards-registry.test.ts +55 -0
  91. package/apps/server/tests/tool-runtime.test.ts +49 -0
  92. package/apps/server/tsconfig.json +5 -0
  93. package/apps/server/tsup.config.ts +25 -0
  94. package/apps/server/vitest.config.ts +8 -0
  95. package/config/chat.json +8 -0
  96. package/config/llm_provider.json +3 -0
  97. package/config/plugins.json +3 -0
  98. package/eslint.config.mjs +41 -0
  99. package/package.json +51 -0
  100. package/packages/module-weather/package.json +35 -0
  101. package/packages/module-weather/src/client/cards/weather/index.less +22 -0
  102. package/packages/module-weather/src/client/cards/weather/index.tsx +14 -0
  103. package/packages/module-weather/src/client/index.ts +19 -0
  104. package/packages/module-weather/src/index.ts +2 -0
  105. package/packages/module-weather/src/server/cards/weather/meta.ts +28 -0
  106. package/packages/module-weather/src/server/index.ts +19 -0
  107. package/packages/module-weather/src/server/tool.ts +29 -0
  108. package/packages/module-weather/tbox.component.json +27 -0
  109. package/packages/module-weather/tsconfig.json +14 -0
  110. package/pnpm-workspace.template.yaml +60 -0
  111. package/scripts/build.sh +39 -0
  112. package/scripts/check-inspector.sh +91 -0
  113. package/scripts/check-workspace.mjs +164 -0
  114. package/scripts/dev.sh +15 -0
  115. package/scripts/prepare.sh +11 -0
  116. package/scripts/start.sh +11 -0
  117. package/scripts/stop.sh +26 -0
  118. package/scripts/verify-generated.mjs +142 -0
  119. package/tsconfig.base.json +20 -0
@@ -0,0 +1,110 @@
1
+ /**
2
+ * ============================================================================
3
+ * 平台配套 babel plugin - 给每个 JSXElement 注入 data-code-inspect-* 属性。
4
+ * 配合 tbox 平台 inspector runtime(/__vs_inspector__.js)使用,让 host 端微调浮层
5
+ * 能拿到源码位置,直接走「直改源码」快路径,而不是把意图发回 LLM 重新生成代码。
6
+ *
7
+ * 注入的属性(与 inspector runtime 期望严格对齐,名字别改):
8
+ * data-code-inspect-loc-filepath = 绝对路径
9
+ * data-code-inspect-loc-line = "{line}:{column}" (1-based, 来自 JSX opening tag)
10
+ * data-code-inspect-jsx-tag = JSX 标签名 (如 "div" / "Form.Item")
11
+ * data-code-inspect-component = 所在组件名 (爬 AST 找最近的函数/类组件)
12
+ *
13
+ * Vite 接入:在 vite.config.ts 里 `react({ babel: { plugins: [...] } })`,仅 dev(command==='serve')启用。
14
+ * ============================================================================
15
+ */
16
+
17
+ const LOC_FILEPATH_ATTRIBUTE_NAME = 'data-code-inspect-loc-filepath';
18
+ const LOC_LINE_ATTRIBUTE_NAME = 'data-code-inspect-loc-line';
19
+ const TAG_ATTRIBUTE_NAME = 'data-code-inspect-jsx-tag';
20
+ const COMPONENT_ATTRIBUTE_NAME = 'data-code-inspect-component';
21
+
22
+ const PLUGIN_NAME = 'code-inspect-plugin';
23
+
24
+ function getJsxName(nameNode) {
25
+ if (nameNode.type === 'JSXIdentifier') return nameNode.name;
26
+ if (nameNode.type === 'JSXMemberExpression') {
27
+ return getJsxName(nameNode.object) + '.' + nameNode.property.name;
28
+ }
29
+ return '';
30
+ }
31
+
32
+ function getCurrentComponentName(path) {
33
+ let current = path;
34
+ while (current) {
35
+ if (current.isFunctionDeclaration() && current.node.id) {
36
+ return current.node.id.name;
37
+ }
38
+ if (
39
+ current.isVariableDeclarator() &&
40
+ current.node.id &&
41
+ current.node.init
42
+ ) {
43
+ const init = current.node.init;
44
+ if (
45
+ init.type === 'ArrowFunctionExpression' ||
46
+ init.type === 'FunctionExpression'
47
+ ) {
48
+ return current.node.id.name;
49
+ }
50
+ }
51
+ if (current.isClassDeclaration() && current.node.id) {
52
+ return current.node.id.name;
53
+ }
54
+ if (
55
+ (current.isFunctionExpression() ||
56
+ current.isArrowFunctionExpression() ||
57
+ current.isCallExpression()) &&
58
+ current.parent &&
59
+ current.parent.type === 'VariableDeclarator' &&
60
+ current.parent.id
61
+ ) {
62
+ return current.parent.id.name;
63
+ }
64
+ current = current.parentPath;
65
+ }
66
+ return 'Unknown';
67
+ }
68
+
69
+ function CodeInspectPlugin(babel) {
70
+ const t = babel.types;
71
+
72
+ return {
73
+ name: PLUGIN_NAME,
74
+ visitor: {
75
+ JSXElement(path) {
76
+ const node = path.node;
77
+ const nodeName = getJsxName(node.openingElement.name);
78
+ if (!nodeName) return;
79
+ if (!node.loc) return;
80
+
81
+ const filename = path.hub.file.opts.filename || 'unknown';
82
+ const { line, column } = node.loc.start;
83
+ const componentName = getCurrentComponentName(path);
84
+
85
+ node.openingElement.attributes.push(
86
+ t.jsxAttribute(
87
+ t.jsxIdentifier(LOC_FILEPATH_ATTRIBUTE_NAME),
88
+ t.stringLiteral(filename),
89
+ ),
90
+ t.jsxAttribute(
91
+ t.jsxIdentifier(LOC_LINE_ATTRIBUTE_NAME),
92
+ t.stringLiteral(`${line}:${column}`),
93
+ ),
94
+ t.jsxAttribute(
95
+ t.jsxIdentifier(TAG_ATTRIBUTE_NAME),
96
+ t.stringLiteral(nodeName),
97
+ ),
98
+ t.jsxAttribute(
99
+ t.jsxIdentifier(COMPONENT_ATTRIBUTE_NAME),
100
+ t.stringLiteral(componentName),
101
+ ),
102
+ );
103
+ },
104
+ },
105
+ };
106
+ }
107
+
108
+ module.exports = CodeInspectPlugin;
109
+ module.exports.CodeInspectPlugin = CodeInspectPlugin;
110
+ module.exports.PLUGIN_NAME = PLUGIN_NAME;
@@ -0,0 +1,36 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <!-- 移动端视口;缩放锁定,配合 postcss-pxtorem 的 750→rem 换算 -->
6
+ <meta
7
+ name="viewport"
8
+ content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
9
+ />
10
+ <!-- rem 弹性根字号(对齐原模板 hd.ts:threshold 450 / designWidth 750)。
11
+ 在 CSS 前同步执行,避免首屏字号闪动。
12
+ - 移动端(clientWidth<=450):动态 (100/750)*clientWidth;
13
+ - PC(clientWidth>450):固定 50px(页面不随窗口缩放,内容 800px 居中、渐变底铺满)。 -->
14
+ <script>
15
+ (function () {
16
+ var THRESHOLD = 450,
17
+ DESIGN = 750;
18
+ function setRootFontSize() {
19
+ var w = document.documentElement.clientWidth;
20
+ document.documentElement.style.fontSize =
21
+ (w > THRESHOLD ? 50 : (100 / DESIGN) * w) + 'px';
22
+ }
23
+ setRootFontSize();
24
+ window.addEventListener('resize', setRootFontSize);
25
+ window.addEventListener('orientationchange', setRootFontSize);
26
+ })();
27
+ </script>
28
+ <title>AI Agent</title>
29
+ <!-- 平台微调(inspector) runtime:由 server 提供 GET /__vs_inspector__.js;dev 下经 Vite 代理到 server。-->
30
+ <script src="/__vs_inspector__.js" defer></script>
31
+ </head>
32
+ <body>
33
+ <div id="root"></div>
34
+ <script type="module" src="/src/main.tsx"></script>
35
+ </body>
36
+ </html>
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@app/client",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "AI Agent 对话 H5 客户端(React/Vite),同时跑在浏览器与支付宝小程序 web-view 壳里。",
7
+ "scripts": {
8
+ "dev": "vite",
9
+ "build": "tsc --noEmit && vite build",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@app/module-weather": "workspace:*",
14
+ "@phosphor-icons/react": "catalog:",
15
+ "@tbox.cn/app-sdk": "catalog:",
16
+ "@tbox.cn/app-contracts": "catalog:",
17
+ "antd-mobile": "catalog:",
18
+ "react": "catalog:",
19
+ "react-dom": "catalog:",
20
+ "react-markdown": "catalog:",
21
+ "remark-gfm": "catalog:",
22
+ "zustand": "catalog:"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "catalog:",
26
+ "@types/react": "catalog:",
27
+ "@types/react-dom": "catalog:",
28
+ "@vitejs/plugin-react": "catalog:",
29
+ "less": "catalog:",
30
+ "postcss-pxtorem": "catalog:",
31
+ "typescript": "catalog:",
32
+ "vite": "catalog:"
33
+ }
34
+ }
@@ -0,0 +1,5 @@
1
+ import BusinessApp from '@/business/App';
2
+
3
+ export default function App() {
4
+ return <BusinessApp />;
5
+ }
@@ -0,0 +1,11 @@
1
+ import AgentChatApp from '@/platform/AgentChatApp';
2
+ import TourismLanding from '@/examples/tourism/TourismLanding';
3
+
4
+ /**
5
+ * 业务应用唯一入口。
6
+ * 新应用替换本组件;需要对话能力时组合 AgentChatApp 和自己的业务 landing。
7
+ * @tbox-template-example-active tourism
8
+ */
9
+ export default function BusinessApp() {
10
+ return <AgentChatApp landing={TourismLanding} />;
11
+ }
@@ -0,0 +1,82 @@
1
+ # Business application entry
2
+
3
+ `App.tsx` 是业务应用的唯一入口。模板基线默认挂载文旅 landing,用于展示完整的对话首页能力。
4
+
5
+ 无论生成普通页面还是对话智能体,都必须替换默认 `TourismLanding`,最终应用只能呈现用户要求的业务。`@/examples/**` 仅供模板维护者参考,除模板基线的 `App.tsx` 外禁止导入。生成完成后运行 `pnpm verify:generated`;示例引用或标记仍存在时验收会失败。
6
+
7
+ ## 本地交互应用
8
+
9
+ 当需求当前只涉及客户端本地行为时:
10
+
11
+ 1. 直接替换 `App.tsx`。
12
+ 2. 页面、feature、业务 store 和本地规则引擎都放在当前目录。
13
+ 3. 持久化状态使用 `@/platform/state/createPersistedStore`。
14
+ 4. 起始阶段无需展开 `server/`、`adapters/`、`bridge/`、chat store。
15
+ 5. 保留根 `client/src/app.tsx`、inspector 和 Vite 配置。
16
+
17
+ 首次编辑前不需要读取根目录结构、`client/src` 目录结构、`vite.config.ts`、`index.html` 或默认对话实现。除上面的入口和 tokens 外,只按实际功能读取持久化 store、Toast 等直接依赖。
18
+
19
+ 以上是起始范围,不是永久限制。首次编辑后,直接依赖、远端契约、测试或构建结果证明需求跨层时,应进入对应所有者模块,不要在 `business/` 内重复实现平台能力。
20
+
21
+ 推荐结构:
22
+
23
+ ```text
24
+ business/
25
+ App.tsx
26
+ features/
27
+ store/
28
+ data/
29
+ ```
30
+
31
+ ## 图标视觉基线
32
+
33
+ 图标用于辅助识别,不应压过标题、数据和主要内容。生成或修改业务页面时遵守以下约束:
34
+
35
+ - 优先使用 `@phosphor-icons/react`,同一区域统一图标系列、`weight`、底板形状和用色方式。浅色底板上的普通入口默认使用 `weight="regular"`;不要为了缩小图标而统一改成 `bold`,否则线条会显得拥挤、厚重。`bold` 只用于确实需要强调的单个操作,`fill` 只用于选中态或少量主操作,不要整页铺满粗线或实心图标。
36
+ - **Phosphor 的 `size` 属性是最终屏幕 CSS 像素,不会经过 PostCSS 的 750 稿换算。** 不要按设计稿尺寸写 `size={36}`、`size={48}`。普通导航/按钮使用 `18~24`,网格入口使用 `20~24`,列表或卡片前导图标使用 `24~28`;只有真正的空状态或主视觉才能更大。
37
+ - 图标底板写 Less 的 750 稿尺寸:四宫格/八宫格入口建议 `72~80px`,列表前导图标建议 `80~96px`。内部图形的视觉宽高不超过底板的 `55%~60%`。
38
+ - 点击热区和图标视觉尺寸分开处理。按钮可以保持足够大的可点击区域,但不要靠放大图标填满热区。
39
+ - 同屏多个入口默认使用浅色底板配单色图标;强渐变和高饱和底板只留给一个主操作或少量重点入口。图标容器不要同时使用大圆角、强渐变和重阴影。
40
+ - 列表和卡片以文字、真实图片或业务数据为主体。纯图标占位不得超过列表项高度的约 `60%`,也不得挤压标题和摘要宽度。
41
+ - 尺寸和颜色通过局部 class 或变量统一管理,不要在每个 JSX 图标上散落互不一致的 `size`、渐变和圆角。
42
+
43
+ 示例:
44
+
45
+ ```tsx
46
+ <button className="feature-entry">
47
+ <span className="feature-entry__icon">
48
+ <Ticket size={22} weight="regular" />
49
+ </span>
50
+ <span>优惠券</span>
51
+ </button>
52
+ ```
53
+
54
+ ```less
55
+ .feature-entry {
56
+ min-height: 112px; // 点击热区可以大于视觉图标
57
+ }
58
+
59
+ .feature-entry__icon {
60
+ width: 76px; // 750 稿尺寸,实际约 38 CSS px
61
+ height: 76px;
62
+ border-radius: 16px;
63
+ display: flex;
64
+ align-items: center;
65
+ justify-content: center;
66
+ flex-shrink: 0;
67
+ color: var(--tbox-primary);
68
+ background: var(--tbox-primary-light);
69
+ }
70
+ ```
71
+
72
+ ## 对话智能体应用
73
+
74
+ 需要保留平台对话能力时继续使用 `AgentChatApp`。业务首屏通过 `landing` 从 `business/App.tsx` 注入,并始终位于当前会话的卡片和消息之前;发送消息后 landing 不会消失。生成欢迎卡的智能体可以不注入 landing,直接让服务端卡片成为首屏内容。
75
+
76
+ ```tsx
77
+ export default function BusinessApp() {
78
+ return <AgentChatApp landing={YourLanding} />;
79
+ }
80
+ ```
81
+
82
+ 修改对话组件、卡片或服务端工具时再读取对应目录的 `AGENTS.md`。不要修改通用 `pages/index` 来写死具体行业内容,也不要把 `src/examples/` 中的参考案例复制回运行时。
@@ -0,0 +1,10 @@
1
+ /**
2
+ * 卡片渲染入口 — 注入本地 registry 后委托 SDK 的 CardRenderer。
3
+ */
4
+ import { CardRenderer as SdkCardRenderer } from '@tbox.cn/app-sdk/client';
5
+ import type { TboxCardPayload } from '@tbox.cn/app-contracts';
6
+ import { CARD_REGISTRY } from './registry';
7
+
8
+ export function CardRenderer({ card }: { card: TboxCardPayload }) {
9
+ return <SdkCardRenderer card={card} registry={CARD_REGISTRY} />;
10
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * 卡片注册表:模块加载期快照(registerAll → ctx.cards.getAll())。
3
+ *
4
+ * 卡片注册发生在各模块 clientModule.register(ctx) 内(模块内自注册闭环),
5
+ * 本文件仅消费组装端上下文;CardType 由 apps/client/src/modules.ts 从装配数组自动推导。
6
+ * 组合层覆盖(CardOverrides)类型安全:模块删除 → CardType 收缩 → 编译报错。
7
+ */
8
+ import type { CardComponent } from '@tbox.cn/app-contracts';
9
+ import { createClientContext } from '@tbox.cn/app-sdk/client';
10
+ import { registerAll, type CardType } from '../modules';
11
+
12
+ /** 组装端单例:注册全部模块客户端能力(模块加载期执行一次;registerMap 幂等) */
13
+ const ctx = createClientContext();
14
+ registerAll(ctx);
15
+
16
+ /** 运行时卡片注册表(由各模块 clientModule.register 填充) */
17
+ export const CARD_REGISTRY = ctx.cards.getAll() as Record<CardType, CardComponent>;
18
+
19
+ export type { CardType } from '../modules';
20
+
21
+ /** 组合层覆盖:类型安全的部分覆盖(002 §7.2),模块删除时键收缩 → 编译报错 */
22
+ export type CardOverrides = Partial<Record<CardType, CardComponent>>;
@@ -0,0 +1,49 @@
1
+ import debugIcon from '../../../assets/debug.png';
2
+
3
+ /**
4
+ * 调试按钮:点击把本轮 requestId / conversationId / messageId / messageCreatedAt
5
+ * 通过 postMessage 发给父窗口,平台据此构造日志时间窗、拉取 trace 日志。
6
+ * 显示条件:dev / URL 带 ?debug=1 / 被 iframe 嵌入。
7
+ */
8
+ function shouldShow(): boolean {
9
+ if (typeof window === 'undefined') return false;
10
+ if (import.meta.env.DEV) return true;
11
+ if (new URLSearchParams(window.location.search).get('debug') === '1') return true;
12
+ try {
13
+ return window.self !== window.top;
14
+ } catch {
15
+ return true;
16
+ }
17
+ }
18
+
19
+ export function DebugButton({
20
+ requestId,
21
+ conversationId,
22
+ messageId,
23
+ messageCreatedAt,
24
+ }: {
25
+ requestId?: string;
26
+ conversationId?: string;
27
+ messageId?: string;
28
+ messageCreatedAt?: number;
29
+ }) {
30
+ if (!shouldShow() || !requestId) return null;
31
+
32
+ const onClick = () => {
33
+ try {
34
+ window.parent.postMessage(
35
+ { type: 'tbox:debug-trace', requestId, conversationId, messageId, messageCreatedAt },
36
+ '*',
37
+ );
38
+ } catch (err) {
39
+ console.warn('[DebugButton] postMessage failed:', err);
40
+ }
41
+ };
42
+
43
+ return (
44
+ <div className="tbox-debug-btn" onClick={onClick}>
45
+ <img className="tbox-debug-btn__icon" src={debugIcon} alt="" />
46
+ <span className="tbox-debug-btn__text">调试</span>
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,120 @@
1
+ .tbox-action-bar {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: 16px;
5
+ }
6
+
7
+ .tbox-action-btn {
8
+ padding: 0;
9
+ border: 0;
10
+ color: inherit;
11
+ font: inherit;
12
+ cursor: pointer;
13
+ width: 52px;
14
+ height: 52px;
15
+ display: flex;
16
+ align-items: center;
17
+ justify-content: center;
18
+ flex-shrink: 0;
19
+ border-radius: 26px;
20
+ background: #fff;
21
+ transition: background-color 0.12s ease, transform 0.12s ease;
22
+
23
+ &:active {
24
+ background: #f5f7fa;
25
+ transform: scale(0.97);
26
+ }
27
+
28
+ &--active {
29
+ background: #eef4ff;
30
+ }
31
+
32
+ &__icon-wrap {
33
+ width: 28px;
34
+ height: 28px;
35
+ display: flex;
36
+ align-items: center;
37
+ justify-content: center;
38
+ flex-shrink: 0;
39
+ }
40
+
41
+ &__icon {
42
+ width: 28px;
43
+ height: 28px;
44
+ object-fit: contain;
45
+ }
46
+ }
47
+
48
+ /* 调试按钮:独立 pill(icon + “调试” 文字) */
49
+ .tbox-debug-btn {
50
+ display: inline-flex;
51
+ align-items: center;
52
+ justify-content: center;
53
+ gap: 8px;
54
+ height: 52px;
55
+ padding: 0 22px;
56
+ border-radius: 26px;
57
+ background: #fff;
58
+ transition: background-color 0.12s ease, transform 0.12s ease;
59
+
60
+ &:active {
61
+ background: #f5f7fa;
62
+ transform: scale(0.97);
63
+ }
64
+
65
+ &__icon {
66
+ width: 28px;
67
+ height: 28px;
68
+ flex-shrink: 0;
69
+ }
70
+
71
+ &__text {
72
+ font-size: 21px;
73
+ line-height: 1;
74
+ color: #405475;
75
+ }
76
+ }
77
+
78
+ /* TTS 播放波形 */
79
+ .tbox-tts-wave {
80
+ display: flex;
81
+ align-items: center;
82
+ justify-content: center;
83
+ gap: 3px;
84
+ width: 28px;
85
+ height: 28px;
86
+
87
+ &__bar {
88
+ width: 3px;
89
+ border-radius: 999px;
90
+ background: #405475;
91
+ transform-origin: center bottom;
92
+ animation: tbox-tts-wave 0.8s ease-in-out infinite;
93
+ }
94
+ &__bar--1 {
95
+ height: 14px;
96
+ animation-delay: 0s;
97
+ }
98
+ &__bar--2 {
99
+ height: 18px;
100
+ animation-delay: 0.15s;
101
+ }
102
+ &__bar--3 {
103
+ height: 12px;
104
+ animation-delay: 0.3s;
105
+ }
106
+ }
107
+
108
+ .tbox-tts-btn--playing {
109
+ background: #eef4ff;
110
+ }
111
+
112
+ @keyframes tbox-tts-wave {
113
+ 0%,
114
+ 100% {
115
+ transform: scaleY(0.6);
116
+ }
117
+ 50% {
118
+ transform: scaleY(1);
119
+ }
120
+ }
@@ -0,0 +1,157 @@
1
+ import { useEffect, useState, useSyncExternalStore } from 'react';
2
+ import { useChatStore } from '@/store/chat';
3
+ import { useChatConfigStore } from '@/store/chat-config';
4
+ import { useFeedbackAdapter, useRefreshSession, useTTSAdapter } from '@tbox.cn/app-sdk/client';
5
+ import type { ChatMessage } from '@/types/chat';
6
+ import { copyText } from '@/utils/copyText';
7
+ import { toast } from '@/utils/toast';
8
+ import { DebugButton } from './DebugButton';
9
+ import copyIcon from '../../../assets/copy@2x.png';
10
+ import likeIcon from '../../../assets/like@2x.png';
11
+ import dislikeIcon from '../../../assets/dislike@2x.png';
12
+ import speakerIcon from '../../../assets/speaker@2x.png';
13
+ import './index.less';
14
+
15
+ /**
16
+ * 跨所有 ActionBar 实例共享的"正在朗读的消息 id"(对齐原模板 useTTS 的 _playingId)。
17
+ * 保证同一时刻只有发起朗读的那条消息按钮显示波纹/暂停态,切到另一条会自动复位前一条。
18
+ */
19
+ let _playingId: string | null = null;
20
+ const _playingIdListeners = new Set<() => void>();
21
+ const getPlayingId = () => _playingId;
22
+ function subscribePlayingId(cb: () => void) {
23
+ _playingIdListeners.add(cb);
24
+ return () => {
25
+ _playingIdListeners.delete(cb);
26
+ };
27
+ }
28
+ function setPlayingId(id: string | null) {
29
+ if (_playingId === id) return;
30
+ _playingId = id;
31
+ _playingIdListeners.forEach((cb) => cb());
32
+ }
33
+ /** play() 同步执行期间为 true,防止切歌瞬间 adapter 的 idle 事件误清 playingId */
34
+ let _isPlaying = false;
35
+
36
+ /** 助手消息操作栏(对齐原模板):朗读(TTS) + 复制 + 赞 + 踩。 */
37
+ export function getMessageCopyText(message: ChatMessage): string {
38
+ return [message.content, message.error].filter(Boolean).join('\n\n');
39
+ }
40
+
41
+ export function CopyButton({ text }: { text: string }) {
42
+ const copy = async () => {
43
+ const copied = await copyText(text);
44
+ void toast(copied ? '复制成功' : '复制失败,请重试', copied ? 'success' : 'error');
45
+ };
46
+
47
+ return (
48
+ <button type="button" className="tbox-action-btn" onClick={copy} aria-label="复制">
49
+ <span className="tbox-action-btn__icon-wrap">
50
+ <img className="tbox-action-btn__icon" src={copyIcon} alt="" />
51
+ </span>
52
+ </button>
53
+ );
54
+ }
55
+
56
+ export function ActionBar({ message }: { message: ChatMessage }) {
57
+ const feedback = useFeedbackAdapter();
58
+ const refreshSession = useRefreshSession();
59
+ const tts = useTTSAdapter();
60
+ const enableTTS = useChatConfigStore((s) => s.enableTTS);
61
+ const conversationId = useChatStore((s) => s.conversationId);
62
+ const submitFeedback = useChatStore((s) => s.submitFeedback);
63
+
64
+ const [ttsState, setTtsState] = useState<'idle' | 'loading' | 'playing' | 'paused'>('idle');
65
+ // 是否轮到本条消息:由共享 playingId 决定(而非各自的本地 state),实现跨实例互斥高亮
66
+ const playingId = useSyncExternalStore(subscribePlayingId, getPlayingId);
67
+ const isMine = playingId === message.id;
68
+ useEffect(() => {
69
+ if (!tts) return;
70
+ return (
71
+ tts.onStateChange((s) => {
72
+ setTtsState(s);
73
+ // 播放自然结束 → 清空全局播放态;_isPlaying 守卫避免切歌瞬间的 idle 误清
74
+ if (s === 'idle' && !_isPlaying) setPlayingId(null);
75
+ }) || undefined
76
+ );
77
+ }, [tts]);
78
+
79
+ const onFeedback = (type: 'like' | 'dislike') => {
80
+ if (!feedback) return;
81
+ const next = message.feedbackState === type ? 'cancel' : type;
82
+ submitFeedback(message.id, next, feedback, refreshSession);
83
+ };
84
+
85
+ const wave = isMine && (ttsState === 'loading' || ttsState === 'playing');
86
+ // 对齐原模板 TTSButton:播放/加载中点击→暂停;暂停中点击→恢复;其余→从头播放
87
+ const onTts = () => {
88
+ if (!tts) return;
89
+ if (isMine) {
90
+ if (ttsState === 'playing' || ttsState === 'loading') {
91
+ tts.pause();
92
+ return;
93
+ }
94
+ if (ttsState === 'paused') {
95
+ tts.resume();
96
+ return;
97
+ }
98
+ }
99
+ _isPlaying = true;
100
+ setPlayingId(message.id);
101
+ tts.speak(message.content, message.id).catch(() => setPlayingId(null));
102
+ _isPlaying = false;
103
+ };
104
+
105
+ // 显示依据 requestId(server 下发、不依赖 session);提交时若无 feedback 适配器则 onFeedback 内部 no-op
106
+ const canFeedback = !!message.requestId;
107
+
108
+ return (
109
+ <div className="tbox-action-bar">
110
+ {enableTTS && tts && (
111
+ <div className={`tbox-action-btn${wave ? ' tbox-tts-btn--playing' : ''}`} onClick={onTts}>
112
+ {wave ? (
113
+ <div className="tbox-tts-wave">
114
+ <div className="tbox-tts-wave__bar tbox-tts-wave__bar--1" />
115
+ <div className="tbox-tts-wave__bar tbox-tts-wave__bar--2" />
116
+ <div className="tbox-tts-wave__bar tbox-tts-wave__bar--3" />
117
+ </div>
118
+ ) : (
119
+ <div className="tbox-action-btn__icon-wrap">
120
+ <img className="tbox-action-btn__icon" src={speakerIcon} alt="朗读" />
121
+ </div>
122
+ )}
123
+ </div>
124
+ )}
125
+
126
+ <CopyButton text={getMessageCopyText(message)} />
127
+
128
+ {canFeedback && (
129
+ <>
130
+ <div
131
+ className={`tbox-action-btn${message.feedbackState === 'like' ? ' tbox-action-btn--active' : ''}`}
132
+ onClick={() => onFeedback('like')}
133
+ >
134
+ <div className="tbox-action-btn__icon-wrap">
135
+ <img className="tbox-action-btn__icon" src={likeIcon} alt="赞" />
136
+ </div>
137
+ </div>
138
+ <div
139
+ className={`tbox-action-btn${message.feedbackState === 'dislike' ? ' tbox-action-btn--active' : ''}`}
140
+ onClick={() => onFeedback('dislike')}
141
+ >
142
+ <div className="tbox-action-btn__icon-wrap">
143
+ <img className="tbox-action-btn__icon" src={dislikeIcon} alt="踩" />
144
+ </div>
145
+ </div>
146
+ </>
147
+ )}
148
+
149
+ <DebugButton
150
+ requestId={message.requestId}
151
+ conversationId={conversationId ?? undefined}
152
+ messageId={message.id}
153
+ messageCreatedAt={new Date(message.createdAt).getTime()}
154
+ />
155
+ </div>
156
+ );
157
+ }