@lark-apaas/coding-steering 0.1.12 → 0.1.13-alpha.1

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.
@@ -0,0 +1,322 @@
1
+ ## PluginInstance 代码编写指南
2
+
3
+ ### 核心原则:根据场景选择调用侧
4
+
5
+ **默认优先在 Client 侧调用 capabilityClient;但当插件结果需要持久化到数据库时,应考虑在 Server 侧调用或确保前端调用后及时通过后端接口保存。**
6
+ **严禁** import { capabilityClient } from '@lark-apaas/client-capability'。
7
+ **唯一指定**的导入方式是 import { capabilityClient } from '@lark-apaas/client-toolkit';
8
+
9
+ | 优先级 | 场景 | 调用方式 |
10
+ |-------|------|---------|
11
+ | **首选** | 绝大多数场景 | `capabilityClient.load(id).call()` |
12
+ | **首选** | 流式输出场景 | `capabilityClient.load(id).callStream()` |
13
+ | **兜底** | Client 侧无法满足时 | `CapabilityService.load(id).call()` |
14
+
15
+ #### 什么情况下应使用 Server 侧?
16
+
17
+ 以下场景适合在 Server 侧调用:
18
+
19
+ 1. **涉及敏感凭证**:调用需要服务端私密 token/secret,不适合暴露给前端
20
+ 2. **必须后端编排**:多个插件调用之间有强事务依赖,需要后端统一编排
21
+ 3. **触发器/定时任务场景**:没有前端上下文,只能由后端发起
22
+ 4. **插件结果需要持久化**:调用结果需要保存到数据库,在 Server 侧可以在同一方法中完成调用+落库,保证数据一致性
23
+
24
+ > **提示**:如果插件结果不需要存储、仅用于即时展示(如流式生成文本、发送消息),优先在前端调用。但当结果需要保存到数据库时,不要回避使用 Server 侧。
25
+
26
+ ---
27
+
28
+ ### 插件结果持久化决策
29
+
30
+ 当插件返回的结果需要保存到数据库时(如 AI 分类/摘要结果、文档解析的结构化数据、图片识别结果、语音转文字内容等),按以下决策选择方案:
31
+
32
+ ```
33
+ 插件结果是否需要持久化到数据库?
34
+ ├── 否 → Client 侧调用(默认)
35
+ └── 是 → 推荐方案 A:Server 侧调用,在 Service 中调用插件并在同一方法中落库
36
+ 备选方案 B:Client 侧调用插件 → 成功后通过已有 CRUD 接口保存结果
37
+ ```
38
+
39
+ | 应避免的做法 | 推荐做法 |
40
+ |------------|---------|
41
+ | 前端调用插件后不保存结果,导致数据丢失 | 插件调用成功后及时持久化 |
42
+ | 为保存插件结果单独新建 API(如 `PATCH /api/xxx/ai-analysis`) | 优先复用已有的 create/update 接口,扩展字段即可 |
43
+ | 仅在前端 state 中暂存插件结果,不写入数据库 | 通过后端接口保存到数据库 |
44
+
45
+ ---
46
+
47
+ ### Client 侧调用方式(默认首选)
48
+
49
+ #### 1. 调用前获取权威依据
50
+
51
+ 在为某个插件实例生成调用代码前,必须先通过 `get_plugin_ai_json` 工具获取该插件实例的运行时投影(plugin_Instance.ai.json),并以其中信息为准:
52
+
53
+ - `actions[].key`:调用时要传的 `actionKey`
54
+ - `actions[].inputSchema / outputSchema`:入参/出参结构
55
+ - `actions[].outputMode`:`unary | stream`(决定调用与结果处理方式)
56
+
57
+ **编码前闸门(必须)**:先产出 Schema 摘录卡,再开始代码编辑。
58
+
59
+ ```markdown
60
+ [Schema 摘录卡]
61
+ - pluginInstanceId / actionKey / outputMode
62
+ - input.required / output.fields / readme.constraints
63
+ - 调用侧决策: Client | Server
64
+ ```
65
+
66
+ 若摘录卡字段缺失,不得进入实现阶段。
67
+
68
+ #### call / callStream 函数签名
69
+
70
+ ```typescript
71
+ .call(actionKey: string, input: object) // 非流式,返回 Promise<output>
72
+ .callStream(actionKey: string, input: object) // 流式,返回 AsyncIterable<chunk>
73
+ ```
74
+
75
+ - **第一个参数 `actionKey`**:必须是字符串,值来自 `get_plugin_ai_json` 返回的 `actions[].key`(如 `'sendFeishuMessage'`、`'textGenerate'`)
76
+ - **第二个参数 `input`**:必须是对象,结构符合 `actions[].inputSchema`
77
+
78
+ ```typescript
79
+ // ❌ 错误:把参数 JSON.stringify 后当作 actionKey
80
+ plugin.call(JSON.stringify({ meeting_title: '...' }));
81
+ // ❌ 错误:漏掉 actionKey,直接传参数对象
82
+ plugin.call({ meeting_title: '...' });
83
+
84
+ // ✅ 正确:第一个参数是 actionKey 字符串,第二个参数是 input 对象
85
+ plugin.call('send_feishu_message', { meeting_title: '...' });
86
+ ```
87
+
88
+ #### 2. 非流式调用(outputMode = "unary")
89
+
90
+ ```typescript
91
+ import { capabilityClient } from '@lark-apaas/client-toolkit';
92
+ import { logger } from "@lark-apaas/client-toolkit/logger";
93
+
94
+ const result = await capabilityClient
95
+ .load('create_feishu_group')
96
+ .call('createGroup', {
97
+ group_name: '项目讨论群',
98
+ members: ['user_001', 'user_002'],
99
+ });
100
+
101
+ logger.info(result);
102
+ ```
103
+
104
+ #### 3. 流式调用(outputMode = "stream")
105
+
106
+ ##### 必须处理返回形态差异(重点)
107
+
108
+ `callStream()` 可能返回 `AsyncIterable<chunk>` 或 `{ output: AsyncIterable<chunk> }`,必须先归一化。
109
+
110
+ ```typescript
111
+ type AnyRecord = Record<string, unknown>;
112
+
113
+ function isAsyncIterable(value: unknown): value is AsyncIterable<AnyRecord> {
114
+ return !!value && typeof (value as AnyRecord)[Symbol.asyncIterator] === 'function';
115
+ }
116
+
117
+ function normalizeStream(resultOrStream: unknown): AsyncIterable<AnyRecord> {
118
+ if (isAsyncIterable(resultOrStream)) {
119
+ return resultOrStream;
120
+ }
121
+ if (
122
+ resultOrStream &&
123
+ typeof resultOrStream === 'object' &&
124
+ 'output' in (resultOrStream as AnyRecord) &&
125
+ isAsyncIterable((resultOrStream as AnyRecord).output)
126
+ ) {
127
+ return (resultOrStream as AnyRecord).output as AsyncIterable<AnyRecord>;
128
+ }
129
+ throw new Error('Invalid callStream result: cannot find AsyncIterable stream');
130
+ }
131
+
132
+ function readFirstStringField(
133
+ chunk: AnyRecord,
134
+ keys: string[],
135
+ ): string {
136
+ for (const key of keys) {
137
+ const value = chunk[key];
138
+ if (typeof value === 'string') {
139
+ return value;
140
+ }
141
+ }
142
+ return '';
143
+ }
144
+ ```
145
+
146
+ ##### 场景判断与推荐方案
147
+
148
+ | 场景 | 特征 | 推荐度 |
149
+ |-----|------|-------|
150
+ | **多插件并行流式** | 多个插件各返回单一输出,并行调用 | **优先推荐** |
151
+ | **单插件 JSON 流式解析** | 单插件返回结构化 JSON,需边接收边解析 | ⚠️ 仅在必要时 |
152
+
153
+ **核心原则**:在插件设计阶段按「原子化拆解」拆分,避免单插件返回多字段 JSON。
154
+
155
+ ##### 推荐:多插件并行流式
156
+
157
+ 适用于需求涉及多种输出(标题、正文、图片等),各输出相对独立。
158
+
159
+ ```tsx
160
+ import { logger } from "@lark-apaas/client-toolkit/logger";
161
+
162
+ function MultiPluginStreamExample() {
163
+ const [title, setTitle] = useState('');
164
+ const [content, setContent] = useState('');
165
+ const [coverUrl, setCoverUrl] = useState('');
166
+
167
+ const handleGenerate = async (keywords: string) => {
168
+ // 1. 封面图(非流式,异步不阻塞)
169
+ capabilityClient
170
+ .load('cover_generator')
171
+ .call<{ images: string[] }>('textToImage', { keywords })
172
+ .then(res => res?.images?.[0] && setCoverUrl(res.images[0]))
173
+ .catch(err => logger.warn('封面生成失败', err));
174
+
175
+ // 2. 标题(非流式)
176
+ capabilityClient
177
+ .load('title_generator')
178
+ .call<{ content: string }>('textGenerate', { keywords })
179
+ .then(res => res?.content && setTitle(res.content));
180
+
181
+ // 3. 正文(流式,边生成边展示)
182
+ const streamResult = capabilityClient
183
+ .load('content_generator')
184
+ .callStream<{ content: string }>('textGenerate', { keywords });
185
+ const contentStream = normalizeStream(streamResult);
186
+
187
+ // 🎯 按 outputSchema 字段提取,禁止把 chunk 当字符串
188
+ for await (const chunk of contentStream) {
189
+ const delta = readFirstStringField(
190
+ chunk as Record<string, unknown>,
191
+ ['content'], // 必须来自 get_plugin_ai_json.actions[].outputSchema
192
+ );
193
+ if (delta) {
194
+ setContent(prev => prev + delta);
195
+ }
196
+ }
197
+ };
198
+
199
+ return (/* 各字段独立渲染 */);
200
+ }
201
+ ```
202
+
203
+ **优点**:代码简洁、各插件独立、某个失败不影响其他。
204
+
205
+ ##### ⚠️ 兜底:单插件 JSON 流式解析
206
+
207
+ 当无法拆分为多插件时,需处理不完整 JSON 的逐字符到达,需实现 `parseStreamingStringField` 和 `extractJsonObject` 工具函数。**强烈建议在插件设计阶段拆分为多插件并行流式调用,避免此场景。**
208
+
209
+ ---
210
+
211
+ ### 失败日志最小集(必须)
212
+
213
+ 失败日志至少包含以下字段:
214
+
215
+ ```typescript
216
+ {
217
+ pluginInstanceId: string,
218
+ actionKey: string,
219
+ outputMode: 'unary' | 'stream',
220
+ inputKeys: string[],
221
+ resultType?: string,
222
+ resultKeys?: string[],
223
+ firstChunkKeys?: string[],
224
+ error: string
225
+ }
226
+ ```
227
+
228
+ ### 改后冒烟验证清单(必须)
229
+
230
+ 完成调用代码后,最少执行并记录:
231
+
232
+ 1. 一个 `unary` action 的真实调用结果(字段按 `outputSchema` 读取)
233
+ 2. 一个 `stream` action 的真实调用结果(chunk 按 `outputSchema` 字段读取)
234
+ 3. 调用失败时的最小日志字段齐全
235
+ 4. 若无法执行真实调用,必须明确写明阻塞原因,禁止直接标记"开发完成"
236
+
237
+ ### Server 侧调用方式(仅兜底场景)
238
+
239
+ > 以下场景适合使用 Server 侧调用,特别是涉及数据持久化时不要回避后端。
240
+
241
+ #### 1. 何时适合用 Server 侧?
242
+
243
+ | 场景 | 原因 | 示例 |
244
+ |------|------|------|
245
+ | 触发器/Webhook | 无前端上下文 | 数据变更时自动发送通知 |
246
+ | 定时任务 | 无前端上下文 | 每日定时生成报告 |
247
+ | 敏感凭证调用 | 凭证不能暴露给前端 | 调用需要 admin token 的 API |
248
+ | 强事务编排 | 多步骤需要原子性 | 创建记录 → 发通知 → 更新状态必须全成功或全回滚 |
249
+ | 插件结果需持久化 | 调用结果需保存到数据库 | AI 分类/摘要结果需落库、文档解析的结构化数据需入库、图片识别结果需关联业务记录、语音转文字结果需存档等 |
250
+
251
+ #### 2. NestJS 注入方式
252
+
253
+ ```typescript
254
+ import { Injectable, Inject, Logger } from '@nestjs/common';
255
+ import { CapabilityService } from '@lark-apaas/fullstack-nestjs-core';
256
+
257
+ @Injectable()
258
+ export class XxxService {
259
+ private readonly logger = new Logger(XxxService.name);
260
+
261
+ constructor(
262
+ @Inject() private readonly capabilityService: CapabilityService,
263
+ ) {}
264
+ }
265
+ ```
266
+
267
+ #### 3. 调用示例
268
+
269
+ ```typescript
270
+ const inputParams = {
271
+ // 严格按 get_plugin_ai_json.actions[].inputSchema 构造
272
+ };
273
+
274
+ try {
275
+ const output = await this.capabilityService
276
+ .load('')
277
+ .call('', inputParams);
278
+ return output;
279
+ } catch (error) {
280
+ this.logger.error('pluginInstance call failed', {
281
+ pluginInstanceId: '',
282
+ actionKey: '',
283
+ error: error instanceof Error ? error.message : 'Unknown error',
284
+ });
285
+ throw error;
286
+ }
287
+ ```
288
+
289
+ #### 4. Server 侧编排与容错原则
290
+
291
+ - PluginInstance 调用在 Server 侧通常属于 **外部依赖 / side-effect**
292
+ - 除非业务明确要求强一致性,**默认不应阻塞主业务流程**
293
+
294
+ 推荐写法:异步触发 + catch 兜底:
295
+
296
+ ```typescript
297
+ this.somePluginInstanceSideEffect(input).catch(error => {
298
+ this.logger.warn('PluginInstance side-effect failed, ignored', {
299
+ error: error instanceof Error ? error.message : 'Unknown error'
300
+ });
301
+ });
302
+ ```
303
+
304
+ ---
305
+
306
+ ### outputMode 与调用侧选择
307
+
308
+ 先通过 `get_plugin_ai_json(pluginInstanceId)` 获取 `actions[].outputMode`:
309
+
310
+ | outputMode | 推荐调用侧 | 调用方式 |
311
+ |------------|-----------|---------|
312
+ | `unary` | **Client 侧优先** | `capabilityClient.load(id).call(actionKey, input)` |
313
+ | `stream` | **Client 侧优先** | `capabilityClient.load(id).callStream(actionKey, input)` |
314
+ | 任意(兜底场景) | Server 侧 | `capabilityService.load(id).call(actionKey, input)` |
315
+
316
+ **选择原则**:
317
+
318
+ - 不涉及持久化时,优先在 Client 侧直接调用
319
+ - `outputMode = stream` 时,Client 侧使用 `callStream` 做渐进式渲染
320
+ - 涉及持久化、触发器、敏感凭证、事务编排等场景时,使用 Server 侧
321
+
322
+ ---
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 插件充血脚本:合并 capability JSON + manifest.json,生成调用投影。
4
+ *
5
+ * 内部逻辑等价于 fullstack-cli hydrateCapability + miaoda plugin list。
6
+ *
7
+ * 输入(命令行参数):
8
+ * pluginKey — 如 @official-plugins/ai-text-to-json
9
+ * instanceId — 如 task-json-extractor
10
+ *
11
+ * 输出:
12
+ * stdout 含 actions[] 的完整 JSON
13
+ * exit 1 → stderr 错误信息
14
+ *
15
+ * 用法:
16
+ * node plugin-hydrate.js @official-plugins/ai-text-to-json task-json-extractor
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const { createRequire } = require('module');
22
+
23
+ const [pluginKey, instanceId] = process.argv.slice(2);
24
+
25
+ if (!pluginKey || !instanceId) {
26
+ process.stderr.write('Usage: node plugin-hydrate.js <pluginKey> <instanceId>\n');
27
+ process.exit(1);
28
+ }
29
+
30
+ const cwd = process.cwd();
31
+
32
+ // ── ① 探测 capabilities 目录 ──
33
+ function resolveCapDir() {
34
+ if (process.env.MIAODA_CAPABILITIES_DIR) {
35
+ return path.isAbsolute(process.env.MIAODA_CAPABILITIES_DIR)
36
+ ? process.env.MIAODA_CAPABILITIES_DIR
37
+ : path.join(cwd, process.env.MIAODA_CAPABILITIES_DIR);
38
+ }
39
+
40
+ let appType = process.env.MIAODA_APP_TYPE;
41
+ if (!appType) {
42
+ try {
43
+ const envLocal = fs.readFileSync(path.join(cwd, '.env.local'), 'utf-8');
44
+ for (const line of envLocal.split('\n')) {
45
+ const trimmed = line.trim();
46
+ if (!trimmed || trimmed.startsWith('#')) continue;
47
+ const eq = trimmed.indexOf('=');
48
+ if (eq < 0) continue;
49
+ const k = trimmed.slice(0, eq).trim();
50
+ const v = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
51
+ if (k === 'MIAODA_APP_TYPE') { appType = v; break; }
52
+ }
53
+ } catch (_) {}
54
+ }
55
+ if (appType === '6') return path.join(cwd, 'shared', 'capabilities');
56
+ if (appType) return path.join(cwd, 'server', 'capabilities');
57
+
58
+ const serverDir = path.join(cwd, 'server', 'capabilities');
59
+ const sharedDir = path.join(cwd, 'shared', 'capabilities');
60
+ const serverExists = fs.existsSync(serverDir);
61
+ const sharedExists = fs.existsSync(sharedDir);
62
+ if (serverExists) return serverDir;
63
+ if (sharedExists) return sharedDir;
64
+ return serverDir; // default
65
+ }
66
+
67
+ // ── 检查是否动态 schema ──
68
+ function isDynamic(schema) {
69
+ return schema && typeof schema === 'object' && schema.dynamic === true;
70
+ }
71
+
72
+ // ── 主流程 ──
73
+ try {
74
+ const capDir = resolveCapDir();
75
+ const capPath = path.join(capDir, instanceId + '.json');
76
+
77
+ // ② 读 capability JSON
78
+ if (!fs.existsSync(capPath)) {
79
+ process.stderr.write('Error: Instance not found: ' + instanceId + '\n');
80
+ process.exit(1);
81
+ }
82
+ const cap = JSON.parse(fs.readFileSync(capPath, 'utf-8'));
83
+ const paramsSchema = cap.paramsSchema;
84
+ const formValue = cap.formValue || {};
85
+
86
+ // ③ 读 manifest.json
87
+ const manifestPath = path.join(cwd, 'node_modules', pluginKey, 'manifest.json');
88
+ if (!fs.existsSync(manifestPath)) {
89
+ process.stderr.write('Error: Plugin not installed: ' + pluginKey + '\n');
90
+ process.exit(1);
91
+ }
92
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
93
+ const rawActions = manifest.actions || [];
94
+ if (rawActions.length === 0) {
95
+ process.stderr.write('Error: Plugin has no actions defined\n');
96
+ process.exit(1);
97
+ }
98
+
99
+ // ④ 检查动态 schema
100
+ const hasDynamic = rawActions.some(
101
+ a => isDynamic(a.inputSchema) || isDynamic(a.outputSchema)
102
+ );
103
+
104
+ // ⑤ 动态解析
105
+ let resolved = {};
106
+ if (hasDynamic) {
107
+ const projectRequire = createRequire(path.join(cwd, 'package.json'));
108
+ const plugin = projectRequire(pluginKey);
109
+ const instance = plugin.create(formValue);
110
+
111
+ const actionKeys = rawActions.map(a => a.key).filter(Boolean);
112
+ for (const key of actionKeys) {
113
+ try { resolved[key + '_input'] = instance.getInputJsonSchema(key); } catch (_) {}
114
+ try { resolved[key + '_output'] = instance.getOutputJsonSchema(key, formValue); } catch (_) {}
115
+ }
116
+ }
117
+
118
+ // ⑥ 构建 actions 数组
119
+ const hasParams = paramsSchema && typeof paramsSchema === 'object' && Object.keys(paramsSchema).length > 0;
120
+ const actions = rawActions.map((action, i) => {
121
+ const out = {
122
+ key: action.key,
123
+ outputMode: action.outputMode || '',
124
+ };
125
+
126
+ // inputSchema: action[0] + paramsSchema 优先
127
+ if (i === 0 && hasParams) {
128
+ out.inputSchema = paramsSchema;
129
+ } else if (hasDynamic && isDynamic(action.inputSchema)) {
130
+ out.inputSchema = resolved[action.key + '_input'];
131
+ } else {
132
+ out.inputSchema = action.inputSchema;
133
+ }
134
+
135
+ // outputSchema
136
+ if (hasDynamic && isDynamic(action.outputSchema)) {
137
+ out.outputSchema = resolved[action.key + '_output'];
138
+ } else {
139
+ out.outputSchema = action.outputSchema;
140
+ }
141
+
142
+ return out;
143
+ });
144
+
145
+ // 输出
146
+ const result = { ...cap, actions };
147
+ process.stdout.write(JSON.stringify(result, null, 2));
148
+
149
+ } catch (e) {
150
+ process.stderr.write('Error: ' + (e.message || e) + '\n');
151
+ process.exit(1);
152
+ }