ai_co_discuss 0.3.57 → 0.3.59

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.
@@ -75,7 +75,7 @@ project_access: write
75
75
 
76
76
  - **工作流 ID**(文件夹名 = SKILL.md 的 `name`):小写英文和数字,可用单个连字符连接(不在首尾),1—40 位。`label` 最多 32 字。
77
77
  - **`description`** 最多 500 字:写清做什么、什么时候用。主持人只凭它判断一条请求该不该按这个工作流做,写含糊了,该用时会不用、不该用时会套用。
78
- - **卡片**:最多 30 张。`name` 是卡片 ID,等于文件名,只用小写英文、数字和下划线,最多 16 位。`platform` 是平台 ID:`Claude`、`Codex`、`Antigravity`、`DeepSeek`、`Qwen`、`Grok`(网页显示的 Gemini 写 `Antigravity`),须是本机已配置的平台。成员名由程序拼成 `平台-卡片ID`。
78
+ - **卡片**:最多 30 张。`name` 是卡片 ID,等于文件名,只用小写英文、数字和下划线,最多 16 位。`platform` 是平台 ID:`Claude`、`Codex`、`Antigravity`、`DeepSeek`、`Qwen`、`Grok`(网页显示的 Gemini 写 `Antigravity`),须是本机已配置的软件,或已在本机保存 API Key 的模型 API(没有本地软件的平台按官方 API 加入)。成员名由程序拼成 `平台-卡片ID`。
79
79
  - **卡片可选字段**:`label`、`description`、`project_access`(read / write / auto / full)、`model`、`effort`。不用的字段直接省略;模型和强度省略即用本机默认值。接入方式、可执行文件、密钥不写进卡片,由各自电脑的配置决定。
80
80
  - 头信息只支持单行 `key: value`;不支持多行值、列表和嵌套。
81
81
  - 卡片正文留空时使用该软件的默认提示。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.3.57",
3
+ "version": "0.3.59",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
@@ -1400,8 +1400,7 @@ export function workflows_command(home, input = {}) {
1400
1400
  if (action === 'save') return save_workflow(home, input.workflow, validate_preset);
1401
1401
  if (action === 'delete') return delete_workflow(home, input.id);
1402
1402
  if (action === 'preview') {
1403
- const configured = launch_settings(home).participants;
1404
- return preview_workflow(input, validate_preset, name => Boolean(configured[platform_of(name)]));
1403
+ return preview_workflow(input, validate_preset, name => card_platform_ready(home, name));
1405
1404
  }
1406
1405
  if (action === 'room-cards') return room_cards(home, input);
1407
1406
  throw new Error('action 须为 list、get、save、delete、preview 或 room-cards');
@@ -1494,6 +1493,24 @@ async function host_room(home, access_file) {
1494
1493
  return { path, config, roster };
1495
1494
  }
1496
1495
 
1496
+ /**
1497
+ * The API settings a program-started card on a platform without local software starts from, as the invite form fills
1498
+ * them: the platform's official API with the key last saved here for that root. Null when the platform has saved
1499
+ * software settings or local software, or no key is saved.
1500
+ */
1501
+ function card_api_profile(home, platform) {
1502
+ if (launch_settings(home).participants[platform] || platform_adapters(platform).some(id => adapter_profile(id).kind !== 'api')) return null;
1503
+ const provider = API_PROVIDERS[platform];
1504
+ const api_key_ref = provider && saved_api_keys(home)[provider.base_url];
1505
+ return api_key_ref ? { adapter: 'api', base_url: provider.base_url, api_protocol: provider.api_protocol, model: provider.models[0], api_key_ref } : null;
1506
+ }
1507
+
1508
+ /** Whether a card's platform can start here: saved software settings, or an API with a saved key. */
1509
+ function card_platform_ready(home, name) {
1510
+ const platform = platform_of(name || '');
1511
+ return Boolean(platform && (launch_settings(home).participants[platform] || card_api_profile(home, platform)));
1512
+ }
1513
+
1497
1514
  /**
1498
1515
  * Give cards of the room's workflow a member: a present one stays, one that left is brought back, and a card with
1499
1516
  * none (or whose member was removed) is invited as a program-started member with the card's settings, label and
@@ -1508,7 +1525,11 @@ async function join_workflow_cards(home, config, roster, workflow, members, card
1508
1525
  if (current?.status === 'away') { await room_member(home, { ...owner, action: 'rejoin', name: current.name }); continue; }
1509
1526
  const taken = new Set([...roster.map(member => member.name), ...Object.values(next)]);
1510
1527
  const name = taken.has(agent.name) ? free_instance(agent.name, taken) : agent.name;
1511
- const profile = Object.fromEntries(['project_access', 'model', 'effort'].filter(field => agent[field]).map(field => [field, agent[field]]));
1528
+ const api = card_api_profile(home, platform_of(agent.name));
1529
+ const card = Object.fromEntries(['project_access', 'model', 'effort'].filter(field => agent[field] && !(api && field === 'effort')).map(field => [field, agent[field]]));
1530
+ // An API member has no command line, so a card asking for more than writing gets the most it allows.
1531
+ if (api && card.project_access && !access_levels('api').includes(card.project_access)) card.project_access = 'write';
1532
+ const profile = { ...api, ...card };
1512
1533
  const joined = await room_member(home, { ...owner, action: 'add', name, participants: { [name]: profile } });
1513
1534
  next[agent.card] = joined.member;
1514
1535
  if (agent.label) labels[joined.member] = agent.label;
@@ -1591,9 +1612,11 @@ export async function adopt_workflow(home, { access_file, draft_file, workflow_i
1591
1612
  if (bound !== draft.id && list_workflows(home).some(item => item.id === draft.id)) throw new Error(`工作流库已有 ${draft.id},主持人不能覆盖其他工作流;要启用它请用 --id ${draft.id},新建请换一个 ID`);
1592
1613
  workflow = draft;
1593
1614
  }
1594
- const configured = launch_settings(home).participants;
1595
- const missing = workflow.agents.filter(agent => !configured[platform_of(agent.name || '')]).map(agent => agent.name || agent.card);
1596
- if (missing.length) throw new Error(`本机没有配置这些卡片的软件:${missing.join('、')};卡片的 platform 只能用本机已配置的平台 ID:${Object.keys(configured).join('、')}`);
1615
+ const missing = workflow.agents.filter(agent => !card_platform_ready(home, agent.name)).map(agent => agent.name || agent.card);
1616
+ if (missing.length) {
1617
+ const ready = [...new Set([...Object.keys(launch_settings(home).participants), ...Object.keys(API_PROVIDERS).filter(platform => card_api_profile(home, platform))])];
1618
+ throw new Error(`本机没有配置这些卡片的软件或 API Key:${missing.join('、')};卡片的 platform 只能用本机已配置的平台 ID:${ready.join('、')}`);
1619
+ }
1597
1620
  workflow = workflow_id ? { ...workflow, agents: host_cards(workflow.agents) }
1598
1621
  : save_workflow(home, { ...workflow, agents: host_cards(workflow.agents) }, validate_preset);
1599
1622
  const owner = { site: config.site, room_id: config.room, token: config.token };
package/web/app.js CHANGED
@@ -48,6 +48,12 @@ const RELAY_COMMAND_POLL_MS = 250;
48
48
  const RELAY_SETTINGS_WAIT_MS = 20000;
49
49
  // The computer allows each operation 60 seconds, as the local website does.
50
50
  const RELAY_COMMAND_WAIT_MS = 75000;
51
+ // The website answers a relayed request within its wait (9 seconds unless asked for more); a request still unanswered
52
+ // this much later is stuck in the network and is given up, so a hung connection cannot hold back reconnecting.
53
+ const RELAY_DEFAULT_WAIT_MS = 9000;
54
+ const MACHINE_REQUEST_MARGIN_MS = 15000;
55
+ // How long a pairing request waits for the computer over the relay, in milliseconds as the relay counts.
56
+ const PAIR_WAIT_MS = 15000;
51
57
  const RELAY_CIPHER_INFO = "codiscuss-relay-v1";
52
58
  // The rooms kept on the paired computer are read and written through its relay connection; only room routes go there.
53
59
  const RELAY_PROTOCOL = "codiscuss-relay";
@@ -231,6 +237,7 @@ async function request_json(path, payload, access_token) {
231
237
  options.headers["Content-Type"] = "application/json";
232
238
  options.body = JSON.stringify(payload);
233
239
  }
240
+ if (/^\/api\/(machines|admin)/.test(path)) options.signal = AbortSignal.timeout((Number.isInteger(payload?.wait) ? payload.wait : RELAY_DEFAULT_WAIT_MS) + MACHINE_REQUEST_MARGIN_MS);
234
241
  const response = await fetch(path, options);
235
242
  const result = await response.json();
236
243
  if (!response.ok) throw Object.assign(new Error(result.error || "请求失败,请重试"), { preflight: response.status >= 400 && response.status < 500, status: response.status });
@@ -2303,7 +2310,7 @@ async function pair_exchange(machine, payload) {
2303
2310
  const id = crypto.randomUUID();
2304
2311
  const { key, envelope } = await seal_relay_request(machine, id, { action: "device-pair", payload });
2305
2312
  const path = `/api/machines/${encodeURIComponent(machine.id)}/pair`;
2306
- const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, envelope, wait: 15 }) });
2313
+ const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, envelope, wait: PAIR_WAIT_MS }) });
2307
2314
  const result = await response.json();
2308
2315
  if (!response.ok) throw new Error(result.error || "配对请求被网站拒绝");
2309
2316
  if (result.status === "done") return open_relay_response(key, id, result.response);
@@ -2435,7 +2442,12 @@ async function relay_exchange(action, payload, wait) {
2435
2442
  const target = computer_rooms_only() ? { target: "computer" } : {};
2436
2443
  const { key, envelope } = await seal_relay_request(machine, id, { action, payload, ...target });
2437
2444
  const result = await request_json(`/api/machines/${encodeURIComponent(machine.id)}/relay`, { id, envelope, ...(wait ? { wait } : {}) })
2438
- .catch(error => { throw error.status === 401 ? Object.assign(error, { need: "pairing" }) : error; });
2445
+ .catch(error => {
2446
+ // A request given up in the network may still have reached the computer, so its outcome is unconfirmed.
2447
+ if (error.name === "TimeoutError") throw Object.assign(new Error(`结果待确认:电脑「${machine.name}」未在限时内答复,请稍后查看房间,确认是否已生效后再重试`),
2448
+ { unconfirmed: true, launch: { stage: "unconfirmed" } });
2449
+ throw error.status === 401 ? Object.assign(error, { need: "pairing" }) : error;
2450
+ });
2439
2451
  if (result.status === "done") return open_relay_response(key, id, result.response);
2440
2452
  if (result.status === "offline") throw Object.assign(new Error(`电脑「${machine.name}」不在线,请确认它开机并运行 codiscuss`), { offline: true });
2441
2453
  throw Object.assign(new Error(`结果待确认:电脑「${machine.name}」未在限时内答复,请稍后查看房间,确认是否已生效后再重试`),
@@ -2619,15 +2631,29 @@ async function show_computer_rooms(remote) {
2619
2631
  await select_room(undefined);
2620
2632
  }
2621
2633
 
2622
- /** Once signed in, find the paired computer and show its rooms; the poll calls this again until the computer answers. */
2623
- async function connect_computer_rooms() {
2634
+ let computer_retry = null, computer_attempt = 0;
2635
+ /**
2636
+ * Once signed in, find the paired computer and show its rooms. This is the only way the computer's rooms appear; until
2637
+ * they do, it tries again by itself, backing off while the computer stays away. `reload: false` reuses settings just read.
2638
+ */
2639
+ async function connect_computer_rooms({ reload = true } = {}) {
2624
2640
  if (!state.admin.logged_in || computer_connecting) return;
2625
2641
  computer_connecting = true;
2642
+ clearTimeout(computer_retry);
2643
+ computer_retry = null;
2626
2644
  try {
2627
- await load_launch_settings();
2645
+ if (reload) await load_launch_settings();
2628
2646
  if (relay_machine && !state.remote_rooms) await show_computer_rooms(true);
2629
2647
  } catch (error) { console.warn("Connecting to the computer failed:", error.message); }
2630
- finally { computer_connecting = false; }
2648
+ finally {
2649
+ computer_connecting = false;
2650
+ if (state.remote_rooms) computer_attempt = 0;
2651
+ else if (state.admin.logged_in && !document.hidden) {
2652
+ const delay = RECONNECT_DELAYS_MS[Math.min(computer_attempt, RECONNECT_DELAYS_MS.length - 1)];
2653
+ computer_attempt += 1;
2654
+ computer_retry = setTimeout(() => { computer_retry = null; connect_computer_rooms(); }, delay);
2655
+ }
2656
+ }
2631
2657
  }
2632
2658
 
2633
2659
  /** The relay's change notices for the computer's rooms, shaped like the website's update stream. */
@@ -2796,7 +2822,9 @@ async function load_launch_settings() {
2796
2822
  state.launch.relay = await load_relay_machine();
2797
2823
  // An online computer that publishes a command key does everything the local service would; an older one only creates rooms.
2798
2824
  // The previous one stays in use until this answer arrives, so requests made meanwhile still reach the computer.
2799
- relay_machine = state.launch.relay?.online && state.launch.relay.public_key ? state.launch.relay : null;
2825
+ // A website without rooms of its own asks the relay itself, which knows at once whether the computer is connected;
2826
+ // the website's own record of the computer's last heartbeat can lag behind when that heartbeat is slow to arrive.
2827
+ relay_machine = state.launch.relay?.public_key && (state.launch.relay.online || computer_rooms_only()) ? state.launch.relay : null;
2800
2828
  if (!relay_machine) return;
2801
2829
  state.launch.remote = true;
2802
2830
  }
@@ -2815,7 +2843,10 @@ async function load_launch_settings() {
2815
2843
  load_room_models();
2816
2844
  } catch (error) {
2817
2845
  // An unpaired or expired device keeps its way to the computer: pairing and renewal go through it.
2818
- relay_machine = error.need === "pairing" || error.need === "renew" ? relay_machine : null;
2846
+ // A website without rooms of its own reaches the computer only through the relay, so one failed request keeps it.
2847
+ relay_machine = computer_rooms_only() || error.need === "pairing" || error.need === "renew" ? relay_machine : null;
2848
+ // The relay answering that the computer is not connected is shown as the computer being offline.
2849
+ if (computer_rooms_only() && !state.remote_rooms) state.remote_online = error.offline ? false : null;
2819
2850
  state.launch.remote = Boolean(local_connection);
2820
2851
  Object.assign(state.launch, { available: false, room_models: null, error: error.message });
2821
2852
  // Without a reachable local service, launches can still go through the paired computer.
@@ -2841,7 +2872,10 @@ async function load_relay_machine() {
2841
2872
  return null;
2842
2873
  }
2843
2874
  return machine;
2844
- } catch { return null; }
2875
+ } catch {
2876
+ // A failed read says nothing about the pairing, so the computer known so far stays in use.
2877
+ return state.launch.relay ?? null;
2878
+ }
2845
2879
  }
2846
2880
 
2847
2881
  /** Hand a room creation to the paired computer through the website and wait for it to finish. */
@@ -4449,8 +4483,10 @@ async function initialize() {
4449
4483
  if (name && field === "model") write_launch_model(name, event.target.value);
4450
4484
  else if (name) write_launch_field(name, field, event.target.type === "checkbox" ? event.target.checked : event.target.value);
4451
4485
  });
4452
- // Whether this website keeps rooms of its own decides how settings and rooms are read.
4453
- await load_admin_state();
4486
+ // Whether this website keeps rooms of its own decides how settings and rooms are read, so it is asked until it answers.
4487
+ for (let attempt = 0; await load_admin_state(), !state.admin.loaded; attempt++) {
4488
+ await new Promise(resolve => setTimeout(resolve, RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)]));
4489
+ }
4454
4490
  const launch_settings = load_launch_settings();
4455
4491
  document.addEventListener("click", download_relay_file, true);
4456
4492
  new MutationObserver(() => { if (state.remote_rooms) show_relay_images(); }).observe(document.body, { childList: true, subtree: true });
@@ -4466,14 +4502,18 @@ async function initialize() {
4466
4502
  });
4467
4503
  if (computer_rooms_only()) {
4468
4504
  await launch_settings;
4469
- state.remote_rooms = Boolean(relay_machine) && await load_remote_site().then(() => true, () => false);
4505
+ // The computer's rooms appear only through connecting, which keeps trying by itself, so nothing here can hide them.
4506
+ await connect_computer_rooms({ reload: false });
4470
4507
  }
4471
4508
  try {
4472
- const result = computer_rooms_only() && !state.remote_rooms ? { rooms: [] } : await request_json("/api/rooms");
4473
- state.rooms = result.rooms;
4474
- state.last_room = result.last_room || "";
4475
- set_folder_directory(result);
4476
- await select_room(opening_room());
4509
+ // Connecting has already opened the computer's rooms when it reached them.
4510
+ if (!computer_rooms_only() || !state.remote_rooms) {
4511
+ const result = computer_rooms_only() ? { rooms: [] } : await request_json("/api/rooms");
4512
+ state.rooms = result.rooms;
4513
+ state.last_room = result.last_room || "";
4514
+ set_folder_directory(result);
4515
+ await select_room(opening_room());
4516
+ }
4477
4517
  set_connection(true);
4478
4518
  } catch (error) { set_connection(false); show_toast(error.message, true); }
4479
4519
  start_live_updates();