ai_co_discuss 0.3.16 → 0.3.18

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 (33) hide show
  1. package/backend/free_room.py +32 -151
  2. package/backend/server.py +5 -0
  3. package/backend/session_room.py +156 -0
  4. package/docs/THIRD_PARTY_NOTICES.md +1264 -1264
  5. package/package.json +1 -1
  6. package/runner/discussion_service.mjs +17 -7
  7. package/runner/remote_launch.mjs +4 -3
  8. package/web/app.js +46 -11
  9. package/web/index.html +3 -0
  10. package/web/katex/LICENSE +21 -0
  11. package/web/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  12. package/web/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  13. package/web/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  14. package/web/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  15. package/web/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  16. package/web/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  17. package/web/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  18. package/web/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  19. package/web/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  20. package/web/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  21. package/web/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  22. package/web/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  23. package/web/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  24. package/web/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  25. package/web/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  26. package/web/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  27. package/web/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  28. package/web/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  29. package/web/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  30. package/web/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  31. package/web/katex/katex.min.css +1 -0
  32. package/web/markdown.css +1 -1
  33. package/web/modern.css +1 -1
@@ -647,9 +647,7 @@ def execution_notes(connection, room_id, execution_id, token=""):
647
647
  execution = connection.execute("SELECT member_id FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone()
648
648
  if not execution:
649
649
  raise LookupError("执行不存在")
650
- from .session_room import session_message_access
651
-
652
- boundary = session_message_access(connection, room_id, {}, token)
650
+ boundary = discussion.session_message_access(connection, room_id, {}, token)
653
651
  if boundary is not None:
654
652
  try:
655
653
  member = require_member(connection, room_id, token)
@@ -714,18 +712,9 @@ def recent_executions(connection, room_id):
714
712
 
715
713
  def session_request_source(connection, member_request_id, message_id):
716
714
  """Expose session provenance and a visible transcript anchor for a member request."""
717
- row = connection.execute("""SELECT q.id AS session_request_id,q.session_id AS discussion_session_id,q.round,
718
- q.question AS source_question,t.cause_kind,t.cause_message_id AS source_message_id,t.cause_request_id,
719
- CASE WHEN q.id IS NULL THEN r.message_id ELSE COALESCE((SELECT MAX(m.id) FROM messages m
720
- WHERE m.room_id=r.room_id AND m.id<r.message_id
721
- AND NOT EXISTS(SELECT 1 FROM internal_task_messages hidden WHERE hidden.message_id=m.id)),0) END AS session_anchor
722
- FROM member_requests r LEFT JOIN session_requests q ON q.member_request_id=r.id
723
- LEFT JOIN task_message_sources t ON t.session_request_id=q.id WHERE r.id=?""", (member_request_id,)).fetchone()
724
- if row and row["session_request_id"] is not None:
725
- return {"session_request_id": row["session_request_id"], "discussion_session_id": row["discussion_session_id"],
726
- "round": row["round"], "source_question": row["source_question"],
727
- "source_message_id": row["source_message_id"], "cause_kind": row["cause_kind"],
728
- "cause_request_id": row["cause_request_id"], "after_message_id": row["session_anchor"]}
715
+ origin = discussion.session_request_origin(connection, member_request_id)
716
+ if origin:
717
+ return origin
729
718
  request = connection.execute("SELECT message_id,purpose FROM member_requests WHERE id=?", (member_request_id,)).fetchone()
730
719
  return {"session_request_id": None, "discussion_session_id": None, "round": None, "source_question": "",
731
720
  "source_message_id": None, "cause_kind": None, "cause_request_id": None, "after_message_id": message_id,
@@ -965,7 +954,7 @@ def check_answer(connection, room_id, member, payload):
965
954
  raise FileExistsError(f"请求 {request_id} 不属于本成员或已处理")
966
955
  purposes.append(row["purpose"])
967
956
  requests.append(row)
968
- validate_session_request_answer(connection, room_id, requests)
957
+ discussion.validate_session_request_answer(connection, room_id, requests)
969
958
  check_dispatch(member, payload, purposes)
970
959
  return None
971
960
  execution_id = payload.get("execution_id")
@@ -983,7 +972,7 @@ def check_answer(connection, room_id, member, payload):
983
972
  raise FileExistsError("本次执行已结束、被打断或已过期;回答不能改作普通发言,请保留草稿")
984
973
  requests = [dict(item) for item in connection.execute(
985
974
  "SELECT id,purpose FROM member_requests WHERE execution_id=? AND status='delivering'", (execution_id,))]
986
- validate_session_request_answer(connection, room_id, requests)
975
+ discussion.validate_session_request_answer(connection, room_id, requests)
987
976
  check_dispatch(member, payload, [item["purpose"] for item in requests])
988
977
  return row
989
978
 
@@ -994,21 +983,6 @@ def check_dispatch(member, payload, purposes):
994
983
  raise ValueError("这次是请全体成员各自回应,其他成员已收到请求;主持人只回答自己的部分,不能用 --to 派活")
995
984
 
996
985
 
997
- def validate_session_request_answer(connection, room_id, requests):
998
- """Keep a discussion speaker request separate and live when answering it."""
999
- discussions = [request for request in requests if request["purpose"] == "discuss"]
1000
- if not discussions:
1001
- return
1002
- if len(requests) != 1 or len(discussions) != 1:
1003
- raise ValueError("讨论安排须单独回答,不能与普通请求合并")
1004
- request_id = discussions[0]["id"]
1005
- plan = connection.execute("""SELECT sr.status,s.status AS session_status FROM session_requests sr
1006
- JOIN discussion_sessions s ON s.id=sr.session_id JOIN member_requests r ON r.id=sr.member_request_id
1007
- WHERE s.room_id=? AND r.id=? AND sr.purpose='discuss'""", (room_id, request_id)).fetchone()
1008
- if not plan or plan["session_status"] != "active" or plan["status"] != "open":
1009
- raise FileExistsError("讨论安排已暂停、结束或取消,不能继续回答")
1010
-
1011
-
1012
986
  def check_proxy(connection, room_id, member, payload):
1013
987
  """The owner forwards an online member's answer and may close that member's open requests."""
1014
988
  request_ids = payload.get("request_ids", [])
@@ -1020,7 +994,7 @@ def check_proxy(connection, room_id, member, payload):
1020
994
  if not row or row["member_id"] != member["id"] or row["status"] != "pending":
1021
995
  raise FileExistsError(f"请求 {request_id} 不属于该在线成员或已处理")
1022
996
  requests.append(row)
1023
- validate_session_request_answer(connection, room_id, requests)
997
+ discussion.validate_session_request_answer(connection, room_id, requests)
1024
998
 
1025
999
 
1026
1000
  def insert_request(connection, room_id, member_id, message_id, chain_id, purpose, created_by, batch_id=0):
@@ -1254,35 +1228,11 @@ def speaking_order(connection, room):
1254
1228
  items, kind, target, later = [], "default", 0, []
1255
1229
  session_plan_state = []
1256
1230
  if room["mode"] == "free":
1257
- from .session_room import current_session
1258
- session = current_session(connection, room["id"], active_only=True)
1231
+ session = discussion.current_session(connection, room["id"], active_only=True)
1259
1232
  if session:
1260
1233
  kind = "turns"
1261
1234
  target = session["id"]
1262
- session_plan_state = [list(row) for row in connection.execute("""SELECT id,round,position,member_id,question,status,submission_id
1263
- FROM session_requests WHERE session_id=? AND purpose='discuss' ORDER BY round,position,id""", (session["id"],))]
1264
- active_round = connection.execute("SELECT MIN(round) FROM session_requests WHERE session_id=? AND purpose='discuss' AND status IN ('open','queued')",
1265
- (session["id"],)).fetchone()[0]
1266
- if active_round is None:
1267
- active_round = connection.execute("SELECT COALESCE(MAX(round),1) FROM session_requests WHERE session_id=? AND purpose='discuss'",
1268
- (session["id"],)).fetchone()[0]
1269
- answered = [row["name"] for row in connection.execute("""SELECT m.name FROM session_requests sr JOIN room_members m ON m.id=sr.member_id
1270
- WHERE sr.session_id=? AND sr.purpose='discuss' AND sr.round=? AND sr.status='answered' ORDER BY sr.position,sr.id""",
1271
- (session["id"], active_round))]
1272
- items.extend({"name": name, "state": "done"} for name in answered)
1273
- open_turn = connection.execute("""SELECT m.name FROM session_requests sr JOIN room_members m ON m.id=sr.member_id
1274
- WHERE sr.session_id=? AND sr.purpose='discuss' AND sr.round=? AND sr.status='open'
1275
- ORDER BY sr.position,sr.id LIMIT 1""", (session["id"], active_round)).fetchone()
1276
- if open_turn:
1277
- items.append({"name": open_turn["name"], "state": "speaking"})
1278
- queued = connection.execute("""SELECT sr.round,m.name FROM session_requests sr JOIN room_members m ON m.id=sr.member_id
1279
- WHERE sr.session_id=? AND sr.purpose='discuss' AND sr.status='queued' ORDER BY sr.round,sr.position,sr.id""",
1280
- (session["id"],)).fetchall()
1281
- for row in queued:
1282
- if row["round"] == active_round:
1283
- items.append({"name": row["name"], "state": "queued"})
1284
- later = [[row["name"] for row in queued if row["round"] == round_number]
1285
- for round_number in sorted({row["round"] for row in queued if row["round"] > active_round})]
1235
+ items, later, session_plan_state = discussion.session_speaking_order(connection, session)
1286
1236
  else:
1287
1237
  target = connection.execute("""SELECT MAX(q.message_id) FROM free_queue q LEFT JOIN member_requests r ON r.message_id=q.message_id
1288
1238
  AND r.member_id=q.member_id WHERE q.room_id=? AND (r.id IS NULL OR r.status IN ('pending','delivering'))""", (room["id"],)).fetchone()[0] or 0
@@ -1352,22 +1302,9 @@ def reorder_speakers(connection, room, token, payload):
1352
1302
  for name in order:
1353
1303
  connection.execute("INSERT INTO free_queue (room_id,message_id,member_id) VALUES (?,?,?)", (room["id"], current["message_id"], names[name]))
1354
1304
  elif current["kind"] == "turns":
1355
- from .session_room import current_session
1356
- session = current_session(connection, room["id"], active_only=True)
1305
+ session = discussion.current_session(connection, room["id"], active_only=True)
1357
1306
  if session:
1358
- active_round = connection.execute("SELECT MIN(round) FROM session_requests WHERE session_id=? AND purpose='discuss' AND status IN ('open','queued')",
1359
- (session["id"],)).fetchone()[0]
1360
- active_round = active_round or 1
1361
- open_position = connection.execute("SELECT COALESCE(MAX(position),0) FROM session_requests WHERE session_id=? AND purpose='discuss' AND round=? AND status IN ('answered','open')",
1362
- (session["id"], active_round)).fetchone()[0]
1363
- rows = connection.execute("SELECT id FROM session_requests WHERE session_id=? AND purpose='discuss' AND round=? AND status='queued'",
1364
- (session["id"], active_round)).fetchall()
1365
- connection.execute("UPDATE session_requests SET position=-id WHERE id IN (%s)" % ",".join("?" for _ in rows),
1366
- [row["id"] for row in rows]) if rows else None
1367
- for offset, name in enumerate(order, 1):
1368
- connection.execute("UPDATE session_requests SET position=? WHERE session_id=? AND purpose='discuss' AND round=? AND status='queued' AND member_id=?",
1369
- (open_position + offset, session["id"], active_round, names[name]))
1370
- connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (session["id"],))
1307
+ discussion.reorder_session_turns(connection, session, order, names)
1371
1308
  else:
1372
1309
  reorder_turns(connection, room["id"], order)
1373
1310
  else:
@@ -1408,14 +1345,7 @@ def after_free_message(connection, room_id, message_id, actor, payload):
1408
1345
  """Create requests for the owner's message, or close what the member's answer covers."""
1409
1346
  now = int(time.time())
1410
1347
  if actor == "用户":
1411
- from .session_room import create_host_request, current_session
1412
- session = current_session(connection, room_id, active_only=True)
1413
- if session and session["status"] == "active" and payload.get("to") is None:
1414
- create_host_request(connection, session["id"],
1415
- f"用户留言 #{message_id}:请处理并决定是否安排专家。",
1416
- {"kind": "user_message", "message_id": message_id, "key": f"message-{message_id}"})
1417
- connection.execute("UPDATE session_state SET held_since=?,revision=revision+1 WHERE session_id=?",
1418
- (message_id, session["id"]))
1348
+ if discussion.hold_user_message(connection, room_id, message_id, payload):
1419
1349
  return
1420
1350
  pairs = recipients(connection, room_id, payload)
1421
1351
  if payload.get("sequential") is True and payload.get("to") is not None:
@@ -1499,17 +1429,14 @@ def finish_execution(connection, execution_id, status, result, request_status, c
1499
1429
  """End one execution and move its delivered requests in the same transaction."""
1500
1430
  if request_status == "cancelled" and cancel_reason:
1501
1431
  record_cancel_reasons(connection, "execution_id=? AND status='delivering'", (execution_id,), cancel_reason)
1502
- session_requests = connection.execute("""SELECT r.id FROM member_requests r JOIN session_requests sr
1503
- ON sr.member_request_id=r.id WHERE r.execution_id=? AND sr.purpose='discuss' AND sr.status='open'""",
1504
- (execution_id,)).fetchall()
1432
+ discussion_requests = discussion.open_discussion_requests(connection, execution_id)
1505
1433
  connection.execute("UPDATE member_executions SET status=?,finished_at=?,result=? WHERE id=? AND status IN ('dispatched','started')",
1506
1434
  (status, int(time.time()), result[:500], execution_id))
1507
1435
  connection.execute("UPDATE member_requests SET status=?,execution_id=CASE WHEN ?='pending' THEN NULL ELSE execution_id END WHERE execution_id=? AND status='delivering'",
1508
1436
  (request_status, request_status, execution_id))
1509
1437
  if status == "failed" and request_status == "failed":
1510
- from .session_room import fail_session_request
1511
- for row in session_requests:
1512
- fail_session_request(connection, row["id"], result)
1438
+ for row in discussion_requests:
1439
+ discussion.fail_session_request(connection, row["id"], result)
1513
1440
 
1514
1441
 
1515
1442
  def open_route_messages(connection, member_id, pending_only=False):
@@ -1541,27 +1468,7 @@ def stop_member_work(connection, member_id, reason, cancel_reason, keep_pending=
1541
1468
  if not keep_pending:
1542
1469
  record_cancel_reasons(connection, "member_id=? AND status='pending'", (member_id,), cancel_reason)
1543
1470
  connection.execute("UPDATE member_requests SET status='cancelled' WHERE member_id=? AND status='pending'", (member_id,))
1544
- from .session_room import advance_session, create_host_request, current_session
1545
-
1546
- member = connection.execute("SELECT room_id,name FROM room_members WHERE id=?", (member_id,)).fetchone()
1547
- session = current_session(connection, member["room_id"], active_only=True) if member else None
1548
- if not session:
1549
- return
1550
- plans = connection.execute("""SELECT id,member_request_id FROM session_requests
1551
- WHERE session_id=? AND member_id=? AND purpose='discuss' AND status='open'
1552
- AND member_request_id IN (SELECT id FROM member_requests WHERE status='cancelled')""",
1553
- (session["id"], member_id)).fetchall()
1554
- for plan in plans:
1555
- connection.execute("UPDATE session_requests SET status='cancelled' WHERE id=?", (plan["id"],))
1556
- if not plans:
1557
- return
1558
- connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (session["id"],))
1559
- if advance_session(connection, session["id"]):
1560
- return
1561
- request_id = plans[0]["member_request_id"]
1562
- create_host_request(connection, session["id"],
1563
- f"专家 {member['name']} 的讨论发言已被打断,请检查余下安排并决定如何继续。",
1564
- {"kind": "request_failed", "request_id": request_id, "key": f"interrupted-{request_id or plans[0]['id']}"})
1471
+ discussion.after_member_stopped(connection, member_id)
1565
1472
 
1566
1473
 
1567
1474
  def stop_room(connection, room_id, token):
@@ -1571,8 +1478,7 @@ def stop_room(connection, room_id, token):
1571
1478
  """
1572
1479
  require_owner(connection, room_id, token)
1573
1480
  begin_write(connection)
1574
- from .session_room import current_session
1575
- if current_session(connection, room_id, active_only=True):
1481
+ if discussion.current_session(connection, room_id, active_only=True):
1576
1482
  raise FileExistsError("讨论进行中,请用暂停或结束讨论")
1577
1483
  connection.execute("DELETE FROM free_queue WHERE room_id=?", (room_id,))
1578
1484
  stop_room_work(connection, room_id, "用户停止全部", "user_stop")
@@ -2099,9 +2005,7 @@ def owner_message(connection, room_id, message_id):
2099
2005
  raise ValueError("只有协作房间的消息可以撤回或编辑")
2100
2006
  if connection.execute("SELECT 1 FROM room_directory WHERE room_id=? AND archived=1", (room_id,)).fetchone():
2101
2007
  raise ValueError("房间已存档;恢复后才能撤回或编辑")
2102
- # A discussion's host reads the user's messages itself, with no request that shows whether it began.
2103
- if connection.execute("SELECT 1 FROM discussion_sessions WHERE room_id=? AND (status IN ('active','paused') OR started_message_id>=?)",
2104
- (room_id, message_id)).fetchone():
2008
+ if discussion.blocks_message_change(connection, room_id, message_id):
2105
2009
  raise FileExistsError("讨论进行中,或这条消息之后开启过讨论,不能撤回或编辑")
2106
2010
  return row
2107
2011
 
@@ -2250,31 +2154,9 @@ def queue_invited_expert(connection, room_id, name, now):
2250
2154
  member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND name=? AND status='active'", (room_id, name)).fetchone()
2251
2155
  if not member or (member["adapter"] == "manual" and not registered_at(connection, member)):
2252
2156
  return
2253
- from .session_room import current_session
2254
- session = current_session(connection, room_id)
2157
+ session = discussion.current_session(connection, room_id)
2255
2158
  if session and session["status"] != "concluded":
2256
- planned = connection.execute("SELECT 1 FROM session_requests WHERE session_id=? AND purpose='discuss' AND status IN ('open','queued') LIMIT 1",
2257
- (session["id"],)).fetchone()
2258
- if not planned:
2259
- return
2260
- round_number = connection.execute("SELECT MIN(round) FROM session_requests WHERE session_id=? AND purpose='discuss' AND status IN ('open','queued')",
2261
- (session["id"],)).fetchone()[0]
2262
- if round_number is None:
2263
- round_number = 1
2264
- position = connection.execute("SELECT COALESCE(MAX(position),0)+1 FROM session_requests WHERE session_id=? AND purpose='discuss' AND round=?",
2265
- (session["id"], round_number)).fetchone()[0]
2266
- invitation = connection.execute("SELECT COUNT(*) FROM session_requests WHERE session_id=? AND member_id=? AND submission_id LIKE 'invite:%'",
2267
- (session["id"], member["id"])).fetchone()[0] + 1
2268
- state = connection.execute("SELECT phase,read_until FROM session_state WHERE session_id=?", (session["id"],)).fetchone()
2269
- question = ("欢迎加入:请先读取议题,独立给出你的开场观点。" if state and state["phase"] == "initial"
2270
- else "欢迎加入:请先读取议题和已有回答,给出你的观点。")
2271
- read_until = state["read_until"] if state and state["phase"] == "initial" and round_number == 1 else None
2272
- connection.execute("""INSERT OR IGNORE INTO session_requests
2273
- (session_id,round,position,member_id,purpose,question,status,submission_id,read_until,created_at)
2274
- VALUES(?,?,?,?,'discuss',?,'queued',?,?,?)""",
2275
- (session["id"], round_number, position, member["id"], question,
2276
- f"invite:{member['id']}:{invitation}", read_until,
2277
- time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())))
2159
+ discussion.queue_session_invite(connection, session, member)
2278
2160
  return
2279
2161
  connection.execute("""INSERT OR IGNORE INTO discussion_turns (room_id,expert,question,status,submission_id,created_at)
2280
2162
  SELECT settings.room_id,member.name,
@@ -2324,8 +2206,7 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
2324
2206
  if action in ("away", "remove"):
2325
2207
  if member["name"] == host:
2326
2208
  raise ValueError("主持人不能离开或移除;请先更换主持人")
2327
- from .session_room import session_member_change
2328
- session_member_change(connection, room_id, member, action)
2209
+ discussion.session_member_change(connection, room_id, member, action)
2329
2210
  connection.execute("UPDATE discussion_turns SET status='cancelled' WHERE room_id=? AND expert=? AND status IN ('pending','notified')",
2330
2211
  (room_id, member["name"]))
2331
2212
  connection.execute("UPDATE discussion_turns SET status='skipped' WHERE room_id=? AND expert=? AND status='queued'", (room_id, member["name"]))
@@ -2340,9 +2221,8 @@ def discussion_member_action(connection, room_id, member_id, token, payload):
2340
2221
  require_fresh_member_token(connection, room_id, payload.get("token"))
2341
2222
  connection.execute("UPDATE room_members SET status='active',token_hash=? WHERE id=?", (hashed, member_id))
2342
2223
  experts[member["name"]] = "online" if member["access"] == "online" else "cli"
2343
- from .session_room import current_session, session_member_change
2344
- if current_session(connection, room_id, active_only=True):
2345
- session_member_change(connection, room_id, member, action)
2224
+ if discussion.current_session(connection, room_id, active_only=True):
2225
+ discussion.session_member_change(connection, room_id, member, action)
2346
2226
  else:
2347
2227
  queue_invited_expert(connection, room_id, member["name"], int(time.time()))
2348
2228
  elif action == "adopt":
@@ -2394,11 +2274,10 @@ def member_action(connection, room_id, member_id, token, payload):
2394
2274
  action = payload.get("action")
2395
2275
  routed = []
2396
2276
  if action in ("away", "remove"):
2397
- from .session_room import pause_session_for_host_change, session_member_change
2398
2277
  if member["role"] == "host":
2399
- pause_session_for_host_change(connection, room_id)
2278
+ discussion.pause_session_for_host_change(connection, room_id)
2400
2279
  else:
2401
- session_member_change(connection, room_id, member, action)
2280
+ discussion.session_member_change(connection, room_id, member, action)
2402
2281
  # Unnamed messages still waiting on a departing host move to its successor instead of being dropped.
2403
2282
  routed = open_route_messages(connection, member_id)
2404
2283
  stop_member_work(connection, member_id, "成员已离开" if action == "away" else "成员已移除",
@@ -2412,9 +2291,8 @@ def member_action(connection, room_id, member_id, token, payload):
2412
2291
  raise ValueError("只有离开的成员可以请回,且须提供新的成员凭证")
2413
2292
  require_fresh_member_token(connection, room_id, payload.get("token"))
2414
2293
  connection.execute("UPDATE room_members SET status='active',token_hash=? WHERE id=?", (hashed, member_id))
2415
- from .session_room import current_session, session_member_change
2416
- if current_session(connection, room_id, active_only=True):
2417
- session_member_change(connection, room_id, member, action)
2294
+ if discussion.current_session(connection, room_id, active_only=True):
2295
+ discussion.session_member_change(connection, room_id, member, action)
2418
2296
  else:
2419
2297
  queue_invited_expert(connection, room_id, member["name"], int(time.time()))
2420
2298
  elif action == "adopt":
@@ -2426,8 +2304,7 @@ def member_action(connection, room_id, member_id, token, payload):
2426
2304
  if role == "member" and member["role"] == "host":
2427
2305
  raise ValueError("自由房间须由一位 AI 主持;请指定另一位成员担任主持人")
2428
2306
  if role == "host":
2429
- from .session_room import pause_session_for_host_change
2430
- pause_session_for_host_change(connection, room_id)
2307
+ discussion.pause_session_for_host_change(connection, room_id)
2431
2308
  # Unnamed messages the old host has not started move to the new host, which carries the host rules.
2432
2309
  old_host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND id<>?", (room_id, member_id)).fetchone()
2433
2310
  if old_host:
@@ -2442,3 +2319,7 @@ def member_action(connection, room_id, member_id, token, payload):
2442
2319
  sync_participants(connection, room_id)
2443
2320
  hand_over_routes(connection, room_id, routed, member_id if action in ("away", "remove") else None)
2444
2321
  return free_view(connection, room_id)
2322
+
2323
+
2324
+ # Imported last: session_room imports helpers from this module, so both load in either order.
2325
+ from . import session_room as discussion # noqa: E402
package/backend/server.py CHANGED
@@ -89,6 +89,11 @@ ASSETS.update({"/pdfjs/" + file.relative_to(WEB_ROOT / "pdfjs").as_posix():
89
89
  (file.relative_to(WEB_ROOT).as_posix(),
90
90
  {".mjs": "text/javascript", ".js": "text/javascript", ".wasm": "application/wasm"}.get(file.suffix, "application/octet-stream"))
91
91
  for file in (WEB_ROOT / "pdfjs").rglob("*") if file.is_file()})
92
+ # KaTeX's stylesheet and the fonts it loads only when a formula is shown.
93
+ ASSETS.update({"/katex/" + file.relative_to(WEB_ROOT / "katex").as_posix():
94
+ (file.relative_to(WEB_ROOT).as_posix(),
95
+ {".css": "text/css", ".woff2": "font/woff2"}.get(file.suffix, "text/plain"))
96
+ for file in (WEB_ROOT / "katex").rglob("*") if file.is_file()})
92
97
  SCHEMA = (Path(__file__).resolve().parent / "schema.sql").read_text(encoding="utf-8")
93
98
  PHASES = {"preparing": "准备中", "initial": "独立观点", "discussing": "深入讨论",
94
99
  "paused": "已暂停", "concluded": "已收束"}
@@ -996,3 +996,159 @@ def update_session_state(connection, session_id, phase=None, held_since=None):
996
996
  fields.append("held_since=?"); values.append(held_since)
997
997
  fields.append("revision=revision+1"); values.append(session_id)
998
998
  connection.execute(f"UPDATE session_state SET {','.join(fields)} WHERE session_id=?",values)
999
+
1000
+
1001
+ # Hooks the free room calls at its own decision points; each leaves the free room alone when no discussion is open.
1002
+
1003
+ def hold_user_message(connection, room_id, message_id, payload):
1004
+ """Hand an unnamed user message to the running discussion's host; True when the discussion took it."""
1005
+ session = current_session(connection, room_id, active_only=True)
1006
+ if not session or session["status"] != "active" or payload.get("to") is not None:
1007
+ return False
1008
+ create_host_request(connection, session["id"],
1009
+ f"用户留言 #{message_id}:请处理并决定是否安排专家。",
1010
+ {"kind": "user_message", "message_id": message_id, "key": f"message-{message_id}"})
1011
+ connection.execute("UPDATE session_state SET held_since=?,revision=revision+1 WHERE session_id=?",
1012
+ (message_id, session["id"]))
1013
+ return True
1014
+
1015
+
1016
+ def validate_session_request_answer(connection, room_id, requests):
1017
+ """Keep a discussion speaker request separate and live when answering it."""
1018
+ discussions = [request for request in requests if request["purpose"] == "discuss"]
1019
+ if not discussions:
1020
+ return
1021
+ if len(requests) != 1 or len(discussions) != 1:
1022
+ raise ValueError("讨论安排须单独回答,不能与普通请求合并")
1023
+ request_id = discussions[0]["id"]
1024
+ plan = connection.execute("""SELECT sr.status,s.status AS session_status FROM session_requests sr
1025
+ JOIN discussion_sessions s ON s.id=sr.session_id JOIN member_requests r ON r.id=sr.member_request_id
1026
+ WHERE s.room_id=? AND r.id=? AND sr.purpose='discuss'""", (room_id, request_id)).fetchone()
1027
+ if not plan or plan["session_status"] != "active" or plan["status"] != "open":
1028
+ raise FileExistsError("讨论安排已暂停、结束或取消,不能继续回答")
1029
+
1030
+
1031
+ def open_discussion_requests(connection, execution_id):
1032
+ """Member requests of an execution that carry an open discussion turn; read before the execution ends."""
1033
+ return connection.execute("""SELECT r.id FROM member_requests r JOIN session_requests sr
1034
+ ON sr.member_request_id=r.id WHERE r.execution_id=? AND sr.purpose='discuss' AND sr.status='open'""",
1035
+ (execution_id,)).fetchall()
1036
+
1037
+
1038
+ def after_member_stopped(connection, member_id):
1039
+ """Cancel the discussion turns a stopped member's cancelled requests carried, then relay or ask the host to re-plan."""
1040
+ member = connection.execute("SELECT room_id,name FROM room_members WHERE id=?", (member_id,)).fetchone()
1041
+ session = current_session(connection, member["room_id"], active_only=True) if member else None
1042
+ if not session:
1043
+ return
1044
+ plans = connection.execute("""SELECT id,member_request_id FROM session_requests
1045
+ WHERE session_id=? AND member_id=? AND purpose='discuss' AND status='open'
1046
+ AND member_request_id IN (SELECT id FROM member_requests WHERE status='cancelled')""",
1047
+ (session["id"], member_id)).fetchall()
1048
+ for plan in plans:
1049
+ connection.execute("UPDATE session_requests SET status='cancelled' WHERE id=?", (plan["id"],))
1050
+ if not plans:
1051
+ return
1052
+ connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (session["id"],))
1053
+ if advance_session(connection, session["id"]):
1054
+ return
1055
+ request_id = plans[0]["member_request_id"]
1056
+ create_host_request(connection, session["id"],
1057
+ f"专家 {member['name']} 的讨论发言已被打断,请检查余下安排并决定如何继续。",
1058
+ {"kind": "request_failed", "request_id": request_id, "key": f"interrupted-{request_id or plans[0]['id']}"})
1059
+
1060
+
1061
+ def session_speaking_order(connection, session):
1062
+ """The open round's speakers so far, current turn and queued turns, later rounds apart, and the plan state for the revision."""
1063
+ items = []
1064
+ plan_state = [list(row) for row in connection.execute("""SELECT id,round,position,member_id,question,status,submission_id
1065
+ FROM session_requests WHERE session_id=? AND purpose='discuss' ORDER BY round,position,id""", (session["id"],))]
1066
+ active_round = connection.execute("SELECT MIN(round) FROM session_requests WHERE session_id=? AND purpose='discuss' AND status IN ('open','queued')",
1067
+ (session["id"],)).fetchone()[0]
1068
+ if active_round is None:
1069
+ active_round = connection.execute("SELECT COALESCE(MAX(round),1) FROM session_requests WHERE session_id=? AND purpose='discuss'",
1070
+ (session["id"],)).fetchone()[0]
1071
+ answered = [row["name"] for row in connection.execute("""SELECT m.name FROM session_requests sr JOIN room_members m ON m.id=sr.member_id
1072
+ WHERE sr.session_id=? AND sr.purpose='discuss' AND sr.round=? AND sr.status='answered' ORDER BY sr.position,sr.id""",
1073
+ (session["id"], active_round))]
1074
+ items.extend({"name": name, "state": "done"} for name in answered)
1075
+ open_turn = connection.execute("""SELECT m.name FROM session_requests sr JOIN room_members m ON m.id=sr.member_id
1076
+ WHERE sr.session_id=? AND sr.purpose='discuss' AND sr.round=? AND sr.status='open'
1077
+ ORDER BY sr.position,sr.id LIMIT 1""", (session["id"], active_round)).fetchone()
1078
+ if open_turn:
1079
+ items.append({"name": open_turn["name"], "state": "speaking"})
1080
+ queued = connection.execute("""SELECT sr.round,m.name FROM session_requests sr JOIN room_members m ON m.id=sr.member_id
1081
+ WHERE sr.session_id=? AND sr.purpose='discuss' AND sr.status='queued' ORDER BY sr.round,sr.position,sr.id""",
1082
+ (session["id"],)).fetchall()
1083
+ for row in queued:
1084
+ if row["round"] == active_round:
1085
+ items.append({"name": row["name"], "state": "queued"})
1086
+ later = [[row["name"] for row in queued if row["round"] == round_number]
1087
+ for round_number in sorted({row["round"] for row in queued if row["round"] > active_round})]
1088
+ return items, later, plan_state
1089
+
1090
+
1091
+ def reorder_session_turns(connection, session, order, names):
1092
+ """Renumber the open round's queued turns in the owner's order, after those already answered or speaking."""
1093
+ active_round = connection.execute("SELECT MIN(round) FROM session_requests WHERE session_id=? AND purpose='discuss' AND status IN ('open','queued')",
1094
+ (session["id"],)).fetchone()[0]
1095
+ active_round = active_round or 1
1096
+ open_position = connection.execute("SELECT COALESCE(MAX(position),0) FROM session_requests WHERE session_id=? AND purpose='discuss' AND round=? AND status IN ('answered','open')",
1097
+ (session["id"], active_round)).fetchone()[0]
1098
+ rows = connection.execute("SELECT id FROM session_requests WHERE session_id=? AND purpose='discuss' AND round=? AND status='queued'",
1099
+ (session["id"], active_round)).fetchall()
1100
+ connection.execute("UPDATE session_requests SET position=-id WHERE id IN (%s)" % ",".join("?" for _ in rows),
1101
+ [row["id"] for row in rows]) if rows else None
1102
+ for offset, name in enumerate(order, 1):
1103
+ connection.execute("UPDATE session_requests SET position=? WHERE session_id=? AND purpose='discuss' AND round=? AND status='queued' AND member_id=?",
1104
+ (open_position + offset, session["id"], active_round, names[name]))
1105
+ connection.execute("UPDATE session_state SET revision=revision+1 WHERE session_id=?", (session["id"],))
1106
+
1107
+
1108
+ def queue_session_invite(connection, session, member):
1109
+ """Queue a newly invited member at the tail of the session's open round, once per invitation; with no live plan it waits."""
1110
+ planned = connection.execute("SELECT 1 FROM session_requests WHERE session_id=? AND purpose='discuss' AND status IN ('open','queued') LIMIT 1",
1111
+ (session["id"],)).fetchone()
1112
+ if not planned:
1113
+ return
1114
+ round_number = connection.execute("SELECT MIN(round) FROM session_requests WHERE session_id=? AND purpose='discuss' AND status IN ('open','queued')",
1115
+ (session["id"],)).fetchone()[0]
1116
+ if round_number is None:
1117
+ round_number = 1
1118
+ position = connection.execute("SELECT COALESCE(MAX(position),0)+1 FROM session_requests WHERE session_id=? AND purpose='discuss' AND round=?",
1119
+ (session["id"], round_number)).fetchone()[0]
1120
+ invitation = connection.execute("SELECT COUNT(*) FROM session_requests WHERE session_id=? AND member_id=? AND submission_id LIKE 'invite:%'",
1121
+ (session["id"], member["id"])).fetchone()[0] + 1
1122
+ state = connection.execute("SELECT phase,read_until FROM session_state WHERE session_id=?", (session["id"],)).fetchone()
1123
+ question = ("欢迎加入:请先读取议题,独立给出你的开场观点。" if state and state["phase"] == "initial"
1124
+ else "欢迎加入:请先读取议题和已有回答,给出你的观点。")
1125
+ read_until = state["read_until"] if state and state["phase"] == "initial" and round_number == 1 else None
1126
+ connection.execute("""INSERT OR IGNORE INTO session_requests
1127
+ (session_id,round,position,member_id,purpose,question,status,submission_id,read_until,created_at)
1128
+ VALUES(?,?,?,?,'discuss',?,'queued',?,?,?)""",
1129
+ (session["id"], round_number, position, member["id"], question,
1130
+ f"invite:{member['id']}:{invitation}", read_until,
1131
+ time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())))
1132
+
1133
+
1134
+ def blocks_message_change(connection, room_id, message_id):
1135
+ """True while a discussion is open, or once one started after the message: its host reads user messages without a request."""
1136
+ return bool(connection.execute("SELECT 1 FROM discussion_sessions WHERE room_id=? AND (status IN ('active','paused') OR started_message_id>=?)",
1137
+ (room_id, message_id)).fetchone())
1138
+
1139
+
1140
+ def session_request_origin(connection, member_request_id):
1141
+ """The discussion turn a member request carries, with a visible transcript anchor; None outside a discussion."""
1142
+ row = connection.execute("""SELECT q.id AS session_request_id,q.session_id AS discussion_session_id,q.round,
1143
+ q.question AS source_question,t.cause_kind,t.cause_message_id AS source_message_id,t.cause_request_id,
1144
+ CASE WHEN q.id IS NULL THEN r.message_id ELSE COALESCE((SELECT MAX(m.id) FROM messages m
1145
+ WHERE m.room_id=r.room_id AND m.id<r.message_id
1146
+ AND NOT EXISTS(SELECT 1 FROM internal_task_messages hidden WHERE hidden.message_id=m.id)),0) END AS session_anchor
1147
+ FROM member_requests r LEFT JOIN session_requests q ON q.member_request_id=r.id
1148
+ LEFT JOIN task_message_sources t ON t.session_request_id=q.id WHERE r.id=?""", (member_request_id,)).fetchone()
1149
+ if not row or row["session_request_id"] is None:
1150
+ return None
1151
+ return {"session_request_id": row["session_request_id"], "discussion_session_id": row["discussion_session_id"],
1152
+ "round": row["round"], "source_question": row["source_question"],
1153
+ "source_message_id": row["source_message_id"], "cause_kind": row["cause_kind"],
1154
+ "cause_request_id": row["cause_request_id"], "after_message_id": row["session_anchor"]}