ai_co_discuss 0.3.8 → 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,12 +53,12 @@ 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 = [];
@@ -66,21 +66,18 @@ state.message_history = null;
66
66
  state.connected = false;
67
67
  state.reconnecting = false;
68
68
  state.attachment_drafts = [];
69
- state.composer_error = "";
70
69
  state.upload_status = "";
71
70
  state.sending_message = false;
72
71
  state.attachment_preview = null;
73
72
  state.archive_changes = new Map();
74
73
  state.archive_version = 0;
75
- state.directory_error = "";
76
- state.directory_notice = "";
77
74
  // The open import's progress for the import dialog: { done, rooms: [{ title, status, members, error }], errors }.
78
75
  state.import_progress = null;
79
76
  state.directory_pending = new Map();
80
77
  state.directory_version = 0;
81
78
  state.navigation_version = 0;
82
79
  state.admin = { loaded: false, local: false, registered: false, setup_available: false, logged_in: false, profile: null, identity_version: 0 };
83
- 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: [] };
84
81
  const PLATFORM_CLIENTS = { Codex: [{ value: "codex_cli", label: "Codex CLI" }, { value: "codex", label: "Codex App" }],
85
82
  Claude: [{ value: "claude", label: "Claude Code CLI" }],
86
83
  Gemini: [{ value: "antigravity_cli", label: "Antigravity CLI" }, { value: "antigravity", label: "Antigravity App" }] };
@@ -137,7 +134,8 @@ function format_time(value) {
137
134
 
138
135
  /** Display operation feedback only in the classic renderer. */
139
136
  function show_toast(message, is_error = false) {
140
- 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; }
141
139
  clearTimeout(state.toast_timer);
142
140
  element("toast").textContent = message;
143
141
  element("toast").classList.toggle("error", is_error);
@@ -145,6 +143,17 @@ function show_toast(message, is_error = false) {
145
143
  state.toast_timer = setTimeout(() => { element("toast").hidden = true; }, TOAST_DURATION_MS);
146
144
  }
147
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
+
148
157
  /** The administrator session token kept by this browser, or an empty string. */
149
158
  function admin_token() {
150
159
  if (state.admin.local) return "";
@@ -437,7 +446,6 @@ async function update_directory(change) {
437
446
  if (state.directory_pending.has(change.key)) return;
438
447
  state.directory_pending.set(change.key, change);
439
448
  state.directory_version++;
440
- state.directory_error = "";
441
449
  change.apply();
442
450
  render_rooms();
443
451
  publish_view();
@@ -447,7 +455,7 @@ async function update_directory(change) {
447
455
  return saved;
448
456
  } catch (error) {
449
457
  change.rollback();
450
- state.directory_error = change.error_message?.(error) || `${change.label}失败,已恢复原状态:${error.message}`;
458
+ notify_failure(error, change.error_message?.(error) || `${change.label}失败,已恢复原状态:${error.message}`);
451
459
  throw error;
452
460
  } finally {
453
461
  state.directory_pending.delete(change.key);
@@ -540,13 +548,12 @@ async function archive_channel(id, archived) {
540
548
  const change = { archived, phase: archived && room.phase && room.phase !== "concluded" ? "paused" : room.phase };
541
549
  state.archive_changes.set(id, change);
542
550
  state.archive_version++;
543
- state.directory_error = "";
544
551
  display_channel_archive(id, change);
545
552
  try {
546
553
  const saved = await request_json(`/api/rooms/${id}/${archived ? "archive" : "restore"}`, { actor: "用户" });
547
554
  display_channel_archive(id, { archived: Boolean(saved.archived), phase: saved.phase }, change);
548
555
  } catch (error) {
549
- state.directory_error = `「${room.title}」${archived ? "存档" : "恢复"}失败,已恢复原状态:${management_error_message(id, error)}`;
556
+ notify_failure(error, `「${room.title}」${archived ? "存档" : "恢复"}失败,已恢复原状态:${management_error_message(id, error)}`);
550
557
  display_channel_archive(id, previous, change);
551
558
  throw error;
552
559
  } finally {
@@ -582,7 +589,6 @@ async function assign_channel_folder(id, folder_id) {
582
589
  const previous_folder = room.folder_id ?? null;
583
590
  if (state.folders.some(folder => folder.id === folder_id && folder.pending)) return;
584
591
  if (folder_id && state.directory_pending.get("folder-order")?.removed_folder === folder_id) return;
585
- state.directory_error = "";
586
592
  state.folder_moves.set(id, folder_id);
587
593
  state.folder_version++;
588
594
  display_channel_folder(id, folder_id);
@@ -591,7 +597,7 @@ async function assign_channel_folder(id, folder_id) {
591
597
  display_channel_folder(id, saved.folder_id ?? null);
592
598
  show_toast("话题归类已更新");
593
599
  } catch (error) {
594
- state.directory_error = `「${room.title}」归类失败,已恢复原状态:${error.message}`;
600
+ notify_failure(error, `「${room.title}」归类失败,已恢复原状态:${error.message}`);
595
601
  display_channel_folder(id, previous_folder);
596
602
  throw error;
597
603
  } finally {
@@ -888,7 +894,6 @@ async function refresh_room({ room: read_room = true } = {}) {
888
894
 
889
895
  /** Select a room and reset room-specific draft and filter state. */
890
896
  async function select_room(room_id) {
891
- state.access_error = ""; state.access_notice = "";
892
897
  state.navigation_version++;
893
898
  state.refresh_version++;
894
899
  state.room = state.rooms.find((room) => room.id === room_id);
@@ -900,9 +905,7 @@ async function select_room(room_id) {
900
905
  state.pending_turn = null;
901
906
  state.pending_submission = null;
902
907
  clear_attachment_drafts();
903
- state.composer_error = "";
904
908
  state.launch.repair = null;
905
- state.launch.repair_error = '';
906
909
  state.launch.room_models = null;
907
910
  state.messages = [];
908
911
  state.message_history = null;
@@ -1027,7 +1030,7 @@ function set_proxy(name) { state.free_proxy = state.free_proxy === name ? "" : n
1027
1030
  async function copy_online_notice(name) {
1028
1031
  const ids = (state.room.free?.requests || []).filter(item => item.member === name && item.status === "pending").map(item => `#${item.message_id}`);
1029
1032
  await copy_text(`请阅读 ${location.origin}/r/${state.room.id}?participant=${name} ,回应 ${ids.join("、") || "最新消息"}。单条原文:${location.origin}/r/${state.room.id}/messages/编号。请在你自己的窗口回答,不要操作网页或调用写入接口。`);
1030
- state.free_notice = `已复制给 ${name} 的阅读通知;复制不代表已送达。拿到回答后,在输入框选择“代发 ${name}”粘贴原文。`;
1033
+ notify("info", `已复制给 ${name} 的阅读通知;复制不代表已送达。拿到回答后,在输入框选择“代发 ${name}”粘贴原文。`);
1031
1034
  }
1032
1035
 
1033
1036
  /** Start every new form from defaults: room use, delivery, text, roster, host, limits and software overrides. */
@@ -1045,7 +1048,6 @@ function reset_create_form() {
1045
1048
  state.create_chain_rounds = DEFAULT_CHAIN_ROUNDS;
1046
1049
  state.launch.profiles = JSON.parse(JSON.stringify(state.launch.saved_profiles || {}));
1047
1050
  Object.values(state.launch.profiles).forEach(fill_api_key);
1048
- state.launch.notice = "";
1049
1051
  render_launch_settings();
1050
1052
  publish_view();
1051
1053
  }
@@ -1073,7 +1075,7 @@ async function return_conclusion() {
1073
1075
  const summary = room.host_brief?.text || room.state_text || "(主持人尚未整理结论,请查看原文)";
1074
1076
  await request_json(`/api/rooms/${parent.id}/messages`, { author: "用户", text: `头脑风暴「${room.title}」已收束。\n\n${summary}\n\n原文:${location.origin}/r/${room.id}`,
1075
1077
  kind: "chat", submitted_by: "用户", submission_id: new_submission_id(), to: [] });
1076
- state.free_notice = `结论已发回「${parent.title}」`;
1078
+ notify("info", `结论已发回「${parent.title}」`);
1077
1079
  await select_room(parent.id);
1078
1080
  }
1079
1081
 
@@ -1239,7 +1241,7 @@ async function send_descriptions(room_id, token, descriptions) {
1239
1241
  const filled = Object.fromEntries(Object.entries(descriptions).filter(([, text]) => text?.trim()));
1240
1242
  if (!room_id || !token || !Object.keys(filled).length) return;
1241
1243
  try { await request_json(`/api/rooms/${room_id}/descriptions`, { descriptions: filled }, token); }
1242
- catch (error) { state.free_notice = `成员描述未保存:${error.message},可在发言顺序的成员菜单里重新填写。`; }
1244
+ catch (error) { notify("warning", `成员描述未保存:${error.message},可在发言顺序的成员菜单里重新填写。`); }
1243
1245
  }
1244
1246
 
1245
1247
  /** Save display labels by member name with the room credential; a failure leaves the members under their names. */
@@ -1247,50 +1249,47 @@ async function send_labels(room_id, token, labels) {
1247
1249
  const filled = Object.fromEntries(Object.entries(labels).filter(([, text]) => text?.trim()));
1248
1250
  if (!room_id || !token || !Object.keys(filled).length) return;
1249
1251
  try { await request_json(`/api/rooms/${room_id}/labels`, { labels: filled }, token); }
1250
- catch (error) { state.free_notice = `成员显示名未保存:${error.message},房间里按成员名显示。`; }
1252
+ catch (error) { notify("warning", `成员显示名未保存:${error.message},房间里按成员名显示。`); }
1251
1253
  }
1252
1254
 
1253
1255
  /** Save the new room's round limit with the room credential; a failure leaves the room on the default. */
1254
1256
  async function send_chain_rounds(room_id, token, max_rounds) {
1255
1257
  if (!room_id || !token || max_rounds === DEFAULT_CHAIN_ROUNDS) return;
1256
1258
  try { await request_json(`/api/rooms/${room_id}/budget`, { max_rounds }, token); }
1257
- catch (error) { state.free_notice = `推进轮数未保存:${error.message},房间沿用默认 ${DEFAULT_CHAIN_ROUNDS} 轮,可在房间页修改。`; }
1259
+ catch (error) { notify("warning", `推进轮数未保存:${error.message},房间沿用默认 ${DEFAULT_CHAIN_ROUNDS} 轮,可在房间页修改。`); }
1258
1260
  }
1259
1261
 
1260
1262
  /** Edit one member's description inside the room; the next decision by the scheduler or host uses it. */
1261
1263
  async function describe_member(name, text) {
1262
1264
  const room_id = state.room.id;
1263
- state.free_error = "";
1264
1265
  try {
1265
1266
  const token = localStorage.getItem(`discuss-access:${room_id}`);
1266
1267
  if (!token) throw new Error("此浏览器缺少房间控制凭据,请使用创建房间的浏览器");
1267
1268
  await request_json(`/api/rooms/${room_id}/descriptions`, { descriptions: { [name]: text } }, token);
1268
1269
  if (state.room?.id === room_id) await refresh_room();
1269
- } catch (error) { state.free_error = error.message; throw error; }
1270
+ } catch (error) { notify("error", error.message); throw error; }
1270
1271
  }
1271
1272
 
1272
1273
  /** Change how many rounds the host may run from one owner message; chains still running follow the new value. */
1273
1274
  async function set_chain_rounds(max_rounds) {
1274
1275
  const room_id = state.room.id;
1275
- state.free_error = "";
1276
1276
  try {
1277
1277
  const token = localStorage.getItem(`discuss-access:${room_id}`);
1278
1278
  if (!token) throw new Error("此浏览器缺少房间控制凭据,请使用创建房间的浏览器");
1279
1279
  await request_json(`/api/rooms/${room_id}/budget`, { max_rounds }, token);
1280
1280
  if (state.room?.id === room_id) await refresh_room();
1281
- } catch (error) { state.free_error = error.message; throw error; }
1281
+ } catch (error) { notify("error", error.message); throw error; }
1282
1282
  }
1283
1283
 
1284
1284
  /** Let a chain that stopped at its round limit run on for more rounds; the dispatch it held back goes out at once. */
1285
1285
  async function continue_chain(chain_id, rounds) {
1286
1286
  const room_id = state.room.id;
1287
- state.free_error = "";
1288
1287
  try {
1289
1288
  const token = localStorage.getItem(`discuss-access:${room_id}`);
1290
1289
  if (!token) throw new Error("此浏览器缺少房间控制凭据,请使用创建房间的浏览器");
1291
1290
  await request_json(`/api/rooms/${room_id}/chains/${chain_id}`, { action: "continue", rounds }, token);
1292
1291
  if (state.room?.id === room_id) await refresh_room();
1293
- } catch (error) { state.free_error = error.message; throw error; }
1292
+ } catch (error) { notify("error", error.message); throw error; }
1294
1293
  }
1295
1294
 
1296
1295
  /** Allow operations a woken member's software refused; the local service passes the rules from its next run. */
@@ -1321,33 +1320,30 @@ async function set_member_adapter({ name, adapter }) {
1321
1320
  /** Owner changes use the website; only automatic members need the local service to reconnect. */
1322
1321
  async function change_member({ member_id, name, action, role, latest_id }) {
1323
1322
  const room_id = state.room.id;
1324
- state.free_error = ""; state.free_notice = "";
1325
- try {
1326
- if (action === "rejoin") {
1327
- const member = (state.room.free?.members || state.room.members).find(item => item.id === member_id);
1328
- const token = localStorage.getItem(`discuss-access:${room_id}`);
1329
- const record = prepare_member_request(room_id, `rejoin:${member_id}`, { name, access: member.access });
1330
- const result = member.access === "runner"
1331
- ? await local_launch_request("room-member", { room_id, token, action: "rejoin", name, member_token: record.member.token })
1332
- : await request_json(`/api/rooms/${room_id}/members/${member_id}`, { action: "active", token: record.member.token });
1333
- finish_member_request(room_id, token, record, result);
1334
- if (state.room?.id === room_id) state.free_notice = member.access === "manual" ? `${name} 已请回,凭证已更新;请用新的启动指令让它重新注册。` : `${name} 已请回,续接原会话。`;
1335
- } else {
1336
- await request_json(`/api/rooms/${room_id}/members/${member_id}${action === "interrupt" ? "/interrupt" : ""}`,
1337
- action === "interrupt" ? {} : { action, ...(role ? { role } : {}), ...(latest_id !== undefined ? { latest_id } : {}) });
1338
- }
1339
- if (state.room?.id === room_id) await refresh_room();
1340
- } 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();
1341
1337
  }
1342
1338
 
1343
1339
  /** Reorder who has not spoken yet against the revision the board showed; a changed order is refused and the board reloads. */
1344
1340
  async function reorder_speakers({ revision, order }) {
1345
1341
  const room_id = state.room.id;
1346
- state.order_error = null;
1347
1342
  try { await request_json(`/api/rooms/${room_id}/speaking-order`, { revision, order }); }
1348
1343
  catch (error) {
1349
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).
1350
- state.order_error = { room_id, message: error.status === 409 ? "发言顺序刚有变化,已更新为最新顺序,请重新调整" : error.message };
1345
+ if (error.status === 409) notify("warning", "发言顺序刚有变化,已更新为最新顺序,请重新调整");
1346
+ else notify("error", error.message);
1351
1347
  }
1352
1348
  if (state.room?.id === room_id) await refresh_room();
1353
1349
  }
@@ -1355,41 +1351,39 @@ async function reorder_speakers({ revision, order }) {
1355
1351
  /** Invite an independent member, retaining its credential before either delivery path runs. */
1356
1352
  async function invite_member({ name, access, project_access = "read", session_id = "" }) {
1357
1353
  const room_id = state.room.id;
1358
- state.free_error = ""; state.free_notice = "";
1359
- try {
1360
- const token = localStorage.getItem(`discuss-access:${room_id}`);
1361
- if (!token) throw new Error("此浏览器缺少房间控制凭据,请使用创建房间的浏览器");
1362
- const taken = new Set((state.room.free?.members || state.room.members || []).map(member => member.name));
1363
- const instance = unique_instance(name, taken);
1364
- const web_member = access === "online";
1365
- const record = prepare_member_request(room_id, `add:${name}:${access}`, { name: instance, access: web_member ? "manual" : access,
1366
- ...(web_member ? { manual_source: "web" } : {}), adapter: "", session_mode: "new" }, { project_access, session_id: session_id.trim() });
1367
- let result;
1368
- if (access === "runner") {
1369
- const draft = launch_options();
1370
- const participants = { ...draft.participants, [record.member.name]: { ...draft.participants?.[name],
1371
- ...draft.participants?.[record.member.name], project_access: record.options?.project_access || "read",
1372
- // An existing conversation joins with its own context; the runner resumes it instead of starting a new one.
1373
- ...(record.options?.session_id ? { session_id: record.options.session_id } : {}) } };
1374
- if (participants[record.member.name].adapter === "api") participants[record.member.name].project_access = "read";
1375
- result = await local_launch_request("room-member", { room_id, token, action: "add", name: record.member.name, access,
1376
- member_token: record.member.token, participants, cwd: draft.cwd });
1377
- } else result = await request_json(`/api/rooms/${room_id}/members`, record.member);
1378
- finish_member_request(room_id, token, record, result);
1379
- await send_descriptions(room_id, token, { [record.member.name]: description_draft(name) });
1380
- await send_labels(room_id, token, { [record.member.name]: label_drafts[name] });
1381
- delete description_drafts[name];
1382
- delete label_drafts[name];
1383
- if (state.room?.id === room_id) {
1384
- state.free_notice = web_member ? `${record.member.name} 已加入,阅读通知与真实回答由你或授权主持人转交。`
1385
- // A manual member's start step lives on the board row, the composer bar and the invite dialog; no extra notice.
1386
- : access === "manual" ? ""
1387
- : record.options?.session_id ? `${record.member.name} 已接入原会话;它工作时请不要在原窗口继续操作。`
1388
- : state.room.mode === "free" ? `${record.member.name} 已加入,之后点名它即会由本机服务唤醒。`
1389
- : `${record.member.name} 已加入讨论,主持人会重新安排发言。`;
1390
- await refresh_room();
1391
- }
1392
- } 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
+ }
1393
1387
  }
1394
1388
 
1395
1389
  /** Clear only the composer that produced the saved message. */
@@ -1417,10 +1411,8 @@ async function send_message(event) {
1417
1411
  event.preventDefault();
1418
1412
  if (!state.room || state.sending_message) return;
1419
1413
  const room_id = state.room.id, drafts = state.attachment_drafts;
1420
- state.composer_error = "";
1421
- let payload, saved = false;
1422
- try { payload = message_payload(); }
1423
- catch (error) { state.composer_error = error.message; render_attachment_drafts(); publish_view(); throw error; }
1414
+ const payload = message_payload();
1415
+ let saved = false;
1424
1416
  if (!payload.text && !drafts.length) return;
1425
1417
  const signature = JSON.stringify({ room: room_id, ...payload, files: drafts.map(file => file.id) });
1426
1418
  if (state.pending_submission?.signature !== signature) state.pending_submission = { signature, id: new_submission_id() };
@@ -1435,9 +1427,9 @@ async function send_message(event) {
1435
1427
  saved = true;
1436
1428
  await finish_message(result, room_id, drafts);
1437
1429
  } catch (error) {
1438
- if (state.room?.id === room_id) state.composer_error = saved
1430
+ if (state.room?.id === room_id) notify_failure(error, saved
1439
1431
  ? `留言已保存,但页面刷新失败:${error.message}`
1440
- : `${error.message};草稿已保留,可重试发送`;
1432
+ : `${error.message};草稿已保留,可重试发送`);
1441
1433
  throw error;
1442
1434
  } finally {
1443
1435
  state.sending_message = false;
@@ -1468,9 +1460,8 @@ function attachment_file_icon(file_name) {
1468
1460
  function add_attachments(files) {
1469
1461
  if (!state.room || state.sending_message) return;
1470
1462
  const selected = Array.from(files);
1471
- state.composer_error = "";
1472
- if (state.attachment_drafts.length + selected.length > MAX_MESSAGE_ATTACHMENTS) state.composer_error = "每条消息最多 10 个附件";
1473
- 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");
1474
1465
  else state.attachment_drafts.push(...selected.map(file => ({ id: new_submission_id(), file,
1475
1466
  preview_url: /^image\/(png|jpeg|gif|webp)$/.test(file.type) ? URL.createObjectURL(file) : "", uploaded: null, actor: null })));
1476
1467
  render_attachment_drafts();
@@ -1490,19 +1481,18 @@ function remove_attachment(id) {
1490
1481
  const file = state.attachment_drafts.find(item => item.id === id);
1491
1482
  if (file?.preview_url) URL.revokeObjectURL(file.preview_url);
1492
1483
  state.attachment_drafts = state.attachment_drafts.filter(item => item.id !== id);
1493
- state.composer_error = "";
1494
1484
  render_attachment_drafts();
1495
1485
  publish_view();
1496
1486
  }
1497
1487
 
1498
- /** Display shared file drafts and errors in the classic theme. */
1488
+ /** Display shared file drafts and upload progress in the classic theme. */
1499
1489
  function render_attachment_drafts() {
1500
1490
  const container = element("attachment-drafts");
1501
1491
  if (!container) return;
1502
1492
  container.innerHTML = state.attachment_drafts.map(item => `<div class="attachment-draft">${item.preview_url ? `<img src="${item.preview_url}" alt="">` : ""}
1503
1493
  <span>${escape_html(item.file.name)}<small>${attachment_size(item.file.size)}</small></span>
1504
1494
  <button type="button" data-remove-attachment="${item.id}" aria-label="移除 ${escape_html(item.file.name)}" ${state.sending_message ? "disabled" : ""}>×</button></div>`).join("");
1505
- element("attachment-status").textContent = state.composer_error || state.upload_status;
1495
+ element("attachment-status").textContent = state.upload_status;
1506
1496
  element("choose-attachments").disabled = state.sending_message;
1507
1497
  }
1508
1498
 
@@ -1782,7 +1772,7 @@ function access_snapshot() {
1782
1772
  name: member.name, access: is_web_member(member) ? "online" : member.access, available: member.status === "active" && !is_web_member(member)
1783
1773
  && Boolean(saved[member.name]?.token) && is_web_member(saved[member.name]) === is_web_member(member)
1784
1774
  && (!saved[member.name].id || saved[member.name].id === member.id),
1785
- })), notice: state.access_notice, error: state.access_error };
1775
+ })) };
1786
1776
  }
1787
1777
 
1788
1778
  /** Generate a separate random credential for each declared participant instance; `cli` members are woken locally. */
@@ -1837,27 +1827,20 @@ async function submit_room(event, folder_id = null) {
1837
1827
  const signature = signed(payload, launch_draft);
1838
1828
  const navigation = state.navigation_version;
1839
1829
  if (!launching) element("room-dialog").close();
1840
- state.launch.error = "";
1841
- let room;
1842
- try {
1843
- const pending = prepare_room_request(signature);
1844
- const token = pending.token;
1845
- pending.members ||= creation_members(payload.experts);
1846
- // The website tracks capabilities (cli/online); a manual expert is a local reader and writer whose delivery lives in its member record.
1847
- const experts = Object.fromEntries(Object.entries(payload.experts || {}).map(([name, mode]) => [name, mode === "manual" ? "cli" : mode]));
1848
- const input = { ...(launch_draft || {}), room: { ...payload, ...(payload.experts ? { experts } : {}), members: pending.members, submission_id: pending.id }, token };
1849
- 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}」创建`,
1850
1837
  error_message: launching ? error => `「${payload.title}」${error.message}` : null,
1851
1838
  apply: () => { state.creating_room = payload; }, rollback: () => { state.creating_room = null; },
1852
1839
  save: async () => (await submit_local_launch(input, payload.title, !launching, signature)).room,
1853
1840
  commit: saved => {
1854
1841
  state.creating_room = null;
1855
1842
  if (!state.rooms.some(existing => existing.id === saved.id)) state.rooms.push(saved);
1856
- } }); }
1857
- catch (error) {
1858
- if (launching) { state.launch.error = error.message; render_launch_settings(); publish_view(); }
1859
- throw error;
1860
- }
1843
+ } });
1861
1844
  state.pending_room = null;
1862
1845
  state.create_parent = null;
1863
1846
  if (discussion) await open_discussion_after_creation(room.id, discussion);
@@ -1880,7 +1863,7 @@ async function open_discussion_after_creation(room_id, { goal, named_followup, l
1880
1863
  await request_json(`${path}/discussion`, { action: "start", revision: current.revision,
1881
1864
  ...(goal ? { goal } : {}), limits, named_followup });
1882
1865
  } catch (error) {
1883
- show_toast(`房间已创建,但开启讨论失败:${error.message};可在右侧成员栏重试`);
1866
+ notify("warning", `房间已创建,但开启讨论失败:${error.message};可在右侧成员栏重试`);
1884
1867
  }
1885
1868
  }
1886
1869
 
@@ -2003,7 +1986,6 @@ async function resume_launch(id) {
2003
1986
  const record = stored_launches().find(item => item.id === id);
2004
1987
  if (!record || state.directory_pending.has(`launch-${id}`)) return;
2005
1988
  state.directory_pending.set(`launch-${id}`, true);
2006
- state.launch.error = "";
2007
1989
  publish_view();
2008
1990
  try {
2009
1991
  const result = await submit_local_launch(record.input, record.title, record.direct, record.signature);
@@ -2016,11 +1998,10 @@ async function resume_launch(id) {
2016
1998
  element("room-dialog").close();
2017
1999
  }
2018
2000
  if (!record.direct) clear_launch_overrides(record.input);
2019
- state.launch.notice = result.status === "running" ? "已核对原启动,实际执行请查看进度回执" : "已核对原房间状态;不会自动恢复已暂停或结束的讨论";
2020
- state.directory_error = "";
2001
+ notify("info", result.status === "running" ? "已核对原启动,实际执行请查看进度回执" : "已核对原房间状态;不会自动恢复已暂停或结束的讨论");
2021
2002
  if (state.pending_room?.id === id) state.pending_room = null;
2022
2003
  await select_room(result.room.id);
2023
- } catch (error) { state.launch.error = error.message; }
2004
+ } catch (error) { notify("error", error.message); }
2024
2005
  finally { state.directory_pending.delete(`launch-${id}`); render_launch_settings(); publish_view(); }
2025
2006
  }
2026
2007
 
@@ -2028,7 +2009,6 @@ async function resume_launch(id) {
2028
2009
  function discard_launch(id) {
2029
2010
  localStorage.removeItem(LAUNCH_PENDING_PREFIX + id);
2030
2011
  if (state.pending_room?.id === id) state.pending_room = null;
2031
- state.launch.error = "";
2032
2012
  refresh_launch_recoveries();
2033
2013
  }
2034
2014
 
@@ -2329,7 +2309,6 @@ function write_launch_field(name, field, value) {
2329
2309
  if (field === "base_url") delete state.launch.profiles[name].api_key_ref;
2330
2310
  fill_api_key(state.launch.profiles[name]);
2331
2311
  }
2332
- state.launch.notice = "";
2333
2312
  if (field === "adapter") render_launch_settings();
2334
2313
  else render_launch_summaries();
2335
2314
  publish_view();
@@ -2501,8 +2480,7 @@ async function pick_launch_directory() {
2501
2480
  try {
2502
2481
  const path = await browse_local_directory();
2503
2482
  if (path) write_launch_field("workspace", "cwd", path);
2504
- state.launch.error = "";
2505
- } catch (error) { state.launch.error = error.message; }
2483
+ } catch (error) { notify("error", error.message); }
2506
2484
  publish_view();
2507
2485
  }
2508
2486
 
@@ -2523,9 +2501,8 @@ async function save_launch_defaults() {
2523
2501
  try {
2524
2502
  await local_launch_request("settings", { participants });
2525
2503
  state.launch.saved_profiles = { ...state.launch.saved_profiles, ...JSON.parse(JSON.stringify(participants)) };
2526
- state.launch.notice = "已保存本机软件配置,后续讨论可复用";
2527
- state.launch.error = "";
2528
- } catch (error) { state.launch.error = error.message; }
2504
+ notify("info", "已保存本机软件配置,后续讨论可复用");
2505
+ } catch (error) { notify("error", error.message); }
2529
2506
  render_launch_settings();
2530
2507
  publish_view();
2531
2508
  }
@@ -2540,10 +2517,9 @@ async function launch_current_room() {
2540
2517
  const draft = launch_options();
2541
2518
  const result = await submit_local_launch({ ...draft, room_id, token }, state.room.title || "已有讨论");
2542
2519
  clear_launch_overrides(draft);
2543
- state.launch.notice = result.status === "running" ? "启动已提交,实际执行请查看进度回执" : "本次启动已有记录,请查看房间状态";
2544
- state.launch.error = "";
2520
+ notify("info", result.status === "running" ? "启动已提交,实际执行请查看进度回执" : "本次启动已有记录,请查看房间状态");
2545
2521
  if (state.room?.id === room_id) await refresh_room();
2546
- } catch (error) { state.launch.error = error.message; }
2522
+ } catch (error) { notify("error", error.message); }
2547
2523
  render_launch_settings();
2548
2524
  publish_view();
2549
2525
  }
@@ -2551,13 +2527,12 @@ async function launch_current_room() {
2551
2527
  /** Load the selected paused room's original connection paths after proving browser ownership. */
2552
2528
  async function load_room_configuration() {
2553
2529
  const room_id = state.room.id;
2554
- state.launch.repair_error = '';
2555
2530
  try {
2556
2531
  const token = localStorage.getItem(`discuss-access:${room_id}`);
2557
2532
  if (!token) throw new Error('此浏览器缺少房间凭据,请使用原浏览器或原接入配置');
2558
2533
  const result = await local_launch_request('room-config', { room_id, token });
2559
- if (state.room?.id === room_id) state.launch.repair = { ...result, notice: '' };
2560
- } 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); }
2561
2536
  render_room_configuration();
2562
2537
  publish_view();
2563
2538
  }
@@ -2568,7 +2543,6 @@ function write_room_connection(name, value) {
2568
2543
  if (repair?.room_id !== state.room?.id || !repair.participants[name]) return;
2569
2544
  const profile = repair.participants[name];
2570
2545
  profile[profile.adapter === 'antigravity' ? 'ipc_directory' : 'executable'] = value;
2571
- repair.notice = '';
2572
2546
  publish_view();
2573
2547
  }
2574
2548
 
@@ -2579,7 +2553,6 @@ function write_room_adapter(name, value) {
2579
2553
  if (repair?.room_id !== state.room?.id || !['codex', 'codex_cli'].includes(profile?.adapter)
2580
2554
  || !['codex', 'codex_cli'].includes(value)) return;
2581
2555
  profile.adapter = value;
2582
- repair.notice = '';
2583
2556
  publish_view();
2584
2557
  }
2585
2558
 
@@ -2591,18 +2564,18 @@ async function save_room_configuration() {
2591
2564
  const field = profile.adapter === 'antigravity' ? 'ipc_directory' : 'executable';
2592
2565
  return [name, { [field]: profile[field], ...(['codex', 'codex_cli'].includes(profile.adapter) ? { adapter: profile.adapter } : {}) }];
2593
2566
  }));
2594
- state.launch.repair_error = '';
2595
2567
  try {
2596
2568
  const token = localStorage.getItem(`discuss-access:${repair.room_id}`);
2597
2569
  if (!token) throw new Error('此浏览器缺少房间凭据,请重新核对');
2598
2570
  const result = await local_launch_request('room-config', { room_id: repair.room_id, token,
2599
2571
  version: repair.version, runtime_revision: repair.runtime_revision, participants });
2600
2572
  if (state.room?.id === repair.room_id) {
2601
- state.launch.repair = { ...result, notice: result.applied ? '本场接入设置已保存,服务已采用;可在进度看板重启并核对实际发言。'
2602
- : '本场接入设置已保存;服务尚未确认采用,请先核对本机服务再重启。' };
2573
+ state.launch.repair = result;
2574
+ notify('info', result.applied ? '本场接入设置已保存,服务已采用;可在进度看板重启并核对实际发言。'
2575
+ : '本场接入设置已保存;服务尚未确认采用,请先核对本机服务再重启。');
2603
2576
  await refresh_room();
2604
2577
  }
2605
- } 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); }
2606
2579
  render_room_configuration();
2607
2580
  publish_view();
2608
2581
  }
@@ -2625,7 +2598,6 @@ function render_room_configuration() {
2625
2598
  fields.dataset.roomId = state.room?.id || '';
2626
2599
  }
2627
2600
  element('local-room-configuration-save').hidden = !repair;
2628
- element('local-room-configuration-notice').textContent = state.launch.repair_error || repair?.notice || '';
2629
2601
  }
2630
2602
 
2631
2603
  /** Render the same local setup controls in the classic theme. */
@@ -2663,7 +2635,7 @@ function render_launch_settings() {
2663
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>`;
2664
2636
  }
2665
2637
  for (const id of ["local-launch-notice", "new-local-launch-notice"]) {
2666
- if (element(id)) element(id).textContent = [state.launch.error || state.launch.notice,
2638
+ if (element(id)) element(id).textContent = [state.launch.error,
2667
2639
  !state.launch.available && state.launch.connect_command ? `在这台电脑运行 ${state.launch.connect_command},使用命令打开的网页完成连接。` : ""].filter(Boolean).join(" ");
2668
2640
  }
2669
2641
  for (const id of ["local-launch-save", "new-local-launch-save"]) if (element(id)) element(id).disabled = !state.launch.available;
@@ -2836,8 +2808,7 @@ function download_member_access(name) {
2836
2808
  const member = access.members.find(item => item.name === name);
2837
2809
  if (!access.can_manage || !member?.available || member.access === "online") throw new Error("当前浏览器没有此成员可下载的私有配置");
2838
2810
  download_private_json({ site: location.origin, room: room.id, token: member_credentials(room.id)[name].token }, `member-${room.id}-${name}.json`);
2839
- state.access_error = "";
2840
- flash_access_notice(`已下载 ${name} 的私有配置`);
2811
+ notify("info", `已下载 ${name} 的私有配置`);
2841
2812
  }
2842
2813
 
2843
2814
  /** Copy the same room-scoped credential as one line of text for an agent that cannot take a file. */
@@ -2846,19 +2817,7 @@ async function copy_member_access_text(name) {
2846
2817
  const member = access.members.find(item => item.name === name);
2847
2818
  if (!access.can_manage || !member?.available || member.access === "online") throw new Error("当前浏览器没有此成员可复制的凭证");
2848
2819
  await copy_text(JSON.stringify({ site: location.origin, room: room.id, token: member_credentials(room.id)[name].token }));
2849
- flash_access_notice(`已复制 ${name} 的凭证文本(含 token)`);
2850
- }
2851
-
2852
- /** Confirm a copy or download briefly in the member rail; the note clears itself. */
2853
- function flash_access_notice(message) {
2854
- state.access_error = "";
2855
- state.access_notice = message;
2856
- clearTimeout(state.access_notice_timer);
2857
- state.access_notice_timer = setTimeout(() => {
2858
- if (state.access_notice !== message) return;
2859
- state.access_notice = "";
2860
- publish_view();
2861
- }, TOAST_DURATION_MS);
2820
+ notify("info", `已复制 ${name} 的凭证文本(含 token)`);
2862
2821
  }
2863
2822
 
2864
2823
  /** Copy a member-specific entry instruction; only the launch variant carries the credential, never conversation context. */
@@ -2881,7 +2840,7 @@ async function copy_member_join(name, with_credential = false) {
2881
2840
  ${instruction}
2882
2841
  房间入口:${address}
2883
2842
  先核对最新阶段、任务和原文编号,再按房间协议参与。${credential}`);
2884
- 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} 的参与说明`);
2885
2844
  }
2886
2845
 
2887
2846
  /** How a manual member learns it is its turn: free rooms are pulled by the member, discussions only on the host's assignment. */
@@ -3121,16 +3080,16 @@ async function fork_room(message_id) {
3121
3080
  const registered = new Set((local?.members || []).filter(member => member.status !== "failed").map(member => member.name));
3122
3081
  for (const member of (state.room.free?.members || []).filter(item => item.status === "away")) {
3123
3082
  if (member.access === "runner" && !registered.has(member.name)) continue;
3124
- await change_member({ member_id: member.id, name: member.name, action: "rejoin" });
3125
- 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}`); }
3126
3085
  }
3127
3086
  const fresh = (local?.members || []).filter(member => member.status === "fresh").map(member => member.name);
3128
3087
  const failed = (local?.members || []).filter(member => member.status === "failed").map(member => `${member.name}(${member.error})`);
3129
3088
  if (failed.length) problems.push(`未分叉、保持离开:${failed.join("、")}`);
3130
3089
  const done = `已从 #${message_id} 分支出新房间,原房间不变`;
3131
- // The rail shows an error in place of a notice, so a partial fork says both in one line.
3132
- state.free_error = problems.length ? `${done},但${problems.join(";")}` : "";
3133
- 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("、")} 将以新会话开始` : ""}。`);
3134
3093
  publish_view();
3135
3094
  }
3136
3095
 
@@ -3769,13 +3728,13 @@ function view_snapshot() {
3769
3728
  const experts = Object.fromEntries(["new-expert-modes", "topic-expert-modes"].map((id) =>
3770
3729
  [id, element(id) ? read_expert_inputs(id) : {}]));
3771
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,
3772
- 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),
3773
3732
  directory_pending: [...state.directory_pending.keys()], folder_pending: [...state.folder_moves.keys()], creating_room: state.creating_room,
3774
3733
  connected: state.connected, reconnecting: state.reconnecting, backup_pending: Boolean(state.backup_pending), page: state.page, pages: state.pages, query: state.query,
3775
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 })),
3776
- 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,
3777
3736
  mention_only: state.mention_only, reply_to: state.reply_to, turn_reply_to: state.turn_reply_to,
3778
- 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,
3779
3738
  create_parent: state.create_parent, access: access_snapshot(), admin: admin_snapshot(),
3780
3739
  tab: state.tab, snapshot_id: state.snapshot_id, context_dirty: state.context_dirty,
3781
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 };
@@ -3871,7 +3830,6 @@ async function backup_room(room_id = state.room?.id) {
3871
3830
  async function backup_rooms({ ids, name }) {
3872
3831
  if (!ids.length || state.backup_pending) return;
3873
3832
  state.backup_pending = true;
3874
- state.directory_notice = state.directory_error = "";
3875
3833
  publish_view();
3876
3834
  try {
3877
3835
  const snapshots = [];
@@ -3881,9 +3839,9 @@ async function backup_rooms({ ids, name }) {
3881
3839
  snapshots.push({ ...snapshot, source_url: `${location.origin}/r/${encodeURIComponent(id)}` });
3882
3840
  }
3883
3841
  await window.discuss_backup.download_rooms_backup(snapshots, name);
3884
- state.directory_notice = `已生成 ${snapshots.length} 个频道的备份,请查看浏览器下载。`;
3842
+ notify("info", `已生成 ${snapshots.length} 个频道的备份,请查看浏览器下载。`);
3885
3843
  } catch (error) {
3886
- state.directory_error = `导出失败,未生成备份:${error.message}`;
3844
+ notify("error", `导出失败,未生成备份:${error.message}`);
3887
3845
  } finally {
3888
3846
  state.backup_pending = false;
3889
3847
  publish_view();
@@ -3995,14 +3953,11 @@ async function execute_view_action(name, value) {
3995
3953
  prepare_brainstorm, return_conclusion, adopt_recommendation, rate_decision,
3996
3954
  busy: () => { throw new Error("上一个操作仍在处理中,请稍候;长时间无响应请刷新页面"); },
3997
3955
  copy_reading: () => copy_text(`${location.origin}/r/${state.room.id}`), copy: copy_text };
3998
- if (!["refresh", "busy"].includes(name)) state.free_error = "";
3999
3956
  try {
4000
3957
  await actions[name](value);
4001
3958
  return true;
4002
3959
  } catch (error) {
4003
- if (["download_member_access", "copy_member_access_text", "copy_member_join", "copy_member_launch"].includes(name)) state.access_error = error.message;
4004
- else if (!state.free_error) state.free_error = error.message;
4005
- show_toast(error.message, true);
3960
+ if (!error.notified) notify("error", error.message);
4006
3961
  return false;
4007
3962
  } finally { publish_view(); }
4008
3963
  }
@@ -4013,7 +3968,7 @@ function change_view_theme(theme) {
4013
3968
  try { localStorage.setItem(THEME_STORAGE_KEY, document.documentElement.dataset.theme); } catch { /* Keep the session preference. */ }
4014
3969
  }
4015
3970
 
4016
- window.discuss_view = { snapshot: view_snapshot, write_field: write_view_field,
3971
+ window.discuss_view = { snapshot: view_snapshot, write_field: write_view_field, notify,
4017
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,
4018
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,
4019
3974
  start_drag: start_directory_drag, reconnect: reconnect_live_updates,