@steedos-labs/plugin-workflow 3.0.33 → 3.0.36

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,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/api/workflow/designer-v2/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>designer</title>
8
+ <script type="module" crossorigin src="/api/workflow/designer-v2/assets/index-r2EtJdFh.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/api/workflow/designer-v2/assets/index-CzP9-MzW.css">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
@@ -0,0 +1,4 @@
1
+ waitForThing(window, 'antd').then(function(){
2
+ loadJs('/amis-renderer/amis-renderer.js');
3
+ loadCss('/amis-renderer/amis-renderer.css')
4
+ })
@@ -102,6 +102,7 @@ router.get('/am/designer/startup/v2', async function (req, res) {
102
102
  res.send({
103
103
  flow,
104
104
  form,
105
+ aiEnabled: !!process.env.WORKFLOW_AI_API_KEY,
105
106
  sync_token: new Date().getTime() / 1000
106
107
  });
107
108
  } catch (error) {
@@ -109,6 +110,17 @@ router.get('/am/designer/startup/v2', async function (req, res) {
109
110
  }
110
111
  })
111
112
 
113
+ // Check if AI features are enabled (independent of flowId)
114
+ router.get('/am/designer/ai-enabled', async function (req, res) {
115
+ try {
116
+ res.send({
117
+ aiEnabled: !!process.env.WORKFLOW_AI_API_KEY,
118
+ });
119
+ } catch (error) {
120
+ res.status(500).send(error.message);
121
+ }
122
+ })
123
+
112
124
  // 表单
113
125
  router.post('/am/forms', async function (req, res) {
114
126
  try {
@@ -180,7 +192,10 @@ router.post('/am/forms', async function (req, res) {
180
192
  created_by: userId,
181
193
  modified: now,
182
194
  modified_by: userId,
183
- fields: form["current"]["fields"]
195
+ fields: form["current"]["fields"],
196
+ version: form["current"]["version"],
197
+ viewMode: form["current"]["viewMode"],
198
+ tableColumns: form["current"]["tableColumns"],
184
199
  };
185
200
  let amis_schema = null;
186
201
  if (objectName) {
@@ -553,6 +568,14 @@ router.put('/am/flows', async function (req, res) {
553
568
  updateObj.$set.modified = now;
554
569
  updateObj.$set.modified_by = userId;
555
570
 
571
+ // Save instance_template and events if provided (e.g. from AI upgrade)
572
+ if (flowCome['instance_template'] !== undefined) {
573
+ updateObj.$set.instance_template = flowCome['instance_template'];
574
+ }
575
+ if (flowCome['events'] !== undefined) {
576
+ updateObj.$set.events = flowCome['events'];
577
+ }
578
+
556
579
  if (flowCome['perms']) {
557
580
  flowCome['perms']['_id'] = flowCome['perms']['id'];
558
581
  delete flowCome['perms']['id'];
@@ -0,0 +1,104 @@
1
+ const express = require("express");
2
+ const router = express.Router();
3
+ const steedosAuth = require('@steedos/auth');
4
+ const axios = require('axios');
5
+ const bodyParser = require('body-parser');
6
+
7
+ router.use(bodyParser.json({ limit: '10mb' }));
8
+
9
+ /**
10
+ * AI Code Generate endpoint
11
+ *
12
+ * A lightweight endpoint for generating code snippets (formulas, event scripts, etc.)
13
+ * using a custom system prompt provided by the client.
14
+ * Unlike /am/ai/form-design, this does NOT inject any form-designer system prompt.
15
+ * It returns the raw AI-generated text as { code: "..." }.
16
+ *
17
+ * Reuses the same environment variables:
18
+ * WORKFLOW_AI_API_KEY - API key for the LLM provider
19
+ * WORKFLOW_AI_BASE_URL - Base URL (default: https://api.openai.com/v1)
20
+ * WORKFLOW_AI_MODEL - Model name (default: gpt-4o)
21
+ */
22
+ router.post('/am/ai/code-generate', async function auth(req, res, next) {
23
+ try {
24
+ const result = await steedosAuth.auth(req, res);
25
+ if (result && result.userId) {
26
+ req.user = result;
27
+ next();
28
+ } else {
29
+ res.status(401).json({ error: '请先登录' });
30
+ }
31
+ } catch (e) {
32
+ res.status(401).json({ error: '认证失败' });
33
+ }
34
+ }, async function (req, res) {
35
+ try {
36
+ const apiKey = process.env.WORKFLOW_AI_API_KEY;
37
+ if (!apiKey) {
38
+ return res.status(500).json({ error: '请先配置环境变量 WORKFLOW_AI_API_KEY' });
39
+ }
40
+
41
+ const baseURL = process.env.WORKFLOW_AI_BASE_URL || 'https://api.openai.com/v1';
42
+ const model = process.env.WORKFLOW_AI_MODEL || 'gpt-4o';
43
+
44
+ const { systemPrompt, userMessage } = req.body;
45
+
46
+ if (!systemPrompt || !userMessage) {
47
+ return res.status(400).json({ error: '缺少 systemPrompt 或 userMessage' });
48
+ }
49
+ console.log('User message content:', JSON.stringify([
50
+ { role: 'system', content: systemPrompt },
51
+ { role: 'user', content: userMessage },
52
+ ], null, 2));
53
+ const response = await axios.post(`${baseURL}/chat/completions`, {
54
+ model,
55
+ messages: [
56
+ { role: 'system', content: systemPrompt },
57
+ { role: 'user', content: userMessage },
58
+ ],
59
+ temperature: 0.3,
60
+ max_tokens: 8000,
61
+ }, {
62
+ headers: {
63
+ 'Authorization': `Bearer ${apiKey}`,
64
+ 'Content-Type': 'application/json',
65
+ },
66
+ timeout: 120000,
67
+ });
68
+
69
+ const content = response.data?.choices?.[0]?.message?.content;
70
+ if (!content) {
71
+ return res.status(500).json({ error: 'AI 未返回有效内容' });
72
+ }
73
+
74
+ // Clean up: remove markdown code fences if present
75
+ let code = content.trim();
76
+ if (code.startsWith('```')) {
77
+ // Remove opening fence (e.g. ```javascript, ```js, ```)
78
+ code = code.replace(/^```[\w]*\n?/, '');
79
+ }
80
+ if (code.endsWith('```')) {
81
+ code = code.slice(0, -3);
82
+ }
83
+ code = code.trim();
84
+
85
+ return res.json({ code });
86
+
87
+ } catch (error) {
88
+ console.error('AI code generate error:', error?.response?.data || error?.message || error);
89
+ if (error.response) {
90
+ const status = error.response.status;
91
+ const data = error.response.data;
92
+ if (status === 401 || status === 403) {
93
+ return res.status(500).json({ error: 'AI API 密钥无效或权限不足' });
94
+ }
95
+ if (status === 429) {
96
+ return res.status(500).json({ error: 'AI API 请求频率超限,请稍后重试' });
97
+ }
98
+ return res.status(500).json({ error: data?.error?.message || 'AI API 调用失败' });
99
+ }
100
+ return res.status(500).json({ error: error.message || 'AI 服务调用失败' });
101
+ }
102
+ });
103
+
104
+ exports.default = router;
@@ -118,7 +118,7 @@ ${userRequest}
118
118
  'Authorization': `Bearer ${apiKey}`,
119
119
  'Content-Type': 'application/json',
120
120
  },
121
- timeout: 120000,
121
+ timeout: 300000,
122
122
  });
123
123
 
124
124
  const content = response.data?.choices?.[0]?.message?.content;
@@ -0,0 +1,363 @@
1
+ const express = require("express");
2
+ const router = express.Router();
3
+ const steedosAuth = require('@steedos/auth');
4
+ const axios = require('axios');
5
+ const bodyParser = require('body-parser');
6
+
7
+ router.use(bodyParser.json({ limit: '20mb' }));
8
+
9
+ /**
10
+ * Streaming AI Workflow Designer endpoint (SSE)
11
+ *
12
+ * Same logic as /am/ai/design but uses Server-Sent Events to stream
13
+ * progress updates so the client can show intermediate status.
14
+ * Also supports images (base64 data URLs) for multimodal analysis.
15
+ *
16
+ * Events sent:
17
+ * { event: "progress", data: { stage: string, message: string } }
18
+ * { event: "chunk", data: { text: string, accumulated: string } } – raw AI text chunks
19
+ * { event: "result", data: { steps: [...] } }
20
+ * { event: "error", data: { error: string } }
21
+ * { event: "done", data: {} }
22
+ */
23
+ router.post('/am/ai/design-stream', async function auth(req, res, next) {
24
+ try {
25
+ const result = await steedosAuth.auth(req, res);
26
+ if (result && result.userId) {
27
+ req.user = result;
28
+ next();
29
+ } else {
30
+ res.status(401).json({ error: '请先登录' });
31
+ }
32
+ } catch (e) {
33
+ res.status(401).json({ error: '认证失败' });
34
+ }
35
+ }, async function (req, res) {
36
+ // ---------- Set up SSE headers ----------
37
+ res.setHeader('Content-Type', 'text/event-stream');
38
+ res.setHeader('Cache-Control', 'no-cache');
39
+ res.setHeader('Connection', 'keep-alive');
40
+ res.setHeader('X-Accel-Buffering', 'no'); // nginx
41
+ res.flushHeaders();
42
+
43
+ /** Helper: send an SSE event */
44
+ function sendEvent(event, data) {
45
+ if (res.writableEnded) return;
46
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
47
+ if (typeof res.flush === 'function') res.flush();
48
+ }
49
+
50
+ // If client disconnects early, stop writing
51
+ let aborted = false;
52
+ req.on('close', () => { aborted = true; });
53
+
54
+ try {
55
+ const apiKey = process.env.WORKFLOW_AI_API_KEY;
56
+ if (!apiKey) {
57
+ sendEvent('error', { error: '请先配置环境变量 WORKFLOW_AI_API_KEY' });
58
+ sendEvent('done', {});
59
+ return res.end();
60
+ }
61
+
62
+ const baseURL = process.env.WORKFLOW_AI_BASE_URL || 'https://api.openai.com/v1';
63
+ const model = process.env.WORKFLOW_AI_MODEL || 'gpt-4o';
64
+
65
+ const { steps, formFields, userRequest, images } = req.body;
66
+
67
+ if (!userRequest) {
68
+ sendEvent('error', { error: '请输入需求描述' });
69
+ sendEvent('done', {});
70
+ return res.end();
71
+ }
72
+
73
+ // ---- Build system prompt ----
74
+ sendEvent('progress', { stage: 'prepare', message: '正在准备请求...' });
75
+
76
+ const systemPrompt = buildSystemPrompt();
77
+
78
+ // Build form fields description
79
+ const fieldsDesc = (formFields || [])
80
+ .filter(f => f.code || f.name)
81
+ .map(f => ` - ${f.name || f.code} (编码: ${f.code || f.name}, 类型: ${f.type || 'text'})`)
82
+ .join('\n');
83
+
84
+ const userContent = `## 当前表单字段
85
+ ${fieldsDesc || '(无字段信息)'}
86
+
87
+ ## 当前流程步骤
88
+ ${JSON.stringify(steps || [], null, 2)}
89
+
90
+ ## 用户需求
91
+ ${userRequest}
92
+
93
+ 请返回修改后的完整 steps JSON 数组。`;
94
+
95
+ let userMessageContent;
96
+ if (Array.isArray(images) && images.length > 0) {
97
+ userMessageContent = [
98
+ { type: 'text', text: userContent },
99
+ ...images.slice(0, 5).map(img => ({
100
+ type: 'image_url',
101
+ image_url: { url: img, detail: 'auto' },
102
+ })),
103
+ ];
104
+ } else {
105
+ userMessageContent = userContent;
106
+ }
107
+
108
+ if (aborted) return res.end();
109
+
110
+ // ---- Call LLM with streaming ----
111
+ sendEvent('progress', { stage: 'calling', message: '正在连接 AI 服务...' });
112
+
113
+ // Log messages sent to AI
114
+ console.log('===== AI Workflow Design Stream - Messages sent to AI =====');
115
+ console.log('System prompt length:', systemPrompt.length);
116
+ console.log('User message type:', typeof userMessageContent === 'string' ? 'text' : 'multipart (with images)');
117
+ console.log('User message length:', typeof userMessageContent === 'string' ? userMessageContent.length : JSON.stringify(userMessageContent).length);
118
+ console.log('===== End of AI Messages =====');
119
+
120
+ let accumulated = '';
121
+ let chunkCount = 0;
122
+
123
+ try {
124
+ const streamResponse = await axios.post(`${baseURL}/chat/completions`, {
125
+ model,
126
+ messages: [
127
+ { role: 'system', content: systemPrompt },
128
+ { role: 'user', content: userMessageContent },
129
+ ],
130
+ temperature: 0.3,
131
+ max_tokens: 8000,
132
+ stream: true,
133
+ }, {
134
+ headers: {
135
+ 'Authorization': `Bearer ${apiKey}`,
136
+ 'Content-Type': 'application/json',
137
+ },
138
+ timeout: 300000,
139
+ responseType: 'stream',
140
+ });
141
+
142
+ sendEvent('progress', { stage: 'streaming', message: 'AI 正在生成内容...' });
143
+
144
+ // Process the SSE stream from OpenAI
145
+ await new Promise((resolve, reject) => {
146
+ let buffer = '';
147
+
148
+ streamResponse.data.on('data', (chunk) => {
149
+ if (aborted) {
150
+ streamResponse.data.destroy();
151
+ return resolve();
152
+ }
153
+
154
+ buffer += chunk.toString();
155
+ const lines = buffer.split('\n');
156
+ buffer = lines.pop() || '';
157
+
158
+ for (const line of lines) {
159
+ const trimmed = line.trim();
160
+ if (!trimmed || trimmed === 'data: [DONE]') continue;
161
+ if (!trimmed.startsWith('data: ')) continue;
162
+
163
+ try {
164
+ const json = JSON.parse(trimmed.slice(6));
165
+ const delta = json.choices?.[0]?.delta?.content;
166
+ if (delta) {
167
+ accumulated += delta;
168
+ chunkCount++;
169
+ sendEvent('chunk', {
170
+ text: delta,
171
+ accumulated: accumulated,
172
+ });
173
+ }
174
+ } catch (_) {
175
+ // Ignore parse errors in stream chunks
176
+ }
177
+ }
178
+ });
179
+
180
+ streamResponse.data.on('end', () => {
181
+ if (buffer.trim() && buffer.trim() !== 'data: [DONE]' && buffer.trim().startsWith('data: ')) {
182
+ try {
183
+ const json = JSON.parse(buffer.trim().slice(6));
184
+ const delta = json.choices?.[0]?.delta?.content;
185
+ if (delta) accumulated += delta;
186
+ } catch (_) {}
187
+ }
188
+ resolve();
189
+ });
190
+
191
+ streamResponse.data.on('error', (err) => {
192
+ reject(err);
193
+ });
194
+ });
195
+
196
+ } catch (streamErr) {
197
+ // If streaming fails, fall back to non-streaming call
198
+ if (streamErr.response && streamErr.response.status) {
199
+ const status = streamErr.response.status;
200
+ if (status === 401 || status === 403) {
201
+ sendEvent('error', { error: 'AI API 密钥无效或权限不足,请检查 WORKFLOW_AI_API_KEY' });
202
+ sendEvent('done', {});
203
+ return res.end();
204
+ }
205
+ if (status === 429) {
206
+ sendEvent('error', { error: 'AI API 请求频率超限,请稍后重试' });
207
+ sendEvent('done', {});
208
+ return res.end();
209
+ }
210
+ }
211
+
212
+ sendEvent('progress', { stage: 'fallback', message: '流式传输不可用,切换为普通请求...' });
213
+
214
+ try {
215
+ const fallbackResponse = await axios.post(`${baseURL}/chat/completions`, {
216
+ model,
217
+ messages: [
218
+ { role: 'system', content: systemPrompt },
219
+ { role: 'user', content: userMessageContent },
220
+ ],
221
+ temperature: 0.3,
222
+ max_tokens: 8000,
223
+ }, {
224
+ headers: {
225
+ 'Authorization': `Bearer ${apiKey}`,
226
+ 'Content-Type': 'application/json',
227
+ },
228
+ timeout: 300000,
229
+ });
230
+
231
+ accumulated = fallbackResponse.data?.choices?.[0]?.message?.content || '';
232
+ } catch (fallbackErr) {
233
+ const errMsg = fallbackErr?.response?.data?.error?.message || fallbackErr?.message || 'AI 服务调用失败';
234
+ sendEvent('error', { error: errMsg });
235
+ sendEvent('done', {});
236
+ return res.end();
237
+ }
238
+ }
239
+
240
+ if (aborted) return res.end();
241
+
242
+ if (!accumulated) {
243
+ sendEvent('error', { error: 'AI 未返回有效内容' });
244
+ sendEvent('done', {});
245
+ return res.end();
246
+ }
247
+
248
+ // ---- Parse JSON ----
249
+ sendEvent('progress', { stage: 'parsing', message: `正在解析 AI 返回内容 (${accumulated.length} 字符)...` });
250
+
251
+ let stepsResult;
252
+ try {
253
+ let text = accumulated.trim();
254
+ // Strip markdown code block if present
255
+ if (text.startsWith('```json')) text = text.slice(7);
256
+ else if (text.startsWith('```')) text = text.slice(3);
257
+ if (text.endsWith('```')) text = text.slice(0, -3);
258
+ text = text.trim();
259
+
260
+ // Find JSON array
261
+ const arrStart = text.indexOf('[');
262
+ const arrEnd = text.lastIndexOf(']');
263
+ if (arrStart !== -1 && arrEnd > arrStart) {
264
+ stepsResult = JSON.parse(text.substring(arrStart, arrEnd + 1));
265
+ } else {
266
+ stepsResult = JSON.parse(text);
267
+ }
268
+ } catch (parseErr) {
269
+ sendEvent('error', { error: 'AI 返回内容无法解析为 JSON', raw: accumulated });
270
+ sendEvent('done', {});
271
+ return res.end();
272
+ }
273
+
274
+ if (!Array.isArray(stepsResult)) {
275
+ sendEvent('error', { error: 'AI 返回的不是步骤数组' });
276
+ sendEvent('done', {});
277
+ return res.end();
278
+ }
279
+
280
+ // ---- Validate ----
281
+ sendEvent('progress', { stage: 'validating', message: '正在验证流程数据...' });
282
+
283
+ const hasStart = stepsResult.some(s => s.step_type === 'start');
284
+ const hasEnd = stepsResult.some(s => s.step_type === 'end');
285
+ if (!hasStart || !hasEnd) {
286
+ sendEvent('error', { error: 'AI 返回的流程缺少开始或结束节点' });
287
+ sendEvent('done', {});
288
+ return res.end();
289
+ }
290
+
291
+ const stepCount = stepsResult.length;
292
+ sendEvent('progress', { stage: 'complete', message: `转换完成!共 ${stepCount} 个步骤` });
293
+ sendEvent('result', { steps: stepsResult });
294
+ sendEvent('done', {});
295
+ return res.end();
296
+
297
+ } catch (error) {
298
+ console.error('AI workflow design stream error:', error?.response?.data || error?.message || error);
299
+ if (!aborted) {
300
+ const errData = error?.response?.data;
301
+ const status = error?.response?.status;
302
+ let errMsg;
303
+ if (status === 401 || status === 403) {
304
+ errMsg = 'AI API 密钥无效或权限不足,请检查 WORKFLOW_AI_API_KEY';
305
+ } else if (status === 429) {
306
+ errMsg = 'AI API 请求频率超限,请稍后重试';
307
+ } else {
308
+ errMsg = errData?.error?.message || error?.message || 'AI 服务调用失败';
309
+ }
310
+ sendEvent('error', { error: errMsg });
311
+ sendEvent('done', {});
312
+ }
313
+ return res.end();
314
+ }
315
+ });
316
+
317
+ // ---- System prompt (same as non-streaming endpoint) ----
318
+ function buildSystemPrompt() {
319
+ return `你是一个专业的审批流程设计师。你的任务是根据用户的自然语言描述,修改审批流程的步骤定义(JSON 格式)。
320
+
321
+ ## 步骤数据结构
322
+
323
+ 每个步骤(step)是一个 JSON 对象,必须包含以下字段:
324
+ - _id: 字符串,唯一标识。已有步骤保留原 _id,新步骤使用 "step-" + 小写字母数字随机串(如 "step-a1b2c3d4")
325
+ - name: 字符串,步骤名称
326
+ - step_type: 字符串,类型,只能是以下之一:
327
+ - "start" — 开始节点(流程必须有且仅有一个)
328
+ - "end" — 结束节点(流程必须有且仅有一个)
329
+ - "submit" — 填写步骤
330
+ - "sign" — 审批步骤
331
+ - "counterSign" — 会签步骤(多人同时审批)
332
+ - "condition" — 条件分支节点
333
+ - deal_type: 字符串, 处理人身份(submit/sign/counterSign 必填),可选值:
334
+ - "pickupAtRuntime" — 审批时指定
335
+ - "specifyUser" — 指定人员
336
+ - "applicantSuperior" — 申请人的上级
337
+ - "applicant" — 申请人
338
+ - "hrRole" — 指定角色
339
+ - "applicantRole" — 指定审批岗位
340
+ - posx, posy: 数字,节点位置。纵向从上到下排列,开始节点 posx=200,posy=40,之后每步 y 增加约120
341
+ - timeout_hours: 数字,超时小时数,默认 168
342
+ - lines: 数组,从当前步骤出发的连线,每条连线包含:
343
+ - _id: 字符串,连线唯一标识,用 "line-" + 随机串
344
+ - name: 字符串,连线名称(可空)
345
+ - to_step: 字符串,目标步骤的 _id
346
+ - state: 字符串,"submitted"(提交)/ "approved"(核准)/ "rejected"(驳回)
347
+ - condition: 字符串,条件表达式(仅条件节点的出线需要),格式如 {字段编码} > 10000
348
+
349
+ ## 规则
350
+ 1. 流程必须有且仅有一个 start 和一个 end 节点
351
+ 2. 所有步骤必须通过 lines 互相连通,不能有孤立节点
352
+ 3. 从 start 开始必须能最终到达 end
353
+ 4. 审批步骤(sign)通常有两条出线:approved(核准)到下一步,rejected(驳回)回到填写或上一步
354
+ 5. 条件节点(condition)有多条出线,每条带不同的 condition 表达式
355
+ 6. 不要添加 notify、concurrentStart、concurrentEnd 类型的步骤
356
+ 7. 修改时尽可能保留用户已有步骤的 _id 和设置,只改需要改的部分
357
+ 8. 返回完整的 steps 数组
358
+
359
+ ## 输出格式
360
+ 直接返回 JSON 数组,不要有任何其他文字、解释、markdown 标记。`;
361
+ }
362
+
363
+ exports.default = router;