ai_co_discuss 0.2.72 → 0.2.74

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/web/app.js CHANGED
@@ -28,6 +28,9 @@ const MEMBER_ACCESS_PREFIX = "discuss-members:";
28
28
  const MEMBER_PENDING_PREFIX = "discuss-member-pending:";
29
29
  const LOCAL_CONNECTION_KEY = "discuss-local-connection";
30
30
  const LOCAL_CONNECTION_TIMEOUT_MS = 15000;
31
+ // A relayed launch waits this long for the paired computer before the request is kept as a pending item.
32
+ const RELAY_WAIT_MS = 45000;
33
+ const RELAY_POLL_MS = 1500;
31
34
  const MODEL_CATALOG_TIMEOUT_MS = 35000;
32
35
  let local_connection = null;
33
36
  const DEFAULT_USER_LABEL = "User";
@@ -71,7 +74,7 @@ state.directory_pending = new Map();
71
74
  state.directory_version = 0;
72
75
  state.navigation_version = 0;
73
76
  state.admin = { loaded: false, local: false, registered: false, setup_available: false, logged_in: false, profile: null, identity_version: 0 };
74
- state.launch = { available: false, remote: false, connect_command: "", profiles: {}, workspace: {}, scheduler: {}, create: true, error: "", notice: "", recoveries: [] };
77
+ state.launch = { available: false, remote: false, relay: null, connect_command: "", profiles: {}, projects: [], workspace: {}, scheduler: {}, create: true, error: "", notice: "", recoveries: [] };
75
78
  const PLATFORM_CLIENTS = { Codex: [{ value: "codex_cli", label: "Codex CLI" }, { value: "codex", label: "Codex App" }],
76
79
  Claude: [{ value: "claude", label: "Claude Code CLI" }],
77
80
  Gemini: [{ value: "antigravity_cli", label: "Antigravity CLI" }, { value: "antigravity", label: "Antigravity App" }] };
@@ -221,6 +224,8 @@ function admin_error(error) {
221
224
  function accept_admin_session({ token, profile }) {
222
225
  store_admin_token(token);
223
226
  apply_admin_state({ registered: true, setup_available: false, logged_in: true, profile });
227
+ // Pairing and the paired computer both need the session, so read them again now.
228
+ load_launch_settings();
224
229
  }
225
230
 
226
231
  /** Create the single administrator account and sign in with it. */
@@ -244,6 +249,7 @@ async function logout_admin() {
244
249
  catch { /* The token is removed below either way. */ }
245
250
  store_admin_token("");
246
251
  state.admin.logged_in = false;
252
+ state.launch.relay = null;
247
253
  publish_view();
248
254
  }
249
255
 
@@ -1540,8 +1546,9 @@ async function submit_room(event, folder_id = null) {
1540
1546
  const free = payload.mode === "free";
1541
1547
  // A discussion can still be created without the local service and started later from the room;
1542
1548
  // a free room needs the service only for members it wakes or for smart scheduling.
1543
- const launching = free ? Object.values(payload.experts).includes("cli") || (state.launch.available && Boolean(creation_launch_options().scheduler || state.launch.workspace.cwd)) : state.launch.create && state.launch.available;
1544
- if (launching && !state.launch.available) throw new Error("本机唤醒的成员需要连接本机服务;也可改为手动接入或网页代发");
1549
+ const launchable = state.launch.available || Boolean(state.launch.relay);
1550
+ const launching = free ? Object.values(payload.experts).includes("cli") || (launchable && Boolean(creation_launch_options().scheduler || state.launch.workspace.cwd || state.launch.workspace.local_project)) : state.launch.create && state.launch.available;
1551
+ if (launching && !launchable) throw new Error("本机唤醒的成员需要连接本机服务;也可改为手动接入或网页代发");
1545
1552
  const launch_draft = launching ? creation_launch_options() : null;
1546
1553
  // A discussion is only a way to push the room forward: the room is created as a free room, then opened with these limits.
1547
1554
  const discussion = free && state.create_discussion ? { goal: payload.goal.trim(), named_followup: state.named_followup,
@@ -1675,7 +1682,8 @@ async function submit_local_launch(input, title, direct = false, signature) {
1675
1682
  const id = input.room?.submission_id || `room-${input.room_id}`;
1676
1683
  const key = LAUNCH_PENDING_PREFIX + id;
1677
1684
  const previous = localStorage.getItem(key);
1678
- const record = previous ? JSON.parse(previous) : { id, title, input, room_id: input.room_id, direct, signature };
1685
+ const relay = !direct && !state.launch.available ? state.launch.relay?.id : undefined;
1686
+ const record = previous ? JSON.parse(previous) : { id, title, input, room_id: input.room_id, direct, signature, ...(relay ? { machine_id: relay } : {}) };
1679
1687
  if (previous && JSON.stringify(record.input) !== JSON.stringify(input)) throw new Error("此房间有未完成的原启动请求,请先继续该请求,不要替换配置");
1680
1688
  record.attempt_id = crypto.randomUUID();
1681
1689
  try { localStorage.setItem(key, JSON.stringify(record)); }
@@ -1683,7 +1691,7 @@ async function submit_local_launch(input, title, direct = false, signature) {
1683
1691
  refresh_launch_recoveries();
1684
1692
  try {
1685
1693
  const result = record.direct ? { room: await request_json("/api/rooms", record.input.room, record.input.token), status: "created" }
1686
- : await local_launch_request("launch", record.input);
1694
+ : record.machine_id ? await relay_launch_request(record.input, record.machine_id) : await local_launch_request("launch", record.input);
1687
1695
  save_room_credentials(result.room, record.input.token, record.input.room?.members || []);
1688
1696
  localStorage.removeItem(key);
1689
1697
  refresh_launch_recoveries();
@@ -1752,21 +1760,54 @@ async function load_launch_settings() {
1752
1760
  local_connection = restore_local_connection();
1753
1761
  state.launch.remote = Boolean(local_connection);
1754
1762
  if (local_connection) state.launch.connect_command = `codiscuss local connect "${location.origin}"`;
1755
- if (!local_connection && !local) return;
1763
+ if (!local_connection && !local) { state.launch.relay = await load_relay_machine(); return; }
1756
1764
  const settings = await local_launch_request("settings");
1757
1765
  // Keep the saved machine defaults so every new form can start from them again.
1758
1766
  state.launch.saved_profiles = JSON.parse(JSON.stringify(settings.participants || {}));
1759
- Object.assign(state.launch, { available: true, profiles: settings.participants, api_providers: settings.api_providers || {}, api_keys: settings.api_keys || {},
1767
+ Object.assign(state.launch, { available: true, profiles: settings.participants, projects: settings.projects || [], api_providers: settings.api_providers || {}, api_keys: settings.api_keys || {},
1760
1768
  scheduler_providers: settings.scheduler_providers || {}, error: "",
1761
1769
  notice: local_connection ? "已连接本机服务,讨论保存在当前网站" : "" });
1762
1770
  Object.values(state.launch.profiles).forEach(fill_api_key);
1771
+ if (local_connection) pair_local_machine();
1763
1772
  // Settings arrive after the form was reset; only an untouched draft gets the default.
1764
1773
  if (!Object.keys(state.launch.scheduler).length) state.launch.scheduler = default_scheduler();
1765
1774
  load_room_models();
1766
- } catch (error) { state.launch.available = false; state.launch.room_models = null; state.launch.error = error.message; }
1775
+ } catch (error) {
1776
+ Object.assign(state.launch, { available: false, room_models: null, error: error.message });
1777
+ // Without a reachable local service, launches can still go through the paired computer.
1778
+ state.launch.relay = await load_relay_machine();
1779
+ if (state.launch.relay) state.launch.error = "";
1780
+ }
1767
1781
  finally { refresh_launch_recoveries(); render_launch_settings(); publish_view(); }
1768
1782
  }
1769
1783
 
1784
+ /** The computer paired with this site, most recently seen first; only the administrator can read or use it. */
1785
+ async function load_relay_machine() {
1786
+ if (!admin_token()) return null;
1787
+ try { return (await request_json("/api/machines")).machines[0] || null; } catch { return null; }
1788
+ }
1789
+
1790
+ /** Pair the connected computer so devices without the local service can launch on it; needs the administrator session. */
1791
+ async function pair_local_machine() {
1792
+ if (!admin_token()) return;
1793
+ try { await request_json("/api/machines", await local_launch_request("machine-link", {})); }
1794
+ catch (error) { console.warn("Pairing this computer failed:", error.message); }
1795
+ }
1796
+
1797
+ /** Hand a room creation to the paired computer through the website and wait for it to finish. */
1798
+ async function relay_launch_request(input, machine_id) {
1799
+ const id = input.room.submission_id;
1800
+ let launch = await request_json("/api/remote-launches", { id, machine_id, request: input });
1801
+ for (let waited = 0; ["pending", "claimed"].includes(launch.status) && waited < RELAY_WAIT_MS; waited += RELAY_POLL_MS) {
1802
+ await new Promise(resolve => setTimeout(resolve, RELAY_POLL_MS));
1803
+ launch = await request_json(`/api/remote-launches/${encodeURIComponent(id)}`);
1804
+ }
1805
+ if (launch.status === "failed") throw Object.assign(new Error(launch.error), { launch: { stage: launch.result.stage, room_id: launch.result.room_id } });
1806
+ const name = state.launch.relay?.name || "电脑";
1807
+ if (launch.status !== "done") throw Object.assign(new Error(`${name}${launch.status === "pending" ? "尚未接手" : "仍在启动"},请保持它开机并运行 codiscuss,稍后在待办中继续`), { launch: { stage: "unconfirmed" } });
1808
+ return { room: await request_json(`/api/rooms/${launch.result.room_id}`, undefined, input.token), status: launch.result.status };
1809
+ }
1810
+
1770
1811
  /** Re-check the local connection and explain when nothing has been connected yet. */
1771
1812
  async function retry_launch_connection() {
1772
1813
  await load_launch_settings();
@@ -1948,7 +1989,9 @@ function restore_launch_profile(name, profile) {
1948
1989
 
1949
1990
  /** Capture local overrides before asynchronous work or navigation can change the draft. */
1950
1991
  function launch_options() {
1951
- const draft = JSON.parse(JSON.stringify({ participants: state.launch.profiles, ...state.launch.workspace, entry: "web" }));
1992
+ const { local_project, ...workspace } = state.launch.workspace;
1993
+ // The paired computer resolves a project id to its own folder; a browser with the local service sends the folder itself.
1994
+ const draft = JSON.parse(JSON.stringify({ participants: state.launch.profiles, ...workspace, ...(!state.launch.available && local_project ? { local_project } : {}), entry: "web" }));
1952
1995
  for (const profile of Object.values(draft.participants)) if (profile.adapter === "api") profile.project_access = "read";
1953
1996
  return draft;
1954
1997
  }
@@ -2077,23 +2120,35 @@ function clear_launch_overrides(draft) {
2077
2120
  if (state.launch.profiles[name]?.[field] === profile[field]) delete state.launch.profiles[name]?.[field];
2078
2121
  }
2079
2122
  }
2080
- for (const field of ["cwd", "discussion_project", "project_access"]) {
2123
+ for (const field of ["cwd", "local_project", "discussion_project", "project_access"]) {
2081
2124
  if (state.launch.workspace[field] === draft[field]) delete state.launch.workspace[field];
2082
2125
  }
2083
2126
  render_launch_settings();
2084
2127
  publish_view();
2085
2128
  }
2086
2129
 
2130
+ /** Browse for a folder on the computer running the local service; resolves to "" when cancelled. */
2131
+ async function browse_local_directory() {
2132
+ return (await local_launch_request("pick-directory", {})).path || "";
2133
+ }
2134
+
2087
2135
  /** Let the user browse for the project folder on the computer running the local service. */
2088
2136
  async function pick_launch_directory() {
2089
2137
  try {
2090
- const result = await local_launch_request("pick-directory", {});
2091
- if (result.path) write_launch_field("workspace", "cwd", result.path);
2138
+ const path = await browse_local_directory();
2139
+ if (path) write_launch_field("workspace", "cwd", path);
2092
2140
  state.launch.error = "";
2093
2141
  } catch (error) { state.launch.error = error.message; }
2094
2142
  publish_view();
2095
2143
  }
2096
2144
 
2145
+ /** Replace the named project directories kept on the computer running the local service. */
2146
+ async function save_launch_projects(projects) {
2147
+ const result = await local_launch_request("projects", { projects });
2148
+ state.launch.projects = result.projects;
2149
+ publish_view();
2150
+ }
2151
+
2097
2152
  /** Save machine defaults without per-discussion session IDs or project write access. */
2098
2153
  async function save_launch_defaults() {
2099
2154
  // Defaults are per platform; an extra instance's model and effort apply to its own discussion only.
@@ -3367,7 +3422,7 @@ function change_view_theme(theme) {
3367
3422
 
3368
3423
  window.discuss_view = { snapshot: view_snapshot, write_field: write_view_field,
3369
3424
  write_launch: write_launch_field, write_room_connection, write_room_adapter, launch_clients: LAUNCH_CLIENTS,
3370
- launch_model_state, load_launch_models, write_launch_model, save_launch_api_key, write_scheduler, save_scheduler_api_key, check_scheduler, write_named_followup, write_create_discussion, check_launch_api, launch_summary, launch_can_set_access, launch_can_bind_session, pick_launch_directory, restore_launch_profile, reset_create_form, empty_discussion_message,
3425
+ launch_model_state, load_launch_models, write_launch_model, save_launch_api_key, write_scheduler, save_scheduler_api_key, check_scheduler, write_named_followup, write_create_discussion, check_launch_api, launch_summary, launch_can_set_access, launch_can_bind_session, pick_launch_directory, browse_local_directory, save_launch_projects, restore_launch_profile, reset_create_form, empty_discussion_message,
3371
3426
  start_drag: start_directory_drag,
3372
3427
  attachment_markup, attachment_size, attachment_file_icon, drop_attachments, paste_attachments,
3373
3428
  write_expert: write_view_expert, add_instance: add_view_instance, platform_of, execute: execute_view_action, theme: change_view_theme,