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.
- package/backend/free_room.py +32 -151
- package/backend/server.py +5 -0
- package/backend/session_room.py +156 -0
- package/docs/THIRD_PARTY_NOTICES.md +1264 -1264
- package/package.json +1 -1
- package/runner/discussion_service.mjs +17 -7
- package/runner/remote_launch.mjs +4 -3
- package/web/app.js +46 -11
- package/web/index.html +3 -0
- package/web/katex/LICENSE +21 -0
- package/web/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/web/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/web/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/web/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/web/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/web/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/web/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/web/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/web/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/web/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/web/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/web/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/web/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/web/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/web/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/web/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/web/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/web/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/web/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/web/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/web/katex/katex.min.css +1 -0
- package/web/markdown.css +1 -1
- package/web/modern.css +1 -1
package/backend/free_room.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1511
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2254
|
-
session = current_session(connection, room_id)
|
|
2157
|
+
session = discussion.current_session(connection, room_id)
|
|
2255
2158
|
if session and session["status"] != "concluded":
|
|
2256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2344
|
-
|
|
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
|
-
|
|
2416
|
-
|
|
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
|
-
|
|
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": "已收束"}
|
package/backend/session_room.py
CHANGED
|
@@ -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"]}
|