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/.agents/skills/discuss-participant/SKILL.md +1 -1
- package/.agents/skills/discuss-participant/references/free.md +1 -1
- package/backend/admin.py +1 -1
- package/backend/free_room.py +185 -4
- package/backend/room_import.py +2 -0
- package/backend/schema.sql +32 -1
- package/backend/server.py +37 -9
- package/docs/deployment.md +2 -2
- package/docs/en/deployment.md +2 -2
- package/docs/en/workflow.md +98 -96
- package/docs/workflow.md +98 -96
- package/package.json +1 -1
- package/runner/admin_account.mjs +10 -14
- package/runner/codex_app_client.mjs +13 -0
- package/runner/codiscuss.mjs +2 -2
- package/runner/discussion_adapters.mjs +205 -26
- package/runner/discussion_launch.mjs +136 -15
- package/runner/discussion_models.mjs +89 -1
- package/runner/discussion_runner.mjs +16 -13
- package/runner/discussion_service.mjs +98 -19
- package/runner/expert_prompts.mjs +85 -1
- package/runner/free_runner.mjs +38 -20
- package/runner/scheduler.mjs +4 -4
- package/runner/session_cut.mjs +167 -0
- package/web/app.js +234 -32
- package/web/markdown.css +1 -1
- package/web/modern.css +1 -1
- package/web/modern.js +2926 -2517
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
|
-
|
|
809
|
-
return { ...
|
|
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
|
|
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
|
|
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 !==
|
|
1158
|
-
show_toast(`已存为预设「${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
2081
|
-
const
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
const
|
|
2085
|
-
|
|
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)
|
|
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
|
-
|
|
2353
|
-
|
|
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();
|