ai_co_discuss 0.3.37 → 0.3.39

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/docs/en/runner.md CHANGED
@@ -37,6 +37,12 @@ codiscuss setup
37
37
 
38
38
  Run `codiscuss local open`, click **New Room** (新建房间), add members and pick a host. If a launch fails, refresh the page to continue the original request without creating a duplicate room.
39
39
 
40
+ ### Browse Project Files
41
+
42
+ When a room has a project directory, its **Files** page below **Room** lists that computer's files and folders, sizes and modification times. Click a file to open a floating text preview, with rendered Markdown and static HTML views. Rooms without a project directory show a disabled entry.
43
+
44
+ Other devices read the same directory over the paired computer's encrypted connection. Previews include up to 128 KB and support UTF-8 and BOM-marked UTF-16. HTML previews display inline styles and embedded images. Access stays within the room's project directory.
45
+
40
46
  ### From an AI Chat
41
47
 
42
48
  In the local agent that should host, invoke `discuss-host` and `discuss-participant` and describe the site, topic, participants and limits. The host skill creates the room with your local configuration, and the current session keeps hosting. `agy` cannot host a fully automatic discussion from its own chat.
package/docs/runner.md CHANGED
@@ -37,6 +37,12 @@ codiscuss setup
37
37
 
38
38
  运行 `codiscuss local open`,点击“新建房间”,添加成员并指定主持人。启动失败时刷新页面,可沿用原请求继续,不会重复建房。
39
39
 
40
+ ### 浏览项目文件
41
+
42
+ 房间设置了项目目录后,左栏“房间”下方的“文件”页可浏览电脑上的文件夹与文件,查看大小和修改时间。点击文件打开悬浮预览,支持文本、Markdown 渲染与 HTML 静态渲染,并可切换源码。未设置项目目录时入口灰显。
43
+
44
+ 其他设备通过已配对电脑的加密连接读取同一目录。文件内容最多预览前 128 KB,支持 UTF-8 和带 BOM 的 UTF-16;HTML 展示内联样式与内嵌图片。文件访问限于房间项目目录,目录外的链接灰显。
45
+
40
46
  ### 从 AI 对话发起
41
47
 
42
48
  在要担任主持人的本地 AI 对话中调用 `discuss-host` 和 `discuss-participant`,说明站点、议题、参与者与上限。主持技能沿用本机软件配置建房,当前会话继续主持。agy 不能在自己的对话中主持全自动讨论。
@@ -92,7 +98,7 @@ API 成员可以担任专家或主持人。房间指定了项目目录时,它
92
98
 
93
99
  在火山引擎控制台开通「语音合成2.0」(朗读)和「录音文件识别2.0」(语音输入),复制 API Key,填进网页“设置”的“语音”。朗读可设一个通用音色,并按 AI 平台(Claude、Codex、Gemini 等)分别配音色,没配的平台、你自己的消息和程序消息用通用音色;还可选语速、改语气提示(默认是“像给朋友讲故事一样”的自然语气)。密钥只保存在本机,费用按你的账号计费。
94
100
 
95
- - 朗读:每条消息的工具栏有“朗读”(手机上在“…”菜单里),在消息里选中文字后点“听当前”从选中处开始;读完这条会接着读后面的消息直到最新,点输入框右下角的播报按钮开启语音播报:之后的新回复会自动朗读回答摘要(没写摘要的短回答读全文,长回答读第一段),主持人的发言会报“主持人”;讨论中还会提示新一轮的发言顺序和轮到谁,执行失败、操作待你允许和讨论结束也会播一句。同时到达的回复依次播报,积压较多时只播最新 3 条。锁屏或切到其他 App 时继续播放,锁屏界面和耳机按键可以暂停、继续、跳到下一条;读过的段落会缓存在电脑上,重听不再计费。正在读的句子会高亮并自动滚到视野内,点一下高亮的句子暂停,再点继续;代码块跳过。
101
+ - 朗读:每条消息的工具栏有“朗读”(手机上在“…”菜单里),在消息里选中文字后点“听当前”从选中处开始;读完这条会接着读后面的消息直到最新,点输入框右下角的播报按钮开启语音播报:之后的新回复会自动朗读回答摘要(没写摘要的短回答读全文,长回答读第一段),主持人的发言会报“主持人”;讨论中还会提示新一轮的发言顺序和轮到谁,执行失败、操作待你允许和讨论结束也会播一句。同时到达的回复依次播报,积压较多时只播最新 3 条。锁屏或切到其他 App 时继续播放,锁屏界面和耳机按键可以暂停、继续、跳到下一条;读过的段落会缓存在电脑上,重听不再计费。正在读的句子会高亮,点一下高亮的句子暂停,再点继续;代码块跳过。朗读时消息区底部有“跟随”和“退出”:页面默认不随朗读滚动,点“跟随”后才把正在读的句子保持在视野内,手动滚动一下就取消跟随。
96
102
  - 语音输入:点输入框右下角的麦克风开始说话,再点一次转成文字插入输入框,单次最长 5 分钟。浏览器会先请求麦克风权限。
97
103
 
98
104
  两者都由本机服务调用火山引擎,手机等其他设备经已配对电脑使用,电脑关机时不可用。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.3.37",
3
+ "version": "0.3.39",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
@@ -177,7 +177,11 @@ function lifecycle(state, event, task, bind) {
177
177
  if (!state.denial && Array.isArray(result.denied_actions) && result.denied_actions.length) {
178
178
  state.denial = `的${result.denied_actions.map(item => item.display_name || item.action).join('、')}操作未获授权,无人值守模式下被自动拒绝,随后会话结束`;
179
179
  }
180
- if (!state.completed) state.error = `Antigravity CLI ${result.status || '缺少结果状态'}:${result.error?.message || result.response || '执行未成功'}`;
180
+ // agy reports `error` as a plain string (1.4.x), older builds as { message }.
181
+ const reason = typeof result.error === 'string' ? result.error : result.error?.message;
182
+ if (!state.completed) state.error = `Antigravity CLI ${result.status || '缺少结果状态'}:${reason || result.response || '执行未成功'}`;
183
+ // Failed before opening a conversation (sign-in, eligibility check): no turn ran, so delivering again repeats nothing.
184
+ if (!state.completed && !state.initialized && !result.conversation_id && !result.num_turns) state.not_started = true;
181
185
  }
182
186
  }
183
187
 
@@ -77,7 +77,8 @@ function monitor_cli(adapter, child, binding, task) {
77
77
  if (!state.spawned) return;
78
78
  if (state.denials.length) task.on_denials?.(state.denials);
79
79
  const error = cli_exit_error(adapter, state, code, signal);
80
- if (error) task.on_failure(error);
80
+ // `not_started`: the adapter saw the CLI fail before any turn ran, so the runner may deliver the same task again.
81
+ if (error) task.on_failure(error, { not_started: Boolean(state.not_started) });
81
82
  else task.on_exit?.(state.denial);
82
83
  });
83
84
  return state;
package/runner/discuss.py CHANGED
@@ -27,6 +27,7 @@ SESSION_VARIABLES = (("CLAUDE_CODE_SESSION_ID", "claude"), ("CODEX_THREAD_ID", "
27
27
  MAX_TEXT_LENGTH = 32000
28
28
  MAX_SUMMARY_LENGTH = 1000
29
29
  MAX_BRIEF_LENGTH = 1600
30
+ MAX_MEMBER_HANDLE = 80
30
31
  PARTICIPANTS = ("用户", "Codex", "Claude", "Antigravity", "DeepSeek", "Qwen", "Grok")
31
32
  # A platform is a type; `Claude-review` is a second Claude instance with its own session. Mirrors backend/identity.py.
32
33
  INSTANCE_PATTERN = re.compile(r"(Codex|Claude|Antigravity|DeepSeek|Qwen|Grok)(?:-[0-9A-Za-z_]{1,16})?")
@@ -109,6 +110,14 @@ def ai_name(value):
109
110
  return value
110
111
 
111
112
 
113
+ def member_handle(value):
114
+ """Accept a member as the room calls it: its display name (such as Gemini) or its ID; the site resolves and checks it."""
115
+ value = value.strip()
116
+ if not 0 < len(value) <= MAX_MEMBER_HANDLE:
117
+ raise argparse.ArgumentTypeError(f"须为成员的显示名或 ID,1–{MAX_MEMBER_HANDLE} 字")
118
+ return value
119
+
120
+
112
121
  def participant(value):
113
122
  """Accept the user or any AI instance."""
114
123
  return value if value == PARTICIPANTS[0] else ai_name(value)
@@ -218,6 +227,11 @@ def room_commands(subcommands, registry):
218
227
  parser.add_argument("--submission-id", type=submission_id, help="按建房提交编号精确查询")
219
228
  elif action in ("view", "brief"):
220
229
  parser.add_argument("--request", type=positive_int, help="手动成员:按自己的有效请求限制可读原文")
230
+ events = add_leaf(subcommands, registry, "room events",
231
+ "房主读取房间事件记录:用户与程序的每次写入(含被拒的)及其引起的状态变化,按发生顺序,用于排查问题;默认最新 500 条", True)
232
+ paging = events.add_mutually_exclusive_group()
233
+ paging.add_argument("--after", type=non_negative_int, help="只读此事件编号之后的记录")
234
+ paging.add_argument("--before", type=non_negative_int, help="读此事件编号之前的一页;按返回的 more 继续向前")
221
235
  limits = add_leaf(subcommands, registry, "room limits", "查看上限;--check 检查并执行到限处理;--done 通知程序目标已完成", True)
222
236
  limits.add_argument("--check", action="store_true")
223
237
  limits.add_argument("--done", action="store_true", help="主持人宣告目标已完成:结束讨论,房间回到自由协作;须带 --conclusion")
@@ -255,11 +269,11 @@ def message_commands(subcommands, registry):
255
269
  add_text(post, "summary")
256
270
  post.add_argument("--kind", choices=("chat", "moderation"), default="chat")
257
271
  post.add_argument("--execution", type=positive_int, help="自由房间:本次执行编号;省略时用凭据绑定的执行。执行失效后不能改作普通发言")
258
- post.add_argument("--to", type=ai_name, action="append", default=[],
272
+ post.add_argument("--to", type=member_handle, action="append", default=[],
259
273
  help="自由房间:主持人点名派活,普通成员提出回应建议(须房主确认);可重复")
260
274
  post.add_argument("--handoff", action="store_true",
261
275
  help="自由房间主持人:与 --to 同用,只点名一位成员时,它做完也回到你这里(如再交评审);点名多位时总会回到你")
262
- post.add_argument("--next-review", type=ai_name, help="主持派实现时声明下一位只读评审;先读 review roster,不可指定本批实现者")
276
+ post.add_argument("--next-review", type=member_handle, help="主持派实现时声明下一位只读评审;先读 review roster,不可指定本批实现者")
263
277
  post.add_argument("--review-scope", help="本批预授权评审范围,1–2000 字")
264
278
  post.add_argument("--review-roster", help="先读取 review roster 取得的 revision;状态变化须重读")
265
279
  post.add_argument("--request", type=positive_int, action="append", default=[],
@@ -304,7 +318,7 @@ def turn_commands(subcommands, registry):
304
318
  elif action not in ("create", "continue", "ask", "roster", "order", "reorder"):
305
319
  parser.add_argument("id", type=positive_int, help="安排编号")
306
320
  if action == "create":
307
- parser.add_argument("--expert", type=ai_name, help="单个安排的专家;与 --plan-file 二选一")
321
+ parser.add_argument("--expert", type=member_handle, help="单个安排的专家;与 --plan-file 二选一")
308
322
  add_text(parser, "question")
309
323
  parser.add_argument("--plan-file", help='整轮安排 JSON 数组 [{"expert":"Claude","question":"…"}],按顺序接力;- 从标准输入读取')
310
324
  parser.add_argument("--roster", required=True, help="turn roster 返回的 revision;任何新安排前必读,专家状态变化后需重新获取")
@@ -316,7 +330,7 @@ def turn_commands(subcommands, registry):
316
330
  help="按新顺序重复指定,须恰好包含 turn order 中 movable 的全部成员")
317
331
  if action == "ask":
318
332
  parser.add_argument("--request", type=positive_int, required=True, help="turn roster 中 host_requests 的请求编号")
319
- parser.add_argument("--expert", type=ai_name, required=True, help="在场且已注册的目标成员,含已发言者和主持人")
333
+ parser.add_argument("--expert", type=member_handle, required=True, help="在场且已注册的目标成员,含已发言者和主持人")
320
334
  parser.add_argument("--submission-id", type=submission_id, help="转交唯一编号;重试沿用原编号与内容。省略时按命令与内容自动生成,同样内容重试得到同一编号")
321
335
  if action in ("continue", "ask"):
322
336
  parser.add_argument("--roster", required=True, help="turn roster 返回的 revision;状态变化后须重新读取")
@@ -541,6 +555,9 @@ def plan_room(args):
541
555
  return RequestPlan(base + "/conclusion")
542
556
  if args.action == "protocol":
543
557
  return RequestPlan(f"/r/{args.room}/protocol")
558
+ if args.action == "events":
559
+ params = {"after": args.after, "before": args.before}
560
+ return RequestPlan(base + "/events", params={key: value for key, value in params.items() if value})
544
561
  return RequestPlan(base, view="brief" if args.action == "brief" else "overview", params=read_context_params(args))
545
562
 
546
563
 
@@ -10,6 +10,7 @@ import { pathToFileURL } from 'node:url';
10
10
  import { parseArgs } from 'node:util';
11
11
 
12
12
  import { atomic_json } from './files.mjs';
13
+ import { read_project_files } from './project_files.mjs';
13
14
  import { setup_sidecar } from './adapters/antigravity_app.mjs';
14
15
  import { adapter_of, adoption_adapter, BRANCHABLE_ADAPTERS, branch_session, session_settings, software_defaults, software_effort, software_models } from './adapters/index.mjs';
15
16
  import { ADAPTER_IDS, ADAPTER_PROFILES, adapter_profile, platform_adapters, same_session_family } from './adapters/profiles.mjs';
@@ -54,6 +55,7 @@ const HELP = `用法:codiscuss launch settings [--file FILE]
54
55
  codiscuss launch start --file FILE [--dry-run]
55
56
  codiscuss launch room-config --file FILE
56
57
  codiscuss launch room-models --file FILE
58
+ codiscuss launch room-files --file FILE
57
59
  codiscuss launch models --file FILE
58
60
  codiscuss launch api-key --file FILE
59
61
  codiscuss launch api-check --file FILE
@@ -79,6 +81,7 @@ settings 读取本机软件默认配置;指定文件时保存,不启动 AI
79
81
  start 使用启动 JSON 创建或接入房间并启动;--dry-run 只校验和预览。
80
82
  room-config 修正已暂停房间的软件路径或切换 Codex App / CLI,不启动讨论;须提供 site、room_id、token。
81
83
  room-models 只读指定 site、room_id 的本机登记模型与思考强度,不启动讨论;quick 为 true 时不查询 agy 的默认模型(较慢),该成员标 defaults_pending。
84
+ room-files 只读房间已保存的项目目录,须提供 site、room_id、token,可选相对 path 和目录分页 offset;文件返回最多前 128 KiB 文本。
82
85
  models 按 adapter、executable 或 API profile 读取模型目录,不启动讨论或生成回答。
83
86
  api-key 将 base_url、api_key 保存于本机私有目录,只返回 api_key_ref。
84
87
  api-check 用 API profile 向模型发送一次极短请求,核对地址、密钥、协议与模型是否可用。
@@ -303,7 +306,13 @@ export async function room_models(home, raw, { read_defaults = software_defaults
303
306
  })));
304
307
  // Smart scheduling is a local choice; only which model decides is shown, never its key.
305
308
  const scheduler = config.scheduler ? { provider: config.scheduler.provider, model: config.scheduler.model, mode: config.scheduler.mode || DEFAULT_SCHEDULER_MODE } : null;
306
- return { room_id: input.room_id, registered: paths.length > 0, participants, scheduler };
309
+ return { room_id: input.room_id, registered: paths.length > 0, participants, scheduler, project_directory: config.cwd || '' };
310
+ }
311
+
312
+ /** Read the owner's saved room project, never a directory supplied by the request. */
313
+ export async function room_files(home, raw) {
314
+ const { input, config } = await member_room_context(home, raw);
315
+ return { room_id: input.room_id, ...read_project_files(config.cwd, input.path, input.offset) };
307
316
  }
308
317
 
309
318
  /** Save only reusable client settings; room credentials and session bindings are never defaults. */
@@ -1308,6 +1317,7 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
1308
1317
  else if (positionals[0] === 'tts' && input) result = await tts_speak(home, input);
1309
1318
  else if (positionals[0] === 'stt' && input) result = await stt_part(home, input);
1310
1319
  else if (positionals[0] === 'room-models' && input) result = await room_models(home, input);
1320
+ else if (positionals[0] === 'room-files' && input) result = await room_files(home, input);
1311
1321
  else if (positionals[0] === 'models' && adapter_profile(input?.adapter)?.kind === 'api') result = await api_models(home, input);
1312
1322
  else if (positionals[0] === 'models' && input) {
1313
1323
  const saved = Object.values(launch_settings(home).participants).find(profile => profile.adapter === input.adapter);
@@ -95,7 +95,7 @@ export function free_task_files(config, member, execution, binding, { session =
95
95
  const requests = execution.requests || [];
96
96
  const discussion_request = requests.find(item => item.session_request_id && ['discuss', 'discuss_host'].includes(item.purpose)) || null;
97
97
  const direct_question = Boolean(session?.status === 'active' && requests.some(item => item.purpose === 'ask'));
98
- const { instructions, prompt } = free_task_text({ name: member.name, role: member.role, discussion: direct_question,
98
+ const { instructions, prompt } = free_task_text({ name: member.label || member.name, role: member.role, discussion: direct_question,
99
99
  discussion_request, profile, cli,
100
100
  room: config.room, messages, read_after: execution.read_until, execution_id: execution.id, task_path,
101
101
  access_file: bound_access ? '' : access_path, read_rule: read_rule(profile.adapter), inbox });
@@ -172,6 +172,8 @@ export class FreeRoomRunner {
172
172
  this.execution_processes = new Map();
173
173
  // Execution id -> why its session ended on its own, such as a command refused in unattended mode ('' when unknown).
174
174
  this.exited = new Map();
175
+ // Executions whose software failed before any turn ran, as its adapter reported; safe to deliver again.
176
+ this.not_started = new Set();
175
177
  this.snapshots = new Map();
176
178
  // Execution id -> { phase, at, sent_phase, sent_at }: what the member's software last showed it doing.
177
179
  this.activity = new Map();
@@ -620,7 +622,10 @@ export class FreeRoomRunner {
620
622
  on_activity: (phase, tool) => { this.touch(execution.id, phase); this.segments.mark(execution.id, phase, tool); },
621
623
  on_note: text => this.note(execution.id, text),
622
624
  // A stopped process may report after the next execution started; only its own execution takes the result.
623
- on_failure: error => { try { if (this.row(member.name)?.execution_id === execution.id) this.save(member.name, { error }); } catch { /* Store may already be closed at shutdown. */ } },
625
+ on_failure: (error, { not_started = false } = {}) => {
626
+ if (not_started) this.not_started.add(execution.id);
627
+ try { if (this.row(member.name)?.execution_id === execution.id) this.save(member.name, { error }); } catch { /* Store may already be closed at shutdown. */ }
628
+ },
624
629
  on_exit: denial => this.exited.set(execution.id, denial || ''),
625
630
  // Shown beside the answer or failure so the owner can allow them; a lost report only hides the list.
626
631
  on_denials: denials => api(this.config, `/executions/${execution.id}`, { action: 'denials', denials }).catch(() => {}),
@@ -841,12 +846,14 @@ export class FreeRoomRunner {
841
846
  }
842
847
  }
843
848
  /**
844
- * A manual member's session refused the delivery: keep the execution and deliver again after a wait shown on the
845
- * website, up to MAX_DELIVERY_RETRIES times; any other member, or the last attempt, fails as before.
849
+ * A manual member's session refused the delivery, or the software failed before any turn ran: keep the execution and
850
+ * deliver again after a wait shown on the website, up to MAX_DELIVERY_RETRIES times; anything else, or the last
851
+ * attempt, fails as before.
846
852
  */
847
853
  async retry_or_fail(name, execution, reason) {
848
854
  const attempt = (execution.retry?.attempt || 0) + 1;
849
- if (this.config.participants[name]?.project_access !== 'own') return this.fail(name, execution.id, reason);
855
+ const not_started = this.not_started.delete(execution.id);
856
+ if (this.config.participants[name]?.project_access !== 'own' && !not_started) return this.fail(name, execution.id, reason);
850
857
  if (attempt > MAX_DELIVERY_RETRIES) return this.fail(name, execution.id, `${reason}(已自动重试 ${MAX_DELIVERY_RETRIES} 次)`);
851
858
  this.stop_member(name);
852
859
  this.exited.delete(execution.id);
@@ -857,6 +864,7 @@ export class FreeRoomRunner {
857
864
  /** Record the cause on the website, then stop this member's own processes. */
858
865
  async fail(name, execution_id, reason) {
859
866
  this.activity.delete(execution_id);
867
+ this.not_started.delete(execution_id);
860
868
  await this.report_segments(execution_id, true);
861
869
  try { await api(this.config, `/executions/${execution_id}`, { action: 'fail', reason }); }
862
870
  catch (error) { if (!/HTTP 409/.test(error.message)) throw error; }
@@ -0,0 +1,78 @@
1
+ /** Read-only directory and text previews, confined to a room's saved project directory. */
2
+ import { closeSync, fstatSync, openSync, readdirSync, readSync, realpathSync, statSync } from 'node:fs';
3
+ import { isAbsolute, relative, resolve } from 'node:path';
4
+
5
+ const DIRECTORY_PAGE_SIZE = 200;
6
+ // Leave room for JSON escaping and base64 within the queued relay's 512 KiB encrypted envelope.
7
+ const PREVIEW_MAX_BYTES = 128 * 1024;
8
+ const DIRECTORY_MAX_BYTES = 128 * 1024;
9
+
10
+ /** Resolve a relative project path, including links, without allowing an escape from the project. */
11
+ function project_path(root, path) {
12
+ if (typeof path !== 'string' || /[\0:]/.test(path) || isAbsolute(path) || path.replaceAll('\\', '/').split('/').includes('..')) {
13
+ throw new Error('只能读取项目目录内的相对路径');
14
+ }
15
+ const target = realpathSync(resolve(root, path));
16
+ const inside = relative(root, target);
17
+ if (inside === '..' || inside.startsWith('../') || inside.startsWith('..\\') || isAbsolute(inside)) {
18
+ throw new Error('此链接指向项目目录之外,无法读取');
19
+ }
20
+ return target;
21
+ }
22
+
23
+ /** Describe one entry; broken or external links remain visible but cannot be opened. */
24
+ function directory_entry(root, path, entry) {
25
+ const child = path ? `${path}/${entry.name}` : entry.name;
26
+ try {
27
+ const info = statSync(project_path(root, child));
28
+ const kind = info.isDirectory() ? 'directory' : info.isFile() ? 'file' : 'unavailable';
29
+ return { name: entry.name, path: child, kind, size: info.isFile() ? info.size : null, modified_at: info.mtime.toISOString() };
30
+ } catch {
31
+ return { name: entry.name, path: child, kind: 'unavailable', size: null, modified_at: null };
32
+ }
33
+ }
34
+
35
+ /** Read a bounded UTF-8 or BOM-marked UTF-16 preview, returning a reason for binary files. */
36
+ function text_preview(target) {
37
+ const descriptor = openSync(target, 'r');
38
+ try {
39
+ const info = fstatSync(descriptor);
40
+ if (!info.isFile()) throw new Error('只能预览普通文件');
41
+ const bytes = Buffer.alloc(Math.min(info.size, PREVIEW_MAX_BYTES));
42
+ const length = readSync(descriptor, bytes, 0, bytes.length, 0);
43
+ const truncated = info.size > length;
44
+ const encoding = bytes[0] === 0xff && bytes[1] === 0xfe ? 'utf-16le'
45
+ : bytes[0] === 0xfe && bytes[1] === 0xff ? 'utf-16be' : 'utf-8';
46
+ const result = { size: info.size, modified_at: info.mtime.toISOString(), truncated, limit: PREVIEW_MAX_BYTES, encoding };
47
+ try {
48
+ const text = new TextDecoder(encoding, { fatal: true }).decode(bytes.subarray(0, length), { stream: truncated });
49
+ if (/[\u0000-\u0008\u000b\u000e-\u001f]/.test(text)) throw new Error('binary');
50
+ return { ...result, text };
51
+ } catch { return { ...result, text: null, reason: '此文件为二进制或使用了暂不支持的文本编码' }; }
52
+ } finally { closeSync(descriptor); }
53
+ }
54
+
55
+ /** List one directory page or preview one file under the configured project root. */
56
+ export function read_project_files(cwd, path = '', offset = 0) {
57
+ if (!cwd?.trim()) throw new Error('本房间未设置项目目录');
58
+ if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('目录分页位置无效');
59
+ const root = realpathSync(cwd);
60
+ if (!statSync(root).isDirectory()) throw new Error('项目目录不是文件夹');
61
+ const target = project_path(root, path);
62
+ const info = statSync(target);
63
+ const normalized = path.replaceAll('\\', '/').split('/').filter(part => part && part !== '.').join('/');
64
+ if (info.isFile()) return { path: normalized, kind: 'file', ...text_preview(target) };
65
+ if (!info.isDirectory()) throw new Error('只能浏览文件夹或预览普通文件');
66
+ const items = readdirSync(target, { withFileTypes: true }).sort((left, right) =>
67
+ Number(right.isDirectory()) - Number(left.isDirectory()) || left.name.localeCompare(right.name, 'zh-CN', { numeric: true }));
68
+ const entries = [];
69
+ let bytes = 0;
70
+ for (const entry of items.slice(offset, offset + DIRECTORY_PAGE_SIZE)) {
71
+ const item = directory_entry(root, normalized, entry);
72
+ bytes += Buffer.byteLength(JSON.stringify(item));
73
+ if (entries.length && bytes > DIRECTORY_MAX_BYTES) break;
74
+ entries.push(item);
75
+ }
76
+ return { path: normalized, kind: 'directory', entries, total: items.length,
77
+ next_offset: offset + entries.length < items.length ? offset + entries.length : null };
78
+ }
@@ -27,9 +27,9 @@ const RELAY_CIPHER_INFO = 'codiscuss-relay-v1';
27
27
  const LAUNCH_SCRIPT = fileURLToPath(new URL('./discussion_launch.mjs', import.meta.url));
28
28
  // What a connected browser may ask the local bridge for, minus the folder dialog nobody would see and pairing itself.
29
29
  const RELAYED_ACTIONS = new Set(['settings', 'launch', 'room-models', 'room-member', 'room-member-access', 'room-member-allow', 'room-member-adapter',
30
- 'room-member-model', 'room-profiles', 'room-restore', 'room-fork', 'models', 'api-key', 'api-check', 'scheduler-check', 'tts-settings', 'tts', 'stt', 'projects', 'presets', 'admin-account']);
30
+ 'room-member-model', 'room-profiles', 'room-files', 'room-restore', 'room-fork', 'models', 'api-key', 'api-check', 'scheduler-check', 'tts-settings', 'tts', 'stt', 'projects', 'presets', 'admin-account']);
31
31
  export const ROOM_ACTIONS = new Set(['launch', 'room-models', 'room-member', 'room-member-access', 'room-member-allow', 'room-member-adapter',
32
- 'room-member-model', 'room-profiles', 'room-restore', 'room-fork']);
32
+ 'room-member-model', 'room-profiles', 'room-files', 'room-restore', 'room-fork']);
33
33
 
34
34
  /** Read every site this computer is paired with. */
35
35
  export function machine_links(home) {
package/runner/stt.mjs CHANGED
@@ -10,7 +10,9 @@ import { read_saved_api_key } from './discussion_api.mjs';
10
10
  import { TTS_BASE_URL, tts_settings } from './tts.mjs';
11
11
 
12
12
  const STT_ENDPOINT = `${TTS_BASE_URL}/api/v3/auc/bigmodel/recognize/flash`;
13
- const STT_RESOURCE_ID = 'volc.bigasr.auc_turbo';
13
+ // The console's “录音文件识别2.0” grants this resource; it answers on the flash route (verified 2026-11-03). The 1.0
14
+ // flash resource `volc.bigasr.auc_turbo` is a separate product and is refused with 45000030 when only 2.0 is enabled.
15
+ const STT_RESOURCE_ID = 'volc.seedasr.auc';
14
16
  const STT_DONE_CODE = '20000000';
15
17
  const STT_SILENT_CODE = '20000003';
16
18
  const STT_TIMEOUT_MS = 50_000;
@@ -22,6 +24,7 @@ const STALE_MS = 60 * 60 * 1000;
22
24
  const STATUS_MESSAGES = {
23
25
  45000001: '请求参数有误',
24
26
  45000002: '没有录到声音',
27
+ 45000030: '火山引擎未授权该识别服务:请在控制台开通「录音文件识别2.0」',
25
28
  45000151: '音频格式不受支持',
26
29
  55000031: '火山引擎语音识别繁忙,请稍后再试',
27
30
  };
@@ -54,8 +57,8 @@ async function recognize(key, wav, request) {
54
57
  'X-Api-Request-Id': randomUUID(), 'X-Api-Sequence': '-1' },
55
58
  body: JSON.stringify({ user: { uid: 'codiscuss' }, audio: { data: wav.toString('base64') }, request: { model_name: 'bigmodel', enable_punc: true, enable_itn: true } }) });
56
59
  } catch { throw new Error('语音识别请求超时或连接失败,请检查网络后重试'); }
57
- if (response.status === 401 || response.status === 403) throw new Error('火山引擎拒绝了请求:API Key 无效或未开通录音文件识别 2.0');
58
60
  const code = response.headers.get('X-Api-Status-Code');
61
+ if ((response.status === 401 || response.status === 403) && !STATUS_MESSAGES[code]) throw new Error('火山引擎拒绝了请求:API Key 无效或未开通录音文件识别 2.0');
59
62
  if (code === STT_SILENT_CODE) return '';
60
63
  if (code !== STT_DONE_CODE) {
61
64
  const message = STATUS_MESSAGES[code] || response.headers.get('X-Api-Message') || `HTTP ${response.status}`;
package/web/app.js CHANGED
@@ -839,7 +839,7 @@ function render_host_brief() {
839
839
  const room = state.room;
840
840
  element("host-brief-panel").hidden = !room;
841
841
  if (!room) return;
842
- room.rendered_brief = window.discuss_markdown.render_markdown(room.host_brief?.text || "主持人尚未更新结论、共识、歧义与下一步意图。");
842
+ room.rendered_brief = render_room_markdown(room.host_brief?.text || "主持人尚未更新结论、共识、歧义与下一步意图。");
843
843
  element("progress-speaker").textContent = progress_speaker(room);
844
844
  element("host-brief-text").innerHTML = room.rendered_brief;
845
845
  element("host-brief-meta").textContent = brief_meta(room);
@@ -871,8 +871,8 @@ function render_messages(result) {
871
871
  const kind = withdrawn.get(message.id) || message.withdrawn || "";
872
872
  // A message shown straight from its send response is replaced by the copy the transcript reads.
873
873
  if (saved && !saved.just_sent) return (saved.withdrawn || "") === kind ? saved : { ...saved, withdrawn: kind };
874
- return { ...message, withdrawn: kind, rendered_text: window.discuss_markdown.render_markdown(message.text),
875
- rendered_summary: window.discuss_markdown.render_markdown(message.summary || "") };
874
+ return { ...message, withdrawn: kind, rendered_text: render_room_markdown(message.text),
875
+ rendered_summary: render_room_markdown(message.summary || "") };
876
876
  });
877
877
  state.message_total = result.total;
878
878
  state.messages_loading = false;
@@ -1096,6 +1096,17 @@ function mention_handles(name, names) {
1096
1096
  return nickname && nickname !== name && !taken.includes(nickname) ? [nickname, name] : [name];
1097
1097
  }
1098
1098
 
1099
+ /** What renders as a mention: the user and each member by the handle @ offers for it; a member ID with a nickname stays plain text. */
1100
+ function mention_highlights() {
1101
+ const names = (state.room?.members || []).map(member => member.name);
1102
+ return ["用户", display_name("用户"), ...names.map(name => mention_handles(name, names)[0])];
1103
+ }
1104
+
1105
+ /** Render room text with the open room's mentions marked. */
1106
+ function render_room_markdown(text) {
1107
+ return window.discuss_markdown.render_markdown(text, mention_highlights());
1108
+ }
1109
+
1099
1110
  /**
1100
1111
  * The members a text @names, longest handle first: once "@Claude评审" names one member, the shorter "@Claude" inside it
1101
1112
  * does not also name another (Chinese nicknames have no space after them).
@@ -2453,9 +2464,9 @@ async function retry_launch_connection() {
2453
2464
  /** Read saved choices for the active room without using drafts or delaying discussion refreshes. */
2454
2465
  async function load_room_models() {
2455
2466
  const room = state.room;
2456
- // Only members this computer wakes make the models worth reading.
2467
+ // The saved project directory also applies to rooms containing only manual members.
2457
2468
  const woken = (room?.members || []).filter(member => member.access === "runner").map(member => member.name);
2458
- if (!state.launch.available || !woken.length) return;
2469
+ if (!state.launch.available || !room) return;
2459
2470
  // The woken names are part of the key; each new execution may run another model, which only its session log tells, so the latest executions are too.
2460
2471
  const executions = (room.free?.members || []).map(member => member.execution?.id || 0).join(",");
2461
2472
  const revision = `${woken.join(",")}:${executions}`;
@@ -2463,13 +2474,15 @@ async function load_room_models() {
2463
2474
  if (previous?.room_id === room.id && previous.revision === revision
2464
2475
  && (previous.loading || previous.participants)) return;
2465
2476
  // The last answer stays on screen while the new one is read.
2466
- const current = { room_id: room.id, revision, loading: true, participants: previous?.room_id === room.id ? previous.participants : null };
2477
+ const current = { room_id: room.id, revision, loading: true, participants: previous?.room_id === room.id ? previous.participants : null,
2478
+ project_directory: previous?.room_id === room.id ? previous.project_directory : "" };
2467
2479
  state.launch.room_models = current;
2468
2480
  const live = () => state.launch.room_models === current && state.room?.id === room.id;
2469
2481
  // agy is run to name its default model, which takes seconds; everything else is shown first and agy follows.
2470
2482
  try {
2471
2483
  const result = await local_launch_request("room-models", { room_id: room.id, quick: true });
2472
- if (live() && result.room_id === room.id) Object.assign(current, { participants: result.participants, registered: result.registered, scheduler: result.scheduler || null });
2484
+ if (live() && result.room_id === room.id) Object.assign(current, { participants: result.participants, registered: result.registered,
2485
+ scheduler: result.scheduler || null, project_directory: result.project_directory || "" });
2473
2486
  } catch (error) {
2474
2487
  // Unavailable or older bridges leave the model unknown; the failure is kept so the page can say so, and the next refresh retries.
2475
2488
  Object.assign(current, { participants: null, error: error.message || "读取失败" });
@@ -2494,6 +2507,11 @@ function reload_room_models() {
2494
2507
  return load_room_models();
2495
2508
  }
2496
2509
 
2510
+ /** Read a directory or file from the room's saved project through the authenticated local connection. */
2511
+ function read_room_files(room_id, path = "", offset = 0) {
2512
+ return local_launch_request("room-files", { room_id, path, offset, token: localStorage.getItem(`discuss-access:${room_id}`) });
2513
+ }
2514
+
2497
2515
  /**
2498
2516
  * Describe registered launch choices: the card's choice, else what the member's session last ran with, else what the software's settings select;
2499
2517
  * anything unknown is "默认".
@@ -4167,8 +4185,8 @@ window.discuss_view = { snapshot: view_snapshot, write_field: write_view_field,
4167
4185
  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,
4168
4186
  admin: { register: register_admin, login: login_admin, logout: logout_admin,
4169
4187
  update_profile: update_admin_profile, avatar_from_file: admin_avatar_from_file, validate: validate_admin_form },
4170
- request: request_json, rewind_plan, identities: IDENTITIES, display_name, mention_handles, mentioned_members, sources: SOURCE_LABELS,
4171
- set_member_access, set_member_model, allow_denials, set_member_adapter, reload_room_models, limits_label, stop_reason_label, participant_model, participant_files, participant_session_hint, participant_access,
4188
+ request: request_json, rewind_plan, identities: IDENTITIES, display_name, mention_handles, mentioned_members, render_room_markdown, sources: SOURCE_LABELS,
4189
+ 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,
4172
4190
  turn_label, turn_guidance, round_groups, limit_rounds, progress_speaker, brief_meta, max_message_length: MAX_MESSAGE_LENGTH, provenance: message_provenance, format_time };
4173
4191
 
4174
4192
  restore_theme();