@lark-apaas/coding-steering 0.1.32-dev.942e73f → 0.1.32-dev.a04167f
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.
- package/package.json +1 -1
- package/steering/design-html/skills/charts/SKILL.md +11 -4
- package/steering/design-html/skills/data-viz/SKILL.md +1 -1
- package/steering/design-html/skills/hi-fi-design/SKILL.md +1 -1
- package/steering/design-html/skills/interactive-prototype/SKILL.md +1 -17
- package/steering/design-html/skills/pptx-style-extract/SKILL.md +5 -5
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +57 -20
- package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +9 -1
- package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +199 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_logo_scope.py +468 -16
- package/steering/design-html/skills/pptx-style-extract/scripts/verify_layout_assets.py +421 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/verify_logo_scope.py +3 -179
- package/steering/design-html/skills/slide-deck/SKILL.md +5 -3
- package/steering/design-html/skills/social-card/SKILL.md +263 -0
- package/steering/nestjs-react-fullstack/skills/app-init-feasibility-guide/SKILL.md +3 -0
- package/steering/nestjs-react-fullstack/skills/client-builtins-file-storage-service/SKILL.md +2 -1
- package/steering/nestjs-react-fullstack/skills/client-builtins-user-service/SKILL.md +2 -0
- package/steering/nestjs-react-fullstack/skills/code-fix/SKILL.md +6 -0
- package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +56 -14
- package/steering/nestjs-react-fullstack/skills/contacts-service/SKILL.md +67 -4
- package/steering/nestjs-react-fullstack/skills/nestjs-cache/SKILL.md +0 -1
- package/steering/nestjs-react-fullstack/skills/openapi-guide/SKILL.md +75 -0
- package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +10 -4
- package/steering/nestjs-react-fullstack/skills/plugin-guide/references/plugin-coding-guide.md +57 -14
- package/steering/nestjs-react-fullstack/skills/server-builtins-file-storage-service/SKILL.md +1 -1
- package/steering/nestjs-react-fullstack/skills_common/mcp-guide/SKILL.md +78 -0
- package/steering/nestjs-react-fullstack/skills_common/mcp-guide/assets/eslint.mcp-ui.config.cjs +19 -0
- package/steering/nestjs-react-fullstack/skills_common/mcp-guide/assets/ui-tsconfig.json +19 -0
- package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/client-onboarding.md +82 -0
- package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/debugging.md +81 -0
- package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/mcp-apps.md +197 -0
- package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/tool-authoring.md +249 -0
- package/steering/nestjs-react-fullstack/skills_common/user-identity/SKILL.md +3 -1
- package/steering/nestjs-react-fullstack/skills_local/client-builtins-user-service/SKILL.md +2 -0
- package/steering/nestjs-react-fullstack/skills_local/code-fix/SKILL.md +6 -0
- package/steering/nestjs-react-fullstack/skills_local/coding-guide/SKILL.md +49 -22
- package/steering/nestjs-react-fullstack/skills_local/plugin-guide/SKILL.md +7 -3
- package/steering/nestjs-react-fullstack/skills_local/plugin-guide/references/plugin-coding-guide.md +57 -14
- package/steering/vite-react/skills/plugin-guide/SKILL.md +1 -0
- package/steering/vite-react/skills/react-three-fiber/SKILL.md +1 -0
- package/steering/vite-react/tech.md +3 -0
- package/steering/design-html/skills/preflight/SKILL.md +0 -51
- package/steering/design-html/skills/preflight/scripts/probe.sh +0 -108
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# MCP Apps:给工具结果加界面
|
|
2
|
+
|
|
3
|
+
一个 MCP App = 一个 `ui://` 资源(单文件 HTML)+ 至少一个用 `ui.resourceUri` 指向它的工具。宿主调用工具后把 HTML 放进沙箱 iframe 渲染,再把工具入参与结果通过通知推给界面;界面里的操作经宿主回调 server 的工具。
|
|
4
|
+
|
|
5
|
+
## 选哪种界面
|
|
6
|
+
|
|
7
|
+
| 信号 | 界面类型 |
|
|
8
|
+
|---|---|
|
|
9
|
+
| 工具已返回候选,要用户挑一个或几个再继续(航班、订单、文件) | 选择型,选中后 `updateModelContext` 同步给模型 |
|
|
10
|
+
| 结果是图表、地图、对比、文件预览等看图才懂的信息 | 展示型 |
|
|
11
|
+
| 写操作前要让用户在界面里核对、确认 | 确认型,确认动作经 `callServerTool` 调一个 `visibility: ['app']` 的工具 |
|
|
12
|
+
| 工具跑之前要用户补一组结构化输入(多字段表单、长列表里选参数) | 表单型。服务端中途问不了用户(没有 elicitation),只有模型和界面能问 |
|
|
13
|
+
| 长任务要用户看着进展 | 进度型:发起工具绑界面并返回任务 ID,界面轮询 `visibility: ['app']` 的查状态工具。进度通知在无状态 JSON 模式下发不出去,界面是唯一的路 |
|
|
14
|
+
| 以上都不匹配 | 不做界面 |
|
|
15
|
+
|
|
16
|
+
## 目录与构建
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
server/mcp/ui/
|
|
20
|
+
├── tsconfig.json # 界面独立的 TS 配置,整个 ui/ 共用一份,从本 skill 的 assets/ 拷
|
|
21
|
+
└── <entry>/
|
|
22
|
+
├── index.html # 入口骨架,见下
|
|
23
|
+
└── main.tsx # 或 main.ts;React 或原生 JS 都行
|
|
24
|
+
dist/mcp-ui/<entry>.html # 预设产物,@McpUiResource 经 readMcpUiTemplate 读取
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```html
|
|
28
|
+
<!-- server/mcp/ui/<entry>/index.html -->
|
|
29
|
+
<!doctype html>
|
|
30
|
+
<html><head><meta charset="utf-8" /></head>
|
|
31
|
+
<body><div id="root"></div><script type="module" src="./main.tsx"></script></body></html>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`<div id="root">` 不能少:缺了 `createRoot(document.getElementById('root')!)` 拿到 null,iframe 全白,而 `read_resource` 照样能读到 HTML,不会报错。
|
|
35
|
+
|
|
36
|
+
- `<entry>` 只允许 `A-Z a-z 0-9 _ -`。
|
|
37
|
+
- `@lark-apaas/coding-preset-vite-react` 的 `mcp-ui` 插件:检测到 `server/mcp/ui/` 就为每个入口跑一次独立子构建,JS / CSS / 资源全部内联成一个 HTML。开发态监听 `server/mcp/ui/` 与 `shared/`,改完自动重建;生产在主构建之后构建,并同步进发布目录。
|
|
38
|
+
- 子构建只带 React 插件与 `@shared` 别名,不加载主应用的 `vite.config.ts`、`.env`、`public/` 与 PostCSS 配置:`@/`、`@client/*`、`@server/*` 解析不到,不支持 styled-jsx、Tailwind 与主应用全局样式。样式写在入口自己的 CSS 里;颜色字体优先读宿主下发的 CSS 变量(`useHostStyles`),深浅两套主题下都要能看。
|
|
39
|
+
- 依赖边界由子构建强制:界面只能 import `server/mcp/ui/` 内的文件、`shared/` 与浏览器可用的 npm 包。引到 `client/`、`server/` 里的业务代码、工具类或服务端实现报「MCP UI 不能引用应用或服务端代码」,引到 `fs`、`path` 等报「MCP UI 不能引用 Node.js 模块」;HTML 的 `src` / `href`、CSS 的 `url()` 指到界面目录外同样被拒。要复用的纯展示组件挪到 `shared/`,且不得依赖应用的 Provider、Router 或登录态。
|
|
40
|
+
- 界面不继承主应用的 TS / ESLint 配置,各自带一份,首次开通界面从本 skill 的 `assets/` 拷进工程。逐字照拷、别自己写:子构建只转译不查类型,配置漏一项,错误要等界面在 iframe 里白屏才暴露。`SKILL_DIR` 取技能清单里本 skill 的 location 所在目录:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
SKILL_DIR=<本 skill SKILL.md 所在目录>
|
|
44
|
+
mkdir -p server/mcp/ui
|
|
45
|
+
cp "$SKILL_DIR/assets/ui-tsconfig.json" server/mcp/ui/tsconfig.json
|
|
46
|
+
cp "$SKILL_DIR/assets/eslint.mcp-ui.config.cjs" eslint.mcp-ui.config.cjs
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- `@modelcontextprotocol/ext-apps` 随 SDK 安装,直接 import。解析失败时把它加进 `package.json` `dependencies`,版本与 `node_modules/@lark-apaas/nestjs-mcp/package.json` 里声明的一致(1.x),不装 2.x。
|
|
50
|
+
|
|
51
|
+
## server 侧:声明资源并关联工具
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import { McpTools, McpTool, McpUiResource, readMcpUiTemplate } from '@lark-apaas/fullstack-nestjs-core';
|
|
55
|
+
|
|
56
|
+
@McpTools({ prefix: 'order_' })
|
|
57
|
+
export class OrderMcpTools {
|
|
58
|
+
@McpTool({
|
|
59
|
+
title: '查询订单',
|
|
60
|
+
description: '…',
|
|
61
|
+
inputSchema: SearchOrdersInput,
|
|
62
|
+
outputSchema: SearchOrdersOutput,
|
|
63
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
64
|
+
ui: { resourceUri: 'ui://order/list' }, // 结果由该界面渲染;模型与界面都可调
|
|
65
|
+
})
|
|
66
|
+
async search(/* … */) { /* … */ }
|
|
67
|
+
|
|
68
|
+
@McpTool({
|
|
69
|
+
description: '按 ID 取一条订单明细,供订单列表界面展开详情时调用。',
|
|
70
|
+
inputSchema: GetOrderInput,
|
|
71
|
+
outputSchema: GetOrderOutput,
|
|
72
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
73
|
+
ui: { resourceUri: 'ui://order/list', visibility: ['app'] }, // 只供界面回调,模型工具清单里隐藏
|
|
74
|
+
})
|
|
75
|
+
async detail(/* … */) { /* … */ }
|
|
76
|
+
|
|
77
|
+
@McpUiResource({
|
|
78
|
+
uri: 'ui://order/list',
|
|
79
|
+
title: '订单列表',
|
|
80
|
+
description: '分页订单列表,可展开详情、勾选后取消。',
|
|
81
|
+
csp: { connectDomains: [], resourceDomains: [] }, // 界面要访问的外部域名写这里,默认全禁
|
|
82
|
+
})
|
|
83
|
+
listView() {
|
|
84
|
+
return readMcpUiTemplate('order-list'); // 读 dist/mcp-ui/order-list.html
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- `uri` 必须以 `ui://` 开头,全应用唯一;`ui.resourceUri` 找不到对应 `@McpUiResource()` 时应用启动会报错。
|
|
90
|
+
- `visibility` 默认 `['model', 'app']`。只给界面用的取数 / 刷新工具写 `['app']`,避免污染模型的工具清单。隐藏由宿主做,server 不过滤:`mcp_inspect` 的 `list_tools` 仍会列出它,`_meta.ui.visibility` 为 `['app']` 即正确,不要因为「还在清单里」去改代码。
|
|
91
|
+
- 绑了界面就在 `description` 里说出来(「结果以可交互的订单列表展示,可在界面里展开详情、取消订单」)。模型只看 `description` 选工具,不写它可能改调别的工具或自己拼 ID。
|
|
92
|
+
- 一个工具既返回 `structuredContent` 又绑界面是常态;模型还需要纯数据继续推理时,拆成「取数工具(无 ui)」+「展示工具(绑 ui)」。
|
|
93
|
+
- 资源读取沿用 `requireUser`:无身份的 `resources/read` 会被拒。
|
|
94
|
+
|
|
95
|
+
## 界面侧:接结果、回调工具
|
|
96
|
+
|
|
97
|
+
React 写法(`@modelcontextprotocol/ext-apps/react`):
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
// server/mcp/ui/order-list/main.tsx
|
|
101
|
+
import { useState } from 'react';
|
|
102
|
+
import { createRoot } from 'react-dom/client';
|
|
103
|
+
import { useApp, useHostStyles } from '@modelcontextprotocol/ext-apps/react';
|
|
104
|
+
// 与 outputSchema 对齐的 TS 类型放 shared/(如 shared/mcp-types.ts),server 与界面共用;@shared 是子构建唯一可用别名
|
|
105
|
+
import type { OrderPage as Page } from '@shared/mcp-types';
|
|
106
|
+
|
|
107
|
+
function OrderList() {
|
|
108
|
+
const [page, setPage] = useState<Page | null>(null);
|
|
109
|
+
const [error, setError] = useState<string | null>(null);
|
|
110
|
+
|
|
111
|
+
const { app, isConnected } = useApp({
|
|
112
|
+
appInfo: { name: 'order-list', version: '1.0.0' },
|
|
113
|
+
capabilities: {},
|
|
114
|
+
onAppCreated: (app) => {
|
|
115
|
+
// 宿主推送:工具入参(可先渲染骨架)、工具结果、取消
|
|
116
|
+
app.ontoolinput = ({ arguments: args }) => console.debug('input', args);
|
|
117
|
+
app.ontoolresult = (result) => {
|
|
118
|
+
if (result.isError) setError(textOf(result));
|
|
119
|
+
else setPage(result.structuredContent as Page);
|
|
120
|
+
};
|
|
121
|
+
app.ontoolcancelled = () => setError('调用已取消');
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
useHostStyles(app, app?.getHostContext()); // 应用宿主主题、CSS 变量与字体;第二参数让首帧就生效
|
|
125
|
+
|
|
126
|
+
async function cancel(orderId: string) {
|
|
127
|
+
if (!app) return;
|
|
128
|
+
const res = await app.callServerTool({ name: 'order_cancel', arguments: { orderId } });
|
|
129
|
+
if (res.isError) return setError(textOf(res));
|
|
130
|
+
// 把用户在界面里做的选择同步给模型,模型下一轮据此继续
|
|
131
|
+
await app.updateModelContext({
|
|
132
|
+
content: [{ type: 'text', text: `用户已在界面中取消订单 ${orderId}` }],
|
|
133
|
+
structuredContent: { cancelledOrderId: orderId },
|
|
134
|
+
});
|
|
135
|
+
setPage((p) => p && { ...p, items: p.items.filter((i) => i.id !== orderId) });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!isConnected) return <p>连接宿主中…</p>;
|
|
139
|
+
if (error) return <p role="alert">{error}</p>;
|
|
140
|
+
if (!page) return <p>等待查询结果…</p>;
|
|
141
|
+
return (
|
|
142
|
+
<ul>
|
|
143
|
+
{page.items.map((o) => (
|
|
144
|
+
<li key={o.id}>
|
|
145
|
+
{o.customer} · {o.amount} 元 · {o.status}
|
|
146
|
+
{o.status === 'pending' && <button onClick={() => cancel(o.id)}>取消</button>}
|
|
147
|
+
</li>
|
|
148
|
+
))}
|
|
149
|
+
</ul>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function textOf(result: { content?: Array<{ type: string; text?: string }> }) {
|
|
154
|
+
return result.content?.find((c) => c.type === 'text')?.text ?? '调用失败';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
createRoot(document.getElementById('root')!).render(<OrderList />);
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
原生 JS 写法(`@modelcontextprotocol/ext-apps`):
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
import { App } from '@modelcontextprotocol/ext-apps';
|
|
164
|
+
const app = new App({ name: 'order-list', version: '1.0.0' }, {});
|
|
165
|
+
app.ontoolresult = (result) => render(result.structuredContent);
|
|
166
|
+
await app.connect();
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`ontoolresult` 等回调必须在 `connect()` 之前挂好——`useApp` 用 `onAppCreated` 注册就是为此,原生写法先赋值再 `connect()`。连上之后再赋值会漏掉宿主推来的第一帧结果,界面停在「等待查询结果…」——页面本身不报错,但 ext-apps 会在控制台 warn 一句 handler 注册太晚。
|
|
170
|
+
|
|
171
|
+
宿主能力速查(`App` 实例方法):
|
|
172
|
+
|
|
173
|
+
| 方法 | 用途 |
|
|
174
|
+
|---|---|
|
|
175
|
+
| `ontoolinput` / `ontoolinputpartial` / `ontoolresult` / `ontoolcancelled` | 宿主推送的入参(完整 / 流式)、结果、取消 |
|
|
176
|
+
| `callServerTool({ name, arguments })` | 经宿主调本 server 的工具;工具名带 prefix;返回标准 `CallToolResult` |
|
|
177
|
+
| `updateModelContext({ content?, structuredContent? })` | 把界面状态同步给模型,不触发模型立刻回复 |
|
|
178
|
+
| `sendMessage({ role: 'user', content })` | 以用户身份发一条消息,触发模型回复 |
|
|
179
|
+
| `getHostContext()` / `onhostcontextchanged` | 主题、`displayMode`、`containerDimensions`、`locale`、`timeZone`、发起本次渲染的 `toolInfo` |
|
|
180
|
+
| `requestDisplayMode({ mode })` | 请求 inline / fullscreen 等展示模式,以 `availableDisplayModes` 为限 |
|
|
181
|
+
|
|
182
|
+
`useApp` 默认开自动调高(`autoResize`),内容变化不用手动发尺寸。
|
|
183
|
+
|
|
184
|
+
## 界面约束
|
|
185
|
+
|
|
186
|
+
- 不存凭证、不读 cookie / localStorage 里的登录态:iframe 是独立 origin,用户在业务系统的登录态带不进来。所有数据走 `callServerTool`,身份由宿主转发。
|
|
187
|
+
- 外部域名写进 `@McpUiResource` 的 `csp`:`connectDomains`(fetch / WebSocket)、`resourceDomains`(图片、脚本、样式)、`frameDomains`(内嵌 iframe)、`baseUriDomains`;默认全禁,漏写表现为资源静默加载失败。
|
|
188
|
+
- 业务返回值用 `textContent` 或 JSX 文本渲染,不拼 `innerHTML`。
|
|
189
|
+
- 每次调用重新注入数据;界面不假设自己能跨调用保留状态。
|
|
190
|
+
- 宿主不支持 MCP Apps 时只显示 `content` 文本,所以带界面的工具也要返回可读的文本摘要。
|
|
191
|
+
- 一个工具配一个专注的界面。一个界面同时伺候多个工具的结果,每次渲染都要先判断数据从哪来,模型也更难预期结果长什么样。
|
|
192
|
+
|
|
193
|
+
## 调试
|
|
194
|
+
|
|
195
|
+
- `mcp_inspect` `read_resource` 读 `ui://…`:成功返回 `text/html;profile=mcp-app` 与正文开头;报「读取 MCP Apps 界面产物失败」→ `dist/mcp-ui/<entry>.html` 不存在:入口名与 `readMcpUiTemplate('<entry>')` 不一致、`server/mcp/ui/<entry>/index.html` 缺失、或 dev server 还没重建,看 dev server 日志里 `[mcp-ui]` 前缀的行。构建失败行里出现「MCP UI 不能引用…」是依赖越界,按上文边界规则改 import,不改预设。
|
|
196
|
+
- 界面逻辑用 `mcp_inspect` 看不到;先用 `call` 确认 `structuredContent` 形状正确,再在预览里看渲染。宿主渲染失败时保留工具结果,只重试界面。
|
|
197
|
+
- 界面里调 `callServerTool` 失败,错误也走 server 日志:按 Trace-ID 查。
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# 工具类写法
|
|
2
|
+
|
|
3
|
+
API 形状以 `node_modules/@lark-apaas/nestjs-mcp/dist/index.d.ts` 为准。下面是当前版本的写法与约束;字段名对不上时以 d.ts 为准。
|
|
4
|
+
|
|
5
|
+
## 工具面规模
|
|
6
|
+
|
|
7
|
+
工具面不是越全越好:每个工具的 schema 在消费方 Agent 的**每一轮**都占上下文,面越大它选错越多。
|
|
8
|
+
|
|
9
|
+
| 工具数 | 做法 |
|
|
10
|
+
|---|---|
|
|
11
|
+
| ≤ 15 | 一个动作一个工具 |
|
|
12
|
+
| 15–30 | 仍可用;把只有参数差别的近重复工具并掉 |
|
|
13
|
+
| > 30 | 收敛成「检索 + 执行」几个通用工具,高频的三五个再单独开 |
|
|
14
|
+
|
|
15
|
+
用户没说清要开放哪些能力、给谁用时,先问清再写,不要把 Service 层能做的全铺出去。
|
|
16
|
+
|
|
17
|
+
## 要不要界面
|
|
18
|
+
|
|
19
|
+
结果要用户动手挑、看、或确认的工具才配界面;只是给模型继续推理的数据不配。要配的话,界面类型与写法见 `mcp-apps.md`。
|
|
20
|
+
|
|
21
|
+
## 骨架
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
// server/mcp/tools/order.tools.ts
|
|
25
|
+
import { z } from 'zod';
|
|
26
|
+
import {
|
|
27
|
+
McpTools,
|
|
28
|
+
McpTool,
|
|
29
|
+
McpToolError,
|
|
30
|
+
type Infer,
|
|
31
|
+
type McpContext,
|
|
32
|
+
type McpToolResult,
|
|
33
|
+
} from '@lark-apaas/fullstack-nestjs-core';
|
|
34
|
+
import { OrderService } from '@server/modules/order/order.service';
|
|
35
|
+
|
|
36
|
+
// schema 提成常量:方法签名用 Infer<typeof …> 引用
|
|
37
|
+
export const SearchOrdersInput = {
|
|
38
|
+
keyword: z.string().optional().describe('客户名关键字,模糊匹配'),
|
|
39
|
+
limit: z.number().int().min(1).max(50).default(20).describe('单页条数'),
|
|
40
|
+
cursor: z.string().optional().describe('上一页返回的 nextCursor;首页不传'),
|
|
41
|
+
};
|
|
42
|
+
export const SearchOrdersOutput = {
|
|
43
|
+
items: z.array(
|
|
44
|
+
z.object({
|
|
45
|
+
id: z.string(),
|
|
46
|
+
customer: z.string(),
|
|
47
|
+
amount: z.number().describe('金额,单位元'),
|
|
48
|
+
status: z.enum(['pending', 'paid', 'cancelled']),
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
nextCursor: z.string().nullable().describe('还有下一页时非空,原样传回 cursor'),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const CancelOrderInput = { orderId: z.string().describe('订单 ID,来自 order_search 的 items[].id') };
|
|
55
|
+
export const CancelOrderOutput = { orderId: z.string(), status: z.literal('cancelled') };
|
|
56
|
+
|
|
57
|
+
@McpTools({ prefix: 'order_' })
|
|
58
|
+
export class OrderMcpTools {
|
|
59
|
+
constructor(private readonly orders: OrderService) {}
|
|
60
|
+
|
|
61
|
+
@McpTool({
|
|
62
|
+
title: '查询订单',
|
|
63
|
+
description:
|
|
64
|
+
'按客户名关键字分页查询当前用户可见的订单。用户想找订单、看订单列表时调用;一页最多 50 条,超出用 cursor 翻页。',
|
|
65
|
+
inputSchema: SearchOrdersInput,
|
|
66
|
+
outputSchema: SearchOrdersOutput,
|
|
67
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
68
|
+
})
|
|
69
|
+
async search(
|
|
70
|
+
input: Infer<typeof SearchOrdersInput>,
|
|
71
|
+
ctx: McpContext,
|
|
72
|
+
): Promise<McpToolResult<typeof SearchOrdersOutput>> {
|
|
73
|
+
const userId = ctx.user.userId!; // requireUser 默认开启,执行到这里必有值;取一次往下传
|
|
74
|
+
const page = await this.orders.search({ ...input, userId });
|
|
75
|
+
return {
|
|
76
|
+
// 按 outputSchema 挑字段,不把 Service 查出来的整行记录原样回传
|
|
77
|
+
structuredContent: {
|
|
78
|
+
items: page.items.map((o) => ({ id: o.id, customer: o.customer, amount: o.amount, status: o.status })),
|
|
79
|
+
nextCursor: page.nextCursor,
|
|
80
|
+
},
|
|
81
|
+
content: [{ type: 'text', text: `找到 ${page.items.length} 条订单` }],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@McpTool({
|
|
86
|
+
title: '取消订单',
|
|
87
|
+
description:
|
|
88
|
+
'取消一个待支付订单。仅在用户明确要求取消、且订单状态为 pending 时调用;订单不存在返回 ORDER_NOT_FOUND,状态不允许返回 ORDER_NOT_CANCELLABLE。重复取消同一订单返回同一结果。',
|
|
89
|
+
inputSchema: CancelOrderInput,
|
|
90
|
+
outputSchema: CancelOrderOutput,
|
|
91
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
92
|
+
})
|
|
93
|
+
async cancel(
|
|
94
|
+
input: Infer<typeof CancelOrderInput>,
|
|
95
|
+
ctx: McpContext,
|
|
96
|
+
): Promise<McpToolResult<typeof CancelOrderOutput>> {
|
|
97
|
+
const userId = ctx.user.userId!;
|
|
98
|
+
const order = await this.orders.findVisibleTo(input.orderId, userId);
|
|
99
|
+
if (!order) throw new McpToolError('订单不存在,用 order_search 按客户名重查一个有效 ID', { code: 'ORDER_NOT_FOUND' });
|
|
100
|
+
if (order.status === 'cancelled') return { structuredContent: { orderId: order.id, status: 'cancelled' } };
|
|
101
|
+
if (order.status !== 'pending') {
|
|
102
|
+
throw new McpToolError(`订单状态为 ${order.status},只有 pending 能取消,不要重试`, { code: 'ORDER_NOT_CANCELLABLE' });
|
|
103
|
+
}
|
|
104
|
+
await this.orders.cancel(order.id, userId);
|
|
105
|
+
return { structuredContent: { orderId: order.id, status: 'cancelled' } };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
// server/modules/order/order.module.ts —— 工具类和它依赖的 Service 注册在同一个模块
|
|
112
|
+
import { Module } from '@nestjs/common';
|
|
113
|
+
// OrderController / OrderService / OrderMcpTools 的 import 略
|
|
114
|
+
|
|
115
|
+
@Module({
|
|
116
|
+
controllers: [OrderController],
|
|
117
|
+
providers: [OrderService, OrderMcpTools],
|
|
118
|
+
})
|
|
119
|
+
export class OrderModule {}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`@McpTools()` 自带 `@Injectable()`。SDK 启动时用 DiscoveryService 扫所有 provider;类没进任何模块的 `providers` 就扫不到它,启动日志不会有任何提示,工具静默缺席——启动日志「已注册 N 个 MCP 工具:…」列出的名字才是真相,对不上就是没注册。工具类及其依赖必须是单例:request / transient 作用域会在启动时抛「MCP 工具必须使用单例及单例依赖」,整个应用起不来。单例意味着所有请求共用一个实例:当前用户、本次入参、分页游标只放方法内的局部变量,写进类字段或模块级变量会在并发请求间串号。
|
|
123
|
+
|
|
124
|
+
## 装饰器选项
|
|
125
|
+
|
|
126
|
+
`@McpTool(options)`:
|
|
127
|
+
|
|
128
|
+
| 字段 | 说明 |
|
|
129
|
+
|---|---|
|
|
130
|
+
| `name?` | 默认取方法名;最终工具名 = `prefix + name`,需匹配 `^[A-Za-z0-9_.-]{1,128}$`,全应用唯一 |
|
|
131
|
+
| `title?` | 面向人的展示名,面板里显示 |
|
|
132
|
+
| `description` | 必填,面向模型 |
|
|
133
|
+
| `inputSchema?` / `outputSchema?` | zod:原始形状 `{ a: z.string() }` 或 `z.object({...})` 都行;顶层必须是对象 |
|
|
134
|
+
| `annotations?` | `readOnlyHint` / `destructiveHint` / `idempotentHint` / `openWorldHint`,四项都写 |
|
|
135
|
+
| `ui?` | `{ resourceUri: 'ui://…', visibility?: ['model' \| 'app'] }`,见 `mcp-apps.md` |
|
|
136
|
+
| `meta?` | 额外 `_meta`,与 `ui` 生成的字段合并 |
|
|
137
|
+
|
|
138
|
+
`@McpTools({ prefix? })`:给类下所有工具名加前缀,如 `order_`。
|
|
139
|
+
|
|
140
|
+
## 方法签名与返回值
|
|
141
|
+
|
|
142
|
+
- 固定 `(input, ctx)`。无入参的工具 `input` 为 `Record<string, never>`,仍要写第二个参数 `ctx`。
|
|
143
|
+
- 声明了 `outputSchema`:返回 `{ structuredContent }`,类型由 schema 推导;`content` 可选,SDK 会自动补一份 JSON 文本给不支持结构化输出的客户端。加一句业务摘要的 `content` 让文本客户端也能读。
|
|
144
|
+
- 未声明 `outputSchema`:返回 `{ content: [{ type: 'text', text }] }`;`content` 还支持 `image` / `audio` / `resource_link` 块。
|
|
145
|
+
- `isError: true` 由 SDK 在捕获 `McpToolError` 时设置,不需要手动返回。
|
|
146
|
+
|
|
147
|
+
## 返回体积
|
|
148
|
+
|
|
149
|
+
SDK 不截断,返回多少消费方 Agent 就吃多少,超了是它的上下文被挤掉。`mcp_inspect` 里看到的截断与落盘是调试通道的显示预算,与工具该返回多少无关。
|
|
150
|
+
|
|
151
|
+
- `structuredContent` 只放对方推理或界面渲染要用的字段。Service 查出来的整行记录、外键、审计字段先挑一遍再返回。
|
|
152
|
+
- 列表工具 `limit` 默认不超过 20、上限写进 zod(`.max(50)`);要更多靠 `cursor` 翻页,不靠调大 `limit`。
|
|
153
|
+
- 结果仍可能很大时自己截断,把截断标记和出路一起返回,让对方知道怎么缩小范围。两个字段都要写进 `outputSchema`,否则与声明对不上;位置放在数据字段**前面**,按尾部截断时先没的才是数组尾巴而不是这两个字段:
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
return {
|
|
157
|
+
structuredContent: {
|
|
158
|
+
truncated: true,
|
|
159
|
+
hint: '命中 847 条,只返回前 20 条;加 status 过滤或换更具体的关键字',
|
|
160
|
+
items: page.items,
|
|
161
|
+
},
|
|
162
|
+
content: [{ type: 'text', text: '命中 847 条,返回前 20 条' }],
|
|
163
|
+
};
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
- 图片、附件、长文本用 `content` 里的 `resource_link` 给指针,不内联进结果。`mcp_inspect` 只渲染文本块,这类块在调试结果里不显示,别据此以为工具什么都没返回。
|
|
167
|
+
|
|
168
|
+
## McpContext
|
|
169
|
+
|
|
170
|
+
| 字段 | 用途 |
|
|
171
|
+
|---|---|
|
|
172
|
+
| `user` | 与 REST 里的 `req.userContext` 同源:`userId` / `tenantId` / `roles` / `env`(`preview` \| `runtime`)等,字段全部可选(`userId?: string`)。默认 `requireUser: true`,缺 `userId` 时 SDK 直接返回 `MCP_USER_REQUIRED`、方法不执行,所以方法体开头 `const userId = ctx.user.userId!;` 取一次往下传;不要为迁就类型去改 Service 签名 |
|
|
173
|
+
| `request` | 原始 HTTP 请求,读 header、logid |
|
|
174
|
+
| `signal` | 客户端取消时触发,长任务把它传给下游 |
|
|
175
|
+
| `requestId` | MCP 请求 id |
|
|
176
|
+
| `extra` | SDK 的 `RequestHandlerExtra`(`sessionId` / `requestId` / `authInfo` 等)。它有 `sendNotification`,但端点是无状态 JSON 模式,`notifications/progress` 发不到客户端——长任务别靠进度通知续命,拆成「发起 + 查状态」两个工具 |
|
|
177
|
+
|
|
178
|
+
工具执行中途要不到用户输入:SDK 没有 elicitation,无状态 JSON 模式下服务端发往客户端的请求也出不去(与进度通知同因)。需要用户确认或补参数时,把它做成入参交给模型去问,或者做确认型界面(见 `mcp-apps.md`),不要设计成「工具跑一半停下来等人」。
|
|
179
|
+
|
|
180
|
+
## 错误处理
|
|
181
|
+
|
|
182
|
+
- 可预期的业务失败抛 `new McpToolError(message, { code?, data? })`:SDK 转成 `isError: true`,`message` / `code` / `data` 原样进 Agent 上下文。`code` 用 `SCREAMING_SNAKE`,并在 `description` 里预告,Agent 才知道怎么应对。
|
|
183
|
+
- `message` 写成「哪里错了 + 下一步做什么」:「订单不存在,用 order_search 按客户名重查一个有效 ID」。错误消息必达,应用 Skill 里的处理说明要消费方主动取 Prompt 才看得到,出路别只写在那边。
|
|
184
|
+
- 其他异常:SDK 记日志、返回通用失败提示,Agent 看不到原因。调试时看到「通用失败提示」就去读 Trace-ID 日志。
|
|
185
|
+
- 入参校验交给 zod,方法体不重复判空;zod 失败由 SDK 返回,方法不执行。
|
|
186
|
+
|
|
187
|
+
## description 怎么写
|
|
188
|
+
|
|
189
|
+
消费方 Agent 只靠 `description` 与字段 `.describe()` 决定调不调、怎么填。写全五件事:用途、何时调用(含前置条件)、参数从哪来(「来自 order_search 的 items[].id」)、返回什么、失败会返回哪些 code。写操作再加一句幂等语义(「重复取消返回同一结果」)。
|
|
190
|
+
|
|
191
|
+
字段描述写单位、格式、取值来源;枚举用 `z.enum`;入参不用 `z.any()` / `z.unknown()`。
|
|
192
|
+
|
|
193
|
+
## 开通(首次)
|
|
194
|
+
|
|
195
|
+
1. 前置检查通过(见 SKILL.md)。
|
|
196
|
+
2. 建 `server/mcp/tools/<域>.tools.ts`,至少一个只读工具复用现有 Service。
|
|
197
|
+
3. 把工具类加进所属业务模块的 `providers`。
|
|
198
|
+
4. 写 `server/mcp/skills/<name>/SKILL.md`(下节)。
|
|
199
|
+
5. 跑 `lark-cli apps +mcp-key-create --app-id "$app_id"` 备好运行态凭证。
|
|
200
|
+
6. 进 `debugging.md` 的调试回路。
|
|
201
|
+
|
|
202
|
+
## 应用 Skill:`server/mcp/skills/<name>/SKILL.md`
|
|
203
|
+
|
|
204
|
+
给消费方 Agent 的使用说明。一个业务流程一个目录,目录里只认固定文件名 `SKILL.md`。SDK 把每份注册为同名 MCP Prompt:`prompts/list` 给 `name` / `description`,`prompts/get` 返回去掉 frontmatter 的正文(一条 `role: user` 文本消息)。它不是 Resource、没有 `skill://` URI,`resources/list` 里看不到它是正常的。每次请求重新读文件,改完不用重启。
|
|
205
|
+
|
|
206
|
+
frontmatter 规则。SDK 每次请求都会校验,不合法时整个 MCP 请求 500,不会静默跳过这一份:
|
|
207
|
+
|
|
208
|
+
- `name` 必填且等于目录名:1–64 位小写字母、数字与单个连字符,不能首尾连字符、不能连续 `--`。
|
|
209
|
+
- `description` 必填非空,不超过 1024 字符;消费方只靠它决定要不要取这份 Prompt,写清适用场景。
|
|
210
|
+
- 全应用不重名;最多 100 份,单文件不超过 1 MiB,合计不超过 8 MiB;必须是工程内普通文件,不接受符号链接。
|
|
211
|
+
- 只有 `SKILL.md` 会被读取和交付,同目录的图片、附件不会。
|
|
212
|
+
|
|
213
|
+
正文必含:适用场景、实际工具名与关键参数、调用顺序、需要用户确认的边界、身份与权限要求、每个错误 code 的处理、界面 URI(如有)。只写已实现的能力;不写凭证、用户数据、内部实现。
|
|
214
|
+
|
|
215
|
+
示例 `server/mcp/skills/orders/SKILL.md`。`---` 必须是文件第一行,前面不能有任何字符,注释也不行:
|
|
216
|
+
|
|
217
|
+
```markdown
|
|
218
|
+
---
|
|
219
|
+
name: orders
|
|
220
|
+
description: 查询当前用户可见的订单,并在用户明确要求后取消待支付订单。
|
|
221
|
+
---
|
|
222
|
+
# 订单能力
|
|
223
|
+
- 需要已登录用户身份;只返回当前用户可见的订单。
|
|
224
|
+
- 找订单:order_search,keyword 模糊匹配客户名;nextCursor 非空时用 cursor 翻页。
|
|
225
|
+
- 取消订单:只有用户明确要求取消、且 status 为 pending 时调用 order_cancel;orderId 取自 order_search 的 items[].id。
|
|
226
|
+
- ORDER_NOT_FOUND:请用户核对订单 ID。ORDER_NOT_CANCELLABLE:告知当前状态,不重试。
|
|
227
|
+
- 支持界面的宿主会用 ui://order/list 展示查询结果。
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## 定义校验
|
|
231
|
+
|
|
232
|
+
工具名格式与重名、资源 URI 的 `ui://` 前缀与重名、`ui.resourceUri` 能否找到对应的 `@McpUiResource()`——这些在应用启动时统一校验,失败直接阻断启动并打印「MCP 定义校验失败」,逐条列出问题与出错的类和方法。一个方法不能同时标 `@McpTool()` 与 `@McpUiResource()`。改完等 dev server 重启看日志即可,没有单独的离线校验命令。
|
|
233
|
+
|
|
234
|
+
## 模块配置
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
PlatformModule.forRoot({
|
|
238
|
+
mcp: {
|
|
239
|
+
serverName: 'travel-approval', // 首次开通 MCP 时按用途设置,别留缺省
|
|
240
|
+
serverVersion: '1.0.0',
|
|
241
|
+
instructions: '…', // initialize 响应里的说明
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
// 关闭端点:PlatformModule.forRoot({ mcp: false })
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
**`serverName` 首次开通时就设上。** `initialize` 把它返回给连上来的外部 Agent,`mcp_inspect` 的 `list_tools` 也显示它;不设则是 `miaoda-app`,谁看都不知道是哪个应用。取材于本 MCP 做什么(差旅审批 → `travel-approval`,库存查询 → `inventory`),小写字母 + 数字 + `-`/`_`,两三个词以内。
|
|
248
|
+
|
|
249
|
+
其余项一般不动。`requireUser` 保持默认 `true`。零工具、零资源、无应用 Skill 时端点返回 404,这是正常状态不是故障。
|
|
@@ -93,12 +93,14 @@ match-template-name: nestjs-react-fullstack
|
|
|
93
93
|
|
|
94
94
|
> 完整的解析→守卫流程见 `authn-guide` skill 第二节"认证流程"。
|
|
95
95
|
|
|
96
|
+
类型使用规则:Controller 直接用 `@Req() req: Request` 读取 SDK 增强的 `req.userContext`。测试、mock 或局部业务需要窄类型时,定义独立接口,只声明实际读取字段;不要继承 Express `Request`,也不要重声明平台增强的 `userContext`。未读取的 `tenantId` 省略;必须保留时用 `unknown` 或 SDK 兼容类型。
|
|
97
|
+
|
|
96
98
|
### 字段表
|
|
97
99
|
|
|
98
100
|
| 字段 | 类型 | 说明 |
|
|
99
101
|
|------|------|------|
|
|
100
102
|
| `userId` | `string` | 妙搭用户 ID |
|
|
101
|
-
| `tenantId` | `number` | 租户 ID |
|
|
103
|
+
| `tenantId` | `number \| undefined` | 租户 ID;按 SDK Express `Request.userContext` 增强:可选 number |
|
|
102
104
|
| `appId` | `string` | 应用 ID |
|
|
103
105
|
| `loginUrl` | `string` | 登录跳转 URL |
|
|
104
106
|
| `userType` | `string` | 用户类型(如 `_employee`) |
|
|
@@ -14,6 +14,8 @@ match-template-name: nestjs-react-fullstack
|
|
|
14
14
|
|
|
15
15
|
> **边界说明**:`authClient.session` 仅用于用户登录/登出/获取用户信息等鉴权操作。**插件调用(capability)不属于账户 SDK**,须使用独立的 `capabilityClient`(参见 plugin-guide)。
|
|
16
16
|
>
|
|
17
|
+
> **本地 dev 限制**:本 skill 的能力底层打 `/app/<appId>/__runtime__/api/v1/account/*`。这类端点只有在 `npm run dev:local` 成功 `env pull` 到 `MIAODA_DEV_PLATFORM_BASE`(或 `SANDBOX_PUBLIC_URL`)时才由 dev-proxy 反代到远端沙箱;**env pull 失败或未装 lark-cli 时该反代不挂**(启动脚本只 warn 不中断),表现为 POST 404 / GET 返回 200 + index.html。`useCurrentUserProfile()` 打的是 `account/login/user`;UserSelect / DepartmentSelect / UserDisplay 打 `search_user` / `list_users` / `user_profile` / `search_department`。先确认 `.env.local` 里有 `MIAODA_DEV_PLATFORM_BASE`;确实缺失时禁止改 `business-ui` 组件或换自研控件绕过,改由服务端出数据(`req.userContext` / `AuthNPaasService`)。
|
|
18
|
+
>
|
|
17
19
|
> **运行时边界**:本 skill 所有能力(`authClient`、`useCurrentUserProfile`、UserSelect/UserDisplay 等)仅限前端代码使用,**严禁在 `server/**` 中 import**。服务端获取用户身份用 `req.userContext` / `AuthNPaasService`(见 `user-identity` skill),完整边界规则见 coding-guide。
|
|
18
20
|
|
|
19
21
|
## 怎么选(决策指引)
|
|
@@ -116,6 +116,12 @@ Module '"@client/src/api/gen"' has no exported member named 'DiscrepancyResponse
|
|
|
116
116
|
- [ ] 已根据实际定义修正代码
|
|
117
117
|
- [ ] 已验证修改后类型使用正确
|
|
118
118
|
|
|
119
|
+
## 业务日期错位
|
|
120
|
+
|
|
121
|
+
**问题描述**:本地凌晨、周一或月初附近,日期落到前一天、上周或上月。
|
|
122
|
+
|
|
123
|
+
**解决步骤**:按 `coding-guide` 的「业务日期」一节改——自然日统一走业务时区 helper;精确时刻不动。
|
|
124
|
+
|
|
119
125
|
## JSX 和语法错误
|
|
120
126
|
|
|
121
127
|
### 特殊字符转义处理
|
|
@@ -129,7 +129,7 @@ shared/ # 前后端共享的目录
|
|
|
129
129
|
|
|
130
130
|
1. 优先使用项目已有依赖,仅在无法实现时安装新依赖
|
|
131
131
|
2. 使用前先查看 `package.json` 确保依赖已存在
|
|
132
|
-
3. **子包完整性检查**:部分库有多个子包(如 `@
|
|
132
|
+
3. **子包完整性检查**:部分库有多个子包(如 `@radix-ui/react-*` 系列每个组件都是独立子包),添加 import 后必须确认 package.json 中包含所有需要的子包
|
|
133
133
|
4. 用法不清时查看 readme,可进一步搜索或网页访问获取信息
|
|
134
134
|
|
|
135
135
|
## 文件命名约定
|
|
@@ -147,6 +147,39 @@ shared/ # 前后端共享的目录
|
|
|
147
147
|
- `/api` 和 `/openapi` 代理已配置,前端 TS 严格模式已禁用,后端已启用
|
|
148
148
|
- Rspack 用于构建,已配置好,**禁止修改**
|
|
149
149
|
|
|
150
|
+
### 平台 runtime 接口(`__runtime__`)在本地要靠 env pull 才通
|
|
151
|
+
|
|
152
|
+
dev server 自己只代理 `/api`、`/openapi`、`/__innerapi__`。`/app/<appId>/__runtime__/*` 这一整类平台 runtime 接口走的是另一条路:`npm run dev:local` 会先跑 `lark-cli apps +env-pull` 把沙箱身份/凭证拉进 `.env.local`,其中的 `MIAODA_DEV_PLATFORM_BASE`(或 legacy `SANDBOX_PUBLIC_URL`)才让 dev-proxy 把 `__runtime__` 反代到远端沙箱。
|
|
153
|
+
|
|
154
|
+
**env pull 失败、未装 lark-cli、或 `.spark/meta.json` 缺 app_id 时,启动脚本只 warn 不中断**,反代就不会挂上。此时这些端点的症状是:**POST 返回 404;GET 落到 Vite 的 SPA fallback,返回 200 + `index.html`**(业务侧表现为「返回一坨 HTML,JSON 解析失败」)。
|
|
155
|
+
|
|
156
|
+
常踩到的端点:
|
|
157
|
+
|
|
158
|
+
| 端点 | 方法 | 谁会打到它 |
|
|
159
|
+
|------|------|-----------|
|
|
160
|
+
| `api/v1/permissions/roles` | GET | `AppContainer` 每次加载应用自动打 |
|
|
161
|
+
| `api/v1/observability/{logs,traces,metrics}/collect`、`current_server_timestamp` | POST/GET | 平台埋点上报,自动打 |
|
|
162
|
+
| `api/v1/account/login/user` | POST | `useCurrentUserProfile()` → `authClient.session.getUserInfo()` |
|
|
163
|
+
| `api/v1/account/search_user`、`list_users`、`user_profile`、`search_department`、`convert_lark_user` | POST / `user_profile` 是 GET | `business-ui` 的 UserSelect / DepartmentSelect / UserDisplay |
|
|
164
|
+
| `api/v1/account/search`、`api/v1/account/chat/list_chats` | POST | ChatSelect 选群组件 |
|
|
165
|
+
| `api/v1/studio/user/profile` | GET | `getUserProfile()` |
|
|
166
|
+
| `api/v1/storage/object/<bucket>/pre_upload` | POST | `dataloom.storage.uploadFile()` 前端上传 |
|
|
167
|
+
| `api/v1/studio/plugins/tmp_files/acquire_upload_url`、`acquire_download_url` | POST | `capabilityClient` 的文件类入参 |
|
|
168
|
+
|
|
169
|
+
> `useCurrentUserProfile()` 打的是 `account/login/user`,**不是** `user_profile`/`search_user` 那一排;它另外还打一个 `GET /api/authnpaas/lark-user-id`,那条走 `/api`、被代理、正常。
|
|
170
|
+
|
|
171
|
+
排查顺序:先看 `.env.local` 里有没有 `MIAODA_DEV_PLATFORM_BASE`,没有就重跑 `env pull`。确认拉不到时:
|
|
172
|
+
|
|
173
|
+
- **禁止**改 `client/src/components/business-ui/**`(平台保护文件)、**禁止**给组件加兜底请求或改用自研选人控件绕过——发布后这些端点是正常的,绕过写法反而是错的
|
|
174
|
+
- 需要验证「按当前用户过滤」「展示用户姓名」这类逻辑:改由服务端出数据。服务端的 `req.userContext` 和 `AuthNPaasService` 由网关注入,不受此限制(见 `user-identity` / `contacts-service` skill)。`useCurrentUserProfile()` 长期停在加载态就是这个原因
|
|
175
|
+
- 需要验证上传链路:改用接口测试直接打后端接口,或复用库里已有的文件 URL
|
|
176
|
+
|
|
177
|
+
另外,`UserSelect` 弹层是**搜索驱动**的:不输关键词时列表为空属预期行为,不是接口挂了。
|
|
178
|
+
|
|
179
|
+
### 服务端启动即崩、`/api` 全部 502
|
|
180
|
+
|
|
181
|
+
报错形如 `UnknownDependenciesException: Nest can't resolve dependencies of the XxxService (?)... argument Function at index [0]` 时,先查构造函数有没有空参 `@Inject()`(见 plugin-guide),不要先去改 Module 的 `imports` / `providers`。
|
|
182
|
+
|
|
150
183
|
## 质量保障流程
|
|
151
184
|
|
|
152
185
|
修改代码后:
|
|
@@ -286,6 +319,12 @@ await db.select().from(users).where(eq(users.adminUser, userId));
|
|
|
286
319
|
|
|
287
320
|
- **`customTimestamptz` 跨网络后是 string**:service 内查询返回 `Date`,但 API 响应经 JSON 序列化后前端拿到 ISO string。`shared/api.interface.ts` 中 timestamptz 字段声明为 `string`,前端需要 Date 时手动 `new Date(value)`。
|
|
288
321
|
|
|
322
|
+
### 业务日期
|
|
323
|
+
|
|
324
|
+
需求里的日期是**自然日**(哪一天 / 哪一周 / 哪一月)而不是精确时刻时,按业务时区计算,未指定时默认 `Asia/Shanghai`。
|
|
325
|
+
|
|
326
|
+
项目里放一个 helper 统一产出 `day` / `month` / `weekStart` / `shiftDay`;服务端写入、统计聚合、前端展示都比较它的返回值。
|
|
327
|
+
|
|
289
328
|
### 数据库使用强约束
|
|
290
329
|
|
|
291
330
|
- 仅通过 schema.ts 暴露的客户端和类型读写,禁止手写 SQL/临时类型
|
|
@@ -329,10 +368,13 @@ await db.select().from(users).where(eq(users.adminUser, userId));
|
|
|
329
368
|
|
|
330
369
|
- **必须从 `req.userContext` 获取**,禁止从前端传递,禁止硬编码
|
|
331
370
|
- 依赖用户信息的接口不要用 API 测试工具测试,用 `think` 工具思考
|
|
371
|
+
- 后端按 `user-identity` 和 `@lark-apaas/fullstack-nestjs-core` 的 Express `Request` 增强读取 `req.userContext`;`tenantId` 为可选 number,不能套用前端 `window.tenantId` / `viewContext` 字符串语义。
|
|
372
|
+
- Controller 直接用 `@Req() req: Request`;测试、mock 或业务窄类型用独立接口,只声明实际读取字段。不要继承 Express `Request` 或重声明 `userContext`;未读 `tenantId` 就省略,必须保留时用 `unknown` 或 SDK 兼容类型。
|
|
332
373
|
|
|
333
374
|
```typescript
|
|
334
|
-
// req.userContext
|
|
335
|
-
// - userId/
|
|
375
|
+
// req.userContext 字段(由身份认证中间件挂载,完整合同见 user-identity):
|
|
376
|
+
// - userId/appId: string
|
|
377
|
+
// - tenantId: SDK Request.userContext 的可选 number
|
|
336
378
|
// - env: 'preview' | 'runtime' (preview=预览态, runtime=发布运行态)
|
|
337
379
|
// - userName/userNameEn: string
|
|
338
380
|
// - userNameI18n: string (多语名字, 如 {zh_cn: '用户', en_us: 'user'})
|
|
@@ -443,14 +485,13 @@ NestJS 自己不读 env。直连 NestJS 端口 → header 缺失 → `req.userCo
|
|
|
443
485
|
|
|
444
486
|
- **框架**: React 19 + TypeScript
|
|
445
487
|
- **路由**: React Router DOM v6
|
|
446
|
-
- **样式**:
|
|
488
|
+
- **样式**: tailwindcss(语义化 token)
|
|
447
489
|
- **UI 组件库**: shadcn/ui — Use components for functionality, heavily style them
|
|
448
490
|
- **图表**: ReactECharts,**开发前必须调用 `/charts-skill`**
|
|
449
491
|
- **图标**: Lucide React(唯一图标库,禁止 Emoji 和其他图标库)
|
|
450
492
|
- **表格/表单/图表**: 见下方"组件 Skill 召回规则",开发前必须先调用对应 Skill
|
|
451
493
|
- **用户**: 用户信息展示/选择必须用 `business-ui` 组件(阅读 README.md),禁止直接展示 userId
|
|
452
|
-
-
|
|
453
|
-
- **Markdown 渲染**: `components/ui/streamdown`(内置 prose 排版)
|
|
494
|
+
- **Markdown 渲染**: `components/ui/markdown`(react-markdown + remark-gfm,内置 prose 排版)
|
|
454
495
|
|
|
455
496
|
## API 请求
|
|
456
497
|
|
|
@@ -483,8 +524,7 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
|
|
|
483
524
|
| --------------------------------------------------- | --------------------------------------- | --------------------------------- |
|
|
484
525
|
| Table | `@lark-apaas/client-toolkit/antd-table` | 数据表格,**先调 `/table-skill`** |
|
|
485
526
|
| UserSelect/UserDisplay/UserProfile/DepartmentSelect | `business-ui/*` | 用户/部门选择展示 |
|
|
486
|
-
|
|
|
487
|
-
| Streamdown | `components/ui/streamdown` | Markdown/流式渲染 |
|
|
527
|
+
| Markdown | `components/ui/markdown` | Markdown 渲染(react-markdown + remark-gfm) |
|
|
488
528
|
|
|
489
529
|
### 组件 Skill 召回规则(强制执行)
|
|
490
530
|
|
|
@@ -535,7 +575,6 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
|
|
|
535
575
|
```
|
|
536
576
|
需要写样式?
|
|
537
577
|
├─ 基础布局/间距/颜色 → Tailwind ✅
|
|
538
|
-
├─ 复杂动画/伪元素/高级CSS → styled-jsx ✅
|
|
539
578
|
└─ JS动态计算值 → 行内 style ✅
|
|
540
579
|
```
|
|
541
580
|
|
|
@@ -548,11 +587,6 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
|
|
|
548
587
|
- **arbitrary values 中空格用下划线**:`from-[hsl(215_60%_18%)]` 非 `from-[hsl(215 60% 18%)]`
|
|
549
588
|
- `tailwind-theme.css` 自定义属性用 `hsl(H, S%, L%)` 格式(非 `23 10% 23%`)
|
|
550
589
|
|
|
551
|
-
### styled-jsx 规范
|
|
552
|
-
|
|
553
|
-
- **技术栈一致性**:仅在已配置 styled-jsx 插件的项目中使用。`package.json` 无 `styled-jsx` 依赖则**禁用**,否则运行时 SyntaxError
|
|
554
|
-
- **禁止动态插值**:`<style jsx>` 内禁止 `${...}` 等表达式(会卡死)。动态值放 CSS 变量,用 `var(--xxx)` 引用
|
|
555
|
-
|
|
556
590
|
### 布局/排版
|
|
557
591
|
|
|
558
592
|
- Spacing 保持一致(small/medium/large 三级),Panel 风格统一
|
|
@@ -635,15 +669,8 @@ return <h1>{data?.title || '未知标题'}</h1>;
|
|
|
635
669
|
| 验证 | zod |
|
|
636
670
|
| 工具函数 | lodash |
|
|
637
671
|
| 样式 | clsx |
|
|
638
|
-
| Excel | xlsx(**仅前端实现,禁止服务端实现**。解析后将结构化数据传到服务端保存) |
|
|
639
|
-
| PDF 导出 | jspdf + html2canvas(**仅前端实现,禁止服务端实现**) |
|
|
640
|
-
| 文件上传 | react-dropzone |
|
|
641
|
-
| 二维码 | qrcode.react |
|
|
642
672
|
| 用户反馈 | sonner |
|
|
643
|
-
|
|
644
|
-
| 数字动画 | react-countup |
|
|
645
|
-
| Base64 | js-base64 — `import { encode, decode } from 'js-base64'` |
|
|
646
|
-
| 3D 场景 | cobe |
|
|
673
|
+
|
|
647
674
|
|
|
648
675
|
## 滚动分页最佳实践
|
|
649
676
|
|