ai_co_discuss 0.3.76 → 0.3.78

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.
@@ -35,4 +35,4 @@ description: "AI CoDiscuss 的主持技能,仅主持人调用,且须同时
35
35
 
36
36
  用户的倾向不等于已验证事实:成员仍应独立判断并指出矛盾与风险。意见相同不等于事实已验证,意见不同也不必投票决定。
37
37
 
38
- `room view` 的 `descriptions` 是用户写的各成员擅长与分工,分派时参考。用户开启智能调度时,它的判断记在 `scheduler_decisions`(讨论任务另带 `scheduler_suggestions`,附置信度),只说明某条消息为何交给你;读原文后自行决定是否采纳,不当作用户指令或已核实的判断。
38
+ `room view` 的 `descriptions` 是用户写的各成员擅长与分工,分派时参考。用户开启智能调度时,它的判断随任务的 `scheduler_suggestions` 给出(附置信度;完整输入用 `state decision ROOM decision_id` 读取),只说明某条消息为何交给你;读原文后自行决定是否采纳,不当作用户指令或已核实的判断。
package/backend/server.py CHANGED
@@ -1688,6 +1688,11 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1688
1688
  if content_type == "application/json":
1689
1689
  body = json.dumps(body, ensure_ascii=False)
1690
1690
  data = body.encode("utf-8") if isinstance(body, str) else body
1691
+ # The room list and a room's view carry a hash of their bytes; a page that already holds them gets 304 and no body.
1692
+ if self.command == "GET" and status == 200 and parts[:2] == ["api", "rooms"] and len(parts) in (2, 3):
1693
+ self.etag = f'"{hashlib.sha256(data).hexdigest()[:32]}"'
1694
+ if self.headers.get("If-None-Match") == self.etag:
1695
+ status, data = 304, b""
1691
1696
  if getattr(self, "api_record", None):
1692
1697
  # A logged write answers only after its record is stored, so a client that reads the log next sees it.
1693
1698
  self.deferred_response = (status, content_type, data)
@@ -1700,6 +1705,9 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1700
1705
  self.send_header("Content-Type", f"{content_type}; charset=utf-8")
1701
1706
  self.send_header("Content-Length", str(len(data)))
1702
1707
  self.send_header("Cache-Control", "no-store")
1708
+ if getattr(self, "etag", None):
1709
+ self.send_header("ETag", self.etag)
1710
+ self.etag = None
1703
1711
  self.send_header("Referrer-Policy", "no-referrer")
1704
1712
  if getattr(self, "login_cookie", None) is not None:
1705
1713
  self.send_header("Set-Cookie", f"{ADMIN_COOKIE}={self.login_cookie}; Path=/; HttpOnly; Secure; SameSite=Strict; "
package/docs/workflow.md CHANGED
@@ -24,7 +24,7 @@
24
24
  - **撤回与编辑**:还没有成员开始处理的消息可撤回,原文回到输入框;最新一条消息即使已开始处理也可撤回,相当于停止对它的处理并回退到它之前。输入框为空时连按两下 Esc 也可撤回最近一条。已被处理的消息点“编辑”改写后重发:这条消息及之后的记录折叠为“已回退”,AI 不再读到它们,由本机唤醒的 Claude Code、Codex、agy 会话会退回到这条消息之前;手动接入和 App 的会话在各自窗口中,无法回退。成员已改动的文件不会撤销。讨论进行中不能撤回或编辑。
25
25
  - **分支**:AI 发言的“…”菜单“从这里分支”会新建一个房间,保留到这条发言为止的记录,由本机唤醒的 Claude Code、Codex、agy 从各自会话分叉、从这里接着工作,原房间和原会话不变;其他成员以新会话开始。两个房间共用同一项目目录,文件不分叉。需要连接本机服务。
26
26
 
27
- 普通成员之间的 `@` 只是引用,不会唤醒对方。多位成员共享同一项目目录时不加锁,由你划分文件;两位成员同时改动同一文件时,成员栏会提示可能的冲突。
27
+ 普通成员之间的 `@` 只是引用,不会唤醒对方。多位成员共享同一项目目录时不加锁,由你划分文件;两位成员同时改动同一文件时,网页右下角会提示可能的冲突(只在你开着房间时发生的才弹,之前的冲突看房间里的程序通知)。
28
28
 
29
29
  ## 开启讨论
30
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.3.76",
3
+ "version": "0.3.78",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
@@ -26,7 +26,20 @@ const NO_TOOL_REMINDER = '普通文本不会被保存。请调用写入工具提
26
26
  const TEXT = { type: 'string' };
27
27
  const NUMBER = { type: 'integer', minimum: 1 };
28
28
  const ROOM_FIELDS = ['id', 'title', 'goal', 'host', 'user_request', 'background', 'boundaries', 'experts', 'phase', 'focus',
29
- 'current_turn', 'queued_turns', 'limits', 'completed_rounds', 'latest_id', 'members', 'free', 'named_followup', 'scheduler_decisions'];
29
+ 'current_turn', 'queued_turns', 'limits', 'completed_rounds', 'latest_id', 'members', 'free', 'named_followup', 'descriptions'];
30
+ // The room carries every request and execution so far; the model gets open work and a short recent trail, as `room view` does.
31
+ const RECENT_EXECUTIONS = 10;
32
+ const EXECUTION_FIELDS = ['id', 'member', 'status', 'result', 'answer_id', 'request_message_id', 'changes', 'late_answer'];
33
+ const TERMINAL_STATUSES = ['completed', 'cancelled', 'failed'];
34
+
35
+ function brief_free(free) {
36
+ if (!free) return free;
37
+ const executions = free.executions || [];
38
+ const recent = executions.length - RECENT_EXECUTIONS;
39
+ return { ...free, requests: (free.requests || []).filter(item => !TERMINAL_STATUSES.includes(item.status)),
40
+ executions: executions.filter((item, index) => index >= recent || !TERMINAL_STATUSES.includes(item.status))
41
+ .map(item => Object.fromEntries(EXECUTION_FIELDS.filter(field => field in item).map(field => [field, item[field]]))) };
42
+ }
30
43
 
31
44
  /** Describe a room tool with an explicit input schema. */
32
45
  function tool(name, description, properties = {}, required = []) {
@@ -113,7 +126,8 @@ async function read_room(context) {
113
126
  context.independent = Boolean(request?.purpose === 'discuss' && request.read_until !== null
114
127
  && request.session_id === context.session?.id && context.session?.phase === 'initial');
115
128
  const fields = context.independent ? ROOM_FIELDS : [...ROOM_FIELDS, 'state_text', 'state_until', 'host_brief'];
116
- return Object.fromEntries(fields.filter(field => room[field] !== undefined).map(field => [field, room[field]]));
129
+ return Object.fromEntries(fields.filter(field => room[field] !== undefined)
130
+ .map(field => [field, field === 'free' ? brief_free(room[field]) : room[field]]));
117
131
  }
118
132
 
119
133
  /** Build a business write without letting the model choose identity, execution or credentials. */
package/runner/discuss.py CHANGED
@@ -34,7 +34,13 @@ INSTANCE_PATTERN = re.compile(r"(Codex|Claude|Antigravity|DeepSeek|Qwen|Grok)(?:
34
34
  KINDS = ("opinion", "moderation", "chat")
35
35
  OVERVIEW_FIELDS = ("id", "title", "goal", "host", "phase", "focus", "message_count",
36
36
  "latest_id", "state_until", "current_turn", "queued_turns", "held_since", "limits", "completed_rounds", "answered_turns", "duplicate", "submission_id",
37
- "mode", "members", "free")
37
+ "mode", "members", "free", "descriptions", "experts")
38
+ # Agent tools cut long command output (Claude Code past 30000 characters, Codex CLI past 10000 tokens), so a page stays inside both.
39
+ LIST_OUTPUT_CHARS = 8000
40
+ # The room view keeps live work and a short recent trail; `execution view` reads any one execution in full.
41
+ RECENT_EXECUTIONS = 10
42
+ EXECUTION_FIELDS = ("id", "member", "status", "result", "answer_id", "request_message_id", "changes", "late_answer")
43
+ TERMINAL_STATUSES = ("completed", "cancelled", "failed")
38
44
  GUIDANCE = {
39
45
  "initial": "先基于目标、背景和边界独立形成观点;提交前不读他人观点或主持总结。",
40
46
  "paused": "停止处理安排并停止本讨论自己的后台任务,核对实际状态。",
@@ -218,7 +224,7 @@ def add_leaf(subcommands, registry, name, description, needs_room=False):
218
224
 
219
225
  def room_commands(subcommands, registry):
220
226
  """Register room discovery, progressive reading, creation, and updates."""
221
- for action, description in (("list", "列出房间概要"), ("view", "只读目标、阶段、焦点及当前安排"),
227
+ for action, description in (("list", "列出房间概要"), ("view", "只读目标、阶段、成员与分工、当前安排;执行只列进行中与最近 10 次的要点,完整内容用 execution view"),
222
228
  ("brief", "按需读取背景、边界和专家配置"),
223
229
  ("conclusion", "收束前申请最新收束状态(阻碍项与 revision);主持人收束前必做"),
224
230
  ("protocol", "按需读取当前服务的参与协议")):
@@ -247,7 +253,7 @@ def room_commands(subcommands, registry):
247
253
 
248
254
  def message_commands(subcommands, registry):
249
255
  """Register bounded search, exact reads, and attributed message writes."""
250
- listing = add_leaf(subcommands, registry, "message list", "按页检索原文,每页 20 条", True)
256
+ listing = add_leaf(subcommands, registry, "message list", "检索原文:每次最多 20 条且不超过约 8000 字符,未列完时按 next_after 用 --after 续读", True)
251
257
  paging = listing.add_mutually_exclusive_group()
252
258
  paging.add_argument("--page", type=positive_int, default=1)
253
259
  paging.add_argument("--after", type=non_negative_int, help="只读此消息编号之后的内容;按 next_after 续读")
@@ -628,7 +634,7 @@ def plan_message(args):
628
634
  params = {"page": args.page, "q": args.query, "author": args.author, "mention": args.mention,
629
635
  "after": args.after, "kind": args.kind, "submission_id": args.submission_id,
630
636
  **read_context_params(args)}
631
- return RequestPlan(base, params={key: value for key, value in params.items() if value is not None})
637
+ return RequestPlan(base, params={key: value for key, value in params.items() if value is not None}, view="message_list")
632
638
  actor = require_actor(args)
633
639
  if args.proactive and (args.execution or args.request or args.kind != "chat"):
634
640
  raise CliError("--proactive 只用于 chat 消息,不能与 --execution / --request 同用")
@@ -1031,12 +1037,44 @@ def project_result(value, plan, site):
1031
1037
  "state": ("id", "state_text", "state_until", "host_brief", "answered_turns")}[plan.view]
1032
1038
  result = select_fields(value, fields)
1033
1039
  if plan.view == "overview":
1040
+ if isinstance(result.get("free"), dict):
1041
+ result["free"] = overview_free(result["free"])
1034
1042
  result["guidance"] = GUIDANCE.get(value["phase"], "按本次主持或专家职责,结合最新阶段、安排和必要原文决定下一步。")
1035
1043
  result["url"] = f"{site}/r/{value['id']}"
1036
1044
  return result
1045
+ if plan.view == "message_list":
1046
+ return fit_message_page(value)
1037
1047
  return value
1038
1048
 
1039
1049
 
1050
+ def overview_free(free):
1051
+ """Keep open requests and live or recent executions in brief; finished history stays behind execution view."""
1052
+ result = dict(free)
1053
+ if isinstance(free.get("requests"), list):
1054
+ result["requests"] = [item for item in free["requests"] if item.get("status") not in TERMINAL_STATUSES]
1055
+ executions = free.get("executions")
1056
+ if isinstance(executions, list):
1057
+ recent = len(executions) - RECENT_EXECUTIONS
1058
+ result["executions"] = [select_fields(item, EXECUTION_FIELDS) for index, item in enumerate(executions)
1059
+ if index >= recent or item.get("status") not in TERMINAL_STATUSES]
1060
+ return result
1061
+
1062
+
1063
+ def fit_message_page(page):
1064
+ """Stop a page at the output budget so no agent tool cuts it silently; the cursor resumes after the last shown."""
1065
+ messages, used = [], 0
1066
+ for message in page.get("messages", []):
1067
+ used += len(json.dumps(message, ensure_ascii=False, indent=2))
1068
+ if messages and used > LIST_OUTPUT_CHARS:
1069
+ break
1070
+ messages.append(message)
1071
+ if len(messages) == len(page.get("messages", [])):
1072
+ return page
1073
+ last = messages[-1]["id"]
1074
+ return {**page, "messages": messages, "next_after": last, "has_more": True,
1075
+ "notice": f"本次输出到上限,只列了 {len(messages)} 条;用 --after {last} 加同样的筛选续读"}
1076
+
1077
+
1040
1078
  def command_schema(args, registry):
1041
1079
  """Describe available commands and fields from the live argparse registry."""
1042
1080
  name = " ".join(args.command)
@@ -74,7 +74,7 @@ async function seal_compressed(key, value, label) {
74
74
  * of the local website itself are reachable; the device cannot name another address. Binary content comes back as base64.
75
75
  */
76
76
  export async function room_api(home, site, payload, { fetcher = fetch, device } = {}) {
77
- const { method = 'GET', path, body, token, offset = 0 } = payload || {};
77
+ const { method = 'GET', path, body, token, offset = 0, etag } = payload || {};
78
78
  if (!['GET', 'POST'].includes(method) || typeof path !== 'string' || !ROOM_API_PATH.test(path) || path.includes('..')
79
79
  || !Number.isSafeInteger(offset) || offset < 0) {
80
80
  return { status: 400, body: { error: '中转请求地址无效' } };
@@ -82,10 +82,14 @@ export async function room_api(home, site, payload, { fetcher = fetch, device }
82
82
  const headers = bridge_headers(home, site);
83
83
  if (typeof token === 'string' && token) headers['X-Discussion-Token'] = token;
84
84
  if (body !== undefined) headers['Content-Type'] = 'application/json';
85
+ // A read the device already holds is asked conditionally; unchanged, the answer is a bare 304 with no body.
86
+ if (method === 'GET' && typeof etag === 'string' && /^"[0-9a-f]{1,64}"$/.test(etag)) headers['If-None-Match'] = etag;
85
87
  const response = await fetcher(`${local_origin(home)}${path}`, { method, headers, redirect: 'error',
86
88
  body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(LOCAL_TIMEOUT_MS) });
89
+ const tag = response.headers.get('ETag');
90
+ if (response.status === 304) return { status: 304, etag: tag || etag };
87
91
  const type = response.headers.get('Content-Type') || '';
88
- if (type.startsWith('application/json')) return { status: response.status, body: await response.json() };
92
+ if (type.startsWith('application/json')) return { status: response.status, body: await response.json(), ...(tag ? { etag: tag } : {}) };
89
93
  // A file larger than one frame comes back in parts; the device asks again from where the previous part ended.
90
94
  const bytes = Buffer.from(await response.arrayBuffer());
91
95
  return { status: response.status, content_type: type, offset, total: bytes.length,
package/web/app.js CHANGED
@@ -10,7 +10,6 @@ const WORKING_STATUSES = ["dispatched", "started"];
10
10
  const UPDATE_BATCH_MS = 100;
11
11
  const UPDATE_DRAG_RETRY_MS = 500;
12
12
  const TOAST_DURATION_MS = 3500;
13
- const SCROLL_BOTTOM_THRESHOLD = 70;
14
13
  const SUBMISSION_ID_BYTES = 16;
15
14
  const MAX_MESSAGE_LENGTH = 32000;
16
15
  const MAX_SUMMARY_LENGTH = 1000;
@@ -68,6 +67,9 @@ let computer_connecting = false;
68
67
  let relay_machine = null;
69
68
  // Object URLs of files read from the computer's rooms, by their room route.
70
69
  const relay_files = new Map();
70
+ // The room list and room views last read, with their ETags, by route; an unchanged read is answered 304 and reuses them.
71
+ const ROOM_READ_PATH = /^\/api\/rooms(\/[^/?]+)?$/;
72
+ const room_reads = new Map();
71
73
  const DEFAULT_USER_LABEL = "User";
72
74
  const DEFAULT_USER_ICON = "/icons/user.svg";
73
75
  // How a manual member learns it is its turn: it is pulled by the member.
@@ -228,14 +230,24 @@ try { localStorage.removeItem(ADMIN_SESSION_KEY); } catch { /* Nothing stored. *
228
230
 
229
231
  /** Request JSON from the local or remotely hosted discussion service. */
230
232
  async function request_json(path, payload, access_token) {
233
+ // The room list and a room are read conditionally: unchanged, the copy already held is reused, the same object.
234
+ const conditional = payload === undefined && ROOM_READ_PATH.test(path);
235
+ const cached = conditional ? room_reads.get(path) : undefined;
236
+ const remember = (status, body, etag) => {
237
+ if (status === 304 && cached) return cached.body;
238
+ if (conditional) etag ? cache_room_entry(room_reads, path, { etag, body }) : room_reads.delete(path);
239
+ return body;
240
+ };
231
241
  if (computer_rooms_only() && ROOM_RELAY_PATH.test(path)) {
232
242
  if (!state.remote_rooms) throw Object.assign(new Error(COMPUTER_UNREACHABLE), { preflight: true });
233
- return (await relay_room_request(path, payload)).body;
243
+ const answer = await relay_room_request(path, payload, cached?.etag);
244
+ return remember(answer.status, answer.body, answer.etag);
234
245
  }
235
246
  const options = { cache: "no-store" };
236
247
  const target_room = path.match(/^\/api\/rooms\/([^/?]+)/)?.[1];
237
248
  const token = access_token || (target_room ? localStorage.getItem(`discuss-access:${target_room}`) : null);
238
249
  options.headers = token ? { "X-Discussion-Token": token } : {};
250
+ if (cached) options.headers["If-None-Match"] = cached.etag;
239
251
  // The online website has no accounts: a paired device signs its requests with its own non-exportable key.
240
252
  if ((state.admin.devices || paired_record()) && /^\/api\/(machines|admin\/profile|admin$)/.test(path)) {
241
253
  const credential = await device_credential(payload === undefined ? "GET" : "POST", path.split("?")[0]);
@@ -248,9 +260,10 @@ async function request_json(path, payload, access_token) {
248
260
  }
249
261
  if (/^\/api\/(machines|admin)/.test(path)) options.signal = AbortSignal.timeout((Number.isInteger(payload?.wait) ? payload.wait : RELAY_DEFAULT_WAIT_MS) + MACHINE_REQUEST_MARGIN_MS);
250
262
  const response = await fetch(path, options);
263
+ if (response.status === 304 && cached) return cached.body;
251
264
  const result = await response.json();
252
265
  if (!response.ok) throw Object.assign(new Error(result.error || "请求失败,请重试"), { preflight: response.status >= 400 && response.status < 500, status: response.status });
253
- return result;
266
+ return conditional ? remember(response.status, result, response.headers.get("ETag")) : result;
254
267
  }
255
268
 
256
269
  /** The owner's public name and avatar replace the default "User" identity for every visitor. */
@@ -876,42 +889,31 @@ function message_provenance(message) {
876
889
  return `${role}${forwarding}`;
877
890
  }
878
891
 
879
- /** Build compact chat lines with colored names, reply references, and provenance. */
880
- function message_markup(message) {
881
- const identity = IDENTITIES[message.author] || IDENTITIES["用户"];
882
- const body = message.kind === "receipt" ? `<details class="message-receipt" data-receipt-id="${message.id}"><summary aria-label="查看运行回执 #${message.id}">运行回执详情</summary>${message.rendered_text}</details>` : message.summary ? `<div class="answer-summary"><small>回答摘要 · ${escape_html(display_name(message.summary_by))}</small>${message.rendered_summary}</div><details class="message-answer" data-answer-id="${message.id}"><summary aria-label="展开完整回答 #${message.id}">完整回答</summary>${message.rendered_text}</details>` : message.rendered_text;
883
- const text = body + attachment_markup(message.attachments);
884
- const reply = message.reply_to ? `<button class="quote-link" data-detail="${message.reply_to}">↳ 回复 #${message.reply_to} · 查看原文</button>` : "";
885
- return `<article class="message ${identity.style}" id="message-${message.id}"><span class="avatar message-avatar" aria-hidden="true">${identity_avatar(identity)}</span><div class="message-line"><time title="${escape_html(message.created_at)}">[${format_time(message.created_at)}]</time><strong class="message-author">${escape_html(display_name(message.author))}</strong><span class="message-verb"> 说:</span><div class="message-text markdown-body">${text}</div></div><div class="message-details"><span class="source-badge ${message.source === "demo" ? "" : "real"}">${SOURCE_LABELS[message.source] || "发言"}</span><span>${escape_html(message_provenance(message))}</span><button class="message-id" data-detail="${message.id}">#${message.id}</button>${reply}<span class="message-actions"><button data-reply="${message.id}" data-author="${escape_html(message.author)}">回复</button><button data-copy-message="${message.id}">复制原文链接</button></span></div></article>`;
886
- }
887
-
888
892
  /** Render continuous history while retaining expanded answers and reading position. */
893
+ // Rendered copies by the cached record they came from, so leaving a filter reuses them instead of rendering every
894
+ // message's Markdown again.
895
+ const rendered_messages = new WeakMap();
889
896
  function render_messages(result) {
890
897
  const previous = new Map(state.messages.map((message) => [message.id, message]));
891
898
  // Cached history is read once; a later retract or rewind reaches it through the room's withdrawal list.
892
899
  const withdrawn = new Map((state.room?.withdrawals || []).map((item) => [item.message_id, item.kind]));
893
- state.messages = result.messages.map((message) => {
894
- const saved = previous.get(message.id);
900
+ const messages = result.messages.map((message) => {
901
+ const saved = previous.get(message.id) || rendered_messages.get(message);
895
902
  const kind = withdrawn.get(message.id) || message.withdrawn || "";
903
+ let shown;
896
904
  // A message shown straight from its send response is replaced by the copy the transcript reads.
897
- if (saved && !saved.just_sent) return (saved.withdrawn || "") === kind ? saved : { ...saved, withdrawn: kind };
898
- return { ...message, withdrawn: kind, rendered_text: render_room_markdown(message.text),
905
+ if (saved && !saved.just_sent) shown = (saved.withdrawn || "") === kind ? saved : { ...saved, withdrawn: kind };
906
+ else shown = { ...message, withdrawn: kind, rendered_text: render_room_markdown(message.text),
899
907
  rendered_summary: render_room_markdown(message.summary || "") };
908
+ rendered_messages.set(message, shown);
909
+ return shown;
900
910
  });
911
+ // An unchanged transcript keeps its array, so views that depend on it are not recomputed.
912
+ if (messages.length !== state.messages.length || messages.some((message, index) => message !== state.messages[index])) state.messages = messages;
901
913
  state.message_total = result.total;
902
914
  state.messages_loading = false;
903
- const container = element("messages");
904
- const open_receipts = [...container.querySelectorAll(".message-receipt[open]")].map((receipt) => receipt.dataset.receiptId);
905
- const open_answers = [...container.querySelectorAll(".message-answer[open]")].map((answer) => answer.dataset.answerId);
906
- const scroll_position = container.scrollTop;
907
- const is_near_bottom = container.scrollHeight - container.clientHeight - scroll_position < SCROLL_BOTTOM_THRESHOLD;
915
+ // The Vue transcript renders from state.messages; the hidden controller markup is not rebuilt on every refresh.
908
916
  state.pages = result.pages;
909
- container.innerHTML = state.messages.length ? state.messages.map(message_markup).join("") :
910
- `<div class="empty-messages"><strong>${state.query || state.mention_only || state.host_only ? "没有找到匹配的发言" : "暂无讨论记录"}</strong><p>${state.query || state.mention_only || state.host_only ? "换个关键词,或清除筛选后查看完整记录。" : empty_discussion_message(state.room)}</p></div>`;
911
- for (const receipt of container.querySelectorAll(".message-receipt")) receipt.open = open_receipts.includes(receipt.dataset.receiptId);
912
- for (const answer of container.querySelectorAll(".message-answer")) answer.open = open_answers.includes(answer.dataset.answerId);
913
- container.scrollTop = is_near_bottom ? container.scrollHeight : scroll_position;
914
- element("record-caption").textContent = state.query || state.mention_only || state.host_only ? `筛选结果 · ${result.total} 条` : "完整记录 · 按时间排列";
915
917
  }
916
918
 
917
919
  /** Join bounded cursor batches for the interactive transcript; refresh only new records. */
@@ -2183,14 +2185,21 @@ async function local_launch_request(action, payload) {
2183
2185
  return result;
2184
2186
  }
2185
2187
 
2188
+ // Relayed answers and files are converted in plain loops: a per-character callback takes tens of milliseconds a megabyte.
2186
2189
  function bytes_to_base64(bytes) {
2190
+ // Characters converted per String.fromCharCode call, below engines' argument limits.
2191
+ const chunk = 0x8000;
2192
+ const view = new Uint8Array(bytes);
2187
2193
  let text = "";
2188
- for (const byte of new Uint8Array(bytes)) text += String.fromCharCode(byte);
2194
+ for (let index = 0; index < view.length; index += chunk) text += String.fromCharCode.apply(null, view.subarray(index, index + chunk));
2189
2195
  return btoa(text);
2190
2196
  }
2191
2197
 
2192
2198
  function base64_to_bytes(text) {
2193
- return Uint8Array.from(atob(text), character => character.charCodeAt(0));
2199
+ const binary = atob(text);
2200
+ const bytes = new Uint8Array(binary.length);
2201
+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
2202
+ return bytes;
2194
2203
  }
2195
2204
 
2196
2205
  /** The AES-GCM key shared with the paired computer for one command; mirrors relay_cipher in runner/remote_launch.mjs. */
@@ -2321,6 +2330,8 @@ async function pair_this_device(code) {
2321
2330
  // WebAuthn must run on the user's tap, so everything above is ready before the dialog asks for it.
2322
2331
  return passkey_prompt({ title: "配对这台设备", description: "用这台设备的面容、指纹或设备密码确认,完成与电脑的配对。之后打开网页即可使用,无需账号和密码。", confirm_label: "继续",
2323
2332
  run: async () => {
2333
+ // A refused passkey still asks the computer, which accepts it only when this code was made to allow no passkey.
2334
+ let refused = null;
2324
2335
  const created = await (typeof navigator.credentials?.create === "function" ? navigator.credentials.create({ publicKey: { challenge, rp: { id: location.hostname, name: "AI CoDiscuss" },
2325
2336
  user: { id: crypto.getRandomValues(new Uint8Array(16)), name: `codiscuss · ${device_label()}`, displayName: `AI CoDiscuss · ${device_label()}` },
2326
2337
  pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }],
@@ -2329,11 +2340,14 @@ async function pair_this_device(code) {
2329
2340
  .catch(error => {
2330
2341
  // A browser without a working credential manager (e.g. Android without Google Play services) pairs by its device key alone.
2331
2342
  if (PASSKEY_UNAVAILABLE.has(error?.name)) return null;
2332
- throw passkey_error(error);
2343
+ if (error?.name !== "NotAllowedError") throw passkey_error(error);
2344
+ refused = error;
2345
+ return null;
2333
2346
  });
2334
2347
  const passkey = created && { id: bytes_to_b64url(created.rawId), client_data_json: bytes_to_b64url(created.response.clientDataJSON),
2335
2348
  attestation_object: bytes_to_b64url(created.response.attestationObject) };
2336
2349
  const answer = await pair_exchange(machine, { public_key: device.public_jwk, label: device_label(), proof, passkey });
2350
+ if (answer.status >= 400 && refused && answer.body?.need === "passkey") throw passkey_error(refused);
2337
2351
  if (answer.status >= 400) throw new Error(answer.body?.error || "配对失败");
2338
2352
  localStorage.setItem(PAIRED_KEY, JSON.stringify({ machine_id: machine.id, fingerprint }));
2339
2353
  Object.assign(state.device, { needs: "", pairing_code: "", pairing_fp: "" });
@@ -2552,19 +2566,19 @@ async function relay_command_once(action, payload) {
2552
2566
  }
2553
2567
 
2554
2568
  /** Read or write one room route on the paired computer's local website, as its owner, and answer as request_json would. */
2555
- async function relay_room_request(path, payload) {
2556
- try { return await relay_room_request_once(path, payload); }
2569
+ async function relay_room_request(path, payload, etag) {
2570
+ try { return await relay_room_request_once(path, payload, etag); }
2557
2571
  catch (error) {
2558
- if (await meet_device_need(error)) return relay_room_request_once(path, payload);
2572
+ if (await meet_device_need(error)) return relay_room_request_once(path, payload, etag);
2559
2573
  throw error;
2560
2574
  }
2561
2575
  }
2562
2576
 
2563
- /** Read or write one room route on the paired computer once. */
2564
- async function relay_room_request_once(path, payload) {
2577
+ /** Read or write one room route on the paired computer once; a read with `etag` may answer 304 without a body. */
2578
+ async function relay_room_request_once(path, payload, etag) {
2565
2579
  let answer;
2566
2580
  for (let attempt = 0; ; attempt += 1) {
2567
- try { answer = await relay_exchange("room-api", { method: payload === undefined ? "GET" : "POST", path, body: payload }); break; }
2581
+ try { answer = await relay_exchange("room-api", { method: payload === undefined ? "GET" : "POST", path, body: payload, ...(etag ? { etag } : {}) }); break; }
2568
2582
  catch (error) {
2569
2583
  // Only reads are asked again: a write whose outcome is unconfirmed must not be sent twice.
2570
2584
  const transient = error.unconfirmed || error.offline || error instanceof TypeError;
@@ -2635,9 +2649,16 @@ function relay_file_url(path) {
2635
2649
  }
2636
2650
 
2637
2651
  /** Show images of the computer's rooms as they appear; one that cannot be read keeps its name. */
2638
- function show_relay_images() {
2639
- for (const image of document.querySelectorAll("img[data-relay-src]:not([src]):not([data-relay-failed])")) {
2640
- relay_file_url(image.dataset.relaySrc).then(url => { image.src = url; }).catch(() => { image.dataset.relayFailed = "1"; });
2652
+ // Only the added nodes are searched: a whole-document query on every change walks the entire transcript.
2653
+ function show_relay_images(records) {
2654
+ const selector = "img[data-relay-src]:not([src]):not([data-relay-failed])";
2655
+ for (const record of records) {
2656
+ for (const node of record.addedNodes) {
2657
+ if (node.nodeType !== Node.ELEMENT_NODE || !node.isConnected) continue;
2658
+ for (const image of node.matches(selector) ? [node] : node.querySelectorAll(selector)) {
2659
+ relay_file_url(image.dataset.relaySrc).then(url => { image.src = url; }).catch(() => { image.dataset.relayFailed = "1"; });
2660
+ }
2661
+ }
2641
2662
  }
2642
2663
  }
2643
2664
 
@@ -4652,7 +4673,7 @@ async function initialize() {
4652
4673
  }
4653
4674
  const launch_settings = load_launch_settings();
4654
4675
  document.addEventListener("click", download_relay_file, true);
4655
- new MutationObserver(() => { if (state.remote_rooms) show_relay_images(); }).observe(document.body, { childList: true, subtree: true });
4676
+ new MutationObserver((records) => { if (state.remote_rooms) show_relay_images(records); }).observe(document.body, { childList: true, subtree: true });
4656
4677
  window.addEventListener("hashchange", () => {
4657
4678
  if (new URLSearchParams(location.hash.slice(1)).has("codiscuss-connect")) load_launch_settings();
4658
4679
  });
@@ -4697,7 +4718,9 @@ function publish_view() {
4697
4718
 
4698
4719
  /** Read current form drafts without transferring ownership to either renderer. */
4699
4720
  function view_snapshot() {
4700
- const fields = Object.fromEntries(Array.from(document.querySelectorAll("#controller-fields input[id], #controller-fields textarea[id], #controller-fields select[id], body > dialog input[id], body > dialog textarea[id], body > dialog select[id]"))
4721
+ // Searched inside their containers: a whole-document selector walks every transcript node on each publish.
4722
+ const containers = [element("controller-fields"), ...Array.from(document.body.children).filter((node) => node.localName === "dialog")];
4723
+ const fields = Object.fromEntries(containers.flatMap((container) => container ? Array.from(container.querySelectorAll("input[id], textarea[id], select[id]")) : [])
4701
4724
  .map((control) => [control.id, control.value]));
4702
4725
  const experts = Object.fromEntries(["new-expert-modes"].map((id) =>
4703
4726
  [id, element(id) ? read_expert_inputs(id) : {}]));
@@ -4717,8 +4740,8 @@ function view_snapshot() {
4717
4740
  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_host_workflows: state.create_host_workflows, create_api_max_requests: state.create_api_max_requests };
4718
4741
  }
4719
4742
 
4720
- /** Update a shared draft using the same input events as the original controls. */
4721
- function write_view_field(id, value) {
4743
+ /** Update a shared draft using the same input events as the original controls; `publish: false` leaves the views to keep their own copy. */
4744
+ function write_view_field(id, value, { publish = true } = {}) {
4722
4745
  const control = element(id);
4723
4746
  if (!control || !control.matches("input, textarea, select")) return;
4724
4747
  state.navigation_version++;
@@ -4732,7 +4755,7 @@ function write_view_field(id, value) {
4732
4755
  if (["new-room-limit-max_rounds", "new-room-limit-max_minutes"].includes(id)) {
4733
4756
  write_create_preferences({ [id.slice("new-room-limit-".length)]: Number(control.value) });
4734
4757
  }
4735
- publish_view();
4758
+ if (publish) publish_view();
4736
4759
  }
4737
4760
 
4738
4761
  /** A participation select for one AI name; instances beyond the platforms get their own control. */
@@ -4781,6 +4804,8 @@ async function filter_view({ query = state.query, mention = state.mention_only,
4781
4804
  state.page = page;
4782
4805
  element("search-input").value = state.query;
4783
4806
  element("mention-filter").setAttribute("aria-pressed", String(mention));
4807
+ // The filter control changes at once; the filtered transcript follows when the site answers.
4808
+ publish_view();
4784
4809
  await refresh_room();
4785
4810
  }
4786
4811