ai_co_discuss 0.3.7 → 0.3.9

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
@@ -53,33 +53,31 @@ const PLATFORM_IDENTITIES = {
53
53
  Grok: { initials: "GK", style: "grok", description: "AI 身份" },
54
54
  };
55
55
  const IDENTITIES = by_platform(PLATFORM_IDENTITIES, (identity, name) => ({ ...identity, label: name, description: "AI 实例" }));
56
- const state = { rooms: [], folders: [], room: null, page: 1, pages: 1, query: "", mention_only: false, free_notice: "", free_error: "", order_error: null,
56
+ const state = { rooms: [], folders: [], room: null, page: 1, pages: 1, query: "", mention_only: false,
57
57
  free_proxy: "", create_parent: null, named_followup: false, create_discussion: false,
58
58
  archive_view: false, directory_sort: "manual", management: null, dragged_room: null, folder_moves: new Map(), folder_version: 0,
59
59
  reply_to: null, turn_reply_to: null, tab: "topic", refresh_version: 0, context_dirty: false, topic_dirty: false,
60
60
  snapshot_id: 0, pending_submission: null, pending_turn: null, pending_room: null, toast_timer: null,
61
- creation_mode: "brainstorm", creation_drafts: {}, access_notice: "", access_error: "" };
61
+ creation_mode: "brainstorm", creation_drafts: {} };
62
62
  const live_updates = { source: null, connected: false, enabled: false, timer: null, poll_timer: null,
63
63
  running: false, dirty: false, full: false, revisions: "", refreshed_at: 0 };
64
64
  state.messages = [];
65
65
  state.message_history = null;
66
66
  state.connected = false;
67
+ state.reconnecting = false;
67
68
  state.attachment_drafts = [];
68
- state.composer_error = "";
69
69
  state.upload_status = "";
70
70
  state.sending_message = false;
71
71
  state.attachment_preview = null;
72
72
  state.archive_changes = new Map();
73
73
  state.archive_version = 0;
74
- state.directory_error = "";
75
- state.directory_notice = "";
76
74
  // The open import's progress for the import dialog: { done, rooms: [{ title, status, members, error }], errors }.
77
75
  state.import_progress = null;
78
76
  state.directory_pending = new Map();
79
77
  state.directory_version = 0;
80
78
  state.navigation_version = 0;
81
79
  state.admin = { loaded: false, local: false, registered: false, setup_available: false, logged_in: false, profile: null, identity_version: 0 };
82
- state.launch = { available: false, remote: false, relay: null, connect_command: "", profiles: {}, projects: [], presets: [], builtin_presets: [], description_templates: {}, workspace: {}, scheduler: {}, create: true, error: "", notice: "", recoveries: [] };
80
+ state.launch = { available: false, remote: false, relay: null, connect_command: "", profiles: {}, projects: [], presets: [], builtin_presets: [], description_templates: {}, workspace: {}, scheduler: {}, create: true, error: "", recoveries: [] };
83
81
  const PLATFORM_CLIENTS = { Codex: [{ value: "codex_cli", label: "Codex CLI" }, { value: "codex", label: "Codex App" }],
84
82
  Claude: [{ value: "claude", label: "Claude Code CLI" }],
85
83
  Gemini: [{ value: "antigravity_cli", label: "Antigravity CLI" }, { value: "antigravity", label: "Antigravity App" }] };
@@ -136,7 +134,8 @@ function format_time(value) {
136
134
 
137
135
  /** Display operation feedback only in the classic renderer. */
138
136
  function show_toast(message, is_error = false) {
139
- if (document.documentElement.dataset.theme.startsWith("channel")) return;
137
+ // The channel themes stay quiet on success; failures go to the shared toaster.
138
+ if (document.documentElement.dataset.theme.startsWith("channel")) { if (is_error) notify("error", message); return; }
140
139
  clearTimeout(state.toast_timer);
141
140
  element("toast").textContent = message;
142
141
  element("toast").classList.toggle("error", is_error);
@@ -144,6 +143,17 @@ function show_toast(message, is_error = false) {
144
143
  state.toast_timer = setTimeout(() => { element("toast").hidden = true; }, TOAST_DURATION_MS);
145
144
  }
146
145
 
146
+ /** Show a transient notice in the channel's toaster; level is "error", "warning", or "info". */
147
+ function notify(level, message) {
148
+ document.dispatchEvent(new CustomEvent("discuss:notice", { detail: { level, message } }));
149
+ }
150
+
151
+ /** Show a rethrown error in its own wording, so the action dispatcher does not show it again. */
152
+ function notify_failure(error, message) {
153
+ notify("error", message);
154
+ error.notified = true;
155
+ }
156
+
147
157
  /** The administrator session token kept by this browser, or an empty string. */
148
158
  function admin_token() {
149
159
  if (state.admin.local) return "";
@@ -313,6 +323,7 @@ function management_error_message(room_id, error) {
313
323
  /** Show connection state without claiming that any AI is online. */
314
324
  function set_connection(connected) {
315
325
  state.connected = connected;
326
+ state.reconnecting = false;
316
327
  element("connection-dot").className = `status-dot ${connected ? "connected" : "failed"}`;
317
328
  element("connection-status").textContent = connected ? "讨论网站已连接" : "网站连接中断,可手动刷新";
318
329
  publish_view();
@@ -435,7 +446,6 @@ async function update_directory(change) {
435
446
  if (state.directory_pending.has(change.key)) return;
436
447
  state.directory_pending.set(change.key, change);
437
448
  state.directory_version++;
438
- state.directory_error = "";
439
449
  change.apply();
440
450
  render_rooms();
441
451
  publish_view();
@@ -445,7 +455,7 @@ async function update_directory(change) {
445
455
  return saved;
446
456
  } catch (error) {
447
457
  change.rollback();
448
- state.directory_error = change.error_message?.(error) || `${change.label}失败,已恢复原状态:${error.message}`;
458
+ notify_failure(error, change.error_message?.(error) || `${change.label}失败,已恢复原状态:${error.message}`);
449
459
  throw error;
450
460
  } finally {
451
461
  state.directory_pending.delete(change.key);
@@ -538,13 +548,12 @@ async function archive_channel(id, archived) {
538
548
  const change = { archived, phase: archived && room.phase && room.phase !== "concluded" ? "paused" : room.phase };
539
549
  state.archive_changes.set(id, change);
540
550
  state.archive_version++;
541
- state.directory_error = "";
542
551
  display_channel_archive(id, change);
543
552
  try {
544
553
  const saved = await request_json(`/api/rooms/${id}/${archived ? "archive" : "restore"}`, { actor: "用户" });
545
554
  display_channel_archive(id, { archived: Boolean(saved.archived), phase: saved.phase }, change);
546
555
  } catch (error) {
547
- state.directory_error = `「${room.title}」${archived ? "存档" : "恢复"}失败,已恢复原状态:${management_error_message(id, error)}`;
556
+ notify_failure(error, `「${room.title}」${archived ? "存档" : "恢复"}失败,已恢复原状态:${management_error_message(id, error)}`);
548
557
  display_channel_archive(id, previous, change);
549
558
  throw error;
550
559
  } finally {
@@ -580,7 +589,6 @@ async function assign_channel_folder(id, folder_id) {
580
589
  const previous_folder = room.folder_id ?? null;
581
590
  if (state.folders.some(folder => folder.id === folder_id && folder.pending)) return;
582
591
  if (folder_id && state.directory_pending.get("folder-order")?.removed_folder === folder_id) return;
583
- state.directory_error = "";
584
592
  state.folder_moves.set(id, folder_id);
585
593
  state.folder_version++;
586
594
  display_channel_folder(id, folder_id);
@@ -589,7 +597,7 @@ async function assign_channel_folder(id, folder_id) {
589
597
  display_channel_folder(id, saved.folder_id ?? null);
590
598
  show_toast("话题归类已更新");
591
599
  } catch (error) {
592
- state.directory_error = `「${room.title}」归类失败,已恢复原状态:${error.message}`;
600
+ notify_failure(error, `「${room.title}」归类失败,已恢复原状态:${error.message}`);
593
601
  display_channel_folder(id, previous_folder);
594
602
  throw error;
595
603
  } finally {
@@ -886,7 +894,6 @@ async function refresh_room({ room: read_room = true } = {}) {
886
894
 
887
895
  /** Select a room and reset room-specific draft and filter state. */
888
896
  async function select_room(room_id) {
889
- state.access_error = ""; state.access_notice = "";
890
897
  state.navigation_version++;
891
898
  state.refresh_version++;
892
899
  state.room = state.rooms.find((room) => room.id === room_id);
@@ -898,9 +905,7 @@ async function select_room(room_id) {
898
905
  state.pending_turn = null;
899
906
  state.pending_submission = null;
900
907
  clear_attachment_drafts();
901
- state.composer_error = "";
902
908
  state.launch.repair = null;
903
- state.launch.repair_error = '';
904
909
  state.launch.room_models = null;
905
910
  state.messages = [];
906
911
  state.message_history = null;
@@ -1025,7 +1030,7 @@ function set_proxy(name) { state.free_proxy = state.free_proxy === name ? "" : n
1025
1030
  async function copy_online_notice(name) {
1026
1031
  const ids = (state.room.free?.requests || []).filter(item => item.member === name && item.status === "pending").map(item => `#${item.message_id}`);
1027
1032
  await copy_text(`请阅读 ${location.origin}/r/${state.room.id}?participant=${name} ,回应 ${ids.join("、") || "最新消息"}。单条原文:${location.origin}/r/${state.room.id}/messages/编号。请在你自己的窗口回答,不要操作网页或调用写入接口。`);
1028
- state.free_notice = `已复制给 ${name} 的阅读通知;复制不代表已送达。拿到回答后,在输入框选择“代发 ${name}”粘贴原文。`;
1033
+ notify("info", `已复制给 ${name} 的阅读通知;复制不代表已送达。拿到回答后,在输入框选择“代发 ${name}”粘贴原文。`);
1029
1034
  }
1030
1035
 
1031
1036
  /** Start every new form from defaults: room use, delivery, text, roster, host, limits and software overrides. */
@@ -1043,7 +1048,6 @@ function reset_create_form() {
1043
1048
  state.create_chain_rounds = DEFAULT_CHAIN_ROUNDS;
1044
1049
  state.launch.profiles = JSON.parse(JSON.stringify(state.launch.saved_profiles || {}));
1045
1050
  Object.values(state.launch.profiles).forEach(fill_api_key);
1046
- state.launch.notice = "";
1047
1051
  render_launch_settings();
1048
1052
  publish_view();
1049
1053
  }
@@ -1071,7 +1075,7 @@ async function return_conclusion() {
1071
1075
  const summary = room.host_brief?.text || room.state_text || "(主持人尚未整理结论,请查看原文)";
1072
1076
  await request_json(`/api/rooms/${parent.id}/messages`, { author: "用户", text: `头脑风暴「${room.title}」已收束。\n\n${summary}\n\n原文:${location.origin}/r/${room.id}`,
1073
1077
  kind: "chat", submitted_by: "用户", submission_id: new_submission_id(), to: [] });
1074
- state.free_notice = `结论已发回「${parent.title}」`;
1078
+ notify("info", `结论已发回「${parent.title}」`);
1075
1079
  await select_room(parent.id);
1076
1080
  }
1077
1081
 
@@ -1237,7 +1241,7 @@ async function send_descriptions(room_id, token, descriptions) {
1237
1241
  const filled = Object.fromEntries(Object.entries(descriptions).filter(([, text]) => text?.trim()));
1238
1242
  if (!room_id || !token || !Object.keys(filled).length) return;
1239
1243
  try { await request_json(`/api/rooms/${room_id}/descriptions`, { descriptions: filled }, token); }
1240
- catch (error) { state.free_notice = `成员描述未保存:${error.message},可在发言顺序的成员菜单里重新填写。`; }
1244
+ catch (error) { notify("warning", `成员描述未保存:${error.message},可在发言顺序的成员菜单里重新填写。`); }
1241
1245
  }
1242
1246
 
1243
1247
  /** Save display labels by member name with the room credential; a failure leaves the members under their names. */
@@ -1245,50 +1249,47 @@ async function send_labels(room_id, token, labels) {
1245
1249
  const filled = Object.fromEntries(Object.entries(labels).filter(([, text]) => text?.trim()));
1246
1250
  if (!room_id || !token || !Object.keys(filled).length) return;
1247
1251
  try { await request_json(`/api/rooms/${room_id}/labels`, { labels: filled }, token); }
1248
- catch (error) { state.free_notice = `成员显示名未保存:${error.message},房间里按成员名显示。`; }
1252
+ catch (error) { notify("warning", `成员显示名未保存:${error.message},房间里按成员名显示。`); }
1249
1253
  }
1250
1254
 
1251
1255
  /** Save the new room's round limit with the room credential; a failure leaves the room on the default. */
1252
1256
  async function send_chain_rounds(room_id, token, max_rounds) {
1253
1257
  if (!room_id || !token || max_rounds === DEFAULT_CHAIN_ROUNDS) return;
1254
1258
  try { await request_json(`/api/rooms/${room_id}/budget`, { max_rounds }, token); }
1255
- catch (error) { state.free_notice = `推进轮数未保存:${error.message},房间沿用默认 ${DEFAULT_CHAIN_ROUNDS} 轮,可在房间页修改。`; }
1259
+ catch (error) { notify("warning", `推进轮数未保存:${error.message},房间沿用默认 ${DEFAULT_CHAIN_ROUNDS} 轮,可在房间页修改。`); }
1256
1260
  }
1257
1261
 
1258
1262
  /** Edit one member's description inside the room; the next decision by the scheduler or host uses it. */
1259
1263
  async function describe_member(name, text) {
1260
1264
  const room_id = state.room.id;
1261
- state.free_error = "";
1262
1265
  try {
1263
1266
  const token = localStorage.getItem(`discuss-access:${room_id}`);
1264
1267
  if (!token) throw new Error("此浏览器缺少房间控制凭据,请使用创建房间的浏览器");
1265
1268
  await request_json(`/api/rooms/${room_id}/descriptions`, { descriptions: { [name]: text } }, token);
1266
1269
  if (state.room?.id === room_id) await refresh_room();
1267
- } catch (error) { state.free_error = error.message; throw error; }
1270
+ } catch (error) { notify("error", error.message); throw error; }
1268
1271
  }
1269
1272
 
1270
1273
  /** Change how many rounds the host may run from one owner message; chains still running follow the new value. */
1271
1274
  async function set_chain_rounds(max_rounds) {
1272
1275
  const room_id = state.room.id;
1273
- state.free_error = "";
1274
1276
  try {
1275
1277
  const token = localStorage.getItem(`discuss-access:${room_id}`);
1276
1278
  if (!token) throw new Error("此浏览器缺少房间控制凭据,请使用创建房间的浏览器");
1277
1279
  await request_json(`/api/rooms/${room_id}/budget`, { max_rounds }, token);
1278
1280
  if (state.room?.id === room_id) await refresh_room();
1279
- } catch (error) { state.free_error = error.message; throw error; }
1281
+ } catch (error) { notify("error", error.message); throw error; }
1280
1282
  }
1281
1283
 
1282
1284
  /** Let a chain that stopped at its round limit run on for more rounds; the dispatch it held back goes out at once. */
1283
1285
  async function continue_chain(chain_id, rounds) {
1284
1286
  const room_id = state.room.id;
1285
- state.free_error = "";
1286
1287
  try {
1287
1288
  const token = localStorage.getItem(`discuss-access:${room_id}`);
1288
1289
  if (!token) throw new Error("此浏览器缺少房间控制凭据,请使用创建房间的浏览器");
1289
1290
  await request_json(`/api/rooms/${room_id}/chains/${chain_id}`, { action: "continue", rounds }, token);
1290
1291
  if (state.room?.id === room_id) await refresh_room();
1291
- } catch (error) { state.free_error = error.message; throw error; }
1292
+ } catch (error) { notify("error", error.message); throw error; }
1292
1293
  }
1293
1294
 
1294
1295
  /** Allow operations a woken member's software refused; the local service passes the rules from its next run. */
@@ -1319,33 +1320,30 @@ async function set_member_adapter({ name, adapter }) {
1319
1320
  /** Owner changes use the website; only automatic members need the local service to reconnect. */
1320
1321
  async function change_member({ member_id, name, action, role, latest_id }) {
1321
1322
  const room_id = state.room.id;
1322
- state.free_error = ""; state.free_notice = "";
1323
- try {
1324
- if (action === "rejoin") {
1325
- const member = (state.room.free?.members || state.room.members).find(item => item.id === member_id);
1326
- const token = localStorage.getItem(`discuss-access:${room_id}`);
1327
- const record = prepare_member_request(room_id, `rejoin:${member_id}`, { name, access: member.access });
1328
- const result = member.access === "runner"
1329
- ? await local_launch_request("room-member", { room_id, token, action: "rejoin", name, member_token: record.member.token })
1330
- : await request_json(`/api/rooms/${room_id}/members/${member_id}`, { action: "active", token: record.member.token });
1331
- finish_member_request(room_id, token, record, result);
1332
- if (state.room?.id === room_id) state.free_notice = member.access === "manual" ? `${name} 已请回,凭证已更新;请用新的启动指令让它重新注册。` : `${name} 已请回,续接原会话。`;
1333
- } else {
1334
- await request_json(`/api/rooms/${room_id}/members/${member_id}${action === "interrupt" ? "/interrupt" : ""}`,
1335
- action === "interrupt" ? {} : { action, ...(role ? { role } : {}), ...(latest_id !== undefined ? { latest_id } : {}) });
1336
- }
1337
- if (state.room?.id === room_id) await refresh_room();
1338
- } catch (error) { if (state.room?.id === room_id) state.free_error = error.message; }
1323
+ if (action === "rejoin") {
1324
+ const member = (state.room.free?.members || state.room.members).find(item => item.id === member_id);
1325
+ const token = localStorage.getItem(`discuss-access:${room_id}`);
1326
+ const record = prepare_member_request(room_id, `rejoin:${member_id}`, { name, access: member.access });
1327
+ const result = member.access === "runner"
1328
+ ? await local_launch_request("room-member", { room_id, token, action: "rejoin", name, member_token: record.member.token })
1329
+ : await request_json(`/api/rooms/${room_id}/members/${member_id}`, { action: "active", token: record.member.token });
1330
+ finish_member_request(room_id, token, record, result);
1331
+ if (state.room?.id === room_id) notify("info", member.access === "manual" ? `${name} 已请回,凭证已更新;请用新的启动指令让它重新注册。` : `${name} 已请回,续接原会话。`);
1332
+ } else {
1333
+ await request_json(`/api/rooms/${room_id}/members/${member_id}${action === "interrupt" ? "/interrupt" : ""}`,
1334
+ action === "interrupt" ? {} : { action, ...(role ? { role } : {}), ...(latest_id !== undefined ? { latest_id } : {}) });
1335
+ }
1336
+ if (state.room?.id === room_id) await refresh_room();
1339
1337
  }
1340
1338
 
1341
1339
  /** Reorder who has not spoken yet against the revision the board showed; a changed order is refused and the board reloads. */
1342
1340
  async function reorder_speakers({ revision, order }) {
1343
1341
  const room_id = state.room.id;
1344
- state.order_error = null;
1345
1342
  try { await request_json(`/api/rooms/${room_id}/speaking-order`, { revision, order }); }
1346
1343
  catch (error) {
1347
1344
  // A conflict means someone else's change landed first; the board reloads below, so ask for the move again (same text as the board's own notice).
1348
- state.order_error = { room_id, message: error.status === 409 ? "发言顺序刚有变化,已更新为最新顺序,请重新调整" : error.message };
1345
+ if (error.status === 409) notify("warning", "发言顺序刚有变化,已更新为最新顺序,请重新调整");
1346
+ else notify("error", error.message);
1349
1347
  }
1350
1348
  if (state.room?.id === room_id) await refresh_room();
1351
1349
  }
@@ -1353,41 +1351,39 @@ async function reorder_speakers({ revision, order }) {
1353
1351
  /** Invite an independent member, retaining its credential before either delivery path runs. */
1354
1352
  async function invite_member({ name, access, project_access = "read", session_id = "" }) {
1355
1353
  const room_id = state.room.id;
1356
- state.free_error = ""; state.free_notice = "";
1357
- try {
1358
- const token = localStorage.getItem(`discuss-access:${room_id}`);
1359
- if (!token) throw new Error("此浏览器缺少房间控制凭据,请使用创建房间的浏览器");
1360
- const taken = new Set((state.room.free?.members || state.room.members || []).map(member => member.name));
1361
- const instance = unique_instance(name, taken);
1362
- const web_member = access === "online";
1363
- const record = prepare_member_request(room_id, `add:${name}:${access}`, { name: instance, access: web_member ? "manual" : access,
1364
- ...(web_member ? { manual_source: "web" } : {}), adapter: "", session_mode: "new" }, { project_access, session_id: session_id.trim() });
1365
- let result;
1366
- if (access === "runner") {
1367
- const draft = launch_options();
1368
- const participants = { ...draft.participants, [record.member.name]: { ...draft.participants?.[name],
1369
- ...draft.participants?.[record.member.name], project_access: record.options?.project_access || "read",
1370
- // An existing conversation joins with its own context; the runner resumes it instead of starting a new one.
1371
- ...(record.options?.session_id ? { session_id: record.options.session_id } : {}) } };
1372
- if (participants[record.member.name].adapter === "api") participants[record.member.name].project_access = "read";
1373
- result = await local_launch_request("room-member", { room_id, token, action: "add", name: record.member.name, access,
1374
- member_token: record.member.token, participants, cwd: draft.cwd });
1375
- } else result = await request_json(`/api/rooms/${room_id}/members`, record.member);
1376
- finish_member_request(room_id, token, record, result);
1377
- await send_descriptions(room_id, token, { [record.member.name]: description_draft(name) });
1378
- await send_labels(room_id, token, { [record.member.name]: label_drafts[name] });
1379
- delete description_drafts[name];
1380
- delete label_drafts[name];
1381
- if (state.room?.id === room_id) {
1382
- state.free_notice = web_member ? `${record.member.name} 已加入,阅读通知与真实回答由你或授权主持人转交。`
1383
- // A manual member's start step lives on the board row, the composer bar and the invite dialog; no extra notice.
1384
- : access === "manual" ? ""
1385
- : record.options?.session_id ? `${record.member.name} 已接入原会话;它工作时请不要在原窗口继续操作。`
1386
- : state.room.mode === "free" ? `${record.member.name} 已加入,之后点名它即会由本机服务唤醒。`
1387
- : `${record.member.name} 已加入讨论,主持人会重新安排发言。`;
1388
- await refresh_room();
1389
- }
1390
- } catch (error) { if (state.room?.id === room_id) state.free_error = error.message; }
1354
+ const token = localStorage.getItem(`discuss-access:${room_id}`);
1355
+ if (!token) throw new Error("此浏览器缺少房间控制凭据,请使用创建房间的浏览器");
1356
+ const taken = new Set((state.room.free?.members || state.room.members || []).map(member => member.name));
1357
+ const instance = unique_instance(name, taken);
1358
+ const web_member = access === "online";
1359
+ const record = prepare_member_request(room_id, `add:${name}:${access}`, { name: instance, access: web_member ? "manual" : access,
1360
+ ...(web_member ? { manual_source: "web" } : {}), adapter: "", session_mode: "new" }, { project_access, session_id: session_id.trim() });
1361
+ let result;
1362
+ if (access === "runner") {
1363
+ const draft = launch_options();
1364
+ const participants = { ...draft.participants, [record.member.name]: { ...draft.participants?.[name],
1365
+ ...draft.participants?.[record.member.name], project_access: record.options?.project_access || "read",
1366
+ // An existing conversation joins with its own context; the runner resumes it instead of starting a new one.
1367
+ ...(record.options?.session_id ? { session_id: record.options.session_id } : {}) } };
1368
+ if (participants[record.member.name].adapter === "api") participants[record.member.name].project_access = "read";
1369
+ result = await local_launch_request("room-member", { room_id, token, action: "add", name: record.member.name, access,
1370
+ member_token: record.member.token, participants, cwd: draft.cwd });
1371
+ } else result = await request_json(`/api/rooms/${room_id}/members`, record.member);
1372
+ finish_member_request(room_id, token, record, result);
1373
+ await send_descriptions(room_id, token, { [record.member.name]: description_draft(name) });
1374
+ await send_labels(room_id, token, { [record.member.name]: label_drafts[name] });
1375
+ delete description_drafts[name];
1376
+ delete label_drafts[name];
1377
+ if (state.room?.id === room_id) {
1378
+ const notice = web_member ? `${record.member.name} 已加入,阅读通知与真实回答由你或授权主持人转交。`
1379
+ // A manual member's start step lives on the board row, the composer bar and the invite dialog; no extra notice.
1380
+ : access === "manual" ? ""
1381
+ : record.options?.session_id ? `${record.member.name} 已接入原会话;它工作时请不要在原窗口继续操作。`
1382
+ : state.room.mode === "free" ? `${record.member.name} 已加入,之后点名它即会由本机服务唤醒。`
1383
+ : `${record.member.name} 已加入讨论,主持人会重新安排发言。`;
1384
+ if (notice) notify("info", notice);
1385
+ await refresh_room();
1386
+ }
1391
1387
  }
1392
1388
 
1393
1389
  /** Clear only the composer that produced the saved message. */
@@ -1415,10 +1411,8 @@ async function send_message(event) {
1415
1411
  event.preventDefault();
1416
1412
  if (!state.room || state.sending_message) return;
1417
1413
  const room_id = state.room.id, drafts = state.attachment_drafts;
1418
- state.composer_error = "";
1419
- let payload, saved = false;
1420
- try { payload = message_payload(); }
1421
- catch (error) { state.composer_error = error.message; render_attachment_drafts(); publish_view(); throw error; }
1414
+ const payload = message_payload();
1415
+ let saved = false;
1422
1416
  if (!payload.text && !drafts.length) return;
1423
1417
  const signature = JSON.stringify({ room: room_id, ...payload, files: drafts.map(file => file.id) });
1424
1418
  if (state.pending_submission?.signature !== signature) state.pending_submission = { signature, id: new_submission_id() };
@@ -1433,9 +1427,9 @@ async function send_message(event) {
1433
1427
  saved = true;
1434
1428
  await finish_message(result, room_id, drafts);
1435
1429
  } catch (error) {
1436
- if (state.room?.id === room_id) state.composer_error = saved
1430
+ if (state.room?.id === room_id) notify_failure(error, saved
1437
1431
  ? `留言已保存,但页面刷新失败:${error.message}`
1438
- : `${error.message};草稿已保留,可重试发送`;
1432
+ : `${error.message};草稿已保留,可重试发送`);
1439
1433
  throw error;
1440
1434
  } finally {
1441
1435
  state.sending_message = false;
@@ -1466,9 +1460,8 @@ function attachment_file_icon(file_name) {
1466
1460
  function add_attachments(files) {
1467
1461
  if (!state.room || state.sending_message) return;
1468
1462
  const selected = Array.from(files);
1469
- state.composer_error = "";
1470
- if (state.attachment_drafts.length + selected.length > MAX_MESSAGE_ATTACHMENTS) state.composer_error = "每条消息最多 10 个附件";
1471
- else if (selected.some(file => file.size > MAX_ATTACHMENT_BYTES)) state.composer_error = "单个附件最多 20 MiB";
1463
+ if (state.attachment_drafts.length + selected.length > MAX_MESSAGE_ATTACHMENTS) notify("warning", "每条消息最多 10 个附件");
1464
+ else if (selected.some(file => file.size > MAX_ATTACHMENT_BYTES)) notify("warning", "单个附件最多 20 MiB");
1472
1465
  else state.attachment_drafts.push(...selected.map(file => ({ id: new_submission_id(), file,
1473
1466
  preview_url: /^image\/(png|jpeg|gif|webp)$/.test(file.type) ? URL.createObjectURL(file) : "", uploaded: null, actor: null })));
1474
1467
  render_attachment_drafts();
@@ -1488,19 +1481,18 @@ function remove_attachment(id) {
1488
1481
  const file = state.attachment_drafts.find(item => item.id === id);
1489
1482
  if (file?.preview_url) URL.revokeObjectURL(file.preview_url);
1490
1483
  state.attachment_drafts = state.attachment_drafts.filter(item => item.id !== id);
1491
- state.composer_error = "";
1492
1484
  render_attachment_drafts();
1493
1485
  publish_view();
1494
1486
  }
1495
1487
 
1496
- /** Display shared file drafts and errors in the classic theme. */
1488
+ /** Display shared file drafts and upload progress in the classic theme. */
1497
1489
  function render_attachment_drafts() {
1498
1490
  const container = element("attachment-drafts");
1499
1491
  if (!container) return;
1500
1492
  container.innerHTML = state.attachment_drafts.map(item => `<div class="attachment-draft">${item.preview_url ? `<img src="${item.preview_url}" alt="">` : ""}
1501
1493
  <span>${escape_html(item.file.name)}<small>${attachment_size(item.file.size)}</small></span>
1502
1494
  <button type="button" data-remove-attachment="${item.id}" aria-label="移除 ${escape_html(item.file.name)}" ${state.sending_message ? "disabled" : ""}>×</button></div>`).join("");
1503
- element("attachment-status").textContent = state.composer_error || state.upload_status;
1495
+ element("attachment-status").textContent = state.upload_status;
1504
1496
  element("choose-attachments").disabled = state.sending_message;
1505
1497
  }
1506
1498
 
@@ -1780,7 +1772,7 @@ function access_snapshot() {
1780
1772
  name: member.name, access: is_web_member(member) ? "online" : member.access, available: member.status === "active" && !is_web_member(member)
1781
1773
  && Boolean(saved[member.name]?.token) && is_web_member(saved[member.name]) === is_web_member(member)
1782
1774
  && (!saved[member.name].id || saved[member.name].id === member.id),
1783
- })), notice: state.access_notice, error: state.access_error };
1775
+ })) };
1784
1776
  }
1785
1777
 
1786
1778
  /** Generate a separate random credential for each declared participant instance; `cli` members are woken locally. */
@@ -1835,27 +1827,20 @@ async function submit_room(event, folder_id = null) {
1835
1827
  const signature = signed(payload, launch_draft);
1836
1828
  const navigation = state.navigation_version;
1837
1829
  if (!launching) element("room-dialog").close();
1838
- state.launch.error = "";
1839
- let room;
1840
- try {
1841
- const pending = prepare_room_request(signature);
1842
- const token = pending.token;
1843
- pending.members ||= creation_members(payload.experts);
1844
- // The website tracks capabilities (cli/online); a manual expert is a local reader and writer whose delivery lives in its member record.
1845
- const experts = Object.fromEntries(Object.entries(payload.experts || {}).map(([name, mode]) => [name, mode === "manual" ? "cli" : mode]));
1846
- const input = { ...(launch_draft || {}), room: { ...payload, ...(payload.experts ? { experts } : {}), members: pending.members, submission_id: pending.id }, token };
1847
- room = await update_directory({ key: "create-room", label: `「${payload.title}」创建`,
1830
+ const pending = prepare_room_request(signature);
1831
+ const token = pending.token;
1832
+ pending.members ||= creation_members(payload.experts);
1833
+ // The website tracks capabilities (cli/online); a manual expert is a local reader and writer whose delivery lives in its member record.
1834
+ const experts = Object.fromEntries(Object.entries(payload.experts || {}).map(([name, mode]) => [name, mode === "manual" ? "cli" : mode]));
1835
+ const input = { ...(launch_draft || {}), room: { ...payload, ...(payload.experts ? { experts } : {}), members: pending.members, submission_id: pending.id }, token };
1836
+ const room = await update_directory({ key: "create-room", label: `「${payload.title}」创建`,
1848
1837
  error_message: launching ? error => `「${payload.title}」${error.message}` : null,
1849
1838
  apply: () => { state.creating_room = payload; }, rollback: () => { state.creating_room = null; },
1850
1839
  save: async () => (await submit_local_launch(input, payload.title, !launching, signature)).room,
1851
1840
  commit: saved => {
1852
1841
  state.creating_room = null;
1853
1842
  if (!state.rooms.some(existing => existing.id === saved.id)) state.rooms.push(saved);
1854
- } }); }
1855
- catch (error) {
1856
- if (launching) { state.launch.error = error.message; render_launch_settings(); publish_view(); }
1857
- throw error;
1858
- }
1843
+ } });
1859
1844
  state.pending_room = null;
1860
1845
  state.create_parent = null;
1861
1846
  if (discussion) await open_discussion_after_creation(room.id, discussion);
@@ -1878,7 +1863,7 @@ async function open_discussion_after_creation(room_id, { goal, named_followup, l
1878
1863
  await request_json(`${path}/discussion`, { action: "start", revision: current.revision,
1879
1864
  ...(goal ? { goal } : {}), limits, named_followup });
1880
1865
  } catch (error) {
1881
- show_toast(`房间已创建,但开启讨论失败:${error.message};可在右侧成员栏重试`);
1866
+ notify("warning", `房间已创建,但开启讨论失败:${error.message};可在右侧成员栏重试`);
1882
1867
  }
1883
1868
  }
1884
1869
 
@@ -2001,7 +1986,6 @@ async function resume_launch(id) {
2001
1986
  const record = stored_launches().find(item => item.id === id);
2002
1987
  if (!record || state.directory_pending.has(`launch-${id}`)) return;
2003
1988
  state.directory_pending.set(`launch-${id}`, true);
2004
- state.launch.error = "";
2005
1989
  publish_view();
2006
1990
  try {
2007
1991
  const result = await submit_local_launch(record.input, record.title, record.direct, record.signature);
@@ -2014,11 +1998,10 @@ async function resume_launch(id) {
2014
1998
  element("room-dialog").close();
2015
1999
  }
2016
2000
  if (!record.direct) clear_launch_overrides(record.input);
2017
- state.launch.notice = result.status === "running" ? "已核对原启动,实际执行请查看进度回执" : "已核对原房间状态;不会自动恢复已暂停或结束的讨论";
2018
- state.directory_error = "";
2001
+ notify("info", result.status === "running" ? "已核对原启动,实际执行请查看进度回执" : "已核对原房间状态;不会自动恢复已暂停或结束的讨论");
2019
2002
  if (state.pending_room?.id === id) state.pending_room = null;
2020
2003
  await select_room(result.room.id);
2021
- } catch (error) { state.launch.error = error.message; }
2004
+ } catch (error) { notify("error", error.message); }
2022
2005
  finally { state.directory_pending.delete(`launch-${id}`); render_launch_settings(); publish_view(); }
2023
2006
  }
2024
2007
 
@@ -2026,7 +2009,6 @@ async function resume_launch(id) {
2026
2009
  function discard_launch(id) {
2027
2010
  localStorage.removeItem(LAUNCH_PENDING_PREFIX + id);
2028
2011
  if (state.pending_room?.id === id) state.pending_room = null;
2029
- state.launch.error = "";
2030
2012
  refresh_launch_recoveries();
2031
2013
  }
2032
2014
 
@@ -2327,7 +2309,6 @@ function write_launch_field(name, field, value) {
2327
2309
  if (field === "base_url") delete state.launch.profiles[name].api_key_ref;
2328
2310
  fill_api_key(state.launch.profiles[name]);
2329
2311
  }
2330
- state.launch.notice = "";
2331
2312
  if (field === "adapter") render_launch_settings();
2332
2313
  else render_launch_summaries();
2333
2314
  publish_view();
@@ -2499,8 +2480,7 @@ async function pick_launch_directory() {
2499
2480
  try {
2500
2481
  const path = await browse_local_directory();
2501
2482
  if (path) write_launch_field("workspace", "cwd", path);
2502
- state.launch.error = "";
2503
- } catch (error) { state.launch.error = error.message; }
2483
+ } catch (error) { notify("error", error.message); }
2504
2484
  publish_view();
2505
2485
  }
2506
2486
 
@@ -2521,9 +2501,8 @@ async function save_launch_defaults() {
2521
2501
  try {
2522
2502
  await local_launch_request("settings", { participants });
2523
2503
  state.launch.saved_profiles = { ...state.launch.saved_profiles, ...JSON.parse(JSON.stringify(participants)) };
2524
- state.launch.notice = "已保存本机软件配置,后续讨论可复用";
2525
- state.launch.error = "";
2526
- } catch (error) { state.launch.error = error.message; }
2504
+ notify("info", "已保存本机软件配置,后续讨论可复用");
2505
+ } catch (error) { notify("error", error.message); }
2527
2506
  render_launch_settings();
2528
2507
  publish_view();
2529
2508
  }
@@ -2538,10 +2517,9 @@ async function launch_current_room() {
2538
2517
  const draft = launch_options();
2539
2518
  const result = await submit_local_launch({ ...draft, room_id, token }, state.room.title || "已有讨论");
2540
2519
  clear_launch_overrides(draft);
2541
- state.launch.notice = result.status === "running" ? "启动已提交,实际执行请查看进度回执" : "本次启动已有记录,请查看房间状态";
2542
- state.launch.error = "";
2520
+ notify("info", result.status === "running" ? "启动已提交,实际执行请查看进度回执" : "本次启动已有记录,请查看房间状态");
2543
2521
  if (state.room?.id === room_id) await refresh_room();
2544
- } catch (error) { state.launch.error = error.message; }
2522
+ } catch (error) { notify("error", error.message); }
2545
2523
  render_launch_settings();
2546
2524
  publish_view();
2547
2525
  }
@@ -2549,13 +2527,12 @@ async function launch_current_room() {
2549
2527
  /** Load the selected paused room's original connection paths after proving browser ownership. */
2550
2528
  async function load_room_configuration() {
2551
2529
  const room_id = state.room.id;
2552
- state.launch.repair_error = '';
2553
2530
  try {
2554
2531
  const token = localStorage.getItem(`discuss-access:${room_id}`);
2555
2532
  if (!token) throw new Error('此浏览器缺少房间凭据,请使用原浏览器或原接入配置');
2556
2533
  const result = await local_launch_request('room-config', { room_id, token });
2557
- if (state.room?.id === room_id) state.launch.repair = { ...result, notice: '' };
2558
- } catch (error) { if (state.room?.id === room_id) state.launch.repair_error = error.message; }
2534
+ if (state.room?.id === room_id) state.launch.repair = result;
2535
+ } catch (error) { if (state.room?.id === room_id) notify("error", error.message); }
2559
2536
  render_room_configuration();
2560
2537
  publish_view();
2561
2538
  }
@@ -2566,7 +2543,6 @@ function write_room_connection(name, value) {
2566
2543
  if (repair?.room_id !== state.room?.id || !repair.participants[name]) return;
2567
2544
  const profile = repair.participants[name];
2568
2545
  profile[profile.adapter === 'antigravity' ? 'ipc_directory' : 'executable'] = value;
2569
- repair.notice = '';
2570
2546
  publish_view();
2571
2547
  }
2572
2548
 
@@ -2577,7 +2553,6 @@ function write_room_adapter(name, value) {
2577
2553
  if (repair?.room_id !== state.room?.id || !['codex', 'codex_cli'].includes(profile?.adapter)
2578
2554
  || !['codex', 'codex_cli'].includes(value)) return;
2579
2555
  profile.adapter = value;
2580
- repair.notice = '';
2581
2556
  publish_view();
2582
2557
  }
2583
2558
 
@@ -2589,18 +2564,18 @@ async function save_room_configuration() {
2589
2564
  const field = profile.adapter === 'antigravity' ? 'ipc_directory' : 'executable';
2590
2565
  return [name, { [field]: profile[field], ...(['codex', 'codex_cli'].includes(profile.adapter) ? { adapter: profile.adapter } : {}) }];
2591
2566
  }));
2592
- state.launch.repair_error = '';
2593
2567
  try {
2594
2568
  const token = localStorage.getItem(`discuss-access:${repair.room_id}`);
2595
2569
  if (!token) throw new Error('此浏览器缺少房间凭据,请重新核对');
2596
2570
  const result = await local_launch_request('room-config', { room_id: repair.room_id, token,
2597
2571
  version: repair.version, runtime_revision: repair.runtime_revision, participants });
2598
2572
  if (state.room?.id === repair.room_id) {
2599
- state.launch.repair = { ...result, notice: result.applied ? '本场接入设置已保存,服务已采用;可在进度看板重启并核对实际发言。'
2600
- : '本场接入设置已保存;服务尚未确认采用,请先核对本机服务再重启。' };
2573
+ state.launch.repair = result;
2574
+ notify('info', result.applied ? '本场接入设置已保存,服务已采用;可在进度看板重启并核对实际发言。'
2575
+ : '本场接入设置已保存;服务尚未确认采用,请先核对本机服务再重启。');
2601
2576
  await refresh_room();
2602
2577
  }
2603
- } catch (error) { if (state.room?.id === repair.room_id) state.launch.repair_error = error.message; }
2578
+ } catch (error) { if (state.room?.id === repair.room_id) notify("error", error.message); }
2604
2579
  render_room_configuration();
2605
2580
  publish_view();
2606
2581
  }
@@ -2623,7 +2598,6 @@ function render_room_configuration() {
2623
2598
  fields.dataset.roomId = state.room?.id || '';
2624
2599
  }
2625
2600
  element('local-room-configuration-save').hidden = !repair;
2626
- element('local-room-configuration-notice').textContent = state.launch.repair_error || repair?.notice || '';
2627
2601
  }
2628
2602
 
2629
2603
  /** Render the same local setup controls in the classic theme. */
@@ -2661,7 +2635,7 @@ function render_launch_settings() {
2661
2635
  + `<label>本次工作目录(可选)<input data-launch-name="workspace" data-launch-field="cwd" value="${escape_html(state.launch.workspace.cwd || "")}"></label><label>本次 AI 项目(可选)<input data-launch-name="workspace" data-launch-field="discussion_project" value="${escape_html(state.launch.workspace.discussion_project || "")}"></label>`;
2662
2636
  }
2663
2637
  for (const id of ["local-launch-notice", "new-local-launch-notice"]) {
2664
- if (element(id)) element(id).textContent = [state.launch.error || state.launch.notice,
2638
+ if (element(id)) element(id).textContent = [state.launch.error,
2665
2639
  !state.launch.available && state.launch.connect_command ? `在这台电脑运行 ${state.launch.connect_command},使用命令打开的网页完成连接。` : ""].filter(Boolean).join(" ");
2666
2640
  }
2667
2641
  for (const id of ["local-launch-save", "new-local-launch-save"]) if (element(id)) element(id).disabled = !state.launch.available;
@@ -2834,8 +2808,7 @@ function download_member_access(name) {
2834
2808
  const member = access.members.find(item => item.name === name);
2835
2809
  if (!access.can_manage || !member?.available || member.access === "online") throw new Error("当前浏览器没有此成员可下载的私有配置");
2836
2810
  download_private_json({ site: location.origin, room: room.id, token: member_credentials(room.id)[name].token }, `member-${room.id}-${name}.json`);
2837
- state.access_error = "";
2838
- flash_access_notice(`已下载 ${name} 的私有配置`);
2811
+ notify("info", `已下载 ${name} 的私有配置`);
2839
2812
  }
2840
2813
 
2841
2814
  /** Copy the same room-scoped credential as one line of text for an agent that cannot take a file. */
@@ -2844,19 +2817,7 @@ async function copy_member_access_text(name) {
2844
2817
  const member = access.members.find(item => item.name === name);
2845
2818
  if (!access.can_manage || !member?.available || member.access === "online") throw new Error("当前浏览器没有此成员可复制的凭证");
2846
2819
  await copy_text(JSON.stringify({ site: location.origin, room: room.id, token: member_credentials(room.id)[name].token }));
2847
- flash_access_notice(`已复制 ${name} 的凭证文本(含 token)`);
2848
- }
2849
-
2850
- /** Confirm a copy or download briefly in the member rail; the note clears itself. */
2851
- function flash_access_notice(message) {
2852
- state.access_error = "";
2853
- state.access_notice = message;
2854
- clearTimeout(state.access_notice_timer);
2855
- state.access_notice_timer = setTimeout(() => {
2856
- if (state.access_notice !== message) return;
2857
- state.access_notice = "";
2858
- publish_view();
2859
- }, TOAST_DURATION_MS);
2820
+ notify("info", `已复制 ${name} 的凭证文本(含 token)`);
2860
2821
  }
2861
2822
 
2862
2823
  /** Copy a member-specific entry instruction; only the launch variant carries the credential, never conversation context. */
@@ -2879,7 +2840,7 @@ async function copy_member_join(name, with_credential = false) {
2879
2840
  ${instruction}
2880
2841
  房间入口:${address}
2881
2842
  先核对最新阶段、任务和原文编号,再按房间协议参与。${credential}`);
2882
- if (state.room?.id === room.id) flash_access_notice(with_credential ? `已复制 ${name} 的启动指令(含 token)` : `已复制 ${name} 的参与说明`);
2843
+ if (state.room?.id === room.id) notify("info", with_credential ? `已复制 ${name} 的启动指令(含 token)` : `已复制 ${name} 的参与说明`);
2883
2844
  }
2884
2845
 
2885
2846
  /** How a manual member learns it is its turn: free rooms are pulled by the member, discussions only on the host's assignment. */
@@ -3119,16 +3080,16 @@ async function fork_room(message_id) {
3119
3080
  const registered = new Set((local?.members || []).filter(member => member.status !== "failed").map(member => member.name));
3120
3081
  for (const member of (state.room.free?.members || []).filter(item => item.status === "away")) {
3121
3082
  if (member.access === "runner" && !registered.has(member.name)) continue;
3122
- await change_member({ member_id: member.id, name: member.name, action: "rejoin" });
3123
- if (state.free_error) problems.push(`${member.name} 未能请回:${state.free_error}`);
3083
+ try { await change_member({ member_id: member.id, name: member.name, action: "rejoin" }); }
3084
+ catch (error) { problems.push(`${member.name} 未能请回:${error.message}`); }
3124
3085
  }
3125
3086
  const fresh = (local?.members || []).filter(member => member.status === "fresh").map(member => member.name);
3126
3087
  const failed = (local?.members || []).filter(member => member.status === "failed").map(member => `${member.name}(${member.error})`);
3127
3088
  if (failed.length) problems.push(`未分叉、保持离开:${failed.join("、")}`);
3128
3089
  const done = `已从 #${message_id} 分支出新房间,原房间不变`;
3129
- // The rail shows an error in place of a notice, so a partial fork says both in one line.
3130
- state.free_error = problems.length ? `${done},但${problems.join(";")}` : "";
3131
- state.free_notice = `${done}${fresh.length ? `;${fresh.join("、")} 将以新会话开始` : ""}。`;
3090
+ // A partial fork says both the result and the problems in one warning instead of a separate notice.
3091
+ if (problems.length) notify("warning", `${done},但${problems.join(";")}`);
3092
+ else notify("info", `${done}${fresh.length ? `;${fresh.join("、")} 将以新会话开始` : ""}。`);
3132
3093
  publish_view();
3133
3094
  }
3134
3095
 
@@ -3614,7 +3575,11 @@ async function flush_live_refresh() {
3614
3575
  const full = live_updates.full;
3615
3576
  live_updates.full = false;
3616
3577
  live_updates.running = true;
3617
- try { await refresh_room({ room: full }); } catch { set_connection(false); }
3578
+ try { await refresh_room({ room: full }); } catch (error) {
3579
+ console.warn("refresh failed", error);
3580
+ if (state.reconnecting) show_toast(`仍未连接:${error.message}`, true);
3581
+ set_connection(false);
3582
+ }
3618
3583
  finally {
3619
3584
  live_updates.running = false;
3620
3585
  if (live_updates.dirty) queue_live_refresh();
@@ -3628,7 +3593,7 @@ function arm_update_poll() {
3628
3593
  live_updates.poll_timer = setTimeout(() => {
3629
3594
  poll_live_updates();
3630
3595
  arm_update_poll();
3631
- }, live_updates.connected ? REFRESH_INTERVAL_MS : FALLBACK_REFRESH_INTERVAL_MS);
3596
+ }, live_updates.connected && state.connected ? REFRESH_INTERVAL_MS : FALLBACK_REFRESH_INTERVAL_MS);
3632
3597
  }
3633
3598
 
3634
3599
  /**
@@ -3637,9 +3602,10 @@ function arm_update_poll() {
3637
3602
  * A site without revisions is read in full as before.
3638
3603
  */
3639
3604
  async function poll_live_updates() {
3605
+ if (!live_updates.source && typeof EventSource !== "undefined") return reconnect_live_updates();
3640
3606
  const room_id = state.room?.id || "";
3641
3607
  const working = (state.room?.free?.members || []).some((member) => WORKING_STATUSES.includes(member.execution?.status));
3642
- if (working || Date.now() - live_updates.refreshed_at >= SAFETY_REFRESH_MS) return queue_live_refresh();
3608
+ if (!state.connected || working || Date.now() - live_updates.refreshed_at >= SAFETY_REFRESH_MS) return queue_live_refresh();
3643
3609
  let revisions;
3644
3610
  try { revisions = await request_json(`/api/revisions?rooms=${encodeURIComponent(room_id)}`); }
3645
3611
  catch { return queue_live_refresh(); }
@@ -3669,6 +3635,8 @@ function start_live_updates() {
3669
3635
  if (live_updates.source !== source) return;
3670
3636
  const was_connected = live_updates.connected;
3671
3637
  live_updates.connected = false;
3638
+ // A closed stream is never retried by the browser; the next poll opens a new one.
3639
+ if (source.readyState === EventSource.CLOSED) live_updates.source = null;
3672
3640
  if (was_connected) arm_update_poll();
3673
3641
  });
3674
3642
  }
@@ -3676,6 +3644,17 @@ function start_live_updates() {
3676
3644
  queue_live_refresh();
3677
3645
  }
3678
3646
 
3647
+ /** Reopen the update stream and read the site now; used by the footer retry and when the stream was closed. */
3648
+ async function reconnect_live_updates() {
3649
+ if (document.hidden) return;
3650
+ state.reconnecting = true;
3651
+ publish_view();
3652
+ live_updates.source?.close();
3653
+ live_updates.source = null;
3654
+ live_updates.connected = false;
3655
+ start_live_updates();
3656
+ }
3657
+
3679
3658
  /** Release streams and scheduled reads while hidden or leaving the page. */
3680
3659
  function stop_live_updates() {
3681
3660
  live_updates.enabled = false;
@@ -3749,13 +3728,13 @@ function view_snapshot() {
3749
3728
  const experts = Object.fromEntries(["new-expert-modes", "topic-expert-modes"].map((id) =>
3750
3729
  [id, element(id) ? read_expert_inputs(id) : {}]));
3751
3730
  return { rooms: ordered_rooms(), folders: state.folders, folder_groups: ordered_folders(), room: state.room, archive_view: state.archive_view, directory_sort: state.directory_sort, messages: state.messages, total: state.message_total,
3752
- archive_pending: [...state.archive_changes.keys()], directory_error: state.directory_error, directory_notice: state.directory_notice, import_progress: structuredClone(state.import_progress),
3731
+ archive_pending: [...state.archive_changes.keys()], import_progress: structuredClone(state.import_progress),
3753
3732
  directory_pending: [...state.directory_pending.keys()], folder_pending: [...state.folder_moves.keys()], creating_room: state.creating_room,
3754
- connected: state.connected, backup_pending: Boolean(state.backup_pending), page: state.page, pages: state.pages, query: state.query,
3733
+ connected: state.connected, reconnecting: state.reconnecting, backup_pending: Boolean(state.backup_pending), page: state.page, pages: state.pages, query: state.query,
3755
3734
  attachment_drafts: state.attachment_drafts.map(item => ({ id: item.id, file_name: item.file.name, size: item.file.size, preview_url: item.preview_url })),
3756
- composer_error: state.composer_error, upload_status: state.upload_status, sending_message: state.sending_message,
3735
+ upload_status: state.upload_status, sending_message: state.sending_message,
3757
3736
  mention_only: state.mention_only, reply_to: state.reply_to, turn_reply_to: state.turn_reply_to,
3758
- free_notice: state.free_notice, free_error: state.free_error, free_proxy: state.free_proxy, order_error: state.order_error,
3737
+ free_proxy: state.free_proxy,
3759
3738
  create_parent: state.create_parent, access: access_snapshot(), admin: admin_snapshot(),
3760
3739
  tab: state.tab, snapshot_id: state.snapshot_id, context_dirty: state.context_dirty,
3761
3740
  theme: document.documentElement.dataset.theme, fields, experts, launch: structuredClone(state.launch), named_followup: state.named_followup, create_discussion: state.create_discussion, create_chain_rounds: state.create_chain_rounds };
@@ -3851,7 +3830,6 @@ async function backup_room(room_id = state.room?.id) {
3851
3830
  async function backup_rooms({ ids, name }) {
3852
3831
  if (!ids.length || state.backup_pending) return;
3853
3832
  state.backup_pending = true;
3854
- state.directory_notice = state.directory_error = "";
3855
3833
  publish_view();
3856
3834
  try {
3857
3835
  const snapshots = [];
@@ -3861,9 +3839,9 @@ async function backup_rooms({ ids, name }) {
3861
3839
  snapshots.push({ ...snapshot, source_url: `${location.origin}/r/${encodeURIComponent(id)}` });
3862
3840
  }
3863
3841
  await window.discuss_backup.download_rooms_backup(snapshots, name);
3864
- state.directory_notice = `已生成 ${snapshots.length} 个频道的备份,请查看浏览器下载。`;
3842
+ notify("info", `已生成 ${snapshots.length} 个频道的备份,请查看浏览器下载。`);
3865
3843
  } catch (error) {
3866
- state.directory_error = `导出失败,未生成备份:${error.message}`;
3844
+ notify("error", `导出失败,未生成备份:${error.message}`);
3867
3845
  } finally {
3868
3846
  state.backup_pending = false;
3869
3847
  publish_view();
@@ -3975,14 +3953,11 @@ async function execute_view_action(name, value) {
3975
3953
  prepare_brainstorm, return_conclusion, adopt_recommendation, rate_decision,
3976
3954
  busy: () => { throw new Error("上一个操作仍在处理中,请稍候;长时间无响应请刷新页面"); },
3977
3955
  copy_reading: () => copy_text(`${location.origin}/r/${state.room.id}`), copy: copy_text };
3978
- if (!["refresh", "busy"].includes(name)) state.free_error = "";
3979
3956
  try {
3980
3957
  await actions[name](value);
3981
3958
  return true;
3982
3959
  } catch (error) {
3983
- if (["download_member_access", "copy_member_access_text", "copy_member_join", "copy_member_launch"].includes(name)) state.access_error = error.message;
3984
- else if (!state.free_error) state.free_error = error.message;
3985
- show_toast(error.message, true);
3960
+ if (!error.notified) notify("error", error.message);
3986
3961
  return false;
3987
3962
  } finally { publish_view(); }
3988
3963
  }
@@ -3993,10 +3968,10 @@ function change_view_theme(theme) {
3993
3968
  try { localStorage.setItem(THEME_STORAGE_KEY, document.documentElement.dataset.theme); } catch { /* Keep the session preference. */ }
3994
3969
  }
3995
3970
 
3996
- window.discuss_view = { snapshot: view_snapshot, write_field: write_view_field,
3971
+ window.discuss_view = { snapshot: view_snapshot, write_field: write_view_field, notify,
3997
3972
  write_launch: write_launch_field, default_launch_entry, description_template, save_description_template, description_draft, write_description_draft, describe_member, set_chain_rounds, continue_chain, expert_presets, apply_expert_preset, unique_instance, member_label, preset_identity, save_expert_preset, delete_expert_preset, write_room_connection, write_room_adapter, launch_clients: LAUNCH_CLIENTS,
3998
3973
  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, write_create_chain_rounds, check_launch_api, launch_summary, launch_can_set_access, pick_launch_directory, browse_local_directory, save_launch_projects, restore_launch_profile, reset_create_form, empty_discussion_message,
3999
- start_drag: start_directory_drag,
3974
+ start_drag: start_directory_drag, reconnect: reconnect_live_updates,
4000
3975
  attachment_markup, attachment_size, attachment_file_icon, drop_attachments, paste_attachments,
4001
3976
  write_expert: write_view_expert, add_instance: add_view_instance, add_preset: add_preset_instance, platform_of, execute: execute_view_action, theme: change_view_theme,
4002
3977
  admin: { register: register_admin, login: login_admin, logout: logout_admin,