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.
- package/.agents/skills/discuss-host/SKILL.md +1 -0
- package/.agents/skills/discuss-host/references/discussion.md +113 -113
- package/.agents/skills/discuss-host/references/free.md +36 -34
- package/.agents/skills/discuss-host/references/goal.md +35 -0
- package/.agents/skills/discuss-participant/SKILL.md +1 -0
- package/.agents/skills/workflow-creator/SKILL.md +2 -2
- package/README.md +3 -1
- package/backend/admin.py +104 -35
- package/backend/free_room.py +2170 -2136
- package/backend/goal_room.py +358 -0
- package/backend/mode_room.py +105 -0
- package/backend/schema.sql +1593 -1510
- package/backend/server.py +2636 -2555
- package/backend/session_room.py +1180 -1148
- package/docs/THIRD_PARTY_NOTICES.md +1293 -1264
- package/docs/deployment.md +5 -1
- package/docs/en/workflow.md +15 -1
- package/docs/runner.md +28 -7
- package/docs/workflow.md +15 -1
- package/package.json +3 -2
- package/runner/adapters/api.mjs +333 -313
- package/runner/adapters/claude.mjs +12 -16
- package/runner/admin_account.mjs +12 -19
- package/runner/device_auth.mjs +369 -0
- package/runner/discuss.py +1208 -1125
- package/runner/discussion_launch.mjs +107 -12
- package/runner/discussion_service.mjs +25 -1
- package/runner/expert_prompts.mjs +132 -132
- package/runner/free_runner.mjs +982 -910
- package/runner/pinyin_table.mjs +406 -0
- package/runner/project_files.mjs +52 -3
- package/runner/relay_channel.mjs +51 -7
- package/runner/remote_launch.mjs +221 -235
- package/runner/remote_policy.mjs +156 -0
- package/runner/scheduler.mjs +459 -429
- package/runner/stt.mjs +2 -2
- package/runner/tts.mjs +74 -113
- package/runner/tts_providers.mjs +205 -0
- package/runner/webauthn.mjs +155 -0
- package/runner/workflows.mjs +371 -348
- package/web/app.js +355 -51
- package/web/modern.css +1 -1
- 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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
/**
|
|
293
|
-
function accept_admin_session({
|
|
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
|
|
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 { /*
|
|
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 ||
|
|
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
|
-
|
|
1952
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 || "请求失败,请重试"),
|
|
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 } : {})
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
/**
|
|
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 };
|