ai_co_discuss 0.2.95 → 0.2.97

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.
@@ -25,7 +25,7 @@ python server.py --host 127.0.0.1 --port 8765 --no-demo
25
25
 
26
26
  Python 3.10+,无第三方依赖。长期部署请自行配置进程管理与 HTTPS 反向代理;代理需对 `/api/updates` 关闭响应缓冲,以支持实时推送。
27
27
 
28
- **管理员**:绑定 `127.0.0.1` 时访问者即管理员。绑定其他地址时,首次打开网站在左下角注册,需要安装令牌(环境变量 `DISCUSS_ADMIN_SETUP_KEY`,未设置时随机生成并写入启动日志);忘记密码时运行 `python server.py --reset-admin` 后重新注册。
28
+ **管理员**:绑定 `127.0.0.1` 时访问者即管理员。绑定其他地址时,首次打开网站在左下角注册,需要安装令牌(环境变量 `DISCUSS_ADMIN_SETUP_KEY`,未设置时随机生成并写入启动日志)。登录管理员后,在其他设备(平板、手机)上也能以用户身份在任意房间发言和管理;忘记密码时运行 `python server.py --reset-admin` 后重新注册。
29
29
 
30
30
  **备份**:附件保存在数据库旁的 `<数据库文件名>.attachments/`。停止写入后同时备份数据库与附件目录。
31
31
 
@@ -95,11 +95,11 @@ Windows PowerShell 使用 `New-Item -ItemType Directory -Force -Path .wrangler/d
95
95
 
96
96
  ```sh
97
97
  npx wrangler d1 execute DB --remote --config .wrangler/deploy/wrangler.jsonc --file backend/schema.sql
98
- npx wrangler pages secret put ADMIN_SETUP_KEY --project-name YOUR_PROJECT_NAME
99
98
  npm run deploy -- --cwd .wrangler/deploy
99
+ codiscuss admin token
100
100
  ```
101
101
 
102
- `ADMIN_SETUP_KEY` 是注册管理员所需的安装令牌。`schema.sql` 可重复执行;升级已有站点前请先备份 D1(`npx wrangler d1 export`)。
102
+ `codiscuss admin token` 生成注册管理员用的一次性令牌(需已 `wrangler login`),在网站左下角注册时填入;在已用 `codiscuss local connect` 连上本机的网页注册时,账号密码会保存到 `~/.codiscuss/admin-accounts.json`。注册后其他设备直接用账号密码登录。`schema.sql` 可重复执行;升级已有站点前请先备份 D1(`npx wrangler d1 export`)。
103
103
 
104
104
  发布后检查网页、`/api/health`,以及:
105
105
 
@@ -25,7 +25,7 @@ python server.py --host 127.0.0.1 --port 8765 --no-demo
25
25
 
26
26
  Python 3.10+ with no third-party dependencies. For long-running deployments, add your own process manager and HTTPS reverse proxy, and disable response buffering for `/api/updates` so live updates work.
27
27
 
28
- **Administrator** — when bound to `127.0.0.1`, every visitor is the administrator. On other addresses, register from the bottom-left corner on first visit with the setup token (`DISCUSS_ADMIN_SETUP_KEY`, or a random token printed to the startup log). If you forget the password, run `python server.py --reset-admin` and register again.
28
+ **Administrator** — when bound to `127.0.0.1`, every visitor is the administrator. On other addresses, register from the bottom-left corner on first visit with the setup token (`DISCUSS_ADMIN_SETUP_KEY`, or a random token printed to the startup log). Once logged in as the administrator, you can speak and manage any room as the user from other devices such as a tablet or phone. If you forget the password, run `python server.py --reset-admin` and register again.
29
29
 
30
30
  **Backup** — attachments are stored next to the database in `<database file>.attachments/`. Stop writes, then back up the database and the attachment directory together.
31
31
 
@@ -95,11 +95,11 @@ Keep the binding names `DB` and `ATTACHMENTS`. Without the R2 binding, text disc
95
95
 
96
96
  ```sh
97
97
  npx wrangler d1 execute DB --remote --config .wrangler/deploy/wrangler.jsonc --file backend/schema.sql
98
- npx wrangler pages secret put ADMIN_SETUP_KEY --project-name YOUR_PROJECT_NAME
99
98
  npm run deploy -- --cwd .wrangler/deploy
99
+ codiscuss admin token
100
100
  ```
101
101
 
102
- `ADMIN_SETUP_KEY` is the setup token required to register the administrator. `schema.sql` is safe to re-run; back up D1 (`npx wrangler d1 export`) before upgrading an existing site.
102
+ `codiscuss admin token` prints a one-time token for registering the administrator (requires `wrangler login`); enter it when registering from the bottom-left corner. Registering from a page connected with `codiscuss local connect` also saves the account to `~/.codiscuss/admin-accounts.json`. Other devices then simply log in with the username and password. `schema.sql` is safe to re-run; back up D1 (`npx wrangler d1 export`) before upgrading an existing site.
103
103
 
104
104
  After publishing, check the page, `/api/health`, and:
105
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.2.95",
3
+ "version": "0.2.97",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
@@ -8,7 +8,7 @@
8
8
  "dev": "npm run build && wrangler pages dev --ip 127.0.0.1 --port 8787",
9
9
  "db:local": "wrangler d1 execute DB --local --file backend/schema.sql",
10
10
  "db:remote": "wrangler d1 execute DB --remote --file backend/schema.sql",
11
- "check": "node --check backend/worker.mjs && node --check web/app.js && node --check runner/discussion_runner.mjs && node --check runner/discussion_adapters.mjs && node --check runner/discussion_sidecar.mjs && node --check runner/codex_app_client.mjs && node --check runner/discussion_service.mjs && node --check runner/codiscuss.mjs && node --check runner/discussion_launch.mjs && node --check runner/discussion_models.mjs && node --check runner/discussion_setup.mjs && node --check runner/discussion_skills.mjs && node --check runner/discussion_tui.mjs && node --check runner/discussion_local.mjs && node --check runner/free_runner.mjs && node --check runner/discussion_api.mjs && node --check runner/api_participant.mjs && node --check runner/scheduler.mjs && node --check runner/remote_launch.mjs",
11
+ "check": "node --check backend/worker.mjs && node --check web/app.js && node --check runner/discussion_runner.mjs && node --check runner/discussion_adapters.mjs && node --check runner/discussion_sidecar.mjs && node --check runner/codex_app_client.mjs && node --check runner/discussion_service.mjs && node --check runner/codiscuss.mjs && node --check runner/discussion_launch.mjs && node --check runner/discussion_models.mjs && node --check runner/discussion_setup.mjs && node --check runner/discussion_skills.mjs && node --check runner/discussion_tui.mjs && node --check runner/discussion_local.mjs && node --check runner/free_runner.mjs && node --check runner/discussion_api.mjs && node --check runner/api_participant.mjs && node --check runner/scheduler.mjs && node --check runner/remote_launch.mjs && node --check runner/admin_account.mjs",
12
12
  "runner": "node runner/discussion_runner.mjs",
13
13
  "service": "node runner/discussion_service.mjs",
14
14
  "build:ui": "vite build --config frontend/vite.config.mjs --mode markdown && vite build --config frontend/vite.config.mjs --mode backup && vite build --config frontend/vite.config.mjs",
@@ -31,6 +31,7 @@
31
31
  "fflate": "0.8.3",
32
32
  "katex": "0.18.10",
33
33
  "markdown-it": "15.0.3",
34
+ "markdown-it-cjk-friendly": "3.0.0",
34
35
  "motion-v": "2.7.0",
35
36
  "pdfjs-dist": "6.4.299",
36
37
  "vite": "8.3.4",
@@ -0,0 +1,66 @@
1
+ /** Site administrator setup from the deploying computer: a one-time registration token and the saved account. */
2
+ import { spawnSync } from 'node:child_process';
3
+ import { createHash, randomBytes } from 'node:crypto';
4
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join, resolve } from 'node:path';
7
+ import { parseArgs } from 'node:util';
8
+
9
+ import { atomic_json } from './discussion_adapters.mjs';
10
+ import { service_home } from './discussion_service.mjs';
11
+
12
+ const DEFAULT_CONFIG = join('.wrangler', 'deploy', 'wrangler.jsonc');
13
+ const ACCOUNTS_FILE = 'admin-accounts.json';
14
+ const TOKEN_BYTES = 32;
15
+ const HELP = `用法:codiscuss admin token [--config WRANGLER_CONFIG]
16
+
17
+ token 生成一次性管理员注册令牌,经 wrangler 把它的哈希写入线上 D1,立即生效。
18
+ 在线上网页左下角注册管理员时填入;注册成功即作废,重新运行会替换旧令牌。
19
+ 需要这台电脑已 wrangler login;--config 默认为当前目录的 ${DEFAULT_CONFIG}。
20
+
21
+ 在已用 codiscuss local connect 连上本机服务的网页注册后,账号与密码保存在 ~/.codiscuss/${ACCOUNTS_FILE}。
22
+ `;
23
+
24
+ /** Write a fresh token's hash to the deployed D1 and return the token; the raw value is never stored. */
25
+ export function create_admin_token(config, run = run_wrangler) {
26
+ const path = resolve(config || DEFAULT_CONFIG);
27
+ if (!existsSync(path)) throw new Error(`找不到部署配置 ${path};请在项目目录运行,或用 --config 指定 wrangler.jsonc`);
28
+ const token = randomBytes(TOKEN_BYTES).toString('hex');
29
+ const hashed = createHash('sha256').update(token).digest('hex');
30
+ const directory = mkdtempSync(join(tmpdir(), 'codiscuss-admin-'));
31
+ const sql = join(directory, 'admin-setup.sql');
32
+ try {
33
+ writeFileSync(sql, `INSERT OR REPLACE INTO admin_setup (id,token_hash,created_at) VALUES (1,'${hashed}',${Math.floor(Date.now() / 1000)});\n`);
34
+ run(['d1', 'execute', 'DB', '--remote', '--config', path, '--file', sql]);
35
+ } finally { rmSync(directory, { recursive: true, force: true }); }
36
+ return { token, config: path };
37
+ }
38
+
39
+ /** Run wrangler through npx; arguments are quoted because Windows needs a shell for npx.cmd. */
40
+ function run_wrangler(args) {
41
+ const quoted = ['wrangler', ...args].map(arg => `"${arg.replaceAll('"', '\\"')}"`).join(' ');
42
+ const result = spawnSync(`npx ${quoted}`, { shell: true, stdio: ['ignore', 'ignore', 'pipe'], encoding: 'utf8', windowsHide: true });
43
+ if (result.status !== 0) throw new Error(`wrangler 写入线上数据库失败:${(result.stderr || result.error?.message || '').trim().slice(-800)}`);
44
+ }
45
+
46
+ /** Keep the account registered from this computer's browser, one entry per site, in the private service directory. */
47
+ export function save_admin_account(home, { site, username, password }) {
48
+ if (typeof username !== 'string' || !username.trim() || typeof password !== 'string' || !password) throw new Error('缺少管理员用户名或密码');
49
+ const origin = new URL(site).origin;
50
+ const path = join(home, ACCOUNTS_FILE);
51
+ const accounts = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { sites: {} };
52
+ accounts.sites[origin] = { username: username.trim(), password, saved_at: new Date().toISOString() };
53
+ mkdirSync(home, { recursive: true, mode: 0o700 });
54
+ atomic_json(path, accounts);
55
+ return { saved: path };
56
+ }
57
+
58
+ /** `codiscuss admin ...` */
59
+ export function admin_main(args) {
60
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
61
+ config: { type: 'string' }, help: { type: 'boolean', short: 'h' },
62
+ } });
63
+ if (values.help || positionals[0] !== 'token') return process.stdout.write(HELP);
64
+ const { token } = create_admin_token(values.config);
65
+ process.stdout.write(`管理员注册令牌(一次性):\n${token}\n\n在线上网页左下角注册管理员时填入。注册成功后作废;账号密码在已连接本机服务的网页注册时保存到 ${join(service_home(), ACCOUNTS_FILE)}。\n`);
66
+ }
@@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { parseArgs } from 'node:util';
6
6
 
7
+ import { admin_main } from './admin_account.mjs';
7
8
  import { main as service_main, read_registry, run_service, service_home, service_pid } from './discussion_service.mjs';
8
9
  import { launch_main } from './discussion_launch.mjs';
9
10
  import { local_settings, setup_main } from './discussion_setup.mjs';
@@ -16,6 +17,7 @@ const HELP = `AI CoDiscuss CLI
16
17
  (无命令) 在当前终端启动服务,Ctrl+C 停止
17
18
  install / setup 安装持久命令并配置 / 配置已有安装
18
19
  local COMMAND 管理本机网站,或 connect 连接线上站点
20
+ admin token 生成线上网站的一次性管理员注册令牌
19
21
  service COMMAND 启动、停止、查询服务,或登记房间
20
22
  launch COMMAND 保存启动配置,或从 AI 对话启动讨论
21
23
  room COMMAND 创建、读取或更新讨论房间
@@ -118,6 +120,7 @@ async function main(args = process.argv.slice(2)) {
118
120
  if (command === 'launch') return launch_main(args.slice(1));
119
121
  if (['install', 'setup'].includes(command)) return setup_main(command, args.slice(1));
120
122
  if (command === 'local') return local_main(args.slice(1));
123
+ if (command === 'admin') return admin_main(args.slice(1));
121
124
  if (!command || (command === 'run' && args.length === 1)) return service_cli([]);
122
125
  if (SERVICE_COMMANDS.has(command) || command === '--json') return service_cli(args);
123
126
  return website_cli(args);
@@ -60,6 +60,7 @@ const HELP = `用法:codiscuss launch settings [--file FILE]
60
60
  codiscuss launch projects [--file FILE]
61
61
  codiscuss launch presets [--file FILE]
62
62
  codiscuss launch machine-link --file FILE
63
+ codiscuss launch admin-account --file FILE
63
64
 
64
65
  settings 读取本机软件默认配置;指定文件时保存,不启动 AI。
65
66
  start 使用启动 JSON 创建或接入房间并启动;--dry-run 只校验和预览。
@@ -73,6 +74,7 @@ pick-directory 在本机弹出系统目录选择窗口,返回所选路径;
73
74
  projects 读取本机已配置的项目(名称与绝对路径);提供 JSON(projects 列表)时整体替换保存。
74
75
  presets 读取本机的专家预设(含内置预设)与各平台默认描述;提供 JSON(presets 列表和/或 description_templates)时替换对应部分。
75
76
  machine-link 返回本机连接指定 site 的电脑编号、名称与凭据,供已登录管理员的网页完成配对;不存在时创建。
77
+ admin-account 把在指定 site 注册的管理员用户名与密码保存到服务目录的 admin-accounts.json;须提供 site、username、password。
76
78
  room-member 为运行中的自由房间或讨论邀请、请回一位成员(讨论只接受本机唤醒的成员);须提供 site、room_id、token、action(add/rejoin)、name。
77
79
  room-member-access 修改一位本机唤醒成员的项目文件权限(project_access 为 read/write),下次投递起生效、保留原会话;须提供 site、room_id、token、name、project_access。
78
80
  room-member-adapter 把自由房间里空闲的 Codex 成员在 App(codex)与 CLI(codex_cli)间切换,保留原会话;须提供 site、room_id、token、name、adapter。
@@ -1111,6 +1113,7 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
1111
1113
  else if (positionals[0] === 'room-restore' && input) result = await restore_room(home, input);
1112
1114
  else if (positionals[0] === 'pick-directory') result = await pick_directory();
1113
1115
  else if (positionals[0] === 'machine-link' && input) result = (await import('./remote_launch.mjs')).machine_link(home, input.site);
1116
+ else if (positionals[0] === 'admin-account' && input) result = (await import('./admin_account.mjs')).save_admin_account(home, input);
1114
1117
  else if (positionals[0] === 'start' && input) result = await launch_discussion(home, input, { dry_run: values['dry-run'], browser_request: values.browser });
1115
1118
  else if (positionals[0] === 'room-config' && input) result = await room_configuration(home, input, { local_guard: values['configuration-locked'] });
1116
1119
  else throw new Error(HELP);
@@ -245,7 +245,8 @@ export class FreeRoomRunner {
245
245
  }
246
246
  /** Ask the scheduler how everyone answers; a failed call still has them answer at once. */
247
247
  async order_group(request, room, everyone) {
248
- const page = await api(this.config, `/messages?after=${Math.max(0, request.message_id - ROUTE_CONTEXT_MESSAGES - 1)}`);
248
+ // A page holds at most 20 messages, so the window must end at the message itself or the message falls off the page.
249
+ const page = await api(this.config, `/messages?after=${Math.max(0, request.message_id - ROUTE_CONTEXT_MESSAGES)}`);
249
250
  const message = page.messages.find(item => item.id === request.message_id);
250
251
  const members = (room.speaking_order?.items || []).map(item => item.name).filter(name => everyone.some(member => member.name === name));
251
252
  const decision = await decide_group_order(this.config.api_home, this.config.scheduler,
@@ -282,9 +283,9 @@ export class FreeRoomRunner {
282
283
  }
283
284
  /** Decide one message and apply a confident pick: one member, everyone, or no one; otherwise the host keeps it. */
284
285
  async recommend(request, room, everyone) {
285
- const page = await api(this.config, `/messages?after=${Math.max(0, request.message_id - ROUTE_CONTEXT_MESSAGES - 1)}`);
286
+ const page = await api(this.config, `/messages?after=${Math.max(0, request.message_id - ROUTE_CONTEXT_MESSAGES)}`);
286
287
  const message = page.messages.find(item => item.id === request.message_id);
287
- if (!message) return;
288
+ if (!message) throw new Error('读取不到这条消息');
288
289
  const described = this.route_members(everyone, room, message.id);
289
290
  const host = described.find(member => member.role === 'host');
290
291
  const members = described.filter(member => member !== host);
package/web/app.js CHANGED
@@ -70,6 +70,8 @@ state.archive_changes = new Map();
70
70
  state.archive_version = 0;
71
71
  state.directory_error = "";
72
72
  state.directory_notice = "";
73
+ // The open import's progress for the import dialog: { done, rooms: [{ title, status, members, error }], errors }.
74
+ state.import_progress = null;
73
75
  state.directory_pending = new Map();
74
76
  state.directory_version = 0;
75
77
  state.navigation_version = 0;
@@ -230,10 +232,15 @@ function accept_admin_session({ token, profile }) {
230
232
 
231
233
  /** Create the single administrator account and sign in with it. */
232
234
  async function register_admin({ username, password, confirm, setup_key }) {
233
- const problem = validate_admin_form({ username, password, confirm }) || (String(setup_key ?? "").trim() ? "" : "请填写安装令牌");
235
+ const problem = validate_admin_form({ username, password, confirm }) || (String(setup_key ?? "").trim() ? "" : "请填写注册令牌");
234
236
  if (problem) throw new Error(problem);
235
237
  try { accept_admin_session(await request_json("/api/admin/register", { username: username.trim(), password, setup_key: setup_key.trim() })); }
236
238
  catch (error) { throw admin_error(error); }
239
+ // Registered from the computer running the local service: keep the account in its private directory.
240
+ if (local_connection) {
241
+ try { show_toast(`账号与密码已保存到本机 ${(await local_launch_request("admin-account", { username: username.trim(), password })).saved}`); }
242
+ catch (error) { show_toast(`注册成功,但账号未能保存到本机:${error.message}`, true); }
243
+ }
237
244
  }
238
245
 
239
246
  /** Sign in and keep the session in this browser. */
@@ -289,6 +296,11 @@ async function admin_avatar_from_file(file) {
289
296
  } finally { URL.revokeObjectURL(url); }
290
297
  }
291
298
 
299
+ /** Whether this browser may write as the room owner: its own control credential, or a logged-in administrator session. */
300
+ function has_room_control(room_id) {
301
+ return Boolean(localStorage.getItem(`discuss-access:${room_id}`) || (state.admin.logged_in && admin_token()));
302
+ }
303
+
292
304
  /** Explain why a management write failed when this browser has neither credential. */
293
305
  function management_error_message(room_id, error) {
294
306
  if (error.status === 403 && !state.admin.logged_in && !localStorage.getItem(`discuss-access:${room_id}`)) return MANAGEMENT_LOGIN_HINT;
@@ -1691,7 +1703,7 @@ function access_snapshot() {
1691
1703
  const room = state.room;
1692
1704
  if (!room) return { can_manage: false, members: [], notice: "", error: "" };
1693
1705
  const saved = member_credentials(room.id);
1694
- return { can_manage: Boolean(localStorage.getItem(`discuss-access:${room.id}`)),
1706
+ return { can_manage: has_room_control(room.id),
1695
1707
  members: (room.members || room.free?.members || []).filter(member => member.status !== "removed").map(member => ({
1696
1708
  name: member.name, access: member.access, available: member.status === "active" && member.access !== "online"
1697
1709
  && Boolean(saved[member.name]?.token) && (saved[member.name].access === "online") === (member.access === "online")
@@ -2606,7 +2618,7 @@ async function choose_runtime(mode) {
2606
2618
  const path = `/api/rooms/${room_id}/runtime`;
2607
2619
  const runtime = await request_json(path);
2608
2620
  if (runtime.mode !== mode) {
2609
- if (!localStorage.getItem(`discuss-access:${room_id}`)) throw new Error("此浏览器缺少房间控制凭据,请使用创建房间的浏览器");
2621
+ if (!has_room_control(room_id)) throw new Error(MANAGEMENT_LOGIN_HINT);
2610
2622
  await request_json(path, { action: "configure", mode, revision: runtime.revision });
2611
2623
  }
2612
2624
  if (state.room?.id !== room_id) return;
@@ -2710,7 +2722,7 @@ async function conclude_discussion() {
2710
2722
  /** Turn this free room into a discussion within the given limits and start the run; the local service takes it over at its next check. */
2711
2723
  async function start_room_discussion({ goal, max_rounds, max_minutes, execution_minutes = 0, named_followup = false }) {
2712
2724
  const room_id = state.room.id, path = `/api/rooms/${room_id}`;
2713
- if (!localStorage.getItem(`discuss-access:${room_id}`)) throw new Error("此浏览器缺少房间控制凭据,请使用创建房间的浏览器");
2725
+ if (!has_room_control(room_id)) throw new Error(MANAGEMENT_LOGIN_HINT);
2714
2726
  await request_json(`${path}/discussion`, { action: "start", ...(goal ? { goal } : {}), limits: { max_rounds, max_minutes, execution_minutes }, named_followup });
2715
2727
  const runtime = await request_json(`${path}/runtime`);
2716
2728
  await request_json(`${path}/runtime`, { action: "start", mode: "automatic", revision: runtime.revision, deadline: 0 });
@@ -3447,7 +3459,7 @@ function view_snapshot() {
3447
3459
  const experts = Object.fromEntries(["new-expert-modes", "topic-expert-modes"].map((id) =>
3448
3460
  [id, element(id) ? read_expert_inputs(id) : {}]));
3449
3461
  return { rooms: ordered_rooms(), folders: state.folders, folder_groups: ordered_folders(), room: state.room, archive_view: state.archive_view, directory_sort: state.directory_sort, messages: state.messages, total: state.message_total,
3450
- archive_pending: [...state.archive_changes.keys()], directory_error: state.directory_error, directory_notice: state.directory_notice,
3462
+ archive_pending: [...state.archive_changes.keys()], directory_error: state.directory_error, directory_notice: state.directory_notice, import_progress: structuredClone(state.import_progress),
3451
3463
  directory_pending: [...state.directory_pending.keys()], folder_pending: [...state.folder_moves.keys()], creating_room: state.creating_room,
3452
3464
  connected: state.connected, backup_pending: Boolean(state.backup_pending), page: state.page, pages: state.pages, query: state.query,
3453
3465
  attachment_drafts: state.attachment_drafts.map(item => ({ id: item.id, file_name: item.file.name, size: item.file.size, preview_url: item.preview_url })),
@@ -3586,17 +3598,15 @@ function import_body(snapshot) {
3586
3598
  return { ...body, tables: { ...body.tables, room_members } };
3587
3599
  }
3588
3600
 
3589
- /** Register an imported room's exported members on this machine; returns notices and problems, one per member. */
3601
+ /** Register an imported room's exported members on this machine; returns one short line per member and any room-wide problem. */
3590
3602
  async function restore_local_members(room, token, local_members) {
3591
- if (!state.launch.available) return { notices: [], problems: [`${room.title}:未连接本机服务,成员会话未恢复`] };
3603
+ if (!state.launch.available) return { members: [], problem: "未连接本机服务,成员会话未恢复" };
3592
3604
  try {
3593
3605
  const result = await local_launch_request("room-restore", { room_id: room.id, token, local_members });
3594
- const notices = result.members.filter(member => member.status !== "failed").map(member => member.status === "resumed"
3595
- ? `${member.name} 请回后续接原会话`
3596
- : `${member.name} 已改为手动接入,请回后在原窗口重新注册${member.session_id ? `(会话 ${member.session_id},目录 ${member.cwd};没有开着的窗口时在该目录运行 claude --resume ${member.session_id})` : ""}`);
3597
- const problems = result.members.filter(member => member.status === "failed").map(member => `${member.name} 未恢复:${member.error}`);
3598
- return { notices: notices.map(text => `${room.title}:${text}`), problems: problems.map(text => `${room.title}:${text}`) };
3599
- } catch (error) { return { notices: [], problems: [`${room.title}:成员会话未恢复:${error.message}`] }; }
3606
+ return { members: result.members.map(member => ({ name: member.name, failed: member.status === "failed",
3607
+ text: member.status === "failed" ? `未恢复:${member.error}` : member.status === "resumed" ? "请回后接续原会话" : "已改为手动接入,请回后在原窗口重新注册",
3608
+ resume: member.status === "manual" && member.session_id ? { command: `claude --resume ${member.session_id}`, cwd: member.cwd } : null })) };
3609
+ } catch (error) { return { members: [], problem: `成员会话未恢复:${error.message}` }; }
3600
3610
  }
3601
3611
 
3602
3612
  /** Upload one imported room's original files to the new attachment IDs; returns how many were missing. */
@@ -3615,41 +3625,43 @@ async function upload_imported_files(room_id, token, mapping, files) {
3615
3625
  async function import_rooms({ files, folder_id = null }) {
3616
3626
  if (!files?.length || state.backup_pending) return;
3617
3627
  state.backup_pending = true;
3618
- state.directory_notice = state.directory_error = "";
3628
+ const progress = state.import_progress = { done: false, rooms: [], errors: [] };
3619
3629
  publish_view();
3620
- const imported = [], skipped = [], failed = [], restored = [], unrestored = [], unfiled = [];
3621
- let missing = 0;
3622
3630
  try {
3623
3631
  for (const file of files) {
3624
3632
  let rooms;
3625
3633
  try { rooms = await window.discuss_backup.read_room_archives(file); }
3626
- catch (error) { failed.push(error.message.includes(file.name) ? error.message : `${file.name}:${error.message}`); continue; }
3634
+ catch (error) { progress.errors.push(error.message.includes(file.name) ? error.message : `${file.name}:${error.message}`); publish_view(); continue; }
3627
3635
  for (const { snapshot, files: originals } of rooms) {
3628
- const title = snapshot.tables?.rooms?.[0]?.title || file.name, token = crypto.randomUUID() + crypto.randomUUID();
3636
+ const item = { title: snapshot.tables?.rooms?.[0]?.title || file.name, status: "importing", members: [], problems: [] };
3637
+ progress.rooms.push(item);
3638
+ publish_view();
3639
+ const token = crypto.randomUUID() + crypto.randomUUID();
3629
3640
  try {
3630
3641
  const result = await request_json("/api/rooms/import", import_body(snapshot), token);
3631
- if (result.skipped) { skipped.push(result.title); continue; }
3642
+ item.title = result.title;
3643
+ if (result.skipped) { item.status = "skipped"; continue; }
3632
3644
  save_room_credentials({ id: result.id }, token);
3633
- missing += await upload_imported_files(result.id, token, result.attachments, originals);
3634
- imported.push(result.title);
3645
+ const missing = await upload_imported_files(result.id, token, result.attachments, originals);
3646
+ if (missing) item.problems.push(`${missing} 个附件原文件缺失,消息中的这些附件无法打开`);
3635
3647
  if (folder_id) await request_json(`/api/rooms/${result.id}/folder`, { actor: "用户", folder_id })
3636
- .catch(error => unfiled.push(`${result.title}:未放入文件夹:${error.message}`));
3648
+ .catch(error => item.problems.push(`未放入文件夹:${error.message}`));
3637
3649
  if (snapshot.local_members) {
3638
- const { notices, problems } = await restore_local_members(result, token, snapshot.local_members);
3639
- restored.push(...notices); unrestored.push(...problems);
3650
+ const { members, problem } = await restore_local_members(result, token, snapshot.local_members);
3651
+ item.members = members;
3652
+ if (problem) item.problems.push(problem);
3640
3653
  }
3641
- } catch (error) { failed.push(`${title}:${error.message}`); }
3654
+ item.status = "imported";
3655
+ } catch (error) { Object.assign(item, { status: "failed", error: error.message }); }
3656
+ finally { publish_view(); }
3642
3657
  }
3643
3658
  }
3644
3659
  await refresh_room();
3645
3660
  } finally {
3661
+ progress.done = true;
3646
3662
  state.backup_pending = false;
3647
3663
  publish_view();
3648
3664
  }
3649
- state.directory_notice = [`已导入 ${imported.length} 个频道`, skipped.length && `跳过已存在的 ${skipped.length} 个(${skipped.join("、")})`, ...restored]
3650
- .filter(Boolean).join(";");
3651
- state.directory_error = [missing && `${missing} 个附件原文件缺失,消息中的这些附件无法打开`, failed.length && `未导入 ${failed.length} 项:${failed.join(";")}`, ...unfiled, ...unrestored]
3652
- .filter(Boolean).join(";");
3653
3665
  }
3654
3666
 
3655
3667
  /** Execute a named view command, retaining existing retry and provenance rules. */