ai_co_discuss 0.3.46 → 0.3.49

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.
Files changed (43) hide show
  1. package/.agents/skills/discuss-host/SKILL.md +1 -0
  2. package/.agents/skills/discuss-host/references/discussion.md +113 -113
  3. package/.agents/skills/discuss-host/references/free.md +36 -34
  4. package/.agents/skills/discuss-host/references/goal.md +35 -0
  5. package/.agents/skills/discuss-participant/SKILL.md +1 -0
  6. package/.agents/skills/workflow-creator/SKILL.md +2 -2
  7. package/README.md +3 -1
  8. package/backend/admin.py +104 -35
  9. package/backend/free_room.py +2170 -2136
  10. package/backend/goal_room.py +358 -0
  11. package/backend/mode_room.py +105 -0
  12. package/backend/schema.sql +1593 -1510
  13. package/backend/server.py +2636 -2555
  14. package/backend/session_room.py +1180 -1148
  15. package/docs/THIRD_PARTY_NOTICES.md +1293 -1264
  16. package/docs/deployment.md +5 -1
  17. package/docs/en/workflow.md +15 -1
  18. package/docs/runner.md +28 -7
  19. package/docs/workflow.md +15 -1
  20. package/package.json +3 -2
  21. package/runner/adapters/api.mjs +333 -313
  22. package/runner/adapters/claude.mjs +12 -16
  23. package/runner/admin_account.mjs +12 -19
  24. package/runner/device_auth.mjs +369 -0
  25. package/runner/discuss.py +1208 -1125
  26. package/runner/discussion_launch.mjs +107 -12
  27. package/runner/discussion_service.mjs +25 -1
  28. package/runner/expert_prompts.mjs +132 -132
  29. package/runner/free_runner.mjs +982 -910
  30. package/runner/pinyin_table.mjs +406 -0
  31. package/runner/project_files.mjs +52 -3
  32. package/runner/relay_channel.mjs +51 -7
  33. package/runner/remote_launch.mjs +221 -235
  34. package/runner/remote_policy.mjs +156 -0
  35. package/runner/scheduler.mjs +459 -429
  36. package/runner/stt.mjs +2 -2
  37. package/runner/tts.mjs +74 -113
  38. package/runner/tts_providers.mjs +205 -0
  39. package/runner/webauthn.mjs +155 -0
  40. package/runner/workflows.mjs +371 -348
  41. package/web/app.js +355 -51
  42. package/web/modern.css +1 -1
  43. package/web/modern.js +5764 -3816
package/web/app.js CHANGED
@@ -66,7 +66,7 @@ const MANUAL_TRIGGER_INSTRUCTION = "首次复制后,可由人工或软件原
66
66
  const MANAGEMENT_LOGIN_HINT = "需要登录管理员账号或使用创建该房间的浏览器";
67
67
  const THEMES = ["channel", "channel-light"];
68
68
  // proxy and host_proxy label answers relayed for the retired web chatbot members.
69
- const SOURCE_LABELS = { demo: "示例", user: "本人发言", proxy: "用户代发", cli: "CLI 发言", api: "API 发言", url: "历史 URL 发言", host_proxy: "主持人代发" };
69
+ const SOURCE_LABELS = { demo: "示例", user: "本人发言", proxy: "用户代发", cli: "CLI 发言", api: "API 发言", url: "历史 URL 发言", host_proxy: "主持人代发", goal: "Goal" };
70
70
  const TURN_LABELS = { pending: "待处理", notified: "已通知,待回答", answered: "已回答", cancelled: "已取消" };
71
71
  const PLATFORM_IDENTITIES = {
72
72
  "用户": { label: "User", initials: "U", style: "user", icon: "/icons/user.svg", description: "发起与协助" },
@@ -81,7 +81,7 @@ const PLATFORM_IDENTITIES = {
81
81
  // An instance shows its platform's nickname with its own suffix: Antigravity-2 → Gemini-2.
82
82
  const IDENTITIES = by_platform(PLATFORM_IDENTITIES, (identity, name) => ({ ...identity, label: identity.label ? identity.label + name.slice(platform_of(name).length) : name, description: "AI 实例" }));
83
83
  const state = { rooms: [], folders: [], room: null, page: 1, pages: 1, query: "", mention_only: false, host_only: false,
84
- named_followup: false, create_discussion: false,
84
+ named_followup: false, create_discussion: false, create_goal: null,
85
85
  archive_view: false, directory_sort: "manual", management: null, dragged_room: null, folder_moves: new Map(), folder_version: 0,
86
86
  reply_to: null, tab: "discussion", refresh_version: 0, context_dirty: false, topic_dirty: false,
87
87
  snapshot_id: 0, pending_submission: null, pending_room: null, toast_timer: null, editing_queued: null };
@@ -124,6 +124,8 @@ state.directory_pending = new Map();
124
124
  state.directory_version = 0;
125
125
  state.navigation_version = 0;
126
126
  state.admin = { loaded: false, local: false, registered: false, setup_available: false, logged_in: false, profile: null, identity_version: 0 };
127
+ // needs: "" | "pairing" | "renew"; pairing_code arrives from a scanned link (#pair=CODE); prompt waits for the user's tap.
128
+ state.device = { id: "", needs: "", pairing_code: "", prompt: null };
127
129
  state.launch = { available: false, remote: false, relay: null, connect_command: "", profiles: {}, projects: [], presets: [], builtin_presets: [], hidden_builtins: [], workflows: [], description_templates: {}, workspace: {}, scheduler: {}, error: "", recoveries: [] };
128
130
  // The workflow chosen in the new-room form: { id, label, members: { card: instance } }; cards removed from the form drop out at submission.
129
131
  state.create_workflow = null;
@@ -204,22 +206,9 @@ function notify_failure(error, message) {
204
206
  error.notified = true;
205
207
  }
206
208
 
207
- /** The administrator session token kept by this browser, or an empty string. */
208
- function admin_token() {
209
- if (state.admin.local) return "";
210
- try { return localStorage.getItem(ADMIN_SESSION_KEY) || ""; } catch { return ""; }
211
- }
212
-
213
- /** Store or clear the administrator session token; storage failures only lose persistence. */
214
- function store_admin_token(token) {
215
- try { token ? localStorage.setItem(ADMIN_SESSION_KEY, token) : localStorage.removeItem(ADMIN_SESSION_KEY); } catch { /* Keep the session in memory only. */ }
216
- }
217
-
218
- /** The administrator header for writes that bypass request_json, or no header without a session. */
219
- function admin_headers() {
220
- const session = admin_token();
221
- return session ? { "X-Discussion-Admin": session } : {};
222
- }
209
+ // The administrator login is an HttpOnly cookie the website sets; this page never sees or stores the token. An older
210
+ // version kept it in localStorage, where any script could read it, so that copy is deleted.
211
+ try { localStorage.removeItem(ADMIN_SESSION_KEY); } catch { /* Nothing stored. */ }
223
212
 
224
213
  /** Request JSON from the local or remotely hosted discussion service. */
225
214
  async function request_json(path, payload, access_token) {
@@ -230,9 +219,7 @@ async function request_json(path, payload, access_token) {
230
219
  const options = { cache: "no-store" };
231
220
  const target_room = path.match(/^\/api\/rooms\/([^/?]+)/)?.[1];
232
221
  const token = access_token || (target_room ? localStorage.getItem(`discuss-access:${target_room}`) : null);
233
- const session = admin_token();
234
222
  options.headers = token ? { "X-Discussion-Token": token } : {};
235
- if (session) options.headers["X-Discussion-Admin"] = session;
236
223
  if (payload !== undefined) {
237
224
  options.method = "POST";
238
225
  options.headers["Content-Type"] = "application/json";
@@ -259,7 +246,6 @@ function apply_admin_identity(profile) {
259
246
  /** Record the server's administrator state and refresh every identity-dependent view without a reload. */
260
247
  function apply_admin_state(info) {
261
248
  const local = info.local === true;
262
- if (local || !info.logged_in) store_admin_token("");
263
249
  Object.assign(state.admin, { loaded: true, local, computer_rooms: info.computer_rooms === true, registered: Boolean(info.registered), setup_available: Boolean(info.setup_available),
264
250
  logged_in: Boolean(info.logged_in), profile: info.profile ? { username: info.profile.username, avatar: info.profile.avatar || "" } : null });
265
251
  apply_admin_identity(state.admin.profile);
@@ -289,9 +275,8 @@ function admin_error(error) {
289
275
  return error;
290
276
  }
291
277
 
292
- /** Store the new session and show the owner's identity. */
293
- function accept_admin_session({ token, profile }) {
294
- store_admin_token(token);
278
+ /** Show the owner's identity once signed in; the website keeps the login in its cookie. */
279
+ function accept_admin_session({ profile }) {
295
280
  apply_admin_state({ registered: true, setup_available: false, logged_in: true, profile, computer_rooms: state.admin.computer_rooms });
296
281
  // Pairing and the paired computer both need the session, so read them again now.
297
282
  computer_rooms_only() ? connect_computer_rooms() : load_launch_settings();
@@ -303,11 +288,6 @@ async function register_admin({ username, password, confirm, setup_key }) {
303
288
  if (problem) throw new Error(problem);
304
289
  try { accept_admin_session(await request_json("/api/admin/register", { username: username.trim(), password, setup_key: setup_key.trim() })); }
305
290
  catch (error) { throw admin_error(error); }
306
- // Registered from the computer running the local service: keep the account in its private directory.
307
- if (local_connection) {
308
- try { show_toast(`账号与密码已保存到本机 ${(await local_launch_request("admin-account", { username: username.trim(), password })).saved}`); }
309
- catch (error) { show_toast(`注册成功,但账号未能保存到本机:${error.message}`, true); }
310
- }
311
291
  }
312
292
 
313
293
  /** Sign in and keep the session in this browser. */
@@ -317,11 +297,10 @@ async function login_admin({ username, password }) {
317
297
  catch (error) { throw admin_error(error); }
318
298
  }
319
299
 
320
- /** Leave this browser; the local session is dropped even if the server cannot be reached. */
300
+ /** Leave this browser: the website ends the login and clears its cookie. */
321
301
  async function logout_admin() {
322
302
  try { await request_json("/api/admin/logout", {}); }
323
- catch { /* The token is removed below either way. */ }
324
- store_admin_token("");
303
+ catch { /* Shown as signed out either way; the login also expires on its own. */ }
325
304
  state.admin.logged_in = false;
326
305
  state.launch.relay = null;
327
306
  if (state.remote_rooms) await show_computer_rooms(false).catch(() => {});
@@ -330,6 +309,32 @@ async function logout_admin() {
330
309
  publish_view();
331
310
  }
332
311
 
312
+ /** List this account's live logins, each with the browser that signed in and whether it is this one. */
313
+ async function admin_sessions() {
314
+ return (await request_json("/api/admin/sessions")).sessions;
315
+ }
316
+
317
+ /** The passkeys that can sign in to this website, and removing one (a lost device's). */
318
+ async function admin_passkeys() {
319
+ return (await request_json("/api/admin/passkeys")).passkeys;
320
+ }
321
+ async function delete_admin_passkey(id) {
322
+ await request_json("/api/admin/passkey-delete", { id });
323
+ }
324
+
325
+ /** End one other login by id, or every login but this one with { others: true }. */
326
+ async function revoke_admin_sessions(target) {
327
+ await request_json("/api/admin/revoke", target);
328
+ }
329
+
330
+ /** Change the password after proving the current one; the website ends every other login. */
331
+ async function change_admin_password({ current_password, password, confirm }) {
332
+ const problem = validate_admin_form({ username: "x", password, confirm });
333
+ if (problem) throw new Error(problem);
334
+ try { await request_json("/api/admin/password", { current_password, password }); }
335
+ catch (error) { throw admin_error(error); }
336
+ }
337
+
333
338
  /** Save the display name and/or avatar; an empty avatar restores the default. */
334
339
  async function update_admin_profile(patch) {
335
340
  if (patch.username !== undefined) {
@@ -369,7 +374,7 @@ async function admin_avatar_from_file(file) {
369
374
  /** Whether this browser may write as the room owner: its own control credential, a logged-in administrator session, or the
370
375
  * loopback-only local site, whose server writes as the owner for every visitor (`admin_as_owner`). */
371
376
  function has_room_control(room_id) {
372
- return Boolean(localStorage.getItem(`discuss-access:${room_id}`) || state.admin.local || (state.admin.logged_in && admin_token()));
377
+ return Boolean(localStorage.getItem(`discuss-access:${room_id}`) || state.admin.local || state.admin.logged_in);
373
378
  }
374
379
 
375
380
  /** Explain why a management write failed when this browser has neither credential. */
@@ -1141,6 +1146,7 @@ function reset_create_form() {
1141
1146
  state.launch.scheduler = default_scheduler();
1142
1147
  state.named_followup = false;
1143
1148
  state.create_discussion = false;
1149
+ state.create_goal = null;
1144
1150
  state.create_chain_rounds = DEFAULT_CHAIN_ROUNDS;
1145
1151
  state.create_workflow = null;
1146
1152
  state.create_host_workflows = false;
@@ -1948,8 +1954,12 @@ async function submit_room(event, folder_id = null) {
1948
1954
  const discussion = state.create_discussion ? { goal: payload.goal.trim(), named_followup: state.named_followup,
1949
1955
  limits: read_limits("new-room") } : null;
1950
1956
  if (discussion && !(launching && payload.host && payload.experts[payload.host] === "cli")) throw new Error("开启讨论需要一位由本机唤醒的 AI 主持人;请先添加并设为主持人");
1951
- // The key joins the signature only when a discussion is requested, so recovered requests keep matching their stored signature.
1952
- const signed = (current_payload, current_draft) => JSON.stringify(discussion ? { payload: current_payload, launch_draft: current_draft, discussion } : { payload: current_payload, launch_draft: current_draft });
1957
+ const goal = state.create_goal ? { ...state.create_goal, objective: state.create_goal.objective.trim() } : null;
1958
+ if (goal && !goal.objective) throw new Error("请填写 Goal 目标");
1959
+ if (goal && !(launching && payload.host && payload.experts[payload.host] === "cli")) throw new Error("开启 Goal 需要一位由本机唤醒的 AI 主持人;请先添加并设为主持人");
1960
+ // The key joins the signature only when a discussion or goal is requested, so recovered requests keep matching their stored signature.
1961
+ const signed = (current_payload, current_draft) => JSON.stringify(discussion ? { payload: current_payload, launch_draft: current_draft, discussion }
1962
+ : goal ? { payload: current_payload, launch_draft: current_draft, goal } : { payload: current_payload, launch_draft: current_draft });
1953
1963
  const signature = signed(payload, launch_draft);
1954
1964
  const navigation = state.navigation_version;
1955
1965
  if (!launching) element("room-dialog").close();
@@ -1969,6 +1979,7 @@ async function submit_room(event, folder_id = null) {
1969
1979
  } });
1970
1980
  state.pending_room = null;
1971
1981
  if (discussion) await open_discussion_after_creation(room.id, discussion);
1982
+ if (goal) await open_goal_after_creation(room.id, goal);
1972
1983
  if (signed(room_creation_payload(folder_id), launching ? creation_launch_options() : null) === signature) {
1973
1984
  element("room-form").reset();
1974
1985
  render_expert_inputs("new-expert-modes", default_experts());
@@ -1990,6 +2001,27 @@ async function open_discussion_after_creation(room_id, { goal, named_followup, l
1990
2001
  }
1991
2002
  }
1992
2003
 
2004
+ /** Open the goal of a room just created; a failure leaves the room as an ordinary free room. */
2005
+ async function open_goal_after_creation(room_id, { objective, max_continues, max_minutes }) {
2006
+ const path = `/api/rooms/${room_id}/goal`;
2007
+ try {
2008
+ const current = await request_json(path);
2009
+ await request_json(path, { action: "start", revision: current.revision, objective, max_continues, max_minutes });
2010
+ state.create_goal = null;
2011
+ } catch (error) {
2012
+ notify("warning", `房间已创建,但开启 Goal 失败:${error.message};可在右侧成员栏重试`);
2013
+ }
2014
+ }
2015
+
2016
+ /** Keep a pairing code from a scanned link (#pair=CODE) for the settings form, and take it out of the address bar. */
2017
+ function take_pairing_code() {
2018
+ const match = (location.hash || "").match(/(?:^#|&)pair=([0-9A-Za-z-]{12,16})/);
2019
+ if (!match) return;
2020
+ state.device.pairing_code = match[1].toUpperCase();
2021
+ history.replaceState(null, "", `${location.pathname}${location.search}`);
2022
+ notify("info", "已读取配对码:登录后在 设置 → 账号 中完成配对");
2023
+ }
2024
+
1993
2025
  /** Read a site-bound connection without exposing its credential to the shared view snapshot. */
1994
2026
  function restore_local_connection() {
1995
2027
  const fragment = new URLSearchParams(location.hash?.slice(1) || "");
@@ -2052,13 +2084,208 @@ async function relay_cipher(private_key, public_jwk) {
2052
2084
  material, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
2053
2085
  }
2054
2086
 
2055
- /** Seal one request so that only the paired computer can read it and seal the answer. */
2087
+
2088
+ // ---- Device trust (.dev/plans/远程访问安全加固/PROTOCOL.md): this browser's own signing key, pairing by a code shown on a
2089
+ // trusted screen, and passkey confirmations. The paired computer decides; the website only forwards. ----
2090
+ const DEVICE_DB = "codiscuss-device";
2091
+ const PASSKEY_TIMEOUT_MS = 60000;
2092
+ let device_identity_promise = null;
2093
+
2094
+ function bytes_to_b64url(bytes) {
2095
+ return bytes_to_base64(new Uint8Array(bytes)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
2096
+ }
2097
+
2098
+ function b64url_to_bytes(text) {
2099
+ const base = String(text || "").replaceAll("-", "+").replaceAll("_", "/");
2100
+ return base64_to_bytes(base + "=".repeat((4 - base.length % 4) % 4));
2101
+ }
2102
+
2103
+ /** Open the browser's store for the device key; a browser without one keeps the key for this page only. */
2104
+ function device_store(mode, use) {
2105
+ return new Promise((resolve, reject) => {
2106
+ const opening = indexedDB.open(DEVICE_DB, 1);
2107
+ opening.onupgradeneeded = () => opening.result.createObjectStore("keys");
2108
+ opening.onerror = () => reject(opening.error);
2109
+ opening.onsuccess = () => {
2110
+ const transaction = opening.result.transaction("keys", mode), request = use(transaction.objectStore("keys"));
2111
+ request.onsuccess = () => resolve(request.result);
2112
+ request.onerror = () => reject(request.error);
2113
+ };
2114
+ });
2115
+ }
2116
+
2117
+ /** This browser's signing key, created once and never exportable; its id is the first 32 hex digits of SHA-256 of the public key. */
2118
+ function device_identity() {
2119
+ device_identity_promise ||= (async () => {
2120
+ let saved = await device_store("readonly", store => store.get("device")).catch(() => null);
2121
+ if (!saved) {
2122
+ const pair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, ["sign", "verify"]);
2123
+ const raw = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
2124
+ const { kty, crv, x, y } = await crypto.subtle.exportKey("jwk", pair.publicKey);
2125
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", raw));
2126
+ saved = { private_key: pair.privateKey, public_jwk: { kty, crv, x, y }, raw: bytes_to_b64url(raw),
2127
+ id: Array.from(digest.slice(0, 16), byte => byte.toString(16).padStart(2, "0")).join("") };
2128
+ await device_store("readwrite", store => store.put(saved, "device")).catch(() => {});
2129
+ }
2130
+ state.device.id = saved.id;
2131
+ return saved;
2132
+ })();
2133
+ return device_identity_promise;
2134
+ }
2135
+
2136
+ /** A short device name from the browser's own description, e.g. "iPhone · Safari". */
2137
+ function device_label(agent = navigator.userAgent) {
2138
+ const text = String(agent || "");
2139
+ const system = /iPhone/.test(text) ? "iPhone" : /iPad/.test(text) ? "iPad" : /Android/.test(text) ? "Android" : /Windows/.test(text) ? "Windows"
2140
+ : /Mac OS X|Macintosh/.test(text) ? "Mac" : /Linux/.test(text) ? "Linux" : "";
2141
+ const browser = /Edg\//.test(text) ? "Edge" : /Chrome\//.test(text) ? "Chrome" : /Firefox\//.test(text) ? "Firefox" : /Safari\//.test(text) ? "Safari" : "";
2142
+ return [system, browser].filter(Boolean).join(" · ") || "未知设备";
2143
+ }
2144
+
2145
+ /** Run a device action on the paired computer, or on this computer's own service from its local page. */
2146
+ async function device_request(action, payload = {}) {
2147
+ if (relay_machine) return relay_command(action, payload);
2148
+ if (action === "device-pairing") return local_launch_request("device-pairing", payload);
2149
+ if (action === "device-list") return local_launch_request("devices", {});
2150
+ if (action === "device-revoke") return local_launch_request("devices", { revoke: payload.device_id });
2151
+ if (action === "write-revoke") return local_launch_request("devices", { revoke_write: payload });
2152
+ throw new Error("请在已配对的设备上操作");
2153
+ }
2154
+
2155
+ /** Pair this browser with the code shown on a trusted screen: the code authenticates this browser's public key. */
2156
+ async function pair_this_device(code) {
2157
+ const machine = relay_machine;
2158
+ if (!machine) throw new Error("请先登录并确认电脑在线,再输入配对码");
2159
+ const normal = String(code || "").toUpperCase().replace(/[^0-9A-Z]/g, "");
2160
+ if (normal.length !== 12) throw new Error("配对码是 12 位字母和数字");
2161
+ const device = await device_identity();
2162
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(normal), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
2163
+ const proof = bytes_to_b64url(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`codiscuss-pair-v1\n${machine.id}\n${device.raw}`)));
2164
+ const result = await relay_command("device-pair", { public_key: device.public_jwk, label: device_label(), proof });
2165
+ Object.assign(state.device, { needs: "", pairing_code: "" });
2166
+ publish_view();
2167
+ // Settings and the computer's rooms load now that the computer accepts this device.
2168
+ load_launch_settings();
2169
+ if (computer_rooms_only()) connect_computer_rooms();
2170
+ return result;
2171
+ }
2172
+
2173
+ /** The page's own passkey confirmation: WebAuthn needs the user's tap, so options are fetched first and the call runs on the tap. */
2174
+ function passkey_prompt({ title, description, confirm_label, run }) {
2175
+ return new Promise((resolve, reject) => {
2176
+ state.device.prompt = { title, description, confirm_label, run: async () => {
2177
+ state.device.prompt = null;
2178
+ publish_view();
2179
+ try { resolve(await run()); } catch (error) { reject(error); }
2180
+ }, cancel: () => { state.device.prompt = null; publish_view(); reject(Object.assign(new Error("已取消"), { cancelled: true })); } };
2181
+ publish_view();
2182
+ });
2183
+ }
2184
+
2185
+ /** Ask this device's passkey to sign a challenge; returns the assertion fields the computer or website checks. */
2186
+ async function passkey_assertion(options) {
2187
+ const credential = await navigator.credentials.get({ publicKey: { challenge: b64url_to_bytes(options.challenge), rpId: options.rp_id,
2188
+ allowCredentials: (options.allow || []).map(id => ({ type: "public-key", id: b64url_to_bytes(id) })), userVerification: "required", timeout: PASSKEY_TIMEOUT_MS } });
2189
+ return { id: bytes_to_b64url(credential.rawId), client_data_json: bytes_to_b64url(credential.response.clientDataJSON),
2190
+ authenticator_data: bytes_to_b64url(credential.response.authenticatorData), signature: bytes_to_b64url(credential.response.signature) };
2191
+ }
2192
+
2193
+ /** Create one passkey for this paired device, registered with both the computer and the website in a single step. */
2194
+ async function create_device_passkey() {
2195
+ const computer = await relay_command("passkey-options", {});
2196
+ const website = await request_json("/api/admin/passkey-challenge", { purpose: "register" });
2197
+ const challenge = new Uint8Array(await crypto.subtle.digest("SHA-256", new Uint8Array([...b64url_to_bytes(computer.challenge), ...b64url_to_bytes(website.challenge)])));
2198
+ return passkey_prompt({ title: "为这台设备创建通行密钥", description: "之后用指纹、面容或设备密码登录、续期和开启远程可写,不用再输密码。", confirm_label: "创建通行密钥",
2199
+ run: async () => {
2200
+ const created = await navigator.credentials.create({ publicKey: { challenge, rp: computer.rp,
2201
+ user: { id: b64url_to_bytes(computer.user_id), name: computer.user_name || "codiscuss", displayName: computer.user_name || "AI CoDiscuss" },
2202
+ pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }],
2203
+ authenticatorSelection: { userVerification: "required", residentKey: "preferred" }, attestation: "none", timeout: PASSKEY_TIMEOUT_MS,
2204
+ excludeCredentials: (computer.exclude || []).map(id => ({ type: "public-key", id: b64url_to_bytes(id) })) } });
2205
+ const credential = { id: bytes_to_b64url(created.rawId), client_data_json: bytes_to_b64url(created.response.clientDataJSON),
2206
+ attestation_object: bytes_to_b64url(created.response.attestationObject) };
2207
+ await relay_command("passkey-register", { challenge_id: computer.challenge_id, website_challenge: website.challenge, credential });
2208
+ await request_json("/api/admin/passkey-register", { challenge_id: website.challenge_id, computer_challenge: computer.challenge, credential, label: device_label() });
2209
+ notify("info", "已创建通行密钥");
2210
+ } });
2211
+ }
2212
+
2213
+ /** Show a pairing code for another device: from the computer's own page directly, from a paired device after its passkey. */
2214
+ async function invite_device(temporary) {
2215
+ if (!relay_machine) return local_launch_request("device-pairing", { temporary });
2216
+ const options = await relay_command("passkey-challenge", { purpose: "pair" });
2217
+ return passkey_prompt({ title: "添加新设备", description: "新设备将获得与这台设备相同的远程权限,请用通行密钥确认是你本人在操作。", confirm_label: "用通行密钥确认",
2218
+ run: async () => relay_command("device-pairing", { temporary, challenge_id: options.challenge_id, assertion: await passkey_assertion(options) }) });
2219
+ }
2220
+
2221
+ /** Confirm remote writing in one room with this device's passkey; the computer grants twelve hours. */
2222
+ async function confirm_write_grant(room_id) {
2223
+ const options = await relay_command("passkey-challenge", { purpose: "write", room_id });
2224
+ return passkey_prompt({ title: "确认远程可写", description: "这个房间的成员可以改动电脑上的文件或运行命令。确认后,这台设备 12 小时内可以在本房间远程操作;可随时在 设置 → 账号 中撤销。",
2225
+ confirm_label: "用通行密钥确认",
2226
+ run: async () => {
2227
+ const result = await relay_command("write-grant", { challenge_id: options.challenge_id, assertion: await passkey_assertion(options), room_id });
2228
+ notify("info", "已开启远程可写,12 小时内有效");
2229
+ return result;
2230
+ } });
2231
+ }
2232
+
2233
+ /** Extend this device's trust by thirty days with its passkey. */
2234
+ async function renew_this_device() {
2235
+ const options = await relay_command("passkey-challenge", { purpose: "renew" });
2236
+ return passkey_prompt({ title: "续期这台设备", description: "用通行密钥确认后,这台设备再获得 30 天授权。", confirm_label: "用通行密钥确认",
2237
+ run: async () => {
2238
+ await relay_command("device-renew", { challenge_id: options.challenge_id, assertion: await passkey_assertion(options) });
2239
+ state.device.needs = "";
2240
+ notify("info", "已续期 30 天");
2241
+ } });
2242
+ }
2243
+
2244
+ /** Sign in to the website with a passkey instead of the password. */
2245
+ async function login_with_passkey() {
2246
+ const options = await request_json("/api/admin/passkey-challenge", { purpose: "login" });
2247
+ return passkey_prompt({ title: "用通行密钥登录", description: "用这台设备的指纹、面容或设备密码登录。", confirm_label: "继续",
2248
+ run: async () => {
2249
+ try { accept_admin_session(await request_json("/api/admin/passkey-login", { challenge_id: options.challenge_id, assertion: await passkey_assertion(options) })); }
2250
+ catch (error) { throw admin_error(error); }
2251
+ } });
2252
+ }
2253
+
2254
+ /**
2255
+ * React to what the computer asked for: pair or renew this device (shown in settings), or confirm writing in a room
2256
+ * and run the request once more. Returns true when the request should be retried.
2257
+ */
2258
+ async function meet_device_need(error) {
2259
+ if (error.need === "write-grant" && error.room_id) {
2260
+ await confirm_write_grant(error.room_id);
2261
+ return true;
2262
+ }
2263
+ if (error.need === "pairing" || error.need === "renew") {
2264
+ if (state.device.needs !== error.need) notify("warning", error.need === "pairing" ? "这台设备还没有与电脑配对:打开 设置 → 账号,扫描或输入配对码" : "这台设备的授权已到期:打开 设置 → 账号 用通行密钥续期");
2265
+ state.device.needs = error.need;
2266
+ publish_view();
2267
+ }
2268
+ return false;
2269
+ }
2270
+
2271
+ /**
2272
+ * Seal one request so that only the paired computer can read it and seal the answer. This browser signs it with its
2273
+ * device key; only a pairing request goes unsigned. The send time lets the computer refuse a replay.
2274
+ */
2056
2275
  async function seal_relay_request(machine, id, request) {
2057
2276
  const encoder = new TextEncoder();
2277
+ // The computer it is meant for is signed too, so the request cannot be passed on to another paired computer.
2278
+ const body = JSON.stringify({ ...request, machine: machine.id, sent_at: Date.now() });
2279
+ let plain = { body };
2280
+ if (request.action !== "device-pair") {
2281
+ const device = await device_identity();
2282
+ const signature = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, device.private_key, encoder.encode(`${id}\n${body}`));
2283
+ plain = { body, device_id: device.id, signature: bytes_to_b64url(signature) };
2284
+ }
2058
2285
  const ephemeral = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
2059
2286
  const key = await relay_cipher(ephemeral.privateKey, machine.public_key);
2060
2287
  const iv = crypto.getRandomValues(new Uint8Array(12));
2061
- const data = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: encoder.encode(id) }, key, encoder.encode(JSON.stringify(request)));
2288
+ const data = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: encoder.encode(id) }, key, encoder.encode(JSON.stringify(plain)));
2062
2289
  const { kty, crv, x, y } = await crypto.subtle.exportKey("jwk", ephemeral.publicKey);
2063
2290
  return { key, envelope: { key: { kty, crv, x, y }, iv: bytes_to_base64(iv), data: bytes_to_base64(data) } };
2064
2291
  }
@@ -2102,8 +2329,17 @@ async function queued_relay_command(action, payload, limit) {
2102
2329
  throw Object.assign(new Error(`电脑「${machine.name}」未在限时内答复,请确认它开机并运行 codiscuss 后重试`), { launch: { stage: "unconfirmed" } });
2103
2330
  }
2104
2331
 
2105
- /** Run one local-service operation on the paired computer and answer as the local bridge would. */
2332
+ /** Run one local-service operation on the paired computer; a write confirmation the computer asks for runs it once more. */
2106
2333
  async function relay_command(action, payload) {
2334
+ try { return await relay_command_once(action, payload); }
2335
+ catch (error) {
2336
+ if (await meet_device_need(error)) return relay_command_once(action, payload);
2337
+ throw error;
2338
+ }
2339
+ }
2340
+
2341
+ /** Run one local-service operation on the paired computer and answer as the local bridge would. */
2342
+ async function relay_command_once(action, payload) {
2107
2343
  // The folder dialog would open on the computer where nobody sees it.
2108
2344
  if (action === "pick-directory") throw new Error("请直接输入电脑上的完整路径");
2109
2345
  const limit = payload === undefined ? RELAY_SETTINGS_WAIT_MS : ["room-models", "models", "api-check", "scheduler-check"].includes(action)
@@ -2113,12 +2349,21 @@ async function relay_command(action, payload) {
2113
2349
  try { answer = await relay_exchange(action, payload, limit); }
2114
2350
  catch (error) { if (computer_rooms_only() || (error.status !== 404 && !error.offline)) throw error; }
2115
2351
  const { status, body } = answer ?? await queued_relay_command(action, payload, limit);
2116
- if (status >= 400) throw Object.assign(new Error(body.error || "本机操作失败"), { launch: body.launch });
2352
+ if (status >= 400) throw Object.assign(new Error(body.error || "本机操作失败"), { launch: body.launch, need: body.need, room_id: body.room_id });
2117
2353
  return body;
2118
2354
  }
2119
2355
 
2120
2356
  /** Read or write one room route on the paired computer's local website, as its owner, and answer as request_json would. */
2121
2357
  async function relay_room_request(path, payload) {
2358
+ try { return await relay_room_request_once(path, payload); }
2359
+ catch (error) {
2360
+ if (await meet_device_need(error)) return relay_room_request_once(path, payload);
2361
+ throw error;
2362
+ }
2363
+ }
2364
+
2365
+ /** Read or write one room route on the paired computer once. */
2366
+ async function relay_room_request_once(path, payload) {
2122
2367
  let answer;
2123
2368
  for (let attempt = 0; ; attempt += 1) {
2124
2369
  try { answer = await relay_exchange("room-api", { method: payload === undefined ? "GET" : "POST", path, body: payload }); break; }
@@ -2129,7 +2374,8 @@ async function relay_room_request(path, payload) {
2129
2374
  await new Promise((resolve) => setTimeout(resolve, RELAY_READ_RETRY_MS[attempt]));
2130
2375
  }
2131
2376
  }
2132
- if (answer.status >= 400) throw Object.assign(new Error(answer.body?.error || "请求失败,请重试"), { preflight: answer.status < 500, status: answer.status });
2377
+ if (answer.status >= 400) throw Object.assign(new Error(answer.body?.error || "请求失败,请重试"),
2378
+ { preflight: answer.status < 500, status: answer.status, need: answer.body?.need, room_id: answer.body?.room_id });
2133
2379
  return answer;
2134
2380
  }
2135
2381
 
@@ -2167,7 +2413,7 @@ async function relay_upload(path, data) {
2167
2413
  async function post_room_file(path, body, token) {
2168
2414
  if (state.remote_rooms) return relay_upload(path, body);
2169
2415
  const response = await fetch(path, { method: "POST",
2170
- headers: { "Content-Type": "application/octet-stream", ...(token ? { "X-Discussion-Token": token } : {}), ...admin_headers() }, body });
2416
+ headers: { "Content-Type": "application/octet-stream", ...(token ? { "X-Discussion-Token": token } : {}) }, body });
2171
2417
  const result = await response.json();
2172
2418
  if (!response.ok) throw new Error(result.error || "附件上传失败");
2173
2419
  return result;
@@ -2262,7 +2508,8 @@ function relay_event_source() {
2262
2508
  const target = new EventTarget();
2263
2509
  const emit = (type, data) => target.dispatchEvent(Object.assign(new Event(type), { data }));
2264
2510
  const url = `${location.origin.replace(/^http/, "ws")}/api/machines/${encodeURIComponent(relay_machine.id)}/relay`;
2265
- const socket = new WebSocket(url, [RELAY_PROTOCOL, `a.${admin_token()}`]);
2511
+ // The login cookie goes with the upgrade, so no credential travels in the subprotocol.
2512
+ const socket = new WebSocket(url, [RELAY_PROTOCOL]);
2266
2513
  let ping;
2267
2514
  target.readyState = 0;
2268
2515
  socket.addEventListener("open", () => {
@@ -2403,6 +2650,7 @@ async function load_launch_settings() {
2403
2650
  try {
2404
2651
  local_connection = null;
2405
2652
  relay_machine = null;
2653
+ take_pairing_code();
2406
2654
  local_connection = restore_local_connection();
2407
2655
  state.launch.remote = Boolean(local_connection);
2408
2656
  if (local_connection) state.launch.connect_command = `codiscuss local connect "${location.origin}"`;
@@ -2429,7 +2677,8 @@ async function load_launch_settings() {
2429
2677
  if (!Object.keys(state.launch.scheduler).length) state.launch.scheduler = default_scheduler();
2430
2678
  load_room_models();
2431
2679
  } catch (error) {
2432
- relay_machine = null;
2680
+ // An unpaired or expired device keeps its way to the computer: pairing and renewal go through it.
2681
+ relay_machine = error.need === "pairing" || error.need === "renew" ? relay_machine : null;
2433
2682
  state.launch.remote = Boolean(local_connection);
2434
2683
  Object.assign(state.launch, { available: false, room_models: null, error: error.message });
2435
2684
  // Without a reachable local service, launches can still go through the paired computer.
@@ -2441,13 +2690,14 @@ async function load_launch_settings() {
2441
2690
 
2442
2691
  /** The computer paired with this site, most recently seen first; only the administrator can read or use it. */
2443
2692
  async function load_relay_machine() {
2444
- if (!admin_token()) return null;
2693
+ // The admin state is read before launch settings at start-up, so this knows whether a login cookie exists.
2694
+ if (state.admin.local || !state.admin.logged_in) return null;
2445
2695
  try { return (await request_json("/api/machines")).machines[0] || null; } catch { return null; }
2446
2696
  }
2447
2697
 
2448
2698
  /** Pair the connected computer so devices without the local service can launch on it; needs the administrator session. */
2449
2699
  async function pair_local_machine() {
2450
- if (!admin_token()) return;
2700
+ if (state.admin.local || !state.admin.logged_in) return;
2451
2701
  try { await request_json("/api/machines", await local_launch_request("machine-link", {})); }
2452
2702
  catch (error) { console.warn("Pairing this computer failed:", error.message); }
2453
2703
  }
@@ -2768,11 +3018,14 @@ function workflow_preset({ card, requested_access, needs_platform, ...preset })
2768
3018
  return preset;
2769
3019
  }
2770
3020
 
2771
- /** Fill the new-room form with a workflow's cards, each a preset-made instance, and remember which card each plays. */
3021
+ /**
3022
+ * Fill the new-room form with a workflow's cards, each a preset-made instance, and remember which card each plays.
3023
+ * Choosing another (or the same one again after editing) replaces the previous cards; clearing the choice keeps them as ordinary members.
3024
+ */
2772
3025
  async function choose_create_workflow(id) {
2773
3026
  const workflow = id ? await local_launch_request("workflows", { action: "get", id }) : null;
2774
- for (const name of Object.values(state.create_workflow?.members || {})) write_view_expert("new-expert-modes", name, "");
2775
3027
  if (!workflow) { state.create_workflow = null; return publish_view(); }
3028
+ for (const name of Object.values(state.create_workflow?.members || {})) write_view_expert("new-expert-modes", name, "");
2776
3029
  const members = {};
2777
3030
  for (const agent of workflow.agents) members[agent.card] = add_preset_instance("new-expert-modes", workflow_preset(agent), "cli");
2778
3031
  state.create_workflow = { id, label: workflow.label || id, members };
@@ -2825,6 +3078,14 @@ function write_create_chain_rounds(value) {
2825
3078
  /** Choose whether a new room opens a discussion right away; the room itself is always the same collaboration room. */
2826
3079
  function write_create_discussion(value) {
2827
3080
  state.create_discussion = Boolean(value);
3081
+ if (state.create_discussion) state.create_goal = null;
3082
+ publish_view();
3083
+ }
3084
+
3085
+ /** Turn the new room's goal on (with a patch of its fields) or off; a goal and a discussion exclude each other. */
3086
+ function write_create_goal(patch) {
3087
+ state.create_goal = patch === null ? null : { objective: "", max_continues: 0, max_minutes: 0, ...(state.create_goal || {}), ...patch };
3088
+ if (state.create_goal) state.create_discussion = false;
2828
3089
  publish_view();
2829
3090
  }
2830
3091
 
@@ -2948,6 +3209,11 @@ async function browse_local_directory() {
2948
3209
  return (await local_launch_request("pick-directory", {})).path || "";
2949
3210
  }
2950
3211
 
3212
+ /** List one page of folders another device may choose on the paired computer: the saved projects, or folders inside them. */
3213
+ async function list_remote_directories(path = "", offset = 0) {
3214
+ return local_launch_request("browse-directory", { path, offset });
3215
+ }
3216
+
2951
3217
  /** Let the user browse for the project folder on the computer running the local service. */
2952
3218
  async function pick_launch_directory() {
2953
3219
  try {
@@ -3151,6 +3417,38 @@ async function start_room_discussion({ goal, max_rounds, max_minutes, named_foll
3151
3417
  show_toast("讨论已开启,由主持人推进;需本机接入程序保持运行");
3152
3418
  }
3153
3419
 
3420
+ /** Open a goal from the page with the revision just read; the host is woken right away to start on it. */
3421
+ async function start_room_goal({ objective, max_continues = 0, max_minutes = 0 }) {
3422
+ const room_id = state.room.id, path = `/api/rooms/${room_id}/goal`;
3423
+ if (!has_room_control(room_id)) throw new Error(MANAGEMENT_LOGIN_HINT);
3424
+ const current = await request_json(path);
3425
+ if (state.room?.id !== room_id) return;
3426
+ await request_json(path, { action: "start", revision: current.revision, objective, max_continues, max_minutes });
3427
+ if (state.room?.id !== room_id) return;
3428
+ await refresh_room();
3429
+ show_toast("Goal 已开启,主持人会持续推进;需本机接入程序保持运行");
3430
+ }
3431
+
3432
+ /** Change the open goal's objective or budgets. */
3433
+ async function update_room_goal({ objective, max_continues = 0, max_minutes = 0 }) {
3434
+ const room_id = state.room.id;
3435
+ if (!has_room_control(room_id)) throw new Error(MANAGEMENT_LOGIN_HINT);
3436
+ await request_json(`/api/rooms/${room_id}/goal`, { action: "update", objective, max_continues, max_minutes });
3437
+ if (state.room?.id !== room_id) return;
3438
+ await refresh_room();
3439
+ show_toast("Goal 已更新");
3440
+ }
3441
+
3442
+ /** Pause, resume or end the open goal; only the owner controls it. */
3443
+ async function goal_control(action) {
3444
+ const room_id = state.room.id;
3445
+ if (!has_room_control(room_id)) throw new Error(MANAGEMENT_LOGIN_HINT);
3446
+ await request_json(`/api/rooms/${room_id}/goal`, { action });
3447
+ if (state.room?.id !== room_id) return;
3448
+ await refresh_room();
3449
+ show_toast({ pause: "Goal 已暂停,房间空闲时不再唤醒主持人", resume: "Goal 已继续", clear: "Goal 已结束,房间回到自由协作" }[action]);
3450
+ }
3451
+
3154
3452
  /** Conclude the discussion and go on as a free room; every message and the conclusion stay. */
3155
3453
  async function end_room_discussion() {
3156
3454
  const room_id = state.room.id, session_id = state.room.session?.id;
@@ -4032,8 +4330,10 @@ function view_snapshot() {
4032
4330
  upload_status: state.upload_status, sending_message: state.sending_message, sending_preview: state.sending_preview,
4033
4331
  mention_only: state.mention_only, host_only: state.host_only, reply_to: state.reply_to, editing_queued: state.editing_queued?.id ?? null,
4034
4332
  access: access_snapshot(), admin: admin_snapshot(),
4333
+ device: { id: state.device.id, needs: state.device.needs, pairing_code: state.device.pairing_code,
4334
+ prompt: state.device.prompt ? { title: state.device.prompt.title, description: state.device.prompt.description, confirm_label: state.device.prompt.confirm_label } : null },
4035
4335
  tab: state.tab, snapshot_id: state.snapshot_id, context_dirty: state.context_dirty,
4036
- 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, create_workflow: state.create_workflow, create_host_workflows: state.create_host_workflows };
4336
+ theme: document.documentElement.dataset.theme, fields, experts, launch: structuredClone(state.launch), named_followup: state.named_followup, create_discussion: state.create_discussion, create_goal: state.create_goal, create_chain_rounds: state.create_chain_rounds, create_workflow: state.create_workflow, create_host_workflows: state.create_host_workflows };
4037
4337
  }
4038
4338
 
4039
4339
  /** Update a shared draft using the same input events as the original controls. */
@@ -4243,7 +4543,7 @@ async function execute_view_action(name, value) {
4243
4543
  save_context: () => save_context(event), save_topic: () => save_topic(event),
4244
4544
  filter: filter_view, reply: set_view_reply, cancel_reply, skip_turn,
4245
4545
  copy_cli: () => copy_context(), copy_background,
4246
- download_member_access, copy_member_access_text, copy_member_join, copy_member_launch: name => copy_member_join(name, true), pause_discussion, restart_discussion, start_room_discussion, end_room_discussion,
4546
+ download_member_access, copy_member_access_text, copy_member_join, copy_member_launch: name => copy_member_join(name, true), pause_discussion, restart_discussion, start_room_discussion, end_room_discussion, start_room_goal, update_room_goal, goal_control,
4247
4547
  save_launch_defaults, resume_launch, discard_launch, retry_launch_connection, check_launch_connection: load_launch_settings,
4248
4548
  load_room_configuration, save_room_configuration, interrupt_member, retry_delivery, retract_message, retract_latest, stop_room, rewind_and_send, fork_room, change_member, reorder_speakers, invite_member,
4249
4549
  choose_create_workflow, enable_room_workflow, move_workflow_members, set_room_host_workflows,
@@ -4270,13 +4570,17 @@ function change_view_theme(theme) {
4270
4570
 
4271
4571
  window.discuss_view = { snapshot: view_snapshot, write_field: write_view_field, notify, load_decision_input,
4272
4572
  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, default_name, label_draft, write_label_draft, preset_identity, save_expert_preset, delete_expert_preset, write_room_connection, write_room_adapter, launch_clients: LAUNCH_CLIENTS,
4273
- 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,
4573
+ 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_goal, write_create_chain_rounds, check_launch_api, launch_summary, launch_can_set_access, pick_launch_directory, browse_local_directory, list_remote_directories, save_launch_projects, restore_launch_profile, reset_create_form, empty_discussion_message,
4274
4574
  start_drag: start_directory_drag, reconnect: reconnect_live_updates, stop_label, local_request: local_launch_request,
4275
4575
  attachment_markup, attachment_size, attachment_file_icon, drop_attachments, paste_attachments,
4276
4576
  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,
4277
4577
  reload_workflows, workflow_preset, create_workflow_label, write_create_host_workflows,
4278
4578
  admin: { register: register_admin, login: login_admin, logout: logout_admin,
4279
- update_profile: update_admin_profile, avatar_from_file: admin_avatar_from_file, validate: validate_admin_form },
4579
+ update_profile: update_admin_profile, avatar_from_file: admin_avatar_from_file, validate: validate_admin_form,
4580
+ sessions: admin_sessions, revoke: revoke_admin_sessions, change_password: change_admin_password, login_with_passkey,
4581
+ passkeys: admin_passkeys, delete_passkey: delete_admin_passkey },
4582
+ device: { request: device_request, pair: pair_this_device, invite: invite_device, create_passkey: create_device_passkey, renew: renew_this_device, confirm_write: confirm_write_grant,
4583
+ label: device_label, prompt_confirm: () => state.device.prompt?.run(), prompt_cancel: () => state.device.prompt?.cancel() },
4280
4584
  request: request_json, rewind_plan, identities: IDENTITIES, display_name, mention_handles, mentioned_members, render_room_markdown, sources: SOURCE_LABELS,
4281
4585
  set_member_access, set_member_model, allow_denials, set_member_adapter, reload_room_models, read_room_files, limits_label, stop_reason_label, participant_model, participant_files, participant_session_hint, participant_access,
4282
4586
  turn_label, turn_guidance, round_groups, limit_rounds, progress_speaker, brief_meta, max_message_length: MAX_MESSAGE_LENGTH, provenance: message_provenance, format_time };