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

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 (81) hide show
  1. package/.gitignore +6 -0
  2. package/.tbox/app.json +12 -3
  3. package/AGENTS.md +17 -6
  4. package/ENV.md +36 -5
  5. package/README.md +23 -7
  6. package/apps/client/package.json +3 -2
  7. package/apps/client/src/app.tsx +28 -0
  8. package/apps/client/src/cards/CardRenderer.tsx +4 -5
  9. package/apps/client/src/components/ActionBar/DebugButton.tsx +5 -1
  10. package/apps/client/src/components/ActionBar/index.tsx +7 -1
  11. package/apps/client/src/components/ChatInput/index.less +54 -0
  12. package/apps/client/src/components/ChatInput/index.tsx +33 -9
  13. package/apps/client/src/components/ChatInput/voice/useVoicePress.ts +20 -1
  14. package/apps/client/src/components/MessageBubble/index.less +12 -1
  15. package/apps/client/src/components/MessageBubble/index.tsx +16 -8
  16. package/apps/client/src/components/PullDownIndicator/index.less +1 -1
  17. package/apps/client/src/components/PullDownIndicator/index.tsx +3 -4
  18. package/apps/client/src/components/ToolCallBlock/index.less +29 -0
  19. package/apps/client/src/components/ToolCallBlock/index.tsx +58 -24
  20. package/apps/client/src/hooks/useMediaUpload.ts +7 -2
  21. package/apps/client/src/main.tsx +4 -1
  22. package/apps/client/src/pages/index/index.tsx +35 -13
  23. package/apps/client/src/platform/AgentChatApp.tsx +5 -1
  24. package/apps/client/src/store/chat-config.ts +12 -1
  25. package/apps/client/src/store/chat.ts +62 -92
  26. package/apps/client/vite.config.ts +46 -26
  27. package/apps/server/package.json +5 -11
  28. package/apps/server/src/app.ts +452 -0
  29. package/apps/server/src/cli/collect-registry.ts +7 -6
  30. package/apps/server/src/config/deployment.ts +36 -70
  31. package/apps/server/src/config/integration.ts +81 -0
  32. package/apps/server/src/dev.ts +81 -0
  33. package/apps/server/src/index.ts +23 -376
  34. package/apps/server/src/intent-routes.ts +11 -4
  35. package/apps/server/src/modules.ts +3 -0
  36. package/apps/server/tests/cards-registry.test.ts +9 -17
  37. package/apps/server/tests/deployment-loader.test.ts +140 -0
  38. package/apps/server/tests/intent-dispatcher.test.ts +58 -0
  39. package/apps/server/tests/tool-runtime.test.ts +97 -23
  40. package/apps/server/tsup.config.ts +15 -2
  41. package/config/chat.json +4 -1
  42. package/config/llm_provider.json +3 -1
  43. package/package.json +19 -11
  44. package/packages/contracts-mall/package.json +30 -0
  45. package/packages/contracts-mall/src/index.ts +1 -0
  46. package/packages/contracts-mall/src/runtime.ts +1 -0
  47. package/packages/contracts-mall/src/server/index.ts +1 -0
  48. package/packages/contracts-mall/tsconfig.json +7 -0
  49. package/packages/module-weather/package.json +4 -4
  50. package/packages/module-weather/skills/weather-guide/SKILL.md +3 -0
  51. package/packages/module-weather/src/server/cards/weather/meta.ts +0 -1
  52. package/packages/module-weather/src/server/index.ts +1 -0
  53. package/packages/module-weather/src/server/tool.ts +24 -24
  54. package/packages/provider-custom/AGENTS.md +33 -0
  55. package/packages/provider-custom/LICENSE +21 -0
  56. package/packages/provider-custom/README.md +8 -0
  57. package/packages/provider-custom/package.json +62 -0
  58. package/packages/provider-custom/src/index.ts +5 -0
  59. package/packages/provider-custom/src/server/index.ts +40 -0
  60. package/packages/provider-custom/tbox.module.json +29 -0
  61. package/packages/provider-custom/tests/provider-custom.test.ts +17 -0
  62. package/packages/provider-custom/tsconfig.json +7 -0
  63. package/packages/provider-custom/tsup.config.ts +18 -0
  64. package/pnpm-lock.template.yaml +6297 -0
  65. package/pnpm-workspace.template.yaml +45 -46
  66. package/scripts/build.sh +6 -12
  67. package/scripts/check-workspace.mjs +11 -10
  68. package/scripts/stop.sh +48 -14
  69. package/scripts/verify-generated.mjs +4 -4
  70. package/tsconfig.base.json +1 -0
  71. package/apps/client/babel-plugins/code-inspect.cjs +0 -110
  72. package/apps/client/src/hooks/usePullDownHistory.ts +0 -97
  73. package/apps/server/skills/shopping-guide-demo/SKILL.md +0 -21
  74. package/apps/server/skills/shopping-guide-demo/references/guide/parking.md +0 -11
  75. package/apps/server/skills/shopping-guide-demo/references/mall-directory.md +0 -17
  76. package/apps/server/skills/shopping-guide-demo/references/promotion.md +0 -10
  77. package/apps/server/src/config/feature-gate.ts +0 -17
  78. package/apps/server/tests/feature-gate.test.ts +0 -30
  79. package/config/deployments/development.json +0 -27
  80. package/scripts/check-inspector.sh +0 -91
  81. /package/packages/module-weather/{tbox.component.json → tbox.module.json} +0 -0
package/.gitignore CHANGED
@@ -2,6 +2,7 @@ node_modules
2
2
  dist
3
3
  dist-ssr
4
4
  .vite
5
+ *.tsbuildinfo
5
6
  *.local
6
7
  .DS_Store
7
8
  *.log
@@ -11,3 +12,8 @@ dist-ssr
11
12
  .env
12
13
  .env.*
13
14
  !.env.example
15
+
16
+ # 本地评测产物(tbox-app eval / start -d;报告与轨迹不进版本库)
17
+ .eval/
18
+ .tbox-server.json
19
+ .tbox-server.log
package/.tbox/app.json CHANGED
@@ -1,4 +1,13 @@
1
1
  {
2
- "templateVersion": "7.0.0",
3
- "npmModules": []
4
- }
2
+ "templateVersion": "7.1.0",
3
+ "npmModules": [
4
+ {
5
+ "id": "provider-custom",
6
+ "package": "@tbox.cn/app-provider-custom",
7
+ "version": "0.1.0",
8
+ "mode": "codegen",
9
+ "baseVersion": "0.1.0"
10
+ }
11
+ ],
12
+ "miniappContainers": []
13
+ }
package/AGENTS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # AGENTS.md
2
2
 
3
- tbox-agent-app 是多包 pnpm workspace:`apps/` 薄组装端 + `packages/` 模块。客户端同时运行在浏览器和支付宝小程序 web-view 壳中。
3
+ template-agent 是多包 pnpm workspace:`apps/` 薄组装端 + `packages/` 模块。客户端同时运行在浏览器和支付宝小程序 web-view 壳中。
4
4
 
5
5
  > **工作区上下文(发布态消费,005 v0.4.0)**:仓库内本目录是**根 workspace 成员子树**(无独立 `pnpm-workspace.yaml`),
6
6
  > 平台引用 `catalog:` + 根 `pnpm-workspace.yaml` `overrides: workspace:*` 回指根 `packages/` 真源;`pnpm` 命令在根上下文执行。
@@ -26,7 +26,7 @@ tbox-agent-app 是多包 pnpm workspace:`apps/` 薄组装端 + `packages/` 模
26
26
  `register` 内经 `ctx.cards.registerMap(cards)` 注册卡片组件。
27
27
  - `cards` map 的 **key 约定 = meta.cardType**(server 端 meta 的 cardType;doctor 会校验),无卡片模块写 `cards: {}`;
28
28
  模块对象必须用 `satisfies`(不能 `: ServerModule` 标注——标注会把 cards 泛化导致类型推导失效)。
29
- - 每个模块根目录有 `tbox.component.json`(schema v1)声明 contributes(tools/cards/handlers/routes/pages/tabs)。
29
+ - 每个模块根目录有 `tbox.module.json`(schema v1)声明 contributes(tools/cards/handlers/routes/pages/tabs)。
30
30
  跨模块写操作走 `ctx.services.register/resolve` + 类型化事件总线,业务代码只 import `@tbox.cn/app-contracts`(或领域契约包),不 import `apps/*`。
31
31
 
32
32
  ## 装配文件(双锚点,双端逐行同构)
@@ -55,7 +55,7 @@ apps/server/src/index.ts # 服务端入口(HTTP + WS + Vi
55
55
  apps/server/src/modules.ts # 服务端模块装配(registerAll)
56
56
  packages/app-sdk/src/server/chat-engine.ts # ChatEngine 工厂
57
57
  packages/app-sdk/src/server/context.ts # ServerContext(注册表聚合 + createServerContext)
58
- packages/app-sdk/src/server/build-engine.ts # createEngineFromContext / createSdkToolRuntime / buildToolCardBindings
58
+ packages/app-sdk/src/server/build-engine.ts # createEngineFromContext / createSdkToolRuntime(出卡 capture 在 engine 构造期包装,见 tool-cards.ts)
59
59
  packages/app-sdk/src/client/chat-store.ts # ChatStore 工厂
60
60
  packages/contracts/src/ # 跨端类型契约
61
61
  config/ # chat/model/plugin 配置
@@ -75,11 +75,11 @@ pnpm plugin:doctor
75
75
  pnpm plugin:inspect <pluginId> <toolId>
76
76
  ```
77
77
 
78
- 模块安装/装配由 `tbox-app` CLI(`@tbox.cn/app-cli`)管理:`create`(顶层)+ `module add --mode local|codegen|sdk --source <路径|tgz|registry包> / remove / list / sync / diff / doctor / update / publish`(模块二级组)。add/remove/sync 后需 `pnpm install`。未安装 CLI 时:`pnpm add -g @tbox.cn/app-cli`(registry 安装)。
78
+ starter 文档:`tbox-app starter --type agent`;可用类型:`tbox-app starter --list`。在沙箱中使用 `tbox-app create . --name <app-name> --source @tbox.cn/app-template-agent` 原地初始化,应用模板的 `AGENTS.md` 会覆盖 starter seed。模块安装/装配由 `tbox-app` CLI(`@tbox.cn/app-cli`)管理:`module add --mode local|codegen|sdk --source <路径|tgz|registry包> / remove / list / sync / diff / doctor / update / publish`。add/remove/sync 后需 `pnpm install`。未安装 CLI 时:`pnpm add -g @tbox.cn/app-cli`(registry 安装)。
79
79
 
80
80
  ## 平台基础设施
81
81
 
82
- 以下链路受保护,不得删除或绕过(`scripts/check-inspector.sh` 校验):
82
+ 以下链路受保护,不得删除或绕过(守卫随 `@tbox.cn/app-sdk` 分发:`pnpm check:inspector` / `tbox-inspector-check`,已入 `pnpm verify` 链):
83
83
 
84
84
  - `@tbox.cn/app-sdk/platform` 的 `installInspector`(`@infrastructure-locked`;`apps/server/src/index.ts` 从 `@tbox.cn/app-sdk/platform` import 并调用 `installInspector(app)`)
85
85
  - `apps/client/index.html` 中的 `/__vs_inspector__.js`(`RUNTIME_PATH` 约定不变)
@@ -95,7 +95,18 @@ pnpm plugin:inspect <pluginId> <toolId>
95
95
  - 平台"插件"页添加插件时只改 `config/plugins.json`。应用代码不复制插件鉴权、参数注入、runtime ID 或 SDK 客户端。
96
96
  - 插件结果出卡时按 `packages/contracts/src/cards.ts` 注册 schema,在模块包内注册 adapter 和 client 组件。
97
97
 
98
+ ## web-view 样式约定(014,POC 沉淀)
99
+
100
+ 客户端同时跑浏览器与支付宝小程序 web-view 壳(组件挂载于 H5 视口,非小程序原生渲染),样式遵守:
101
+
102
+ 1. **长度单位 PX 大写**(如 `800PX`)——web-view 内联字体放大:小写 `px` 被浏览器 rem 缩放改写,大写跳过改写保持视觉尺寸。
103
+ 2. **flex 子项设 `flex-shrink: 0`**——壳内内容宽度不定,防元素被压缩变形。
104
+ 3. **滚动容器隐藏滚动条**(`::-webkit-scrollbar { display: none }`)——web-view 无原生滚动条,出现即破相。
105
+ 4. **颜色用 `rgba()` 传统语法**——避免 `color-mix`/`oklch` 等新语法在低版本 web-view 内核不渲染。
106
+
98
107
  ## 工程边界(check-workspace 校验)
99
108
 
100
- - `packages/*` 业务包必须有 `tbox.component.json`;SDK/契约(`app-sdk` / `contracts*` / `shared`)豁免。
109
+ - `packages/*` 业务包必须有 `tbox.module.json`;SDK/契约(`app-sdk` / `contracts*` / `shared`)豁免。
110
+ - `scripts/` 下所有文件保持可执行位(git 100755;CLI create 后 chmod 兜底);双模板 scripts 文件集合镜像全等(guard-workspace R4)。
101
111
  - 装配文件不得裸 import(全部 import 行在锚区内);模块包不得 import SDK/契约内部路径(仅公开入口 `@tbox.cn/app-sdk{/server,/client,/platform}` 与 `@tbox.cn/app-contracts`)。
112
+ - 构建序列单源 = 根 `build` / `build:client` script(守卫经 `check` 组合注入);`build.sh` 只管环境 + install + 委托——新守卫改 `check` 组合,不改 build.sh;零依赖守卫恒可 install 前(`check:workspace` 已占此位)。
package/ENV.md CHANGED
@@ -4,11 +4,28 @@
4
4
 
5
5
  ## 配置方式
6
6
 
7
- - **本地开发**:shell export 注入(每次新终端需重新执行;见文末命令块)。tbox 模板仓库根提供 `env.sh.example`(git 跟踪,无密钥)——模板仓库内开发可 `cp env.sh.example env.sh` 填值;生成应用请直接 shell export 或平台配置。
7
+ - **本地开发**:shell export 注入(每次新终端需重新执行;见文末命令块)。生成应用请直接 shell export 或平台配置。
8
8
  - **生产**:建议在 tbox 平台页面上配置环境变量(平台托管 env,密钥不进代码仓库、不进 git)。
9
9
  生产部署必须 `NODE_ENV=production`(dev-login 强制关闭 + JWT 密钥链 fail-fast 的依据)。
10
+ 入口分离后 dist 恒静态供给(vite dev 仅存在于源码 `src/dev.ts` 本地开发入口);`NODE_ENV` 不再决定
11
+ 客户端供给形态——测试环境可用 `node dist/index.js`(不设 NODE_ENV)获得「静态产物 + dev-login」。
10
12
  - 以下变量缺失时服务可启动,但对话/会话/上传链路会 503。
11
13
 
14
+ ## 配置文件:config/chat.json(会话 UI 设置)
15
+
16
+ 六字段(`quickCommands`/`enableImageUpload`/`enableVideoUpload`/`enableTTS`/`enableVoiceInput`/
17
+ `defaultInputMode`)由 server 经 `loadChatConfig` 读取(zod fail-soft),WS HELLO chatConfig
18
+ **服务端运行时下发单一真源**:`quickCommands` 映射 query→prompt,其余五键经 `settings` 下发 →
19
+ client 水合覆盖构建期快照;`/ws/tts` 通道门同源(`enableTTS:false` 时 upgrade 硬拒)。
20
+ dev 态 `TBOX_CONFIG_DIR` 可指档位目录替换默认 `config/`。**改 chat.json 需重启 server,新连接生效**
21
+ (client 构建期静态 import 仅作 ack 未达/键缺席的回落快照)。
22
+
23
+ ## 配置文件:config/agent.jsonc(agent 提示词)
24
+
25
+ `instructions` 业务段行数组(首行身份句)+ `memoryInstructions` 可选(装配长期记忆时由 SDK 追加)。
26
+ 未配置时 SDK 默认提示词兜底;解析失败 ERROR 日志 + 兜底。改文件需重启 server。(本模板出厂不携带
27
+ 该文件——即 SDK 默认形态)
28
+
12
29
  ## 必需
13
30
 
14
31
  ### 模型服务(OpenAI 兼容接口)
@@ -17,7 +34,9 @@
17
34
  |---|---|
18
35
  | `APP_AI_BASE_URL` | 模型服务地址(OpenAI 兼容 `/v1`) |
19
36
  | `APP_AI_API_KEY` | API 密钥 |
20
- | `APP_AI_MODEL_NAME` | 模型名称;可留空:`config/llm_provider.json` 的 `modelName` 优先 |
37
+ | `APP_AI_MODEL_NAME` | 模型名称;可留空:`config/llm_provider.json` 的 `modelName` 优先。同文件 `thinking` 字段控制模型思考,优先级高于下方 env 开关 |
38
+ | `APP_AI_THINKING` | 模型思考开关 `true`/`false`/`auto`,缺省 `false`(代码级默认——删除配置文件/字段≠开启思考);`config/llm_provider.json` 的 `thinking` 字段优先级更高,且额外支持 object 形态(字面量合并进请求体顶层,可覆盖 `model`/`messages` 等键——慎用);`"auto"` 为不注入逃生舱;每请求热读,改配置即生效无需重启 |
39
+ | `APP_AI_PARALLEL_TOOL_CALLS` | 并行工具调用 `true`/`false`/`auto`,缺省 `auto`(不注入,走网关按模型默认);`config/llm_provider.json` 的 `parallelToolCalls` 字段优先级更高;每请求热读,改配置即生效无需重启 |
21
40
 
22
41
  ### tbox 平台凭证
23
42
 
@@ -25,16 +44,26 @@
25
44
  |---|---|
26
45
  | `TBOX_API_KEY` | 平台 API Key:插件直接使用;语音/上传/反馈/TTS 靠它换 session。缺失时插件不加载,`GET /api/tbox/session` 返回 503 → 前端平台能力适配器建不起来 |
27
46
  | `TBOX_APP_ID` | 应用 ID |
47
+ | `TBOX_MEMORY_KB_ID` | 长期记忆知识库 ID(`TBOX_MEMORY_STORE=knowledge` 时与 `TBOX_MEMORY_KB_DOC_ID`/平台凭证三缺一回退 inmemory;SDK `bootstrapMemoryKnowledgeBase()` 幂等建库产出固化(生成应用内 `node -e` 调用或联系平台)) |
48
+ | `TBOX_MEMORY_KB_DOC_ID` | 记忆知识库文档 ID(同上必填;addSegment/容量淘汰通道入参) |
49
+ | `TBOX_MEMORY_KB_BASE_URL` | 知识库通道地址(缺省 https://o.tbox.cn;预发联调 https://agtgw-pre.alipay.com) |
50
+ | `TBOX_MEMORY_KB_COOKIE` | 预发访问管控 Cookie 透传(仅预发联调;生产不设——配置会触发 warn。多应用同租户共用知识库时勿共用缺省 KB 名,建库脚本已按 appId 命名) |
51
+ | `TBOX_MEMORY_KB_VALIDATE` | knowledge 启动校验开关(`=1` 异步校验——不阻塞启动;KB 回读不符/超时 → 记忆功能降级:调用时报错+HTTP 503,防 kbId 误配静默击穿跨用户隔离且不拖垮系统;`strict` 同步阻塞校验,不符即启动失败——零窗口合规场景;未设置不校验。仅 knowledge 三件套齐备时生效;生产启用建议 `=1`) |
52
+ | `TBOX_MEMORY_KB_VALIDATE_TIMEOUT_MS` | 校验整体超时毫秒数(缺省 10000;`>=1` 才生效,非法回退缺省。超时按校验失败定性——降级态修复 KB 配置后重启恢复) |
53
+ | `TBOX_MEMORY_RECALL_TIMEOUT_MS` | recall 整体 deadline 毫秒数(缺省 200;`0` = 禁用召回注入。B4 起 withRecallCache 装饰器缺省装配——零记忆用户负缓存短路 recall <10ms,真查询单 RTT 约 150-300ms,超时降级跳过不拖首字) |
54
+ | `TBOX_LLM_AFFINITY` | 会话亲和大容量旋钮(缺省 on;`=off` 关闭——fetch 头 `X-Affinity-Session` 与 body `prompt_cache_key` 双通道均不注入。best-effort 语义:网关负载可覆盖亲和;排障时若网关对未知 body 字段报错可关) |
55
+ | `TBOX_LLM_CONN_BIND` | per-session 连接绑定旋钮(缺省 on;`=off` 关闭——LLM fetch 回退全局连接池。连接=网关缓存池路由单位:per-session 单连接(120s keep-alive)钉死集群分配,保活跨 run 提升前缀缓存命中;排障时可关) |
56
+ | `TBOX_LLM_COOKIE_AFFINITY` | 网关 cookie 亲和旋钮(缺省 on;`=off` 关闭——不回传网关 set-cookie。回传激活 LB 粘性:连接重建后恢复原集群的通道;best-effort) |
28
57
 
29
58
  ## 统一鉴权(006)
30
59
 
31
60
  | 变量 | 说明 |
32
61
  |---|---|
33
- | `AUTH_TOKEN_SECRET` | 自有 JWT 密钥(≥32 字节随机串),生产由平台注入。未配置回退:`TBOX_API_KEY`(须 ≥32 字节)→ dev 临时随机;prod 整链空则 fail-fast(拒绝启动)。本地一般无需设置 |
62
+ | `AUTH_TOKEN_SECRET` | 自有 JWT 密钥(≥32 字节随机串),生产由平台注入。006 鉴权与 011 模块动作授权(action-grant)共用统一密钥链:未配置回退 `TBOX_API_KEY`(须 ≥32 字节)→ dev 临时随机;prod 整链空则 fail-fast(拒绝启动)。011 另有 `ACTION_TOKEN_SECRET` 覆盖档。本地一般无需设置 |
34
63
  | `AUTH_MODE` | `required`(默认,缺失 token 一律 401)/ `optional`(缺失 token → 匿名,写通道仍拒绝) |
35
64
  | `AUTH_DEV_LOGIN_DISABLED` | 非生产环境需关闭 dev-login 时设 `true`(dev-login 非 production 默认开启,生产强制关闭) |
36
65
  | `AUTH_TOKEN_TTL_MS` | token 有效期(毫秒,默认 2h) |
37
- | `AUTH_TOKEN_ISSUER` | token issuer(默认 `tbox-agent-app`) |
66
+ | `AUTH_TOKEN_ISSUER` | token issuer(默认 `agent`) |
38
67
  | `AUTH_EMBED_EXTERNAL_TOKEN` | 是否把外部原始 token 内嵌进自有 JWT(external claim,默认 `true`;`false` 则上游调用需另取) |
39
68
  | `AUTH_EXTERNAL_TOKEN_MAX_BYTES` | external 原始 token 上限(bytes,默认 16KB=Node http header 上限)。external claim 使自有 JWT 膨胀 ~1.4x,生产网关 header 上限需 ≥16KB |
40
69
  | `AUTH_EXTERNAL_EXCHANGE_URL` | 外部 provider 换发地址;配置后 `/api/auth/login(platform=external)` 走 HMAC 转发换发 |
@@ -62,6 +91,8 @@
62
91
  | `TTS_AUTH_URL` / `TTS_APPKEY` | TTS 鉴权必需:AUTH_URL 为鉴权网关端点(POST `{appkey}` → `timestamp+digest`);APPKEY 为 TTS 应用密钥。**缺失时 TTS 播放直接 CONFIGURATION_ERROR(无「走平台」回退)**,前端报 `[TTS] server error` |
63
92
  | `TBOX_SKILL_MODE` | 本地 Skill 模式(009):`accelerated`(默认,本地路由命中 → SOP 注入当前轮 user 消息,零额外 LLM 轮次)/ `progressive`(全量注册 InlineSkill,模型渐进 skill_read)。非法值回退默认 |
64
93
  | `TBOX_PRE_TOOL_TIMEOUT_MS` | 前置工具(010)单工具总预算 ms(decide+execute 共享):默认 `2000`;`0` = 整体禁用(预执行全关,行为与未启用逐字节一致)。非法值回退默认 |
94
+ | `TBOX_TOOL_DEBUG` | 工具调试视图(018):`1` 开 / `0` 关;缺省 = 非 `NODE_ENV=production` 开启。开启时 memory 召回/silent 前置工具以伪工具事件下发前端(不持久化)且 HELLO_ACK 携带旗标解锁客户端 dev 工具视图(出入参展开+紧凑格式)。**生产勿开**——测试环境开启会使 silent 检索 query 与用户记忆预览(截断 200 字符)对页面访问者可见。非法值回退缺省 |
95
+ | `TBOX_EARLY_TOOL_EXECUTION` | 尽早工具执行(缺省 `off`):`1`/`true` 开启。模型流中白名单工具(只读查询类,`withEarlyExecution` 标记)参数流完即预执行,多工具轮延迟优化;收益依赖 `parallelToolCalls` 开启;参数漂移自动作废重执行(正确性无损);单工具轮行为等价现状。仅显式 opt-in,观测 journal `tool_preexec` |
65
96
  | `HOST` | 服务监听地址(默认 `0.0.0.0`) |
66
97
  | `PORT` | 服务监听端口(默认 `8000`) |
67
98
 
@@ -75,5 +106,5 @@ export TBOX_API_KEY=...
75
106
  export TBOX_APP_ID=...
76
107
  # TTS(可选;缺失时 TTS 播放报 CONFIGURATION_ERROR):
77
108
  export TTS_AUTH_URL=https://open.tbox.alipay.com/agent/v1/homepage/secret/get
78
- export TTS_APPKEY=your-tts-appkey # 敏感值勿提交;本地经 shell export 或 env.sh(gitignore)注入
109
+ export TTS_APPKEY=your-tts-appkey # 敏感值勿提交;本地 shell export 注入(生产由平台注入)
79
110
  ```
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # tbox-agent-app
1
+ # template-agent
2
2
 
3
3
  tbox AI Agent 全栈模板(npm 包名 `@tbox.cn/app-template-agent`),多包 pnpm workspace:`apps/` 薄组装端 + `packages/` 模块。
4
4
  客户端同时支持浏览器和支付宝小程序 web-view;平台能力(鉴权/会话/TTS/ASR/上传/反馈)经 `@tbox.cn/app-sdk` 统一提供(007 下沉)。
@@ -14,11 +14,11 @@ pnpm verify # typecheck + test + build
14
14
  > 环境变量:本地开发用 shell export 注入(见 `ENV.md`,含必需变量命令块;每次新终端需重新执行),
15
15
  > 生产建议在 tbox 平台页面上配置(平台托管 env,密钥不进代码仓库)。
16
16
 
17
- > 平台包消费(005 v0.4.0 发布态):生成应用以 `catalog:` 引用 `@tbox.cn/{app-sdk,contracts,contracts-mall}`——
17
+ > 平台包消费(005 v0.4.0 发布态):生成应用初始通过 `@app/contracts-mall` shim 使用 `catalog:` 引用的发布包;添加依赖 contracts-mall 的 codegen 模块后,CLI 将 shim 替换为本地源码包——
18
18
  > 生产(registry 已发布)直接 `pnpm install`;平台包未发布 npm 时,由 `tbox-app create --local-deps <平台 tgz 目录>`
19
19
  > 注入 overrides(`file:` 本地发布态产物)后 install 离线可用。
20
20
  >
21
- > 依赖链说明(007):`@tbox.cn/app-sdk` 新增 dependencies `@tbox-dev-js/sdk`(平台能力客户端)、`@ai-sdk/openai`(LLM)——
21
+ > 依赖链说明(007):`@tbox.cn/app-sdk` 新增 dependencies `@tbox-dev-js/sdk`(平台能力客户端)、`@ai-sdk/openai-compatible`(LLM)——
22
22
  > 生成应用 install 时由 pnpm 自动解析(`@tbox-dev-js/sdk` 已发布 npm;内网/离线环境需 registry 镜像或 store 缓存兜底)。
23
23
 
24
24
  > 🔐 鉴权(006):非 production 默认开启 dev-login(浏览器 H5 测试登录,开箱即用);
@@ -28,7 +28,7 @@ pnpm verify # typecheck + test + build
28
28
  ## 工程结构
29
29
 
30
30
  ```text
31
- tbox-agent-app/
31
+ template-agent/
32
32
  apps/
33
33
  client/ React/Vite 装配端(业务页面/组件/hooks/样式;平台能力经 @tbox.cn/app-sdk/client)
34
34
  server/ Express + WS 装配端(~120 行装配:auth 中间件 + 模块挂载 + 平台路由面/TTS/WS)
@@ -85,7 +85,7 @@ const skills = Object.fromEntries(
85
85
  export const serverModule = { cards, skills, register(ctx) { /* ... */ } } satisfies ServerModule;
86
86
  ```
87
87
 
88
- 底层能力(SDK `loadModuleSkills(packageName, import.meta.url)`)**单策略**(createRequire 包名解析)定位模块包根,**dev / build dist / 发布态路径恒等**:以调用文件为基准解析 `${packageName}/package.json`(workspace link / 模块 dist / 应用 tsup bundle dist / node_modules 全形态命中)。前置:模块 exports 须含 `"./package.json"`。加载失败降级 `[]` + 可行动 warn(提示 exports 键/安装问题,不阻断启动)。生成应用内(codegen 展开)源码调用名由 CLI 自动改写为本地装配包名(`@app/<id>`),模块源码按字面量包名写法即可。
88
+ 底层能力(SDK `loadModuleSkills(packageName, import.meta.url)`)**单策略**(createRequire 包名解析)定位模块包根,**dev / build dist / 发布态路径恒等**:以调用文件为基准解析 `${packageName}/package.json`(workspace link / 模块 dist / 应用 tsup bundle dist / node_modules 全形态命中)。前置:模块 exports 须含 `"./package.json"`。加载失败降级 `[]` + 可行动 warn(提示 exports 键/安装问题,不阻断启动)。生成应用内业务模块 codegen 源码调用名由 CLI 自动改写为本地装配包名(`@app/<id>`);contracts-mall 作为平台 codegen 依赖时,模块 import 与依赖键由同一归一化器改写为 `@app/contracts-mall`。
89
89
 
90
90
  inline 声明(`serverModule.skills` 直接内联 instructions/references)仍是发布态模块的轻量选择(无文件系统依赖),两种形态可并存。
91
91
 
@@ -118,7 +118,7 @@ definePreTool(weatherTool, {
118
118
  });
119
119
  ```
120
120
 
121
- 铁律:仅只读/幂等工具(`requireApproval`/交互式工具不参与);decide 不得抛(SDK 兜底 warn + skip);args 必须 JSON 可序列化(坏形状/非法参数 → warn + skip);身份经入参 `identity` 获取(per-run 并发安全,**不用 `ctx.auth`**);`always` 路由每轮进入 decide(慎用,防每轮噪音);框架层**无跨轮去重**(同参可重入,执行策略完全由 decide 工程化自控)。
121
+ 铁律:仅只读/幂等工具(`requireApproval`/交互式工具不参与);decide 不得抛(SDK 兜底 warn + skip);args 必须 JSON 可序列化(坏形状/非法参数 → warn + skip);身份经入参 `identity`、领域属性经入参 `attributes` 获取(per-run 并发安全——显式入参是唯一通道,`ctx.auth` 全局单例已拆除);`always` 路由每轮进入 decide(慎用,防每轮噪音);框架层**无跨轮去重**(同参可重入,执行策略完全由 decide 工程化自控)。
122
122
 
123
123
  ### 行为
124
124
 
@@ -149,13 +149,29 @@ definePreTool(weatherTool, {
149
149
  # ⑥"杭州天气怎么样"后再问"那纽约呢"(词表外城市) → decide 不命中 → 模型正常调用 getWeather(city=纽约)(无合成工具消息,跨轮不再泛化「已调用过」)
150
150
  ```
151
151
 
152
+ ## 深链自动发送
153
+
154
+ 打开页面时 URL 携带 `?query=...`(或 `?prompt=...` 兜底,`query` 优先)即自动发送该消息一次,
155
+ 与手动输入同通道。参数读取后立即从地址栏剥离(replaceState):刷新不重发、分享不带参;
156
+ socket 5 秒内未就绪则静默放弃。SDK 单点实现(`@tbox.cn/app-sdk/client` 的
157
+ `consumeAutoSendQuery`/`runAutoSend`),模板侧仅 3 行接线(IndexPage init effect)。
158
+
159
+ ### 冒烟
160
+
161
+ ```sh
162
+ # 浏览器访问 http://127.0.0.1:8000/?query=今天天气怎么样
163
+ # 预期:自动发送一次;地址栏 query 消失(其余参数保留);刷新不重发;dev 模式(StrictMode)不双发
164
+ ```
165
+
152
166
  ## 平台能力定制(007 扩展点)
153
167
 
154
168
  平台轴(安全管道/能力管道/协议客户端)已下沉 `@tbox.cn/app-sdk`,模板保留 3 个注入点 + 模板源码扩展:
155
169
 
156
170
  | 口子 | 位置 | 场景 |
157
171
  |---|---|---|
158
- | `createAuthRuntime(readAuthConfig(), { exchanges })` | `@tbox.cn/app-sdk/server` | 自定义/覆盖登录 provider(注入优先于内置 alipay/weapp) |
172
+ | `ctx.authExchanges.register(...)` | `@tbox.cn/app-sdk/server` | 自定义登录换码器(F2 注册制:scoped 谓词路由 / 无条件兜底,机制见 docs/reference/auth.md;静态 `exchanges` 注入仍兼容) |
173
+ | `getToolRequestContext` / `requireToolRequestContext` | `@tbox.cn/app-sdk/server` | 工具 execute 第二参读取运行上下文(fail-soft / 缺失抛 AUTH_FORBIDDEN;上下文统一化) |
174
+ | `createToolExecContext(reqCtx)` | `@tbox.cn/app-sdk/server` | 测试/直调助手:构造工具 execute 第二参(散键与引擎同构) |
159
175
  | `createPlatformAdapters(session, overrides)` | `@tbox.cn/app-sdk/client` | 自定义 ASR/TTS/upload/feedback 实现(contracts 接口,UI 零改动) |
160
176
  | `createAuthClient({ loginPlatform, devUser })` | `@tbox.cn/app-sdk/client` | 客户端登录配置(`main.tsx`/`platform/agent-runtime.ts`);**应用级单例**——重复调用幂等返回首建实例,首建选项生效 |
161
177
  | `configureRuntime({ apiBaseUrl })` | `@tbox.cn/app-sdk/client` | API/WS 地址注入(`main.tsx`;对应 `VITE_API_URL`) |
@@ -19,7 +19,8 @@
19
19
  "react-dom": "catalog:",
20
20
  "react-markdown": "catalog:",
21
21
  "remark-gfm": "catalog:",
22
- "zustand": "catalog:"
22
+ "zustand": "catalog:",
23
+ "@app/provider-custom": "workspace:*"
23
24
  },
24
25
  "devDependencies": {
25
26
  "@types/node": "catalog:",
@@ -31,4 +32,4 @@
31
32
  "typescript": "catalog:",
32
33
  "vite": "catalog:"
33
34
  }
34
- }
35
+ }
@@ -1,5 +1,33 @@
1
+ import { lazy, Suspense } from 'react';
1
2
  import BusinessApp from '@/business/App';
2
3
 
4
+ // dev 卡片预览页(/_tbox/card-preview):
5
+ // - 守卫用**三元**而非 if——`import.meta.env.DEV` 在生产构建被静态替换为 false,
6
+ // lazy 分支成为可摇除死代码,动态 import 与页面 chunk 一并消失(若用顶层 if,
7
+ // lazy() 调用本身活过 treeshaking,chunk 仍会产出);
8
+ // - lazy 双动态 import 组装(SDK 页面 + 模板 registry/authClient 单例注入——
9
+ // authClient 含模板选项,不经预览页自建)。
10
+ const CARD_PREVIEW_PATH = '/_tbox/card-preview';
11
+ const DevCardPreview = import.meta.env.DEV
12
+ ? lazy(async () => {
13
+ const [{ CardPreviewPage }, { CARD_REGISTRY }, { authClient }] = await Promise.all([
14
+ import('@tbox.cn/app-sdk/client/card-preview'),
15
+ import('@/cards/registry'),
16
+ import('@/platform/agent-runtime'),
17
+ ]);
18
+ return {
19
+ default: () => <CardPreviewPage registry={CARD_REGISTRY} authClient={authClient} />,
20
+ };
21
+ })
22
+ : null;
23
+
3
24
  export default function App() {
25
+ if (import.meta.env.DEV && DevCardPreview && window.location.pathname === CARD_PREVIEW_PATH) {
26
+ return (
27
+ <Suspense fallback={<div>加载卡片预览…</div>}>
28
+ <DevCardPreview />
29
+ </Suspense>
30
+ );
31
+ }
4
32
  return <BusinessApp />;
5
33
  }
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * 卡片渲染入口 — 注入本地 registry 后委托 SDK 的 CardRenderer。
3
- * 011 B3:动作按钮 → sendUiAction componentAction(token 来自 payload.actionTokens)。
3
+ * 011 B3:动作按钮 → sendUiAction(token 来自 payload.actionTokens)。
4
+ * P1:onAction 升级为完整 UiAction(CardRenderer 按 kind 分发后回传,桥一行透传)。
4
5
  */
5
6
  import { CardRenderer as SdkCardRenderer } from '@tbox.cn/app-sdk/client';
6
- import type { TboxCardPayload } from '@tbox.cn/app-contracts';
7
+ import type { TboxCardPayload, UiAction } from '@tbox.cn/app-contracts';
7
8
  import { CARD_REGISTRY } from './registry';
8
9
  import { useChatStore } from '@/store/chat';
9
10
 
@@ -13,9 +14,7 @@ export function CardRenderer({ card }: { card: TboxCardPayload }) {
13
14
  <SdkCardRenderer
14
15
  card={card}
15
16
  registry={CARD_REGISTRY}
16
- onAction={(actionId, contextToken) =>
17
- sendUiAction(card.id, { type: 'componentAction', actionId, contextToken })
18
- }
17
+ onAction={(action: UiAction) => sendUiAction(card.id, action)}
19
18
  />
20
19
  );
21
20
  }
@@ -1,3 +1,7 @@
1
+ import { createLogger } from '@tbox.cn/app-sdk/client';
2
+
3
+ const debugLogger = createLogger('DebugButton');
4
+
1
5
  import debugIcon from '../../../assets/debug.png';
2
6
 
3
7
  /**
@@ -36,7 +40,7 @@ export function DebugButton({
36
40
  '*',
37
41
  );
38
42
  } catch (err) {
39
- console.warn('[DebugButton] postMessage failed:', err);
43
+ debugLogger.warn('postMessage failed:', err);
40
44
  }
41
45
  };
42
46
 
@@ -1,4 +1,7 @@
1
1
  import { useEffect, useState, useSyncExternalStore } from 'react';
2
+ import { createLogger } from '@tbox.cn/app-sdk/client';
3
+
4
+ const ttsActionLogger = createLogger('tts');
2
5
  import { useChatStore } from '@/store/chat';
3
6
  import { useChatConfigStore } from '@/store/chat-config';
4
7
  import { useFeedbackAdapter, useRefreshSession, useTTSAdapter } from '@tbox.cn/app-sdk/client';
@@ -98,7 +101,10 @@ export function ActionBar({ message }: { message: ChatMessage }) {
98
101
  }
99
102
  _isPlaying = true;
100
103
  setPlayingId(message.id);
101
- tts.speak(message.content, message.id).catch(() => setPlayingId(null));
104
+ tts.speak(message.content, message.id).catch((err: unknown) => {
105
+ ttsActionLogger.warn(`tts.speak 失败:err=${(err as Error)?.message ?? String(err)}`);
106
+ setPlayingId(null);
107
+ });
102
108
  _isPlaying = false;
103
109
  };
104
110
 
@@ -1,3 +1,11 @@
1
+ .tbox-voice-page-lock,
2
+ .tbox-voice-page-lock * {
3
+ user-select: none !important;
4
+ -webkit-user-select: none !important;
5
+ -webkit-touch-callout: none !important;
6
+ -webkit-user-drag: none !important;
7
+ }
8
+
1
9
  .tbox-chat-input {
2
10
  position: relative;
3
11
  z-index: 1;
@@ -12,6 +20,36 @@
12
20
  display: flex;
13
21
  align-items: flex-end;
14
22
  gap: 16px;
23
+ flex-wrap: wrap;
24
+ }
25
+
26
+ /* 连接终态阻断提示条(禁用输入 + 重试入口;配色沿用既有错误语义,不硬编码新色值) */
27
+ &__blocked {
28
+ flex-basis: 100%;
29
+ display: flex;
30
+ align-items: center;
31
+ gap: 12px;
32
+ padding: 12px 20px;
33
+ border-radius: 12px;
34
+ background: rgba(255, 241, 241, 0.9);
35
+ color: #d5492f;
36
+ font-size: 24px;
37
+ line-height: 1.4;
38
+
39
+ &-msg {
40
+ flex: 1;
41
+ min-width: 0;
42
+ }
43
+
44
+ &-retry {
45
+ flex-shrink: 0;
46
+ padding: 6px 20px;
47
+ border: none;
48
+ border-radius: 999px;
49
+ background: #d5492f;
50
+ color: #fff;
51
+ font-size: 22px;
52
+ }
15
53
  }
16
54
 
17
55
  /* 半透明白色药丸 */
@@ -44,6 +82,11 @@
44
82
  &::placeholder {
45
83
  color: rgba(16, 41, 83, 0.6);
46
84
  }
85
+
86
+ /* 连接终态阻断(登录失败/scope 拒绝):禁用态弱化输入框 */
87
+ &:disabled {
88
+ opacity: 0.45;
89
+ }
47
90
  }
48
91
 
49
92
  /* 发送 / 停止(图片图标) */
@@ -212,6 +255,9 @@
212
255
  box-sizing: border-box;
213
256
  overflow: hidden;
214
257
  user-select: none;
258
+ -webkit-user-select: none;
259
+ -webkit-touch-callout: none;
260
+ touch-action: none;
215
261
 
216
262
  &--disabled {
217
263
  opacity: 0.35;
@@ -249,6 +295,10 @@
249
295
  position: fixed;
250
296
  inset: 0;
251
297
  z-index: 300;
298
+ user-select: none;
299
+ -webkit-user-select: none;
300
+ -webkit-touch-callout: none;
301
+ touch-action: none;
252
302
 
253
303
  &__mask {
254
304
  position: absolute;
@@ -264,6 +314,9 @@
264
314
  justify-content: center;
265
315
  padding: 0 24px calc(56px + env(safe-area-inset-bottom, 16px));
266
316
  box-sizing: border-box;
317
+ user-select: none;
318
+ -webkit-user-select: none;
319
+ touch-action: none;
267
320
  }
268
321
 
269
322
  &__panel {
@@ -328,5 +381,6 @@
328
381
  background-image: url('https://mdn.alipayobjects.com/huamei_frp8ut/afts/img/A*5PDvR4j7fBgAAAAAVBAAAAgAet-KAQ/original');
329
382
  background-size: 100% 100%;
330
383
  background-repeat: no-repeat;
384
+ pointer-events: none;
331
385
  }
332
386
  }
@@ -27,6 +27,12 @@ const OPTION_ICONS: Record<PickKind, { icon: string; label: string }> = {
27
27
 
28
28
  const MENU_ANIMATION_MS = 250;
29
29
 
30
+ /** 连接终态阻断(登录失败/scope 拒绝):提示条 + 输入禁用 + 可选重试入口 */
31
+ export interface ChatBlocked {
32
+ message: string;
33
+ onRetry?: () => void;
34
+ }
35
+
30
36
  /** 媒体上传默认约束(分类型数量 + 单文件大小)。如需放开/收紧按业务改这里。 */
31
37
  const DEFAULT_MEDIA_CONFIG = {
32
38
  image: { maxCount: 9, maxSize: 10 * 1024 * 1024 },
@@ -38,12 +44,14 @@ const MEDIA_MAX_TOTAL = 9;
38
44
  /** 对话输入框(对齐原模板):媒体预览 + 新对话 + 药丸[输入框/按住说话 + 语音切换 + 发送/“+”] + 动画选取菜单 + 免责声明。 */
39
45
  export function ChatInput({
40
46
  generating,
47
+ blocked,
41
48
  onSend,
42
49
  onCancel,
43
50
  onNewChat,
44
51
  disclaimer = '以上内容由AI生成,请谨慎甄别。',
45
52
  }: {
46
53
  generating?: boolean;
54
+ blocked?: ChatBlocked;
47
55
  onSend: (msg: ChatInputMessage) => void;
48
56
  onCancel?: () => void;
49
57
  onNewChat?: () => void;
@@ -64,14 +72,16 @@ export function ChatInput({
64
72
  const enableVideo = useChatConfigStore((s) => s.enableVideoUpload);
65
73
  const enableVoice = useChatConfigStore((s) => s.enableVoiceInput);
66
74
  const defaultInputMode = useChatConfigStore((s) => s.defaultInputMode);
67
- const [inputMode, setInputMode] = useState<'text' | 'voice'>(
68
- defaultInputMode === 'VOICE' && enableVoice ? 'voice' : 'text',
69
- );
75
+ // 派生模式(禁 useState 捕获初值——水合晚到时服务端 defaultInputMode 进不了已捕获 state):
76
+ // userMode=null 跟随默认(随水合重渲染更新);显式值 = 用户手动切换优先
77
+ const [userMode, setUserMode] = useState<'text' | 'voice' | null>(null);
78
+ const inputMode: 'text' | 'voice' =
79
+ !enableVoice ? 'text' : userMode ?? (defaultInputMode === 'VOICE' ? 'voice' : 'text');
70
80
  const [menuVisible, setMenuVisible] = useState(false);
71
81
  const [menuAnimating, setMenuAnimating] = useState(false);
72
82
 
73
83
  const voice = useVoicePress({
74
- disabled: generating,
84
+ disabled: generating || !!blocked,
75
85
  asr,
76
86
  onResult: (t) => onSend({ text: t, mediaItems: [], inputMethod: 'voice' }),
77
87
  });
@@ -128,7 +138,7 @@ export function ChatInput({
128
138
 
129
139
  useEffect(() => () => closeTimerRef.current && clearTimeout(closeTimerRef.current), []);
130
140
 
131
- const canSend = !!(text.trim() || items.length > 0) && !uploading && inputMode === 'text';
141
+ const canSend = !!(text.trim() || items.length > 0) && !uploading && inputMode === 'text' && !blocked;
132
142
 
133
143
  const submit = () => {
134
144
  if (!canSend) return;
@@ -144,7 +154,10 @@ export function ChatInput({
144
154
  e.target.value = '';
145
155
  };
146
156
 
147
- const toggleMode = () => setInputMode((m) => (m === 'text' ? 'voice' : 'text'));
157
+ const toggleMode = () => {
158
+ if (blocked) return;
159
+ setUserMode(inputMode === 'text' ? 'voice' : 'text');
160
+ };
148
161
 
149
162
  return (
150
163
  <div className="tbox-chat-input">
@@ -174,7 +187,7 @@ export function ChatInput({
174
187
 
175
188
  {inputMode === 'voice' ? (
176
189
  <VoicePressField
177
- disabled={generating}
190
+ disabled={generating || !!blocked}
178
191
  interactionState={voice.interactionState}
179
192
  handlers={voice.handlers}
180
193
  />
@@ -182,8 +195,9 @@ export function ChatInput({
182
195
  <textarea
183
196
  className="tbox-chat-input__field"
184
197
  value={text}
185
- placeholder="有什么需要问我的吗"
198
+ placeholder={blocked?.message ?? '有什么需要问我的吗'}
186
199
  rows={1}
200
+ disabled={!!blocked}
187
201
  onChange={(e) => setText(e.target.value)}
188
202
  onKeyDown={(e) => {
189
203
  if (e.key === 'Enter' && !e.shiftKey) {
@@ -204,7 +218,7 @@ export function ChatInput({
204
218
  </div>
205
219
  )}
206
220
 
207
- {generating ? (
221
+ {blocked ? null : generating ? (
208
222
  <div className="tbox-chat-input__send tbox-chat-input__send--active" onClick={onCancel}>
209
223
  <img className="tbox-chat-input__send-img" src={stopIcon} alt="停止" />
210
224
  </div>
@@ -223,6 +237,16 @@ export function ChatInput({
223
237
  </div>
224
238
  ) : null}
225
239
  </div>
240
+ {blocked && (
241
+ <div className="tbox-chat-input__blocked">
242
+ <span className="tbox-chat-input__blocked-msg">{blocked.message}</span>
243
+ {blocked.onRetry && (
244
+ <button type="button" className="tbox-chat-input__blocked-retry" onClick={blocked.onRetry}>
245
+ 重试
246
+ </button>
247
+ )}
248
+ </div>
249
+ )}
226
250
  </div>
227
251
 
228
252
  {menuVisible && (
@@ -1,4 +1,4 @@
1
- import { useCallback, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import type { IASRAdapter } from '@tbox.cn/app-sdk/client';
3
3
  import { useRefreshSession } from '@tbox.cn/app-sdk/client';
4
4
  import { isSessionExpiredError } from '@/utils/session-error';
@@ -81,6 +81,8 @@ export function useVoicePress({
81
81
  const onTouchStart = useCallback(
82
82
  async (e: React.TouchEvent) => {
83
83
  if (disabled || !asr || recordingRef.current) return;
84
+ // 长按录音由应用接管,避免移动端浏览器启动原生选择/拖拽手势。
85
+ e.preventDefault();
84
86
  startYRef.current = e.touches[0]?.clientY ?? 0;
85
87
  setState('pressing');
86
88
 
@@ -133,6 +135,7 @@ export function useVoicePress({
133
135
  const onTouchMove = useCallback(
134
136
  (e: React.TouchEvent) => {
135
137
  if (!recordingRef.current) return;
138
+ e.preventDefault();
136
139
  const y = e.touches[0]?.clientY ?? 0;
137
140
  setState(getMoveInteractionState(startYRef.current, y));
138
141
  },
@@ -184,5 +187,21 @@ export function useVoicePress({
184
187
  (interactionState === 'recording-active' || interactionState === 'recording-cancel' || interactionState === 'pressing');
185
188
  const countdown = counting && duration >= MAX_DURATION - COUNTDOWN_START ? MAX_DURATION - duration : null;
186
189
 
190
+ useEffect(() => {
191
+ const locked = interactionState !== 'idle';
192
+ document.body.classList.toggle('tbox-voice-page-lock', locked);
193
+ if (!locked) return undefined;
194
+ const preventNativeGesture = (event: Event) => event.preventDefault();
195
+ document.addEventListener('selectstart', preventNativeGesture);
196
+ document.addEventListener('dragstart', preventNativeGesture);
197
+ document.addEventListener('contextmenu', preventNativeGesture);
198
+ return () => {
199
+ document.body.classList.remove('tbox-voice-page-lock');
200
+ document.removeEventListener('selectstart', preventNativeGesture);
201
+ document.removeEventListener('dragstart', preventNativeGesture);
202
+ document.removeEventListener('contextmenu', preventNativeGesture);
203
+ };
204
+ }, [interactionState]);
205
+
187
206
  return { interactionState, overlayVisible: interactionState !== 'idle', countdown, handlers };
188
207
  }