ai_co_discuss 0.2.99 → 0.3.0

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.
Files changed (39) hide show
  1. package/.agents/skills/discuss-host/SKILL.md +3 -3
  2. package/.agents/skills/discuss-host/references/discussion.md +16 -16
  3. package/.agents/skills/discuss-host/references/launch.md +1 -3
  4. package/.agents/skills/discuss-host/references/manual.md +14 -77
  5. package/.agents/skills/discuss-participant/SKILL.md +3 -3
  6. package/.agents/skills/discuss-participant/references/discussion.md +29 -27
  7. package/.agents/skills/discuss-participant/references/free.md +2 -2
  8. package/.agents/skills/discuss-participant/references/manual.md +8 -10
  9. package/.agents/skills/discuss-participant/references/writing.md +9 -9
  10. package/.github/SECURITY.md +1 -1
  11. package/README.md +3 -3
  12. package/README.zh-CN.md +3 -3
  13. package/backend/free_room.py +360 -68
  14. package/backend/room_import.py +75 -15
  15. package/backend/runtime.py +81 -169
  16. package/backend/schema.sql +136 -244
  17. package/backend/server.py +602 -166
  18. package/backend/session_room.py +919 -0
  19. package/docs/architecture.md +3 -2
  20. package/docs/en/architecture.md +4 -3
  21. package/docs/en/runner.md +3 -1
  22. package/docs/en/workflow.md +9 -6
  23. package/docs/runner.md +3 -1
  24. package/docs/workflow.md +11 -8
  25. package/package.json +2 -2
  26. package/runner/api_participant.mjs +47 -44
  27. package/runner/discuss.py +77 -58
  28. package/runner/discussion_adapters.mjs +6 -3
  29. package/runner/discussion_launch.mjs +98 -70
  30. package/runner/discussion_runner.mjs +38 -496
  31. package/runner/discussion_service.mjs +21 -74
  32. package/runner/discussion_sidecar.mjs +4 -13
  33. package/runner/expert_prompts.mjs +29 -21
  34. package/runner/free_runner.mjs +84 -19
  35. package/runner/session_driver.mjs +175 -0
  36. package/web/app.js +193 -128
  37. package/web/backup.js +1 -1
  38. package/web/modern.css +1 -1
  39. package/web/modern.js +5482 -6646
package/backend/server.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """AI CoDiscuss 的零依赖 HTTP / SQLite 概念验证服务。"""
2
2
 
3
3
  import argparse
4
+ import copy
4
5
  import hashlib
5
6
  import hmac
6
7
  import json
@@ -27,14 +28,19 @@ from .free_room import (add_member, after_free_message, authorize_free_write, ch
27
28
  inbox, interrupt_member, member_action, member_values, migrate_free_tables, proxy_member, reserve_execution,
28
29
  discussion_members, room_mode, save_room_access, sync_discussion_members, view_execution)
29
30
  from .free_room import add_discussion_member, convert_suggestion, discussion_member_action, message_suggestions, save_suggestions, sync_participants
30
- from .free_room import active_members, adopt_recommendation, ask_members, asks_view, checked_asks, recommend_member, register_member, route_held_messages
31
+ from .free_room import active_members, adopt_recommendation, ask_members, asks_view, checked_asks, recommend_member, register_member, route_held_messages, require_member
31
32
  from .free_room import describe_members, execution_notes, label_members, member_descriptions, member_labels, reorder_speakers, set_chain_rounds, speaking_order, stop_room_work
32
33
  from .free_room import continue_chain, execution_timeline, retract_message, rewind_plan, rewind_room, withdrawals
33
34
  from .identity import MAX_ROOM_AGENTS, PARTICIPANTS, is_ai, is_participant
34
35
  from .room_import import import_room, imported_attachment
35
- from .runtime import (authorize_write, begin_write, configure_runtime, event_action,
36
+ from .runtime import (authorize_write, begin_write, event_view,
36
37
  LIMIT_DEFAULTS, LIMITS_SQL, limit_values, limits_view, require_within_limits,
37
- list_events, owner_proxy_token, require_control, require_owner, runtime_view, token_hash)
38
+ list_events, owner_proxy_token, require_control, require_owner, runtime_view, token_hash,
39
+ enforce_session_limits, has_session_request_model, session_limits_view)
40
+ from .session_room import (cancel_plan, complete_session_requests, current_session, discussion_action,
41
+ get_session_request, query_session_requests, roster_view as session_roster_view,
42
+ session_message_access, session_room, create_plan, continue_plan, update_session_limits,
43
+ session_conclusion_view, conclude_session_by_host)
38
44
 
39
45
  ROOT = Path(__file__).resolve().parent.parent
40
46
  WEB_ROOT = ROOT / "web"
@@ -89,8 +95,10 @@ FROM scheduler_decisions d LEFT JOIN scheduler_feedback f ON f.decision_id=d.id
89
95
  # Every discussion of a room with its conclusion snapshot (NULL columns until it ends) and the message counts that
90
96
  # place it on the room's timeline; message ids are site-wide, so counts come from the room's own messages.
91
97
  DISCUSSIONS_SELECT = """SELECT s.id,s.goal,s.status,s.started_message_id,s.ended_message_id,s.created_at,
92
- (SELECT COUNT(*) FROM messages WHERE room_id=s.room_id AND id<=s.started_message_id) AS messages_before,
93
- (SELECT COUNT(*) FROM messages WHERE room_id=s.room_id AND (s.ended_message_id=0 OR id<=s.ended_message_id)) AS messages_through,
98
+ (SELECT COUNT(*) FROM messages WHERE room_id=s.room_id AND id<=s.started_message_id
99
+ AND NOT EXISTS (SELECT 1 FROM task_message_sources t WHERE t.message_id=messages.id)) AS messages_before,
100
+ (SELECT COUNT(*) FROM messages WHERE room_id=s.room_id AND (s.ended_message_id=0 OR id<=s.ended_message_id)
101
+ AND NOT EXISTS (SELECT 1 FROM task_message_sources t WHERE t.message_id=messages.id)) AS messages_through,
94
102
  c.stop_reason,c.max_rounds,c.max_minutes,c.brief,c.state,c.state_until,c.concluded_at
95
103
  FROM discussion_sessions AS s LEFT JOIN discussion_conclusions AS c ON c.session_id=s.id WHERE s.room_id=? ORDER BY s.id"""
96
104
  MAX_DECISION_LABEL = 200
@@ -110,6 +118,15 @@ ROOM_BACKUP_QUERIES = {
110
118
  "message_withdrawals": "SELECT w.* FROM message_withdrawals w JOIN messages m ON m.id=w.message_id WHERE m.room_id=? ORDER BY w.message_id",
111
119
  "attachments": "SELECT * FROM attachments WHERE room_id=? AND message_id IS NOT NULL ORDER BY message_id,position",
112
120
  "discussion_turns": "SELECT * FROM discussion_turns WHERE room_id=? ORDER BY id",
121
+ "discussion_sessions": "SELECT * FROM discussion_sessions WHERE room_id=? ORDER BY id",
122
+ "discussion_conclusions": "SELECT * FROM discussion_conclusions WHERE room_id=? ORDER BY session_id",
123
+ "session_state": "SELECT * FROM session_state WHERE session_id IN (SELECT id FROM discussion_sessions WHERE room_id=?) ORDER BY session_id",
124
+ "session_limits": "SELECT * FROM session_limits WHERE session_id IN (SELECT id FROM discussion_sessions WHERE room_id=?) ORDER BY session_id",
125
+ "session_round_budgets": "SELECT * FROM session_round_budgets WHERE session_id IN (SELECT id FROM discussion_sessions WHERE room_id=?) ORDER BY session_id",
126
+ "session_options": "SELECT * FROM session_options WHERE session_id IN (SELECT id FROM discussion_sessions WHERE room_id=?) ORDER BY session_id",
127
+ "session_pauses": "SELECT * FROM session_pauses WHERE session_id IN (SELECT id FROM discussion_sessions WHERE room_id=?) ORDER BY session_id",
128
+ "session_requests": "SELECT * FROM session_requests WHERE session_id IN (SELECT id FROM discussion_sessions WHERE room_id=?) ORDER BY id",
129
+ "task_message_sources": "SELECT * FROM task_message_sources WHERE session_id IN (SELECT id FROM discussion_sessions WHERE room_id=?) ORDER BY message_id",
113
130
  **{table: f"SELECT * FROM {table} WHERE message_id IN (SELECT id FROM messages WHERE room_id=?) ORDER BY message_id"
114
131
  for table in ("message_details", "message_summaries", "message_timings")},
115
132
  }
@@ -118,7 +135,7 @@ ROOM_BACKUP_QUERIES["room_creations"] = "SELECT submission_id,room_id FROM room_
118
135
  FREE_BACKUP_QUERIES = {
119
136
  "room_modes": "SELECT room_id,mode FROM room_modes WHERE room_id=?",
120
137
  "room_members": "SELECT id,room_id,name,adapter,role,status,read_until,created_at,access,session_mode FROM room_members WHERE room_id=? ORDER BY id",
121
- "member_requests": "SELECT * FROM member_requests WHERE room_id=? ORDER BY id",
138
+ "member_requests": "SELECT id,room_id,member_id,message_id,status,answer_id,created_at,chain_id,batch_id,purpose,created_by,execution_id FROM member_requests WHERE room_id=? ORDER BY id",
122
139
  "member_suggestions": "SELECT s.* FROM member_suggestions s JOIN messages m ON m.id=s.message_id WHERE m.room_id=? ORDER BY s.message_id,s.member_id",
123
140
  "member_recommendations": "SELECT * FROM member_recommendations WHERE room_id=? ORDER BY message_id",
124
141
  }
@@ -154,10 +171,76 @@ def room_backup(connection, room_id):
154
171
  for table, query in queries.items()}
155
172
  if not tables["rooms"]:
156
173
  raise LookupError("房间不存在")
157
- return {"format": "ai-codiscuss-room-backup", "version": 1,
174
+ return {"format": "ai-codiscuss-room-backup", "version": 2,
158
175
  "exported_at": utc_now(), "tables": tables}
159
176
 
160
177
 
178
+ def session_read_boundary(connection, room_id, token=""):
179
+ """Return the current identity's initial-opinion cutoff, or None when unrestricted."""
180
+ return session_message_access(connection, room_id, {}, token)
181
+
182
+
183
+ def room_read_view(connection, room, token=""):
184
+ """Hide post-cutoff state and execution references from readers in the initial-opinion phase."""
185
+ boundary = session_read_boundary(connection, room["id"], token)
186
+ if boundary is None:
187
+ return room
188
+ room = copy.deepcopy(room)
189
+ if room.get("state_until", 0) > boundary:
190
+ room["state_text"] = ""
191
+ brief = room.get("host_brief")
192
+ if brief and brief.get("until", 0) > boundary:
193
+ room["host_brief"] = None
194
+ free = room.get("free") or {}
195
+ for request in free.get("requests", []):
196
+ _hide_after_boundary(request, boundary, ("message_id", "answer_id", "source_message_id", "after_message_id"))
197
+ if request.get("discussion_session_id") and request.get("source_question"):
198
+ request["source_question"] = ""
199
+ for execution in free.get("executions", []):
200
+ _hide_execution_after_boundary(execution, boundary)
201
+ executions_by_id = {execution.get("id"): execution for execution in free.get("executions", [])}
202
+ for member in free.get("members", []):
203
+ execution = member.get("execution") or {}
204
+ _hide_execution_after_boundary(execution, boundary)
205
+ sanitized = executions_by_id.get(execution.get("id"))
206
+ if sanitized:
207
+ execution["result"] = sanitized.get("result", execution.get("result", ""))
208
+ return room
209
+
210
+
211
+ def _hide_after_boundary(item, boundary, fields):
212
+ """Clear message identifiers that point beyond an initial reader's allowed transcript."""
213
+ for field in fields:
214
+ value = item.get(field)
215
+ if isinstance(value, int) and value > boundary:
216
+ item[field] = None
217
+
218
+
219
+ def _hide_execution_after_boundary(execution, boundary):
220
+ """Remove references to an answer or request beyond the allowed transcript cutoff."""
221
+ answer_id = execution.get("answer_id")
222
+ request_message_id = execution.get("request_message_id")
223
+ hidden_answer = isinstance(answer_id, int) and answer_id > boundary
224
+ hidden_request = isinstance(request_message_id, int) and request_message_id > boundary
225
+ result = str(execution.get("result", ""))
226
+ if result.startswith("message:"):
227
+ try:
228
+ hidden_answer = hidden_answer or int(result.removeprefix("message:")) > boundary
229
+ except ValueError:
230
+ pass
231
+ if hidden_answer:
232
+ execution["answer_id"] = None
233
+ if result.startswith("message:"):
234
+ execution["result"] = ""
235
+ if hidden_request:
236
+ execution["request_message_id"] = None
237
+ execution["result"] = ""
238
+ for request in execution.get("requests", []):
239
+ _hide_after_boundary(request, boundary, ("message_id", "answer_id", "source_message_id", "after_message_id"))
240
+ if request.get("discussion_session_id") and request.get("source_question"):
241
+ request["source_question"] = ""
242
+
243
+
161
244
  def utc_now():
162
245
  """Return the current UTC timestamp in ISO format."""
163
246
  return datetime.now(timezone.utc).isoformat(timespec="seconds")
@@ -199,6 +282,13 @@ def init_database(database, seed=True):
199
282
  database.parent.mkdir(parents=True, exist_ok=True)
200
283
  with closing(connect_db(database)) as connection, connection:
201
284
  connection.execute("PRAGMA journal_mode = WAL")
285
+ tables = {row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
286
+ if "room_members" in tables:
287
+ columns = {row[1] for row in connection.execute("PRAGMA table_info(room_members)")}
288
+ if "access" not in columns:
289
+ connection.execute("ALTER TABLE room_members ADD COLUMN access TEXT NOT NULL DEFAULT 'runner'")
290
+ if "session_mode" not in columns:
291
+ connection.execute("ALTER TABLE room_members ADD COLUMN session_mode TEXT NOT NULL DEFAULT 'new'")
202
292
  connection.executescript(SCHEMA)
203
293
  migrate_free_tables(connection)
204
294
  if seed and is_new:
@@ -207,9 +297,18 @@ def init_database(database, seed=True):
207
297
 
208
298
  def require_room(connection, room_id):
209
299
  """Return a room and its current message count, or raise a lookup error."""
210
- row = connection.execute("SELECT rooms.*, COUNT(messages.id) AS message_count, COALESCE(MAX(messages.id),0) AS latest_id FROM rooms LEFT JOIN messages ON rooms.id=messages.room_id WHERE rooms.id=? GROUP BY rooms.id", (room_id,)).fetchone()
300
+ row = connection.execute("""SELECT rooms.*,
301
+ COUNT(CASE WHEN NOT EXISTS (SELECT 1 FROM task_message_sources t WHERE t.message_id=messages.id) THEN messages.id END) AS message_count,
302
+ COALESCE(MAX(CASE WHEN NOT EXISTS (SELECT 1 FROM task_message_sources t WHERE t.message_id=messages.id) THEN messages.id END),0) AS latest_id
303
+ FROM rooms LEFT JOIN messages ON rooms.id=messages.room_id WHERE rooms.id=? GROUP BY rooms.id""", (room_id,)).fetchone()
211
304
  if row is None:
212
305
  raise LookupError("房间不存在")
306
+ if enforce_session_limits(connection, room_id):
307
+ connection.commit()
308
+ row = connection.execute("""SELECT rooms.*,
309
+ COUNT(CASE WHEN NOT EXISTS (SELECT 1 FROM task_message_sources t WHERE t.message_id=messages.id) THEN messages.id END) AS message_count,
310
+ COALESCE(MAX(CASE WHEN NOT EXISTS (SELECT 1 FROM task_message_sources t WHERE t.message_id=messages.id) THEN messages.id END),0) AS latest_id
311
+ FROM rooms LEFT JOIN messages ON rooms.id=messages.room_id WHERE rooms.id=? GROUP BY rooms.id""", (room_id,)).fetchone()
213
312
  room = dict(row)
214
313
  room["archived"] = bool(connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0])
215
314
  room["folder_id"] = connection.execute("SELECT folder_id FROM room_folder_assignments WHERE room_id=?", (room_id,)).fetchone()[0]
@@ -241,18 +340,55 @@ def require_room(connection, room_id):
241
340
  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()
242
341
  room["session"] = dict(session) if session else None
243
342
  room["discussions"] = [dict(row) for row in connection.execute(DISCUSSIONS_SELECT, (room_id,))]
244
- room["round_groups"] = budget_round_groups(connection, room_id, room["experts"], budget["base"], room["session"]) if budget else room["rounds"]
343
+ legacy_groups = budget_round_groups(connection, room_id, room["experts"], budget["base"], room["session"]) if budget else room["rounds"]
344
+ room["round_groups"] = [{**group, "key": f"legacy-round-{group['number']}",
345
+ "speakers": group.get("speakers", []), "participants": len(room["experts"])}
346
+ for group in legacy_groups]
245
347
  room["runtime"] = runtime_view(connection, room_id)
246
348
  room["limits"] = limits_view(connection.execute(LIMITS_SQL, (room_id,)).fetchone(), room)
247
349
  room["mode"] = room_mode(connection, room_id)
350
+ session_limits = session_limits_view(connection, room_id)
351
+ if session_limits:
352
+ room["limits"] = session_limits
353
+ session = current_session(connection, room_id)
354
+ if session:
355
+ state = connection.execute("SELECT phase,held_since FROM session_state WHERE session_id=?", (session["id"],)).fetchone()
356
+ room["phase"] = ("paused" if session["status"] == "paused" else "concluded" if session["status"] == "concluded"
357
+ else state["phase"] if state else room["phase"])
358
+ room["held_since"] = state["held_since"] if state else room["held_since"]
359
+ room["round_budget"] = dict(connection.execute(
360
+ "SELECT base,cap,extra FROM session_round_budgets WHERE session_id=?", (session["id"],)).fetchone())
361
+ room["opinion_count"] = session_limits["answered"]
362
+ room["completed_rounds"] = session_limits["completed_rounds"]
363
+ room["answered_turns"] = session_limits["answered"]
364
+ active_session = current_session(connection, room_id, active_only=True)
365
+ if active_session:
366
+ current = connection.execute("""SELECT sr.*,m.name AS expert FROM session_requests sr
367
+ JOIN room_members m ON m.id=sr.member_id WHERE sr.session_id=? AND sr.purpose='discuss' AND sr.status='open'
368
+ ORDER BY sr.round,sr.position,sr.id LIMIT 1""", (active_session["id"],)).fetchone()
369
+ room["current_turn"] = ({**dict(current), "status": "pending",
370
+ "status_label": turn_status_label({"status": "pending", "expert": current["expert"]}, room)}
371
+ if current else None)
372
+ room["queued_turns"] = [dict(row) for row in connection.execute("""SELECT sr.id,sr.question,m.name AS expert
373
+ FROM session_requests sr JOIN room_members m ON m.id=sr.member_id
374
+ WHERE sr.session_id=? AND sr.purpose='discuss' AND sr.status='queued' ORDER BY sr.round,sr.position,sr.id""",
375
+ (active_session["id"],))]
376
+ else:
377
+ room["current_turn"] = None
378
+ room["queued_turns"] = []
248
379
  if room["mode"] == "free" and not session:
249
380
  room["round_groups"] = []
381
+ else:
382
+ room["round_groups"] = merge_round_groups(connection, room_id, room["round_groups"])
250
383
  room["members"] = [dict(row) for row in connection.execute(
251
- """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,
384
+ """SELECT m.id,m.name,m.adapter,m.role,m.status,m.access,m.session_mode,COALESCE(source.source,'') AS manual_source,COALESCE(s.session_id,'') AS reported_session,COALESCE(s.client,'') AS reported_client,
252
385
  COALESCE(r.registered_at,'') AS registered_at
253
386
  FROM room_members AS m LEFT JOIN member_sessions AS s ON s.member_id=m.id
254
- LEFT JOIN member_registrations AS r ON r.member_id=m.id AND r.token_hash=m.token_hash WHERE m.room_id=? AND m.status<>'removed' ORDER BY m.id""", (room_id,))]
255
- option = connection.execute("SELECT named_followup FROM discussion_options WHERE room_id=?", (room_id,)).fetchone()
387
+ LEFT JOIN member_registrations AS r ON r.member_id=m.id AND r.token_hash=m.token_hash
388
+ LEFT JOIN member_manual_sources AS source ON source.member_id=m.id WHERE m.room_id=? AND m.status<>'removed' ORDER BY m.id""", (room_id,))]
389
+ option = connection.execute("SELECT named_followup FROM session_options WHERE session_id=?", (session["id"],)).fetchone() if session else None
390
+ if not option:
391
+ option = connection.execute("SELECT named_followup FROM discussion_options WHERE room_id=?", (room_id,)).fetchone()
256
392
  # Rooms created before the option keep the host's named follow-ups.
257
393
  room["named_followup"] = bool(option[0]) if option else True
258
394
  room["scheduler_decisions"] = [dict(row) for row in connection.execute(
@@ -295,10 +431,58 @@ def host_arrangements(connection, room_id, decisions):
295
431
  if plans and plans[-1]["created_at"] == row["created_at"]:
296
432
  plans[-1]["turns"].append({"expert": row["expert"], "question": row["question"]})
297
433
  else:
298
- plans.append({"id": row["id"], "after_message_id": latest_answer, "created_at": row["created_at"], "turns": [{"expert": row["expert"], "question": row["question"]}]})
434
+ plans.append({"id": f"legacy-plan-{row['id']}", "key": f"legacy-plan-{row['id']}", "display_id": row["id"],
435
+ "after_message_id": latest_answer, "created_at": row["created_at"],
436
+ "turns": [{"expert": row["expert"], "question": row["question"]}]})
299
437
  latest_answer = max(latest_answer, row["answer_id"] or 0)
438
+ plans.extend(session_host_arrangements(connection, room_id))
300
439
  applied = {item["after_message_id"] for item in decisions if item["applied"]}
301
- return [plan for plan in plans if plan["after_message_id"] not in applied][-RECENT_DECISIONS:]
440
+ plans = [plan for plan in plans if plan["after_message_id"] not in applied]
441
+ plans.sort(key=lambda plan: (plan["created_at"], plan["after_message_id"], str(plan["id"])))
442
+ return plans[-RECENT_DECISIONS:]
443
+
444
+
445
+ def visible_anchor_before(connection, room_id, message_id):
446
+ """Return the last ordinary transcript message before a hidden task message."""
447
+ if not message_id:
448
+ return 0
449
+ return connection.execute("""SELECT COALESCE(MAX(m.id),0) FROM messages m WHERE m.room_id=? AND m.id<?
450
+ AND NOT EXISTS(SELECT 1 FROM task_message_sources t WHERE t.message_id=m.id)""", (room_id, message_id)).fetchone()[0]
451
+
452
+
453
+ def session_host_arrangements(connection, room_id):
454
+ """Expose request-based plans in the legacy card shape, grouped by submission batch."""
455
+ result = []
456
+ sessions = connection.execute("SELECT id FROM discussion_sessions WHERE room_id=? ORDER BY id", (room_id,))
457
+ for session_row in sessions:
458
+ session_id = session_row["id"]
459
+ rows = [dict(row) for row in connection.execute("""SELECT q.id,q.session_id,q.round,q.position,q.member_id,q.question,
460
+ q.created_at,q.submission_id,q.member_request_id,m.name AS expert,r.message_id AS task_message_id
461
+ FROM session_requests q JOIN room_members m ON m.id=q.member_id
462
+ LEFT JOIN member_requests r ON r.id=q.member_request_id
463
+ WHERE q.session_id=? AND q.purpose='discuss' AND q.submission_id NOT LIKE 'invite:%'
464
+ ORDER BY q.created_at,q.id""", (session_id,))]
465
+ submission_ids = {row["submission_id"] for row in rows}
466
+ batches = {}
467
+ for row in rows:
468
+ base_id = row["submission_id"]
469
+ match = re.match(r"^(.*)-q\d+$", base_id)
470
+ if match and match.group(1) in submission_ids:
471
+ base_id = match.group(1)
472
+ batches.setdefault((base_id, row["created_at"]), []).append(row)
473
+ for (base_id, created_at), batch in batches.items():
474
+ first = batch[0]
475
+ task_message_id = first["task_message_id"]
476
+ host_task = connection.execute("""SELECT t.message_id FROM task_message_sources t
477
+ JOIN session_requests h ON h.id=t.session_request_id
478
+ WHERE h.session_id=? AND h.purpose='discuss_host' AND t.message_id<?
479
+ ORDER BY t.message_id DESC LIMIT 1""", (session_id, task_message_id or 2 ** 63 - 1)).fetchone()
480
+ anchor_message_id = host_task["message_id"] if host_task else task_message_id
481
+ anchor = visible_anchor_before(connection, room_id, anchor_message_id)
482
+ result.append({"id": f"session-{session_id}-plan-{first['id']}", "key": f"session-{session_id}-plan-{first['id']}", "display_id": first["id"],
483
+ "after_message_id": anchor, "created_at": created_at,
484
+ "turns": [{"expert": row["expert"], "question": row["question"], "round": row["round"]} for row in batch]})
485
+ return result
302
486
 
303
487
 
304
488
  def cancelled_turns(connection, room_id):
@@ -307,15 +491,82 @@ def cancelled_turns(connection, room_id):
307
491
  The anchor is the later of the previous turn's answer and the latest message at creation time, so the card keeps its place in the transcript.
308
492
  """
309
493
  rows, latest_answer = [], 0
310
- for row in connection.execute("""SELECT id,expert,question,status,answer_id,
494
+ for row in connection.execute("""SELECT id,expert,question,status,answer_id,notified_at,created_at,
311
495
  (SELECT COALESCE(MAX(messages.id),0) FROM messages WHERE messages.room_id=discussion_turns.room_id AND messages.created_at<=discussion_turns.created_at) AS created_after
312
496
  FROM discussion_turns WHERE room_id=? ORDER BY id""", (room_id,)):
313
- if row["status"] == "cancelled":
314
- rows.append({"id": row["id"], "expert": row["expert"], "question": row["question"], "after_message_id": max(latest_answer, row["created_after"])})
497
+ if row["status"] == "cancelled" and row["notified_at"] and row["answer_id"] is None:
498
+ rows.append({"id": f"legacy-turn-{row['id']}", "key": f"legacy-turn-{row['id']}", "created_at": row["created_at"],
499
+ "display_id": row["id"], "expert": row["expert"], "question": row["question"],
500
+ "status": row["status"], "after_message_id": max(latest_answer, row["created_after"])})
315
501
  latest_answer = max(latest_answer, row["answer_id"] or 0)
502
+ rows.extend(session_cancelled_turns(connection, room_id))
503
+ rows.sort(key=lambda item: (item.get("created_at", ""), item["after_message_id"], str(item["id"])))
316
504
  return rows[-RECENT_DECISIONS:]
317
505
 
318
506
 
507
+ def session_cancelled_turns(connection, room_id):
508
+ """Return only canceled/skipped session assignments that were opened and lack an execution failure card."""
509
+ result = []
510
+ for row in connection.execute("""SELECT q.id,q.session_id,q.member_id,q.question,q.status,q.member_request_id,q.created_at,m.name AS expert,
511
+ r.message_id AS task_message_id,r.execution_id FROM session_requests q JOIN room_members m ON m.id=q.member_id
512
+ JOIN member_requests r ON r.id=q.member_request_id
513
+ WHERE q.session_id IN (SELECT id FROM discussion_sessions WHERE room_id=?) AND q.purpose='discuss'
514
+ AND q.status IN ('cancelled','skipped') AND q.member_request_id IS NOT NULL AND r.answer_id IS NULL
515
+ AND NOT EXISTS(SELECT 1 FROM member_executions e WHERE e.id=r.execution_id AND e.status='failed')
516
+ ORDER BY q.id""", (room_id,)):
517
+ result.append({"id": f"session-{row['session_id']}-request-{row['id']}", "key": f"session-{row['session_id']}-request-{row['id']}", "display_id": row["id"],
518
+ "expert": row["expert"], "question": row["question"], "status": row["status"],
519
+ "execution_id": row["execution_id"],
520
+ "after_message_id": visible_anchor_before(connection, room_id, row["task_message_id"]),
521
+ "created_at": row["created_at"]})
522
+ return result
523
+
524
+
525
+ def merge_round_groups(connection, room_id, legacy_groups):
526
+ """Combine legacy and request-based rounds without losing prior session history."""
527
+ groups = list(legacy_groups)
528
+ sessions = connection.execute("SELECT id,started_message_id,ended_message_id FROM discussion_sessions WHERE room_id=? ORDER BY id", (room_id,))
529
+ for session in sessions:
530
+ groups.extend(session_round_groups(connection, room_id, session))
531
+ return sorted(groups, key=lambda group: (group["start_id"], group["key"]))
532
+
533
+
534
+ def session_round_groups(connection, room_id, session):
535
+ """Group request-based chat answers by their stored discussion round."""
536
+ rows = connection.execute("""SELECT q.round,q.status,q.member_id,m.name AS expert,m.role,r.answer_id
537
+ FROM session_requests q JOIN room_members m ON m.id=q.member_id
538
+ LEFT JOIN member_requests r ON r.id=q.member_request_id
539
+ WHERE q.session_id=? AND q.purpose='discuss' AND r.answer_id IS NOT NULL ORDER BY q.round,q.position,q.id""", (session["id"],)).fetchall()
540
+ by_round = {}
541
+ for row in rows:
542
+ group = by_round.setdefault(row["round"], {"number": row["round"], "speakers": [], "opinions": 0,
543
+ "completed": False, "host_scheduled": False})
544
+ if row["answer_id"]:
545
+ group["opinions"] += 1
546
+ if row["expert"] not in group["speakers"]:
547
+ group["speakers"].append(row["expert"])
548
+ group["host_scheduled"] = group["host_scheduled"] or row["role"] == "host"
549
+ if not by_round:
550
+ return []
551
+ active_experts = connection.execute("""SELECT COUNT(*) FROM room_members m LEFT JOIN member_manual_sources src ON src.member_id=m.id
552
+ WHERE m.room_id=? AND m.status='active' AND m.role<>'host'
553
+ AND (m.access<>'manual' OR src.source='web' OR EXISTS(SELECT 1 FROM member_registrations reg
554
+ WHERE reg.member_id=m.id AND reg.token_hash=m.token_hash))""", (room_id,)).fetchone()[0]
555
+ previous_answers = 0
556
+ result = []
557
+ for number, group in sorted(by_round.items()):
558
+ denominator = active_experts + int(group.pop("host_scheduled"))
559
+ group["completed"] = denominator > 0 and group["opinions"] >= denominator
560
+ group["start_id"] = max(int(session["started_message_id"] or 0), previous_answers) + 1
561
+ previous_answers = connection.execute("""SELECT COALESCE(MAX(r.answer_id),0) FROM session_requests q
562
+ JOIN member_requests r ON r.id=q.member_request_id WHERE q.session_id=? AND q.purpose='discuss' AND q.round=?""",
563
+ (session["id"], number)).fetchone()[0]
564
+ result.append({**{key: group[key] for key in ("number", "speakers", "opinions", "completed", "start_id")},
565
+ "session_id": session["id"], "key": f"session-{session['id']}-round-{number}",
566
+ "end_id": session["ended_message_id"] or None, "participants": denominator})
567
+ return result
568
+
569
+
319
570
  def discussion_rounds(connection, room_id, experts):
320
571
  """Group complete history metadata until every configured expert has spoken.
321
572
 
@@ -508,8 +759,22 @@ def update_topic(connection, room_id, payload):
508
759
  """Update topic direction and participants through a declared host action."""
509
760
  begin_write(connection)
510
761
  room = require_room(connection, room_id)
762
+ if room["mode"] == "free" and "phase" in payload:
763
+ raise ValueError("自由协作房间的讨论阶段由 discussion 会话控制")
511
764
  require_host(room, payload)
512
765
  settings = topic_values(payload, room)
766
+ request_session = current_session(connection, room_id, active_only=True)
767
+ if request_session:
768
+ if "limits" in payload:
769
+ raise ValueError("新讨论的上限请通过 limits 接口修改")
770
+ if settings["host"] != room["host"] or settings["experts"] != room["experts"]:
771
+ raise ValueError("新讨论中的主持人与成员请通过成员管理操作调整")
772
+ connection.execute("UPDATE rooms SET title=?,goal=? WHERE id=?", (settings["title"], settings["goal"], room_id))
773
+ connection.execute("UPDATE topic_settings SET background=?,boundaries=?,focus=? WHERE room_id=?",
774
+ (settings["background"], settings["boundaries"], settings["focus"], room_id))
775
+ connection.execute("INSERT INTO room_user_requests (room_id,text) VALUES (?,?) ON CONFLICT(room_id) DO UPDATE SET text=excluded.text",
776
+ (room_id, settings.get("user_request", "")))
777
+ return require_room(connection, room_id)
513
778
  if room["archived"] and settings["phase"] not in ("paused", "concluded"):
514
779
  raise ValueError("请先恢复已存档房间,再调整讨论阶段")
515
780
  turn = room["current_turn"]
@@ -544,7 +809,11 @@ def list_rooms(connection, params=None):
544
809
  params = params or {}
545
810
  identifier = submission_value(params["submission_id"]) if "submission_id" in params else None
546
811
  where = "WHERE EXISTS (SELECT 1 FROM room_creations WHERE room_id=rooms.id AND submission_id=?)" if identifier else ""
547
- rows = connection.execute(f"SELECT rooms.*,COUNT(messages.id) AS message_count,COALESCE(MAX(messages.id),0) AS latest_id FROM rooms LEFT JOIN messages ON rooms.id=messages.room_id {where} GROUP BY rooms.id ORDER BY rooms.created_at, rooms.id", (identifier,) if identifier else ())
812
+ rows = connection.execute(f"""SELECT rooms.*,
813
+ COUNT(CASE WHEN NOT EXISTS (SELECT 1 FROM task_message_sources t WHERE t.message_id=messages.id) THEN messages.id END) AS message_count,
814
+ COALESCE(MAX(CASE WHEN NOT EXISTS (SELECT 1 FROM task_message_sources t WHERE t.message_id=messages.id) THEN messages.id END),0) AS latest_id
815
+ FROM rooms LEFT JOIN messages ON rooms.id=messages.room_id {where}
816
+ GROUP BY rooms.id ORDER BY rooms.created_at, rooms.id""", (identifier,) if identifier else ())
548
817
  directory = {row["room_id"]: dict(row) for row in connection.execute("SELECT * FROM room_directory")}
549
818
  assignments = {row["room_id"]: row["folder_id"] for row in connection.execute("SELECT room_id,folder_id FROM room_folder_assignments")}
550
819
  rooms = [{**dict(row), "archived": bool(directory[row["id"]]["archived"]), "folder_id": assignments[row["id"]]} for row in rows]
@@ -660,6 +929,14 @@ def manage_room(connection, room_id, action, payload):
660
929
  raise ValueError("title 内容为空或超过 80 字")
661
930
  connection.execute("UPDATE rooms SET title=? WHERE id=?", (title, room_id))
662
931
  elif action in ("archive", "restore"):
932
+ if action == "archive" and room["mode"] == "free":
933
+ session = current_session(connection, room_id, active_only=True)
934
+ if session and session["status"] == "active":
935
+ from .session_room import cancel_session_work
936
+ cancel_session_work(connection, session["id"], "房间已归档")
937
+ connection.execute("UPDATE discussion_sessions SET status='paused' WHERE id=?", (session["id"],))
938
+ connection.execute("UPDATE session_pauses SET paused_at=? WHERE session_id=?", (int(time.time()), session["id"]))
939
+ connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (session["id"],))
663
940
  if action == "archive" and room["phase"] != "concluded":
664
941
  save_topic_settings(connection, room_id, {**room, "phase": "paused"})
665
942
  if action == "archive" and room["mode"] == "free":
@@ -678,6 +955,8 @@ def manage_room(connection, room_id, action, payload):
678
955
 
679
956
  def delete_room_records(connection, room_id):
680
957
  """Remove a room's dependent records in the caller's transaction."""
958
+ connection.execute("DELETE FROM task_message_sources WHERE session_id IN (SELECT id FROM discussion_sessions WHERE room_id=?)", (room_id,))
959
+ connection.execute("DELETE FROM discussion_sessions WHERE room_id=?", (room_id,))
681
960
  for table in ("member_recommendations", "free_queue", "speaking_orders", "member_requests", "member_descriptions", "member_labels", "execution_notes", "execution_segments", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "free_handoffs", "free_settings", "free_round_limits", "free_chain_holds", "room_links",
682
961
  "discussion_options", "round_budgets", "scheduler_decisions", "scheduler_routing", "discussion_conclusions", "discussion_sessions"):
683
962
  if table == "scheduler_decisions":
@@ -711,15 +990,14 @@ def creation_request(settings):
711
990
 
712
991
  def create_room(connection, payload, control_token=""):
713
992
  """Create a credential-protected room, binding retries to the original credentials."""
714
- if payload.get("mode", "brainstorm") not in ("free", "brainstorm"):
993
+ if payload.get("mode", "free") not in ("free", "brainstorm"):
715
994
  raise ValueError("房间模式须为 free 或 brainstorm")
716
- free = payload.get("mode") == "free"
995
+ free = True
717
996
  members = member_values(payload.get("members")) if free else []
718
997
  if free:
719
- payload = {**payload, "phase": "discussing", "experts": {member["name"]: "online" if member["access"] == "online" else "cli" for member in members}}
998
+ payload = {**payload, "mode": "free", "phase": "discussing",
999
+ "experts": {member["name"]: "online" if member.get("manual_source") == "web" else "cli" for member in members}}
720
1000
  settings = topic_values(payload)
721
- if not free:
722
- members = discussion_members(payload, settings)
723
1001
  automatic = payload.get("runtime_mode") == "automatic"
724
1002
  if payload.get("runtime_mode", "manual") not in ("manual", "automatic") or not token_hash(control_token):
725
1003
  raise ValueError("建房须提供有效的运行模式和房主控制凭据")
@@ -737,7 +1015,8 @@ def create_room(connection, payload, control_token=""):
737
1015
  raise ValueError("文件夹编号必须是文本或 null")
738
1016
  request_text = creation_request(settings) + (":automatic" if automatic else "")
739
1017
  request_text += ":access:" + token_hash(control_token) + ":" + json.dumps(
740
- [[member["name"], member["adapter"], member["access"], member["session_mode"], token_hash(member["token"])] for member in members], ensure_ascii=False)
1018
+ [[member["name"], member["adapter"], member["access"], member["manual_source"],
1019
+ member["session_mode"], token_hash(member["token"])] for member in members], ensure_ascii=False)
741
1020
  if free:
742
1021
  request_text += ":free"
743
1022
  elif named_followup:
@@ -770,65 +1049,9 @@ def create_room(connection, payload, control_token=""):
770
1049
  connection.execute("INSERT INTO round_budgets (room_id) VALUES (?)", (room_id,))
771
1050
  if parent_id is not None:
772
1051
  connection.execute("INSERT INTO room_links (room_id,parent_id,created_at) VALUES (?,?,?)", (room_id, parent_id, utc_now()))
773
- if automatic:
774
- configure_runtime(connection, room_id, control_token, {"revision": 0, "mode": "automatic"})
775
1052
  return {**require_room(connection, room_id), "duplicate": False, "submission_id": identifier}
776
1053
 
777
1054
 
778
- def start_discussion(connection, room_id, token, payload):
779
- """Turn a free room into a discussion; its AI host then pushes speakers within the limits until end_discussion.
780
-
781
- The room, its members and history stay; only the discussion state (topic, limits, budget, session) is set up and the
782
- run is prepared, not started: the local service starts it once it takes the room over.
783
- """
784
- require_owner(connection, room_id, token)
785
- begin_write(connection)
786
- room = require_room(connection, room_id)
787
- if room["mode"] != "free":
788
- raise FileExistsError("房间正在讨论中;先结束当前讨论")
789
- if room["archived"]:
790
- raise ValueError("房间已存档;恢复后才能开启讨论")
791
- sync_participants(connection, room_id)
792
- active = active_members(connection, room_id, registered_only=False)
793
- host = next((row for row in active.values() if row["role"] == "host"), None)
794
- if not host or host["access"] != "runner":
795
- raise ValueError("开启讨论需要一位由本机托管的 AI 主持人;先在成员栏指定或转为自动")
796
- experts = {name: "online" if row["access"] == "online" else "cli" for name, row in active.items()}
797
- values = {key: payload[key] for key in ("goal", "background", "boundaries", "focus", "user_request", "limits") if key in payload}
798
- settings = topic_values({**values, "host": host["name"], "experts": experts, "phase": "preparing"}, {**room, "phase": "paused"})
799
- named_followup = payload.get("named_followup", False)
800
- if type(named_followup) is not bool:
801
- raise ValueError("named_followup 须为布尔值")
802
- # A fresh session first, so the phase mirror follows it and not a concluded earlier one.
803
- if not connection.execute("SELECT 1 FROM discussion_sessions WHERE room_id=? AND status IN ('active','paused')", (room_id,)).fetchone():
804
- connection.execute("INSERT INTO discussion_sessions (room_id,goal,status,started_message_id,created_at) VALUES (?,?,'paused',?,?)",
805
- (room_id, settings["goal"], room["latest_id"], utc_now()))
806
- connection.execute("UPDATE rooms SET goal=? WHERE id=?", (settings["goal"], room_id))
807
- save_topic_settings(connection, room_id, settings)
808
- connection.execute("UPDATE room_modes SET mode='brainstorm' WHERE room_id=?", (room_id,))
809
- connection.execute("INSERT INTO discussion_options (room_id,named_followup) VALUES (?,?) ON CONFLICT(room_id) DO UPDATE SET named_followup=excluded.named_followup",
810
- (room_id, int(named_followup)))
811
- connection.execute("INSERT INTO round_budgets (room_id,base) VALUES (?,?) ON CONFLICT(room_id) DO UPDATE SET base=excluded.base,cap=0,extra=0",
812
- (room_id, opinion_count(connection, room_id, experts)))
813
- connection.execute("UPDATE discussion_limits SET started_at=0,stop_reason='',revision=revision+1 WHERE room_id=?", (room_id,))
814
- connection.execute("UPDATE discussion_pauses SET paused_seconds=0,paused_at=0 WHERE room_id=?", (room_id,))
815
- # Switching the run to automatic converts manual members that carry an adapter; a manual expert of a woken discussion is marked and stays manual.
816
- connection.execute("UPDATE room_members SET adapter='manual' WHERE room_id=? AND access='manual' AND status='active'", (room_id,))
817
- configure_runtime(connection, room_id, token, {"revision": runtime_view(connection, room_id)["revision"], "mode": "automatic"})
818
- return require_room(connection, room_id)
819
-
820
-
821
- def end_discussion(connection, room_id, token, payload):
822
- """Conclude the discussion for good and return the room to free collaboration; history and conclusion stay."""
823
- require_owner(connection, room_id, token)
824
- begin_write(connection)
825
- room = require_room(connection, room_id)
826
- if room["mode"] == "free":
827
- raise FileExistsError("房间没有进行中的讨论")
828
- return_to_free(connection, room)
829
- return require_room(connection, room_id)
830
-
831
-
832
1055
  def return_to_free(connection, room):
833
1056
  """End the discussion in place: stop its run and turns, switch back to free collaboration and route held user messages."""
834
1057
  room_id = room["id"]
@@ -1039,16 +1262,23 @@ def append_free_message(connection, room, payload, actor):
1039
1262
  The owner may also forward an online member's answer; it keeps the member as author and the
1040
1263
  owner as submitter.
1041
1264
  """
1265
+ if payload.get("kind") == "moderation":
1266
+ return append_free_moderation(connection, room, payload, actor)
1267
+ if payload.get("kind") not in (None, "chat"):
1268
+ raise ValueError("自由协作与新讨论使用 kind=chat,并以 execution_id 或 request_ids 关联请求")
1269
+ if payload.get("turn_id") is not None and current_session(connection, room["id"], active_only=True):
1270
+ raise ValueError("新讨论使用 kind=chat,并以 execution_id 或 request_ids 关联请求")
1042
1271
  author, text, submission_id, reply_to = message_values(payload)
1043
1272
  room_id = room["id"]
1044
- proxy = None
1273
+ proxy = actor if isinstance(actor, dict) and actor.get("proxy_by") else None
1045
1274
  if actor == "用户" and author != "用户":
1046
1275
  proxy = proxy_member(connection, room_id, author)
1047
1276
  if payload.get("submitted_by", "用户") != "用户":
1048
1277
  raise PermissionError("代发在线成员的回答须由用户提交")
1049
- submitted_by = "用户" if proxy else author
1278
+ proxy_by = proxy.get("proxy_by") if proxy else None
1279
+ submitted_by = proxy_by or ("用户" if proxy else author)
1050
1280
  expected = {"author": author, "text": text, "reply_to": reply_to, "kind": "chat", "submitted_by": submitted_by,
1051
- "turn_id": None, "source": "proxy" if proxy else ("user" if author == "用户" else "api" if payload.get("source") == "api" else "cli")}
1281
+ "turn_id": None, "source": ("host_proxy" if proxy_by else "proxy") if proxy else ("user" if author == "用户" else "api" if payload.get("source") == "api" else "cli")}
1052
1282
  expected.update(message_summary(payload, expected))
1053
1283
  identifiers = attachment_ids(payload)
1054
1284
  previous = connection.execute(f"{MESSAGE_QUERY} WHERE room_id=? AND submission_id=?", (room_id, submission_id)).fetchone()
@@ -1076,7 +1306,59 @@ def append_free_message(connection, room, payload, actor):
1076
1306
  connection.execute("UPDATE attachments SET message_id=?,position=? WHERE id=?", (cursor.lastrowid, position, identifier))
1077
1307
  save_suggestions(connection, room_id, cursor.lastrowid, proxy or actor, payload)
1078
1308
  after_free_message(connection, room_id, cursor.lastrowid, proxy or actor, payload)
1079
- return {"message": get_message(connection, room_id, cursor.lastrowid), "duplicate": False}
1309
+ complete_session_requests(connection, room_id, payload, proxy or actor, cursor.lastrowid)
1310
+ saved_message = connection.execute(f"{MESSAGE_QUERY} WHERE messages.id=?", (cursor.lastrowid,)).fetchone()
1311
+ return {"message": message_with_attachments(connection, saved_message), "duplicate": False}
1312
+
1313
+
1314
+ def append_free_moderation(connection, room, payload, actor):
1315
+ """Save a host note against its live host task without answering or routing it."""
1316
+ author, text, submission_id, reply_to = message_values(payload)
1317
+ if author != room["host"] or payload.get("submitted_by", author) != author:
1318
+ raise ValueError("主持说明须保留当前主持人署名")
1319
+ if not isinstance(actor, dict) or actor.get("role") != "host" or actor.get("name") != room["host"]:
1320
+ raise PermissionError("主持说明只能由当前主持成员提交")
1321
+ if payload.get("to") or payload.get("handoff") or payload.get("turn_id") is not None:
1322
+ raise ValueError("主持说明不能派活或关联发言轮次")
1323
+ if actor["access"] == "manual":
1324
+ ids = payload.get("request_ids")
1325
+ if payload.get("execution_id") is not None or not isinstance(ids, list) or not ids:
1326
+ raise ValueError("手动主持说明须关联有效的主持请求 request_ids")
1327
+ check_answer(connection, room["id"], actor, payload)
1328
+ rows = connection.execute("SELECT purpose FROM member_requests WHERE room_id=? AND id IN (%s)" % ",".join("?" for _ in ids),
1329
+ (room["id"], *ids)).fetchall()
1330
+ if len(rows) != len(set(ids)) or any(row["purpose"] != "discuss_host" for row in rows):
1331
+ raise ValueError("主持说明只能关联当前有效的主持请求")
1332
+ else:
1333
+ if payload.get("request_ids"):
1334
+ raise ValueError("自动主持说明须关联执行编号 execution_id")
1335
+ execution = check_answer(connection, room["id"], actor, payload)
1336
+ purposes = [row[0] for row in connection.execute(
1337
+ "SELECT purpose FROM member_requests WHERE execution_id=? AND status='delivering'", (execution["id"],))]
1338
+ if not purposes or any(purpose != "discuss_host" for purpose in purposes):
1339
+ raise ValueError("主持说明只能关联当前有效的主持执行")
1340
+ expected = {"author": author, "text": text, "reply_to": reply_to, "kind": "moderation",
1341
+ "submitted_by": author, "turn_id": None, "source": payload.get("source") if payload.get("source") in ("cli", "api") else "cli"}
1342
+ expected.update(message_summary(payload, expected))
1343
+ identifiers = attachment_ids(payload)
1344
+ previous = connection.execute(f"{MESSAGE_QUERY} WHERE room_id=? AND submission_id=?", (room["id"], submission_id)).fetchone()
1345
+ if previous:
1346
+ previous = message_with_attachments(connection, previous)
1347
+ if any(previous[key] != expected[key] for key in ("author", "text", "reply_to", "kind", "submitted_by", "summary", "summary_by")) or [item["id"] for item in previous["attachments"]] != identifiers:
1348
+ raise FileExistsError("这个提交编号已被另一条内容使用,请为新发言生成新编号")
1349
+ return {"message": previous, "duplicate": True}
1350
+ check_message_references(connection, room["id"], expected)
1351
+ check_message_attachments(connection, room["id"], author, identifiers)
1352
+ cursor = connection.execute("INSERT INTO messages (room_id,author,text,reply_to,source,submission_id,created_at) VALUES (?,?,?,?,?,?,?)",
1353
+ (room["id"], author, text, reply_to, expected["source"], submission_id, utc_now()))
1354
+ connection.execute("INSERT INTO message_details (message_id,kind,submitted_by,turn_id) VALUES (?,?,?,NULL)",
1355
+ (cursor.lastrowid, "moderation", author))
1356
+ connection.execute("INSERT INTO message_summaries (message_id,text,author) VALUES (?,?,?)",
1357
+ (cursor.lastrowid, "", ""))
1358
+ for position, identifier in enumerate(identifiers):
1359
+ connection.execute("UPDATE attachments SET message_id=?,position=? WHERE id=?", (cursor.lastrowid, position, identifier))
1360
+ saved_message = connection.execute(f"{MESSAGE_QUERY} WHERE messages.id=?", (cursor.lastrowid,)).fetchone()
1361
+ return {"message": message_with_attachments(connection, saved_message), "duplicate": False}
1080
1362
 
1081
1363
 
1082
1364
  def record_reported_session(connection, room_id, token, payload):
@@ -1104,7 +1386,7 @@ def append_message(connection, room_id, payload, token="", free_actor=None):
1104
1386
  if room["mode"] == "free" or free_actor is not None:
1105
1387
  previous = connection.execute(f"{MESSAGE_QUERY} WHERE room_id=? AND submission_id=?", (room_id, submission_id)).fetchone()
1106
1388
  # An exact retry of a discussion message saved before the discussion ended keeps its original result.
1107
- if previous and previous["kind"] != "chat" and (previous["author"], previous["text"]) == (author, text):
1389
+ if previous and previous["kind"] not in ("chat", "moderation") and (previous["author"], previous["text"]) == (author, text):
1108
1390
  return {"message": message_with_attachments(connection, previous), "duplicate": True}
1109
1391
  return append_free_message(connection, room, payload, free_actor)
1110
1392
  details = message_details(room, {**payload, "author": author})
@@ -1147,7 +1429,8 @@ def append_message(connection, room_id, payload, token="", free_actor=None):
1147
1429
  ask_members(connection, room, cursor.lastrowid, asked)
1148
1430
  if details["turn_id"]:
1149
1431
  connection.execute("UPDATE discussion_turns SET status='answered',answer_id=? WHERE id=?", (cursor.lastrowid, details["turn_id"]))
1150
- return {"message": get_message(connection, room_id, cursor.lastrowid), "duplicate": False}
1432
+ saved_message = connection.execute(f"{MESSAGE_QUERY} WHERE messages.id=?", (cursor.lastrowid,)).fetchone()
1433
+ return {"message": message_with_attachments(connection, saved_message), "duplicate": False}
1151
1434
 
1152
1435
 
1153
1436
  def get_turn(connection, room_id, turn_id):
@@ -1411,6 +1694,11 @@ def check_limits(connection, room_id, payload):
1411
1694
  if action == "reopen":
1412
1695
  return reopen_limits(connection, room_id, payload)
1413
1696
  begin_write(connection)
1697
+ if current_session(connection, room_id, active_only=True):
1698
+ if action == "done":
1699
+ raise ValueError("讨论会话须由房主通过 discussion 接口结束")
1700
+ enforce_session_limits(connection, room_id)
1701
+ return require_room(connection, room_id)
1414
1702
  room = require_room(connection, room_id)
1415
1703
  require_host(room, payload)
1416
1704
  limits = room["limits"]
@@ -1455,6 +1743,7 @@ def message_filters(room_id, params):
1455
1743
  mention = str(params.get("mention", "")).strip()
1456
1744
  clauses = ["room_id=?"]
1457
1745
  values = [room_id]
1746
+ clauses.append("NOT EXISTS (SELECT 1 FROM task_message_sources task WHERE task.message_id=messages.id)")
1458
1747
  if query:
1459
1748
  clauses.append("(instr(lower(messages.text), lower(?)) > 0 OR CAST(messages.id AS TEXT)=?)")
1460
1749
  values.extend([query, query.lstrip("#")])
@@ -1483,11 +1772,15 @@ def message_filters(room_id, params):
1483
1772
  return " AND ".join(clauses), values
1484
1773
 
1485
1774
 
1486
- def query_messages(connection, room_id, params):
1775
+ def query_messages(connection, room_id, params, token=""):
1487
1776
  """Read one bounded page; incremental cursors advance only through returned rows."""
1488
1777
  require_room(connection, room_id)
1489
1778
  page = max(1, int(params.get("page", 1)))
1779
+ cutoff = session_message_access(connection, room_id, params, token)
1490
1780
  where, values = message_filters(room_id, params)
1781
+ if cutoff is not None:
1782
+ where += " AND messages.id<=?"
1783
+ values.append(cutoff)
1491
1784
  total = connection.execute(f"SELECT COUNT(*) FROM ({MESSAGE_QUERY} WHERE {where})", values).fetchone()[0]
1492
1785
  rows = connection.execute(f"{MESSAGE_QUERY} WHERE {where} ORDER BY messages.id LIMIT ? OFFSET ?", [*values, PAGE_SIZE + 1, (page - 1) * PAGE_SIZE]).fetchall()
1493
1786
  messages = [message_with_attachments(connection, row) for row in rows[:PAGE_SIZE]]
@@ -1498,9 +1791,15 @@ def query_messages(connection, room_id, params):
1498
1791
  return result
1499
1792
 
1500
1793
 
1501
- def get_message(connection, room_id, message_id):
1794
+ def get_message(connection, room_id, message_id, params=None, token=""):
1502
1795
  """Read an exact message, constrained to the specified room."""
1503
- row = connection.execute(f"{MESSAGE_QUERY} WHERE room_id=? AND messages.id=?", (room_id, message_id)).fetchone()
1796
+ params = params or {}
1797
+ cutoff = session_message_access(connection, room_id, params, token)
1798
+ where, values = message_filters(room_id, params)
1799
+ if cutoff is not None:
1800
+ where += " AND messages.id<=?"
1801
+ values.append(cutoff)
1802
+ row = connection.execute(f"{MESSAGE_QUERY} WHERE {where} AND messages.id=?", [*values, message_id]).fetchone()
1504
1803
  if row is None:
1505
1804
  raise LookupError("消息不存在")
1506
1805
  return message_with_attachments(connection, row)
@@ -1525,6 +1824,11 @@ def update_state(connection, room_id, payload):
1525
1824
  connection.execute("""INSERT INTO room_briefs (room_id,text,until,updated_at) VALUES (?,?,?,?)
1526
1825
  ON CONFLICT(room_id) DO UPDATE SET text=excluded.text,until=excluded.until,updated_at=excluded.updated_at""",
1527
1826
  (room_id, brief, until, utc_now()))
1827
+ session = current_session(connection, room_id, active_only=True)
1828
+ if session:
1829
+ held_since = connection.execute("SELECT held_since FROM session_state WHERE session_id=?", (session["id"],)).fetchone()[0]
1830
+ if held_since and until >= held_since:
1831
+ connection.execute("UPDATE session_state SET held_since=0,revision=revision+1 WHERE session_id=?", (session["id"],))
1528
1832
  return require_room(connection, room_id)
1529
1833
 
1530
1834
 
@@ -1542,7 +1846,8 @@ def message_text(message):
1542
1846
  summary = f"\n回答摘要({message['summary_by']}):\n{message['summary']}\n\n完整正文:\n" if message.get("summary") else "\n"
1543
1847
  attachments = "".join(f"\n附件:{item['file_name']}({item['size']} 字节;ID: {item['id']})\n{item['download_url']}"
1544
1848
  for item in message.get("attachments", []))
1545
- return f"#{message['id']} · {message['author']} · {message['created_at']}{reply}\n职责:{role};来源:{message['source']};提交者:{message.get('submitted_by', '用户')};安排:{message.get('turn_id') or '无'}{summary}{message['text']}{attachments}"
1849
+ turn_link = f" · 安排详情:/r/{message['room_id']}/turns/{message['turn_id']}?legacy=1" if message.get("turn_id") else ""
1850
+ return f"#{message['id']} · {message['author']} · {message['created_at']}{reply}\n职责:{role};来源:{message['source']};提交者:{message.get('submitted_by', '用户')};安排:{message.get('turn_id') or '无'}{turn_link}{summary}{message['text']}{attachments}"
1546
1851
 
1547
1852
 
1548
1853
  def room_text(connection, room_id, params):
@@ -1554,10 +1859,23 @@ def room_text(connection, room_id, params):
1554
1859
  f"当前记录截止 #{room['latest_id']};共 {room['message_count']} 条;已完成 {room['completed_rounds']} 轮讨论(所有参与专家各发言一次)。"]
1555
1860
  limits = room["limits"]
1556
1861
  content.append(f"讨论上限:{limits['max_rounds'] or '不限'} 轮;总时长 {limits['max_minutes'] or '不限'} 分钟;单次自动执行 {str(limits['execution_minutes']) + ' 分钟' if limits['execution_minutes'] else '不限时长'};到限后讨论结束,房间回到自由协作。到限:{limits['reached']}。")
1862
+ participant = params.get("participant", params.get("for"))
1557
1863
  if room["mode"] == "free":
1558
1864
  members = ";".join(f"{item['name']}(已读至 #{item['read_until']},待投递 {item['pending']})" for item in room["free"]["members"])
1559
- content.append(f"自由协作房间:无发言权、安排与轮次。成员:{members or '无'}。用户的消息点名成员后生成请求;成员回答须带本次执行编号,执行结束、被打断或过期后的回答会被拒绝,不能改作普通发言。")
1560
- content.extend(["", "## 读取", f"完整历史(分页):{base}/messages?page=1", f"单条原文:{base}/messages/消息编号"])
1865
+ request_session = current_session(connection, room_id, active_only=True)
1866
+ if request_session:
1867
+ current = room.get("current_turn")
1868
+ assignment = f"当前安排 #{current['id']}:{current['expert']}" if current else "当前没有专家安排"
1869
+ content.append(f"讨论会话处于{PHASES[room['phase']]};{assignment}。其余成员安排按队列执行。成员:{members or '无'}。专家回答须关联本次执行或手动请求,已结束的执行或失效请求不能改作普通发言。")
1870
+ else:
1871
+ content.append(f"自由协作房间:无发言权、安排与轮次。成员:{members or '无'}。用户的消息点名成员后生成请求;成员回答须带本次执行编号,执行结束、被打断或过期后的回答会被拒绝,不能改作普通发言。")
1872
+ content.extend(["", "## 按需展开", f"背景、边界与参与专家:{base}/brief",
1873
+ f"共识与分歧(整理至 #{room['state_until']}):{base}/state",
1874
+ "## 读取与检索", f"完整历史(分页):{base}/messages?page=1",
1875
+ f"单条原文:{base}/messages/消息编号", f"检索:{base}/search?q=关键词&page=1",
1876
+ f"按作者检索:{base}/search?author=Claude", f"发言安排历史:{base}/turns?page=1"])
1877
+ if participant:
1878
+ content.append(f"与 @{participant} 相关的观点:{base}/search?mention={participant}&page=1")
1561
1879
  content.extend(participation_text(params, free=True))
1562
1880
  return "\n\n".join(content)
1563
1881
  turn = room["current_turn"]
@@ -1577,8 +1895,8 @@ def room_text(connection, room_id, params):
1577
1895
  content.extend(["## 按需展开", "请自主选择需要的材料,不必读取全部历史。",
1578
1896
  f"背景、边界与参与专家:{base}/brief",
1579
1897
  f"共识与分歧(整理至 #{room['state_until']}):{base}/state"])
1580
- if params.get("participant"):
1581
- content.append(f"与 @{params['participant']} 相关的观点:{base}/search?mention={params['participant']}&page=1")
1898
+ if participant:
1899
+ content.append(f"与 @{participant} 相关的观点:{base}/search?mention={participant}&page=1")
1582
1900
  content.extend(["", "## 读取与检索", f"完整历史(分页):{base}/messages?page=1",
1583
1901
  f"单条原文:{base}/messages/消息编号", f"检索:{base}/search?q=关键词&page=1",
1584
1902
  f"按作者检索:{base}/search?author=Claude", f"发言安排历史:{base}/turns?page=1"])
@@ -1611,49 +1929,40 @@ def brief_text(room):
1611
1929
  def turn_text(turn, room):
1612
1930
  """Render a speaking assignment without embedding discussion context."""
1613
1931
  room_id = room["id"]
1614
- answer = f"\n回答原文:/r/{room_id}/messages/{turn['answer_id']}" if turn["answer_id"] else ""
1615
- return f"安排 #{turn['id']} · {turn['expert']} · {turn_status_label(turn, room)}\n待回应问题:{turn['question']}\n话题入口:/r/{room_id}?participant={turn['expert']}\n回答关联 turn_id:{turn['id']}{answer}\n回答附简短摘要及完整正文,保留关键条件和不确定性。\n已接入的本地 CLI 专家可自行检查安排;在线专家需联系。复制通知不代表已送达;仅实际通知后由主持人确认。"
1932
+ answer_id = turn.get("answer_id")
1933
+ answer = f"\n回答原文:/r/{room_id}/messages/{answer_id}" if answer_id else ""
1934
+ status = {"open": "pending"}.get(turn["status"], turn["status"])
1935
+ label = turn.get("status_label") or (turn_status_label({**turn, "status": status}, room)
1936
+ if status in TURN_STATUSES else {"failed": "回答失败", "active": "进行中"}.get(status, status))
1937
+ association = f"关联请求:{turn.get('member_request_id') or '尚未派发'}" if turn.get("session_id") else f"历史安排:{turn['id']}"
1938
+ return f"安排 #{turn['id']} · {turn['expert']} · {label}\n待回应问题:{turn['question']}\n话题入口:/r/{room_id}?participant={turn['expert']}\n{association}{answer}\n本地成员凭自己的请求或执行保存完整回答,较长回答可附摘要;旧安排仅供历史阅读。"
1616
1939
 
1617
1940
 
1618
1941
  def protocol_text(room):
1619
- """Describe explicit CLI and host operations separately from topic content."""
1942
+ """Describe the unified request/execution protocol separately from discussion material."""
1620
1943
  base = f"/api/rooms/{room['id']}"
1621
- if room["mode"] == "free":
1622
- return "\n\n".join([
1623
- "# 自由协作参与协议", f"当前主持人:{room['host']}。没有讨论发言权、正式安排或轮次。",
1624
- "先用 room view 读取成员、职责和 free.requests,再按需读取 message list / message view 原文;通知只给对象、问题和入口。",
1625
- "用户写入和成员管理须房主控制凭据;本地成员写入须本人的 --access-file,不能借用用户或其他成员身份。凭据文件私下交付,不公开 token。",
1626
- "runner 成员等待本机接入程序交付有效任务,不另建定时。用 message post --as 本人 --kind chat --execution 本次编号 --access-file FILE 回答;不执行 execution enter。执行失效后保留草稿,不能去掉编号改作普通发言。",
1627
- "manual 成员可由人工或软件原生定时触发,用本人成员文件自行提交。回答带 --request 请求编号(可重复)关闭自己的请求,不带则为普通发言。不得编造 execution 编号。",
1628
- "online 成员只读,在原聊天回答,由用户以房主凭据代发原文;作者仍是在线成员、提交者为用户,不能用成员凭证自行写入。复制说明不表示已送达或接入。",
1629
- "获授 host 职责的本地成员可在回答中用 --to 点名其他成员,不能点名自己或扩大权限;route 表示分配任务,handoff 表示派出的成员已结束。链上 AI 请求受房间预算限制,到限等待用户。",
1630
- "普通成员的 --to 或正文 @ 只保存为回应建议,不唤醒;代码与引用中的正文 @ 不算。房主在网页转为请求后建立关联原消息的新用户任务链,重复点击不重复派发。",
1631
- f"附件使用 attachment upload/download;上传使用本人成员文件,消息带 attachment_ids。原始接口为 {base}/attachments 与 {base}/messages,自动成员在回答完成前上传。",
1632
- "每次新内容用新 submission_id,同次重试沿用编号、原文、凭据和执行/请求关联;失败先核对服务端结果,不换身份或编号绕过。用户打断、成员离开或移除后停止本次工作。",
1633
- ])
1634
- return "\n\n".join([
1635
- "# 本地 CLI 读写与主持协议", f"当前主持人:{room['host']}。以下为留言板接口,不是模型服务 API。",
1636
- "API 参与者由本机 runner 使用相同房间授权和执行约束提交,消息 source=api;CLI 仍使用 source=cli。兼容字段 experts 中的 cli 表示可自行提交,实际接入方式见成员 adapter。",
1637
- "写入使用 POST,通常为 Content-Type: application/json;附件上传使用 application/octet-stream 原始字节。主持操作填写 actor 为当前主持人或用户;这些是声明身份,不是认证凭据。",
1638
- f"附件:POST {base}/attachments?actor=身份&file_name=文件名&submission_id=新编号,原文件最多 20 MiB;GET 同路径默认列已发送附件,加 submission_id 精确核对上传。GET {base}/attachments/ID 读元数据,/ID/content 下载原文件。消息可带 attachment_ids(最多 10 个有序编号);只关联同房间、同提交者的未使用附件,可只有附件无正文。自动任务须在回答完成前上传;消息查询包含附件,按需下载后由当前软件读取,不自动解析。",
1639
- "讨论上限:room.limits 返回轮次、总时长、单次执行和实际进度。准备/暂停时通过 topic 的 limits 对象修改;0 表示不预设轮次/总时长/单次执行时限。POST /api/rooms/ROOM/limits(actor)或 room limits ROOM --check 检查到限处理;到限后讨论结束并停止新专家安排与观点,房间回到自由协作;暂停期间不计时也不消耗总时长。",
1640
- "运行方式:room.runtime 显示 manual/automatic、状态及真实执行回执。所有房间写入须带 X-Discussion-Token,CLI 用 --access-file 读取私有文件。用户及运行控制使用房主凭据,手动本地成员使用自己的成员凭证,不能相互冒充;在线成员只读,由房主或讨论主持人代发。自动讨论先 execution enter,创建安排或保存关联回答即完成本步,不创建原生定时任务;本机唤醒成员的持久凭证不能替代自动执行,手动接入的专家用自己的成员凭证回答安排,暂停、停止、换代、租约到期后的旧执行失效。run view/start/pause/stop/configure 字段查 CLI schema。",
1641
- "没有发言权:同一话题同时只有一个未完成的发言安排,被安排的专家直接回答它;专家观点自动回答该专家当前有效的安排,turn_id 可省略(填写则须与之相符),没有轮到你时被拒绝。主持发言(moderation)只能由当前主持人署名,任何时候都可发出;创建、取消安排由主持人或用户操作。用户的 @ 直接提问不占安排也不计轮次。失败的写入不会改变安排状态,同编号重试沿用原内容。",
1642
- '创建话题:POST /api/rooms,携带房主控制凭据;字段 title、goal、user_request、background、boundaries、host、experts、members、submission_id(新 UUID)。members 与 experts 名单一致,各项含 name、独立 token、access(手动本地 manual、自动 runner、在线 online)。AI 主持人必须为本地可写成员。CLI room create --access-file 私有房主文件 在请求前保存凭据,确认后返回各本地成员文件路径;同编号重试沿用原资料和全部凭据。名单、主持职责变更仅房主可做。',
1643
- f"查询提交:GET /api/rooms?submission_id=编号 查建房结果;GET {base}/messages?submission_id=编号 或 {base}/turns?submission_id=编号 查消息或安排。查询成功且列表为空表示尚未查到,读取失败不表示未写入。",
1644
- f"增量读取:GET {base}/messages?after=已读消息编号,每次最多 20 条,按 next_after 续读,has_more 表示还有一批;可加 kind=opinion/moderation/chat、author、mention、q(旧房间里的历史回执 kind=receipt 仍可读取)。after 不与翻页混用,过滤后的游标只代表该查询范围。",
1645
- f"维护话题:POST {base}/topic;actor 加需要修改的上述字段,另可更新 phase(preparing/initial/discussing/paused/concluded)、focus。收束前先完成或取消当前安排;主持人收束还须先 GET {base}/conclusion 取得 blockers 与 revision,并把 revision 作为 conclusion 一并提交(有阻碍项或状态已变会被拒绝)。暂停保留安排;恢复阶段后再继续。",
1646
- 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)。",
1647
- f"通知:仅把对象、问题、话题入口交给对应专家,不附背景、摘要或历史。使用已有且获准的会话通道;没有通道时交由用户协助。实际送达后 POST {base}/turns/安排编号,字段 actor、status=notified;取消用 status=cancelled。复制不算送达。",
1648
- 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 时不自动生成。",
1649
- f"主持发言:同一消息接口,author={room['host']}、submitted_by={room['host']}、kind=moderation、source=cli;个人观点使用 kind=opinion,不能自动视为共识。",
1650
- f"代发只读专家观点:author 保留原专家,submitted_by={room['host']}、kind=opinion、text 为原文;须关联 turn_id,可附 reply_to。服务保存代发来源;不要冒充专家直接提交。该专家须有当前有效的安排。",
1651
- f"整理状态:POST {base}/state;actor、text(详细状态,最多 {MAX_STATE_LENGTH} 字)、until(依据的消息截止编号);brief(最多 {MAX_BRIEF_LENGTH} 字)为面向人的主持简报,说明结论、共识、歧义和下一步意图。进度或安排变化后更新;省略 brief 保留旧简报,空字符串清除。所有参与专家各保存一次完整观点才完成一轮;同轮重复发言只计该专家一次,主持说明、用户留言及取消安排不推进轮次。房间 rounds 按当前配置专家和完整历史生成,completed_rounds 为完成轮数,answered_turns 仅为已回答安排数。状态不覆盖原文,也不代表全体一致同意。",
1652
- "讨论方法:initial 先独立给出结论、依据、假设和风险,不回应他人;discussing 围绕具体分歧引用原文,说明维持或修正及原因;拟定结论时检查是否误述自己、遗漏条件或仍有阻碍。沉默不算同意。",
1653
- "后台参与:先立即检查,再用本会话真实可用的原生任务按约定频率和自身运行上限检查。专家只答自己未完成的安排,无任务时等待;主持人仍需处理新回答、网页回复和下一安排。paused/concluded、用户叫停或达到自身上限时停止自己的任务并核验,实际结果保留在本地接续信息;单个专家任务停止不等于整场讨论结束。失败时保留未提交原文,重新读取阶段后再决定是否重试。",
1654
- "收束:结论须有原文依据,区分明确共识、未决分歧与主持人判断;完成约定复核或达到已说明的时间/轮次上限后收束。讨论完成与后台任务退出分别核验;不为追求全员确认无限续轮。",
1655
- "房间、消息与安排都支持 submission_id 去重,同次重试沿用编号和原始内容,新内容使用新编号。写入后重新读取服务器返回的结果,核对身份、内容与引用。",
1656
- ])
1944
+ if room["mode"] != "free":
1945
+ return "# 旧讨论历史\n\n旧讨论已只读保留;如需继续,由用户在协作房间开启新讨论。"
1946
+ paragraphs = [
1947
+ "# 协作房间参与协议",
1948
+ "当前主持人:HOST。讨论是房间内的会话;自由协作请求与讨论请求共用成员执行。",
1949
+ "先用 room view 读取成员、职责、free.requests、session 与阶段,再按需读取原文。任务中的 purpose=discuss 表示讨论发言,discuss_host 表示主持任务,ask 表示直接提问;内部任务来源不是普通发言。",
1950
+ "用户管理和讨论开关使用房主凭据;本地成员使用本人的 --access-file,不借用其他身份。凭据私下交付,不公开 token。API 参与者遵循相同请求与执行约束,source=api;本地 CLI 使用 source=cli。",
1951
+ "runner 成员等待本机接入程序交付任务,用 execution view 核对本次执行和请求,再以 message post --as 本人 --kind chat --execution 本次编号 --access-file FILE 回答。执行失效后保留草稿,不能去掉编号改作普通发言;认领、续租与后续投递由接入程序处理。",
1952
+ "manual 本地成员首次用 member register 注册,会话 ID 由 CLI 自动取得;回答用 --request 请求编号,不编造执行编号。讨论不使用原生定时;原会话不能托管时由用户触发。网页成员只读,用户或当前主持人在核对真实回答后以本人身份、--author 原专家和 --request 关联请求代发。",
1953
+ "自由协作中的 host 可用 --to 点名成员,route 表示未点名的用户请求,handoff 表示派出任务已结束;链上 AI 派活受房间预算限制。普通成员的 --to 或正文 @ 只保存回应建议,不直接唤醒;不点名自己、不扩大授权。",
1954
+ "讨论中的用户 @ 直接提问独立排队,不计讨论轮次、不与讨论发言合并。initial 阶段先独立判断;服务端按讨论请求的 read_until 限制读取。直接提问和主持任务不继承该阅读限制。",
1955
+ "主持安排前先 turn roster 读取在场专家、waiting、unregistered 和 revision,再 turn create --roster revision;单项和多项都要带凭据,多项计划须覆盖本轮 waiting。继续排队同样先读 roster,再 turn continue --roster revision。状态或新消息变化后旧凭据被拒绝,重新读取再判断;每个回答保存后由程序推进下一项。",
1956
+ "计划或收束成功即完成本次主持执行,无需再提交回答回执。主持说明使用 kind=moderation,不替代专家回答,也不会完成主持执行;主持人作为专家发言须回答自己的 discuss 请求。",
1957
+ "讨论状态读取 GET BASE/discussion;房主先读取 revision,再 POST 同路径提交 start、pause、resume 或 end。CLI 对应 run view/start/pause/resume/stop。开启可携带 submission_id,原编号与原内容重试返回原会话,已结束的会话不会因重试恢复。",
1958
+ "room limits 返回轮次、时长和进度;到限立即结束讨论并回到自由协作,不再唤醒主持人总结。暂停不计时,并作废讨论请求和执行;恢复生成新任务,旧执行不得继续写。",
1959
+ "原会话送达失败可等待 60 秒后自动重试,最多 3 次;重试用尽只失败该请求,并交回主持人判断跳过、重排或暂停,不自动暂停整场。结果不明的写入不作为新提交重发。",
1960
+ "保存 state 与主持简报时,until 只覆盖实际已读原文。主动收束前先保存有依据的总结与状态,再 room conclusion 获取 blockers 和 revision,无阻碍后用 room limits --done --conclusion revision;有未处理内容或状态变化时拒绝收束。",
1961
+ "message list --after 按 next_after/has_more 增量读取;message view 核对原文;历史可筛选 opinion/moderation/chat。内部任务不出现在普通消息列表、搜索和统计中,也没有普通发言的撤回或编辑入口。",
1962
+ "附件用 attachment upload/download,最多 10 个、单个 20 MiB;自动成员在完成执行前上传。回答正文最多 32000 个 Unicode 字符,摘要最多 1000 字,较长回答才附摘要,完整原文不截断。",
1963
+ "每次新内容使用新 submission_id,同次重试沿用编号、原文、凭据和执行/请求关联。按 submission_id 查询持久结果;读取失败不表示未写入,不能换身份或编号绕过拒绝。"
1964
+ ]
1965
+ return "\n\n".join(paragraphs).replace("HOST", room["host"]).replace("BASE", base)
1657
1966
 
1658
1967
 
1659
1968
  class DiscussionServer(ThreadingHTTPServer):
@@ -1919,16 +2228,15 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1919
2228
  return self.run_local_command(command, payload)
1920
2229
 
1921
2230
  def save_local_room_configuration(self, command, payload):
1922
- """Keep pause/revision validation and local file replacement ahead of any runtime write."""
2231
+ """Keep session pause/revision validation and local file replacement in one boundary."""
1923
2232
  with closing(connect_db(self.server.database)) as connection, connection:
1924
2233
  begin_write(connection)
1925
2234
  room = require_room(connection, payload.get("room_id", ""))
1926
- runtime = room["runtime"]
1927
- if not runtime["protected"]:
1928
- raise PermissionError("本场配置修正需要原控制凭据")
1929
- require_control(connection, room["id"], payload.get("token", ""))
1930
- if (room["archived"] or room["phase"] == "concluded" or runtime["mode"] != "automatic"
1931
- or runtime["status"] != "paused" or runtime["revision"] != payload.get("runtime_revision")):
2235
+ require_owner(connection, room["id"], payload.get("token", ""))
2236
+ discussion = session_room(connection, room["id"])
2237
+ revision = payload.get("session_revision", payload.get("runtime_revision"))
2238
+ if (room["archived"] or not discussion["session"] or discussion["session"]["status"] != "paused"
2239
+ or discussion["revision"] != revision):
1932
2240
  raise FileExistsError("房间状态已变化,请暂停后重新读取本场配置")
1933
2241
  return self.run_local_command(command, payload, configuration_locked=True)
1934
2242
 
@@ -2072,13 +2380,14 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2072
2380
  record_reported_session(connection, room_id, token, {"session_id": self.headers.get("X-Discussion-Session"),
2073
2381
  "session_client": self.headers.get("X-Discussion-Session-Client")})
2074
2382
  connection.commit()
2075
- if parts[3:] == ["discussion"] and method == "POST":
2076
- body = self.read_payload()
2077
- if body.get("action") not in ("start", "end"):
2078
- raise ValueError("action 须为 start 或 end")
2079
- result = (start_discussion if body["action"] == "start" else end_discussion)(connection, room_id, token, body)
2080
- connection.commit()
2081
- return self.respond(result)
2383
+ if parts[3:] == ["discussion"]:
2384
+ if method == "GET":
2385
+ return self.respond(session_room(connection, room_id))
2386
+ if method == "POST":
2387
+ result = discussion_action(connection, room_id, token, self.read_payload(), require_room(connection, room_id))
2388
+ connection.commit()
2389
+ return self.respond(result)
2390
+ raise LookupError("地址不存在")
2082
2391
  if parts[3:] == ["descriptions"] and method == "POST":
2083
2392
  result = describe_members(connection, room_id, token, self.read_payload())
2084
2393
  connection.commit()
@@ -2096,7 +2405,23 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2096
2405
  connection.commit()
2097
2406
  return self.respond(result)
2098
2407
  if parts[3:] == ["timeline"] and method == "GET":
2099
- return self.respond(execution_timeline(connection, room_id, int(params.get("after", 0))))
2408
+ timeline = execution_timeline(connection, room_id, int(params.get("after", 0)))
2409
+ boundary = session_read_boundary(connection, room_id, token)
2410
+ if boundary is not None:
2411
+ for execution in timeline["executions"]:
2412
+ hidden_task = execution.get("source") == "discussion" or (
2413
+ isinstance(execution.get("task_id"), int) and execution["task_id"] > boundary)
2414
+ if hidden_task:
2415
+ execution["task"] = ""
2416
+ execution["task_id"] = None
2417
+ execution["reason"] = ""
2418
+ execution["last_note"] = ""
2419
+ execution["notes"] = []
2420
+ hidden_answer = isinstance(execution.get("answer_id"), int) and execution["answer_id"] > boundary
2421
+ if hidden_answer:
2422
+ execution["answer_id"] = None
2423
+ execution["result"] = ""
2424
+ return self.respond(timeline)
2100
2425
  if parts[3:] == ["register"] and method == "POST":
2101
2426
  result = register_member(connection, room_id, token, utc_now())
2102
2427
  connection.commit()
@@ -2110,6 +2435,9 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2110
2435
  result = require_room(connection, room_id)
2111
2436
  connection.commit()
2112
2437
  return self.respond(result)
2438
+ if (parts[3:4] in (["topic"], ["turns"])
2439
+ and (room_mode(connection, room_id) == "free" or current_session(connection, room_id, active_only=True))):
2440
+ return self.handle_host_api(connection, parts, params, method)
2113
2441
  if len(parts) == 6 and parts[3] == "messages" and parts[5] == "retract" and method == "POST":
2114
2442
  result = retract_message(connection, room_id, int(parts[4]), token)
2115
2443
  connection.commit()
@@ -2137,15 +2465,27 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2137
2465
  if parts[3:4] == ["attachments"]:
2138
2466
  return self.handle_attachments(connection, parts, params, method)
2139
2467
  if parts[3:4] in (["runtime"], ["events"], ["scheduler-decisions"], ["scheduler-routing"]):
2140
- # A discussion execution's last timeline report may arrive after the room returned to free collaboration.
2468
+ if current_session(connection, room_id, active_only=True) and parts[3:4] in (["runtime"], ["events"]):
2469
+ raise ValueError("新讨论会话使用自由房间的执行接口;旧 runtime/events 接口只读历史")
2470
+ legacy_execution_api = parts[3:4] in (["runtime"], ["events"])
2471
+ payload = self.read_payload() if method == "POST" and legacy_execution_api else {}
2472
+ event_view = parts[3:4] == ["events"] and payload.get("action") == "view"
2473
+ if method == "POST" and legacy_execution_api and not event_view:
2474
+ if has_session_request_model(connection, room_id):
2475
+ raise FileExistsError("该房间已使用讨论会话请求接口;旧讨论运行接口仅保留历史读取")
2476
+ raise ValueError("旧讨论运行接口仅保留历史读取")
2141
2477
  if (free and method == "POST" and parts[3:4] not in (["scheduler-decisions"], ["scheduler-routing"])
2142
- and not (parts[3:4] == ["events"] and self.read_payload().get("action") == "segments")):
2478
+ and not event_view):
2143
2479
  raise ValueError("自由房间不使用讨论运行控制")
2144
2480
  return self.handle_runtime_api(connection, parts, params, method)
2145
2481
  free_actor = None
2146
2482
  if method == "POST" and len(parts) == 4 and (parts[3] in DIRECTORY_OPERATIONS
2147
2483
  or parts[3] in ADMIN_OPERATIONS and self.admin_session(connection)):
2148
2484
  pass
2485
+ elif method == "POST" and parts[3:] == ["state"] and free:
2486
+ pass
2487
+ elif method == "POST" and parts[3:] == ["limits"] and free and current_session(connection, room_id, active_only=True):
2488
+ pass
2149
2489
  elif method == "POST" and free:
2150
2490
  free_actor = authorize_free_write(connection, room_id, token, parts[3] if len(parts) > 3 else "", self.read_payload())
2151
2491
  elif method == "POST" and parts[3:] == ["messages"] and answers_direct_question(self.read_payload()):
@@ -2153,6 +2493,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2153
2493
  elif method == "POST":
2154
2494
  authorize_write(connection, room_id, token, parts[3] if len(parts) > 3 else "", self.read_payload())
2155
2495
  if parts[3:] == ["backup"] and method == "GET":
2496
+ if session_read_boundary(connection, room_id, token) is not None:
2497
+ raise PermissionError("独立观点阶段需房主凭据导出")
2156
2498
  return self.respond(room_backup(connection, room_id))
2157
2499
  if len(parts) == 4 and parts[3] in ("move", "folder", "rename", "archive", "restore", "delete") and method == "POST":
2158
2500
  files = connection.execute("SELECT id FROM attachments WHERE room_id=?", (room_id,)).fetchall() if parts[3] == "delete" else []
@@ -2165,28 +2507,61 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2165
2507
  logging.exception("Attachment cleanup failed: %s", attachment["id"])
2166
2508
  return self.respond(result)
2167
2509
  if len(parts) == 3 and method == "GET":
2168
- return self.respond(require_room(connection, room_id))
2510
+ return self.respond(room_read_view(connection, require_room(connection, room_id), token))
2169
2511
  if parts[3:] == ["conclusion"] and method == "GET":
2170
- return self.respond(conclusion_view(connection, require_room(connection, room_id)))
2512
+ room = require_room(connection, room_id)
2513
+ request_conclusion = session_conclusion_view(connection, room_id)
2514
+ return self.respond(request_conclusion or conclusion_view(connection, room))
2171
2515
  if parts[3:] == ["limits"]:
2172
2516
  if method == "GET":
2517
+ active_session = current_session(connection, room_id, active_only=True)
2518
+ if active_session:
2519
+ view = session_room(connection, room_id)["session"]
2520
+ if view:
2521
+ return self.respond({**view["limits"], "revision": view["revision"]})
2173
2522
  return self.respond(require_room(connection, room_id)["limits"])
2174
- result = check_limits(connection, room_id, self.read_payload())
2523
+ payload = self.read_payload()
2524
+ if current_session(connection, room_id, active_only=True):
2525
+ if payload.get("action") == "check":
2526
+ actor = payload.get("actor", "用户")
2527
+ if actor == "用户":
2528
+ require_owner(connection, room_id, token)
2529
+ elif require_member(connection, room_id, token, actor)["role"] != "host":
2530
+ raise PermissionError("只有房主或当前 AI 主持人可以检查讨论上限")
2531
+ enforce_session_limits(connection, room_id)
2532
+ result = session_room(connection, room_id)
2533
+ elif payload.get("action") == "done":
2534
+ actor = payload.get("actor")
2535
+ if not actor or require_member(connection, room_id, token, actor)["role"] != "host":
2536
+ raise PermissionError("主持人收束讨论须使用当前 AI 主持人的成员凭据")
2537
+ result = conclude_session_by_host(connection, room_id, payload)
2538
+ elif payload.get("action") == "reopen":
2539
+ raise ValueError("新讨论会话请通过 discussion 接口重新开启")
2540
+ else:
2541
+ result = update_session_limits(connection, room_id, token, payload)
2542
+ else:
2543
+ result = check_limits(connection, room_id, payload)
2175
2544
  connection.commit()
2176
2545
  return self.respond(result)
2177
2546
  if parts[3:4] in (["topic"], ["turns"]):
2178
2547
  return self.handle_host_api(connection, parts, params, method)
2179
2548
  if parts[3:] == ["messages"]:
2180
2549
  if method == "GET":
2181
- return self.respond(query_messages(connection, room_id, params))
2550
+ return self.respond(query_messages(connection, room_id, params, token))
2182
2551
  payload = self.read_payload()
2183
2552
  result = append_message(connection, room_id, payload, self.headers.get("X-Discussion-Token", ""), free_actor)
2184
2553
  connection.commit()
2185
2554
  return self.respond(result, 200 if result["duplicate"] else 201)
2186
2555
  if len(parts) == 5 and parts[3] == "messages" and method == "GET":
2187
- return self.respond(get_message(connection, room_id, int(parts[4])))
2556
+ return self.respond(get_message(connection, room_id, int(parts[4]), params, token))
2188
2557
  if parts[3:] == ["state"] and method == "POST":
2189
- result = update_state(connection, room_id, self.read_payload())
2558
+ payload = self.read_payload()
2559
+ actor = payload.get("actor", "用户")
2560
+ if actor == "用户":
2561
+ require_owner(connection, room_id, token)
2562
+ elif require_member(connection, room_id, token, actor)["role"] != "host":
2563
+ raise PermissionError("只有房主或当前 AI 主持人可以更新状态")
2564
+ result = update_state(connection, room_id, payload)
2190
2565
  connection.commit()
2191
2566
  return self.respond(result)
2192
2567
  raise LookupError("地址不存在")
@@ -2262,12 +2637,13 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2262
2637
  require_room(connection, room_id)
2263
2638
  token = self.headers.get("X-Discussion-Token", "")
2264
2639
  if parts[3:] == ["runtime"]:
2265
- result = runtime_view(connection, room_id) if method == "GET" else configure_runtime(
2266
- connection, room_id, token, self.read_payload())
2640
+ if method != "GET":
2641
+ raise LookupError("地址不存在")
2642
+ result = runtime_view(connection, room_id)
2267
2643
  elif parts[3:] == ["events"] and method == "GET":
2268
2644
  result = list_events(connection, room_id, token, params.get("after", 0))
2269
2645
  elif len(parts) == 5 and parts[3] == "events" and method == "POST":
2270
- result = event_action(connection, room_id, int(parts[4]), token, self.read_payload())
2646
+ result = event_view(connection, room_id, int(parts[4]), token, self.read_payload())
2271
2647
  elif parts[3:] == ["scheduler-decisions"] and method == "POST":
2272
2648
  result = record_scheduler_decision(connection, room_id, token, self.read_payload(), owner=room_mode(connection, room_id) == "free")
2273
2649
  elif parts[3:] == ["scheduler-routing"] and method == "POST":
@@ -2294,7 +2670,7 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2294
2670
  elif len(parts) == 5 and parts[3] == "recommendations" and method == "POST":
2295
2671
  result = adopt_recommendation(connection, room_id, token, int(parts[4]), self.read_payload())
2296
2672
  elif len(parts) == 6 and parts[3] == "executions" and parts[5] == "notes" and method == "GET":
2297
- result = execution_notes(connection, room_id, int(parts[4]))
2673
+ result = execution_notes(connection, room_id, int(parts[4]), token)
2298
2674
  elif len(parts) == 5 and parts[3] == "executions":
2299
2675
  execution_id = int(parts[4])
2300
2676
  result = (view_execution(connection, room_id, execution_id, token) if method == "GET"
@@ -2314,8 +2690,20 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2314
2690
  """Route topic maintenance and sequential speaking assignments."""
2315
2691
  room_id = parts[2]
2316
2692
  status = 200
2693
+ session = current_session(connection, room_id)
2694
+ if session and parts[3:4] == ["turns"]:
2695
+ return self.handle_session_turn_api(connection, room_id, parts, params, method)
2696
+ if parts[3:4] == ["turns"] and method == "POST":
2697
+ raise ValueError("旧讨论安排仅供读取;请先开启新的讨论会话")
2317
2698
  if parts[3:] == ["topic"] and method == "POST":
2318
- result = update_topic(connection, room_id, self.read_payload())
2699
+ payload = self.read_payload()
2700
+ actor = payload.get("actor", "用户")
2701
+ token = self.headers.get("X-Discussion-Token", "")
2702
+ if actor == "用户":
2703
+ require_owner(connection, room_id, token)
2704
+ elif require_member(connection, room_id, token, actor)["role"] != "host":
2705
+ raise PermissionError("只有房主或当前 AI 主持人可以修改话题资料")
2706
+ result = update_topic(connection, room_id, payload)
2319
2707
  elif parts[3:] == ["turns"]:
2320
2708
  if method == "GET":
2321
2709
  return self.respond(query_turns(connection, room_id, params))
@@ -2332,31 +2720,79 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2332
2720
  connection.commit()
2333
2721
  return self.respond(result, status)
2334
2722
 
2723
+ def handle_session_turn_api(self, connection, room_id, parts, params, method):
2724
+ """Read and write session plans through the legacy turns URL family."""
2725
+ if parts[3:] == ["turns"]:
2726
+ if method == "GET":
2727
+ return self.respond(query_session_requests(connection, room_id, params))
2728
+ payload = self.read_payload()
2729
+ host = require_member(connection, room_id, self.headers.get("X-Discussion-Token", ""), payload.get("actor"))
2730
+ if host["role"] != "host":
2731
+ raise PermissionError("只有当前 AI 主持人可以安排讨论发言")
2732
+ result = create_plan(connection, room_id, payload, require_room(connection, room_id))
2733
+ connection.commit()
2734
+ return self.respond(result, 200 if result["duplicate"] else 201)
2735
+ if parts[3:] == ["turns", "roster"] and method == "GET":
2736
+ return self.respond(session_roster_view(connection, room_id))
2737
+ if parts[3:] == ["turns", "continue"] and method == "POST":
2738
+ payload = self.read_payload()
2739
+ host = require_member(connection, room_id, self.headers.get("X-Discussion-Token", ""), payload.get("actor"))
2740
+ if host["role"] != "host":
2741
+ raise PermissionError("只有当前 AI 主持人可以继续讨论安排")
2742
+ result = continue_plan(connection, room_id, payload, require_room(connection, room_id))
2743
+ connection.commit()
2744
+ return self.respond(result)
2745
+ if len(parts) == 5 and parts[3] == "turns":
2746
+ if method == "GET":
2747
+ return self.respond(get_session_request(connection, room_id, parts[4]))
2748
+ payload = self.read_payload()
2749
+ token = self.headers.get("X-Discussion-Token", "")
2750
+ try:
2751
+ require_owner(connection, room_id, token)
2752
+ except PermissionError:
2753
+ host = require_member(connection, room_id, token, payload.get("actor"))
2754
+ if host["role"] != "host":
2755
+ raise PermissionError("只有房主或 AI 主持人可以取消安排")
2756
+ if payload.get("status") != "cancelled":
2757
+ raise ValueError("新讨论安排只支持专用取消操作")
2758
+ result = cancel_plan(connection, room_id, parts[4], payload)
2759
+ connection.commit()
2760
+ return self.respond(result)
2761
+ raise LookupError("地址不存在")
2762
+
2335
2763
  def handle_text(self, connection, parts, params):
2336
2764
  """Expose read-only history and search without JavaScript or MCP."""
2337
2765
  if len(parts) < 2:
2338
2766
  raise LookupError("请指定房间")
2339
2767
  room_id = parts[1]
2768
+ token = self.headers.get("X-Discussion-Token", "")
2340
2769
  if len(parts) == 2:
2341
2770
  body = room_text(connection, room_id, params)
2342
2771
  elif parts[2:] in (["brief"], ["state"], ["protocol"]):
2343
- room = require_room(connection, room_id)
2772
+ room = room_read_view(connection, require_room(connection, room_id), self.headers.get("X-Discussion-Token", ""))
2344
2773
  body = brief_text(room) if parts[2] == "brief" else protocol_text(room)
2345
2774
  if parts[2] == "state":
2346
2775
  body = state_reading_text(room)
2347
2776
  elif parts[2:] == ["turns"]:
2348
- result = query_turns(connection, room_id, params)
2777
+ if current_session(connection, room_id) and params.get("legacy") != "1":
2778
+ result = query_session_requests(connection, room_id, params)
2779
+ else:
2780
+ result = query_turns(connection, room_id, params)
2349
2781
  room = require_room(connection, room_id)
2350
2782
  body = f"第 {result['page']} / {result['pages']} 页,共 {result['total']} 项。\n\n" + "\n\n".join(turn_text(turn, room) for turn in result["turns"])
2351
2783
  elif len(parts) == 4 and parts[2] == "turns":
2352
- body = turn_text(get_turn(connection, room_id, parts[3]), require_room(connection, room_id))
2784
+ if current_session(connection, room_id) and params.get("legacy") != "1":
2785
+ turn = get_session_request(connection, room_id, parts[3])
2786
+ else:
2787
+ turn = get_turn(connection, room_id, parts[3])
2788
+ body = turn_text(turn, require_room(connection, room_id))
2353
2789
  elif parts[2:] in (["messages"], ["search"]):
2354
- result = query_messages(connection, room_id, params)
2790
+ result = query_messages(connection, room_id, params, token)
2355
2791
  items = [message_text(message) for message in result["messages"]]
2356
2792
  next_page = f"第 {result['page']} / {result['pages']} 页,共 {result['total']} 条。翻页:保留查询条件,修改 page 参数。"
2357
2793
  body = next_page + "\n\n" + "\n\n---\n\n".join(items or ["没有匹配的消息。"])
2358
2794
  elif len(parts) == 4 and parts[2] == "messages":
2359
- body = message_text(get_message(connection, room_id, int(parts[3])))
2795
+ body = message_text(get_message(connection, room_id, int(parts[3]), params, token))
2360
2796
  elif parts[2:] == ["publish"]:
2361
2797
  return self.respond("URL 发布入口已停用。在线 AI 只读;本地 CLI AI 请使用 POST 留言接口。", 410, "text/plain")
2362
2798
  else: