@tbox.cn/app-template-agent 0.1.0 → 0.2.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 (32) hide show
  1. package/ENV.md +2 -0
  2. package/README.md +110 -0
  3. package/apps/client/src/cards/CardRenderer.tsx +12 -1
  4. package/apps/client/src/components/QuickCommands/index.tsx +9 -2
  5. package/apps/client/src/components/ToolCallBlock/index.less +11 -0
  6. package/apps/client/src/components/ToolCallBlock/index.tsx +38 -10
  7. package/apps/client/src/store/chat.ts +35 -4
  8. package/apps/server/package.json +1 -0
  9. package/apps/server/skills/shopping-guide-demo/SKILL.md +21 -0
  10. package/apps/server/skills/shopping-guide-demo/references/guide/parking.md +11 -0
  11. package/apps/server/skills/shopping-guide-demo/references/mall-directory.md +17 -0
  12. package/apps/server/skills/shopping-guide-demo/references/promotion.md +10 -0
  13. package/apps/server/src/app-tools.ts +13 -0
  14. package/apps/server/src/cli/collect-registry.ts +4 -3
  15. package/apps/server/src/config/deployment.ts +81 -0
  16. package/apps/server/src/config/feature-gate.ts +17 -0
  17. package/apps/server/src/index.ts +170 -12
  18. package/apps/server/src/modules.ts +9 -5
  19. package/apps/server/src/pre-tools.ts +25 -0
  20. package/apps/server/src/tools/clock.ts +24 -0
  21. package/apps/server/src/tools/translate.ts +10 -4
  22. package/apps/server/tests/feature-gate.test.ts +30 -0
  23. package/apps/server/tests/tool-runtime.test.ts +28 -0
  24. package/config/deployments/development.json +27 -0
  25. package/config/llm_provider.json +1 -1
  26. package/package.json +1 -1
  27. package/packages/module-weather/package.json +2 -1
  28. package/packages/module-weather/skills/weather-guide/SKILL.md +19 -0
  29. package/packages/module-weather/skills/weather-guide/references/dressing-tips.md +5 -0
  30. package/packages/module-weather/src/server/index.ts +26 -1
  31. package/packages/module-weather/src/server/pre-tools.ts +20 -0
  32. package/pnpm-workspace.template.yaml +5 -4
package/ENV.md CHANGED
@@ -60,6 +60,8 @@
60
60
  | `ALIPAY_APP_ID` / `ALIPAY_APP_PRIVATE_KEY` | 支付宝小程序 Auth(login code 换 openId;按需)。私钥为支付宝应用私钥 RSA2 PEM |
61
61
  | `WX_APP_ID` / `WX_APP_SECRET` | weapp 换码登录(本地 provider,按需) |
62
62
  | `TTS_AUTH_URL` / `TTS_APPKEY` | TTS 鉴权必需:AUTH_URL 为鉴权网关端点(POST `{appkey}` → `timestamp+digest`);APPKEY 为 TTS 应用密钥。**缺失时 TTS 播放直接 CONFIGURATION_ERROR(无「走平台」回退)**,前端报 `[TTS] server error` |
63
+ | `TBOX_SKILL_MODE` | 本地 Skill 模式(009):`accelerated`(默认,本地路由命中 → SOP 注入当前轮 user 消息,零额外 LLM 轮次)/ `progressive`(全量注册 InlineSkill,模型渐进 skill_read)。非法值回退默认 |
64
+ | `TBOX_PRE_TOOL_TIMEOUT_MS` | 前置工具(010)单工具总预算 ms(decide+execute 共享):默认 `2000`;`0` = 整体禁用(预执行全关,行为与未启用逐字节一致)。非法值回退默认 |
63
65
  | `HOST` | 服务监听地址(默认 `0.0.0.0`) |
64
66
  | `PORT` | 服务监听端口(默认 `8000`) |
65
67
 
package/README.md CHANGED
@@ -32,6 +32,7 @@ tbox-agent-app/
32
32
  apps/
33
33
  client/ React/Vite 装配端(业务页面/组件/hooks/样式;平台能力经 @tbox.cn/app-sdk/client)
34
34
  server/ Express + WS 装配端(~120 行装配:auth 中间件 + 模块挂载 + 平台路由面/TTS/WS)
35
+ server/skills/ 本地 Skill(SKILL.md 目录格式,随模板复制/pack)
35
36
  packages/
36
37
  module-weather/ 示例业务模块(双端单包)
37
38
  config/ chat/model/plugin 配置
@@ -39,6 +40,115 @@ tbox-agent-app/
39
40
  pnpm-workspace.template.yaml 应用 workspace 模板(create 生成应用 pnpm-workspace.yaml 的来源)
40
41
  ```
41
42
 
43
+ ## 本地 Skill(009)
44
+
45
+ 应用与模块可为 Agent 提供受控技能(SOP + 参考资料),支持双模式(全局配置,运行期恒定):
46
+
47
+ | 模式 | 环境变量 | 行为 | 适用 |
48
+ |---|---|---|---|
49
+ | **accelerated(默认)** | `TBOX_SKILL_MODE=accelerated` | 本地打分路由(零 LLM)命中 → `<system-reminder skill="...">SOP+references</system-reminder>` 注入**当前轮 user 消息前部**;同 skill 会话内最多一次;system prompt 与工具列表恒定 → 前缀缓存稳定、零 skill 轮次 | 大多数业务场景 |
50
+ | **progressive** | `TBOX_SKILL_MODE=progressive` | Agent 全量注册 InlineSkill,模型按需 skill_read 渐进读取(mastra 原生) | 需要模型自主挑选技能的复杂场景 |
51
+
52
+ ### 应用级 Skill(目录格式)
53
+
54
+ ```text
55
+ apps/server/skills/<skill-name>/
56
+ ├── SKILL.md # frontmatter(name/description/metadata.routingTerms)+ SOP 正文
57
+ └── references/ # 可选,递归 *.md(随 SKILL.md 内存化加载,运行期零文件系统暴露)
58
+ ├── a.md
59
+ └── sub/b.md
60
+ ```
61
+
62
+ 模板已内置 `shopping-guide` 示例(含子目录 references)。新增步骤:建目录 → 写 SKILL.md(`name` 须匹配 `^[a-z0-9-]{1,64}$`)→ references 可选 → 启动加载(无需改代码)。
63
+
64
+ ### 模块级 Skill(文件加载,推荐示例)
65
+
66
+ 模块包内放 `skills/` 目录(与 `src/` 平级),顶层 await 加载后填回 `serverModule.skills` 声明式通道(装配端 `registerAll` 自动注册,register 内零代码):
67
+
68
+ ```text
69
+ packages/module-weather/
70
+ ├── skills/weather-guide/
71
+ │ ├── SKILL.md # frontmatter(name/description/metadata.routingTerms)+ SOP
72
+ │ └── references/dressing-tips.md
73
+ ├── src/server/index.ts
74
+ └── package.json # exports 须含 "./package.json": "./package.json"
75
+ ```
76
+
77
+ ```ts
78
+ // packages/module-weather/src/server/index.ts
79
+ import { loadModuleSkills } from '@tbox.cn/app-sdk/server';
80
+
81
+ const skills = Object.fromEntries(
82
+ (await loadModuleSkills('@app/module-weather', import.meta.url)).map((s) => [s.name, s]),
83
+ ) satisfies Record<string, LocalSkill>;
84
+
85
+ export const serverModule = { cards, skills, register(ctx) { /* ... */ } } satisfies ServerModule;
86
+ ```
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>`),模块源码按字面量包名写法即可。
89
+
90
+ inline 声明(`serverModule.skills` 直接内联 instructions/references)仍是发布态模块的轻量选择(无文件系统依赖),两种形态可并存。
91
+
92
+ ### 冒烟
93
+
94
+ ```sh
95
+ # 启动日志会打印装配结果:mode + skill 名列表(不含内容)
96
+ [SKILL] mode=accelerated skills=weather-guide,shopping-guide
97
+ # 提问触发词(accelerated):"今天天气怎么样"(weather-guide → getWeather 工具+卡片)/ "3楼有什么"(shopping-guide → SOP)
98
+ # 切换模式:TBOX_SKILL_MODE=progressive
99
+ ```
100
+
101
+ ## 前置 Tool 加速执行(010)
102
+
103
+ 对「参数可工程化获取、不依赖模型抽参」的**只读工具**,模型请求前先执行、结果注入上下文——正式回答首字从「模型①生成 tool-call → 工具执行 → 模型②作答」缩短为「工具执行 → 模型①直接作答」,**每个预执行工具省一整轮 loop**。
104
+
105
+ ### 三步声明法
106
+
107
+ ```ts
108
+ // 模块:serverModule.preTools(key = tool.id;装配端自动注册,register 内零代码)
109
+ // 应用:BuildEngineOptions.preTools(apps/server/src/pre-tools.ts)
110
+ // 统一经 definePreTool(tool, spec) 构造——tool 实例引用 = 单一真源(id/description 派生)
111
+ import { definePreTool } from '@tbox.cn/app-sdk/server';
112
+ definePreTool(weatherTool, {
113
+ routing: { mode: 'keywords', terms: ['天气', '气温'] }, // ② 结构化路由:词表打分 / { mode:'always' } 必选
114
+ decide: ({ text, identity }) => { // ③ 决策即构造:run(args) = 预执行 / skip = 不生效
115
+ const city = CITIES.find((c) => text.includes(c));
116
+ return city ? { decision: 'run', args: { city } } : { decision: 'skip' };
117
+ },
118
+ });
119
+ ```
120
+
121
+ 铁律:仅只读/幂等工具(`requireApproval`/交互式工具不参与);decide 不得抛(SDK 兜底 warn + skip);args 必须 JSON 可序列化(坏形状/非法参数 → warn + skip);身份经入参 `identity` 获取(per-run 并发安全,**不用 `ctx.auth`**);`always` 路由每轮进入 decide(慎用,防每轮噪音);框架层**无跨轮去重**(同参可重入,执行策略完全由 decide 工程化自控)。
122
+
123
+ ### 行为
124
+
125
+ - **注入**:每预执行工具一个 `<system-reminder pre-tool="...">` 数据块(`<input>`/`<output>` 内嵌 JSON,user 消息前部;**无合成工具消息**——模型不会把结果误判为「已调用过该工具」,可跨轮正常再调用);server-only(不进事件/落库/历史恢复)。
126
+ - **前端**:thinking 气泡内实时显示「自动查询 xx」徽标(执行前即出现 spinner)→「已自动获取 xx」+ 可展开结果详情;刷新后历史消息同样可见。
127
+ - **失败降级**:decide skip / 执行失败 / 超时 → 该工具按普通 LLM 工具路径走,不影响本轮回答;意图路由命中 → 零预执行。
128
+ - **预算**:单工具 decide+execute 共享 `TBOX_PRE_TOOL_TIMEOUT_MS`(默认 2000ms;`0` = 整体禁用);单轮最多 3 个工具并行预执行。
129
+
130
+ ### 模板演示
131
+
132
+ | 示例 | 位置 | 形态 |
133
+ |---|---|---|
134
+ | `getWeather` | `packages/module-weather/src/server/pre-tools.ts` | 文本抽取(城市词表,抽不到 skip)——与 weather-guide skill SOP 共存,单轮完成 |
135
+ | `getCurrentTime` | `apps/server/src/pre-tools.ts` + `tools/clock.ts` | 零参工程化(词表命中即执行) |
136
+ | `queryPoints` | `modules/module-member`(`tbox-app module add` 后) | `always` 必选 + 身份门控(identity 取 userId,匿名 skip) |
137
+ | `translate` | 反例(不改代码) | 参数依赖语义抽取,不适配——保持 LLM 工具路径 |
138
+
139
+ ### 冒烟
140
+
141
+ ```sh
142
+ # 启动日志:timeout + 预执行工具清单
143
+ [PRETOOL] timeoutMs=2000 preTools=getCurrentTime,getWeather
144
+ # ①"杭州天气怎么样" → thinking 气泡出现「自动查询 查询城市天气信息」(label = 工具自身 description 派生)→ 直接作答(单轮 1 次模型请求,无 getWeather 二次调用)
145
+ # ②"现在几点了" → 「自动查询 查询当前日期和时间」→ 作答(零参形态)
146
+ # ③"帮我翻译 hello" → 无「自动查询」徽标(反例:正常 LLM 工具路径)
147
+ # ④ TBOX_PRE_TOOL_TIMEOUT_MS=0 复测① → 回退为模型调用工具两轮(对比基准)
148
+ # ⑤ 刷新页面 → 历史消息仍显示「已自动获取」徽标(inputs.preTools 重建)
149
+ # ⑥"杭州天气怎么样"后再问"那纽约呢"(词表外城市) → decide 不命中 → 模型正常调用 getWeather(city=纽约)(无合成工具消息,跨轮不再泛化「已调用过」)
150
+ ```
151
+
42
152
  ## 平台能力定制(007 扩展点)
43
153
 
44
154
  平台轴(安全管道/能力管道/协议客户端)已下沉 `@tbox.cn/app-sdk`,模板保留 3 个注入点 + 模板源码扩展:
@@ -1,10 +1,21 @@
1
1
  /**
2
2
  * 卡片渲染入口 — 注入本地 registry 后委托 SDK 的 CardRenderer。
3
+ * 011 B3:动作按钮 → sendUiAction componentAction(token 来自 payload.actionTokens)。
3
4
  */
4
5
  import { CardRenderer as SdkCardRenderer } from '@tbox.cn/app-sdk/client';
5
6
  import type { TboxCardPayload } from '@tbox.cn/app-contracts';
6
7
  import { CARD_REGISTRY } from './registry';
8
+ import { useChatStore } from '@/store/chat';
7
9
 
8
10
  export function CardRenderer({ card }: { card: TboxCardPayload }) {
9
- return <SdkCardRenderer card={card} registry={CARD_REGISTRY} />;
11
+ const sendUiAction = useChatStore((s) => s.sendUiAction);
12
+ return (
13
+ <SdkCardRenderer
14
+ card={card}
15
+ registry={CARD_REGISTRY}
16
+ onAction={(actionId, contextToken) =>
17
+ sendUiAction(card.id, { type: 'componentAction', actionId, contextToken })
18
+ }
19
+ />
20
+ );
10
21
  }
@@ -1,19 +1,26 @@
1
1
  // ⚠️ 平台标准组件:输入框上方的「快捷指令」。视觉还原时不改本组件、不另画。
2
2
  // 改内容只改 client/config/chat.json 的 quickCommands 数组({ label, query, icon? })。
3
+ // 011 I2:服务端 HelloAck.chatConfig.quickCommands 优先(单一真源),本地 chat.json 为 fallback。
3
4
  // label 只写纯文字;前面的星标由本组件渲染(icon 字段或默认 ✦),不要把 ✦/emoji 写进 label。
4
5
  import { useChatConfigStore } from '@/store/chat-config';
6
+ import { useChatStore } from '@/store/chat';
5
7
  import starIcon from '../../../assets/quick-command-star.svg';
6
8
  import './index.less';
7
9
 
8
10
  export function QuickCommands({ onCommand }: { onCommand: (query: string) => void }) {
9
- const commands = useChatConfigStore((s) => s.quickCommands);
11
+ const staticCommands = useChatConfigStore((s) => s.quickCommands);
12
+ const serverCommands = useChatStore((s) => s.helloAck?.chatConfig.quickCommands);
13
+ // 服务端下发优先;未收到 HELLO_ACK(非 mall 部署)回落本地静态
14
+ const commands = (serverCommands && serverCommands.length > 0
15
+ ? serverCommands.map((c) => ({ label: c.label, query: c.prompt }))
16
+ : staticCommands) as { label: string; query: string }[];
10
17
  if (!commands.length) return null;
11
18
 
12
19
  return (
13
20
  <div className="quick-commands">
14
21
  {commands.map((cmd) => (
15
22
  <button key={cmd.label} className="quick-commands__item" onClick={() => onCommand(cmd.query)}>
16
- <img className="quick-commands__icon" src={cmd.icon || starIcon} alt="" />
23
+ <img className="quick-commands__icon" src={starIcon} alt="" />
17
24
  <span className="quick-commands__label">{cmd.label}</span>
18
25
  </button>
19
26
  ))}
@@ -43,6 +43,17 @@
43
43
  animation: tool-spin 0.8s linear infinite;
44
44
  }
45
45
 
46
+ // 010:前置工具「自动」徽标(与模型主动调用区分)
47
+ &__badge {
48
+ font-size: 18px;
49
+ line-height: 1;
50
+ padding: 3px 8px;
51
+ border-radius: 8px;
52
+ background: rgba(93, 126, 198, 0.12);
53
+ color: #5f7ec6;
54
+ flex-shrink: 0;
55
+ }
56
+
46
57
  &__name {
47
58
  font-size: 22px;
48
59
  line-height: 1.4;
@@ -1,22 +1,51 @@
1
1
  import { useState } from 'react';
2
2
  import { Check, X } from '@phosphor-icons/react';
3
3
  import type { ToolCallInfo } from '@/types/chat';
4
+ import { describeToolCall } from '@tbox.cn/app-sdk/client';
4
5
  import './index.less';
5
6
 
6
- /** 工具调用状态卡片(调用中 spinner / 完成 / 失败,可展开详情)。 */
7
+ /**
8
+ * 工具调用状态卡片(调用中 spinner / 完成 / 失败,可展开详情)。
9
+ * 010:展示语义判定下沉 SDK describeToolCall(skill/preTool/normal 三分支),
10
+ * 本组件退化为纯渲染(徽标/颜色/展开交互)。
11
+ */
7
12
  export default function ToolCallBlock({ toolCalls }: { toolCalls: ToolCallInfo[] }) {
8
13
  const [expandedId, setExpandedId] = useState<string | null>(null);
9
14
 
10
15
  return (
11
16
  <div className="tool-calls">
12
17
  {toolCalls.map((tc) => {
18
+ const display = describeToolCall(tc);
13
19
  const isCalling = tc.status === 'calling';
14
- const isError = tc.status === 'error';
15
- const isDone = tc.status === 'done';
20
+ const isError = tc.status === 'error' || (display.kind === 'preTool' && display.status === 'error');
21
+ const isDone = !isCalling && !isError;
16
22
  const isExpanded = expandedId === tc.id;
17
- const hasDetail = !!(tc.result || tc.error);
23
+ const hasDetail = display.kind !== 'skill' && display.hasDetail;
18
24
  const stateClass = isError ? 'tool-call--error' : isCalling ? 'tool-call--calling' : 'tool-call--done';
19
25
 
26
+ const displayName = (() => {
27
+ if (display.kind === 'skill') {
28
+ return isCalling ? `加载技能 ${display.label}` : `已加载技能 ${display.label}`;
29
+ }
30
+ if (display.kind === 'preTool') {
31
+ if (display.status === 'calling') return `自动查询 ${display.label}`;
32
+ if (display.status === 'error') return `自动查询失败 ${display.label}`;
33
+ return `已自动获取 ${display.label}`;
34
+ }
35
+ return isCalling
36
+ ? `调用 ${display.label}`
37
+ : isDone
38
+ ? `${display.label} 已完成`
39
+ : `${display.label} 调用失败`;
40
+ })();
41
+
42
+ const detailText =
43
+ display.kind === 'preTool'
44
+ ? (display.status === 'error' ? display.errorText : display.resultText) ?? ''
45
+ : isError
46
+ ? tc.error ?? ''
47
+ : tc.result ?? '';
48
+
20
49
  return (
21
50
  <div key={tc.id} className={`tool-call ${stateClass}`}>
22
51
  <div
@@ -27,11 +56,10 @@ export default function ToolCallBlock({ toolCalls }: { toolCalls: ToolCallInfo[]
27
56
  {isCalling && <div className="tool-call__spinner" />}
28
57
  {isDone && <Check size={16} color="#10a37f" strokeWidth={2.5} />}
29
58
  {isError && <X size={16} color="#ef4444" strokeWidth={2.5} />}
30
- <span className="tool-call__name">
31
- {isCalling && `调用 ${tc.name}`}
32
- {isDone && `${tc.name} 已完成`}
33
- {isError && `${tc.name} 调用失败`}
34
- </span>
59
+ <span className="tool-call__name">{displayName}</span>
60
+ {display.kind === 'preTool' && (
61
+ <span className="tool-call__badge">自动</span>
62
+ )}
35
63
  </div>
36
64
  {hasDetail && (
37
65
  <span className={`tool-call__toggle${isExpanded ? ' tool-call__toggle--up' : ''}`}>
@@ -42,7 +70,7 @@ export default function ToolCallBlock({ toolCalls }: { toolCalls: ToolCallInfo[]
42
70
  {isExpanded && hasDetail && (
43
71
  <div className="tool-call__result">
44
72
  <span className={`tool-call__result-text${isError ? ' tool-call__result-text--error' : ''}`}>
45
- {formatResult(isError ? tc.error! : tc.result!)}
73
+ {formatResult(detailText)}
46
74
  </span>
47
75
  </div>
48
76
  )}
@@ -4,9 +4,12 @@
4
4
  * 共用同一份 set/get,避免双 store 断裂。
5
5
  */
6
6
  import { create } from 'zustand';
7
- import { createCoreChatState, getWsUrl } from '@tbox.cn/app-sdk/client';
7
+ import { createCoreChatState, getWsUrl, configureHelloContext, configureWsScopeErrorHandler } from '@tbox.cn/app-sdk/client';
8
8
  import type { CoreChatState } from '@tbox.cn/app-sdk/client';
9
+ import { resolveHistoryMessage, buildHistoryToolCalls } from '@tbox.cn/app-sdk/client';
9
10
  import type { CardSnapshot, TboxCardPayload } from '@tbox.cn/app-contracts';
11
+ import type { SkillRecord } from '@tbox.cn/app-contracts';
12
+ import type { PreToolRecord } from '@tbox.cn/app-contracts';
10
13
  import type { ChatMessage } from '@/types/chat';
11
14
  import type { IFeedbackAdapter } from '@tbox.cn/app-sdk/client';
12
15
  import type { AdapterContextValue } from '@tbox.cn/app-sdk/client';
@@ -24,6 +27,10 @@ interface HistoryMessageInfo {
24
27
  multiModalInputs?: Record<string, unknown>;
25
28
  /** P3 W1:服务端已恢复(migrate + isHistory=true)的卡片快照 */
26
29
  cards?: CardSnapshot[];
30
+ /** 009 D10:服务端已恢复的 skill 加载记录(inputs.skills) */
31
+ skills?: SkillRecord[];
32
+ /** 010:服务端已恢复的前置工具记录(inputs.preTools) */
33
+ preTools?: PreToolRecord[];
27
34
  }
28
35
 
29
36
  function convertHistoryMessages(messages: HistoryMessageInfo[]): ChatMessage[] {
@@ -31,11 +38,14 @@ function convertHistoryMessages(messages: HistoryMessageInfo[]): ChatMessage[] {
31
38
  const nextId = (prefix: string) => `${prefix}_${Date.now()}_${idSeq++}`;
32
39
  const result: ChatMessage[] = [];
33
40
  for (const info of messages) {
41
+ // 009 D10 + 010:skill/preTool 双表达组合(B1 结构化;skill B2 query 解析补齐)
42
+ // —— 010 下沉 S4:解析与组合规则在 SDK,模板只消费组合入口
43
+ const resolved = resolveHistoryMessage(info);
34
44
  if (info.query) {
35
45
  const userMsg: ChatMessage = {
36
46
  id: `history-user-${info.messageId}`,
37
47
  role: 'user',
38
- content: info.query,
48
+ content: resolved.content,
39
49
  status: 'done',
40
50
  createdAt: info.createTime || Date.now(),
41
51
  ...(info.outerBusinessId && { requestId: info.outerBusinessId }),
@@ -58,6 +68,15 @@ function convertHistoryMessages(messages: HistoryMessageInfo[]): ChatMessage[] {
58
68
  status: 'done',
59
69
  createdAt: info.createTime || Date.now(),
60
70
  ...(info.outerBusinessId && { requestId: info.outerBusinessId }),
71
+ // 009 D10 + 010:skill/preTool 加载标记挂在 user 消息后的 assistant 消息(ToolCallBlock 渲染)
72
+ ...(resolved.skills.length > 0 || resolved.preTools.length > 0
73
+ ? {
74
+ toolCalls: buildHistoryToolCalls(
75
+ resolved,
76
+ info.messageId ?? nextId('history-skill'),
77
+ ),
78
+ }
79
+ : {}),
61
80
  });
62
81
  }
63
82
  }
@@ -84,8 +103,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
84
103
  { getWsUrl: () => getWsUrl(), toast },
85
104
  set as (partial: any) => void,
86
105
  get as () => any,
87
- ),
88
- // 业务扩展状态
106
+ ), // 业务扩展状态
89
107
  historyMessages: [],
90
108
  historyHasMore: true,
91
109
  isLoadingHistory: false,
@@ -199,3 +217,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
199
217
  }
200
218
  },
201
219
  }));
220
+
221
+ // 011 L4:HELLO context 采集——mallId 经 URL query 采集(mall 部署;无 mallId 时不携带 context)
222
+ configureHelloContext(() => {
223
+ const mallId = new URLSearchParams(window.location.search).get('mallId');
224
+ const miniAppId = new URLSearchParams(window.location.search).get('miniAppId');
225
+ if (!mallId) return undefined;
226
+ return { mall: { mallId, ...(miniAppId ? { miniAppId } : {}) } };
227
+ });
228
+
229
+ // 011 评审 7.2:4002 scope 拒绝 → 停止重连 + 轻提示(L4「切 mall = 新会话 + 轻提示」UI 端接线)
230
+ configureWsScopeErrorHandler((message) => {
231
+ toast(message);
232
+ });
@@ -16,6 +16,7 @@
16
16
  "@tbox.cn/agent-plugin": "catalog:",
17
17
  "@tbox.cn/app-sdk": "catalog:",
18
18
  "@tbox.cn/app-contracts": "catalog:",
19
+ "@tbox.cn/app-contracts-mall": "catalog:",
19
20
  "@tbox-dev-js/sdk": "catalog:",
20
21
  "ai": "catalog:",
21
22
  "cors": "catalog:",
@@ -0,0 +1,21 @@
1
+ ---
2
+ name: shopping-guide-demo
3
+ description: 商圈导购:找店、找商品、楼层导航、优惠活动、停车指引
4
+ metadata:
5
+ routingTerms: [找店, 导购, 楼层, 导航, 优惠, 活动, 停车, 商场, 逛]
6
+ ---
7
+ # 商圈导购 SOP
8
+
9
+ ## 适用意图
10
+ 用户询问商场内找店、找商品、楼层分布、优惠活动、停车导航等(如"哪里有运动品牌"、"3 楼有什么吃的"、"最近有什么活动"、"车停哪里")时,遵循本规范回答。非商场相关提问不要套用本规范。
11
+
12
+ ## 回答组织方式
13
+ 1. 先判断用户意图归属(找店 / 优惠活动 / 停车导航),命中后从对应 reference 查找资料;
14
+ 2. 回答按"结论先行"组织:先给出店铺/楼层/活动/车位位置,再补充营业时间、规则等细节;
15
+ 3. 信息缺失时如实说明"资料未收录",不要编造楼层或活动信息;
16
+ 4. 涉及楼层请使用「1F/2F/B1/B2」格式,停车场使用「B1/B2」格式。
17
+
18
+ ## 全局约束
19
+ - 只回答与商场导购相关的内容;用户问及其他领域时正常转回通用助手;
20
+ - 禁止编造未收录的店铺、活动或停车信息;
21
+ - 回答保持简洁,一次回答聚焦一个问题(复合问题先回答优先级最高的意图)。
@@ -0,0 +1,11 @@
1
+ # 停车导航 SOP
2
+
3
+ ## 停车场信息
4
+ - 停车场:B2 层(含机械车位),B1 层为临时卸货区(不对外停车);
5
+ - 入口:商场东侧入口(导航定位「东门停车场入口」);
6
+ - 收费标准:首 2 小时免费,之后每小时 5 元,当日封顶 40 元。
7
+
8
+ ## 停车指引
9
+ 1. 用户问「停车/车位/车停哪」→ 告知 B2 停车场与东侧入口;
10
+ 2. 问「停车优惠」→ 参考优惠活动中的「观影送停车券」;
11
+ 3. 问「充电桩」→ 告知 B2 东区设有新能源充电桩(数量有限,先到先得)。
@@ -0,0 +1,17 @@
1
+ # 楼层与店铺分布
2
+
3
+ ## 楼层总览
4
+ | 楼层 | 业态 | 代表店铺 |
5
+ |---|---|---|
6
+ | B2 | 停车场 | — |
7
+ | B1 | 餐饮 + 超市 | 烘焙工坊、精品超市、美食广场 |
8
+ | 1F | 美妆 + 珠宝 | 美妆集合店、金饰专柜 |
9
+ | 2F | 服饰 | 运动品牌集合店、女装、男装 |
10
+ | 3F | 亲子 + 家居 | 儿童乐园、母婴、家居生活馆 |
11
+ | 4F | 影院 + 餐饮 | 电影院、火锅、日料 |
12
+
13
+ ## 找店指引
14
+ - 问「运动品牌/鞋子」→ 2F 运动品牌集合店;
15
+ - 问「美妆/护肤」→ 1F 美妆集合店;
16
+ - 问「吃饭/餐厅」→ B1 美食广场(快餐)与 4F(正餐);
17
+ - 问「超市」→ B1 精品超市。
@@ -0,0 +1,10 @@
1
+ # 优惠活动
2
+
3
+ ## 当前活动(模板示例数据)
4
+ - 周末会员日:全场服饰 8 折(2F),每周末;
5
+ - 餐饮满 100 减 20:B1 与 4F 指定商户,需会员积分抵扣;
6
+ - 观影送停车券:凭 4F 影院当日电影票根,可兑换 B2 停车 2 小时免费。
7
+
8
+ ## 活动规则
9
+ - 活动以商户现场公示为准,本资料为示例数据;
10
+ - 用户问「有什么活动/优惠」→ 按上述条目逐一列出,并给出楼层位置。
@@ -0,0 +1,13 @@
1
+ import type { ServerContext } from '@tbox.cn/app-sdk/server';
2
+ import { translateTool } from './tools/translate.js';
3
+ import { clockTool } from './tools/clock.js';
4
+
5
+ /**
6
+ * apps 级演示工具统一注册面(010:双注册面同步):
7
+ * start()(运行时)与 cli/collect-registry.ts(doctor/inspect 视图)两处同调本函数,
8
+ * 未来新增 apps 级工具只改此处,避免两处注册面失同步(R 评审修正)。
9
+ */
10
+ export function registerAppTools(ctx: ServerContext): void {
11
+ ctx.tools.register(translateTool);
12
+ ctx.tools.register(clockTool);
13
+ }
@@ -1,6 +1,6 @@
1
1
  import { createServerContext, buildToolCardBindings } from '@tbox.cn/app-sdk/server';
2
2
  import { registerAll } from '../modules.js';
3
- import { translateTool } from '../tools/translate.js';
3
+ import { registerAppTools } from '../app-tools.js';
4
4
 
5
5
  export interface RegistryState {
6
6
  staticToolIds: string[];
@@ -11,12 +11,13 @@ export interface RegistryState {
11
11
  /**
12
12
  * 从自注册闭环收集当前注册状态(静态工具 id / cardType / tool→card 绑定)。
13
13
  * 供 plugin-doctor / plugin-inspect 复用——替代已删除的手写 tools/registry.ts + cards/registry.ts。
14
- * 与运行时(apps/server/src/index.ts start())保持一致的注册面:registerAll + 模板演示工具 translate。
14
+ * 与运行时(apps/server/src/index.ts start())保持一致的注册面:registerAll + registerAppTools
15
+ * (010:apps 级演示工具统一注册面,双处同调防失同步)。
15
16
  */
16
17
  export async function collectRegistryState(): Promise<RegistryState> {
17
18
  const ctx = createServerContext();
18
19
  await registerAll(ctx);
19
- ctx.tools.register(translateTool); // 与运行时一致(index.ts 中 translate 为 apps 级演示工具)
20
+ registerAppTools(ctx); // 与运行时一致(translate + clock 为 apps 级演示工具)
20
21
  const cards = ctx.cards.getAll();
21
22
  return {
22
23
  staticToolIds: Object.keys(ctx.tools.getAll()),
@@ -0,0 +1,81 @@
1
+ /**
2
+ * 部署控制平面 loader(011 L2:模板装配点专用)。
3
+ * - DEPLOYMENT_CONTROL_PLANE_PATH 存在 → 读取 + zod 校验 + 无内联密钥断言;
4
+ * - 未配置 → 返回 undefined(模板退化为通用助手形态,非 mall 部署)。
5
+ * - mall 语义集中在 contracts-mall(TIER1);apps 只做装配组合(011 L6 checklist)。
6
+ */
7
+ import { readFileSync } from 'node:fs';
8
+ import { z } from 'zod';
9
+ import {
10
+ createMallRuntimeAssembly,
11
+ type MallDeploymentControlPlane,
12
+ type MallRuntimeAssembly,
13
+ } from '@tbox.cn/app-contracts-mall';
14
+
15
+ const providerInstanceSchema = z.object({
16
+ providerInstanceId: z.string().min(1),
17
+ providerTemplateId: z.string().min(1),
18
+ scope: z.record(z.string(), z.string()).optional(),
19
+ config: z.record(z.string(), z.unknown()).optional(),
20
+ credentialRef: z.string().optional(),
21
+ credentialVersion: z.string().optional(),
22
+ enabled: z.boolean(),
23
+ });
24
+
25
+ const controlPlaneSchema = z.object({
26
+ deployment: z.object({
27
+ miniAppId: z.string().min(1),
28
+ agentId: z.string().min(1),
29
+ components: z.array(
30
+ z.object({
31
+ componentId: z.string().min(1),
32
+ enabledFeatures: z.array(z.string()).optional(),
33
+ optional: z.boolean().optional(),
34
+ }),
35
+ ),
36
+ }),
37
+ malls: z.array(z.object({ mallId: z.string().min(1), name: z.string().min(1) })),
38
+ providerTemplates: z
39
+ .array(
40
+ z.object({
41
+ providerTemplateId: z.string().min(1),
42
+ credentialType: z.string().min(1),
43
+ }),
44
+ )
45
+ .optional(),
46
+ providerInstances: z.array(providerInstanceSchema),
47
+ });
48
+
49
+ /** 无内联密钥断言(011 L2):命中敏感键名即拒(部署配置只放引用不放机密) */
50
+ const SECRET_KEY_PATTERN = /api[-_]?key|private[-_]?key|secret|password|token|credential/i;
51
+
52
+ function assertNoInlineSecrets(config: unknown, path = '$'): void {
53
+ if (Array.isArray(config)) {
54
+ config.forEach((item, i) => assertNoInlineSecrets(item, `${path}[${i}]`));
55
+ return;
56
+ }
57
+ if (config && typeof config === 'object') {
58
+ for (const [k, v] of Object.entries(config as Record<string, unknown>)) {
59
+ if (SECRET_KEY_PATTERN.test(k)) {
60
+ throw new Error(`部署配置含疑似内联密钥字段:${path}.${k}(机密只允许经 credentialRef 引用环境变量)`);
61
+ }
62
+ assertNoInlineSecrets(v, `${path}.${k}`);
63
+ }
64
+ }
65
+ }
66
+
67
+ export interface LoadedDeployment {
68
+ controlPlane: MallDeploymentControlPlane;
69
+ assembly: MallRuntimeAssembly;
70
+ }
71
+
72
+ /** 读取并校验部署控制平面(路径未配置 → undefined = 非 mall 部署) */
73
+ export function loadDeploymentControlPlane(envPath?: string): LoadedDeployment | undefined {
74
+ const p = envPath ?? process.env.DEPLOYMENT_CONTROL_PLANE_PATH;
75
+ if (!p) return undefined;
76
+ const raw = JSON.parse(readFileSync(p, 'utf8'));
77
+ assertNoInlineSecrets(raw);
78
+ const parsed = controlPlaneSchema.parse(raw);
79
+ const controlPlane = parsed as MallDeploymentControlPlane;
80
+ return { controlPlane, assembly: createMallRuntimeAssembly(controlPlane) };
81
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * feature 门控纯函数(011 D10 / 评审 M2 修复):
3
+ * 组件未声明 enabledFeatures = 全启用(MallDeploymentComponent 语义,contracts-mall deployment.ts);
4
+ * 声明了 → 白名单包含才放行。提取为纯函数供单测与装配共用。
5
+ */
6
+ export interface FeatureGateComponent {
7
+ componentId: string;
8
+ enabledFeatures?: string[];
9
+ }
10
+
11
+ /** 构造门控判定(components 任一命中即 true) */
12
+ export function createFeatureGate(components: readonly FeatureGateComponent[]) {
13
+ return (featureId: string): boolean =>
14
+ components.some(
15
+ (c) => !c.enabledFeatures || c.enabledFeatures.includes(featureId),
16
+ );
17
+ }
@@ -17,10 +17,30 @@ import {
17
17
  createModelProvider,
18
18
  createPluginIntegration,
19
19
  ConversationService,
20
+ loadLocalSkills,
21
+ resolveSkillMode,
22
+ resolvePreToolTimeout,
23
+ buildMessageInputs,
20
24
  } from '@tbox.cn/app-sdk/server';
21
25
  import { installInspector, injectScript, isReady } from '@tbox.cn/app-sdk/platform';
22
- import { translateTool } from './tools/translate.js';
26
+ import { registerAppTools } from './app-tools.js';
27
+ import { APP_PRE_TOOLS } from './pre-tools.js';
23
28
  import { INTENT_ROUTES, createAppIntentDispatcher } from './intent-routes.js';
29
+ import { loadDeploymentControlPlane } from './config/deployment.js';
30
+ import { createFeatureGate } from './config/feature-gate.js';
31
+ // 011 S2/S3:mall 装配原语(条件装配——未配置部署文件时零引用)
32
+ import {
33
+ createScopeSessionRegistry,
34
+ createActionGrantService,
35
+ createComponentActionDispatcher,
36
+ createEffectRuntime,
37
+ createProviderRegistry,
38
+ createAuthenticatedTransportFactory,
39
+ createAuthStrategyRegistry,
40
+ createEnvironmentSecretProvider,
41
+ createInMemoryStateStore,
42
+ attachCardActionTokens,
43
+ } from '@tbox.cn/app-sdk/server';
24
44
 
25
45
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
46
  const PORT = Number(process.env.PORT) || 8000;
@@ -77,7 +97,44 @@ if (auth.config.devLoginEnabled) {
77
97
 
78
98
  // 自注册闭环:模块路由挂载到 /api/m/<moduleId>/(在 apiRouter 之前,避免被 /api 兜底 404 截获)
79
99
  // ctx 为模块级单例:registerAll 在 start() 内执行,ApiRouteRegistry 共享 root 保证延迟注册即时生效
80
- const ctx = createServerContext();
100
+ // 011 L1:条件 mall 装配——DEPLOYMENT_CONTROL_PLANE_PATH 存在时注入 runtime/providers/state
101
+ const deployment = loadDeploymentControlPlane();
102
+ // 011 B2:可变认证策略注册表——模块 register 期注册(joycity 签名策略),transportFactory 同引用
103
+ const authStrategies = createAuthStrategyRegistry();
104
+
105
+ const ctx = createServerContext({
106
+ ...(deployment
107
+ ? {
108
+ state: createInMemoryStateStore(),
109
+ runtime: {
110
+ // 011 D10/S5/评审 M2:门控 = 执行期护栏——组件未声明 enabledFeatures = 全启用
111
+ // (与 MallDeploymentComponent 语义一致;声明了 → 白名单包含才放行)
112
+ isFeatureEnabled: createFeatureGate(deployment.controlPlane.deployment.components),
113
+ config: deployment.controlPlane as unknown as Record<string, unknown>,
114
+ },
115
+ providers: createProviderRegistry({
116
+ instances: deployment.controlPlane.providerInstances,
117
+ // 011 评审 7.1:credentialType 真源参数化——providerTemplates 声明优先,
118
+ // 缺省回退从实例派生 + joycity-c-signed-v1(向后兼容既有部署配置)
119
+ templates:
120
+ deployment.controlPlane.providerTemplates ??
121
+ deployment.controlPlane.providerInstances.map((i: { providerTemplateId: string }) => ({
122
+ providerTemplateId: i.providerTemplateId,
123
+ credentialType: 'joycity-c-signed-v1',
124
+ })),
125
+ transportFactory: createAuthenticatedTransportFactory({
126
+ strategies: authStrategies,
127
+ secretProvider: createEnvironmentSecretProvider({
128
+ source: process.env as Record<string, string | undefined>,
129
+ allowedHeaderNames: ['content-type', 'sign', 'timestamp', 'publickey', 'x-api-key'],
130
+ production: isProduction,
131
+ }),
132
+ }),
133
+ }),
134
+ }
135
+ : {}),
136
+ authStrategies,
137
+ });
81
138
 
82
139
  // 全局 /api 鉴权(H2 挂载顺序契约):除 public(login/dev-login/health)外一律要求 Bearer token;
83
140
  // optional 模式缺失 token → 匿名身份(写通道仍被 action gateway 拒绝)
@@ -94,13 +151,30 @@ app.use('/api/action', ctx.actions.middleware());
94
151
  app.use('/api', createPlatformApiRouter({ ctx, auth }));
95
152
 
96
153
  // 从 ServerContext 构造 ChatEngine(自注册闭环组装端)
97
- async function createEngine() {
98
- return createEngineFromContext(ctx, {
154
+ async function createEngine(
155
+ extra?: { cardActionAttacher: (payload: import('@tbox.cn/app-contracts').TboxCardPayload, sessionId: string) => Promise<import('@tbox.cn/app-contracts').TboxCardPayload> },
156
+ ) {
157
+ // 009:应用级本地 skills(SKILL.md 目录格式,dev/dist 路径恒等 = src/dist 平级 + __dirname 绝对解析)
158
+ const appSkills = await loadLocalSkills(path.resolve(__dirname, '../skills'));
159
+ const skillMode = resolveSkillMode(process.env.TBOX_SKILL_MODE);
160
+ // 010:单次解析(引擎选项与启动日志同源,非法值只 warn 一次)
161
+ const preToolTimeoutMs = resolvePreToolTimeout(process.env.TBOX_PRE_TOOL_TIMEOUT_MS);
162
+
163
+ const engine = await createEngineFromContext(ctx, {
99
164
  instructions: AGENT_CONFIG.instructions,
100
165
  model: getModel(AI_MODEL_NAME),
101
166
  traceExporter: tboxTraceExporter,
102
167
  modelName: AI_MODEL_NAME,
103
168
  fileUrlPrefix: TBOX_FILE_URL_PREFIX,
169
+ // 009:双模式全局配置(accelerated 默认:本地路由命中 → reminder 注入当前轮,零额外 LLM 轮次;
170
+ // progressive:全量注册 InlineSkill,模型渐进 skill_read)
171
+ skillMode,
172
+ skills: appSkills,
173
+ // 010:前置工具加速(应用级声明 + 单工具预算;TBOX_PRE_TOOL_TIMEOUT_MS=0 整体禁用)
174
+ preTools: APP_PRE_TOOLS,
175
+ preToolTimeoutMs,
176
+ // 011 B3:卡片动作 token 签发器(mall 装配时注入)
177
+ cardActionAttacher: extra?.cardActionAttacher,
104
178
  // B21:意图路由接线(业务意图表 + handlerDispatch)
105
179
  intentRoutes: INTENT_ROUTES,
106
180
  handlerDispatch: createAppIntentDispatcher(ctx),
@@ -110,11 +184,17 @@ async function createEngine() {
110
184
  });
111
185
  return { tools: loaded.tools, references: loaded.references };
112
186
  },
113
- saveMessage: async ({ conversationId, query, answer, mediaItems, outerBusinessId, cards }) => {
187
+ saveMessage: async ({ conversationId, query, answer, mediaItems, outerBusinessId, cards, skills, preTools }) => {
114
188
  const agentId = process.env.TBOX_APP_ID;
115
189
  const service = getConversationService();
116
190
  if (!service || !agentId) return undefined;
117
191
  const imageUrls = extractImageUrls(mediaItems, TBOX_FILE_URL_PREFIX);
192
+ // P3 W1:卡片快照随消息持久化(MessageEmbedded,004 D1)。
193
+ // 009 D10:skill 加载记录随消息持久化(inputs.skills,前端历史表达重建)。
194
+ // 010:前置工具记录随消息持久化(inputs.preTools,前端历史表达重建)。
195
+ // inputs「非空才加」组装规则已下沉 SDK buildMessageInputs(010 S3)。
196
+ // 若平台拒绝 inputs(V1 验证失败),此处切换为 extraParams 字符串或 FileCardPersistenceBackend 落盘。
197
+ const inputs = buildMessageInputs({ cards, skills, preTools });
118
198
  return await service.saveMessage({
119
199
  conversationId,
120
200
  agentId,
@@ -124,12 +204,20 @@ async function createEngine() {
124
204
  ...(imageUrls.length > 0 && {
125
205
  multiModalInputs: { imageUrls },
126
206
  }),
127
- // P3 W1:卡片快照随消息持久化(MessageEmbedded,004 D1)。
128
- // 若平台拒绝 inputs(V1 验证失败),此处切换为 extraParams 字符串或 FileCardPersistenceBackend 落盘。
129
- ...(cards && cards.length > 0 && { inputs: { cards } }),
207
+ ...(inputs && { inputs }),
130
208
  });
131
209
  },
132
210
  });
211
+
212
+ // 009:启动日志诊断(仅名称/描述/路由词,不含 SOP 内容)
213
+ console.log(
214
+ `[SKILL] mode=${skillMode} skills=${engine.listSkills().map((s) => s.name).join(',') || '(none)'}`,
215
+ );
216
+ // 010:前置工具诊断(仅 toolId/描述/路由形态,不含 decide 逻辑)
217
+ console.log(
218
+ `[PRETOOL] timeoutMs=${preToolTimeoutMs} preTools=${engine.listPreTools().map((d) => d.toolId).join(',') || '(none)'}`,
219
+ );
220
+ return engine;
133
221
  }
134
222
 
135
223
  if (isProduction) {
@@ -178,15 +266,85 @@ if (isProduction) {
178
266
  const httpServer = createServer(app);
179
267
 
180
268
  async function start() {
269
+ // 011 S6:mall 装配 + 生产环境缺动作授权密钥 → 启动期提示(签发路径运行时兜底报错)
270
+ if (deployment && isProduction && !process.env.ACTION_TOKEN_SECRET && !process.env.AUTH_TOKEN_SECRET) {
271
+ console.warn('[ACTION] 生产环境未配置 ACTION_TOKEN_SECRET/AUTH_TOKEN_SECRET——卡片写动作 token 签发将失败');
272
+ }
273
+
181
274
  // 注册业务模块(自注册闭环)
182
275
  await registerAll(ctx);
183
276
 
184
- // 模板演示工具(apps 级工具,P3 建议迁入模块):不参与模块体系,直接注册
185
- ctx.tools.register(translateTool);
277
+ // 模板演示工具(apps 级工具,P3 建议迁入模块):不参与模块体系,经统一注册面注册
278
+ // (010:start() 与 cli/collect-registry.ts 双处同调,防注册面失同步)
279
+ registerAppTools(ctx);
280
+
281
+ // 011 F2/E2:mall 装配(条件)——scope 会话 + 动作授权先行(engine attacher 依赖)
282
+ const scopeSessions: ReturnType<typeof createScopeSessionRegistry> | undefined = deployment
283
+ ? createScopeSessionRegistry({
284
+ extractScopeKey: deployment.assembly.extractScopeKey,
285
+ validateScope: deployment.assembly.validateScope,
286
+ buildScope: deployment.assembly.buildScope,
287
+ })
288
+ : undefined;
289
+ const grants = scopeSessions ? createActionGrantService({ store: ctx.state }) : undefined;
186
290
 
187
- const engine = await createEngine();
291
+ // 011 B3:卡片动作 token 签发(build → attach → send;sessionId 引擎登记、scope 经 sessions 查)
292
+ const cardActionAttacher = grants && scopeSessions
293
+ ? async (
294
+ payload: import('@tbox.cn/app-contracts').TboxCardPayload,
295
+ sessionId: string,
296
+ ): Promise<import('@tbox.cn/app-contracts').TboxCardPayload> => {
297
+ const session = scopeSessions.get(sessionId);
298
+ if (!session) return payload;
299
+ return attachCardActionTokens({
300
+ payload,
301
+ actions: ctx.cardActions,
302
+ grants: grants,
303
+ sessionId: session.sessionId,
304
+ scope: session.scopeFlat,
305
+ });
306
+ }
307
+ : undefined;
308
+ const engine = await createEngine(cardActionAttacher ? { cardActionAttacher } : undefined);
188
309
 
189
- const chatWss = createWsServer(engine, { auth: auth.wsAuth });
310
+ // 011 F2/E2:组件动作分发(依赖 scopeSessions/grants)
311
+ let componentActions: ReturnType<typeof createComponentActionDispatcher> | undefined;
312
+ if (deployment && scopeSessions && grants) {
313
+ componentActions = createComponentActionDispatcher({
314
+ grants,
315
+ sessions: scopeSessions,
316
+ state: ctx.state,
317
+ actions: ctx.cardActions,
318
+ cardRegistry: ctx.cards.getAll(),
319
+ // 011 E5/J7:alipay 授权宿主处理器——二期占位(authCode 回传通道未实现)
320
+ effects: createEffectRuntime({
321
+ handleAlipayAuthorization: async () => {
322
+ console.warn('[ALIPAY] 授权回调通道二期(authCode 回传 + resultActionId 补签未实现),授权效果未执行');
323
+ },
324
+ }),
325
+ extractScopeKey: deployment.assembly.extractScopeKey,
326
+ });
327
+ for (const action of ctx.cardActions.getAll().values()) {
328
+ componentActions.register(action);
329
+ }
330
+ }
331
+
332
+ const chatWss = createWsServer(engine, {
333
+ auth: auth.wsAuth,
334
+ ...(scopeSessions
335
+ ? {
336
+ scopeSessions,
337
+ ...(componentActions ? { componentActions } : {}),
338
+ chatConfig: {
339
+ quickCommands: [
340
+ { label: '找美食', prompt: '帮我找商场里的美食' },
341
+ { label: '会员积分', prompt: '我的会员积分是多少' },
342
+ ],
343
+ enableImageUpload: true,
344
+ },
345
+ }
346
+ : {}),
347
+ });
190
348
  const ttsWss = createTtsProxy();
191
349
 
192
350
  httpServer.on('error', (err: NodeJS.ErrnoException) => {
@@ -8,6 +8,8 @@
8
8
  import type { ServerContext, InferModuleCards } from '@tbox.cn/app-sdk/server';
9
9
 
10
10
  // @tbox:imports-begin
11
+ // SDK 装配框架(非模块行,CLI 增量维护保留非标行)
12
+ import { registerServerModules } from '@tbox.cn/app-sdk/server';
11
13
  import { serverModule as moduleWeather } from '@app/module-weather/server';
12
14
  // @tbox:imports-end
13
15
 
@@ -20,11 +22,13 @@ const modules = [
20
22
  // 自由区:可做装配级定制——条件注册、顺序调整、包裹中间件
21
23
  // const dynamicModule = process.env.ENABLE_X ? xModule : null;
22
24
 
23
- /** 注册所有已安装模块(服务端装配——勿与 apps/client/src/modules.ts 混淆) */
24
- export function registerAll(ctx: ServerContext): void {
25
- for (const m of modules) {
26
- m.register(ctx);
27
- }
25
+ /**
26
+ * 注册所有已安装模块(服务端装配——勿与 apps/client/src/modules.ts 混淆)。
27
+ * 010 下沉:声明式字段自动注册(skills/preTools)与 async register 等待由
28
+ * SDK registerServerModules 承载——未来新增声明式字段只改 SDK,本文件零改动。
29
+ */
30
+ export function registerAll(ctx: ServerContext): Promise<void> {
31
+ return registerServerModules(ctx, modules);
28
32
  }
29
33
 
30
34
  // 类型从运行时装配数组自动推导:增删模块后 CardType 自动收缩/扩展(002 §6.7)
@@ -0,0 +1,25 @@
1
+ // 铁律:不直接 import @mastra/core,经 @tbox.cn/app-sdk/server 的 definePreTool 入口(单例铁律)
2
+ import { definePreTool } from '@tbox.cn/app-sdk/server';
3
+ import { clockTool } from './tools/clock.js';
4
+
5
+ /**
6
+ * 应用级前置工具声明(010):BuildEngineOptions.preTools 通道。
7
+ *
8
+ * 声明形态(详见 README「前置 Tool 加速执行」节):
9
+ * ① tool —— 工具实例引用(单一真源,id/description 派生)
10
+ * ② routing —— 结构化路由:{ mode: 'keywords', terms } 宽召回词表(与 skill 同源打分)
11
+ * 或 { mode: 'always' } 必选候选(decide 门控;慎用防每轮噪音)
12
+ * ③ decide —— 决策即构造:{ decision: 'run', args } = 预执行 / { decision: 'skip' } = 不生效
13
+ *
14
+ * 演示形态:零参工程化——词表命中即生效(decide 恒 run 空参)。
15
+ * 词表刻意排除「今天/时间」等泛词,防止与天气类 preTool 叠加误触发(词表精度示范)。
16
+ */
17
+ export const APP_PRE_TOOLS = [
18
+ definePreTool(clockTool, {
19
+ routing: {
20
+ mode: 'keywords',
21
+ terms: ['几点', '几点了', '现在时间', '日期', '星期几'],
22
+ },
23
+ decide: () => ({ decision: 'run', args: {} }),
24
+ }),
25
+ ];
@@ -0,0 +1,24 @@
1
+ // 铁律:不直接 import @mastra/core,经 @tbox.cn/app-sdk/server 的 createTool 入口(单例铁律)
2
+ import { createTool } from '@tbox.cn/app-sdk/server';
3
+ import { z } from 'zod';
4
+
5
+ /**
6
+ * 当前时间工具 — 前置工具(pre-tool)演示:零参工具,参数无需模型抽取,
7
+ * 词表命中即可工程化预执行(见 apps/server/src/pre-tools.ts)。
8
+ */
9
+ export const clockTool = createTool({
10
+ id: 'getCurrentTime',
11
+ description: '查询当前日期和时间',
12
+ inputSchema: z.object({}),
13
+ outputSchema: z.object({
14
+ iso: z.string(),
15
+ zh: z.string(),
16
+ }),
17
+ execute: async () => {
18
+ const now = new Date();
19
+ return {
20
+ iso: now.toISOString(),
21
+ zh: now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }),
22
+ };
23
+ },
24
+ });
@@ -12,10 +12,11 @@ export const translateTool = createTool({
12
12
  '中英文互译工具。当用户需要翻译文本时调用此工具。自动检测源语言并翻译为目标语言:中文→英文,英文→中文。',
13
13
  inputSchema: z.object({
14
14
  text: z.string().describe('要翻译的文本'),
15
+ // D11 参数规范:仅 string 形态(LLM 生成字符串最可靠);非法值工具内回落自动检测
15
16
  targetLang: z
16
- .enum(['zh', 'en'])
17
+ .string()
17
18
  .optional()
18
- .describe('目标语言,不传则自动检测:中文输入翻译为英文,英文输入翻译为中文'),
19
+ .describe('目标语言(zh 或 en),不传则自动检测:中文输入翻译为英文,英文输入翻译为中文'),
19
20
  }),
20
21
  outputSchema: z.object({
21
22
  original: z.string(),
@@ -24,9 +25,14 @@ export const translateTool = createTool({
24
25
  targetLang: z.string(),
25
26
  }),
26
27
  execute: async ({ text, targetLang }) => {
27
- // 简单语言检测:包含中文字符则认为是中文
28
+ // 简单语言检测:包含中文字符则认为是中文;非法 targetLang 回落自动检测
28
29
  const isChinese = /[\u4e00-\u9fff]/.test(text);
29
- const detectedTarget = targetLang || (isChinese ? 'en' : 'zh');
30
+ const detectedTarget =
31
+ targetLang === 'zh' || targetLang === 'en'
32
+ ? targetLang
33
+ : isChinese
34
+ ? 'en'
35
+ : 'zh';
30
36
  const sourceLang = isChinese ? 'zh' : 'en';
31
37
 
32
38
  // 注意:这里返回指令让 LLM 来做实际翻译
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createFeatureGate } from '../src/config/feature-gate.js';
3
+
4
+ describe('createFeatureGate(011 评审 M2:未声明 enabledFeatures = 全启用)', () => {
5
+ it('组件未声明 enabledFeatures → 任意 feature 放行', () => {
6
+ const gate = createFeatureGate([{ componentId: 'shopping-guide' }]);
7
+ expect(gate('merchant-discovery')).toBe(true);
8
+ expect(gate('anything')).toBe(true);
9
+ });
10
+
11
+ it('声明白名单 → 包含才放行', () => {
12
+ const gate = createFeatureGate([
13
+ { componentId: 'shopping-guide', enabledFeatures: ['merchant-discovery', 'product-search'] },
14
+ ]);
15
+ expect(gate('merchant-discovery')).toBe(true);
16
+ expect(gate('product-search')).toBe(true);
17
+ expect(gate('navigation')).toBe(false);
18
+ });
19
+
20
+ it('多组件任一命中即放行;空数组 → 全禁用', () => {
21
+ const gate = createFeatureGate([
22
+ { componentId: 'member', enabledFeatures: ['member-points'] },
23
+ { componentId: 'shopping-guide' },
24
+ ]);
25
+ expect(gate('member-points')).toBe(true);
26
+ expect(gate('navigation')).toBe(true);
27
+ const none = createFeatureGate([]);
28
+ expect(none('x')).toBe(false);
29
+ });
30
+ });
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { createServerContext, createSdkToolRuntime } from '@tbox.cn/app-sdk/server';
3
3
  import { registerAll } from '../src/modules.js';
4
+ import { translateTool } from '../src/tools/translate.js';
4
5
 
5
6
  describe('tool runtime 宿主装配(自注册闭环)', () => {
6
7
  it('合并模块工具并用同一次加载结果创建卡片 resolver', async () => {
@@ -46,4 +47,31 @@ describe('tool runtime 宿主装配(自注册闭环)', () => {
46
47
  status: 'unbound',
47
48
  });
48
49
  });
50
+
51
+ describe('translate 参数规范(D11:string 形态 + 工具内 normalize)', () => {
52
+ // mastra Tool.execute 为可选 + 双参签名(input, context)——测试经非空断言直调
53
+ const callExecute = (input: { text: string; targetLang?: string }) =>
54
+ translateTool.execute!(input, {} as never);
55
+
56
+ it('合法 targetLang(zh/en)→ 生效', async () => {
57
+ const r = await callExecute({ text: '你好', targetLang: 'en' });
58
+ expect(r).toMatchObject({ targetLang: 'en' });
59
+ const r2 = await callExecute({ text: 'hello', targetLang: 'zh' });
60
+ expect(r2).toMatchObject({ targetLang: 'zh' });
61
+ });
62
+
63
+ it('非法 targetLang → 回落自动检测(不抛)', async () => {
64
+ const r = await callExecute({ text: '你好', targetLang: 'fr' });
65
+ expect(r).toMatchObject({ targetLang: 'en' }); // 中文输入 → 自动检测回落英文
66
+ const r2 = await callExecute({ text: 'hello', targetLang: 'xx' });
67
+ expect(r2).toMatchObject({ targetLang: 'zh' }); // 英文输入 → 自动检测回落中文
68
+ });
69
+
70
+ it('缺省 targetLang → 自动检测', async () => {
71
+ const r = await callExecute({ text: '你好' });
72
+ expect(r).toMatchObject({ targetLang: 'en' });
73
+ const r2 = await callExecute({ text: 'hello' });
74
+ expect(r2).toMatchObject({ targetLang: 'zh' });
75
+ });
76
+ });
49
77
  });
@@ -0,0 +1,27 @@
1
+ {
2
+ "deployment": {
3
+ "miniAppId": "wx-demo-mall-app",
4
+ "agentId": "mall-guide-agent",
5
+ "components": [
6
+ { "componentId": "shopping-guide", "optional": true, "enabledFeatures": ["merchant-discovery", "product-search", "recommendation", "mall-directory", "customer-service", "navigation"] },
7
+ { "componentId": "member", "optional": true, "enabledFeatures": ["member-profile", "member-card", "member-benefits", "member-points", "member-coupons", "member-enrollment"] }
8
+ ]
9
+ },
10
+ "malls": [
11
+ { "mallId": "mall-demo", "name": "示例大悦城" }
12
+ ],
13
+ "providerTemplates": [
14
+ { "providerTemplateId": "joycity-commercial@1", "credentialType": "joycity-c-signed-v1" }
15
+ ],
16
+ "providerInstances": [
17
+ {
18
+ "providerInstanceId": "joycity-demo",
19
+ "providerTemplateId": "joycity-commercial@1",
20
+ "scope": { "mallId": "mall-demo" },
21
+ "config": { "baseUrl": "https://openapi-crm-t.joycity.mobi" },
22
+ "credentialRef": "secret://JOYCITY_CREDENTIALS",
23
+ "credentialVersion": "v1",
24
+ "enabled": true
25
+ }
26
+ ]
27
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "modelName": "glm-5.1"
2
+ "modelName": "deepseek-v4-flash"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tbox.cn/app-template-agent",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "tbox AI Agent 全栈模板(多包 pnpm workspace):apps/client + apps/server + packages/module-*。客户端同时跑在浏览器与支付宝小程序 web-view 壳里。",
5
5
  "engines": {
6
6
  "node": ">=24.0.0 <25.0.0"
@@ -15,7 +15,8 @@
15
15
  "./client": {
16
16
  "types": "./src/client/index.ts",
17
17
  "import": "./src/client/index.ts"
18
- }
18
+ },
19
+ "./package.json": "./package.json"
19
20
  },
20
21
  "scripts": {
21
22
  "typecheck": "tsc --noEmit"
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: weather-guide
3
+ description: 天气查询与穿衣出行建议(调用 getWeather 真实数据)
4
+ metadata:
5
+ routingTerms: [天气, 穿衣, 温度, 湿度, 出行]
6
+ ---
7
+ ## 适用意图
8
+ 用户询问天气、穿衣建议、出行准备(如"今天穿什么")时使用本规范。
9
+
10
+ ## 调用工具
11
+ 必须调用 getWeather 工具查询真实天气(city 取用户提到的城市),禁止编造温度/天气数据。
12
+
13
+ ## 返回字段说明
14
+ getWeather 返回:temp(温度℃)、weather(天气现象)、humidity(湿度%)。
15
+
16
+ ## 回答组织方式
17
+ 1. 先报城市与天气现象、温度、湿度
18
+ 2. 再给出穿衣/出行建议(结合温度与天气现象)
19
+ 3. 温度 <10℃ 提醒保暖,>30℃ 提醒防晒
@@ -0,0 +1,5 @@
1
+ ## 穿衣指数
2
+ - 温度 <10℃:厚外套/羽绒服
3
+ - 10~20℃:夹克/风衣
4
+ - 20~30℃:单衣/薄外套
5
+ - >30℃:短袖,注意防晒
@@ -1,6 +1,8 @@
1
1
  import { weatherTool } from './tool';
2
2
  import weatherMeta from './cards/weather/meta';
3
- import type { ServerContext, ServerModule } from '@tbox.cn/app-sdk/server';
3
+ import { loadModuleSkills } from '@tbox.cn/app-sdk/server';
4
+ import type { LocalSkill, ServerContext, ServerModule } from '@tbox.cn/app-sdk/server';
5
+ import { weatherPreTool } from './pre-tools';
4
6
 
5
7
  export { weatherTool, weatherMeta };
6
8
 
@@ -9,9 +11,32 @@ const cards = {
9
11
  weather: weatherMeta,
10
12
  } as const;
11
13
 
14
+ /**
15
+ * 模块 skill 声明(009 模块文件化:SKILL.md 目录格式,文件即唯一源)。
16
+ * - 底层能力:SDK `loadModuleSkills(packageName, import.meta.url)` 单策略定位包根
17
+ * (createRequire 包名解析:dev workspace link / 模块 dist / 应用 bundle dist / 发布态
18
+ * node_modules 全形态恒命中;失败 warn 可行动 + 恒 resolve []);
19
+ * - 顶层 await 加载 → 填回 serverModule.skills 声明式通道(装配端 registerAll 自动注册);
20
+ * - 前置:本包 exports 须含 "./package.json"(见 package.json);
21
+ * - 生成应用内(codegen 展开)调用名由 CLI 自动改写为 @app/<id>,源码按字面量包名写法。
22
+ */
23
+ const skills = Object.fromEntries(
24
+ (await loadModuleSkills('@app/module-weather', import.meta.url)).map((s) => [s.name, s]),
25
+ ) satisfies Record<string, LocalSkill>;
26
+
27
+ /**
28
+ * 模块前置工具声明(010:key 约定 = toolId,纯数据 + 一个 resolve 函数)。
29
+ * 装配端 registerServerModules 自动注册(register 内无需代码)。
30
+ */
31
+ const preTools = {
32
+ getWeather: weatherPreTool,
33
+ } as const;
34
+
12
35
  /** 模块注册入口:自注册闭环(与客户端 clientModule 对称) */
13
36
  export const serverModule = {
14
37
  cards,
38
+ skills,
39
+ preTools,
15
40
  register(ctx: ServerContext): void {
16
41
  ctx.tools.register(weatherTool);
17
42
  ctx.cards.registerMap(cards);
@@ -0,0 +1,20 @@
1
+ import { definePreTool } from '@tbox.cn/app-sdk/server';
2
+ import { weatherTool } from './tool';
3
+
4
+ /**
5
+ * 前置工具声明(010):getWeather 的 city 参数可工程化抽取(城市词表),
6
+ * 无需模型抽参——词表命中即预执行,模型第一轮直接作答(省一轮 loop)。
7
+ *
8
+ * 演示形态:文本工程化抽取——决策即构造:
9
+ * 抽到城市 → { decision: 'run', args: { city } }(预执行,args 强类型由 tool 推断);
10
+ * 抽不到 → { decision: 'skip' }(本轮不生效,模型正常调用工具)。
11
+ */
12
+ const CITIES = ['北京', '上海', '广州', '深圳', '杭州', '成都', '武汉', '西安', '南京', '重庆'];
13
+
14
+ export const weatherPreTool = definePreTool(weatherTool, {
15
+ routing: { mode: 'keywords', terms: ['天气', '气温', '下雨', '下雪', '穿衣', '出行'] },
16
+ decide: ({ text }) => {
17
+ const city = CITIES.find((c) => text.includes(c));
18
+ return city ? { decision: 'run', args: { city } } : { decision: 'skip' };
19
+ },
20
+ });
@@ -26,16 +26,17 @@ catalog:
26
26
  "@types/express": ^4.17.21
27
27
  ws: ^8.18.0
28
28
  "@types/ws": ^8.5.0
29
- "@mastra/core": ^1.33.0
30
- "@mastra/observability": 1.12.0
29
+ "@mastra/core": ^1.57.0
30
+ "@mastra/observability": 1.16.5
31
+ "gray-matter": ^4.0.3
31
32
  "@ag-ui/core": ^0.0.47
32
33
  ai: ^6.0.0
33
34
  "@ai-sdk/openai": ^3.0.0
34
35
  "@ai-sdk/provider": ^3.0.10
35
36
  "@tbox.cn/agent-plugin": 0.1.0
36
- "@tbox.cn/app-sdk": ^0.1.0
37
+ "@tbox.cn/app-sdk": ^0.2.0
37
38
  "@tbox.cn/app-contracts": ^0.1.0
38
- "@tbox.cn/app-contracts-mall": ^0.1.0
39
+ "@tbox.cn/app-contracts-mall": ^0.2.0
39
40
  zustand: ^4.5.5
40
41
  commander: ^12.1.0
41
42
  diff: ^7.0.0