ai_co_discuss 0.2.71 → 0.2.73

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.
@@ -0,0 +1,175 @@
1
+ """Room launches relayed through the website to a paired computer running the local service.
2
+
3
+ A device without the local service (a phone, a tablet) submits the same launch request the
4
+ connected browser would send to the local bridge; the paired computer claims it on its next
5
+ sync, resolves the chosen project to its own directory and runs it. The Worker backend
6
+ implements the same contract in ``worker.mjs``; both operate on ``local_machines`` and
7
+ ``remote_launches``.
8
+ """
9
+
10
+ import hashlib
11
+ import hmac
12
+ import json
13
+ import re
14
+
15
+ from .admin import AdminError
16
+ from .runtime import begin_write
17
+
18
+ MACHINE_ONLINE_SECONDS = 30
19
+ MACHINE_NAME_MAX = 60
20
+ PROJECT_NAME_MAX = 80
21
+ PROJECTS_MAX = 100
22
+ CLIENTS_MAX = 40
23
+ CLIENT_FIELD_MAX = 40
24
+ ERROR_MAX = 2000
25
+ MACHINE_ID = re.compile(r"[0-9a-f]{32}")
26
+ MACHINE_TOKEN = re.compile(r"[0-9a-f]{64}")
27
+ LAUNCH_ID = re.compile(r"[A-Za-z0-9_-]{1,80}")
28
+ PROJECT_ID = re.compile(r"[0-9a-f]{8,32}")
29
+ LAUNCH_STAGES = ("not_started", "unconfirmed", "created", "started")
30
+
31
+
32
+ def token_digest(token):
33
+ """Hash a machine token; malformed values hash to an empty string and never match."""
34
+ return hashlib.sha256(token.encode()).hexdigest() if isinstance(token, str) and MACHINE_TOKEN.fullmatch(token) else ""
35
+
36
+
37
+ def machine_view(row, now):
38
+ """Public machine fields: projects by name and id, and which platforms it can wake."""
39
+ return {"id": row["id"], "name": row["name"], "projects": json.loads(row["projects"]), "clients": json.loads(row["clients"]),
40
+ "seen_at": row["seen_at"], "online": now - row["seen_at"] <= MACHINE_ONLINE_SECONDS}
41
+
42
+
43
+ def launch_view(row):
44
+ """Report a relayed launch without its request, which carries the room's credentials."""
45
+ return {"id": row["id"], "machine_id": row["machine_id"], "status": row["status"],
46
+ "result": json.loads(row["result"]), "error": row["error"]}
47
+
48
+
49
+ def list_machines(connection, now):
50
+ """Paired computers, most recently seen first."""
51
+ rows = connection.execute("SELECT * FROM local_machines ORDER BY seen_at DESC, created_at DESC").fetchall()
52
+ return {"machines": [machine_view(row, now) for row in rows]}
53
+
54
+
55
+ def pair_machine(connection, payload, now):
56
+ """Pair a computer or replace its token; called by the administrator's browser connected to that computer."""
57
+ machine_id, token = payload.get("id"), payload.get("token")
58
+ name = payload.get("name").strip() if isinstance(payload.get("name"), str) else ""
59
+ if not isinstance(machine_id, str) or not MACHINE_ID.fullmatch(machine_id) or not token_digest(token):
60
+ raise ValueError("电脑编号或凭据无效")
61
+ if not 1 <= len(name) <= MACHINE_NAME_MAX:
62
+ raise ValueError(f"电脑名称需为 1–{MACHINE_NAME_MAX} 个字符")
63
+ begin_write(connection)
64
+ connection.execute("INSERT INTO local_machines (id,name,token_hash,created_at) VALUES (?,?,?,?) "
65
+ "ON CONFLICT(id) DO UPDATE SET name=excluded.name, token_hash=excluded.token_hash",
66
+ (machine_id, name, token_digest(token), now))
67
+ return machine_view(connection.execute("SELECT * FROM local_machines WHERE id=?", (machine_id,)).fetchone(), now)
68
+
69
+
70
+ def require_machine(connection, machine_id, token):
71
+ """Authenticate the computer itself by its own token."""
72
+ row = connection.execute("SELECT * FROM local_machines WHERE id=?", (machine_id,)).fetchone()
73
+ hashed = token_digest(token)
74
+ if not row or not hashed or not hmac.compare_digest(row["token_hash"], hashed):
75
+ raise AdminError("电脑凭据无效,请在这台电脑上重新连接网站", 401)
76
+ return row
77
+
78
+
79
+ def valid_projects(value):
80
+ """Project names and ids only; directories never leave the computer."""
81
+ if not isinstance(value, list) or len(value) > PROJECTS_MAX:
82
+ raise ValueError(f"项目列表须为不超过 {PROJECTS_MAX} 项的列表")
83
+ projects = []
84
+ for item in value:
85
+ project_id, name = (item.get("id"), item.get("name")) if isinstance(item, dict) else (None, None)
86
+ if not isinstance(project_id, str) or not PROJECT_ID.fullmatch(project_id) or not isinstance(name, str) or not 1 <= len(name) <= PROJECT_NAME_MAX:
87
+ raise ValueError("项目须包含有效编号与名称")
88
+ projects.append({"id": project_id, "name": name})
89
+ return projects
90
+
91
+
92
+ def valid_clients(value):
93
+ """Map each platform the computer can wake to its configured client type."""
94
+ if not isinstance(value, dict) or len(value) > CLIENTS_MAX or any(
95
+ not isinstance(key, str) or not isinstance(item, str) or not 0 < len(key) <= CLIENT_FIELD_MAX or not 0 < len(item) <= CLIENT_FIELD_MAX
96
+ for key, item in value.items()):
97
+ raise ValueError("软件配置须为平台到客户端类型的对象")
98
+ return value
99
+
100
+
101
+ def machine_sync(connection, machine_id, token, payload, now):
102
+ """Record the computer's projects and clients, and hand it every launch it has not finished."""
103
+ require_machine(connection, machine_id, token)
104
+ projects, clients = valid_projects(payload.get("projects")), valid_clients(payload.get("clients"))
105
+ begin_write(connection)
106
+ connection.execute("UPDATE local_machines SET projects=?, clients=?, seen_at=? WHERE id=?",
107
+ (json.dumps(projects, ensure_ascii=False), json.dumps(clients, ensure_ascii=False), now, machine_id))
108
+ rows = connection.execute("SELECT id,request FROM remote_launches WHERE machine_id=? AND status IN ('pending','claimed') ORDER BY created_at",
109
+ (machine_id,)).fetchall()
110
+ return {"launches": [{"id": row["id"], "request": json.loads(row["request"])} for row in rows]}
111
+
112
+
113
+ def machine_launch_action(connection, machine_id, launch_id, token, payload, now):
114
+ """Claim, finish or fail one launch; a finished or failed launch drops its request."""
115
+ require_machine(connection, machine_id, token)
116
+ begin_write(connection)
117
+ row = connection.execute("SELECT * FROM remote_launches WHERE id=? AND machine_id=?", (launch_id, machine_id)).fetchone()
118
+ if not row:
119
+ raise LookupError("启动请求不存在")
120
+ action = payload.get("action")
121
+ if action not in ("claim", "finish", "fail"):
122
+ raise ValueError("action 须为 claim、finish 或 fail")
123
+ if row["status"] not in ("pending", "claimed"):
124
+ raise FileExistsError("启动请求已结束")
125
+ if action == "claim":
126
+ connection.execute("UPDATE remote_launches SET status='claimed', updated_at=? WHERE id=?", (now, launch_id))
127
+ elif action == "finish":
128
+ room_id = payload.get("room_id")
129
+ if not isinstance(room_id, str) or not room_id:
130
+ raise ValueError("完成启动须提供 room_id")
131
+ result = {"room_id": room_id, "status": str(payload.get("status") or "")}
132
+ connection.execute("UPDATE remote_launches SET status='done', request='{}', result=?, error='', updated_at=? WHERE id=?",
133
+ (json.dumps(result), now, launch_id))
134
+ else:
135
+ stage = payload.get("stage") if payload.get("stage") in LAUNCH_STAGES else "unconfirmed"
136
+ result = {"stage": stage, **({"room_id": payload["room_id"]} if isinstance(payload.get("room_id"), str) else {})}
137
+ connection.execute("UPDATE remote_launches SET status='failed', request='{}', result=?, error=?, updated_at=? WHERE id=?",
138
+ (json.dumps(result), str(payload.get("error") or "启动失败")[:ERROR_MAX], now, launch_id))
139
+ return launch_view(connection.execute("SELECT * FROM remote_launches WHERE id=?", (launch_id,)).fetchone())
140
+
141
+
142
+ def submit_remote_launch(connection, payload, now):
143
+ """Queue a room creation for the paired computer; resubmitting the same id replays only a failed launch."""
144
+ launch_id, machine_id, request = payload.get("id"), payload.get("machine_id"), payload.get("request")
145
+ if not isinstance(launch_id, str) or not LAUNCH_ID.fullmatch(launch_id):
146
+ raise ValueError("启动编号无效")
147
+ if not isinstance(request, dict) or not isinstance(request.get("room"), dict) or request["room"].get("submission_id") != launch_id:
148
+ raise ValueError("远程启动只用于新建房间,room.submission_id 须与启动编号一致")
149
+ if request.get("cwd") or request.get("access_file"):
150
+ raise ValueError("远程启动只能选择电脑上已配置的项目,不能指定路径")
151
+ begin_write(connection)
152
+ machine = connection.execute("SELECT * FROM local_machines WHERE id=?", (machine_id,)).fetchone()
153
+ if not machine:
154
+ raise LookupError("这台电脑尚未连接网站")
155
+ project_id = request.get("local_project")
156
+ if project_id and project_id not in {project["id"] for project in json.loads(machine["projects"])}:
157
+ raise FileExistsError("所选项目已在电脑上移除或更改,请重新选择")
158
+ row = connection.execute("SELECT * FROM remote_launches WHERE id=?", (launch_id,)).fetchone()
159
+ if row and row["machine_id"] != machine_id:
160
+ raise FileExistsError("启动编号已用于另一台电脑")
161
+ if not row:
162
+ connection.execute("INSERT INTO remote_launches (id,machine_id,request,created_at,updated_at) VALUES (?,?,?,?,?)",
163
+ (launch_id, machine_id, json.dumps(request, ensure_ascii=False), now, now))
164
+ elif row["status"] == "failed":
165
+ connection.execute("UPDATE remote_launches SET status='pending', request=?, result='{}', error='', updated_at=? WHERE id=?",
166
+ (json.dumps(request, ensure_ascii=False), now, launch_id))
167
+ return launch_view(connection.execute("SELECT * FROM remote_launches WHERE id=?", (launch_id,)).fetchone())
168
+
169
+
170
+ def remote_launch_status(connection, launch_id):
171
+ """Read a relayed launch's progress."""
172
+ row = connection.execute("SELECT * FROM remote_launches WHERE id=?", (launch_id,)).fetchone()
173
+ if not row:
174
+ raise LookupError("启动请求不存在")
175
+ return launch_view(row)
@@ -532,8 +532,19 @@ CREATE TABLE IF NOT EXISTS member_executions (
532
532
  result TEXT NOT NULL DEFAULT '',
533
533
  changes TEXT NOT NULL DEFAULT '',
534
534
  cwd TEXT NOT NULL DEFAULT '',
535
+ activity TEXT NOT NULL DEFAULT '',
536
+ activity_at INTEGER NOT NULL DEFAULT 0,
535
537
  UNIQUE(room_id,client_id)
536
538
  );
539
+ -- Progress notes the runner saw a member's software write between tool calls; numbered per execution so a retried renewal adds nothing.
540
+ CREATE TABLE IF NOT EXISTS execution_notes (
541
+ execution_id INTEGER NOT NULL REFERENCES member_executions(id) ON DELETE CASCADE,
542
+ seq INTEGER NOT NULL,
543
+ room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
544
+ text TEXT NOT NULL,
545
+ created_at INTEGER NOT NULL,
546
+ PRIMARY KEY(execution_id,seq)
547
+ );
537
548
  -- One live execution per member: different members may run in parallel, one session never twice.
538
549
  CREATE UNIQUE INDEX IF NOT EXISTS one_member_execution ON member_executions(member_id)
539
550
  WHERE status IN ('dispatched','started');
@@ -729,3 +740,28 @@ END;
729
740
  -- The review phase behaved exactly like discussing and was retired; move rows left in it.
730
741
  UPDATE topic_settings SET phase='discussing' WHERE phase='reviewing';
731
742
  UPDATE discussion_runtime SET resume_phase='discussing' WHERE resume_phase='reviewing';
743
+
744
+ -- A computer running the local service, paired by the site administrator. Only project names and ids and the
745
+ -- configured client of each platform are published; directories and credentials stay on that computer.
746
+ CREATE TABLE IF NOT EXISTS local_machines (
747
+ id TEXT PRIMARY KEY,
748
+ name TEXT NOT NULL,
749
+ token_hash TEXT NOT NULL,
750
+ projects TEXT NOT NULL DEFAULT '[]',
751
+ clients TEXT NOT NULL DEFAULT '{}',
752
+ seen_at INTEGER NOT NULL DEFAULT 0,
753
+ created_at INTEGER NOT NULL
754
+ );
755
+ -- A room launch submitted from a device without the local service; the paired computer claims and runs it.
756
+ -- The request (with the room's new credentials) is cleared once the launch finishes or fails.
757
+ CREATE TABLE IF NOT EXISTS remote_launches (
758
+ id TEXT PRIMARY KEY,
759
+ machine_id TEXT NOT NULL,
760
+ request TEXT NOT NULL DEFAULT '{}',
761
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','claimed','done','failed')),
762
+ result TEXT NOT NULL DEFAULT '{}',
763
+ error TEXT NOT NULL DEFAULT '',
764
+ created_at INTEGER NOT NULL,
765
+ updated_at INTEGER NOT NULL
766
+ );
767
+ CREATE INDEX IF NOT EXISTS remote_launches_machine ON remote_launches(machine_id, status);
package/backend/server.py CHANGED
@@ -21,12 +21,14 @@ from pathlib import Path
21
21
  from urllib.parse import parse_qs, quote, urlsplit
22
22
 
23
23
  from .admin import AdminError, admin_status, login_admin, logout_admin, register_admin, session_valid, update_profile
24
+ from .remote_launch import (list_machines, machine_launch_action, machine_sync, pair_machine, remote_launch_status,
25
+ submit_remote_launch)
24
26
  from .free_room import (add_member, after_free_message, authorize_free_write, check_answer, check_proxy, execution_action, free_view,
25
27
  inbox, interrupt_member, member_action, member_values, migrate_free_tables, proxy_member, reserve_execution,
26
28
  discussion_members, room_mode, save_room_access, sync_discussion_members, view_execution)
27
29
  from .free_room import add_discussion_member, convert_suggestion, discussion_member_action, message_suggestions, save_suggestions, sync_participants
28
30
  from .free_room import active_members, adopt_recommendation, ask_members, asks_view, checked_asks, recommend_member, register_member, route_held_messages
29
- from .free_room import reorder_speakers, speaking_order, stop_room_work
31
+ from .free_room import execution_notes, reorder_speakers, speaking_order, stop_room_work
30
32
  from .identity import MAX_ROOM_AGENTS, PARTICIPANTS, is_ai, is_participant
31
33
  from .room_import import import_room, imported_attachment
32
34
  from .runtime import (authorize_write, begin_write, configure_runtime, event_action,
@@ -61,7 +63,8 @@ ASSETS = {"/": ("index.html", "text/html"),
61
63
  "/markdown.js": ("markdown.js", "text/javascript"),
62
64
  "/backup.js": ("backup.js", "text/javascript"),
63
65
  "/markdown.css": ("markdown.css", "text/css"),
64
- "/icons/LICENSE.txt": ("icons/LICENSE.txt", "text/plain")}
66
+ "/icons/LICENSE.txt": ("icons/LICENSE.txt", "text/plain"),
67
+ "/icons/typesafe.png": ("icons/typesafe.png", "image/png")}
65
68
  ASSETS.update({f"/icons/{name}.svg": (f"icons/{name}.svg", "image/svg+xml")
66
69
  for name in ("openai", "claude", "gemini", "deepseek", "qwen", "user")})
67
70
  ASSETS.update({"/pdfjs/" + file.relative_to(WEB_ROOT / "pdfjs").as_posix():
@@ -223,12 +226,14 @@ def require_room(connection, room_id):
223
226
  room["round_budget"] = dict(budget) if budget else None
224
227
  if budget:
225
228
  room["opinion_count"] = opinion_count(connection, room_id, room["experts"])
226
- room["round_groups"] = budget_round_groups(connection, room_id, room["experts"], budget["base"]) if budget else room["rounds"]
229
+ session = connection.execute("SELECT id,goal,status,started_message_id,ended_message_id,created_at FROM discussion_sessions WHERE room_id=? ORDER BY id DESC LIMIT 1", (room_id,)).fetchone()
230
+ room["session"] = dict(session) if session else None
231
+ room["round_groups"] = budget_round_groups(connection, room_id, room["experts"], budget["base"], room["session"]) if budget else room["rounds"]
227
232
  room["runtime"] = runtime_view(connection, room_id)
228
233
  room["limits"] = limits_view(connection.execute(LIMITS_SQL, (room_id,)).fetchone(), room)
229
234
  room["mode"] = room_mode(connection, room_id)
230
- session = connection.execute("SELECT id,goal,status,started_message_id,ended_message_id,created_at FROM discussion_sessions WHERE room_id=? ORDER BY id DESC LIMIT 1", (room_id,)).fetchone()
231
- room["session"] = dict(session) if session else None
235
+ if room["mode"] == "free" and not session:
236
+ room["round_groups"] = []
232
237
  room["members"] = [dict(row) for row in connection.execute(
233
238
  """SELECT m.id,m.name,m.adapter,m.role,m.status,m.access,m.session_mode,COALESCE(s.session_id,'') AS reported_session,COALESCE(s.client,'') AS reported_client,
234
239
  COALESCE(r.registered_at,'') AS registered_at
@@ -326,27 +331,33 @@ def discussion_rounds(connection, room_id, experts):
326
331
  return rounds
327
332
 
328
333
 
329
- def budget_round_groups(connection, room_id, experts, base):
334
+ def budget_round_groups(connection, room_id, experts, base, session):
330
335
  """Group history by the round budget's rule: every len(experts) opinions form a round, and a new segment (base) starts a new one.
331
336
 
337
+ Only messages of the latest discussion are grouped, numbered from 1; free collaboration before or after it has no rounds.
332
338
  `rounds` stays speaker-based because scheduling needs who still owes a turn; this view only labels history.
333
339
  """
334
- if not experts:
340
+ if not experts or not session:
335
341
  return []
342
+ first, last = session["started_message_id"], session["ended_message_id"] or float("inf")
336
343
  rows = connection.execute("""SELECT messages.id,messages.author,
337
344
  COALESCE(details.kind,'opinion') AS kind FROM messages
338
345
  LEFT JOIN message_details AS details ON details.message_id=messages.id
339
346
  WHERE room_id=? ORDER BY messages.id""", (room_id,))
340
347
  groups, next_start, seen = [], None, 0
341
348
  for message in rows:
349
+ opinion = message["kind"] == "opinion" and message["author"] in experts
350
+ # base counts every opinion in the room, so opinions outside the discussion still advance seen.
351
+ seen += opinion
352
+ if not first < message["id"] <= last:
353
+ continue
342
354
  if next_start is None:
343
355
  next_start = message["id"]
344
- if message["kind"] != "opinion" or message["author"] not in experts:
356
+ if not opinion:
345
357
  continue
346
358
  if not groups or groups[-1]["completed"]:
347
359
  groups.append({"number": len(groups) + 1, "start_id": next_start, "opinions": 0, "completed": False})
348
360
  current = groups[-1]
349
- seen += 1
350
361
  current["opinions"] += 1
351
362
  current["completed"] = current["opinions"] >= len(experts) or seen == base
352
363
  if current["completed"]:
@@ -645,7 +656,7 @@ def manage_room(connection, room_id, action, payload):
645
656
 
646
657
  def delete_room_records(connection, room_id):
647
658
  """Remove a room's dependent records in the caller's transaction."""
648
- for table in ("member_recommendations", "free_queue", "speaking_orders", "member_requests", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
659
+ for table in ("member_recommendations", "free_queue", "speaking_orders", "member_requests", "execution_notes", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
649
660
  "discussion_options", "round_budgets", "scheduler_decisions", "scheduler_routing", "discussion_sessions"):
650
661
  if table == "scheduler_decisions":
651
662
  for child in ("scheduler_feedback", "scheduler_decision_context", "scheduler_decision_inputs"):
@@ -766,7 +777,8 @@ def start_discussion(connection, room_id, token, payload):
766
777
  raise ValueError("named_followup 须为布尔值")
767
778
  # A fresh session first, so the phase mirror follows it and not a concluded earlier one.
768
779
  if not connection.execute("SELECT 1 FROM discussion_sessions WHERE room_id=? AND status IN ('active','paused')", (room_id,)).fetchone():
769
- connection.execute("INSERT INTO discussion_sessions (room_id,goal,status,created_at) VALUES (?,?,'paused',?)", (room_id, settings["goal"], utc_now()))
780
+ connection.execute("INSERT INTO discussion_sessions (room_id,goal,status,started_message_id,created_at) VALUES (?,?,'paused',?,?)",
781
+ (room_id, settings["goal"], room["latest_id"], utc_now()))
770
782
  connection.execute("UPDATE rooms SET goal=? WHERE id=?", (settings["goal"], room_id))
771
783
  save_topic_settings(connection, room_id, settings)
772
784
  connection.execute("UPDATE room_modes SET mode='brainstorm' WHERE room_id=?", (room_id,))
@@ -1556,7 +1568,7 @@ def participation_text(params, free=False):
1556
1568
  "先回答真正的问题,给出理由、证据、适用条件与不确定性;区分事实、推断和建议,引用原文编号,不编造他人的回答。"
1557
1569
  "讨论初始阶段先独立判断,暂不读他人观点;深入讨论先理解对方主张及其最有力的依据,再说明同意、分歧或需要补的证据。"
1558
1570
  "可以坚持有依据的少数意见,也可因新证据修改判断并说明变化。",
1559
- "输出:先 1–3 句摘要,再给完整正文。",
1571
+ "输出:完整正文;回答较长时先给 1–3 句摘要。",
1560
1572
  "不要操作网页表单、调用写入接口或生成发布链接。读取失败时如实报告,由用户提供复制的材料。",
1561
1573
  *(["自由协作:只回应点名给你的消息(编号见阅读通知),按时间顺序处理,后面的消息可以修正前面的。"] if free else []), footer]
1562
1574
 
@@ -1604,7 +1616,7 @@ def protocol_text(room):
1604
1616
  f"维护话题:POST {base}/topic;actor 加需要修改的上述字段,另可更新 phase(preparing/initial/discussing/paused/concluded)、focus。收束前先完成或取消当前安排;主持人收束还须先 GET {base}/conclusion 取得 blockers 与 revision,并把 revision 作为 conclusion 一并提交(有阻碍项或状态已变会被拒绝)。暂停保留安排;恢复阶段后再继续。",
1605
1617
  f"安排发言:POST {base}/turns;字段 actor、expert、question、submission_id(新 UUID)。同一话题同时只有一个未完成安排;重试沿用同一编号和问题。整轮安排改传 turns=[{{expert,question}},…](最多 {MAX_PLAN_TURNS} 项),并须先 GET {base}/turns/roster 取得最新专家状态,把返回的 revision 作为 roster 一并提交(名单变化后需重取;须包含 waiting 中的每位专家):第一项立即生效,其余排队(queued);每个回答保存后自动交给下一位,最后一位答完再交回主持人。用户留言或修改目标、背景、边界、焦点后(room.held_since 非 0),当前回答完成即交回主持人;主持人处理后用 POST {base}/turns/continue(actor)按原计划继续,或直接创建新安排替换剩余排队(原排队改为 skipped)。",
1606
1618
  f"通知:仅把对象、问题、话题入口交给对应专家,不附背景、摘要或历史。使用已有且获准的会话通道;没有通道时交由用户协助。实际送达后 POST {base}/turns/安排编号,字段 actor、status=notified;取消用 status=cancelled。复制不算送达。",
1607
- f"专家观点:POST {base}/messages;author、text、submission_id、source=cli,kind=opinion;turn_id(回答的安排编号)可省略,省略时回答你当前有效的安排;可选 reply_to(本话题原文编号)。每次回答同时提供 summary(最多 {MAX_SUMMARY_LENGTH} 字),通常 1–3 句结论先行,保留关键理由、条件和不确定性;summary_by 为原作者或提交者,默认提交者。text 保留完整正文,最多 {MAX_TEXT_LENGTH} 个 Unicode 字符,请求最多 {MAX_BODY_BYTES} 字节;不截断或用摘要完成安排。旧客户端缺少 summary 时不自动生成。",
1619
+ f"专家观点:POST {base}/messages;author、text、submission_id、source=cli,kind=opinion;turn_id(回答的安排编号)可省略,省略时回答你当前有效的安排;可选 reply_to(本话题原文编号)。回答较长时可附 summary(最多 {MAX_SUMMARY_LENGTH} 字),1–3 句结论先行,保留关键理由、条件和不确定性,简短回答不写;summary_by 为原作者或提交者,默认提交者。text 保留完整正文,最多 {MAX_TEXT_LENGTH} 个 Unicode 字符,请求最多 {MAX_BODY_BYTES} 字节;不截断或用摘要完成安排。旧客户端缺少 summary 时不自动生成。",
1608
1620
  f"主持发言:同一消息接口,author={room['host']}、submitted_by={room['host']}、kind=moderation、source=cli;个人观点使用 kind=opinion,不能自动视为共识。",
1609
1621
  f"代发只读专家观点:author 保留原专家,submitted_by={room['host']}、kind=opinion、text 为原文;须关联 turn_id,可附 reply_to。服务保存代发来源;不要冒充专家直接提交。该专家须有当前有效的安排。",
1610
1622
  f"整理状态:POST {base}/state;actor、text(详细状态,最多 {MAX_STATE_LENGTH} 字)、until(依据的消息截止编号);brief(最多 {MAX_BRIEF_LENGTH} 字)为面向人的主持简报,说明结论、共识、歧义和下一步意图。进度或安排变化后更新;省略 brief 保留旧简报,空字符串清除。所有参与专家各保存一次完整观点才完成一轮;同轮重复发言只计该专家一次,主持说明、用户留言及取消安排不推进轮次。房间 rounds 按当前配置专家和完整历史生成,completed_rounds 为完成轮数,answered_turns 仅为已回答安排数。状态不覆盖原文,也不代表全体一致同意。",
@@ -1825,7 +1837,7 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1825
1837
  site = self.local_request_site(method)
1826
1838
  route = parts[2:]
1827
1839
  if method == "OPTIONS" and route in (["settings"], ["launch"], ["room-models"], ["room-member"], ["models"], ["api-key"], ["api-check"],
1828
- ["scheduler-check"], ["pick-directory"]):
1840
+ ["scheduler-check"], ["pick-directory"], ["projects"], ["machine-link"]):
1829
1841
  return self.respond({"ok": True})
1830
1842
  if route == ["settings"] and method in ("GET", "POST"):
1831
1843
  command = "settings"
@@ -1847,6 +1859,10 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1847
1859
  command = "room-member"
1848
1860
  elif route == ["pick-directory"] and method == "POST":
1849
1861
  command = "pick-directory"
1862
+ elif route == ["projects"] and method == "POST":
1863
+ command = "projects"
1864
+ elif route == ["machine-link"] and method == "POST":
1865
+ command = "machine-link"
1850
1866
  else:
1851
1867
  raise LookupError("本机入口不存在")
1852
1868
  payload = None
@@ -1857,6 +1873,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1857
1873
  if command in ("start", "room-config", "room-models", "room-member"):
1858
1874
  payload.pop("access_file", None)
1859
1875
  payload.update(site=site, entry="web")
1876
+ if command == "machine-link":
1877
+ payload = {"site": site}
1860
1878
  if command == "room-config" and "participants" in payload:
1861
1879
  return self.save_local_room_configuration(command, payload)
1862
1880
  return self.run_local_command(command, payload)
@@ -1933,6 +1951,30 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1933
1951
  connection.commit()
1934
1952
  return self.respond(result, 201 if parts[2] == "register" else 200)
1935
1953
 
1954
+ def handle_remote_launch_api(self, connection, parts, method):
1955
+ """Pair computers and relay room launches; a computer authenticates with its own token, everything else as the administrator."""
1956
+ now = int(time.time())
1957
+ if len(parts) in (4, 5) and parts[1] == "machines" and method == "POST" and (len(parts) == 4 and parts[3] == "sync" or parts[3:4] == ["launches"]):
1958
+ token = self.headers.get("X-Machine-Token", "")
1959
+ result = (machine_sync(connection, parts[2], token, self.read_payload(), now) if len(parts) == 4
1960
+ else machine_launch_action(connection, parts[2], parts[4], token, self.read_payload(), now))
1961
+ connection.commit()
1962
+ return self.respond(result)
1963
+ if not self.admin_session(connection):
1964
+ raise AdminError("需要登录管理员账号", 401)
1965
+ if parts == ["api", "machines"] and method == "GET":
1966
+ return self.respond(list_machines(connection, now))
1967
+ if parts == ["api", "machines"] and method == "POST":
1968
+ result = pair_machine(connection, self.read_payload(), now)
1969
+ elif parts == ["api", "remote-launches"] and method == "POST":
1970
+ result = submit_remote_launch(connection, self.read_payload(), now)
1971
+ elif len(parts) == 3 and parts[1] == "remote-launches" and method == "GET":
1972
+ return self.respond(remote_launch_status(connection, parts[2]))
1973
+ else:
1974
+ raise LookupError("地址不存在")
1975
+ connection.commit()
1976
+ return self.respond(result)
1977
+
1936
1978
  def handle_api(self, connection, parts, params, method):
1937
1979
  """Handle room, state, and message operations used by the web UI."""
1938
1980
  if parts == ["api", "health"] and method == "GET":
@@ -1943,6 +1985,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1943
1985
  if not self.admin_session(connection):
1944
1986
  raise AdminError("需要登录管理员账号", 401)
1945
1987
  return self.respond(list_feedback_cases(connection, int(params.get("after", 0))))
1988
+ if parts[1:2] in (["machines"], ["remote-launches"]):
1989
+ return self.handle_remote_launch_api(connection, parts, method)
1946
1990
  if parts == ["api", "rooms"]:
1947
1991
  if method == "GET":
1948
1992
  return self.respond({**list_rooms(connection, params), "participants": PARTICIPANTS})
@@ -2170,6 +2214,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2170
2214
  result = recommend_member(connection, room_id, token, self.read_payload())
2171
2215
  elif len(parts) == 5 and parts[3] == "recommendations" and method == "POST":
2172
2216
  result = adopt_recommendation(connection, room_id, token, int(parts[4]), self.read_payload())
2217
+ elif len(parts) == 6 and parts[3] == "executions" and parts[5] == "notes" and method == "GET":
2218
+ result = execution_notes(connection, room_id, int(parts[4]))
2173
2219
  elif len(parts) == 5 and parts[3] == "executions":
2174
2220
  execution_id = int(parts[4])
2175
2221
  result = (view_execution(connection, room_id, execution_id, token) if method == "GET"