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.
- package/.agents/skills/discuss-host/SKILL.md +3 -3
- package/.agents/skills/discuss-host/references/discussion.md +16 -16
- package/.agents/skills/discuss-host/references/launch.md +1 -3
- package/.agents/skills/discuss-host/references/manual.md +14 -77
- package/.agents/skills/discuss-participant/SKILL.md +3 -3
- package/.agents/skills/discuss-participant/references/discussion.md +29 -27
- package/.agents/skills/discuss-participant/references/free.md +2 -2
- package/.agents/skills/discuss-participant/references/manual.md +8 -10
- package/.agents/skills/discuss-participant/references/writing.md +9 -9
- package/.github/SECURITY.md +1 -1
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/backend/free_room.py +360 -68
- package/backend/room_import.py +75 -15
- package/backend/runtime.py +81 -169
- package/backend/schema.sql +136 -244
- package/backend/server.py +602 -166
- package/backend/session_room.py +919 -0
- package/docs/architecture.md +3 -2
- package/docs/en/architecture.md +4 -3
- package/docs/en/runner.md +3 -1
- package/docs/en/workflow.md +9 -6
- package/docs/runner.md +3 -1
- package/docs/workflow.md +11 -8
- package/package.json +2 -2
- package/runner/api_participant.mjs +47 -44
- package/runner/discuss.py +77 -58
- package/runner/discussion_adapters.mjs +6 -3
- package/runner/discussion_launch.mjs +98 -70
- package/runner/discussion_runner.mjs +38 -496
- package/runner/discussion_service.mjs +21 -74
- package/runner/discussion_sidecar.mjs +4 -13
- package/runner/expert_prompts.mjs +29 -21
- package/runner/free_runner.mjs +84 -19
- package/runner/session_driver.mjs +175 -0
- package/web/app.js +193 -128
- package/web/backup.js +1 -1
- package/web/modern.css +1 -1
- 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,
|
|
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
|
|
93
|
-
|
|
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
|
|
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":
|
|
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.*,
|
|
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
|
-
|
|
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
|
|
255
|
-
|
|
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[
|
|
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
|
-
|
|
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[
|
|
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.*,
|
|
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", "
|
|
993
|
+
if payload.get("mode", "free") not in ("free", "brainstorm"):
|
|
715
994
|
raise ValueError("房间模式须为 free 或 brainstorm")
|
|
716
|
-
free =
|
|
995
|
+
free = True
|
|
717
996
|
members = member_values(payload.get("members")) if free else []
|
|
718
997
|
if free:
|
|
719
|
-
payload = {**payload, "
|
|
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["
|
|
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
|
-
|
|
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
|
-
|
|
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"]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1560
|
-
|
|
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
|
|
1581
|
-
content.append(f"与 @{
|
|
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
|
-
|
|
1615
|
-
|
|
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
|
|
1942
|
+
"""Describe the unified request/execution protocol separately from discussion material."""
|
|
1620
1943
|
base = f"/api/rooms/{room['id']}"
|
|
1621
|
-
if room["mode"]
|
|
1622
|
-
return "
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
"
|
|
1636
|
-
"
|
|
1637
|
-
"
|
|
1638
|
-
|
|
1639
|
-
"
|
|
1640
|
-
"
|
|
1641
|
-
|
|
1642
|
-
|
|
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
|
|
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
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
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"]
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2266
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|