ai_co_discuss 0.2.69 → 0.2.70
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-participant/references/free.md +1 -1
- package/backend/free_room.py +163 -19
- package/backend/schema.sql +12 -1
- package/backend/server.py +19 -3
- package/docs/architecture.md +279 -277
- package/docs/cli.md +197 -196
- package/docs/workflow.md +5 -3
- package/package.json +1 -1
- package/runner/discuss.py +1001 -991
- package/runner/discussion_adapters.mjs +11 -4
- package/runner/free_runner.mjs +1 -1
- package/web/app.js +3382 -3370
- package/web/modern.css +1 -1
- package/web/modern.js +1623 -1417
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
## 主持人
|
|
14
14
|
|
|
15
|
-
任务文件 `role` 为 `host` 时你是本房间主持人:`route` 请求是用户未点名的消息,`handoff` 表示你派出的成员都已结束,`all` 表示用户请全体成员各自回应一次(其他成员已同时收到,你只回答自己的部分,不加 `--to`)。先 `room view` 看成员,需要他人时在回答中加 `--to 成员名`
|
|
15
|
+
任务文件 `role` 为 `host` 时你是本房间主持人:`route` 请求是用户未点名的消息,`handoff` 表示你派出的成员都已结束,`all` 表示用户请全体成员各自回应一次(其他成员已同时收到,你只回答自己的部分,不加 `--to`)。先 `room view` 看成员,需要他人时在回答中加 `--to 成员名` 派活并说明各自负责的文件或模块,不需要时不加,本次即结束;不点名自己,不扩大用户给的权限。同一条用户消息引出的 AI 请求(含你派出的成员)最多 8 次,用完即停并等用户发新消息,所以派活前先想清楚需要几位。房间开了智能调度时,它对未点名消息的判断记在 `room view` 的 `scheduler_decisions` 里,可读来了解这条消息为何交给你;仅供参考,读原文后自行决定,不当作用户指令。
|
|
16
16
|
|
|
17
17
|
多位成员共享同一工作目录时,只改分给自己的文件;只提交自己的改动,切分支、reset、全仓 stash 或 checkout 须用户确认。
|
|
18
18
|
|
package/backend/free_room.py
CHANGED
|
@@ -9,6 +9,7 @@ answers by itself always hosts, routes unnamed messages, names members in its an
|
|
|
9
9
|
woken once when everything it dispatched has finished. No software is launched here.
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
+
import hashlib
|
|
12
13
|
import hmac
|
|
13
14
|
import json
|
|
14
15
|
import re
|
|
@@ -38,6 +39,8 @@ ASSIGN_HOST = """UPDATE room_members SET role='host' WHERE id=(SELECT id FROM ro
|
|
|
38
39
|
AND access<>'online' ORDER BY access='runner' DESC, id LIMIT 1)
|
|
39
40
|
AND NOT EXISTS (SELECT 1 FROM room_members WHERE room_id=? AND role='host' AND status='active')"""
|
|
40
41
|
TERMINAL = ("completed", "cancelled", "failed")
|
|
42
|
+
# A queued member has been asked once its request for that message exists.
|
|
43
|
+
QUEUE_ASKED = "EXISTS(SELECT 1 FROM member_requests r WHERE r.message_id={row}.message_id AND r.member_id={row}.member_id)"
|
|
41
44
|
SESSION_MODES = ("new", "borrowed")
|
|
42
45
|
FREE_OPERATIONS = ("messages", "attachments", "move", "folder", "archive", "restore", "delete")
|
|
43
46
|
MEMBER_FIELDS = ("id", "name", "adapter", "role", "status", "access", "session_mode", "read_until")
|
|
@@ -247,7 +250,8 @@ def free_view(connection, room_id):
|
|
|
247
250
|
members.append(member)
|
|
248
251
|
names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
|
|
249
252
|
requests = [{**dict(row), "member": names.get(row["member_id"], "")} for row in connection.execute(
|
|
250
|
-
"""SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at
|
|
253
|
+
"""SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at,
|
|
254
|
+
(SELECT reason FROM request_cancel_reasons c WHERE c.request_id=member_requests.id) AS cancel_reason FROM member_requests
|
|
251
255
|
WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
|
|
252
256
|
chains = [dict(row) for row in connection.execute(
|
|
253
257
|
"SELECT chain_id,reason FROM free_chains WHERE room_id=? ORDER BY chain_id DESC LIMIT 10", (room_id,))]
|
|
@@ -550,24 +554,116 @@ def settle_batches(connection, room_id):
|
|
|
550
554
|
|
|
551
555
|
|
|
552
556
|
def queue_in_turn(connection, room_id, message_id, member_ids):
|
|
553
|
-
"""Line the named members up for one owner message
|
|
554
|
-
|
|
555
|
-
for row in connection.execute(
|
|
556
|
-
|
|
557
|
+
"""Line the named members up for one owner message in the room's speaking order; only the first is asked now."""
|
|
558
|
+
chosen = set(member_ids)
|
|
559
|
+
members = [dict(row) for row in connection.execute("SELECT id,name,role FROM room_members WHERE room_id=?", (room_id,))]
|
|
560
|
+
for member in default_order(connection, room_id, [member for member in members if member["id"] in chosen]):
|
|
561
|
+
connection.execute("INSERT INTO free_queue (room_id,message_id,member_id) VALUES (?,?,?)", (room_id, message_id, member["id"]))
|
|
557
562
|
advance_queues(connection, room_id)
|
|
558
563
|
|
|
559
564
|
|
|
560
565
|
def advance_queues(connection, room_id):
|
|
561
|
-
"""Ask the next queued member of each message whose previous request ended; members no longer present are skipped.
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
566
|
+
"""Ask the next queued member of each message whose previous request ended; members no longer present are skipped.
|
|
567
|
+
|
|
568
|
+
Asked rows stay, so the speaking order can still show who already answered.
|
|
569
|
+
"""
|
|
570
|
+
connection.execute(f"""DELETE FROM free_queue WHERE room_id=? AND NOT {QUEUE_ASKED.format(row="free_queue")}
|
|
571
|
+
AND NOT EXISTS(SELECT 1 FROM room_members m WHERE m.id=free_queue.member_id AND m.status='active')""", (room_id,))
|
|
572
|
+
connection.execute(f"""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,batch_id,purpose,created_by,created_at)
|
|
565
573
|
SELECT q.room_id,q.member_id,q.message_id,q.message_id,0,'ask','user',? FROM free_queue q
|
|
566
|
-
WHERE q.room_id=? AND q.id=(SELECT MIN(id) FROM free_queue x WHERE x.message_id=q.message_id)
|
|
574
|
+
WHERE q.room_id=? AND q.id=(SELECT MIN(x.id) FROM free_queue x WHERE x.message_id=q.message_id AND NOT {QUEUE_ASKED.format(row="x")})
|
|
567
575
|
AND NOT EXISTS(SELECT 1 FROM member_requests r WHERE r.message_id=q.message_id AND r.status IN ('pending','delivering'))""",
|
|
568
576
|
(int(time.time()), room_id))
|
|
569
|
-
|
|
570
|
-
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def default_order(connection, room_id, members):
|
|
580
|
+
"""Members in the room's saved speaking order; without one (or for members it lacks), the host first, then by joining order."""
|
|
581
|
+
row = connection.execute("SELECT member_ids FROM speaking_orders WHERE room_id=?", (room_id,)).fetchone()
|
|
582
|
+
rank = {member_id: index for index, member_id in enumerate(json.loads(row[0]) if row else [])}
|
|
583
|
+
return sorted(members, key=lambda member: (member["id"] not in rank, rank.get(member["id"], 0), member["role"] != "host", member["id"]))
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def speaking_order(connection, room):
|
|
587
|
+
"""Who spoke, who speaks now and who is next, shared by free rooms and discussions; only the not-yet-spoken part may be reordered.
|
|
588
|
+
|
|
589
|
+
A free room shows its latest unfinished one-after-another queue, else every member in the room's default order;
|
|
590
|
+
a discussion shows the open round: speakers so far, the current turn, then queued turns. The revision changes with any of it.
|
|
591
|
+
"""
|
|
592
|
+
active = [member for member in room["members"] if member["status"] == "active"]
|
|
593
|
+
by_id = {member["id"]: member for member in active}
|
|
594
|
+
ordered = [member["name"] for member in default_order(connection, room["id"], active)]
|
|
595
|
+
items, kind, target = [], "default", 0
|
|
596
|
+
if room["mode"] == "free":
|
|
597
|
+
target = connection.execute("""SELECT MAX(q.message_id) FROM free_queue q LEFT JOIN member_requests r ON r.message_id=q.message_id
|
|
598
|
+
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
|
|
599
|
+
if target:
|
|
600
|
+
kind = "queue"
|
|
601
|
+
for row in connection.execute("""SELECT q.member_id,r.status FROM free_queue q LEFT JOIN member_requests r
|
|
602
|
+
ON r.message_id=q.message_id AND r.member_id=q.member_id WHERE q.message_id=? ORDER BY q.id""", (target,)):
|
|
603
|
+
if row["member_id"] in by_id:
|
|
604
|
+
state = "queued" if row["status"] is None else "speaking" if row["status"] in ("pending", "delivering") else "done"
|
|
605
|
+
items.append({"name": by_id[row["member_id"]]["name"], "state": state})
|
|
606
|
+
else:
|
|
607
|
+
kind = "turns"
|
|
608
|
+
last = room["rounds"][-1] if room["rounds"] else None
|
|
609
|
+
items = [{"name": name, "state": "done"} for name in (last["speakers"] if last and not last["completed"] else [])]
|
|
610
|
+
turn = room["current_turn"]
|
|
611
|
+
if turn and turn["status"] in ("pending", "notified"):
|
|
612
|
+
items.append({"name": turn["expert"], "state": "speaking"})
|
|
613
|
+
items += [{"name": item["expert"], "state": "queued"} for item in room["queued_turns"]]
|
|
614
|
+
# The user may still hold the host seat of an old discussion; only AI participants have a place.
|
|
615
|
+
ordered = [name for name in [room["host"], *ordered, *room["experts"]] if (name in room["experts"] or name == room["host"]) and name != "用户"]
|
|
616
|
+
listed = {item["name"] for item in items}
|
|
617
|
+
items += [{"name": name, "state": "idle"} for name in dict.fromkeys(ordered) if name not in listed]
|
|
618
|
+
movable = "queued" if kind != "default" else "idle"
|
|
619
|
+
state = json.dumps([kind, target, [[item["name"], item["state"]] for item in items]], ensure_ascii=False)
|
|
620
|
+
return {"kind": kind, "message_id": target, "items": items, "movable": [item["name"] for item in items if item["state"] == movable],
|
|
621
|
+
"default": [member["name"] for member in default_order(connection, room["id"], active)],
|
|
622
|
+
"revision": hashlib.sha256(state.encode("utf-8")).hexdigest()[:12]}
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def reorder_speakers(connection, room, token, payload):
|
|
626
|
+
"""Owner reorders the not-yet-spoken part of the speaking order; who already spoke or speaks now stays put.
|
|
627
|
+
|
|
628
|
+
Read the speaking order first and send its revision: a missing revision is refused, a changed one conflicts,
|
|
629
|
+
and the new order must list exactly the movable members.
|
|
630
|
+
"""
|
|
631
|
+
require_owner(connection, room["id"], token)
|
|
632
|
+
current = room["speaking_order"]
|
|
633
|
+
if payload.get("revision") is None:
|
|
634
|
+
raise ValueError("调整发言顺序前须先读取最新顺序(speaking-order),并带上其 revision")
|
|
635
|
+
if payload["revision"] != current["revision"]:
|
|
636
|
+
raise FileExistsError("发言顺序已变化,请重新读取后再调整")
|
|
637
|
+
order = payload.get("order")
|
|
638
|
+
movable = current["movable"]
|
|
639
|
+
if not isinstance(order, list) or sorted(map(str, order)) != sorted(movable):
|
|
640
|
+
given = [str(name) for name in order] if isinstance(order, list) else []
|
|
641
|
+
missing = [name for name in dict.fromkeys(movable) if given.count(name) < movable.count(name)]
|
|
642
|
+
extra = [name for name in dict.fromkeys(given) if movable.count(name) < given.count(name)]
|
|
643
|
+
raise ValueError("新顺序须恰好包含尚未发言的成员" + (f";缺少:{'、'.join(missing)}" if missing else "") + (f";多出:{'、'.join(extra)}" if extra else ""))
|
|
644
|
+
names = {member["name"]: member["id"] for member in room["members"] if member["status"] == "active"}
|
|
645
|
+
if current["kind"] == "queue":
|
|
646
|
+
connection.execute(f"DELETE FROM free_queue WHERE message_id=? AND NOT {QUEUE_ASKED.format(row='free_queue')}", (current["message_id"],))
|
|
647
|
+
for name in order:
|
|
648
|
+
connection.execute("INSERT INTO free_queue (room_id,message_id,member_id) VALUES (?,?,?)", (room["id"], current["message_id"], names[name]))
|
|
649
|
+
elif current["kind"] == "turns":
|
|
650
|
+
reorder_turns(connection, room["id"], order)
|
|
651
|
+
else:
|
|
652
|
+
connection.execute("INSERT OR REPLACE INTO speaking_orders (room_id,member_ids) VALUES (?,?)",
|
|
653
|
+
(room["id"], json.dumps([names[name] for name in order])))
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def reorder_turns(connection, room_id, order):
|
|
657
|
+
"""Move queued turns into the new order; ids keep the relay order, so each turn's content moves to the id at its new place."""
|
|
658
|
+
rows = [dict(row) for row in connection.execute("SELECT * FROM discussion_turns WHERE room_id=? AND status='queued' ORDER BY id", (room_id,))]
|
|
659
|
+
remaining, moved = list(rows), []
|
|
660
|
+
for name in order:
|
|
661
|
+
moved.append(remaining.pop(next(index for index, row in enumerate(remaining) if row["expert"] == name)))
|
|
662
|
+
# Submission ids are unique, so free them before writing them to their new rows.
|
|
663
|
+
connection.execute("UPDATE discussion_turns SET submission_id=submission_id||':moving:'||id WHERE room_id=? AND status='queued'", (room_id,))
|
|
664
|
+
for slot, content in zip(rows, moved):
|
|
665
|
+
connection.execute("UPDATE discussion_turns SET expert=?,question=?,submission_id=?,created_at=? WHERE id=?",
|
|
666
|
+
(content["expert"], content["question"], content["submission_id"], content["created_at"], slot["id"]))
|
|
571
667
|
|
|
572
668
|
|
|
573
669
|
def answered_chain(connection, payload, actor):
|
|
@@ -641,7 +737,8 @@ def asks_view(connection, room_id):
|
|
|
641
737
|
"""Direct questions of a discussion room: recent requests with their member, for status badges beside messages."""
|
|
642
738
|
names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
|
|
643
739
|
requests = [{**dict(row), "member": names.get(row["member_id"], "")} for row in connection.execute(
|
|
644
|
-
"""SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at
|
|
740
|
+
"""SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at,
|
|
741
|
+
(SELECT reason FROM request_cancel_reasons c WHERE c.request_id=member_requests.id) AS cancel_reason FROM member_requests
|
|
645
742
|
WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
|
|
646
743
|
return {"requests": requests[::-1], "executions": recent_executions(connection, room_id), "execution_minutes": execution_minutes(connection, room_id)}
|
|
647
744
|
|
|
@@ -659,22 +756,56 @@ def expire_executions(connection, room_id):
|
|
|
659
756
|
settle_batches(connection, room_id)
|
|
660
757
|
|
|
661
758
|
|
|
662
|
-
def
|
|
759
|
+
def record_cancel_reasons(connection, where, params, reason):
|
|
760
|
+
"""Remember why requests are about to be cancelled; call before the status change selects them."""
|
|
761
|
+
connection.execute(f"INSERT OR REPLACE INTO request_cancel_reasons (request_id,reason) SELECT id,? FROM member_requests WHERE {where}", (reason, *params))
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def finish_execution(connection, execution_id, status, result, request_status, cancel_reason=""):
|
|
663
765
|
"""End one execution and move its delivered requests in the same transaction."""
|
|
766
|
+
if request_status == "cancelled" and cancel_reason:
|
|
767
|
+
record_cancel_reasons(connection, "execution_id=? AND status='delivering'", (execution_id,), cancel_reason)
|
|
664
768
|
connection.execute("UPDATE member_executions SET status=?,finished_at=?,result=? WHERE id=? AND status IN ('dispatched','started')",
|
|
665
769
|
(status, int(time.time()), result[:500], execution_id))
|
|
666
770
|
connection.execute("UPDATE member_requests SET status=?,execution_id=CASE WHEN ?='pending' THEN NULL ELSE execution_id END WHERE execution_id=? AND status='delivering'",
|
|
667
771
|
(request_status, request_status, execution_id))
|
|
668
772
|
|
|
669
773
|
|
|
670
|
-
def
|
|
774
|
+
def open_route_messages(connection, member_id, pending_only=False):
|
|
775
|
+
"""Messages whose host request is still open, in order; they need a new host if this one leaves."""
|
|
776
|
+
states = "'pending'" if pending_only else "'pending','delivering'"
|
|
777
|
+
return [row[0] for row in connection.execute(
|
|
778
|
+
f"SELECT message_id FROM member_requests WHERE member_id=? AND purpose='route' AND status IN ({states}) ORDER BY message_id", (member_id,))]
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def hand_over_routes(connection, room_id, message_ids, departed_id):
|
|
782
|
+
"""Ask the current host to route messages left behind by the previous host."""
|
|
783
|
+
host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND status='active' AND id<>?",
|
|
784
|
+
(room_id, departed_id or 0)).fetchone()
|
|
785
|
+
for message_id in message_ids if host else []:
|
|
786
|
+
connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
|
|
787
|
+
VALUES (?,?,?,?,'route','user',?)""", (room_id, host["id"], message_id, message_id, int(time.time())))
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def stop_member_work(connection, member_id, reason, cancel_reason):
|
|
671
791
|
"""Void a member's live execution and every open request, so nothing restarts by itself."""
|
|
672
792
|
for row in connection.execute("SELECT id FROM member_executions WHERE member_id=? AND status IN ('dispatched','started')",
|
|
673
793
|
(member_id,)).fetchall():
|
|
674
|
-
finish_execution(connection, row["id"], "cancelled", reason, "cancelled")
|
|
794
|
+
finish_execution(connection, row["id"], "cancelled", reason, "cancelled", cancel_reason)
|
|
795
|
+
record_cancel_reasons(connection, "member_id=? AND status='pending'", (member_id,), cancel_reason)
|
|
675
796
|
connection.execute("UPDATE member_requests SET status='cancelled' WHERE member_id=? AND status='pending'", (member_id,))
|
|
676
797
|
|
|
677
798
|
|
|
799
|
+
def stop_room_work(connection, room_id, reason, cancel_reason):
|
|
800
|
+
"""Void every live execution and open request of a room, e.g. when it is archived."""
|
|
801
|
+
for row in connection.execute("SELECT id FROM member_executions WHERE room_id=? AND status IN ('dispatched','started')",
|
|
802
|
+
(room_id,)).fetchall():
|
|
803
|
+
finish_execution(connection, row["id"], "cancelled", reason, "cancelled", cancel_reason)
|
|
804
|
+
record_cancel_reasons(connection, "room_id=? AND status='pending'", (room_id,), cancel_reason)
|
|
805
|
+
connection.execute("UPDATE member_requests SET status='cancelled' WHERE room_id=? AND status='pending'", (room_id,))
|
|
806
|
+
settle_batches(connection, room_id)
|
|
807
|
+
|
|
808
|
+
|
|
678
809
|
def inbox(connection, room_id, token):
|
|
679
810
|
"""List what the runner should deliver or reconcile; owner credential only."""
|
|
680
811
|
require_owner(connection, room_id, token)
|
|
@@ -742,6 +873,7 @@ def route_wide(connection, room_id, message_id, action):
|
|
|
742
873
|
if route["status"] != "pending":
|
|
743
874
|
raise FileExistsError("主持人已开始处理这条消息,不再改派")
|
|
744
875
|
if action == "none":
|
|
876
|
+
record_cancel_reasons(connection, "id=?", (route["id"],), "no_reply")
|
|
745
877
|
connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?", (route["id"],))
|
|
746
878
|
else:
|
|
747
879
|
# The host keeps its row (one request per message and member) and now answers its own share, without dispatching.
|
|
@@ -777,8 +909,9 @@ def adopt_recommendation(connection, room_id, token, message_id, payload):
|
|
|
777
909
|
mode = payload["mode"]
|
|
778
910
|
if mode == "interrupt" and route["status"] == "delivering":
|
|
779
911
|
# The host's execution may carry other messages too; stopping it voids that whole execution.
|
|
780
|
-
finish_execution(connection, route["execution_id"], "cancelled", "智能调度改派,改由其他成员回答", "cancelled")
|
|
912
|
+
finish_execution(connection, route["execution_id"], "cancelled", "智能调度改派,改由其他成员回答", "cancelled", "rerouted")
|
|
781
913
|
elif mode == "interrupt" and route["status"] == "pending":
|
|
914
|
+
record_cancel_reasons(connection, "id=?", (route["id"],), "rerouted")
|
|
782
915
|
connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?", (route["id"],))
|
|
783
916
|
connection.execute("UPDATE member_recommendations SET status='waiting',mode=? WHERE message_id=?", (mode, message_id))
|
|
784
917
|
release_recommendations(connection, room_id)
|
|
@@ -903,7 +1036,7 @@ def owned_member(connection, room_id, member_id, token):
|
|
|
903
1036
|
def interrupt_member(connection, room_id, member_id, token):
|
|
904
1037
|
"""Owner stop: void the live execution and every open request."""
|
|
905
1038
|
owned_member(connection, room_id, member_id, token)
|
|
906
|
-
stop_member_work(connection, member_id, "用户打断")
|
|
1039
|
+
stop_member_work(connection, member_id, "用户打断", "user_stop")
|
|
907
1040
|
settle_batches(connection, room_id)
|
|
908
1041
|
return free_view(connection, room_id)
|
|
909
1042
|
|
|
@@ -1057,8 +1190,12 @@ def member_action(connection, room_id, member_id, token, payload):
|
|
|
1057
1190
|
"""
|
|
1058
1191
|
member = owned_member(connection, room_id, member_id, token)
|
|
1059
1192
|
action = payload.get("action")
|
|
1193
|
+
routed = []
|
|
1060
1194
|
if action in ("away", "remove"):
|
|
1061
|
-
|
|
1195
|
+
# Unnamed messages still waiting on a departing host move to its successor instead of being dropped.
|
|
1196
|
+
routed = open_route_messages(connection, member_id)
|
|
1197
|
+
stop_member_work(connection, member_id, "成员已离开" if action == "away" else "成员已移除",
|
|
1198
|
+
"member_away" if action == "away" else "member_removed")
|
|
1062
1199
|
connection.execute("UPDATE room_members SET status=? WHERE id=?", ("away" if action == "away" else "removed", member_id))
|
|
1063
1200
|
elif action == "active":
|
|
1064
1201
|
hashed = token_hash(payload.get("token"))
|
|
@@ -1077,10 +1214,17 @@ def member_action(connection, room_id, member_id, token, payload):
|
|
|
1077
1214
|
if role == "member" and member["role"] == "host":
|
|
1078
1215
|
raise ValueError("自由房间须由一位 AI 主持;请指定另一位成员担任主持人")
|
|
1079
1216
|
if role == "host":
|
|
1217
|
+
# Unnamed messages the old host has not started move to the new host, which carries the host rules.
|
|
1218
|
+
old_host = connection.execute("SELECT id FROM room_members WHERE room_id=? AND role='host' AND id<>?", (room_id, member_id)).fetchone()
|
|
1219
|
+
if old_host:
|
|
1220
|
+
routed = open_route_messages(connection, old_host["id"], pending_only=True)
|
|
1221
|
+
record_cancel_reasons(connection, "member_id=? AND purpose='route' AND status='pending'", (old_host["id"],), "rerouted")
|
|
1222
|
+
connection.execute("UPDATE member_requests SET status='cancelled' WHERE member_id=? AND purpose='route' AND status='pending'", (old_host["id"],))
|
|
1080
1223
|
connection.execute("UPDATE room_members SET role='member' WHERE room_id=? AND role='host'", (room_id,))
|
|
1081
1224
|
connection.execute("UPDATE room_members SET role=? WHERE id=?", (role, member_id))
|
|
1082
1225
|
else:
|
|
1083
1226
|
raise ValueError("action 须为 away、active、remove、role 或 adopt")
|
|
1084
1227
|
settle_batches(connection, room_id)
|
|
1085
1228
|
sync_participants(connection, room_id)
|
|
1229
|
+
hand_over_routes(connection, room_id, routed, member_id if action in ("away", "remove") else None)
|
|
1086
1230
|
return free_view(connection, room_id)
|
package/backend/schema.sql
CHANGED
|
@@ -602,6 +602,11 @@ CREATE TABLE IF NOT EXISTS scheduler_routing (
|
|
|
602
602
|
created_at INTEGER NOT NULL,
|
|
603
603
|
PRIMARY KEY (room_id, message_id)
|
|
604
604
|
);
|
|
605
|
+
-- Why a request was cancelled; a side table so older databases need no column change. Requests cancelled before it existed have no row.
|
|
606
|
+
CREATE TABLE IF NOT EXISTS request_cancel_reasons (
|
|
607
|
+
request_id INTEGER PRIMARY KEY REFERENCES member_requests(id) ON DELETE CASCADE,
|
|
608
|
+
reason TEXT NOT NULL CHECK(reason IN ('user_stop','member_away','member_removed','rerouted','no_reply','archived'))
|
|
609
|
+
);
|
|
605
610
|
-- What produced a decision (model, threshold, prompt and package versions), so ratings stay comparable across versions.
|
|
606
611
|
CREATE TABLE IF NOT EXISTS scheduler_decision_context (
|
|
607
612
|
decision_id INTEGER PRIMARY KEY REFERENCES scheduler_decisions(id) ON DELETE CASCADE,
|
|
@@ -635,13 +640,19 @@ CREATE TABLE IF NOT EXISTS free_batches (
|
|
|
635
640
|
settled INTEGER NOT NULL DEFAULT 0,
|
|
636
641
|
PRIMARY KEY (room_id,batch_id)
|
|
637
642
|
);
|
|
638
|
-
-- Members the user named to answer one after another: each gets its request once the one before
|
|
643
|
+
-- Members the user named to answer one after another, in the room's speaking order: each gets its request once the one before
|
|
644
|
+
-- it ends. Asked rows stay so the speaking order can show who already answered; the owner may reorder rows not yet asked.
|
|
639
645
|
CREATE TABLE IF NOT EXISTS free_queue (
|
|
640
646
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
641
647
|
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
|
642
648
|
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
643
649
|
member_id INTEGER NOT NULL REFERENCES room_members(id) ON DELETE CASCADE
|
|
644
650
|
);
|
|
651
|
+
-- The owner's default speaking order (JSON list of member ids); members it lacks follow, the host first, then by joining order.
|
|
652
|
+
CREATE TABLE IF NOT EXISTS speaking_orders (
|
|
653
|
+
room_id TEXT PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
|
|
654
|
+
member_ids TEXT NOT NULL
|
|
655
|
+
);
|
|
645
656
|
-- Chains whose automatic progress stopped (budget reached or every dispatched member failed).
|
|
646
657
|
CREATE TABLE IF NOT EXISTS free_chains (
|
|
647
658
|
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
package/backend/server.py
CHANGED
|
@@ -26,6 +26,7 @@ from .free_room import (add_member, after_free_message, authorize_free_write, ch
|
|
|
26
26
|
discussion_members, room_mode, save_room_access, sync_discussion_members, view_execution)
|
|
27
27
|
from .free_room import add_discussion_member, convert_suggestion, discussion_member_action, message_suggestions, save_suggestions, sync_participants
|
|
28
28
|
from .free_room import active_members, adopt_recommendation, ask_members, asks_view, checked_asks, recommend_member, register_member, route_held_messages
|
|
29
|
+
from .free_room import reorder_speakers, speaking_order, stop_room_work
|
|
29
30
|
from .identity import MAX_ROOM_AGENTS, PARTICIPANTS, is_ai, is_participant
|
|
30
31
|
from .room_import import import_room, imported_attachment
|
|
31
32
|
from .runtime import (authorize_write, begin_write, configure_runtime, event_action,
|
|
@@ -252,6 +253,7 @@ def require_room(connection, room_id):
|
|
|
252
253
|
# Free rooms have no host hand-off: user messages go straight to the named members.
|
|
253
254
|
room["held_since"] = 0
|
|
254
255
|
room["free"] = free_view(connection, room_id)
|
|
256
|
+
room["speaking_order"] = speaking_order(connection, room)
|
|
255
257
|
return room
|
|
256
258
|
|
|
257
259
|
|
|
@@ -621,6 +623,9 @@ def manage_room(connection, room_id, action, payload):
|
|
|
621
623
|
elif action in ("archive", "restore"):
|
|
622
624
|
if action == "archive" and room["phase"] != "concluded":
|
|
623
625
|
save_topic_settings(connection, room_id, {**room, "phase": "paused"})
|
|
626
|
+
if action == "archive" and room["mode"] == "free":
|
|
627
|
+
# An archived room is read-only: nothing it still owes may be delivered or answered later.
|
|
628
|
+
stop_room_work(connection, room_id, "房间已归档", "archived")
|
|
624
629
|
connection.execute("UPDATE room_directory SET archived=? WHERE room_id=?", (int(action == "archive"), room_id))
|
|
625
630
|
elif action == "delete":
|
|
626
631
|
if payload.get("confirm_title") != room["title"]:
|
|
@@ -634,7 +639,7 @@ def manage_room(connection, room_id, action, payload):
|
|
|
634
639
|
|
|
635
640
|
def delete_room_records(connection, room_id):
|
|
636
641
|
"""Remove a room's dependent records in the caller's transaction."""
|
|
637
|
-
for table in ("member_recommendations", "free_queue", "member_requests", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
|
|
642
|
+
for table in ("member_recommendations", "free_queue", "speaking_orders", "member_requests", "member_executions", "member_sessions", "member_registrations", "room_members", "room_modes", "free_batches", "free_chains", "room_links",
|
|
638
643
|
"discussion_options", "round_budgets", "scheduler_decisions", "scheduler_routing", "discussion_sessions"):
|
|
639
644
|
if table == "scheduler_decisions":
|
|
640
645
|
for child in ("scheduler_feedback", "scheduler_decision_context"):
|
|
@@ -1144,8 +1149,10 @@ def roster_view(room):
|
|
|
1144
1149
|
last = room["rounds"][-1] if room["rounds"] else None
|
|
1145
1150
|
spoken = set(last["speakers"]) if last and not last["completed"] else set()
|
|
1146
1151
|
pending_registration = unregistered_experts(room)
|
|
1147
|
-
|
|
1148
|
-
|
|
1152
|
+
# Experts follow the room's speaking order, so a round plan can take the user's preferred order from the roster.
|
|
1153
|
+
rank = {item["name"]: index for index, item in enumerate(room["speaking_order"]["items"])}
|
|
1154
|
+
experts = [{"name": name, "mode": room["experts"][name], "host": name == room["host"], "spoken": name in spoken,
|
|
1155
|
+
"registered": name not in pending_registration} for name in sorted(room["experts"], key=lambda name: rank.get(name, len(rank)))]
|
|
1149
1156
|
state = json.dumps([sorted(room["experts"]), sorted(spoken), sorted(pending_registration)], ensure_ascii=False)
|
|
1150
1157
|
return {"experts": experts, "unregistered": pending_registration,
|
|
1151
1158
|
"waiting": [item["name"] for item in experts if not item["spoken"] and not item["host"] and item["registered"]],
|
|
@@ -1943,6 +1950,15 @@ class DiscussionHandler(BaseHTTPRequestHandler):
|
|
|
1943
1950
|
result = register_member(connection, room_id, token, utc_now())
|
|
1944
1951
|
connection.commit()
|
|
1945
1952
|
return self.respond(result)
|
|
1953
|
+
if parts[3:] == ["speaking-order"]:
|
|
1954
|
+
if method == "GET":
|
|
1955
|
+
return self.respond(require_room(connection, room_id)["speaking_order"])
|
|
1956
|
+
payload = self.read_payload()
|
|
1957
|
+
begin_write(connection)
|
|
1958
|
+
reorder_speakers(connection, require_room(connection, room_id), token, payload)
|
|
1959
|
+
result = require_room(connection, room_id)
|
|
1960
|
+
connection.commit()
|
|
1961
|
+
return self.respond(result)
|
|
1946
1962
|
free = room_mode(connection, room_id) == "free"
|
|
1947
1963
|
# A discussion keeps the local service's inbox and per-member executions for the user's direct questions.
|
|
1948
1964
|
if parts[3:4] in (["inbox"], ["executions"]) or (free and parts[3:4] in (["members"], ["suggestions"], ["recommendations"])):
|