ai_co_discuss 0.2.41 → 0.2.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/app.js CHANGED
@@ -17,12 +17,22 @@ const DIRECTORY_DRAG_OFFSET = 12;
17
17
  const THEME_STORAGE_KEY = "ai-codiscuss-theme";
18
18
  const DIRECTORY_SORT_KEY = "ai-codiscuss-directory-sort";
19
19
  const LAUNCH_PENDING_PREFIX = "discuss-launch:";
20
+ const ADMIN_SESSION_KEY = "discuss-admin-session";
21
+ const ADMIN_USERNAME_MAX = 30;
22
+ const ADMIN_PASSWORD_MIN = 8;
23
+ const ADMIN_PASSWORD_MAX = 128;
24
+ const ADMIN_AVATAR_MAX_CHARS = 60000;
25
+ const ADMIN_AVATAR_SIZE = 96;
26
+ const ADMIN_AVATAR_QUALITY = 0.8;
20
27
  const MEMBER_ACCESS_PREFIX = "discuss-members:";
21
28
  const MEMBER_PENDING_PREFIX = "discuss-member-pending:";
22
29
  const LOCAL_CONNECTION_KEY = "discuss-local-connection";
23
30
  const LOCAL_CONNECTION_TIMEOUT_MS = 15000;
24
31
  const MODEL_CATALOG_TIMEOUT_MS = 35000;
25
32
  let local_connection = null;
33
+ const DEFAULT_USER_LABEL = "User";
34
+ const DEFAULT_USER_ICON = "/icons/user.svg";
35
+ const MANAGEMENT_LOGIN_HINT = "需要登录管理员账号或使用创建该房间的浏览器";
26
36
  const THEMES = ["channel", "channel-light"];
27
37
  const SOURCE_LABELS = { demo: "示例", user: "本人发言", proxy: "用户代发", cli: "CLI 发言", api: "API 发言", url: "历史 URL 发言", host_proxy: "主持人代发" };
28
38
  const PHASE_LABELS = { preparing: "准备中", initial: "独立观点", discussing: "深入讨论", reviewing: "结论复核", paused: "已暂停", concluded: "已收束" };
@@ -60,6 +70,7 @@ state.directory_notice = "";
60
70
  state.directory_pending = new Map();
61
71
  state.directory_version = 0;
62
72
  state.navigation_version = 0;
73
+ state.admin = { loaded: false, local: false, registered: false, setup_available: false, logged_in: false, profile: null, identity_version: 0 };
63
74
  state.launch = { available: false, remote: false, connect_command: "", profiles: {}, workspace: {}, scheduler: {}, create: true, error: "", notice: "", recoveries: [] };
64
75
  const PLATFORM_CLIENTS = { Codex: [{ value: "codex_cli", label: "Codex CLI" }, { value: "codex", label: "Codex App" }],
65
76
  Claude: [{ value: "claude", label: "Claude Code CLI" }],
@@ -125,12 +136,31 @@ function show_toast(message, is_error = false) {
125
136
  state.toast_timer = setTimeout(() => { element("toast").hidden = true; }, TOAST_DURATION_MS);
126
137
  }
127
138
 
139
+ /** The administrator session token kept by this browser, or an empty string. */
140
+ function admin_token() {
141
+ if (state.admin.local) return "";
142
+ try { return localStorage.getItem(ADMIN_SESSION_KEY) || ""; } catch { return ""; }
143
+ }
144
+
145
+ /** Store or clear the administrator session token; storage failures only lose persistence. */
146
+ function store_admin_token(token) {
147
+ try { token ? localStorage.setItem(ADMIN_SESSION_KEY, token) : localStorage.removeItem(ADMIN_SESSION_KEY); } catch { /* Keep the session in memory only. */ }
148
+ }
149
+
150
+ /** The administrator header for writes that bypass request_json, or no header without a session. */
151
+ function admin_headers() {
152
+ const session = admin_token();
153
+ return session ? { "X-Discussion-Admin": session } : {};
154
+ }
155
+
128
156
  /** Request JSON from the local or remotely hosted discussion service. */
129
157
  async function request_json(path, payload, access_token) {
130
158
  const options = { cache: "no-store" };
131
159
  const target_room = path.match(/^\/api\/rooms\/([^/?]+)/)?.[1];
132
160
  const token = access_token || (target_room ? localStorage.getItem(`discuss-access:${target_room}`) : null);
161
+ const session = admin_token();
133
162
  options.headers = token ? { "X-Discussion-Token": token } : {};
163
+ if (session) options.headers["X-Discussion-Admin"] = session;
134
164
  if (payload !== undefined) {
135
165
  options.method = "POST";
136
166
  options.headers["Content-Type"] = "application/json";
@@ -138,10 +168,127 @@ async function request_json(path, payload, access_token) {
138
168
  }
139
169
  const response = await fetch(path, options);
140
170
  const result = await response.json();
141
- if (!response.ok) throw Object.assign(new Error(result.error || "请求失败,请重试"), { preflight: response.status >= 400 && response.status < 500 });
171
+ if (!response.ok) throw Object.assign(new Error(result.error || "请求失败,请重试"), { preflight: response.status >= 400 && response.status < 500, status: response.status });
142
172
  return result;
143
173
  }
144
174
 
175
+ /** The owner's public name and avatar replace the default "User" identity for every visitor. */
176
+ function apply_admin_identity(profile) {
177
+ Object.assign(PLATFORM_IDENTITIES["用户"], { label: profile?.username || DEFAULT_USER_LABEL, icon: profile?.avatar || DEFAULT_USER_ICON });
178
+ state.admin.identity_version++;
179
+ if (!element("participants")) return;
180
+ for (const id of ["author-select", "topic-host-select", "new-room-host"]) {
181
+ const option = Array.from(element(id)?.options || []).find(item => item.value === "用户");
182
+ if (option) option.textContent = display_name("用户");
183
+ }
184
+ render_participants();
185
+ }
186
+
187
+ /** Record the server's administrator state and refresh every identity-dependent view without a reload. */
188
+ function apply_admin_state(info) {
189
+ const local = info.local === true;
190
+ if (local || !info.logged_in) store_admin_token("");
191
+ Object.assign(state.admin, { loaded: true, local, registered: Boolean(info.registered), setup_available: Boolean(info.setup_available),
192
+ logged_in: Boolean(info.logged_in), profile: info.profile ? { username: info.profile.username, avatar: info.profile.avatar || "" } : null });
193
+ apply_admin_identity(state.admin.profile);
194
+ publish_view();
195
+ }
196
+
197
+ /** Read the public administrator state; a failure leaves the visitor view unchanged. */
198
+ async function load_admin_state() {
199
+ try { apply_admin_state(await request_json("/api/admin")); }
200
+ catch { /* The directory request reports connection problems. */ }
201
+ }
202
+
203
+ /** Return the first problem with an administrator form, matching the server's limits. */
204
+ function validate_admin_form({ username, password, confirm, check_password = true }) {
205
+ const name_length = [...String(username ?? "").trim()].length;
206
+ if (username !== undefined && (name_length < 1 || name_length > ADMIN_USERNAME_MAX)) return `用户名需要 1–${ADMIN_USERNAME_MAX} 个字符`;
207
+ if (!check_password) return "";
208
+ const length = [...String(password ?? "")].length;
209
+ if (length < ADMIN_PASSWORD_MIN || length > ADMIN_PASSWORD_MAX) return `密码需要 ${ADMIN_PASSWORD_MIN}–${ADMIN_PASSWORD_MAX} 个字符`;
210
+ if (confirm !== undefined && confirm !== password) return "两次输入的密码不一致";
211
+ return "";
212
+ }
213
+
214
+ /** Keep the Chinese server message and add a retry hint when a locked response lacks the delay. */
215
+ function admin_error(error) {
216
+ if (error.status === 429 && !/\d/.test(error.message)) error.message += ",请稍后再试";
217
+ return error;
218
+ }
219
+
220
+ /** Store the new session and show the owner's identity. */
221
+ function accept_admin_session({ token, profile }) {
222
+ store_admin_token(token);
223
+ apply_admin_state({ registered: true, setup_available: false, logged_in: true, profile });
224
+ }
225
+
226
+ /** Create the single administrator account and sign in with it. */
227
+ async function register_admin({ username, password, confirm, setup_key }) {
228
+ const problem = validate_admin_form({ username, password, confirm }) || (String(setup_key ?? "").trim() ? "" : "请填写安装令牌");
229
+ if (problem) throw new Error(problem);
230
+ try { accept_admin_session(await request_json("/api/admin/register", { username: username.trim(), password, setup_key: setup_key.trim() })); }
231
+ catch (error) { throw admin_error(error); }
232
+ }
233
+
234
+ /** Sign in and keep the session in this browser. */
235
+ async function login_admin({ username, password }) {
236
+ if (!String(username ?? "").trim() || !password) throw new Error("请填写用户名和密码");
237
+ try { accept_admin_session(await request_json("/api/admin/login", { username: username.trim(), password })); }
238
+ catch (error) { throw admin_error(error); }
239
+ }
240
+
241
+ /** Leave this browser; the local session is dropped even if the server cannot be reached. */
242
+ async function logout_admin() {
243
+ try { await request_json("/api/admin/logout", {}); }
244
+ catch { /* The token is removed below either way. */ }
245
+ store_admin_token("");
246
+ state.admin.logged_in = false;
247
+ publish_view();
248
+ }
249
+
250
+ /** Save the display name and/or avatar; an empty avatar restores the default. */
251
+ async function update_admin_profile(patch) {
252
+ if (patch.username !== undefined) {
253
+ const problem = validate_admin_form({ username: patch.username, check_password: false });
254
+ if (problem) throw new Error(problem);
255
+ patch = { ...patch, username: patch.username.trim() };
256
+ }
257
+ const result = await request_json("/api/admin/profile", patch);
258
+ apply_admin_state({ local: state.admin.local, registered: true, setup_available: false, logged_in: true, profile: result.profile });
259
+ }
260
+
261
+ /** Shrink the chosen image into a small square data URL that fits the server limit. */
262
+ async function admin_avatar_from_file(file) {
263
+ if (!file?.type?.startsWith("image/")) throw new Error("请选择图片文件");
264
+ const url = URL.createObjectURL(file);
265
+ try {
266
+ const image = await new Promise((resolve, reject) => {
267
+ const loaded = new Image();
268
+ loaded.onload = () => resolve(loaded);
269
+ loaded.onerror = () => reject(new Error("无法读取这张图片"));
270
+ loaded.src = url;
271
+ });
272
+ const side = Math.min(image.naturalWidth, image.naturalHeight);
273
+ if (!side) throw new Error("无法读取这张图片");
274
+ const canvas = document.createElement("canvas");
275
+ canvas.width = canvas.height = ADMIN_AVATAR_SIZE;
276
+ const context = canvas.getContext("2d");
277
+ context.fillStyle = "#ffffff";
278
+ context.fillRect(0, 0, ADMIN_AVATAR_SIZE, ADMIN_AVATAR_SIZE);
279
+ context.drawImage(image, (image.naturalWidth - side) / 2, (image.naturalHeight - side) / 2, side, side, 0, 0, ADMIN_AVATAR_SIZE, ADMIN_AVATAR_SIZE);
280
+ const data_url = canvas.toDataURL("image/jpeg", ADMIN_AVATAR_QUALITY);
281
+ if (data_url.length > ADMIN_AVATAR_MAX_CHARS) throw new Error("图片过大,请换一张");
282
+ return data_url;
283
+ } finally { URL.revokeObjectURL(url); }
284
+ }
285
+
286
+ /** Explain why a management write failed when this browser has neither credential. */
287
+ function management_error_message(room_id, error) {
288
+ if (error.status === 403 && !state.admin.logged_in && !localStorage.getItem(`discuss-access:${room_id}`)) return MANAGEMENT_LOGIN_HINT;
289
+ return error.message;
290
+ }
291
+
145
292
  /** Show connection state without claiming that any AI is online. */
146
293
  function set_connection(connected) {
147
294
  state.connected = connected;
@@ -179,18 +326,23 @@ function display_name(name) { return IDENTITIES[name]?.label || name; }
179
326
 
180
327
  /** Render an identity avatar from a local SVG. */
181
328
  function identity_avatar(identity) {
182
- return identity.icon ? `<img src="${identity.icon}" alt="">` : identity.initials;
329
+ return identity.icon ? `<img src="${escape_html(identity.icon)}" alt="">` : identity.initials;
183
330
  }
184
331
 
185
332
  /** Render identity controls and distinguish selectable identities from connections. */
186
333
  function render_identities() {
187
- const options = Object.keys(IDENTITIES).map((name) => `<option value="${name}">${display_name(name)}</option>`).join("");
334
+ const options = Object.keys(IDENTITIES).map((name) => `<option value="${name}">${escape_html(display_name(name))}</option>`).join("");
188
335
  element("author-select").innerHTML = options;
189
336
  element("topic-host-select").innerHTML = options;
190
337
  element("new-room-host").innerHTML = options;
191
338
  render_expert_inputs("new-expert-modes", default_experts());
339
+ render_participants();
340
+ }
341
+
342
+ /** List the selectable identities in the classic sidebar. */
343
+ function render_participants() {
192
344
  element("participants").innerHTML = Object.entries(IDENTITIES).map(([name, identity]) =>
193
- `<div class="participant ${identity.style}"><span class="avatar" aria-hidden="true">${identity_avatar(identity)}</span><span class="participant-name">${display_name(name)}</span><span class="participant-role">${identity.description}</span></div>`).join("");
345
+ `<div class="participant ${identity.style}"><span class="avatar" aria-hidden="true">${identity_avatar(identity)}</span><span class="participant-name">${escape_html(display_name(name))}</span><span class="participant-role">${identity.description}</span></div>`).join("");
194
346
  }
195
347
 
196
348
  /** Render rooms while retaining the selected room. */
@@ -331,6 +483,7 @@ async function delete_channel(id) {
331
483
  if (!room) return;
332
484
  const previous = state.rooms.map(item => item.id);
333
485
  return update_directory({ key: `delete:${id}`, label: `「${room.title}」删除`,
486
+ error_message: error => `「${room.title}」删除失败,已恢复原状态:${management_error_message(id, error)}`,
334
487
  apply: () => {
335
488
  state.rooms = state.rooms.filter(item => item.id !== id);
336
489
  if (state.room?.id === id) run_action(() => select_room(visible_rooms()[0]?.id));
@@ -370,7 +523,7 @@ async function archive_channel(id, archived) {
370
523
  const saved = await request_json(`/api/rooms/${id}/${archived ? "archive" : "restore"}`, { actor: "用户" });
371
524
  display_channel_archive(id, { archived: Boolean(saved.archived), phase: saved.phase }, change);
372
525
  } catch (error) {
373
- state.directory_error = `「${room.title}」${archived ? "存档" : "恢复"}失败,已恢复原状态:${error.message}`;
526
+ state.directory_error = `「${room.title}」${archived ? "存档" : "恢复"}失败,已恢复原状态:${management_error_message(id, error)}`;
374
527
  display_channel_archive(id, previous, change);
375
528
  throw error;
376
529
  } finally {
@@ -1077,7 +1230,7 @@ async function upload_message_attachments(room_id, actor, drafts) {
1077
1230
  const params = new URLSearchParams({ file_name: item.file.name, actor, submission_id: item.upload_submission });
1078
1231
  const token = localStorage.getItem(`discuss-access:${room_id}`);
1079
1232
  const response = await fetch(`/api/rooms/${room_id}/attachments?${params}`, { method: "POST",
1080
- headers: { "Content-Type": "application/octet-stream", ...(token ? { "X-Discussion-Token": token } : {}) }, body: item.file });
1233
+ headers: { "Content-Type": "application/octet-stream", ...(token ? { "X-Discussion-Token": token } : {}), ...admin_headers() }, body: item.file });
1081
1234
  const result = await response.json();
1082
1235
  if (!response.ok) throw new Error(result.error || "附件上传失败");
1083
1236
  item.uploaded = result.attachment.id;
@@ -1310,6 +1463,12 @@ function save_room_credentials(room, token, members = []) {
1310
1463
  localStorage.setItem(MEMBER_ACCESS_PREFIX + room.id, JSON.stringify(saved));
1311
1464
  }
1312
1465
 
1466
+ /** Public administrator state for the account UI; the session token never leaves the controller. */
1467
+ function admin_snapshot() {
1468
+ const { loaded, local, registered, setup_available, logged_in, profile, identity_version } = state.admin;
1469
+ return { loaded, local, registered, setup_available, logged_in, identity_version, profile: profile && { ...profile } };
1470
+ }
1471
+
1313
1472
  /** Build only non-secret status metadata for the currently selected room. */
1314
1473
  function access_snapshot() {
1315
1474
  const room = state.room;
@@ -2900,6 +3059,7 @@ async function initialize() {
2900
3059
  if (button?.dataset.launchDiscard) discard_launch(button.dataset.launchDiscard);
2901
3060
  if (button?.dataset.launchRoom) run_action(() => select_room(button.dataset.launchRoom));
2902
3061
  });
3062
+ load_admin_state();
2903
3063
  try {
2904
3064
  const result = await request_json("/api/rooms");
2905
3065
  state.rooms = result.rooms;
@@ -2911,7 +3071,7 @@ async function initialize() {
2911
3071
  start_live_updates();
2912
3072
  document.addEventListener("visibilitychange", () => {
2913
3073
  if (document.hidden) stop_live_updates();
2914
- else start_live_updates();
3074
+ else { start_live_updates(); load_admin_state(); }
2915
3075
  });
2916
3076
  window.addEventListener("pagehide", stop_live_updates);
2917
3077
  window.addEventListener("pageshow", start_live_updates);
@@ -2936,7 +3096,7 @@ function view_snapshot() {
2936
3096
  composer_error: state.composer_error, upload_status: state.upload_status, sending_message: state.sending_message,
2937
3097
  mention_only: state.mention_only, reply_to: state.reply_to, turn_reply_to: state.turn_reply_to,
2938
3098
  free_notice: state.free_notice, free_error: state.free_error, free_proxy: state.free_proxy,
2939
- create_parent: state.create_parent, access: access_snapshot(),
3099
+ create_parent: state.create_parent, access: access_snapshot(), admin: admin_snapshot(),
2940
3100
  tab: state.tab, snapshot_id: state.snapshot_id, context_dirty: state.context_dirty,
2941
3101
  theme: document.documentElement.dataset.theme, fields, experts, launch: structuredClone(state.launch), named_followup: state.named_followup };
2942
3102
  }
@@ -3052,7 +3212,7 @@ async function upload_imported_files(room_id, token, mapping, files) {
3052
3212
  for (const [old_id, new_id] of Object.entries(mapping)) {
3053
3213
  const data = files.get(old_id);
3054
3214
  const response = data && await fetch(`/api/rooms/${encodeURIComponent(room_id)}/attachments/${encodeURIComponent(new_id)}/import`,
3055
- { method: "POST", headers: { "X-Discussion-Token": token }, body: data });
3215
+ { method: "POST", headers: { "X-Discussion-Token": token, ...admin_headers() }, body: data });
3056
3216
  if (!response?.ok) missing++;
3057
3217
  }
3058
3218
  return missing;
@@ -3137,6 +3297,8 @@ window.discuss_view = { snapshot: view_snapshot, write_field: write_view_field,
3137
3297
  start_drag: start_directory_drag,
3138
3298
  attachment_markup, attachment_size, attachment_file_icon, drop_attachments, paste_attachments,
3139
3299
  write_expert: write_view_expert, add_instance: add_view_instance, platform_of, execute: execute_view_action, theme: change_view_theme,
3300
+ admin: { register: register_admin, login: login_admin, logout: logout_admin,
3301
+ update_profile: update_admin_profile, avatar_from_file: admin_avatar_from_file, validate: validate_admin_form },
3140
3302
  request: request_json, identities: IDENTITIES, display_name, sources: SOURCE_LABELS, phases: PHASE_LABELS,
3141
3303
  runtime_label, execution_label, limits_label, participant_model, participant_access, can_retry_execution,
3142
3304
  turns: TURN_LABELS, turn_label, turn_guidance, floor_status, progress_round, progress_speaker, brief_meta, max_message_length: MAX_MESSAGE_LENGTH, provenance: message_provenance, format_time };