ai_co_discuss 0.3.96 → 0.3.98
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/.agents/skills/discuss-host/references/free.md +2 -1
- package/.agents/skills/workflow-creator/SKILL.md +2 -0
- package/README.md +1 -1
- package/backend/free_room.py +116 -30
- package/backend/schema.sql +8 -3
- package/backend/server.py +51 -9
- package/docs/en/workflow.md +1 -1
- package/docs/runner.md +4 -3
- package/docs/workflow.md +1 -1
- package/package.json +7 -3
- package/runner/adapters/api.mjs +18 -9
- package/runner/discuss.py +22 -3
- package/runner/workflow_flow.mjs +211 -0
- package/runner/workflows.mjs +46 -7
- package/web/app.js +21 -13
- package/web/icons/azure.svg +1 -0
- package/web/icons/baichuan.svg +1 -0
- package/web/icons/cohere.svg +1 -0
- package/web/icons/cursor.svg +1 -0
- package/web/icons/doubao.svg +1 -0
- package/web/icons/githubcopilot.svg +1 -0
- package/web/icons/grok.svg +1 -0
- package/web/icons/groq.svg +1 -0
- package/web/icons/hunyuan.svg +1 -0
- package/web/icons/kimi.svg +1 -0
- package/web/icons/meta.svg +1 -0
- package/web/icons/minimax.svg +1 -0
- package/web/icons/mistral.svg +1 -0
- package/web/icons/moonshot.svg +1 -0
- package/web/icons/ollama.svg +1 -0
- package/web/icons/openrouter.svg +1 -0
- package/web/icons/perplexity.svg +1 -0
- package/web/icons/siliconcloud.svg +1 -0
- package/web/icons/windsurf.svg +1 -0
- package/web/icons/zhipu.svg +1 -0
- package/web/modern.css +1 -1
- package/web/modern.js +71 -33
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai_co_discuss",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.98",
|
|
4
4
|
"bin": {
|
|
5
5
|
"codiscuss": "runner/codiscuss.mjs"
|
|
6
6
|
},
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"dev": "npm run build && wrangler pages dev --ip 127.0.0.1 --port 8787",
|
|
9
9
|
"db:local": "wrangler d1 execute DB --local --file backend/schema.sql",
|
|
10
10
|
"db:remote": "wrangler d1 execute DB --remote --file backend/schema.sql",
|
|
11
|
-
"check": "node --check backend/worker.mjs && node --check web/app.js && node --check runner/admin_account.mjs && node --check runner/codiscuss.mjs && node --check runner/discussion_api.mjs && node --check runner/discussion_launch.mjs && node --check runner/discussion_local.mjs && node --check runner/discussion_runner.mjs && node --check runner/discussion_service.mjs && node --check runner/discussion_setup.mjs && node --check runner/discussion_sidecar.mjs && node --check runner/discussion_skills.mjs && node --check runner/discussion_tui.mjs && node --check runner/execution_progress.mjs && node --check runner/expert_prompts.mjs && node --check runner/files.mjs && node --check runner/free_runner.mjs && node --check runner/remote_launch.mjs && node --check runner/remote_policy.mjs && node --check runner/device_auth.mjs && node --check runner/webauthn.mjs && node --check runner/scheduler.mjs && node --check runner/session_driver.mjs && node --check runner/workflows.mjs && node --check runner/adapters/antigravity_app.mjs && node --check runner/adapters/antigravity_cli.mjs && node --check runner/adapters/antigravity_sidecar.mjs && node --check runner/adapters/api.mjs && node --check runner/adapters/claude.mjs && node --check runner/adapters/cli.mjs && node --check runner/adapters/codex_app.mjs && node --check runner/adapters/codex_app_client.mjs && node --check runner/adapters/codex_cli.mjs && node --check runner/adapters/common.mjs && node --check runner/adapters/index.mjs && node --check runner/adapters/profiles.mjs",
|
|
11
|
+
"check": "node --check backend/worker.mjs && node --check web/app.js && node --check runner/admin_account.mjs && node --check runner/codiscuss.mjs && node --check runner/discussion_api.mjs && node --check runner/discussion_launch.mjs && node --check runner/discussion_local.mjs && node --check runner/discussion_runner.mjs && node --check runner/discussion_service.mjs && node --check runner/discussion_setup.mjs && node --check runner/discussion_sidecar.mjs && node --check runner/discussion_skills.mjs && node --check runner/discussion_tui.mjs && node --check runner/execution_progress.mjs && node --check runner/expert_prompts.mjs && node --check runner/files.mjs && node --check runner/free_runner.mjs && node --check runner/remote_launch.mjs && node --check runner/remote_policy.mjs && node --check runner/device_auth.mjs && node --check runner/webauthn.mjs && node --check runner/scheduler.mjs && node --check runner/session_driver.mjs && node --check runner/workflows.mjs && node --check runner/workflow_flow.mjs && node --check runner/adapters/antigravity_app.mjs && node --check runner/adapters/antigravity_cli.mjs && node --check runner/adapters/antigravity_sidecar.mjs && node --check runner/adapters/api.mjs && node --check runner/adapters/claude.mjs && node --check runner/adapters/cli.mjs && node --check runner/adapters/codex_app.mjs && node --check runner/adapters/codex_app_client.mjs && node --check runner/adapters/codex_cli.mjs && node --check runner/adapters/common.mjs && node --check runner/adapters/index.mjs && node --check runner/adapters/profiles.mjs",
|
|
12
12
|
"runner": "node runner/discussion_runner.mjs",
|
|
13
13
|
"service": "node runner/discussion_service.mjs",
|
|
14
14
|
"build:ui": "vite build --config frontend/vite.config.mjs --mode markdown && vite build --config frontend/vite.config.mjs --mode backup && vite build --config frontend/vite.config.mjs",
|
|
@@ -28,13 +28,17 @@
|
|
|
28
28
|
"@nuxt/ui": "4.11.3",
|
|
29
29
|
"@tailwindcss/vite": "4.3.3",
|
|
30
30
|
"@vitejs/plugin-vue": "6.0.10",
|
|
31
|
+
"@vue-flow/background": "1.3.2",
|
|
32
|
+
"@vue-flow/controls": "1.1.3",
|
|
33
|
+
"@vue-flow/core": "1.48.2",
|
|
34
|
+
"@vue-flow/minimap": "1.5.4",
|
|
31
35
|
"fflate": "0.8.3",
|
|
36
|
+
"jsqr": "1.4.0",
|
|
32
37
|
"katex": "0.18.10",
|
|
33
38
|
"markdown-it": "15.0.3",
|
|
34
39
|
"markdown-it-cjk-friendly": "3.0.0",
|
|
35
40
|
"motion-v": "2.7.0",
|
|
36
41
|
"pdfjs-dist": "6.4.299",
|
|
37
|
-
"jsqr": "1.4.0",
|
|
38
42
|
"uqr": "0.1.2",
|
|
39
43
|
"vite": "8.3.4",
|
|
40
44
|
"vue": "3.5.44",
|
package/runner/adapters/api.mjs
CHANGED
|
@@ -54,8 +54,9 @@ export function participant_tools(context) {
|
|
|
54
54
|
tool('read_message', '读取指定原文;服务端按本次请求限制讨论中的可见范围', { id: NUMBER }, ['id']),
|
|
55
55
|
...file_tools(context.project), ...command_tools(context.commands, context.project)];
|
|
56
56
|
const answer = { text: { ...TEXT, maxLength: 32000 }, summary: { ...TEXT, maxLength: 1000 }, reply_to: NUMBER };
|
|
57
|
-
const submit = tool('submit_chat', '保存本次执行的完整回答并结束;summary 仅在回答较长时填写;只有主持人可用 to 点名成员,派出的工作做完或失败都会交回你(只点名一位、回答已足以直接给用户时可加 no_handoff: true,失败仍交回);to 点名多位并加 sequential: true 时按点名顺序依次接力、最后交回你,依次接力走完一圈算一轮,再加 loop: true 则交回为检查点;parallel 列出要同一棒同时做的几组成员(每组至少两位、在 to 中相邻),这一组全部结束才交下一棒,组里有人失败则接力停下交回你;detach 列出与这次派活同时开工、但不等待的成员(不计入交回或下一棒),它失败时程序通知你;主持人要在房间空闲后接着推进时填 continue(写明继续做什么),算本链一轮;派活时可用 need_rounds(本任务从这次派活起还需要的轮数)加 rounds_reason 向用户申请轮数,超过本链剩余时任务链立即停下等用户批准',
|
|
58
|
-
{ ...answer, ...(context.role === 'host' ? { to: { type: 'array', items: TEXT }, sequential: { type: 'boolean' }, loop: { type: 'boolean' }, parallel: { type: 'array', items: { type: 'array', items: TEXT } },
|
|
57
|
+
const submit = tool('submit_chat', '保存本次执行的完整回答并结束;summary 仅在回答较长时填写;只有主持人可用 to 点名成员,派出的工作做完或失败都会交回你(只点名一位、回答已足以直接给用户时可加 no_handoff: true,失败仍交回);to 点名多位并加 sequential: true 时按点名顺序依次接力、最后交回你,依次接力走完一圈算一轮,再加 loop: true 则交回为检查点;parallel 列出要同一棒同时做的几组成员(每组至少两位、在 to 中相邻),这一组全部结束才交下一棒,组里有人失败则接力停下交回你;gates 在接力中途设检查点(每项 {after: 成员, repeat_from?: 成员}):after 所在那一棒结束后交回你,带 repeat_from 时为循环检查点、可从 repeat_from 所在那一棒再来一圈,repeat_from 不能早于前一个检查点;detach 列出与这次派活同时开工、但不等待的成员(不计入交回或下一棒),它失败时程序通知你;主持人要在房间空闲后接着推进时填 continue(写明继续做什么),算本链一轮;派活时可用 need_rounds(本任务从这次派活起还需要的轮数)加 rounds_reason 向用户申请轮数,超过本链剩余时任务链立即停下等用户批准',
|
|
58
|
+
{ ...answer, ...(context.role === 'host' ? { to: { type: 'array', items: TEXT }, sequential: { type: 'boolean' }, loop: { type: 'boolean' }, parallel: { type: 'array', items: { type: 'array', items: TEXT } },
|
|
59
|
+
gates: { type: 'array', items: { type: 'object', properties: { after: TEXT, repeat_from: TEXT }, required: ['after'] } }, detach: { type: 'array', items: TEXT }, no_handoff: { type: 'boolean' }, continue: { ...TEXT, maxLength: 300 },
|
|
59
60
|
need_rounds: { type: 'integer', minimum: 1, maximum: 100 }, rounds_reason: { ...TEXT, maxLength: 300 },
|
|
60
61
|
next_review: TEXT, review_scope: { ...TEXT, maxLength: 2000 } } : {}) }, ['text']);
|
|
61
62
|
const discussion_host = context.role === 'host' && context.discussion_request?.purpose === 'discuss_host';
|
|
@@ -63,10 +64,10 @@ export function participant_tools(context) {
|
|
|
63
64
|
if (context.role === 'host') tools.push(tool('read_review_roster', '先读取只读评审候选,再用 submit_chat 的 next_review 和 review_scope 声明实现后的评审;不可指定本批实现者,程序自取最新 revision'));
|
|
64
65
|
if (context.role === 'host') tools.push(tool('read_review_ready', '推进前读取本批原委派、完整交付、执行报告、评审资格、额度及不能推进的原因', { batch_id: NUMBER }, ['batch_id']),
|
|
65
66
|
tool('advance_review', '先 read_review_ready 核对交付,再只推进该批既定只读评审;程序自取最新 revision,只结束对应交接', { batch_id: NUMBER }, ['batch_id']));
|
|
66
|
-
if (context.role === 'host' && (context.requests || []).some(item => item.sequence?.phase
|
|
67
|
+
if (context.role === 'host' && (context.requests || []).some(item => ['checkpoint', 'gate'].includes(item.sequence?.phase))) tools.push(
|
|
67
68
|
tool('read_sequence', '读取依次接力的顺序、当前圈与本圈各棒原文', { id: NUMBER }, ['id']),
|
|
68
|
-
tool('continue_sequence', '只用于 sequence.phase 为 checkpoint
|
|
69
|
-
{ request_id: NUMBER }, ['request_id']));
|
|
69
|
+
tool('continue_sequence', '只用于 sequence.phase 为 checkpoint 或 gate 的交接请求,只结束这条交接:checkpoint 处要返工时再来一圈,认可且 sequence.gate.can_proceed 为真时加 proceed: true 往下一棒;gate 处往下一棒;要结束接力、裁定或说明时不调用,直接用 submit_chat 回答',
|
|
70
|
+
{ request_id: NUMBER, proceed: { type: 'boolean' } }, ['request_id']));
|
|
70
71
|
if (goal_host(context)) tools.push(tool('read_goal', '读取 Goal 的目标、状态、续跑次数、用时与是否空闲'),
|
|
71
72
|
tool('start_goal', '只用于任务中 purpose 为 goal_start 的请求:用户发了 /goal,补全目标后开启;用户带了文本以原文为准,提炼不出明确目标就不开启并在 submit_chat 说明原因',
|
|
72
73
|
{ request_id: NUMBER, objective: { ...TEXT, maxLength: 2000 } }, ['request_id', 'objective']),
|
|
@@ -146,7 +147,7 @@ function write_action(context, name, args) {
|
|
|
146
147
|
...(context.role === 'host' && args.no_handoff === true ? { no_handoff: true } : {}),
|
|
147
148
|
...(context.role === 'host' && Array.isArray(args.detach) && args.detach.length ? { detach: args.detach } : {}),
|
|
148
149
|
...(context.role === 'host' && Number.isInteger(args.need_rounds) ? { need_rounds: args.need_rounds, rounds_reason: args.rounds_reason } : {}),
|
|
149
|
-
...(context.role === 'host' && args.sequential === true ? { sequential: true, ...(args.loop === true ? { loop: true } : {}), ...(Array.isArray(args.parallel) && args.parallel.length ? { parallel: args.parallel } : {}) } : {}),
|
|
150
|
+
...(context.role === 'host' && args.sequential === true ? { sequential: true, ...(args.loop === true ? { loop: true } : {}), ...(Array.isArray(args.parallel) && args.parallel.length ? { parallel: args.parallel } : {}), ...(Array.isArray(args.gates) && args.gates.length ? { gates: args.gates } : {}) } : {}),
|
|
150
151
|
...(context.role === 'host' && args.next_review ? { next_review: args.next_review, review_scope: args.review_scope, review_roster: args.review_roster } : {}),
|
|
151
152
|
}, terminal: true };
|
|
152
153
|
const actions = {
|
|
@@ -157,7 +158,7 @@ function write_action(context, name, args) {
|
|
|
157
158
|
continue_plan: ['/turns/continue', { revision: args.roster }, true],
|
|
158
159
|
ask_interjection: ['/turns/ask', { revision: args.roster, request_id: args.request_id, expert: args.expert, submission_id: randomUUID() }, false],
|
|
159
160
|
advance_review: ['/reviews/advance', { batch_id: args.batch_id, revision: args.review_revision, submission_id: randomUUID() }, false],
|
|
160
|
-
continue_sequence: ['/sequences/continue', { request_id: args.request_id }, false],
|
|
161
|
+
continue_sequence: ['/sequences/continue', { request_id: args.request_id, ...(args.proceed === true ? { proceed: true } : {}) }, false],
|
|
161
162
|
finish_discussion: ['/limits', { action: 'done', conclusion: args.conclusion }, true],
|
|
162
163
|
declare_done: ['/limits', { action: 'done', conclusion: args.conclusion }, true],
|
|
163
164
|
start_goal: ['/goal', { action: 'start', request_id: args.request_id, objective: args.objective }, false],
|
|
@@ -247,10 +248,18 @@ function workflow_text(workflow) {
|
|
|
247
248
|
return `本房间启用了工作流「${workflow.label}」${workflow.description ? `(用途:${workflow.description})` : ''}。请求与它的用途相符或用户要求按它做时,按下面的工作流说明组织工作;其他请求照常处理。只点名下列在场成员,缺少的角色在回答里说明,不另找成员代替。
|
|
248
249
|
成员:
|
|
249
250
|
${members.join('\n') || '(无)'}
|
|
250
|
-
|
|
251
|
+
${flow_text(workflow.flow)}
|
|
251
252
|
${workflow.skill}`;
|
|
252
253
|
}
|
|
253
254
|
|
|
255
|
+
/** The relay the workflow's canvas lays out, for an API host to dispatch with submit_chat as given. */
|
|
256
|
+
function flow_text(flow) {
|
|
257
|
+
if (!flow) return '';
|
|
258
|
+
if (flow.errors) return `流程画布排不成接力(${flow.errors.join(';')}),按下面的说明组织,并在回答里提醒用户修改画布。\n`;
|
|
259
|
+
if (flow.missing_cards) return `流程画布要用的卡片 ${flow.missing_cards.join('、')} 现在没有在场成员,不能照画布派活;在回答里说明缺谁。\n`;
|
|
260
|
+
return `流程画布排好的接力(照它派活时把下面的字段原样放进 submit_chat,正文写清每一棒做什么):\n${flow.order.join('\n')}\n字段:${JSON.stringify(flow.api)}\n`;
|
|
261
|
+
}
|
|
262
|
+
|
|
254
263
|
/** Map the shared skills onto the API environment, which has room tools and, with a project, file tools. */
|
|
255
264
|
export function system_prompt(context) {
|
|
256
265
|
const host = context.role === 'host';
|
|
@@ -259,7 +268,7 @@ export function system_prompt(context) {
|
|
|
259
268
|
下文是本项目的参与技能${host ? '与主持技能' : ''},与本地软件参与者使用同一份。你经本机接入程序以 API 参与,环境差异如下,与技能冲突时以本段为准:
|
|
260
269
|
- 只能使用提供的${context.project ? '房间工具和项目文件工具' : '房间工具'},没有${command_tools(context.commands, context.project).length ? '通用' : ''}命令行、浏览器、附件下载或其他软件。接入程序负责执行认领、续租和凭据管理;你的房间写入工具都绑定本次执行,不要重试结果不明的写入。
|
|
261
270
|
- 命令与工具对应:room view → read_room;message list / view → read_messages / read_message;message post --kind chat → submit_chat。${host ? 'state decision → read_decision(按任务的决策编号读取完整判断)。' : ''}${discussion && host
|
|
262
|
-
? '主持讨论工具还包括 state set → save_state;room update → update_topic;turn create --plan-file → arrange_turns;turn continue → continue_plan;turn roster → read_roster,读后 turn ask → ask_interjection;取消安排 → cancel_turn;room limits --done → finish_discussion / declare_done。' : ''}${host ? ' 派活用 to,派出的工作结束后都会交回你;房间空闲后要接着推进时填 continue;message post --sequential / --loop / --with / --detach → submit_chat 的 sequential / loop / parallel / detach;sequence view → read_sequence;sequence continue → continue_sequence。' : ''}${goal_host(context) ? ' goal view → read_goal;goal start → start_goal;goal done / block → finish_goal。' : ''}
|
|
271
|
+
? '主持讨论工具还包括 state set → save_state;room update → update_topic;turn create --plan-file → arrange_turns;turn continue → continue_plan;turn roster → read_roster,读后 turn ask → ask_interjection;取消安排 → cancel_turn;room limits --done → finish_discussion / declare_done。' : ''}${host ? ' 派活用 to,派出的工作结束后都会交回你;房间空闲后要接着推进时填 continue;message post --sequential / --loop / --with / --checkpoint / --repeat / --detach → submit_chat 的 sequential / loop / parallel / gates / detach;sequence view → read_sequence;sequence continue → continue_sequence。' : ''}${goal_host(context) ? ' goal view → read_goal;goal start → start_goal;goal done / block → finish_goal。' : ''}
|
|
263
272
|
${project_text(context.project, context.commands)}
|
|
264
273
|
- ${max_requests(context) ? `本次执行最多 ${max_requests(context)} 次模型请求,每轮都计入;` : ''}互不依赖的读取或修改(如同一文件的多处 edit)在同一轮一次调用多个工具,减少模型请求次数。
|
|
265
274
|
- 必须通过写入工具保存真实结果,普通文本输出不会保存。确实无法继续时不调用写入工具直接结束,本次执行会暂停等待用户。
|
package/runner/discuss.py
CHANGED
|
@@ -121,6 +121,14 @@ def parallel_groups(names, joined):
|
|
|
121
121
|
return groups
|
|
122
122
|
|
|
123
123
|
|
|
124
|
+
def repeat_gate(value):
|
|
125
|
+
"""`--repeat AFTER=START`: a loop's checkpoint after AFTER's step whose next pass starts at START's step."""
|
|
126
|
+
after, separator, start = value.partition("=")
|
|
127
|
+
if not separator:
|
|
128
|
+
raise argparse.ArgumentTypeError("须写成 检查点成员=再来一圈的起点成员,如 批评家=写手")
|
|
129
|
+
return {"after": member_handle(after), "repeat_from": member_handle(start)}
|
|
130
|
+
|
|
131
|
+
|
|
124
132
|
def positive_int(value):
|
|
125
133
|
"""Parse a positive page or resource identifier."""
|
|
126
134
|
try:
|
|
@@ -312,6 +320,10 @@ def message_commands(subcommands, registry):
|
|
|
312
320
|
help="自由房间主持人:--to 点名的多位成员按点名顺序依次接力,走完一圈算一轮,最后一位做完交回你")
|
|
313
321
|
post.add_argument("--loop", action="store_true",
|
|
314
322
|
help="与 --sequential 同用:交回成为检查点,用 sequence continue 开始下一圈,不继续即直接验收")
|
|
323
|
+
post.add_argument("--checkpoint", type=member_handle, action="append", default=[], metavar="MEMBER",
|
|
324
|
+
help="与 --sequential 同用:该成员所在那一棒结束后交回你(检查点),你用 sequence continue 往下一棒走,直接回答即结束接力;可重复")
|
|
325
|
+
post.add_argument("--repeat", type=repeat_gate, action="append", default=[], metavar="MEMBER=START",
|
|
326
|
+
help="与 --sequential 同用:MEMBER 所在那一棒结束后为循环检查点,sequence continue 从 START 所在那一棒再来一圈,加 --proceed 往下一棒走;START 不能早于前一个检查点;可重复")
|
|
315
327
|
post.add_argument("--detach", type=member_handle, action="append", default=[], metavar="MEMBER",
|
|
316
328
|
help="自由房间主持人:与 --to 同用,点名一位与这次派活同时开工、但不等它的成员(不计入交回或下一棒的等待);它失败时程序通知你;可重复")
|
|
317
329
|
post.add_argument("--with", dest="to", type=member_handle, action=JoinPreviousStep, metavar="MEMBER",
|
|
@@ -475,8 +487,9 @@ def sequence_commands(subcommands, registry):
|
|
|
475
487
|
"""Read a host's one-after-another relay and run a loop's next pass from its checkpoint."""
|
|
476
488
|
view = add_leaf(subcommands, registry, "sequence view", "房主或主持人读取依次接力的顺序、当前圈与本圈各棒原文;此读取不派发任务", True)
|
|
477
489
|
view.add_argument("id", type=positive_int, help="依次接力编号(任务文件 requests[].sequence.id)")
|
|
478
|
-
advance = add_leaf(subcommands, registry, "sequence continue", "
|
|
479
|
-
advance.add_argument("--request", type=positive_int, required=True, help="检查点交接请求编号(purpose=handoff 且 sequence.phase
|
|
490
|
+
advance = add_leaf(subcommands, registry, "sequence continue", "主持人在检查点让接力继续:循环检查点再来一圈(加 --proceed 往下一棒),普通检查点往下一棒;只结束这条检查点交接,不继续则直接回答即结束接力", True)
|
|
491
|
+
advance.add_argument("--request", type=positive_int, required=True, help="检查点交接请求编号(purpose=handoff 且 sequence.phase 为 checkpoint 或 gate)")
|
|
492
|
+
advance.add_argument("--proceed", action="store_true", help="循环检查点不再来一圈,往下一棒走;最后一棒之后没有下一棒")
|
|
480
493
|
|
|
481
494
|
|
|
482
495
|
def build_parser(prog="discuss"):
|
|
@@ -720,6 +733,11 @@ def plan_message(args):
|
|
|
720
733
|
payload["parallel"] = parallel_groups(args.to, joined)
|
|
721
734
|
if args.loop:
|
|
722
735
|
payload["loop"] = True
|
|
736
|
+
gates = [{"after": name} for name in args.checkpoint] + args.repeat
|
|
737
|
+
if gates:
|
|
738
|
+
if not args.sequential:
|
|
739
|
+
raise CliError("--checkpoint 与 --repeat 须与 --sequential 一起使用")
|
|
740
|
+
payload["gates"] = gates
|
|
723
741
|
for field in ("next_review", "review_scope", "review_roster"):
|
|
724
742
|
if getattr(args, field, None) is not None:
|
|
725
743
|
payload[field] = getattr(args, field)
|
|
@@ -933,7 +951,8 @@ def plan_sequence(args):
|
|
|
933
951
|
"""Read a relay, or continue a loop bound to its one checkpoint request."""
|
|
934
952
|
if args.action == "view":
|
|
935
953
|
return RequestPlan(f"/api/rooms/{args.room}/sequences/{args.id}")
|
|
936
|
-
return RequestPlan(f"/api/rooms/{args.room}/sequences/continue", "POST", {"actor": require_actor(args), "request_id": args.request
|
|
954
|
+
return RequestPlan(f"/api/rooms/{args.room}/sequences/continue", "POST", {"actor": require_actor(args), "request_id": args.request,
|
|
955
|
+
**({"proceed": True} if args.proceed else {})})
|
|
937
956
|
|
|
938
957
|
|
|
939
958
|
def build_plan(args):
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A workflow's canvas (`flow.json`): member cards and host checkpoints joined by arrows, compiled into the host's
|
|
3
|
+
* one-after-another dispatch: steps (a card, or cards working at once), and gates where the relay hands back to the host,
|
|
4
|
+
* optionally looping over part of it. Plain JavaScript without Node modules: the web editor and the runner share it.
|
|
5
|
+
*
|
|
6
|
+
* Shape the program runs: from the start node, each layer is one step; cards in one layer work at once and must all come
|
|
7
|
+
* from the same step and go to the same next one. A host node is a checkpoint after the step before it. An arrow back to an
|
|
8
|
+
* earlier card makes a loop: the step it leaves (or the host node it leaves) becomes a checkpoint whose next pass starts at
|
|
9
|
+
* that card, never reaching back past the checkpoint before it. No branching on conditions: put a host node there.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const FLOW_NODE_TYPES = ['start', 'card', 'host'];
|
|
13
|
+
export const MAX_FLOW_NODES = 60;
|
|
14
|
+
export const MAX_FLOW_EDGES = 120;
|
|
15
|
+
const NODE_ID = /^[A-Za-z0-9_-]{1,40}$/;
|
|
16
|
+
|
|
17
|
+
/** A blank canvas: only the start node. */
|
|
18
|
+
export function empty_flow() {
|
|
19
|
+
return { nodes: [{ id: 'start', type: 'start', x: 0, y: 0 }], edges: [] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Whether a flow holds anything beyond its start node. */
|
|
23
|
+
export function flow_in_use(flow) {
|
|
24
|
+
return Boolean(flow?.nodes?.some(node => node.type !== 'start'));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Check the stored shape of a flow and return a clean copy; throws in Chinese on anything malformed. */
|
|
28
|
+
export function normalize_flow(flow) {
|
|
29
|
+
if (!flow || typeof flow !== 'object' || !Array.isArray(flow.nodes) || !Array.isArray(flow.edges)) throw new Error('flow 须为 { nodes, edges }');
|
|
30
|
+
if (flow.nodes.length > MAX_FLOW_NODES || flow.edges.length > MAX_FLOW_EDGES) throw new Error(`画布最多 ${MAX_FLOW_NODES} 个节点、${MAX_FLOW_EDGES} 条连线`);
|
|
31
|
+
const ids = new Set();
|
|
32
|
+
const nodes = flow.nodes.map(node => {
|
|
33
|
+
if (!node || !NODE_ID.test(node.id) || ids.has(node.id) || !FLOW_NODE_TYPES.includes(node.type)) throw new Error('画布节点须有不重复的 id 与类型 start / card / host');
|
|
34
|
+
if (node.type === 'card' && (typeof node.card !== 'string' || !node.card)) throw new Error(`节点 ${node.id} 没有指明卡片`);
|
|
35
|
+
ids.add(node.id);
|
|
36
|
+
const x = Number.isFinite(node.x) ? Math.round(node.x) : 0, y = Number.isFinite(node.y) ? Math.round(node.y) : 0;
|
|
37
|
+
return { id: node.id, type: node.type, ...(node.type === 'card' ? { card: node.card } : {}), x, y };
|
|
38
|
+
});
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
const edges = flow.edges.map(edge => {
|
|
41
|
+
if (!edge || !ids.has(edge.source) || !ids.has(edge.target)) throw new Error('连线须连接画布上已有的节点');
|
|
42
|
+
const key = `${edge.source}>${edge.target}`;
|
|
43
|
+
if (seen.has(key)) throw new Error('两个节点之间只能有一条同向连线');
|
|
44
|
+
seen.add(key);
|
|
45
|
+
return { source: edge.source, target: edge.target };
|
|
46
|
+
});
|
|
47
|
+
return { nodes, edges };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Top to bottom, then left to right: the order members of one step are named in. */
|
|
51
|
+
function by_position(left, right) {
|
|
52
|
+
return left.y - right.y || left.x - right.x;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Walk from the start: an arrow to a node still on the walk's path goes back (a loop); every other arrow runs forward. */
|
|
56
|
+
function walk_edges(nodes, outgoing, start_id) {
|
|
57
|
+
const back = [], forward = [], state = new Map();
|
|
58
|
+
const walk = id => {
|
|
59
|
+
state.set(id, 'open');
|
|
60
|
+
for (const target of [...outgoing.get(id)].sort((left, right) => by_position(nodes.get(left), nodes.get(right)))) {
|
|
61
|
+
if (state.get(target) === 'open') back.push({ source: id, target });
|
|
62
|
+
else {
|
|
63
|
+
forward.push({ source: id, target });
|
|
64
|
+
if (!state.has(target)) walk(target);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
state.set(id, 'done');
|
|
68
|
+
};
|
|
69
|
+
walk(start_id);
|
|
70
|
+
return { back, forward, state };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Arrows of a canvas as the relay reads them, for drawing it: `loops` holds `source>target` of each arrow going back, and
|
|
75
|
+
* `layers` gives each node reached from the start its column (the longest forward path to it).
|
|
76
|
+
*/
|
|
77
|
+
export function flow_shape(flow) {
|
|
78
|
+
try {
|
|
79
|
+
const shape = normalize_flow(flow);
|
|
80
|
+
const nodes = new Map(shape.nodes.map(node => [node.id, node]));
|
|
81
|
+
const start = shape.nodes.find(node => node.type === 'start');
|
|
82
|
+
if (!start) return { loops: new Set(), layers: new Map() };
|
|
83
|
+
const outgoing = new Map(shape.nodes.map(node => [node.id, []]));
|
|
84
|
+
for (const edge of shape.edges) if (edge.source !== edge.target && edge.target !== start.id) outgoing.get(edge.source).push(edge.target);
|
|
85
|
+
const { back, forward } = walk_edges(nodes, outgoing, start.id);
|
|
86
|
+
const layers = new Map([[start.id, 0]]);
|
|
87
|
+
// Forward arrows have no cycle, so relaxing them as many times as there are nodes settles every column.
|
|
88
|
+
for (let round = 0; round < shape.nodes.length; round++) {
|
|
89
|
+
for (const edge of forward) if (layers.has(edge.source)) layers.set(edge.target, Math.max(layers.get(edge.target) ?? 0, layers.get(edge.source) + 1));
|
|
90
|
+
}
|
|
91
|
+
return { loops: new Set(back.map(edge => `${edge.source}>${edge.target}`)), layers };
|
|
92
|
+
} catch { return { loops: new Set(), layers: new Map() }; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Compile a flow over the given card IDs (`labels` names them in errors): `{ steps: [[card…]…], gates: [{after, repeat_from}], errors: [text…], empty }`.
|
|
97
|
+
* Step and gate positions are step indexes. Any error leaves steps and gates out.
|
|
98
|
+
*/
|
|
99
|
+
export function compile_flow(flow, cards = [], labels = {}) {
|
|
100
|
+
let shape;
|
|
101
|
+
try { shape = normalize_flow(flow || empty_flow()); }
|
|
102
|
+
catch (error) { return { steps: [], gates: [], errors: [error.message], empty: false }; }
|
|
103
|
+
const errors = [];
|
|
104
|
+
const nodes = new Map(shape.nodes.map(node => [node.id, node]));
|
|
105
|
+
const starts = shape.nodes.filter(node => node.type === 'start');
|
|
106
|
+
if (starts.length !== 1) return { steps: [], gates: [], errors: ['画布须有且只有一个开始节点'], empty: false };
|
|
107
|
+
const start = starts[0];
|
|
108
|
+
const used = shape.nodes.filter(node => node.type !== 'start');
|
|
109
|
+
if (!used.length) return { steps: [], gates: [], errors: [], empty: true };
|
|
110
|
+
const known = new Set(cards);
|
|
111
|
+
const label = node => node.type === 'host' ? '主持人检查点' : node.type === 'start' ? '开始' : `卡片 ${labels[node.card] || node.card}`;
|
|
112
|
+
const placed = new Set();
|
|
113
|
+
for (const node of used) {
|
|
114
|
+
if (node.type !== 'card') continue;
|
|
115
|
+
if (!known.has(node.card)) errors.push(`画布引用了不存在的卡片 ${node.card}`);
|
|
116
|
+
if (placed.has(node.card)) errors.push(`卡片 ${node.card} 在画布上出现了多次`);
|
|
117
|
+
placed.add(node.card);
|
|
118
|
+
}
|
|
119
|
+
const outgoing = new Map(shape.nodes.map(node => [node.id, []]));
|
|
120
|
+
for (const edge of shape.edges) {
|
|
121
|
+
if (edge.source === edge.target) errors.push(`${label(nodes.get(edge.source))} 连向了自己`);
|
|
122
|
+
else if (edge.target === start.id) errors.push('不能连回开始节点');
|
|
123
|
+
else outgoing.get(edge.source).push(edge.target);
|
|
124
|
+
}
|
|
125
|
+
if (errors.length) return { steps: [], gates: [], errors, empty: false };
|
|
126
|
+
|
|
127
|
+
const { back, forward, state } = walk_edges(nodes, outgoing, start.id);
|
|
128
|
+
const unreached = used.filter(node => !state.has(node.id));
|
|
129
|
+
if (unreached.length) return { steps: [], gates: [], errors: unreached.map(node => `${label(node)} 没有从开始节点连过来`), empty: false };
|
|
130
|
+
|
|
131
|
+
const successors = id => new Set(forward.filter(edge => edge.source === id).map(edge => edge.target));
|
|
132
|
+
const predecessors = id => new Set(forward.filter(edge => edge.target === id).map(edge => edge.source));
|
|
133
|
+
const same = (left, right) => left.size === right.size && [...left].every(item => right.has(item));
|
|
134
|
+
const steps = [], gates = [], step_of = new Map(), gate_node = new Map();
|
|
135
|
+
let layer = [start.id];
|
|
136
|
+
while (true) {
|
|
137
|
+
const next = new Set(layer.flatMap(id => [...successors(id)]));
|
|
138
|
+
if (!next.size) break;
|
|
139
|
+
const current = new Set(layer);
|
|
140
|
+
if (layer.some(id => !same(successors(id), next))) {
|
|
141
|
+
errors.push(`${layer.map(id => label(nodes.get(id))).join('、')} 须一起交给同一棒`);
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
const members = [...next].map(id => nodes.get(id)).sort(by_position);
|
|
145
|
+
if (members.some(node => !same(predecessors(node.id), current))) {
|
|
146
|
+
errors.push(`${members.map(label).join('、')} 须由同一棒交来`);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
const hosts = members.filter(node => node.type === 'host');
|
|
150
|
+
if (hosts.length) {
|
|
151
|
+
if (members.length > 1) { errors.push('主持人检查点须单独一棒,不能与卡片并列'); break; }
|
|
152
|
+
if (!steps.length) { errors.push('开始节点之后须先接卡片,主持人本来就在开始处派活'); break; }
|
|
153
|
+
if (gate_node.has(steps.length - 1)) { errors.push('两个主持人检查点不能相连'); break; }
|
|
154
|
+
gate_node.set(steps.length - 1, hosts[0].id);
|
|
155
|
+
} else {
|
|
156
|
+
members.forEach(node => step_of.set(node.id, steps.length));
|
|
157
|
+
steps.push(members.map(node => node.card));
|
|
158
|
+
}
|
|
159
|
+
layer = members.map(node => node.id);
|
|
160
|
+
}
|
|
161
|
+
if (errors.length) return { steps: [], gates: [], errors, empty: false };
|
|
162
|
+
if (steps.flat().length < 2) return { steps: [], gates: [], errors: ['流程至少要有两位成员接力'], empty: false };
|
|
163
|
+
|
|
164
|
+
const gate_of_node = new Map([...gate_node].map(([after, id]) => [id, after]));
|
|
165
|
+
const loops = new Map();
|
|
166
|
+
for (const edge of back) {
|
|
167
|
+
const target = nodes.get(edge.target);
|
|
168
|
+
if (target.type !== 'card') { errors.push('循环的回连须指向一张卡片'); continue; }
|
|
169
|
+
const after = step_of.has(edge.source) ? step_of.get(edge.source) : gate_of_node.get(edge.source);
|
|
170
|
+
const from = step_of.get(edge.target);
|
|
171
|
+
if (loops.has(after) && loops.get(after) !== from) errors.push(`第 ${after + 1} 棒之后只能回到一个起点`);
|
|
172
|
+
loops.set(after, from);
|
|
173
|
+
}
|
|
174
|
+
for (const after of new Set([...gate_node.keys(), ...loops.keys()])) {
|
|
175
|
+
// A plain checkpoint after the last step changes nothing: the relay hands back there anyway.
|
|
176
|
+
if (!loops.has(after) && after === steps.length - 1) continue;
|
|
177
|
+
gates.push({ after, repeat_from: loops.has(after) ? loops.get(after) : null });
|
|
178
|
+
}
|
|
179
|
+
gates.sort((left, right) => left.after - right.after);
|
|
180
|
+
gates.forEach((gate, index) => {
|
|
181
|
+
const floor = index ? gates[index - 1].after + 1 : 0;
|
|
182
|
+
if (gate.repeat_from !== null && gate.repeat_from < floor) errors.push(`第 ${gate.after + 1} 棒之后的循环跨过了前一个检查点`);
|
|
183
|
+
});
|
|
184
|
+
return errors.length ? { steps: [], gates: [], errors, empty: false } : { steps, gates, errors: [], empty: false };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Quote a member handle for a shell command line when it needs it. */
|
|
188
|
+
function quoted(value) {
|
|
189
|
+
return /^[\w\u4e00-\u9fff.-]+$/.test(value) ? value : `"${value.replace(/(["\\$`])/g, '\\$1')}"`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The dispatch a compiled flow becomes for the room's members: `{ to, parallel, gates }` for the API and the CLI flags.
|
|
194
|
+
* `member_of` maps a card ID to the member handle the host names it by; a card without a member is listed in `missing`.
|
|
195
|
+
*/
|
|
196
|
+
export function flow_dispatch(compiled, member_of) {
|
|
197
|
+
const missing = compiled.steps.flat().filter(card => !member_of[card]);
|
|
198
|
+
if (compiled.errors.length || compiled.empty || missing.length) return { missing };
|
|
199
|
+
const steps = compiled.steps.map(step => step.map(card => member_of[card]));
|
|
200
|
+
const to = steps.flat();
|
|
201
|
+
const parallel = steps.filter(step => step.length > 1);
|
|
202
|
+
const gates = compiled.gates.map(gate => ({ after: steps[gate.after][0], ...(gate.repeat_from === null ? {} : { repeat_from: steps[gate.repeat_from][0] }) }));
|
|
203
|
+
const flags = [...steps.flatMap(step => step.map((name, index) => `${index ? '--with' : '--to'} ${quoted(name)}`)), '--sequential',
|
|
204
|
+
...gates.map(gate => gate.repeat_from ? `--repeat ${quoted(`${gate.after}=${gate.repeat_from}`)}` : `--checkpoint ${quoted(gate.after)}`)];
|
|
205
|
+
return { missing, to, ...(parallel.length ? { parallel } : {}), ...(gates.length ? { gates } : {}), cli: flags.join(' '),
|
|
206
|
+
order: steps.map((step, index) => {
|
|
207
|
+
const gate = gates.find(item => item.after === step[0]);
|
|
208
|
+
const tail = !gate ? '' : gate.repeat_from ? `(之后检查点:可从 ${gate.repeat_from} 再来一圈)` : '(之后交回主持人)';
|
|
209
|
+
return `${index + 1}. ${step.join(' + ')}${tail}`;
|
|
210
|
+
}) };
|
|
211
|
+
}
|
package/runner/workflows.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
6
6
|
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { dirname, join, resolve } from 'node:path';
|
|
8
8
|
import { DatabaseSync } from 'node:sqlite';
|
|
9
|
+
import { compile_flow, flow_dispatch, flow_in_use, normalize_flow } from './workflow_flow.mjs';
|
|
9
10
|
|
|
10
11
|
// A workflow ID follows the Agent Skills naming rule: lowercase letters and digits joined by single hyphens.
|
|
11
12
|
export const WORKFLOW_ID_PATTERN = /^(?=.{1,40}$)[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
@@ -34,6 +35,8 @@ const RESOURCE_SEGMENT = /^[^./\\:*?"<>|\x00-\x1f][^/\\:*?"<>|\x00-\x1f]{0,99}$/
|
|
|
34
35
|
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
35
36
|
// Commands an API member may run, registered beside SKILL.md so they travel with the workflow.
|
|
36
37
|
export const COMMANDS_FILE = 'commands.json';
|
|
38
|
+
// The canvas of cards and arrows the host's relay follows; a field of its own, not one of the folder's other files.
|
|
39
|
+
export const FLOW_FILE = 'flow.json';
|
|
37
40
|
const COMMAND_ID_PATTERN = /^(?=.{1,40}$)[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
38
41
|
const COMMAND_FIELDS = ['id', 'description', 'run', 'path', 'access', 'timeout_seconds'];
|
|
39
42
|
const MAX_COMMANDS = 20;
|
|
@@ -151,16 +154,31 @@ function card_from_markdown(card, text) {
|
|
|
151
154
|
return value;
|
|
152
155
|
}
|
|
153
156
|
|
|
154
|
-
/** Turn SKILL.md, card files and the folder's other files into one workflow object, unchecked. */
|
|
157
|
+
/** Turn SKILL.md, card files and the folder's other files into one workflow object, unchecked; flow.json becomes `flow`. */
|
|
155
158
|
function workflow_from_files(skill_text, cards, resources = {}) {
|
|
156
159
|
const { fields, body } = parse_markdown(skill_text, 'SKILL.md');
|
|
160
|
+
const { [FLOW_FILE]: flow_text, ...rest } = resources;
|
|
161
|
+
let flow;
|
|
162
|
+
if (typeof flow_text === 'string') {
|
|
163
|
+
try { flow = JSON.parse(flow_text); } catch (error) { throw new Error(`${FLOW_FILE} 不是合法 JSON:${error.message}`); }
|
|
164
|
+
}
|
|
157
165
|
return { id: fields.name || '', label: fields.label || '', description: fields.description || '', skill: body,
|
|
158
|
-
agents: Object.entries(cards).map(([card, text]) => card_from_markdown(card, text)), resources };
|
|
166
|
+
agents: Object.entries(cards).map(([card, text]) => card_from_markdown(card, text)), resources: rest, ...(flow ? { flow } : {}) };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A checked canvas over the workflow's cards, or null for none; an arrangement the relay cannot follow is refused. */
|
|
170
|
+
function validate_flow(flow, cards) {
|
|
171
|
+
if (flow === undefined || flow === null) return flow;
|
|
172
|
+
const normalized = normalize_flow(flow);
|
|
173
|
+
if (!flow_in_use(normalized)) return null;
|
|
174
|
+
const compiled = compile_flow(normalized, cards);
|
|
175
|
+
if (compiled.errors.length) throw new Error(`流程画布:${compiled.errors.join(';')}`);
|
|
176
|
+
return normalized;
|
|
159
177
|
}
|
|
160
178
|
|
|
161
179
|
/** A path, relative to the workflow folder, that a resource may take: inside it, not SKILL.md and not under agents/. */
|
|
162
180
|
function resource_path_allowed(path) {
|
|
163
|
-
return path !== 'SKILL.md' && !path.startsWith('agents/') && path.split('/').every(segment => RESOURCE_SEGMENT.test(segment));
|
|
181
|
+
return path !== 'SKILL.md' && path !== FLOW_FILE && !path.startsWith('agents/') && path.split('/').every(segment => RESOURCE_SEGMENT.test(segment));
|
|
164
182
|
}
|
|
165
183
|
|
|
166
184
|
/** Check resource files `{ "relative/path": text }`. */
|
|
@@ -185,6 +203,8 @@ function read_resource_folder(directory, prefix, resources, ignored) {
|
|
|
185
203
|
for (const name of readdirSync(directory).sort()) {
|
|
186
204
|
const relative = `${prefix}${name}`;
|
|
187
205
|
if (relative === 'SKILL.md' || relative === 'agents') continue;
|
|
206
|
+
// The canvas is read as a resource here and taken out as `flow` with the rest of the folder.
|
|
207
|
+
if (relative === FLOW_FILE) { resources[relative] = readFileSync(join(directory, name), 'utf8'); continue; }
|
|
188
208
|
const path = join(directory, name);
|
|
189
209
|
if (lstatSync(path).isDirectory()) { read_resource_folder(path, `${relative}/`, resources, ignored); continue; }
|
|
190
210
|
let text = null;
|
|
@@ -245,8 +265,11 @@ export function validate_workflow(input, validate_card) {
|
|
|
245
265
|
return { card, ...kept };
|
|
246
266
|
});
|
|
247
267
|
// Absent resources mean "keep the saved folder's other files" (a host draft or the editor carries none); `{}` clears them.
|
|
268
|
+
// Likewise an absent flow keeps the saved canvas, and null removes it.
|
|
269
|
+
const flow = validate_flow(input.flow, normalized.map(agent => agent.card));
|
|
248
270
|
return { id, label, description, skill, agents: normalized,
|
|
249
|
-
...(input.resources === undefined ? {} : { resources: validate_resources(input.resources) })
|
|
271
|
+
...(input.resources === undefined ? {} : { resources: validate_resources(input.resources) }),
|
|
272
|
+
...(flow === undefined ? {} : { flow }) };
|
|
250
273
|
}
|
|
251
274
|
|
|
252
275
|
/** Refuse links anywhere in a folder, so a shared workflow cannot point outside itself. */
|
|
@@ -315,7 +338,7 @@ export function read_workflow_files(files) {
|
|
|
315
338
|
if (path === 'SKILL.md') continue;
|
|
316
339
|
const match = CARD_PATH.exec(path);
|
|
317
340
|
if (match && typeof files[path] === 'string') cards[match[1]] = files[path];
|
|
318
|
-
else if (typeof files[path] === 'string' && resource_path_allowed(path)) resources[path] = files[path];
|
|
341
|
+
else if (typeof files[path] === 'string' && (path === FLOW_FILE || resource_path_allowed(path))) resources[path] = files[path];
|
|
319
342
|
else ignored.push(path);
|
|
320
343
|
}
|
|
321
344
|
return { workflow: workflow_from_files(files['SKILL.md'], cards, resources), ignored };
|
|
@@ -378,6 +401,9 @@ export function save_workflow(home, input, validate_card) {
|
|
|
378
401
|
}
|
|
379
402
|
if (workflow.resources) write_resources(staging, workflow.resources);
|
|
380
403
|
else if (existsSync(directory)) keep_resources(directory, staging);
|
|
404
|
+
if (workflow.flow) writeFileSync(join(staging, FLOW_FILE), `${JSON.stringify(workflow.flow, null, 2)}
|
|
405
|
+
`);
|
|
406
|
+
else if (workflow.flow === null || workflow.resources) rmSync(join(staging, FLOW_FILE), { force: true });
|
|
381
407
|
});
|
|
382
408
|
return read_saved_workflow(home, workflow.id);
|
|
383
409
|
});
|
|
@@ -470,7 +496,8 @@ export function load_room_workflow(config) {
|
|
|
470
496
|
const cards = Object.fromEntries(workflow.agents.map(agent => [agent.card, agent]));
|
|
471
497
|
return { id: binding.id, label: workflow.label || binding.id, description: workflow.description || '', directory, skill_path: join(directory, 'SKILL.md'), skill: workflow.skill,
|
|
472
498
|
revision: createHash('sha256').update(workflow.skill).digest('hex').slice(0, REVISION_LENGTH),
|
|
473
|
-
members: binding.members || {}, cards, commands: workflow.commands
|
|
499
|
+
members: binding.members || {}, cards, commands: workflow.commands,
|
|
500
|
+
...(workflow.flow ? { flow: compile_flow(workflow.flow, Object.keys(cards)) } : {}) };
|
|
474
501
|
} catch (error) { return { id: binding.id, error: error.message }; }
|
|
475
502
|
}
|
|
476
503
|
|
|
@@ -501,7 +528,19 @@ export function workflow_task_field(workflow, room_members, participants) {
|
|
|
501
528
|
.map(card => ({ card: card.card, label: card.label || '', description: card.description || '' }));
|
|
502
529
|
// The host reads SKILL.md only when a request fits the description and its revision is one it has not read.
|
|
503
530
|
return { id: workflow.id, label: workflow.label, description: workflow.description || '', revision: workflow.revision || '', members,
|
|
504
|
-
...(missing.length ? { missing } : {}) };
|
|
531
|
+
...(missing.length ? { missing } : {}), ...(workflow.flow ? { flow: flow_task_field(workflow.flow, members) } : {}) };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* The relay the canvas asks for, ready for the host to dispatch: the order in words, the CLI flags and the API fields.
|
|
536
|
+
* Cards whose member is away or removed are named instead, since the relay cannot run without them.
|
|
537
|
+
*/
|
|
538
|
+
function flow_task_field(compiled, members) {
|
|
539
|
+
if (compiled.errors.length) return { errors: compiled.errors };
|
|
540
|
+
const member_of = Object.fromEntries(members.filter(item => item.status === 'active').map(item => [item.card, item.member]));
|
|
541
|
+
const { missing, order, cli, to, parallel, gates } = flow_dispatch(compiled, member_of);
|
|
542
|
+
if (missing.length) return { missing_cards: missing };
|
|
543
|
+
return { order, cli, api: { to, sequential: true, ...(parallel ? { parallel } : {}), ...(gates ? { gates } : {}) } };
|
|
505
544
|
}
|
|
506
545
|
|
|
507
546
|
/**
|
package/web/app.js
CHANGED
|
@@ -87,7 +87,7 @@ const PLATFORM_IDENTITIES = {
|
|
|
87
87
|
Antigravity: { label: "Gemini", initials: "GM", style: "gemini", icon: "/icons/gemini.svg", description: "AI 身份" },
|
|
88
88
|
DeepSeek: { initials: "DS", style: "deepseek", icon: "/icons/deepseek.svg", description: "AI 身份" },
|
|
89
89
|
Qwen: { initials: "QW", style: "qwen", icon: "/icons/qwen.svg", description: "AI 身份" },
|
|
90
|
-
Grok: { initials: "GK", style: "grok", description: "AI 身份" },
|
|
90
|
+
Grok: { initials: "GK", style: "grok", icon: "/icons/grok.svg", description: "AI 身份" },
|
|
91
91
|
OpenAI: { initials: "OA", style: "codex", icon: "/icons/openai.svg", description: "AI 身份" },
|
|
92
92
|
Custom: { label: "自定义 API", initials: "API", style: "custom", api_label: "自定义 API", description: "AI 身份" },
|
|
93
93
|
};
|
|
@@ -716,24 +716,24 @@ async function assign_channel_folder(id, folder_id) {
|
|
|
716
716
|
}
|
|
717
717
|
|
|
718
718
|
/** Display a provisional folder while its identifier is being assigned. */
|
|
719
|
-
async function create_folder(name) {
|
|
720
|
-
const folder = { id: `pending-${new_submission_id()}`, name: name.trim(), pending: true };
|
|
719
|
+
async function create_folder({ name, directories = [] }) {
|
|
720
|
+
const folder = { id: `pending-${new_submission_id()}`, name: name.trim(), directories, pending: true };
|
|
721
721
|
return update_directory({ key: "create-folder", label: `文件夹「${folder.name}」创建`,
|
|
722
722
|
apply: () => { state.folders = [...state.folders, folder]; },
|
|
723
723
|
rollback: () => { state.folders = state.folders.filter(item => item.id !== folder.id); },
|
|
724
|
-
save: () => request_json("/api/folders", { name: folder.name }),
|
|
724
|
+
save: () => request_json("/api/folders", { name: folder.name, directories }),
|
|
725
725
|
commit: saved => { state.folders = state.folders.map(item => item.id === folder.id ? saved : item); } });
|
|
726
726
|
}
|
|
727
727
|
|
|
728
|
-
/** Rename the exact folder without moving its conversations or changing selection. */
|
|
729
|
-
async function rename_folder({ id, name }) {
|
|
728
|
+
/** Rename the exact folder and replace its project paths, without moving its conversations or changing selection. */
|
|
729
|
+
async function rename_folder({ id, name, directories }) {
|
|
730
730
|
const folder = state.folders.find(item => item.id === id);
|
|
731
731
|
if (!folder || folder.pending || !name.trim()) return;
|
|
732
|
-
const display = value => { state.folders = state.folders.map(item => item.id === id ? { ...item, name: value } : item); };
|
|
733
|
-
return update_directory({ key: `folder-name:${id}`, label: `文件夹「${folder.name}
|
|
734
|
-
apply: () => display(name.trim()), rollback: () => display(folder.name),
|
|
735
|
-
save: () => request_json(`/api/folders/${encodeURIComponent(id)}/rename`, { name: name.trim() }),
|
|
736
|
-
commit: saved => display(saved.name) });
|
|
732
|
+
const display = (value, paths) => { state.folders = state.folders.map(item => item.id === id ? { ...item, name: value, directories: paths } : item); };
|
|
733
|
+
return update_directory({ key: `folder-name:${id}`, label: `文件夹「${folder.name}」保存`,
|
|
734
|
+
apply: () => display(name.trim(), directories), rollback: () => display(folder.name, folder.directories),
|
|
735
|
+
save: () => request_json(`/api/folders/${encodeURIComponent(id)}/rename`, { name: name.trim(), directories }),
|
|
736
|
+
commit: saved => display(saved.name, saved.directories) });
|
|
737
737
|
}
|
|
738
738
|
|
|
739
739
|
/** Remove only a grouping; keep its conversations, current transcript and drafts. */
|
|
@@ -1158,11 +1158,11 @@ function mentioned(text, name) {
|
|
|
1158
1158
|
}
|
|
1159
1159
|
|
|
1160
1160
|
/** Clear this room's text, project and roster, then restore the user's last reusable choices. */
|
|
1161
|
-
function reset_create_form() {
|
|
1161
|
+
function reset_create_form(folder = null) {
|
|
1162
1162
|
element("room-form").reset();
|
|
1163
1163
|
render_expert_inputs("new-expert-modes", default_experts());
|
|
1164
1164
|
render_host_options("new-room-host", [], "");
|
|
1165
|
-
state.launch.workspace =
|
|
1165
|
+
state.launch.workspace = folder_workspace(folder);
|
|
1166
1166
|
state.create_goal = null;
|
|
1167
1167
|
apply_create_preferences();
|
|
1168
1168
|
state.launch.profiles = JSON.parse(JSON.stringify(state.launch.saved_profiles || {}));
|
|
@@ -1172,6 +1172,14 @@ function reset_create_form() {
|
|
|
1172
1172
|
return refresh_create_preferences();
|
|
1173
1173
|
}
|
|
1174
1174
|
|
|
1175
|
+
/** A room created in a folder starts in the folder's project paths: the first is its main directory, the rest its other
|
|
1176
|
+
* directories. Only the local service resolves paths, so a device reaching its computer through the queued relay starts empty. */
|
|
1177
|
+
function folder_workspace(folder) {
|
|
1178
|
+
const [cwd, ...extra_directories] = folder?.directories || [];
|
|
1179
|
+
if (!cwd || !state.launch.available) return {};
|
|
1180
|
+
return extra_directories.length ? { cwd, extra_directories } : { cwd };
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1175
1183
|
/** Restore choices without touching the new room's title, project, members, host or goal text. */
|
|
1176
1184
|
function apply_create_preferences() {
|
|
1177
1185
|
const preferences = state.launch.create_preferences || {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Azure</title><path d="M7.242 1.613A1.11 1.11 0 018.295.857h6.977L8.03 22.316a1.11 1.11 0 01-1.052.755h-5.43a1.11 1.11 0 01-1.053-1.466L7.242 1.613z" fill="url(#lobe-icons-azure-0-_R_0_)"></path><path d="M18.397 15.296H7.4a.51.51 0 00-.347.882l7.066 6.595c.206.192.477.298.758.298h6.226l-2.706-7.775z" fill="#0078D4"></path><path d="M15.272.857H7.497L0 23.071h7.775l1.596-4.73 5.068 4.73h6.665l-2.707-7.775h-7.998L15.272.857z" fill="url(#lobe-icons-azure-1-_R_0_)"></path><path d="M17.193 1.613a1.11 1.11 0 00-1.052-.756h-7.81.035c.477 0 .9.304 1.052.756l6.748 19.992a1.11 1.11 0 01-1.052 1.466h-.12 7.895a1.11 1.11 0 001.052-1.466L17.193 1.613z" fill="url(#lobe-icons-azure-2-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-0-_R_0_" x1="8.247" x2="1.002" y1="1.626" y2="23.03"><stop stop-color="#114A8B"></stop><stop offset="1" stop-color="#0669BC"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-1-_R_0_" x1="14.042" x2="12.324" y1="15.302" y2="15.888"><stop stop-opacity=".3"></stop><stop offset=".071" stop-opacity=".2"></stop><stop offset=".321" stop-opacity=".1"></stop><stop offset=".623" stop-opacity=".05"></stop><stop offset="1" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-2-_R_0_" x1="12.841" x2="20.793" y1="1.626" y2="22.814"><stop stop-color="#3CCBF4"></stop><stop offset="1" stop-color="#2892DF"></stop></linearGradient></defs></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Baichuan</title><defs><linearGradient id="lobe-icons-baichuan-_R_0_" x1="17.764%" x2="100%" y1="8.678%" y2="91.322%"><stop offset="0%" stop-color="#FEC13E"></stop><stop offset="100%" stop-color="#FF6933"></stop></linearGradient></defs><path d="M7.333 2h-3.2l-2 4.333V17.8L0 22h5.2l2.028-4.2L7.333 2zm7.334 0h-5.2v20h5.2V2zM16.8 7.733H22V22h-5.2V7.733zM22 2h-5.2v4.133H22V2z" fill="url(#lobe-icons-baichuan-_R_0_)" fill-rule="nonzero"></path></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cohere</title><path clip-rule="evenodd" d="M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z" fill="#39594D" fill-rule="evenodd"></path><path clip-rule="evenodd" d="M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z" fill="#D18EE2" fill-rule="evenodd"></path><path d="M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z" fill="#FF7759"></path></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cursor</title><path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z"></path></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Doubao</title><path d="M5.31 15.756c.172-3.75 1.883-5.999 2.549-6.739-3.26 2.058-5.425 5.658-6.358 8.308v1.12C1.501 21.513 4.226 24 7.59 24a6.59 6.59 0 002.2-.375c.353-.12.7-.248 1.039-.378.913-.899 1.65-1.91 2.243-2.992-4.877 2.431-7.974.072-7.763-4.5l.002.001z" fill="#1E37FC"></path><path d="M22.57 10.283c-1.212-.901-4.109-2.404-7.397-2.8.295 3.792.093 8.766-2.1 12.773a12.782 12.782 0 01-2.244 2.992c3.764-1.448 6.746-3.457 8.596-5.219 2.82-2.683 3.353-5.178 3.361-6.66a2.737 2.737 0 00-.216-1.084v-.002z" fill="#37E1BE"></path><path d="M14.303 1.867C12.955.7 11.248 0 9.39 0 7.532 0 5.883.677 4.545 1.807 2.791 3.29 1.627 5.557 1.5 8.125v9.201c.932-2.65 3.097-6.25 6.357-8.307.5-.318 1.025-.595 1.569-.829 1.883-.801 3.878-.932 5.746-.706-.222-2.83-.718-5.002-.87-5.617h.001z" fill="#A569FF"></path><path d="M17.305 4.961a199.47 199.47 0 01-1.08-1.094c-.202-.213-.398-.419-.586-.622l-1.333-1.378c.151.615.648 2.786.869 5.617 3.288.395 6.185 1.898 7.396 2.8-1.306-1.275-3.475-3.487-5.266-5.323z" fill="#1E37FC"></path></svg>
|