@lark-apaas/coding-steering 0.1.0-alpha.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/coding-steering",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0",
4
4
  "description": "Stack-specific steering content for miaoda-coding templates",
5
5
  "type": "module",
6
6
  "files": ["steering"],
@@ -0,0 +1,498 @@
1
+ ---
2
+ name: plugin-guide
3
+ description: "Use when 需要:(1) 创建或管理 PluginInstance 插件实例,(2) 调用 capabilityClient 生成插件调用代码,(3) 理解 Plugin、PluginInstance、PluginInstanceAIJson 三层关系,(4) 使用 get_plugin_ai_json 或 plugin_instance 工具。触发词:插件, plugin, 飞书消息, 飞书群组, 多维表格, AI生文, AI生图, 图片理解, capabilityClient, pluginInstance"
4
+ steering: true
5
+ steering-topic: plugin_guide
6
+ ---
7
+
8
+ # Plugin 集成指南
9
+
10
+ 飞书/AI 新版插件集成规范与 Plugin 链路指南。支持的能力包括:飞书多维表格/Base操作(插入记录、更新记录、删除记录、查询记录)、发送飞书消息、创建飞书群组、AI智能生文、AI智能生图、AI图片理解等。本文档介绍插件实例配置、运行时投影(get_plugin_ai_json)的用法,以及 `capabilityClient` 调用入口;具体业务能力需以实际 available_plugin_instances 与对应 plugin.ai.json 为准。
11
+
12
+ ## ⚠️ 铁律:禁止 Mock 插件调用
13
+
14
+ **当 AGENTS.md 包含「插件规划」时,所有规划的插件必须通过 `plugin_instance` 工具创建并通过 `capabilityClient` 真实调用。**
15
+
16
+ | 禁止 | 正确 |
17
+ |------|------|
18
+ | ❌ `setTimeout` + 硬编码数据模拟 AI 返回 | ✅ `(capabilityClient as any).load(id)` → `(executor as any).call(actionKey, input)` |
19
+ | ❌ `picsum.photos` / 随机图片替代 AI 生图 | ✅ `(capabilityClient as any).load(id)` → `(executor as any).call('textToImage', {...})` |
20
+ | ❌ 注释掉 `capabilityClient` import 用 Mock 函数 | ✅ 真实 import 并调用(详见调用规范) |
21
+ | ❌ AGENTS.md 规划了插件但 `shared/capabilities/` 为空 | ✅ 每个规划的插件都有对应 JSON 文件 |
22
+ | ❌ `Math.random()` 替代 AI 分类/评分/推荐结果 | ✅ 使用插件返回值中的实际字段 |
23
+ | ❌ `logger.log(input); return { success: true }` 占位通知 | ✅ 通过 `capabilityClient` 真实调用 send-feishu-message |
24
+ | ❌ `const PRESET_DATA = [...]` 替代插件调用 | ✅ 调用插件获取真实结果 |
25
+
26
+ > **与 Mock 展示数据的区别**:design_agent prompt 中的 "Mock 数据仅用于无附件的 prototype" 指的是表格示例行、卡片占位文本等**展示数据**。AI 生文/生图/翻译/结构化等**插件能力不属于 Mock 数据范围**,必须真实调用。
27
+
28
+ ## Quick Reference
29
+
30
+ | 操作 | 方式 |
31
+ |------|------|
32
+ | 创建/更新 PluginInstance | 调用 `plugin_instance` 工具(禁止手动改文件) |
33
+ | 获取运行时投影 | 调用 `get_plugin_ai_json(pluginInstanceId)` |
34
+ | 非流式调用 | `const ex = (capabilityClient as any).load(id); await (ex as any).call(actionKey, input)` |
35
+ | 流式调用 | `const ex = (capabilityClient as any).load(id); for await (const c of (ex as any).callStream(actionKey, input)) {}` |
36
+ | capabilityClient 导入 | `import { capabilityClient } from '@lark-apaas/client-toolkit-lite'` |
37
+ | 配置存储位置 | `shared/capabilities/<plugin_instance_id>.json` |
38
+
39
+ > **capabilityClient 导入警告**:`capabilityClient` 是从 `@lark-apaas/client-toolkit-lite` 直接导入的独立对象,**不是**从 `getDataloom()` 上获取的。错误写法:`const dataloom = await getDataloom(); (dataloom as any).capability` — 这样写不会工作。正确且唯一的方式:`import { capabilityClient } from '@lark-apaas/client-toolkit-lite'`。
40
+ >
41
+ > **调用规范:双重 `as any` 强转**。lite 包暴露的 `capabilityClient` 类型是泛化合同(actionKey / input / output 都依赖运行时 `pluginInstance.ai.json`),直接调用编译器无法约束 schema。vite-react 项目里**canonical 写法是拆两步、各加一次 `as any`**:
42
+ >
43
+ > ```ts
44
+ > import { capabilityClient } from '@lark-apaas/client-toolkit-lite';
45
+ >
46
+ > const executor = (capabilityClient as any).load('ai_image_generation_quality_1');
47
+ > const result = await (executor as any).call('textToImage', {
48
+ > prompt: prompt.trim(),
49
+ > });
50
+ > ```
51
+ >
52
+ > 下方所有代码示例都遵守这个模式。链式 `(capabilityClient as any).load('id').call('action', input)` 也合法(一次 cast 让整条链路推导为 `any`),但拆步骤更便于打日志、错误处理和复用 executor。
53
+
54
+ ## Plugin 代码编写指南
55
+
56
+ 以下场景**必须**先读取 `references/plugin-coding-guide.md` 再编写代码:
57
+
58
+ - 编写 `capabilityClient` 调用代码时(含导入路径、调用方式)
59
+ - 处理流式输出(`outputMode = stream`),包括多插件并行流式、单插件 JSON 流式解析
60
+ - 需要根据 `outputMode` 选择 `call()` 或 `callStream()` 时
61
+
62
+ ## 飞书多维表格(feishu-bitable)编码指南
63
+
64
+ 以下场景**必须**先读取 `references/table.md` 再编写代码:
65
+
66
+ - 编写飞书多维表格 CRUD 代码时(searchRecords / getRecord / batchAddRecords / batchUpdateRecords / deleteRecords)
67
+ - 需要了解 BizType 字段类型、读写格式差异时
68
+ - 构建搜索过滤条件(filter / conditions)时
69
+ - 使用多维表格相关 UI 组件(人员展示、超链接、单/多选下拉 等;vite-react 用 shadcn-ui 拼装)时
70
+
71
+ ## Plugin 链式调用(Plugin Chain)
72
+
73
+ 很多业务场景需要多个插件串联完成,**禁止用正则/字符串解析替代 AI 插件做结构化输出处理**(包括提取和生成场景)。
74
+
75
+ ### 插件能力分类
76
+
77
+ | 类别 | 插件 | 输入→输出 |
78
+ |------|------|----------|
79
+ | **内容提取** | `ai-doc-parser` | 文档(PDF/DOC/PPTX/XLSX/CSV等9种)→纯文本 |
80
+ | **内容提取** | `ai-speech-to-text` | 音频→纯文本 |
81
+ | **内容提取** | `ai-image-understanding` | 图片→文本描述(流式,适合理解/问答) |
82
+ | **结构化提取** | `ai-text-to-json` | 文本→结构化 JSON(最多20字段) |
83
+ | **结构化提取** | `ai-image-to-json` | 图片→结构化 JSON(最多20字段,**单步直达**) |
84
+ | **结构化提取** | `ai-categorization` | 文本→分类标签 |
85
+ | **内容生成** | `ai-text-generate` | 提示词→文本(流式) |
86
+ | **内容生成** | `ai-text-to-image` | 文字描述→图片 |
87
+ | **内容生成** | `ai-text-summary` | 长文本→摘要(流式) |
88
+ | **内容生成** | `ai-translate` | 文本→翻译(12种语言,流式) |
89
+ | **内容生成** | `ai-search-summary` | 搜索词→网页摘要(流式) |
90
+ | **内容生成** | `ai-speech-synthesis` | 文本→语音(44+音色) |
91
+ | **图片处理** | `ai-image-matting` | 图片→抠图/去背景/去水印 |
92
+ | **图片处理** | `ai-background-replace` | 主体图+背景→合成图 |
93
+ | **图片处理** | `ai-image-to-image` | 参考图(1-5张)+描述→编辑/风格转换 |
94
+ | **图片处理** | `ai-image-compare` | 两张图→对比分析(流式) |
95
+ | **外部服务** | `feishu-bitable` | CRUD 飞书多维表格(5个Action) |
96
+ | **外部服务** | `send-feishu-message` | 发送飞书卡片消息 |
97
+ | **外部服务** | `feishu-group-create` | 创建飞书群组 |
98
+
99
+ ### 决策树:选择单步还是链式
100
+
101
+ ```
102
+ 输入是什么?
103
+ ├── 文档文件 → 必须先用 ai-doc-parser 提取文本,再根据目标选择下游插件:
104
+ │ ├── 需要结构化数据 → ai-doc-parser → ai-text-to-json (2步链)
105
+ │ ├── 需要摘要 → ai-doc-parser → ai-text-summary
106
+ │ ├── 需要翻译 → ai-doc-parser → ai-translate
107
+ │ ├── 需要分类 → ai-doc-parser → ai-categorization
108
+ │ └── 仅需原文 → ai-doc-parser(单步)
109
+ ├── 图片 → ⚠️ 注意选择正确的插件:
110
+ │ ├── 提取结构化数据(发票/名片/证件等)→ ai-image-to-json(⭐ 单步直达!)
111
+ │ ├── 理解内容后提取结构化数据 → ai-image-understanding → ai-text-to-json(2步链)
112
+ │ ├── 抠图后换背景 → ai-image-matting → ai-background-replace(2步链)
113
+ │ └── 理解/问答/编辑/对比 → 对应单插件即可
114
+ ├── 音频
115
+ │ ├── 需要结构化数据 → ai-speech-to-text → ai-text-to-json(2步链)
116
+ │ └── 仅需文字 → ai-speech-to-text(单步)
117
+ └── 纯文本
118
+ ├── 需要结构化数据 → ai-text-to-json(⭐ 单步直达!)
119
+ └── 摘要/翻译/分类/生成
120
+ ├── 输出包含多个独立字段(标题+正文+评分等)
121
+ │ → 拆成多个独立插件并行调用(⭐ 优先)或用 ai-text-to-json
122
+ │ → ⛔ 禁止用 ai-text-generate + 正则/split 解析多字段
123
+ └── 输出为单一文本(仅展示,不需解析)→ ai-text-generate
124
+ ```
125
+
126
+ ### 常见 Plugin Chain 组合
127
+
128
+ | 链路 | 插件组合 | 场景举例 |
129
+ |------|---------|---------|
130
+ | 文档→结构化数据 | `ai-doc-parser` → `ai-text-to-json` | 简历PDF→员工档案、合同→结构化条款 |
131
+ | 文档→摘要 | `ai-doc-parser` → `ai-text-summary` | 研报PDF→摘要、长文档→概要 |
132
+ | 文档→翻译 | `ai-doc-parser` → `ai-translate` | 英文论文→中文翻译 |
133
+ | 文档→分类 | `ai-doc-parser` → `ai-categorization` | 工单文档→类型标签 |
134
+ | 图片→结构化数据 | `ai-image-to-json`(**单步**) | 发票→金额/日期、名片→联系人 |
135
+ | 图片理解→结构化 | `ai-image-understanding` → `ai-text-to-json` | 复杂图表→数据、截图→字段 |
136
+ | 音频→结构化数据 | `ai-speech-to-text` → `ai-text-to-json` | 会议录音→待办事项 |
137
+ | 音频→翻译 | `ai-speech-to-text` → `ai-translate` | 外语录音→中文 |
138
+ | 抠图→换背景 | `ai-image-matting` → `ai-background-replace` | 商品图→电商主图 |
139
+ | 生成内容→通知 | `ai-text-generate` → `send-feishu-message` | AI生成报告→发送飞书通知 |
140
+ | 提取数据→入库 | `ai-text-to-json` → `feishu-bitable` | 文本→结构化→写入多维表格 |
141
+
142
+ ### Plugin Chain 调用模式
143
+
144
+ ```typescript
145
+ // 示例:文档 → 结构化数据(2步链)
146
+ // Step 1: 内容提取(注意:fileUrl 通常为数组类型,必须按 inputSchema 传入)
147
+ const docParser = (capabilityClient as any).load('doc_parser_instance');
148
+ const rawResult = await (docParser as any).call('parseDocToMarkdown', { fileUrl: [docUrl] });
149
+
150
+ // Step 2: AI 结构化提取
151
+ const jsonExtractor = (capabilityClient as any).load('text_to_json_instance');
152
+ const structured = await (jsonExtractor as any).call('textToJson', { text: rawResult.content });
153
+
154
+ // Step 3: 使用结果(填入表单 / 写入数据库 / 写入多维表格等)
155
+ ```
156
+
157
+ > **Client 侧提示**:`capabilityClient` 支持直接传 File/Blob 对象作为文件参数,无需先上传到 dataloom 获取 URL:
158
+ >
159
+ > ```typescript
160
+ > // Client 侧:直接传 File 对象,SDK 自动处理上传
161
+ > const docParser = (capabilityClient as any).load('doc_parser_instance');
162
+ > const rawResult = await (docParser as any).call('parseDocToMarkdown', { fileUrl: [file] }); // file 为 File/Blob 对象
163
+ > ```
164
+ >
165
+ > ⚠️ `capabilityClient` 支持此能力,SDK 自动处理文件上传。
166
+
167
+ ### 创建结构化提取 PluginInstance 的关键要求
168
+
169
+ 创建 `ai-text-to-json` 或 `ai-image-to-json` 类型的 PluginInstance 时:
170
+
171
+ 1. **必须一次性定义所有需要提取的字段**(参考数据库 schema / 表单定义 / UI 设计),宁多勿漏
172
+ 2. 字段类型仅支持 String / Number / Boolean,最多 20 个字段
173
+ 3. 先调用 `get_plugin_ai_json` 确认上游插件的 `outputSchema`,确保输入格式正确
174
+ 4. 图片→结构化数据场景,优先使用 `ai-image-to-json`(单步),避免不必要的链式调用
175
+
176
+ ## 核心概念
177
+
178
+ 新版链路中,**Plugin(插件)**、**PluginInstance(插件实例配置)**、**PluginInstanceAIJson(运行时投影:pluginInstance.ai.json)** 的关系如下:
179
+
180
+ - **Plugin(插件)**:底层承载单元,包含插件元信息与表单定义(form.schema)。模型侧只感知插件及其表单字段,不感知插件内部实现细节。
181
+ - **PluginInstance(插件实例配置)**:基于某个 Plugin 的表单做"业务封装",以 **单文件 JSON** 的形式存储(每个插件实例一个文件,语义化 id)。
182
+ - 通过 `paramsSchema` 暴露业务入参
183
+ - 通过 `formValue` 将业务入参映射到插件表单字段(可常量或引用 `{{input.xxx}}`)
184
+ - **PluginInstanceAIJson(pluginInstance.ai.json)**:工程转化层产物,是 pluginInstance 的**运行时投影 / 调用合同(Runtime Spec)**。
185
+ - 包含插件定位信息、actions 入口列表、input/output schema、outputMode、readme 等
186
+ - Code Agent 在生成**调用代码**前,必须读取它作为权威依据(使用 `capabilityClient` 调用)
187
+
188
+ ### 插件 Plugin
189
+
190
+ 插件是插件实例的承载单元,包含:
191
+ • 插件元信息(tags/name/description/version/...)
192
+ • 插件表单定义(form.schema),用于描述"这个插件需要哪些表单字段"。
193
+ 重要:模型侧只感知插件与其表单 schema,不感知插件内部实现(如 Action的实现、API 细节等)。
194
+
195
+ Plugin 的具体内容以JSON格式给出,例如:
196
+
197
+ ```json
198
+ {
199
+ "name": "plugin-key", // 插件唯一标识
200
+ "displayName": "飞书群组创建", // 展示名称
201
+ "version": "1.0.0",
202
+ "form": {
203
+ "schema": { // 插件表单定义(PluginInstance 的 formValue 映射到此)
204
+ "type": "object",
205
+ "properties": {
206
+ "group_name": { "type": "string", "description": "群组名称" },
207
+ "members": { "type": "array", "description": "群成员id列表", "items": { "type": "string" } }
208
+ },
209
+ "required": ["group_name", "members"]
210
+ }
211
+ }
212
+ }
213
+ ```
214
+
215
+ ### 插件实例 PluginInstance
216
+
217
+ 开发框架内置 `plugin` 工具,用于创建和管理基于 **Plugin(插件表单)** 的业务插件实例(PluginInstance)。
218
+
219
+ **重要说明**:
220
+
221
+ - PluginInstance 的配置以"单文件 JSON"形式存储在 `shared/capabilities/`(每个插件实例一个文件,逻辑上对应 shared/capabilities/<id>.json)。
222
+ - 运行时调用前,Code Agent 需要通过 get_plugin_ai_json 获取对应插件实例的 pluginInstance.ai.json,再基于其中的 actions/schema/outputMode 生成调用代码。
223
+ - 运行时调用入口统一走 SDK:`(capabilityClient as any).load(pluginInstanceId)` 拿到 executor,再 `(executor as any).call(actionKey, input)`(流式用 `callStream`,详见 SKILL.md 头部"调用规范")
224
+
225
+ PluginInstance 的配置以 JSON 形式输出,例如:
226
+
227
+ ```json
228
+ {
229
+ "id": "create_feishu_group", // 全局唯一语义化 ID
230
+ "pluginKey": "@xxx/feishu-group", // 绑定的 Plugin name
231
+ "pluginVersion": "1.0.0",
232
+ "name": "任务创建时自动创建飞书群组",
233
+ "description": "根据任务名称自动生成飞书群组并设置初始成员",
234
+ "paramsSchema": { // 对外暴露的业务入参(仅 string / array<string> / picture / file)
235
+ "type": "object",
236
+ "properties": {
237
+ "group_name": { "type": "string", "description": "群组名称" },
238
+ "members": { "type": "array", "items": { "type": "string" }, "description": "成员ID列表" }
239
+ },
240
+ "required": ["group_name"]
241
+ },
242
+ "formValue": { // 映射到 Plugin form.schema 字段(常量或 {{input.xxx}})
243
+ "group_name": "{{input.group_name}}",
244
+ "members": "{{input.members}}"
245
+ }
246
+ }
247
+ ```
248
+
249
+ **注意**paramsSchema 支持以下 4 种参数类型,需要按下面规定的格式进行填充:
250
+
251
+ 1. **文本** - 单行或多行文本输入
252
+
253
+ ```json
254
+ {
255
+ "type": "string",
256
+ "description": "文本参数描述"
257
+ }
258
+ ```
259
+
260
+ 2. **数组** - 字符串数组(如 ID 列表、标签列表等)
261
+
262
+ ```json
263
+ {
264
+ "type": "array",
265
+ "description": "数组参数描述",
266
+ "items": {
267
+ "type": "string",
268
+ "description": "数组元素描述"
269
+ }
270
+ }
271
+ ```
272
+
273
+ 3. **图片** - 图片资源(需指定 format 为 picture)
274
+
275
+ ```json
276
+ {
277
+ "type": "string",
278
+ "format": "picture",
279
+ "description": "图片参数描述"
280
+ }
281
+ ```
282
+
283
+ 4. **文件** - 文件资源(需指定 format 为 file)
284
+
285
+ ```json
286
+ {
287
+ "type": "string",
288
+ "format": "file",
289
+ "description": "文件参数描述"
290
+ }
291
+ ```
292
+
293
+ > **注意**:`format: "file"` 和 `format: "picture"` 的字段支持直接传入 File/Blob 对象,`capabilityClient` SDK 会自动处理上传。
294
+ >
295
+ #### PluginInstanceAIJson(运行时投影 / 工程转化层产物:pluginInstance.ai.json)
296
+
297
+ pluginInstance.ai.json 是从 PluginInstance 配置派生出的运行时插件实例说明(Runtime Spec),用于 Code Agent 动态生成调用代码。
298
+ 它包含:
299
+ • 插件实例元数据(id/pluginKey/pluginVersion/name/description)
300
+ • 可执行入口列表 actions[](每个入口包含 key/inputSchema/outputSchema/outputMode)
301
+ • 详细说明 readme
302
+ • type:单入口/多入口(single_action | multi_action)
303
+
304
+ **重要**:模型不能自行猜测某个插件实例有哪些 action、入参/出参结构;在生成调用代码前必须通过工具读取该 pluginInstance.ai.json。
305
+
306
+ PluginInstanceAIJson 的配置以 JSON 形式输出,例如:
307
+
308
+ ```json
309
+ {
310
+ "type": "multi_action", // 插件实例类型:single_action 表示仅 1 个 action;multi_action 表示多个 action(调用前需选择 actionKey)
311
+ "id": "********", // PluginInstance 的唯一标识(插件实例ID),用于后续通过 get_plugin_ai_json 获取详情、以及运行时调用定位插件实例
312
+ "pluginKey": "@*******", // 该插件实例绑定的插件ID(运行时用于定位具体插件实现)
313
+ "pluginVersion": "1.0.0", // 插件版本号(用于版本锁定/兼容性,避免插件升级导致 schema 或行为变化)
314
+ "name": "******", // 插件实例名称(面向人/模型展示,用于检索与选择插件实例)
315
+ "description": "...", // 插件实例描述(说明该插件实例解决什么业务问题、适用场景,用于帮助模型理解意图)
316
+ "actions": [ // 可执行入口列表
317
+ {
318
+ "key": "insertRecords", // action 标识(调用时作为 actionKey 使用):插入记录/新增数据
319
+ "inputSchema": {}, // 该 action 的入参 JSON Schema(生成调用参数时必须严格遵循)
320
+ "outputSchema": {}, // 该 action 的出参 JSON Schema(解析返回值时按此结构读取字段)
321
+ "outputMode": "unary" // 输出模式:unary=一次性返回;stream=流式返回(决定代码生成的处理方式)
322
+ },
323
+ {
324
+ "key": "updateRecords", // action 标识:更新记录/修改数据
325
+ "inputSchema": {}, // 更新操作需要的入参结构定义(JSON Schema)
326
+ "outputSchema": {}, // 更新操作返回结构定义(JSON Schema)
327
+ "outputMode": "unary" // 输出模式(同上)
328
+ },
329
+ {
330
+ "key": "deleteRecord", // action 标识:删除记录(注意:有的插件实例会是 deleteRecords 表示批量删除)
331
+ "inputSchema": {}, // 删除操作需要的入参结构定义(JSON Schema)
332
+ "outputSchema": {}, // 删除操作返回结构定义(JSON Schema)
333
+ "outputMode": "unary" // 输出模式(同上)
334
+ }
335
+ ],
336
+ "readme": "", // 插件实例使用说明文档(可能包含特殊字段解释、限制、示例代码;优先参考它来生成调用逻辑)
337
+ "createdAt": 1764234360374, // 插件实例创建时间戳(毫秒),用于变更追踪/缓存刷新
338
+ "updatedAt": 1764234360374 // 插件实例更新时间戳(毫秒),用于变更追踪/缓存刷新
339
+ }
340
+ ```
341
+
342
+ ## 可用的 Plugin
343
+
344
+ ```
345
+ {{available_plugins}}
346
+ ```
347
+
348
+ 说明:先从可用的 PluginInstance 进行选择,如果无法满足需求,看可用的 Plugin,如果有满足的插件,调用插件生成工具进行生成,如果没有,直接拒答。
349
+
350
+ ## 可用的 PluginInstance
351
+
352
+ ```
353
+ {{available_plugin_instances}}
354
+ ```
355
+
356
+ ### 使用方式
357
+
358
+ 1. **创建/修改 PluginInstance(配置层)**:调用 `plugin_instance` 工具生成/更新单文件 PluginInstance JSON(基于插件表单封装)。
359
+ 2. **查询已有 PluginInstance(配置层)**:优先使用可用的 PluginInstance;如仍需核对细节,再读取对应插件实例配置(调用'get_plugin_ai_json')。
360
+ 3. **生成运行时代码(调用层)**:
361
+ - 已确定使用某个 PluginInstance 后,先调用 `get_plugin_ai_json` 获取该插件实例的运行时投影(pluginInstance.ai.json)
362
+ - 基于返回的 `actions[].key/inputSchema/outputSchema/outputMode` 动态生成调用代码(使用 `capabilityClient`)
363
+ - 仔细阅读返回的 readme,必须严格遵循里面制定的规则
364
+
365
+ ### 典型场景示例
366
+
367
+ - **消息通知类**:封装"发送飞书消息"相关插件为业务插件实例
368
+ - **群组管理类**:封装"创建飞书群组"相关插件为业务插件实例
369
+ - **AI 生成类**:封装"AI 生文/生图/图片理解"相关插件为业务插件实例
370
+
371
+ ### 使用限制
372
+
373
+ - PluginInstance 必须通过 `plugin_instance` 工具创建/更新,不支持 agent 直接手改 `shared/capabilities/` 下的配置文件
374
+ - 调用前必须通过 `get_plugin_ai_json` 获取权威 schema,禁止猜测入参/出参结构
375
+ - PluginInstance 配置信息存储在 `shared/capabilities/` 目录
376
+ - 运行时调用统一走 SDK,不再为每个插件实例预生成固定的 call 文件:`(capabilityClient as any).load(capabilityId)` → `(executor as any).call(actionKey, input)`(流式用 `callStream`)
377
+
378
+ ## PluginInstance 生成约束
379
+
380
+ 1. 非常**注意**,必须通过调用 `plugin_instance`工具 来创建、更新 PluginInstance,**绝对禁止**使用 `multi_edit`工具 和 `write`工具 来直接修改 `shared/capabilities/` 目录下的内容。
381
+ 2. PluginInstance 配置以单文件形式存储在 shared/capabilities/ 目录下(逻辑上为 shared/capabilities/<plugin_instance_id>.json),不再维护集中式的 capabilities.json。
382
+ 3. 用户可以手动修改 `shared/capabilities/<plugin_instance_id>.json` 文件中的配置,且用户修改完配置后一定会通知你(例如告诉你'刚刚更新了PluginInstance配置'),此时你需要根据用户修改后的配置,通过调用(必须,禁止使用其他工具来操作 PluginInstance)`plugin_instance`工具 来更新PluginInstance。
383
+ 4. 调用侧在生成调用代码前,**必须**通过 get_plugin_ai_json 获取该插件实例的运行时投影(plugin_Instance.ai.json),并以其作为入参/出参 schema 与 action 列表的唯一权威依据,禁止自行猜测。
384
+ 5. 若插件表单字段中包含 card_content 且其语义为飞书消息卡片,则生成/更新 PluginInstance 配置时,formValue.card_content 必须是 JSON Object,不能是转义字符串;并且卡片 DSL 的最后两个元素必须固定。
385
+
386
+ ---
387
+
388
+ ## GetPluginInstanceAIJson 工具使用指南
389
+
390
+ `get_plugin_ai_json(pluginInstanceID)` — 读取插件实例的运行时投影,返回结构参见上文 PluginInstanceAIJson 示例。
391
+
392
+ ### 何时调用
393
+
394
+ | 场景 | 是否调用 |
395
+ |------|---------|
396
+ | 已选定插件实例,需生成调用代码 | **必须调用** |
397
+ | 需确认 actions / inputSchema / outputSchema / outputMode | **必须调用** |
398
+ | 创建或修改 PluginInstance 配置 | 不需要(用 `plugin_instance` 工具) |
399
+ | 只需插件实例列表概览 | 不需要(已在上下文中提供) |
400
+
401
+ ### 消费返回数据要点
402
+
403
+ 1. 根据 `actions[].key` 选择 actionKey,严格按 `inputSchema` 构造入参、按 `outputSchema` 解析出参
404
+ 2. `type = single_action` 时只有一个 action;`multi_action` 时需选择合适的 actionKey
405
+ 3. **务必阅读 `readme` 字段**,可能包含特殊参数说明、使用限制和示例代码
406
+ 4. 注意 `inputSchema` 中的类型定义(特别是 `type: array` 的字段,必须传数组而非字符串)
407
+
408
+ ## 开发流程要求
409
+
410
+ ### 第一步:检查现有 PluginInstance(复用优先)
411
+
412
+ 1. 用户描述需求后,优先基于上下文提供的插件实例列表检索是否已存在可复用插件实例。
413
+ 1. 若存在候选插件实例但你无法确认其是否满足需求,必须调用 `get_plugin_ai_json` 获取该插件实例的运行时投影(pluginInstance.ai.json),根据 `actions[].key` / `actions[].inputSchema / outputSchema` / `actions[].outputMode` 判断是否可复用以及如何调用。
414
+ 1. 禁止按旧链路去读取/维护 `shared/capabilities/capabilities.json` 来做复用判断。
415
+
416
+ > 结论:**复用判断以插件实例列表 + get_plugin_ai_json 为准**,禁止猜测 action、入参/出参、输出模式。
417
+
418
+ ### 第二步:决策
419
+
420
+ - 如果找到匹配的插件实例:直接进入「第三步:代码调用」,严禁为了"更贴合需求"而随意新建重复的插件实例。
421
+ - 如果不存在合适插件实例,则基于上下文提供的插件列表进行判断:
422
+ - 需要创建:如果插件列表有更贴合需求的插件,调用 `plugin_instance` 工具(operType=CREATE)生成新的单文件 PluginInstance 配置。
423
+ - 需要调整已有插件实例:调用 `plugin_instance` 工具(operType=UPDATE)更新该插件实例配置。
424
+ - 其他情况:告知用户该需求目前无法满足
425
+
426
+ **强制约束**:
427
+
428
+ - 创建/更新 PluginInstance **必须**通过 `plugin_instance` 工具完成,绝对禁止直接修改 `shared/capabilities/` 下的配置文件。
429
+ - UPDATE 场景严禁修改保护字段:`id / pluginKey / pluginVersion / createdAt`。
430
+
431
+ ### 第三步:生成调用代码
432
+
433
+ 1. **必须**先调用 `get_plugin_ai_json(pluginInstanceId)`
434
+ 2. 根据 `actions[].key` 选择正确的 actionKey
435
+ 3. 严格按 `inputSchema` 构造入参,严格按 `outputSchema` 解析出参
436
+ 4. 根据 `outputMode` 选择调用方式:
437
+ - `outputMode = unary` → `(executor as any).call(actionKey, input)`
438
+ - `outputMode = stream` → `(executor as any).callStream(actionKey, input)`(异步迭代)
439
+
440
+ ### 第四步:代码放置位置
441
+
442
+ 调用代码放在 `src/` 目录下的组件/hooks 中(vite-react flat 布局),由用户交互触发。
443
+
444
+ ## 常见错误(必须避免)
445
+
446
+ | 错误做法 | 正确做法 |
447
+ |---------|---------|
448
+ | 为插件调用创建后端 API 中转 | 直接使用 `capabilityClient` 在前端调用 |
449
+ | 未调用 `get_plugin_ai_json` 就猜测参数 | 先获取 runtime spec,再生成代码 |
450
+ | Mock `capabilityClient` 返回值 | 必须真实调用 |
451
+ | 猜测 actionKey 或参数结构 | 严格按 `get_plugin_ai_json` 返回的 schema |
452
+ | `call()` 调用签名错误:`plugin.call(JSON.stringify({...}))` 或 `plugin.call({...})` | `call()` 第一个参数必须是 actionKey 字符串,第二个参数才是 input 对象:`plugin.call('actionKey', {...})` |
453
+ | 用正则/字符串解析处理 AI 输出(提取或生成场景) | 提取用 `ai-text-to-json` / `ai-image-to-json`;多字段生成拆多插件或用 `ai-text-to-json` |
454
+ | 创建 `ai-text-to-json` / `ai-image-to-json` PluginInstance 时只定义部分字段 | 分析需求中**全部**字段后一次性定义完整(最多20字段) |
455
+ | 认为 `ai-doc-parser` 能直接输出结构化 JSON | `ai-doc-parser` 只输出纯文本,需链式调用 `ai-text-to-json` 做结构化 |
456
+ | 图片提取结构化数据时用 `ai-image-understanding` → `ai-text-to-json` 两步链 | 优先用 `ai-image-to-json` 单步直达(发票/名片/证件等场景) |
457
+ | 未读 `inputSchema` 就假设参数类型(如 string vs array) | 先调用 `get_plugin_ai_json` 查看 `inputSchema`,注意 `type: array` 字段 |
458
+ | 通过 `getDataloom().capability` 或 `(dataloom as any).capability` 调用插件 | `capabilityClient` 是独立导入,不通过 dataloom 访问。dataloom 仅提供 storage 和 service |
459
+ | 调用插件时,先通过 dataloom 上传文件拿 URL 再传给插件 | 可直接传 File/Blob 对象给 `capabilityClient`,SDK 自动处理上传 |
460
+ | 前端调用插件后不保存结果到数据库,导致页面刷新后数据丢失 | 调用插件成功后,必须通过已有 CRUD 接口立即保存结果 |
461
+ | 为保存插件结果单独新建 API 端点(如 `PATCH /api/xxx/ai-analysis`) | 优先复用已有的业务 CRUD 接口(create/update)扩展字段 |
462
+
463
+ ## 业务语义映射约定
464
+
465
+ 用户会用「AI 生文」「AI 生图」「发送飞书消息」等**业务语言**描述需求;这些关键词必须被识别为**待使用或待创建的 PluginInstance**。
466
+
467
+ 开发流程:
468
+
469
+ 1. 收到需求后,先看可用的 PluginInstance 是否有可以直接使用的插件实例
470
+ 2. 若有候选但不确定是否满足,调用 `get_plugin_ai_json` 查看其 actions/schema/outputMode 再决策
471
+ 3. 若无,立即调用 `plugin_instance` 工具新建
472
+ 4. 生成 `capabilityClient` 调用代码
473
+
474
+ ## 插件调用错误处理规范
475
+
476
+ | 错误类型 | 应对策略 |
477
+ |----------|---------|
478
+ | `InputValidationError` | 修复参数后重试 |
479
+ | `RateLimitError` | 指数退避重试(1s/2s/4s),最多 3 次 |
480
+ | `ExecutionError` | 记录日志 + 降级方案 + 通知用户 |
481
+ | 网络超时 | 重试 + 超时后降级 |
482
+
483
+ **规则**: (1) 禁止静默吞异常,每个 catch 必须向用户展示错误或触发补偿;(2) 异步调用必须有终态(success/failed),前端不能永远 loading;(3) 通知类插件失败必须有补偿机制。
484
+
485
+ ## 缓存与幂等性
486
+
487
+ - AI 类插件**没有请求级缓存**。同样输入返回相似结果是 LLM 低 temperature 下的正常行为。
488
+ - **禁止**通过修改业务参数注入 UUID 来"绕缓存",会污染 AI 输入导致输出包含垃圾文本。
489
+ - 需要不同结果时:调整 temperature、修改 prompt 要求、或使用不同业务上下文。
490
+
491
+ ## 插件参数来源规范
492
+
493
+ | 类型 | 来源 | 示例 |
494
+ |------|------|------|
495
+ | 业务数据 | DB 查询或前端传入 | 候选人姓名、简历内容 |
496
+ | 运行时配置 | 配置/环境变量/平台 API | 接收人 user_id、阈值 |
497
+
498
+ **禁止**硬编码运行时配置值(user_id、邮箱等),必须从配置表/平台 API/环境变量获取。
@@ -0,0 +1,143 @@
1
+ ## PluginInstance 代码编写指南
2
+
3
+ ### 核心原则
4
+
5
+ **严禁** import { capabilityClient } from '@lark-apaas/client-capability'。
6
+ **唯一指定**的导入方式是 import { capabilityClient } from '@lark-apaas/client-toolkit-lite';
7
+
8
+ | 场景 | 调用方式 |
9
+ |------|---------|
10
+ | 非流式调用 | `const ex = (capabilityClient as any).load(id); await (ex as any).call(actionKey, input)` |
11
+ | 流式输出场景 | `const ex = (capabilityClient as any).load(id); for await (const c of (ex as any).callStream(actionKey, input)) {}` |
12
+
13
+ ---
14
+
15
+ ### 调用方式
16
+
17
+ #### 1. 调用前获取权威依据
18
+
19
+ 在为某个插件实例生成调用代码前,必须先通过 `get_plugin_ai_json` 工具获取该插件实例的运行时投影(plugin_Instance.ai.json),并以其中信息为准:
20
+
21
+ - `actions[].key`:调用时要传的 `actionKey`
22
+ - `actions[].inputSchema / outputSchema`:入参/出参结构
23
+ - `actions[].outputMode`:`unary | stream`(决定调用与结果处理方式)
24
+
25
+ #### call / callStream 函数签名
26
+
27
+ ```typescript
28
+ .call(actionKey: string, input: object) // 非流式,返回 Promise<output>
29
+ .callStream(actionKey: string, input: object) // 流式,返回 AsyncIterable<chunk>
30
+ ```
31
+
32
+ - **第一个参数 `actionKey`**:必须是字符串,值来自 `get_plugin_ai_json` 返回的 `actions[].key`(如 `'sendFeishuMessage'`、`'textGenerate'`)
33
+ - **第二个参数 `input`**:必须是对象,结构符合 `actions[].inputSchema`
34
+
35
+ vite-react 侧 TS 类型偏泛,调用必须 `as any` 强转两次(一次给 `capabilityClient`,一次给 `load` 返回的 executor):
36
+
37
+ ```typescript
38
+ import { capabilityClient } from '@lark-apaas/client-toolkit-lite';
39
+
40
+ const executor = (capabilityClient as any).load('plugin_instance_id');
41
+
42
+ // ❌ 错误:把参数 JSON.stringify 后当作 actionKey
43
+ (executor as any).call(JSON.stringify({ meeting_title: '...' }));
44
+ // ❌ 错误:漏掉 actionKey,直接传参数对象
45
+ (executor as any).call({ meeting_title: '...' });
46
+
47
+ // ✅ 正确:第一个参数是 actionKey 字符串,第二个参数是 input 对象
48
+ const result = await (executor as any).call('send_feishu_message', { meeting_title: '...' });
49
+ ```
50
+
51
+ #### 2. 非流式调用(outputMode = "unary")
52
+
53
+ ```typescript
54
+ import { capabilityClient, logger } from '@lark-apaas/client-toolkit-lite';
55
+
56
+ const executor = (capabilityClient as any).load('create_feishu_group');
57
+ const result = await (executor as any).call('createGroup', {
58
+ group_name: '项目讨论群',
59
+ members: ['user_001', 'user_002'],
60
+ });
61
+
62
+ logger.info(result);
63
+ ```
64
+
65
+ #### 3. 流式调用(outputMode = "stream")
66
+
67
+ ##### 流式 chunk 字段速查表(必须遵守)
68
+
69
+ capabilityClient `callStream` 返回的每个 chunk 是**扁平对象**,字段名与 `outputSchema` 一致。**禁止使用 `chunk.data?.text`、`chunk.choices[0]`、`chunk.message` 等非 capabilityClient 格式。**
70
+
71
+ | 插件 | chunk 字段 | 正确写法 | 错误写法 |
72
+ |------|-----------|---------|---------|
73
+ | ai-text-generate | `content` | `chunk.content` | ~~`chunk.data?.text`~~ |
74
+ | ai-translate | `translation` | `chunk.translation` | ~~`chunk.content`~~ ~~`chunk.text`~~ |
75
+ | ai-text-summary | `summary` | `chunk.summary` | ~~`chunk.content`~~ ~~`chunk.data?.text`~~ |
76
+ | ai-image-understanding | `content` | `chunk.content` | ~~`chunk.data?.text`~~ |
77
+ | ai-search-summary | `content` | `chunk.content` | ~~`chunk.data?.text`~~ |
78
+
79
+ > **⚠️ 同一应用多个页面调用同一插件时,所有页面必须使用一致的 chunk 字段名。** parallel_write 并发生成多页面时尤其注意。
80
+
81
+ ##### 场景判断与推荐方案
82
+
83
+ | 场景 | 特征 | 推荐度 |
84
+ |-----|------|-------|
85
+ | **多插件并行流式** | 多个插件各返回单一输出,并行调用 | **优先推荐** |
86
+ | **单插件 JSON 流式解析** | 单插件返回结构化 JSON,需边接收边解析 | ⚠️ 仅在必要时 |
87
+
88
+ **核心原则**:在插件设计阶段按「原子化拆解」拆分,避免单插件返回多字段 JSON。
89
+
90
+ ##### 推荐:多插件并行流式
91
+
92
+ 适用于需求涉及多种输出(标题、正文、图片等),各输出相对独立。
93
+
94
+ ```tsx
95
+ import { capabilityClient, logger } from '@lark-apaas/client-toolkit-lite';
96
+
97
+ function MultiPluginStreamExample() {
98
+ const [title, setTitle] = useState('');
99
+ const [content, setContent] = useState('');
100
+ const [coverUrl, setCoverUrl] = useState('');
101
+
102
+ const handleGenerate = async (keywords: string) => {
103
+ // 1. 封面图(非流式,异步不阻塞)
104
+ const coverExecutor = (capabilityClient as any).load('cover_generator');
105
+ (coverExecutor as any).call('textToImage', { keywords })
106
+ .then((res: { images?: string[] }) => res?.images?.[0] && setCoverUrl(res.images[0]))
107
+ .catch((err: unknown) => logger.warn('封面生成失败', err));
108
+
109
+ // 2. 标题(非流式)
110
+ const titleExecutor = (capabilityClient as any).load('title_generator');
111
+ (titleExecutor as any).call('textGenerate', { keywords })
112
+ .then((res: { content?: string }) => res?.content && setTitle(res.content));
113
+
114
+ // 3. 正文(流式,边生成边展示)
115
+ const contentExecutor = (capabilityClient as any).load('content_generator');
116
+ const contentStream = (contentExecutor as any).callStream('textGenerate', { keywords });
117
+
118
+ // 🎯 流式内容直接拼接,无需复杂解析
119
+ for await (const chunk of contentStream as AsyncIterable<{ content?: string }>) {
120
+ setContent(prev => prev + (chunk.content || ''));
121
+ }
122
+ };
123
+
124
+ return (/* 各字段独立渲染 */);
125
+ }
126
+ ```
127
+
128
+ **优点**:代码简洁、各插件独立、某个失败不影响其他。
129
+
130
+ ##### ⚠️ 兜底:单插件 JSON 流式解析
131
+
132
+ 当无法拆分为多插件时,需处理不完整 JSON 的逐字符到达,需实现 `parseStreamingStringField` 和 `extractJsonObject` 工具函数。**强烈建议在插件设计阶段拆分为多插件并行流式调用,避免此场景。**
133
+
134
+ ### outputMode 与调用方式
135
+
136
+ 先通过 `get_plugin_ai_json(pluginInstanceId)` 获取 `actions[].outputMode`:
137
+
138
+ | outputMode | 调用方式 |
139
+ |------------|---------|
140
+ | `unary` | `const ex = (capabilityClient as any).load(id); await (ex as any).call(actionKey, input)` |
141
+ | `stream` | `const ex = (capabilityClient as any).load(id); for await (const c of (ex as any).callStream(actionKey, input)) {}` |
142
+
143
+ ---
@@ -0,0 +1,501 @@
1
+ # 飞书多维表格 (feishu-bitable)
2
+
3
+ 本插件提供了一整套与飞书多维表格进行交互(CRUD + 聚合查询)的接口。
4
+
5
+ ## 开发者注意事项
6
+
7
+ **本文档是为 妙搭助手 设计的技术指南。**
8
+
9
+ 在查看本插件的 Action 时,你将同时获得两部分信息:
10
+
11
+ 1. **本文档 (README)**: 提供高级指引、业务逻辑、使用场景、重要约束。
12
+ 2. **Action 的 `inputSchema` 和 `outputSchema`**: 提供精确的 JSON Schema 格式的输入/输出结构。
13
+
14
+ **请遵循以下原则:**
15
+
16
+ - **以 `Schema` 为结构基准**:严格按照 `inputSchema` 构建你的输入对象,并参照 `outputSchema` 解析返回结果。
17
+ - **以 `README` 为语义权威**:`README` 中的描述包含了 `Schema` 无法表达的关键信息。**必须优先遵循 `README` 中的指引和约束。**
18
+
19
+ ---
20
+
21
+ ## 重要提醒
22
+
23
+ **禁止修改插件配置中的任何字段!即使出现字段类型错误或 Action 执行错误也不能修改。**
24
+
25
+ 使用前需读取配置中的 `fields` 字段,了解可操作的字段及其 `bizType`。
26
+
27
+ ## 字段使用规范
28
+
29
+ 1. **使用字段名称而非 ID**:所有操作必须使用 `fields.name`(字段名称),禁止使用字段 ID
30
+
31
+ 2. **选项字段必须严格匹配 enumValues**:`SingleSelect` 和 `MultiSelect` 类型的选项值必须完全参考配置中的 `enumValues`,禁止自行编造
32
+
33
+ ```json
34
+ // 示例:假设字段配置如下
35
+ {
36
+ "name": "状态",
37
+ "bizType": "SingleSelect",
38
+ "enumValues": ["待处理", "进行中", "已完成"]
39
+ }
40
+ // ✅ 正确:使用 enumValues 中的值
41
+ { "状态": "进行中" }
42
+ // ❌ 错误:使用不存在的选项
43
+ { "状态": "处理中" }
44
+ ```
45
+
46
+ 3. **字段名称可能包含中文或特殊字符**:必须使用括号表示法,禁止点表示法
47
+
48
+ ```javascript
49
+ // ✅ 正确
50
+ record['状态']
51
+ record['v1.0版本']
52
+ { '状态': '进行中', 'v1.0版本': '已发布' }
53
+
54
+ // ❌ 错误
55
+ record.状态 // 语法错误
56
+ record.v1.0版本 // 会被错误解析
57
+ ```
58
+
59
+ 4. **特殊字段需特化展示**:Progress(进度条)、Currency(货币符号)、Rating(星级)、DateTime(格式化日期)、Checkbox(勾选图标)、User(头像+名称)、Barcode(条码图形)
60
+ 5. **特殊字段需专用录入组件**:SingleSelect/MultiSelect(下拉选择器)、Checkbox(开关)、DateTime(日期选择器)、User(人员选择器)
61
+
62
+ ---
63
+
64
+ ## BizType 字段类型对照表
65
+
66
+ | 字段类型 | BizType | 字段类型 | BizType | 字段类型 | BizType |
67
+ | -------- | -------------- | ------------ | -------------- | -------- | ------------- |
68
+ | 文本 | `Text` | 单选 | `SingleSelect` | 附件 | `Attachment` |
69
+ | 邮箱 | `Email` | 多选 | `MultiSelect` | 单向关联 | `SingleLink` |
70
+ | 条码 | `Barcode` | 日期 | `DateTime` | 双向关联 | `DuplexLink` |
71
+ | 数字 | `Number` | 复选框 | `Checkbox` | 查找引用 | `Lookup` |
72
+ | 进度 | `Progress` | 人员 | `User` | 公式 | `Formula` |
73
+ | 货币 | `Currency` | 电话号码 | `Phone` | 地理位置 | `Location` |
74
+ | 评分 | `Rating` | 超链接 | `Url` | 群组 | `GroupChat` |
75
+ | 创建时间 | `CreatedTime` | 最后更新时间 | `ModifiedTime` | 创建人 | `CreatedUser` |
76
+ | 修改人 | `ModifiedUser` | 自动编号 | `AutoNumber` | 按钮 | `Button` |
77
+
78
+ ---
79
+
80
+ ## BizType 读写格式对照表
81
+
82
+ **重要**:读取和写入时的数据格式可能不同,必须严格按照下表处理。
83
+
84
+ ### 可写字段
85
+
86
+ | BizType | 写入格式 | 读取格式 | 说明 |
87
+ | --------------------------------------- | ---------------- | ------------------ | ------------------------ |
88
+ | `Text`/`Email`/`Barcode` | `string` | `{ text: string }` | 写入纯字符串,读取为对象 |
89
+ | `Phone` | `string` | `string` | 读写均为字符串 |
90
+ | `Number`/`Progress`/`Currency`/`Rating` | `number` | `number` | 数值类型 |
91
+ | `SingleSelect` | `string` | `string` | 必须匹配 enumValues |
92
+ | `MultiSelect` | `string[]` | `string[]` | 必须匹配 enumValues |
93
+ | `DateTime` | `number` | `number` | Unix 时间戳(毫秒) |
94
+ | `Checkbox` | `boolean` | `boolean \| null` | 读取时可能为 null |
95
+ | `User` | `number[]` | `number[]` | suda user id 数组 |
96
+ | `Url` | `{ text, link }` | `{ text, link }` | 读写格式一致 |
97
+ | `Attachment` | `string[]` | 复杂对象 | 写入文件 URL 数组 |
98
+
99
+ ### 只读字段
100
+
101
+ | BizType | 读取格式 |
102
+ | ---------------------------- | ---------------------------------------------- |
103
+ | `CreatedTime`/`ModifiedTime` | `number`(时间戳) |
104
+ | `CreatedUser`/`ModifiedUser` | `number[]`(user id 数组) |
105
+ | `AutoNumber` | `{ text: string }` |
106
+ | `Formula` | `{ bizType: string, value: any }` |
107
+ | `Location` | `{ fullAddress, provinceName, cityName, ... }` |
108
+ | `GroupChat` | `[{ groupID, displayName, avatarImageUrl }]` |
109
+
110
+ ---
111
+
112
+ ## Actions 概览
113
+
114
+ | Action | 说明 |
115
+ | -------------------- | ------------------------------ |
116
+ | `searchRecords` | 搜索记录,支持筛选、排序、分页 |
117
+ | `getRecord` | 根据 ID 查询单条记录 |
118
+ | `batchAddRecords` | 批量新增记录(最多 500 条) |
119
+ | `batchUpdateRecords` | 批量更新记录(最多 500 条) |
120
+ | `deleteRecords` | 批量删除记录(最多 500 条) |
121
+ | `aggregateQuery` | 聚合查询,支持分组、聚合计算、筛选、排序、分页 |
122
+
123
+ > 注意:分析/统计类场景应优先使用 `aggregateQuery`,不要用 `searchRecords` 模拟统计。
124
+
125
+ ### Action 选择决策树
126
+
127
+ ```
128
+ 需要什么?
129
+ ├─ 查看/编辑单条记录 → getRecord / batchUpdateRecords
130
+ ├─ 列表分页浏览 → searchRecords(游标分页,pageToken 翻页)
131
+ ├─ 按条件筛选少量记录 → searchRecords + filter + pageSize
132
+ ├─ 排行榜 / TOP N → searchRecords + sort + pageSize=N
133
+ ├─ 统计数值(计数/求和/平均/最大最小) → aggregateQuery
134
+ ├─ 分组统计(按部门/状态等维度) → aggregateQuery + dimensions
135
+ ├─ 统计 + 明细下钻 → 聚合用 aggregateQuery,下钻用 searchRecords + filter
136
+ ├─ 新增数据 → batchAddRecords
137
+ └─ 删除数据 → deleteRecords
138
+
139
+ ⚠️ 禁止模式:
140
+ ❌ searchRecords 拉全量 → 内存 reduce/filter 做统计 → 必须用 aggregateQuery
141
+ ❌ searchRecords 拉全量 → sort → slice 取 TOP N → 必须用 searchRecords + sort + pageSize
142
+ ❌ searchRecords 拉全量 → find 找单条 → 必须用 getRecord
143
+ ```
144
+
145
+ ## 数据分析类Action补充说明
146
+
147
+ `aggregateQuery` 用于分组聚合统计,支持 dimensions(分组维度)和 measures(聚合计算)。
148
+
149
+ ### 输入参数
150
+
151
+ - **dimensions 支持的 bizType**:`Text`、`Email`、`Barcode`、`Number`、`Progress`、`Currency`、`Rating`、`SingleSelect`、`MultiSelect`、`DateTime`、`CreatedTime`、`ModifiedTime`、`Checkbox`、`User`、`CreatedUser`、`ModifiedUser`
152
+
153
+ ### 输出结构
154
+
155
+ - 返回 `{ result, hasMore, pageToken }`
156
+ - `result` 是聚合结果数组,每个元素是对象,key 对应 dimensions/measures 中声明字段,value 结构为 `{ value: <actual_value> }`
157
+
158
+ ### dimensions value 类型(常见)
159
+
160
+ - `Text` / `Email` / `Barcode`: `{ text: string }`
161
+ - `Number`: `string`
162
+ - `Progress`: `string`
163
+ - `Currency`: `string`
164
+ - `Rating`: `string`
165
+ - `SingleSelect`: `string`
166
+ - `MultiSelect`: `string[]`
167
+ - `DateTime` / `CreatedTime` / `ModifiedTime`: `string`(按多维表格配置格式)
168
+ - `Checkbox`: `string`(`"true"` / `"false"`)
169
+ - `User`: `Array<{ id, name }>`
170
+ - `CreatedUser` / `ModifiedUser`: `{ id, name }`
171
+
172
+ ### measures value 类型
173
+
174
+ - 所有 measures 的 `value` 都是 `number`
175
+
176
+ ### measures 支持的 bizType
177
+
178
+ | aggregation | 支持的 bizType | 不支持 |
179
+ |-------------|---------------|--------|
180
+ | `count` | 所有类型(但只统计非空值,统计总行数建议用不会为空的 Number 字段) | - |
181
+ | `sum` / `avg` | `Number`、`Currency`、`Progress`、`Rating` | **`Formula`**、`Text` |
182
+ | `max` / `min` | `Number`、`Currency`、`DateTime` | `Formula`、`Text` |
183
+
184
+ ### filter
185
+
186
+ aggregateQuery 的 filter 格式与 searchRecords **完全相同**,遵循下方"搜索过滤条件规则"章节的所有规则。
187
+
188
+ ### expandArrayDimension
189
+
190
+ - `expandArrayDimension=true` 时会先展开数组维度再聚合统计
191
+ - 典型场景:`MultiSelect`、`User`
192
+
193
+ ---
194
+
195
+ ## 代码示例
196
+
197
+ ```typescript
198
+ import { capabilityClient } from '@lark-apaas/client-toolkit-lite';
199
+
200
+ // 推荐:load 一次 executor 后复用,每次 cast 两次(详见 SKILL.md 调用规范)
201
+ const bitable = (capabilityClient as any).load('plugin_instance_id');
202
+
203
+ // 搜索记录
204
+ const response = await (bitable as any).call('searchRecords', {
205
+ filter: {
206
+ conjunction: 'and',
207
+ conditions: [{ fieldName: '状态', operator: 'is', value: ['进行中'] }],
208
+ },
209
+ pageSize: 20,
210
+ });
211
+
212
+ // ⚠️ 响应数据直接从 response 访问,不要加 .data 或 .output 前缀
213
+ // ✅ 正确:response.records / response.hasMore / response.total / response.pageToken
214
+ // ❌ 错误:response.data.records / response.data.output.records
215
+ const { records, hasMore, total, pageToken } = response;
216
+ console.log(`共 ${total} 条,本页 ${records.length} 条,还有更多: ${hasMore}`);
217
+
218
+ // 获取单条记录
219
+ const detail = await (bitable as any).call('getRecord', { recordID: 'recXXX' });
220
+ // ✅ 正确:detail.id / detail.record
221
+ // ❌ 错误:detail.data.output.id
222
+ const title = detail.record['标题']?.text;
223
+
224
+ // 批量新增(注意:写入时用写入格式,字段名必须用引号包裹)
225
+ await (bitable as any).call('batchAddRecords', {
226
+ records: [
227
+ {
228
+ record: {
229
+ 文本字段: '内容', // Text: string(非 { text: string })
230
+ 数字字段: 100, // Number: number
231
+ 单选字段: '选项A', // SingleSelect: string(必须匹配 enumValues)
232
+ 多选字段: ['标签1', '标签2'], // MultiSelect: string[]
233
+ 复选框: true, // Checkbox: boolean
234
+ 日期字段: Date.now(), // DateTime: number (毫秒时间戳)
235
+ 人员字段: [123456, 789012], // User: number[] (suda user id)
236
+ 超链接: { text: '链接文本', link: 'https://example.com' }, // Url
237
+ },
238
+ },
239
+ ],
240
+ });
241
+
242
+ // 批量更新
243
+ await (bitable as any).call('batchUpdateRecords', {
244
+ records: [{ id: 'record_id', record: { 文本字段: '新内容' } }],
245
+ });
246
+
247
+ // 删除记录
248
+ await (bitable as any).call('deleteRecords', { recordIDs: ['id1', 'id2'] });
249
+
250
+ // 读取时注意格式差异
251
+ const textValue = record.record['文本字段']?.text; // 读取时是 { text: string }
252
+ const numberValue = record.record['数字字段']; // number
253
+ const userIds = record.record['人员字段']; // number[]
254
+
255
+ // 聚合查询
256
+ const aggResp = await (bitable as any).call('aggregateQuery', {
257
+ dimensions: ['region'],
258
+ measures: [{ fieldName: 'order_id', aggregation: 'count', alias: 'order_count' }],
259
+ sort: [{ fieldName: 'region', desc: false }],
260
+ pageSize: 100,
261
+ });
262
+
263
+ const chartData =
264
+ aggResp.result?.map((item: Record<string, { value: unknown }>) => ({
265
+ region: String(item.region?.value || ''),
266
+ order_count: Number(item.order_count?.value || 0),
267
+ })) || [];
268
+ ```
269
+
270
+ ## 代码实现注意事项
271
+
272
+ 1. **响应数据直接访问,禁止猜测嵌套路径**。`capabilityClient.call()` 返回的就是最终数据,直接用 `response.records`、`response.total` 等。**禁止**加 `.data`、`.output`、`.data.output` 等前缀。如果不确定响应结构,先用 `console.log` 打印完整响应再编码,禁止凭猜测修改数据路径。
273
+ 2. 在进行写入操作时,比如确认提交时,应该有loading效果,禁止用户重复点击。
274
+ 3. 如果capabilityClient call失败,命中了error逻辑,要打印日志,同时把error message提示给用户。
275
+ 4. 在进行更新操作时,只更新需要更新的字段即可,不需要对全量字段进行更新。
276
+ 5. **分页必须使用游标分页,禁止全量拉取后切片**。先拉全部数据再 `slice` 会导致性能极差、浪费带宽和内存。每次只拉当前页数据,通过 `pageToken` 翻页:
277
+
278
+ ```typescript
279
+ const ex = (capabilityClient as any).load(pluginInstanceId);
280
+ const result = await (ex as any).call('searchRecords', {
281
+ pageSize: 10,
282
+ pageToken: cursor, // 上一页返回的 pageToken
283
+ });
284
+ // 返回:{ records, pageToken(下一页游标), hasMore, total }
285
+ ```
286
+
287
+ ---
288
+
289
+ ## 推荐 UI 组件(vite-react / shadcn-ui)
290
+
291
+ vite-react 用的是 shadcn-ui + Radix Primitives(无 spark UI 的 `<UserDisplay>` / `<UserSelect>` / `<Hyperlink>`)。下表按 shadcn 体系给出推荐组件,**核心读写格式跟 spark 是一致的**,差异在 UI 控件本身。
292
+
293
+ | BizType | 展示 | 录入 |
294
+ | ---------------------------- | -------------------------------------------------------------------- | --------------------------------------------------- |
295
+ | `User` | `<Avatar>` + 名字(按需查询 `getCurrentUserProfile` 等接口) | 自己拼:`<Select>` / `<Combobox>` 加搜索接口 |
296
+ | `Url` | `<a href={val.link} target="_blank">{val.text}</a>` | 两个 `<Input>`(一个填 text,一个填 link) |
297
+ | `SingleSelect`/`MultiSelect` | 文本 / `<Badge>` 标签 | `<Select>` 选项来自 enumValues |
298
+ | `DateTime` | `new Date(ts).toLocaleDateString()` | `<Popover>` + `<Calendar>`(shadcn date picker) |
299
+ | `Checkbox` | `value ? '是' : '否'` | `<Checkbox />` |
300
+ | `Currency` | `¥${value.toFixed(2)}` | `<Input type="number" />` |
301
+ | `Progress` | `<Progress value={value} />` 或 `${value}%` | `<Input type="number" min={0} max={100} />` 或 `<Slider />` |
302
+ | `Rating` | `'★'.repeat(value) + '☆'.repeat(5-value)` | `<Input type="number" min={0} max={5} />` |
303
+
304
+ > spark 体系下的 `<UserDisplay>` / `<UserSelect>` / `<Hyperlink>` 在 vite-react 不可用。`@lark-apaas/client-toolkit-lite` 没有暴露这些组件 —— 需要自己用 shadcn 的 `<Avatar>` / `<Select>` / `<Combobox>` 拼装。人员数据的获取需要走业务 API(视具体 capability 而定)。
305
+
306
+ ### 组件代码示例
307
+
308
+ ```tsx
309
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
310
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
311
+ import { Checkbox } from '@/components/ui/checkbox';
312
+
313
+ // 单选下拉(选项必须来自 enumValues)
314
+ <Select value={value} onValueChange={setValue}>
315
+ <SelectTrigger><SelectValue placeholder="请选择" /></SelectTrigger>
316
+ <SelectContent>
317
+ {enumValues.map(opt => (
318
+ <SelectItem key={opt} value={opt}>{opt}</SelectItem>
319
+ ))}
320
+ </SelectContent>
321
+ </Select>
322
+
323
+ // 人员展示(最小实现:Avatar + 名字)
324
+ <div className="flex items-center gap-2">
325
+ <Avatar className="size-6">
326
+ <AvatarImage src={user.avatarUrl} />
327
+ <AvatarFallback>{user.name?.[0]}</AvatarFallback>
328
+ </Avatar>
329
+ <span>{user.name}</span>
330
+ </div>
331
+
332
+ // 超链接展示
333
+ <a href={record['超链接']?.link} target="_blank" rel="noreferrer" className="text-primary underline">
334
+ {record['超链接']?.text}
335
+ </a>
336
+ ```
337
+
338
+ ---
339
+
340
+ ## 表格列渲染示例
341
+
342
+ ```tsx
343
+ const columns = [
344
+ // 文本字段 - 注意读取格式是 { text: string }
345
+ { title: '文本', dataIndex: ['record', '文本字段', 'text'], key: 'text' },
346
+ // 人员字段 - vite-react 自行用 Avatar + 名字渲染(数据来源走业务接口)
347
+ {
348
+ title: '负责人',
349
+ key: 'user',
350
+ render: (_, record) => {
351
+ const ids = (record.record['人员字段'] as number[]) || [];
352
+ return ids.map((id) => <UserChip key={id} userId={id} />);
353
+ },
354
+ },
355
+ // 日期字段 - 格式化显示
356
+ {
357
+ title: '创建日期',
358
+ dataIndex: ['record', '日期字段'],
359
+ key: 'date',
360
+ render: (v) => (v ? new Date(v).toLocaleDateString() : '-'),
361
+ },
362
+ // 货币 - 格式化显示
363
+ {
364
+ title: '金额',
365
+ dataIndex: ['record', '货币字段'],
366
+ key: 'currency',
367
+ render: (v) => (v != null ? `¥${v.toFixed(2)}` : '-'),
368
+ },
369
+ // 超链接 - vite-react 直接渲染 <a>
370
+ {
371
+ title: '链接',
372
+ key: 'hyperlink',
373
+ render: (_, record) => {
374
+ const v = record.record['超链接'] as { text?: string; link?: string } | undefined;
375
+ return v?.link ? (
376
+ <a href={v.link} target="_blank" rel="noreferrer" className="text-primary underline">
377
+ {v.text || v.link}
378
+ </a>
379
+ ) : '-';
380
+ },
381
+ },
382
+ // 多选 - 逗号分隔显示
383
+ {
384
+ title: '标签',
385
+ dataIndex: ['record', '多选字段'],
386
+ key: 'multiSelect',
387
+ render: (v) => v?.join(', ') || '-',
388
+ },
389
+ ];
390
+ ```
391
+
392
+ ---
393
+
394
+ ## 搜索过滤条件规则
395
+
396
+ ### 核心规则
397
+
398
+ 1. **value 始终是字符串数组**:数字需转为字符串,如 `["23.4"]`
399
+ 2. **空值判断**:`isEmpty`/`isNotEmpty` 时 value 填 `[]`
400
+ 3. **Formula 字段不支持筛选**
401
+ 4. **Text 字段 `is`/`isNot` 的 value 只能传一个元素**(见下方常见错误)
402
+
403
+ ### 各 BizType 的 value 格式与支持的操作符
404
+
405
+ | BizType | value 格式 | 支持的操作符 |
406
+ | ---------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
407
+ | `Text`/`Email`/`Barcode`/`Phone` | `["内容"]`(只能一个元素) | `is`, `isNot`, `contains`, `doesNotContain`, `isEmpty`, `isNotEmpty` |
408
+ | `Number`/`Currency`/`Progress`/`Rating`/`AutoNumber` | `["23.4"]`(数字转字符串) | `is`, `isNot`, `isGreater`, `isGreaterEqual`, `isLess`, `isLessEqual`, `isEmpty`, `isNotEmpty` |
409
+ | `SingleSelect`/`MultiSelect` | `["选项A", "选项B"]`(`is`/`isNot` 填一个,其他可多个) | `is`, `isNot`, `contains`, `doesNotContain`, `isEmpty`, `isNotEmpty` |
410
+ | `Checkbox` | `["true"]` 或 `["false"]` | `is`(仅此一个) |
411
+ | `User`/`CreatedUser`/`ModifiedUser` | `["123456"]`(suda user id) | `is`, `isNot`, `contains`, `doesNotContain`, `isEmpty`, `isNotEmpty` |
412
+ | `Url` | `["显示名称"]`(填显示文本,非 URL) | `is`, `isNot`, `contains`, `doesNotContain`, `isEmpty`, `isNotEmpty` |
413
+ | `GroupChat` | `["328472133423"]`(群组 ID) | `is`, `isNot`, `contains`, `doesNotContain`, `isEmpty`, `isNotEmpty` |
414
+ | `Location` | `["地址文本"]`(只能一个元素) | `is`, `isNot`, `contains`, `doesNotContain`, `isEmpty`, `isNotEmpty` |
415
+ | `Attachment` | `[]`(仅支持空值判断) | `isEmpty`, `isNotEmpty` |
416
+ | `DateTime`/`CreatedTime`/`ModifiedTime` | 见下方 | `is`, `isEmpty`, `isNotEmpty`, `isGreater`, `isLess` |
417
+
418
+ ### 日期字段 value
419
+
420
+ | value | 含义 | operator 限制 |
421
+ | ------------------------------------------------------------------------- | ---------------------- | ------------- |
422
+ | `["ExactDate", "1702449755000"]` | 具体日期(毫秒时间戳) | - |
423
+ | `["Today"]`/`["Tomorrow"]`/`["Yesterday"]` | 今天/明天/昨天 | - |
424
+ | `["CurrentWeek"]`/`["LastWeek"]`/`["CurrentMonth"]`/`["LastMonth"]` | 本周/上周/本月/上月 | 仅 `is` |
425
+ | `["TheLastWeek"]`/`["TheNextWeek"]`/`["TheLastMonth"]`/`["TheNextMonth"]` | 过去/未来七天/三十天 | 仅 `is` |
426
+
427
+ ### ⚠️ 常见错误(必读)
428
+
429
+ #### 1. Text 字段 `is`/`isNot` 传多个 value → 查询返回空结果
430
+
431
+ ```typescript
432
+ // ❌ 错误:Text 类型 is 只能传一个元素,传多个会静默返回空
433
+ { fieldName: '状态', operator: 'is', value: ['红色', '黑色'] }
434
+
435
+ // ✅ 排除少数值:多条 isNot + conjunction: 'and'
436
+ {
437
+ conjunction: 'and',
438
+ conditions: [
439
+ { fieldName: '进展', operator: 'isNot', value: ['已完成'] },
440
+ { fieldName: '进展', operator: 'isNot', value: ['已终止'] },
441
+ ],
442
+ }
443
+
444
+ // ✅ 匹配多个值:用 contains 模糊匹配(适合有公共子串的场景)
445
+ { fieldName: '进展', operator: 'contains', value: ['付款'] } // 匹配"付款审批"和"付款动作"
446
+ ```
447
+
448
+ #### 2. Formula 字段不支持 filter 和 sum/avg 聚合
449
+
450
+ 同一数据常有两个字段:原始 Number 字段(如 `金额`,单位元)和计算 Formula 字段(如 `金额(万元)`)。聚合和过滤**必须用 Number 字段**,在代码里做单位转换:
451
+
452
+ ```typescript
453
+ // ❌ 错误:金额(万元) 是 Formula,不能聚合也不能过滤
454
+ { fieldName: '金额(万元)', aggregation: 'sum' }
455
+ { fieldName: '金额(万元)', operator: 'isGreater', value: ['0'] }
456
+
457
+ // ✅ 正确:用 Number 类型的 金额 字段,代码里除以 10000 转万元
458
+ { fieldName: '金额', aggregation: 'sum', alias: 'total_amount' }
459
+ // 结果处理:const amountWan = totalAmount / 10000;
460
+ ```
461
+
462
+ #### 3. aggregateQuery 的 `isNot` 会排掉字段为空/null 的记录
463
+
464
+ 与 searchRecords 不同,aggregateQuery 中 `isNot` 会把字段值为空的记录也排除。如果很多记录的该字段为空,结果会远少于预期甚至为 0。
465
+
466
+ 解决方案:不在 filter 里用 `isNot` 排除 Text 值,改为在 dimensions 里加上该字段,在结果侧用 if 跳过不要的分组:
467
+
468
+ ```typescript
469
+ // ❌ 错误:isNot 会把「目前进展」为空的记录也排掉
470
+ filter: { conditions: [{ fieldName: '目前进展', operator: 'isNot', value: ['已完成'] }] }
471
+
472
+ // ✅ 正确:不加 filter,在 dimensions 里加「目前进展」,结果侧排除
473
+ dimensions: ['当前状态', '目前进展'],
474
+ // 解析时:if (progress === '已完成' || progress === '已终止') continue;
475
+ ```
476
+
477
+ #### 4. count 聚合只统计非空值
478
+
479
+ 统计总行数时用不会为空的 Number 字段(如"行号"),不要用可能为空的 Text 字段。
480
+
481
+ ### 筛选条件构建示例
482
+
483
+ ```typescript
484
+ const filter = {
485
+ conjunction: 'and', // 或 'or'
486
+ conditions: [
487
+ { fieldName: '标题', operator: 'contains', value: ['关键词'] }, // 文本包含
488
+ { fieldName: '状态', operator: 'is', value: ['进行中'] }, // 单选匹配
489
+ { fieldName: '金额', operator: 'isGreater', value: ['1000'] }, // 数字比较
490
+ { fieldName: '创建日期', operator: 'is', value: ['Today'] }, // 日期-今天
491
+ { fieldName: '截止日期', operator: 'isLess', value: ['ExactDate', Date.now().toString()] }, // 日期-具体
492
+ { fieldName: '负责人', operator: 'is', value: ['123456'] }, // 人员筛选
493
+ { fieldName: '备注', operator: 'isEmpty', value: [] }, // 空值检查
494
+ { fieldName: '已归档', operator: 'is', value: ['true'] }, // 复选框
495
+ ],
496
+ };
497
+
498
+ const response = await capabilityClient
499
+ .load('plugin_instance_id')
500
+ .call<SearchResponse>('searchRecords', { filter, pageSize: 20 });
501
+ ```
@@ -13,23 +13,64 @@
13
13
 
14
14
  ```
15
15
  my-app/
16
- ├── client/
17
- │ ├── src/
18
- ├── components/ # 业务组件
19
- │ │ ├── ui/ # shadcn-ui 基础组件(不要直接改)
20
- ├── pages/ # 路由页面
21
- ├── hooks/ # 自定义 hooks
22
- └── lib/ # 工具函数
23
- ├── index.html
24
- │ └── public/
16
+ ├── src/
17
+ │ ├── assets/ # 代码 import 的静态资源(图标、组件用图)
18
+ │ ├── components/ # 业务组件
19
+ │ │ └── ui/ # shadcn-ui 基础组件(不要直接改)
20
+ │ ├── pages/ # 路由页面
21
+ │ ├── hooks/ # 自定义 hooks
22
+ │ └── lib/ # 工具函数
23
+ ├── public/ # 固定 URL 的公开静态资源(favicon 等)
24
+ ├── shared/
25
+ │ ├── static/ # 鉴权访问的静态资源(私有源托管)
26
+ │ └── capabilities/ # capabilities 声明
27
+ ├── index.html
25
28
  ├── vite.config.ts
26
- ├── tsconfig.json
27
29
  └── package.json
28
30
  ```
29
31
 
32
+ 路径别名:
33
+
34
+ - `@/*` → `./src/*`
35
+ - `@shared/*` → `./shared/*`
36
+
37
+ ## 静态资源放哪——判定规则
38
+
39
+ 按问题树走,**从上往下选第一个 yes**:
40
+
41
+ 1. **代码里要 `import` 它吗?**(组件用的图标、被引用的图片、CSS 里的小图)
42
+ → `src/assets/`,用 `import url from '@/assets/foo.png'`。
43
+ Vite 会 hash + 优化,进 CDN。**绝大多数情况选这条。**
44
+
45
+ 2. **需要鉴权才能访问吗?**(仅登录用户可见的图片、PDF、配置)
46
+ → `shared/static/`。通过平台运行时 SDK 拿带 token 的 URL,**不要**用 `/shared/static/...` 这种相对路径直接引。
47
+
48
+ 3. **必须是固定 URL 文件名吗?**(favicon、被外部系统按 URL 抓取、第三方 SDK 写死路径)
49
+ → `public/`。
50
+ - HTML 里:`<link rel="icon" href="/favicon.svg" />`(Vite 自动重写到 BASE_URL)
51
+ - JSX/CSS 里**不要**写 `<img src="/foo.png" />`——绝对路径在生产会解析到 HTML 域名而不是 CDN,会 404。必须用 `${import.meta.env.BASE_URL}foo.png`。
52
+
53
+ ⚠️ 写代码默认走 `src/assets/`。`public/` 只在 1、2 都不适用时才用,并且必须用 `BASE_URL` 拼路径。
54
+
55
+ ## 动态选择一组资源(国旗、头像类)
56
+
57
+ 不要用 `public/flags/` + 拼字符串。用 `src/assets/` + `import.meta.glob`:
58
+
59
+ ```ts
60
+ const flags = import.meta.glob('@/assets/flags/*.svg', {
61
+ eager: true,
62
+ query: '?url',
63
+ import: 'default',
64
+ }) as Record<string, string>
65
+
66
+ const url = flags[`/src/assets/flags/${code}.svg`]
67
+ ```
68
+
69
+ 这样能拿到 hash 后的 CDN URL,且 Vite 会 tree-shake 未引用项。
70
+
30
71
  ## 核心约定
31
72
 
32
- 1. **shadcn-ui 组件不直接改源码**——需要变化时通过 className / variant props 控制
33
- 2. **样式优先用 Tailwind utility**,避免写 CSS module
34
- 3. **表单始终用 react-hook-form + zod**——不要直接操作 input value
35
- 4. **图表 ECharts 优先 echarts-for-react**——React 18 严格模式下要小心 echarts instance 的初始化
73
+ 1. **shadcn-ui 组件不直接改源码**——需要变化时通过 `className` / `variant` props 控制。
74
+ 2. **样式优先用 Tailwind utility**,避免写 CSS module
75
+ 3. **表单始终用 react-hook-form + zod**——不要直接操作 input value
76
+ 4. **图表 ECharts 优先 echarts-for-react**——React 19 严格模式下注意 echarts instance 的初始化时机。