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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.3.96",
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",
@@ -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 } }, detach: { type: 'array', items: TEXT }, no_handoff: { type: 'boolean' }, continue: { ...TEXT, maxLength: 300 },
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 === 'checkpoint')) tools.push(
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 的交接请求:本圈最后一位要求返工时开始下一圈,只结束这条交接;认可或需要裁定时不调用,直接用 submit_chat 验收或说明',
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", "主持人在循环检查点开始下一圈,只结束这条检查点交接;不继续则直接验收回答", True)
479
- advance.add_argument("--request", type=positive_int, required=True, help="检查点交接请求编号(purpose=handoff 且 sequence.phase=checkpoint)")
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
+ }
@@ -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>