ai_co_discuss 0.2.97 → 0.2.99

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
@@ -339,8 +339,8 @@ function new_submission_id() {
339
339
  return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("");
340
340
  }
341
341
 
342
- /** Resolve a UI label without changing stored identity values. */
343
- function display_name(name) { return IDENTITIES[name]?.label || name; }
342
+ /** Resolve a UI label without changing stored identity values; the open room's member labels come first. */
343
+ function display_name(name) { return state.room?.labels?.[name] || IDENTITIES[name]?.label || name; }
344
344
 
345
345
  /** Render an identity avatar from a local SVG. */
346
346
  function identity_avatar(identity) {
@@ -803,10 +803,13 @@ function message_markup(message) {
803
803
  /** Render continuous history while retaining expanded answers and reading position. */
804
804
  function render_messages(result) {
805
805
  const previous = new Map(state.messages.map((message) => [message.id, message]));
806
+ // Cached history is read once; a later retract or rewind reaches it through the room's withdrawal list.
807
+ const withdrawn = new Map((state.room?.withdrawals || []).map((item) => [item.message_id, item.kind]));
806
808
  state.messages = result.messages.map((message) => {
807
809
  const saved = previous.get(message.id);
808
- if (saved) return saved;
809
- return { ...message, rendered_text: window.discuss_markdown.render_markdown(message.text),
810
+ const kind = withdrawn.get(message.id) || message.withdrawn || "";
811
+ if (saved) return (saved.withdrawn || "") === kind ? saved : { ...saved, withdrawn: kind };
812
+ return { ...message, withdrawn: kind, rendered_text: window.discuss_markdown.render_markdown(message.text),
810
813
  rendered_summary: window.discuss_markdown.render_markdown(message.summary || "") };
811
814
  });
812
815
  state.message_total = result.total;
@@ -826,7 +829,7 @@ function render_messages(result) {
826
829
 
827
830
  /** Join bounded cursor batches for the interactive transcript; refresh only new records. */
828
831
  async function load_message_history(room_id, version) {
829
- const params = new URLSearchParams({ q: state.query });
832
+ const params = new URLSearchParams({ q: state.query, withdrawn: "include" });
830
833
  if (state.mention_only) params.set("mention", element("author-select").value);
831
834
  const key = `${room_id}?${params}`;
832
835
  const saved = state.message_history?.key === key ? state.message_history.messages : [];
@@ -1065,6 +1068,8 @@ function finish_member_request(room_id, token, record, result) {
1065
1068
  // room being created or the member being invited.
1066
1069
  const DESCRIPTION_TEMPLATE_PREFIX = "discuss-description-template:";
1067
1070
  const description_drafts = {};
1071
+ // A display label a card brings from a named preset; the room keeps it so screens show it instead of the instance name.
1072
+ const label_drafts = {};
1068
1073
 
1069
1074
  /** Templates this browser kept before they moved to the local service, by platform. */
1070
1075
  function browser_description_templates() {
@@ -1137,9 +1142,28 @@ function apply_expert_preset(name, preset) {
1137
1142
  for (const field of ["model", "effort", "system_prompt"]) if (Object.hasOwn(preset, field)) profile[field] = preset[field];
1138
1143
  state.launch.profiles[name] = profile;
1139
1144
  description_drafts[name] = preset.description || "";
1145
+ // A second copy of a preset keeps the number its instance name got: 评审官 → 评审官2.
1146
+ if (preset.label) label_drafts[name] = preset.label + name.slice(preset.name.length);
1140
1147
  publish_view();
1141
1148
  }
1142
1149
 
1150
+ /** The name a card shows: its preset label, else the room's or platform's display name. */
1151
+ function member_label(name) {
1152
+ return label_drafts[name] || display_name(name);
1153
+ }
1154
+
1155
+ /** The instance a preset of this platform is saved under for a free name: the name itself when it is already one, else platform-name. */
1156
+ function preset_identity(name, label) {
1157
+ const platform = platform_of(name), text = label.trim();
1158
+ if (platform_of(text) === platform) return text;
1159
+ const suffix = Array.from(text.replace(/[^0-9A-Za-z_一-鿿]/g, "")).slice(0, 16).join("");
1160
+ if (suffix) return `${platform}-${suffix}`;
1161
+ const taken = new Set(expert_presets().map(preset => preset.name));
1162
+ let number = 1;
1163
+ while (taken.has(`${platform}-预设${number}`)) number++;
1164
+ return `${platform}-预设${number}`;
1165
+ }
1166
+
1143
1167
  /** Add a preset to the new-room draft under a free name. */
1144
1168
  function add_preset_instance(container_id, preset, value) {
1145
1169
  const name = unique_instance(preset.name, new Set(Object.keys(read_expert_inputs(container_id))));
@@ -1148,14 +1172,16 @@ function add_preset_instance(container_id, preset, value) {
1148
1172
  return name;
1149
1173
  }
1150
1174
 
1151
- /** Save one card as the user's preset under its name, replacing an earlier preset of that name. */
1152
- async function save_expert_preset(name) {
1175
+ /** Save one card as the user's preset, under a new display label or, without one, updating the preset of its own name. */
1176
+ async function save_expert_preset(name, label) {
1153
1177
  const profile = state.launch.profiles[name] || {};
1154
- const preset = { name, description: description_draft(name).trim(), project_access: profile.project_access || "read" };
1178
+ const id = label === undefined ? name : preset_identity(name, label);
1179
+ const shown = label === undefined ? expert_presets().find(item => item.name === name)?.label || "" : label.trim();
1180
+ const preset = { name: id, ...shown && shown !== id ? { label: shown } : {}, description: description_draft(name).trim(), project_access: profile.project_access || "read" };
1155
1181
  for (const field of ["model", "effort", "system_prompt"]) if (Object.hasOwn(profile, field) && profile[field] !== "") preset[field] = profile[field];
1156
1182
  try {
1157
- await save_presets({ presets: [...state.launch.presets.filter(item => item.name !== name), preset] });
1158
- show_toast(`已存为预设「${name}」`);
1183
+ await save_presets({ presets: [...state.launch.presets.filter(item => item.name !== id), preset] });
1184
+ show_toast(`已存为预设「${preset.label || id}」`);
1159
1185
  } catch (error) { show_toast(`预设未保存:${error.message}`, true); }
1160
1186
  }
1161
1187
 
@@ -1186,6 +1212,14 @@ async function send_descriptions(room_id, token, descriptions) {
1186
1212
  catch (error) { state.free_notice = `成员描述未保存:${error.message},可在发言顺序的成员菜单里重新填写。`; }
1187
1213
  }
1188
1214
 
1215
+ /** Save display labels by member name with the room credential; a failure leaves the members under their names. */
1216
+ async function send_labels(room_id, token, labels) {
1217
+ const filled = Object.fromEntries(Object.entries(labels).filter(([, text]) => text?.trim()));
1218
+ if (!room_id || !token || !Object.keys(filled).length) return;
1219
+ try { await request_json(`/api/rooms/${room_id}/labels`, { labels: filled }, token); }
1220
+ catch (error) { state.free_notice = `成员显示名未保存:${error.message},房间里按成员名显示。`; }
1221
+ }
1222
+
1189
1223
  /** Save the new room's round limit with the room credential; a failure leaves the room on the default. */
1190
1224
  async function send_chain_rounds(room_id, token, max_rounds) {
1191
1225
  if (!room_id || !token || max_rounds === DEFAULT_CHAIN_ROUNDS) return;
@@ -1229,6 +1263,12 @@ async function continue_chain(chain_id, rounds) {
1229
1263
  } catch (error) { state.free_error = error.message; throw error; }
1230
1264
  }
1231
1265
 
1266
+ /** Allow operations a woken member's software refused; the local service passes the rules from its next run. */
1267
+ async function allow_denials({ name, rules }) {
1268
+ const room_id = state.room.id;
1269
+ await local_launch_request("room-member-allow", { room_id, token: localStorage.getItem(`discuss-access:${room_id}`), name, rules });
1270
+ }
1271
+
1232
1272
  /** Switch a woken member's project files between read-only and writable; the local service applies it from its next run. */
1233
1273
  async function set_member_access({ name, project_access }) {
1234
1274
  const room_id = state.room.id;
@@ -1305,7 +1345,9 @@ async function invite_member({ name, access, project_access = "read", session_id
1305
1345
  } else result = await request_json(`/api/rooms/${room_id}/members`, record.member);
1306
1346
  finish_member_request(room_id, token, record, result);
1307
1347
  await send_descriptions(room_id, token, { [record.member.name]: description_draft(name) });
1348
+ await send_labels(room_id, token, { [record.member.name]: label_drafts[name] });
1308
1349
  delete description_drafts[name];
1350
+ delete label_drafts[name];
1309
1351
  if (state.room?.id === room_id) {
1310
1352
  state.free_notice = access === "online" ? `${record.member.name} 已加入,阅读通知与真实回答由你或授权主持人转交。`
1311
1353
  // A manual member's start step lives on the board row, the composer bar and the invite dialog; no extra notice.
@@ -1900,7 +1942,8 @@ async function submit_local_launch(input, title, direct = false, signature) {
1900
1942
  refresh_launch_recoveries();
1901
1943
  const names = [...(record.input.room?.members || []).map(member => member.name), ...Object.keys(result.room?.experts || {})];
1902
1944
  await send_descriptions(result.room?.id, record.input.token, Object.fromEntries(names.map(name => [name, description_draft(name)])));
1903
- for (const name of names) delete description_drafts[name];
1945
+ await send_labels(result.room?.id, record.input.token, Object.fromEntries(names.map(name => [name, label_drafts[name]])));
1946
+ for (const name of names) { delete description_drafts[name]; delete label_drafts[name]; }
1904
1947
  await send_chain_rounds(result.room?.id, record.input.token, state.create_chain_rounds);
1905
1948
  return result;
1906
1949
  } catch (error) {
@@ -2031,12 +2074,15 @@ async function load_room_models() {
2031
2074
  // A free room runs no discussion yet still has members this computer wakes, so either one makes the models worth reading.
2032
2075
  const woken = (room?.members || []).filter(member => member.access === "runner").map(member => member.name);
2033
2076
  if (!state.launch.available || (room?.runtime?.mode !== "automatic" && !woken.length)) return;
2034
- // Inviting or removing a member in a free room leaves the runtime revision alone, so the woken names are part of the key.
2035
- const revision = `${room.runtime?.revision ?? 0}:${woken.join(",")}`;
2077
+ // Inviting or removing a member in a free room leaves the runtime revision alone, so the woken names are part of the key;
2078
+ // each new execution may run another model, which only its session log tells, so the latest executions are too.
2079
+ const executions = (room.free?.members || []).map(member => member.execution?.id || 0).join(",");
2080
+ const revision = `${room.runtime?.revision ?? 0}:${woken.join(",")}:${executions}`;
2036
2081
  const previous = state.launch.room_models;
2037
2082
  if (previous?.room_id === room.id && previous.revision === revision
2038
2083
  && (previous.loading || previous.participants)) return;
2039
- const current = { room_id: room.id, revision, loading: true, participants: null };
2084
+ // The last answer stays on screen while the new one is read.
2085
+ const current = { room_id: room.id, revision, loading: true, participants: previous?.room_id === room.id ? previous.participants : null };
2040
2086
  state.launch.room_models = current;
2041
2087
  const live = () => state.launch.room_models === current && state.room?.id === room.id;
2042
2088
  // agy is run to name its default model, which takes seconds; everything else is shown first and agy follows.
@@ -2045,7 +2091,7 @@ async function load_room_models() {
2045
2091
  if (live() && result.room_id === room.id) Object.assign(current, { participants: result.participants, registered: result.registered, scheduler: result.scheduler || null });
2046
2092
  } catch (error) {
2047
2093
  // Unavailable or older bridges leave the model unknown; the failure is kept so the page can say so, and the next refresh retries.
2048
- current.error = error.message || "读取失败";
2094
+ Object.assign(current, { participants: null, error: error.message || "读取失败" });
2049
2095
  } finally {
2050
2096
  current.loading = false;
2051
2097
  if (live()) publish_view();
@@ -2067,22 +2113,52 @@ function reload_room_models() {
2067
2113
  return load_room_models();
2068
2114
  }
2069
2115
 
2070
- /** Describe registered launch choices; an empty choice shows what the software's own settings select, or says the software decides. */
2116
+ /**
2117
+ * Describe registered launch choices: the card's choice, else what the member's session last ran with, else what the software's settings select;
2118
+ * anything unknown is "默认".
2119
+ */
2071
2120
  function participant_model(name, room, saved) {
2072
2121
  // Nothing to say beats a placeholder: a manual member's settings live in its own session and unread ones are not guessed.
2073
2122
  const member = (room?.members || []).find(item => item.name === name);
2074
2123
  if (member ? member.access !== "runner" : room?.runtime?.mode !== "automatic") return "";
2075
2124
  const profile = saved?.room_id === room.id ? saved.participants?.[name] : null;
2076
2125
  if (!profile) return "";
2077
- if (["codex", "antigravity"].includes(profile.adapter)) return "模型与强度由 App 设置";
2078
2126
  // Only an agy member without a chosen model depends on the default that is read later.
2079
- if (!profile.model && profile.defaults_pending) return "读取中…";
2080
- if (!profile.model && profile.defaults_failed) return "";
2081
- const model = profile.model || profile.default_model || "软件默认模型";
2082
- // agy's default effort belongs to its default model, so a chosen model keeps its own default instead.
2083
- const default_effort = profile.adapter === "antigravity_cli" && profile.model ? "" : profile.default_effort;
2084
- const effort = profile.effort || default_effort || (profile.adapter === "antigravity_cli" ? "模型默认强度" : "软件默认强度");
2085
- return `${model} · ${effort}`;
2127
+ if (!profile.model && !profile.actual_model && profile.defaults_pending) return "读取中…";
2128
+ // App members choose in their own chat, so only their session log knows.
2129
+ const app = ["codex", "antigravity"].includes(profile.adapter);
2130
+ const model = (app ? profile.actual_model : profile.model || profile.actual_model || profile.default_model) || "默认";
2131
+ // agy's default effort belongs to its default model, so any other model keeps its own default instead.
2132
+ const default_effort = profile.adapter === "antigravity_cli" && model !== profile.default_model ? "" : profile.default_effort;
2133
+ const effort = (app ? profile.actual_effort : profile.effort || profile.actual_effort || default_effort) || "默认";
2134
+ return model === "默认" && effort === "默认" ? "默认" : `${model} · ${effort}`;
2135
+ }
2136
+
2137
+ /** A woken member's project files as 只读 / 可写; empty when the site does not decide them (App, its own agy project, a taken-over manual member, unread settings). */
2138
+ function participant_files(name, room, saved) {
2139
+ const member = (room?.members || []).find(item => item.name === name);
2140
+ if (member?.access === "online") return "只读";
2141
+ if (member?.access !== "runner") return "";
2142
+ const profile = saved?.room_id === room.id ? saved.participants?.[name] : null;
2143
+ if (!profile) return "";
2144
+ if (profile.adapter === "api") return "只读";
2145
+ if (!MODEL_CLIENTS.includes(profile.adapter) || profile.own_project || profile.project_access === "own") return "";
2146
+ return ACCESS_LABELS[profile.project_access] || "只读";
2147
+ }
2148
+
2149
+ /**
2150
+ * What a borrowed session means for its original window, shown when hovering "借用会话". The Apps take each task in
2151
+ * their own chat, and a taken-over Claude member gets it in its open window, so only a background resume shares nothing.
2152
+ */
2153
+ function participant_session_hint(name, room, saved) {
2154
+ const member = (room?.members || []).find(item => item.name === name);
2155
+ if (member?.access !== "runner" || member.session_mode !== "borrowed") return undefined;
2156
+ if (["codex", "antigravity"].includes(member.adapter)) return "续接你已有的 App 会话:任务直接送进 App 里的这个会话,可在其中查看";
2157
+ const profile = saved?.room_id === room.id ? saved.participants?.[name] : null;
2158
+ if (member.adapter === "claude" && profile?.project_access === "own") {
2159
+ return "续接你自己的会话:原窗口开着时任务直接送进该窗口;窗口关闭时改在后台续接,期间不要重新打开该会话";
2160
+ }
2161
+ return "续接你已有的会话;它工作时请不要在原窗口继续操作";
2086
2162
  }
2087
2163
 
2088
2164
  /** Label how a member answers: the registered adapter says API / App; other locally woken members are CLI. */
@@ -2195,6 +2271,11 @@ function fill_api_key(profile) {
2195
2271
  if (ref) profile.api_key_ref = ref;
2196
2272
  }
2197
2273
 
2274
+ /** A card woken by the local service on a platform without local software starts on its API, unless it already names an entry. */
2275
+ function default_launch_entry(name) {
2276
+ if (!LAUNCH_CLIENTS[name]?.some(item => item.value !== "api") && !state.launch.profiles[name]?.adapter) write_launch_field(name, "adapter", "api");
2277
+ }
2278
+
2198
2279
  /** Keep software drafts separate from shared room records and from discussion advancement mode. */
2199
2280
  function write_launch_field(name, field, value) {
2200
2281
  if (name === "workspace") state.launch.workspace[field] = value;
@@ -2322,7 +2403,7 @@ function launch_summary(name) {
2322
2403
  : project_id || project_name || "软件默认项目";
2323
2404
  const directory = profile.cwd || state.launch.workspace.cwd;
2324
2405
  const access = !cli_directory ? "按 App 权限" : !directory ? "无项目目录"
2325
- : launch_access(name) === "write" ? "项目可写" : "项目只读";
2406
+ : `项目${ACCESS_LABELS[launch_access(name)] || "只读"}`;
2326
2407
  const label = LAUNCH_CLIENTS[name]?.find(item => item.value === profile.adapter)?.label || "尚未选择客户端";
2327
2408
  const choice = [profile.model, profile.effort && `${profile.effort} 档`].filter(Boolean).join(" · ");
2328
2409
  return { client: cli_directory && choice ? `${label} · ${choice}` : label,
@@ -2349,8 +2430,13 @@ function launch_access(name) {
2349
2430
  }
2350
2431
 
2351
2432
  /** Classic select options; an empty value inherits the launch-wide setting. */
2352
- function access_options(value = "", inherit = "") {
2353
- const options = [...(inherit ? [["", inherit]] : []), ["read", "只读"], ["write", "可写"]];
2433
+ const ACCESS_LABELS = { read: "只读", write: "可写", auto: "自动", full: "完全放开" };
2434
+
2435
+ // Mirrors access_options in runner/expert_prompts.mjs; agy has no headless 自动, and the shared setting stays 只读 / 可写.
2436
+ function access_options(value = "", inherit = "", adapter = "") {
2437
+ const levels = !inherit ? [["read", "只读"], ["write", "可写"]]
2438
+ : [["read", "只读"], ["write", "可写"], ...(adapter === "antigravity_cli" ? [] : [["auto", "自动"]]), ["full", "完全放开"]];
2439
+ const options = [...(inherit ? [["", inherit]] : []), ...levels];
2354
2440
  return options.map(([key, label]) => `<option value="${key}" ${(value || (inherit ? "" : "read")) === key ? "selected" : ""}>${label}</option>`).join("");
2355
2441
  }
2356
2442
 
@@ -2543,7 +2629,7 @@ function render_launch_settings() {
2543
2629
  fields.push(["session_id", "复用会话 ID(仅本次,可选)"], ["cwd", "此 AI 的工作目录(可选)"], ["project_name", "此 AI 的项目(可选)"]);
2544
2630
  if (["codex", "antigravity", "antigravity_cli"].includes(profile.adapter)) fields.push(["project_id", "软件中的项目 ID(可选)"]);
2545
2631
  const summary = launch_summary(name);
2546
- const permission = launch_can_set_access(name) ? `<label>此 AI 的项目文件<select data-launch-name="${name}" data-launch-field="project_access">${access_options(profile.project_access, "沿用本次设置")}</select></label>`
2632
+ const permission = launch_can_set_access(name) ? `<label>此 AI 的项目文件<select data-launch-name="${name}" data-launch-field="project_access">${access_options(profile.project_access, "沿用本次设置", profile.adapter)}</select></label>`
2547
2633
  : profile.adapter === "api" ? `<p class="local-note">项目文件只读</p>` : "";
2548
2634
  return `<fieldset><legend>${name}</legend><p class="local-note" data-launch-summary="${name}">${summary.session} · 工作目录:${escape_html(summary.cwd)}(${summary.access}) · 项目:${escape_html(summary.project)}</p><label>执行客户端<select data-launch-name="${name}" data-launch-field="adapter"><option value="">选择 CLI / App</option>${clients.map(client => `<option value="${client.value}" ${profile.adapter === client.value ? "selected" : ""}>${client.label}</option>`).join("")}</select></label>${render_launch_model_fields(name)}${fields.map(([field, label]) => `<label>${label}<textarea rows="${field === "allowed_tools" ? 2 : 1}" data-launch-name="${name}" data-launch-field="${field}">${escape_html(Array.isArray(profile[field]) ? profile[field].join("\n") : profile[field] || "")}</textarea></label>`).join("")}${permission}</fieldset>`;
2549
2635
  }).join("") + (Object.keys(state.launch.profiles).some(name => launch_can_set_access(name)) ? `<label>本次 CLI 项目文件<select data-launch-name="workspace" data-launch-field="project_access">${access_options(state.launch.workspace.project_access)}</select></label>` : "")
@@ -2865,6 +2951,122 @@ async function interrupt_member(member_id) {
2865
2951
  if (state.room?.id === room_id) await refresh_room();
2866
2952
  }
2867
2953
 
2954
+ /** Take back the user's message before any member handled it; its text returns to an empty input for editing. */
2955
+ async function retract_message(message_id) {
2956
+ const room_id = state.room.id;
2957
+ const result = await request_json(`/api/rooms/${room_id}/messages/${message_id}/retract`, {});
2958
+ if (state.room?.id !== room_id) return;
2959
+ if (!element("message-input").value.trim()) element("message-input").value = result.text;
2960
+ await refresh_room();
2961
+ }
2962
+
2963
+ /** What editing a message would take back, with the revision the rewind must carry. */
2964
+ function rewind_plan(message_id) {
2965
+ return request_json(`/api/rooms/${state.room.id}/rewind?message_id=${message_id}`);
2966
+ }
2967
+
2968
+ /** Return the room to just before a message, then send its edited text in its place through the normal composer. */
2969
+ async function rewind_and_send({ message_id, revision, text }) {
2970
+ if (element("message-input").value.trim() || state.attachment_drafts.length) throw new Error("输入框里还有草稿,请先发送或清空后再编辑这条消息");
2971
+ const room_id = state.room.id;
2972
+ await request_json(`/api/rooms/${room_id}/rewind`, { message_id, revision });
2973
+ if (state.room?.id !== room_id) return;
2974
+ cancel_reply();
2975
+ element("message-input").value = text;
2976
+ await send_message({ preventDefault() {} });
2977
+ }
2978
+
2979
+ /**
2980
+ * Cut a room backup down to a fork at one message: history up to it (without retracted or rewound messages), the records
2981
+ * on that history, and a new room identity. Also returns, per member name, the executions the fork leaves out: any that
2982
+ * touched a later or withdrawn message, or was still running, so each local session branches before them.
2983
+ */
2984
+ function fork_snapshot(snapshot, message_id, room_id, now = new Date().toISOString()) {
2985
+ const tables = snapshot.tables;
2986
+ const withdrawn = new Set((tables.message_withdrawals || []).map(row => row.message_id));
2987
+ const kept = new Set(tables.messages.filter(row => row.id <= message_id && !withdrawn.has(row.id)).map(row => row.id));
2988
+ const on_kept = rows => (rows || []).filter(row => kept.has(row.message_id));
2989
+ const names = new Map((tables.room_members || []).map(member => [member.id, member.name]));
2990
+ const executions = {};
2991
+ for (const request of tables.member_requests || []) {
2992
+ const left_out = !kept.has(request.message_id) || (request.answer_id && !kept.has(request.answer_id)) || request.status === "delivering";
2993
+ if (request.execution_id && left_out) (executions[names.get(request.member_id)] ||= []).push(request.execution_id);
2994
+ }
2995
+ const [room] = tables.rooms;
2996
+ const covered = until => (until || 0) <= message_id;
2997
+ return { executions, body: { format: snapshot.format, version: snapshot.version, tables: { ...tables,
2998
+ rooms: [{ ...room, id: room_id, title: `${room.title}(分支)`, created_at: now,
2999
+ ...(covered(room.state_until) ? {} : { state_text: "", state_until: 0 }) }],
3000
+ room_directory: (tables.room_directory || []).map(row => ({ ...row, archived: 0 })),
3001
+ room_creations: [], room_folder_assignments: [], topic_folders: [], message_withdrawals: [],
3002
+ messages: tables.messages.filter(row => kept.has(row.id)),
3003
+ message_details: on_kept(tables.message_details), message_summaries: on_kept(tables.message_summaries),
3004
+ message_timings: on_kept(tables.message_timings), attachments: on_kept(tables.attachments),
3005
+ member_suggestions: on_kept(tables.member_suggestions), member_recommendations: on_kept(tables.member_recommendations),
3006
+ // A request answered after the fork point is unanswered in the fork.
3007
+ member_requests: on_kept(tables.member_requests).map(row => row.answer_id && !kept.has(row.answer_id)
3008
+ ? { ...row, status: "cancelled", answer_id: null } : row),
3009
+ discussion_turns: (tables.discussion_turns || []).filter(row => !row.answer_id || kept.has(row.answer_id)),
3010
+ scheduler_decisions: (tables.scheduler_decisions || []).filter(row => !row.after_message_id || kept.has(row.after_message_id)),
3011
+ room_briefs: (tables.room_briefs || []).filter(row => covered(row.until)),
3012
+ room_members: (tables.room_members || []).map(row => ({ ...row, read_until: Math.min(row.read_until || 0, message_id) })) } } };
3013
+ }
3014
+
3015
+ /** Copy the fork's attachment files from the source room to their new IDs; returns how many could not be copied. */
3016
+ async function copy_fork_files(source_id, room_id, token, mapping) {
3017
+ let missing = 0;
3018
+ for (const [old_id, new_id] of Object.entries(mapping)) {
3019
+ const original = await fetch(`/api/rooms/${encodeURIComponent(source_id)}/attachments/${encodeURIComponent(old_id)}/content`, { cache: "no-store" });
3020
+ const response = original.ok && await fetch(`/api/rooms/${encodeURIComponent(room_id)}/attachments/${encodeURIComponent(new_id)}/import`,
3021
+ { method: "POST", headers: { "X-Discussion-Token": token, ...admin_headers() }, body: await original.arrayBuffer() });
3022
+ if (!response?.ok) missing++;
3023
+ }
3024
+ return missing;
3025
+ }
3026
+
3027
+ /**
3028
+ * Branch the room into a new one at a message, as Codex branches a chat: the new room keeps the history up to it, each
3029
+ * local member continues its own session from there, and the members come back with new credentials. The source room
3030
+ * and its sessions do not change; files are shared, since both rooms work in the same project directories.
3031
+ */
3032
+ async function fork_room(message_id) {
3033
+ const source = state.room;
3034
+ const source_token = localStorage.getItem(`discuss-access:${source.id}`);
3035
+ if (!source_token) throw new Error("此浏览器缺少房间控制凭据,请使用创建房间的浏览器");
3036
+ const snapshot = await request_json(`/api/rooms/${encodeURIComponent(source.id)}/backup`);
3037
+ const room_id = crypto.randomUUID().replaceAll("-", "").slice(0, 12);
3038
+ const { body, executions } = fork_snapshot(snapshot, message_id, room_id);
3039
+ const token = crypto.randomUUID() + crypto.randomUUID();
3040
+ const result = await request_json("/api/rooms/import", body, token);
3041
+ save_room_credentials({ id: result.id }, token);
3042
+ const problems = [];
3043
+ const missing = await copy_fork_files(source.id, result.id, token, result.attachments);
3044
+ if (missing) problems.push(`${missing} 个附件未能复制`);
3045
+ if (source.folder_id) await request_json(`/api/rooms/${result.id}/folder`, { actor: "用户", folder_id: source.folder_id })
3046
+ .catch(error => problems.push(`未放入原文件夹:${error.message}`));
3047
+ const local = state.launch.available
3048
+ ? await local_launch_request("room-fork", { room_id: result.id, token, source_room_id: source.id, source_token, executions })
3049
+ .catch(error => { problems.push(`成员会话未分叉:${error.message}`); return null; })
3050
+ : (problems.push("未连接本机服务,本机成员会话未分叉"), null);
3051
+ // The directory learns of the new room before it can be opened.
3052
+ await refresh_room();
3053
+ await select_room(result.id);
3054
+ const registered = new Set((local?.members || []).filter(member => member.status !== "failed").map(member => member.name));
3055
+ for (const member of (state.room.free?.members || []).filter(item => item.status === "away")) {
3056
+ if (member.access === "runner" && !registered.has(member.name)) continue;
3057
+ await change_member({ member_id: member.id, name: member.name, action: "rejoin" });
3058
+ if (state.free_error) problems.push(`${member.name} 未能请回:${state.free_error}`);
3059
+ }
3060
+ const fresh = (local?.members || []).filter(member => member.status === "fresh").map(member => member.name);
3061
+ const failed = (local?.members || []).filter(member => member.status === "failed").map(member => `${member.name}(${member.error})`);
3062
+ if (failed.length) problems.push(`未分叉、保持离开:${failed.join("、")}`);
3063
+ const done = `已从 #${message_id} 分支出新房间,原房间不变`;
3064
+ // The rail shows an error in place of a notice, so a partial fork says both in one line.
3065
+ state.free_error = problems.length ? `${done},但${problems.join(";")}` : "";
3066
+ state.free_notice = `${done}${fresh.length ? `;${fresh.join("、")} 将以新会话开始` : ""}。`;
3067
+ publish_view();
3068
+ }
3069
+
2868
3070
  /** Adopt smart scheduling's recommendation: interrupt the host now, or ask the member once the host is done. */
2869
3071
  async function adopt_recommendation({ message_id, mode }) {
2870
3072
  const room_id = state.room.id;
@@ -3679,7 +3881,7 @@ async function execute_view_action(name, value) {
3679
3881
  copy_online: () => copy_context(), copy_cli: () => copy_context("cli"), copy_background,
3680
3882
  choose_runtime, export_runtime, download_member_access, copy_member_access_text, copy_member_join, copy_member_launch: name => copy_member_join(name, true), pause_runtime, check_limits, pause_discussion, restart_discussion, retry_execution, conclude_discussion, start_room_discussion, end_room_discussion, reopen_discussion,
3681
3883
  save_launch_defaults, launch_current_room, resume_launch, discard_launch, retry_launch_connection, check_launch_connection: load_launch_settings,
3682
- load_room_configuration, save_room_configuration, interrupt_member, change_member, reorder_speakers, invite_member, set_proxy, copy_online_notice,
3884
+ load_room_configuration, save_room_configuration, interrupt_member, retract_message, rewind_and_send, fork_room, change_member, reorder_speakers, invite_member, set_proxy, copy_online_notice,
3683
3885
  prepare_brainstorm, return_conclusion, adopt_recommendation, rate_decision,
3684
3886
  busy: () => { throw new Error("上一个操作仍在处理中,请稍候;长时间无响应请刷新页面"); },
3685
3887
  copy_reading: () => copy_text(`${location.origin}/r/${state.room.id}`), copy: copy_text };
@@ -3702,15 +3904,15 @@ function change_view_theme(theme) {
3702
3904
  }
3703
3905
 
3704
3906
  window.discuss_view = { snapshot: view_snapshot, write_field: write_view_field,
3705
- write_launch: write_launch_field, description_template, save_description_template, description_draft, write_description_draft, describe_member, set_chain_rounds, continue_chain, expert_presets, apply_expert_preset, unique_instance, save_expert_preset, delete_expert_preset, write_room_connection, write_room_adapter, launch_clients: LAUNCH_CLIENTS,
3907
+ 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,
3706
3908
  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, launch_can_bind_session, pick_launch_directory, browse_local_directory, save_launch_projects, restore_launch_profile, reset_create_form, empty_discussion_message,
3707
3909
  start_drag: start_directory_drag,
3708
3910
  attachment_markup, attachment_size, attachment_file_icon, drop_attachments, paste_attachments,
3709
3911
  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,
3710
3912
  admin: { register: register_admin, login: login_admin, logout: logout_admin,
3711
3913
  update_profile: update_admin_profile, avatar_from_file: admin_avatar_from_file, validate: validate_admin_form },
3712
- request: request_json, identities: IDENTITIES, display_name, sources: SOURCE_LABELS, phases: PHASE_LABELS,
3713
- set_member_access, set_member_adapter, reload_room_models, runtime_label, execution_label, limits_label, stop_reason_label, participant_model, participant_access, can_retry_execution,
3914
+ request: request_json, rewind_plan, identities: IDENTITIES, display_name, sources: SOURCE_LABELS, phases: PHASE_LABELS,
3915
+ set_member_access, allow_denials, set_member_adapter, reload_room_models, runtime_label, execution_label, limits_label, stop_reason_label, participant_model, participant_files, participant_session_hint, participant_access, can_retry_execution,
3714
3916
  turns: TURN_LABELS, turn_label, turn_guidance, progress_round, round_groups, limit_rounds, progress_speaker, brief_meta, max_message_length: MAX_MESSAGE_LENGTH, provenance: message_provenance, format_time };
3715
3917
 
3716
3918
  restore_theme();