ai_co_discuss 0.3.58 → 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.58",
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
@@ -52,6 +52,8 @@ const RELAY_COMMAND_WAIT_MS = 75000;
52
52
  // this much later is stuck in the network and is given up, so a hung connection cannot hold back reconnecting.
53
53
  const RELAY_DEFAULT_WAIT_MS = 9000;
54
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;
55
57
  const RELAY_CIPHER_INFO = "codiscuss-relay-v1";
56
58
  // The rooms kept on the paired computer are read and written through its relay connection; only room routes go there.
57
59
  const RELAY_PROTOCOL = "codiscuss-relay";
@@ -235,7 +237,7 @@ async function request_json(path, payload, access_token) {
235
237
  options.headers["Content-Type"] = "application/json";
236
238
  options.body = JSON.stringify(payload);
237
239
  }
238
- if (path.startsWith("/api/machines")) options.signal = AbortSignal.timeout((Number.isInteger(payload?.wait) ? payload.wait : RELAY_DEFAULT_WAIT_MS) + MACHINE_REQUEST_MARGIN_MS);
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);
239
241
  const response = await fetch(path, options);
240
242
  const result = await response.json();
241
243
  if (!response.ok) throw Object.assign(new Error(result.error || "请求失败,请重试"), { preflight: response.status >= 400 && response.status < 500, status: response.status });
@@ -2308,7 +2310,7 @@ async function pair_exchange(machine, payload) {
2308
2310
  const id = crypto.randomUUID();
2309
2311
  const { key, envelope } = await seal_relay_request(machine, id, { action: "device-pair", payload });
2310
2312
  const path = `/api/machines/${encodeURIComponent(machine.id)}/pair`;
2311
- 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 }) });
2312
2314
  const result = await response.json();
2313
2315
  if (!response.ok) throw new Error(result.error || "配对请求被网站拒绝");
2314
2316
  if (result.status === "done") return open_relay_response(key, id, result.response);
@@ -2629,15 +2631,29 @@ async function show_computer_rooms(remote) {
2629
2631
  await select_room(undefined);
2630
2632
  }
2631
2633
 
2632
- /** Once signed in, find the paired computer and show its rooms; the poll calls this again until the computer answers. */
2633
- 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 } = {}) {
2634
2640
  if (!state.admin.logged_in || computer_connecting) return;
2635
2641
  computer_connecting = true;
2642
+ clearTimeout(computer_retry);
2643
+ computer_retry = null;
2636
2644
  try {
2637
- await load_launch_settings();
2645
+ if (reload) await load_launch_settings();
2638
2646
  if (relay_machine && !state.remote_rooms) await show_computer_rooms(true);
2639
2647
  } catch (error) { console.warn("Connecting to the computer failed:", error.message); }
2640
- 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
+ }
2641
2657
  }
2642
2658
 
2643
2659
  /** The relay's change notices for the computer's rooms, shaped like the website's update stream. */
@@ -2806,7 +2822,9 @@ async function load_launch_settings() {
2806
2822
  state.launch.relay = await load_relay_machine();
2807
2823
  // An online computer that publishes a command key does everything the local service would; an older one only creates rooms.
2808
2824
  // The previous one stays in use until this answer arrives, so requests made meanwhile still reach the computer.
2809
- 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;
2810
2828
  if (!relay_machine) return;
2811
2829
  state.launch.remote = true;
2812
2830
  }
@@ -2827,6 +2845,8 @@ async function load_launch_settings() {
2827
2845
  // An unpaired or expired device keeps its way to the computer: pairing and renewal go through it.
2828
2846
  // A website without rooms of its own reaches the computer only through the relay, so one failed request keeps it.
2829
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;
2830
2850
  state.launch.remote = Boolean(local_connection);
2831
2851
  Object.assign(state.launch, { available: false, room_models: null, error: error.message });
2832
2852
  // Without a reachable local service, launches can still go through the paired computer.
@@ -4463,8 +4483,10 @@ async function initialize() {
4463
4483
  if (name && field === "model") write_launch_model(name, event.target.value);
4464
4484
  else if (name) write_launch_field(name, field, event.target.type === "checkbox" ? event.target.checked : event.target.value);
4465
4485
  });
4466
- // Whether this website keeps rooms of its own decides how settings and rooms are read.
4467
- 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
+ }
4468
4490
  const launch_settings = load_launch_settings();
4469
4491
  document.addEventListener("click", download_relay_file, true);
4470
4492
  new MutationObserver(() => { if (state.remote_rooms) show_relay_images(); }).observe(document.body, { childList: true, subtree: true });
@@ -4480,16 +4502,18 @@ async function initialize() {
4480
4502
  });
4481
4503
  if (computer_rooms_only()) {
4482
4504
  await launch_settings;
4483
- // A reconnect that ran meanwhile may already show the computer's rooms; a slower failure here must not hide them.
4484
- const reachable = Boolean(relay_machine) && await load_remote_site().then(() => true, () => false);
4485
- state.remote_rooms ||= reachable;
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 });
4486
4507
  }
4487
4508
  try {
4488
- const result = computer_rooms_only() && !state.remote_rooms ? { rooms: [] } : await request_json("/api/rooms");
4489
- state.rooms = result.rooms;
4490
- state.last_room = result.last_room || "";
4491
- set_folder_directory(result);
4492
- 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
+ }
4493
4517
  set_connection(true);
4494
4518
  } catch (error) { set_connection(false); show_toast(error.message, true); }
4495
4519
  start_live_updates();