ai_co_discuss 0.3.75 → 0.3.77
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/discuss-host/SKILL.md +1 -1
- package/backend/server.py +8 -0
- package/docs/workflow.md +1 -1
- package/package.json +1 -1
- package/runner/adapters/api.mjs +16 -2
- package/runner/discuss.py +42 -4
- package/runner/relay_channel.mjs +6 -2
- package/web/app.js +63 -43
- package/web/modern.css +1 -1
- package/web/modern.js +59 -73397
|
@@ -35,4 +35,4 @@ description: "AI CoDiscuss 的主持技能,仅主持人调用,且须同时
|
|
|
35
35
|
|
|
36
36
|
用户的倾向不等于已验证事实:成员仍应独立判断并指出矛盾与风险。意见相同不等于事实已验证,意见不同也不必投票决定。
|
|
37
37
|
|
|
38
|
-
`room view` 的 `descriptions`
|
|
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
package/runner/adapters/api.mjs
CHANGED
|
@@ -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', '
|
|
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)
|
|
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", "
|
|
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)
|
package/runner/relay_channel.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
898
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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. */
|
|
@@ -2552,19 +2561,19 @@ async function relay_command_once(action, payload) {
|
|
|
2552
2561
|
}
|
|
2553
2562
|
|
|
2554
2563
|
/** 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); }
|
|
2564
|
+
async function relay_room_request(path, payload, etag) {
|
|
2565
|
+
try { return await relay_room_request_once(path, payload, etag); }
|
|
2557
2566
|
catch (error) {
|
|
2558
|
-
if (await meet_device_need(error)) return relay_room_request_once(path, payload);
|
|
2567
|
+
if (await meet_device_need(error)) return relay_room_request_once(path, payload, etag);
|
|
2559
2568
|
throw error;
|
|
2560
2569
|
}
|
|
2561
2570
|
}
|
|
2562
2571
|
|
|
2563
|
-
/** Read or write one room route on the paired computer once. */
|
|
2564
|
-
async function relay_room_request_once(path, payload) {
|
|
2572
|
+
/** Read or write one room route on the paired computer once; a read with `etag` may answer 304 without a body. */
|
|
2573
|
+
async function relay_room_request_once(path, payload, etag) {
|
|
2565
2574
|
let answer;
|
|
2566
2575
|
for (let attempt = 0; ; attempt += 1) {
|
|
2567
|
-
try { answer = await relay_exchange("room-api", { method: payload === undefined ? "GET" : "POST", path, body: payload }); break; }
|
|
2576
|
+
try { answer = await relay_exchange("room-api", { method: payload === undefined ? "GET" : "POST", path, body: payload, ...(etag ? { etag } : {}) }); break; }
|
|
2568
2577
|
catch (error) {
|
|
2569
2578
|
// Only reads are asked again: a write whose outcome is unconfirmed must not be sent twice.
|
|
2570
2579
|
const transient = error.unconfirmed || error.offline || error instanceof TypeError;
|
|
@@ -2635,9 +2644,16 @@ function relay_file_url(path) {
|
|
|
2635
2644
|
}
|
|
2636
2645
|
|
|
2637
2646
|
/** Show images of the computer's rooms as they appear; one that cannot be read keeps its name. */
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2647
|
+
// Only the added nodes are searched: a whole-document query on every change walks the entire transcript.
|
|
2648
|
+
function show_relay_images(records) {
|
|
2649
|
+
const selector = "img[data-relay-src]:not([src]):not([data-relay-failed])";
|
|
2650
|
+
for (const record of records) {
|
|
2651
|
+
for (const node of record.addedNodes) {
|
|
2652
|
+
if (node.nodeType !== Node.ELEMENT_NODE || !node.isConnected) continue;
|
|
2653
|
+
for (const image of node.matches(selector) ? [node] : node.querySelectorAll(selector)) {
|
|
2654
|
+
relay_file_url(image.dataset.relaySrc).then(url => { image.src = url; }).catch(() => { image.dataset.relayFailed = "1"; });
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2641
2657
|
}
|
|
2642
2658
|
}
|
|
2643
2659
|
|
|
@@ -4652,7 +4668,7 @@ async function initialize() {
|
|
|
4652
4668
|
}
|
|
4653
4669
|
const launch_settings = load_launch_settings();
|
|
4654
4670
|
document.addEventListener("click", download_relay_file, true);
|
|
4655
|
-
new MutationObserver(() => { if (state.remote_rooms) show_relay_images(); }).observe(document.body, { childList: true, subtree: true });
|
|
4671
|
+
new MutationObserver((records) => { if (state.remote_rooms) show_relay_images(records); }).observe(document.body, { childList: true, subtree: true });
|
|
4656
4672
|
window.addEventListener("hashchange", () => {
|
|
4657
4673
|
if (new URLSearchParams(location.hash.slice(1)).has("codiscuss-connect")) load_launch_settings();
|
|
4658
4674
|
});
|
|
@@ -4697,7 +4713,9 @@ function publish_view() {
|
|
|
4697
4713
|
|
|
4698
4714
|
/** Read current form drafts without transferring ownership to either renderer. */
|
|
4699
4715
|
function view_snapshot() {
|
|
4700
|
-
|
|
4716
|
+
// Searched inside their containers: a whole-document selector walks every transcript node on each publish.
|
|
4717
|
+
const containers = [element("controller-fields"), ...Array.from(document.body.children).filter((node) => node.localName === "dialog")];
|
|
4718
|
+
const fields = Object.fromEntries(containers.flatMap((container) => container ? Array.from(container.querySelectorAll("input[id], textarea[id], select[id]")) : [])
|
|
4701
4719
|
.map((control) => [control.id, control.value]));
|
|
4702
4720
|
const experts = Object.fromEntries(["new-expert-modes"].map((id) =>
|
|
4703
4721
|
[id, element(id) ? read_expert_inputs(id) : {}]));
|
|
@@ -4717,8 +4735,8 @@ function view_snapshot() {
|
|
|
4717
4735
|
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
4736
|
}
|
|
4719
4737
|
|
|
4720
|
-
/** Update a shared draft using the same input events as the original controls. */
|
|
4721
|
-
function write_view_field(id, value) {
|
|
4738
|
+
/** Update a shared draft using the same input events as the original controls; `publish: false` leaves the views to keep their own copy. */
|
|
4739
|
+
function write_view_field(id, value, { publish = true } = {}) {
|
|
4722
4740
|
const control = element(id);
|
|
4723
4741
|
if (!control || !control.matches("input, textarea, select")) return;
|
|
4724
4742
|
state.navigation_version++;
|
|
@@ -4732,7 +4750,7 @@ function write_view_field(id, value) {
|
|
|
4732
4750
|
if (["new-room-limit-max_rounds", "new-room-limit-max_minutes"].includes(id)) {
|
|
4733
4751
|
write_create_preferences({ [id.slice("new-room-limit-".length)]: Number(control.value) });
|
|
4734
4752
|
}
|
|
4735
|
-
publish_view();
|
|
4753
|
+
if (publish) publish_view();
|
|
4736
4754
|
}
|
|
4737
4755
|
|
|
4738
4756
|
/** A participation select for one AI name; instances beyond the platforms get their own control. */
|
|
@@ -4781,6 +4799,8 @@ async function filter_view({ query = state.query, mention = state.mention_only,
|
|
|
4781
4799
|
state.page = page;
|
|
4782
4800
|
element("search-input").value = state.query;
|
|
4783
4801
|
element("mention-filter").setAttribute("aria-pressed", String(mention));
|
|
4802
|
+
// The filter control changes at once; the filtered transcript follows when the site answers.
|
|
4803
|
+
publish_view();
|
|
4784
4804
|
await refresh_room();
|
|
4785
4805
|
}
|
|
4786
4806
|
|