ai_co_discuss 0.3.72 → 0.3.74

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/README.md CHANGED
@@ -115,7 +115,7 @@ Details: [Architecture](docs/en/architecture.md) · [Setup](docs/en/runner.md)
115
115
  The site can be self-hosted on **Cloudflare Pages + D1** or **Docker**; agents and the runner still run on your own machine. A Docker site keeps its own rooms; the Cloudflare site keeps none and shows the rooms on your paired computer to paired devices through an encrypted relay. See [Deployment](docs/en/deployment.md).
116
116
 
117
117
  > [!WARNING]
118
- > The site does not yet provide user login or read-access isolation. Anyone who can reach the site can read its rooms. Read the [security policy](.github/SECURITY.md) before exposing it publicly.
118
+ > Sites that store rooms (local or Docker) do not yet provide user login or read-access isolation: anyone who can reach such a site can read its rooms. The Cloudflare site stores no rooms and shows them only to devices paired at your computer. Read the [security policy](.github/SECURITY.md) before exposing a site publicly.
119
119
 
120
120
  ## Documentation
121
121
 
package/README.zh-CN.md CHANGED
@@ -109,7 +109,7 @@ flowchart LR
109
109
  网站支持自托管于 **Cloudflare Pages + D1** 或 **Docker**;AI 与接入服务仍在你的电脑运行。Docker 站点自己保存房间;Cloudflare 站点不保存房间,已配对的设备经加密中转看到电脑上的房间。见 [部署指南](docs/deployment.md)。
110
110
 
111
111
  > [!WARNING]
112
- > 网站暂无用户登录与读取权限隔离,能访问站点的人即可阅读其中的房间。公开部署前请阅读 [安全说明](.github/SECURITY.md)。
112
+ > 保存房间的站点(本机或 Docker)暂无用户登录与读取权限隔离,能访问该站点的人即可阅读其中的房间。Cloudflare 站点不保存房间,只向在电脑前配对过的设备显示。公开部署前请阅读 [安全说明](.github/SECURITY.md)。
113
113
 
114
114
  ## 文档
115
115
 
@@ -1885,6 +1885,9 @@ def inbox(connection, room_id, token):
1885
1885
  # The runner compares this with its adapter's effect and reports a change.
1886
1886
  reported = connection.execute("SELECT rewind FROM member_capabilities WHERE member_id=?", (row["id"],)).fetchone()
1887
1887
  member_view["rewind"] = reported[0] if reported else ""
1888
+ # Private chat lines the runner still has to run in this member's session (backend/private_chat.py).
1889
+ member_view["chats"] = [dict(item) for item in connection.execute(
1890
+ "SELECT id,text,status FROM private_chats WHERE member_id=? AND status IN ('pending','running') ORDER BY id", (row["id"],))]
1888
1891
  pending = [{**request, **review_request_context(connection, request), **sequence_request_context(connection, request)} for request in pending]
1889
1892
  (members if row["access"] == "runner" else observers).append({**member_view, "pending": pending})
1890
1893
  # Pending interjections ride along so the runner hands them to the running software at its next check.
@@ -0,0 +1,108 @@
1
+ """
2
+ Private chat: the owner talks to one room member directly, in the member's own session, outside the room's messages.
3
+
4
+ Each row of `private_chats` is one thing the owner said and the member's reply. While the member works on a room
5
+ execution that takes interjections, a new line goes in as an interjection of that execution; otherwise it waits until
6
+ the member is idle and the runner runs it as a turn of its own, taking the reply from the software's output. Only the
7
+ owner reads or writes chats; the runner, which holds the owner credential, reports their progress.
8
+ """
9
+ import time
10
+
11
+ from .free_room import ACTIVE, add_interjection
12
+ from .runtime import begin_write, require_owner
13
+
14
+ MAX_CHAT_LENGTH = 4000
15
+ MAX_REPLY_LENGTH = 20000
16
+ MAX_REASON_LENGTH = 500
17
+ SHOWN_CHATS = 200
18
+ # Interjections made from a chat line carry its submission id under this prefix, so the two are found together.
19
+ INTERJECTION_PREFIX = "chat-"
20
+ # Runner reports and the owner's stop, each with the states it may leave.
21
+ TRANSITIONS = {"start": ("pending", "running"), "steered": ("pending", "interjected"), "reply": ("running", "answered"),
22
+ "fail": (("pending", "running"), "failed"), "stop": (("pending", "running"), "stopped")}
23
+
24
+
25
+ def chat_member(connection, room_id, member_id):
26
+ """The active member the runner wakes; a manual member is reached only in its own window."""
27
+ member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND id=? AND status='active'", (room_id, member_id)).fetchone()
28
+ if not member:
29
+ raise LookupError("成员不存在或已离开")
30
+ if member["access"] != "runner":
31
+ raise FileExistsError("这位成员是手动接入的,只能在它自己的窗口里对话")
32
+ return member
33
+
34
+
35
+ def public_chat(connection, row):
36
+ """One chat line as the page shows it; a line passed in as an interjection shows how that went."""
37
+ chat = {key: row[key] for key in ("id", "member_id", "text", "status", "reply", "reason", "execution_id", "created_at", "updated_at")}
38
+ if row["status"] == "interjected":
39
+ interjection = connection.execute("""SELECT i.status,i.reason,e.status AS execution_status FROM execution_interjections i
40
+ JOIN member_executions e ON e.id=i.execution_id WHERE i.room_id=? AND i.submission_id=?""",
41
+ (row["room_id"], INTERJECTION_PREFIX + row["submission_id"])).fetchone()
42
+ if interjection:
43
+ ended = interjection["status"] == "pending" and interjection["execution_status"] not in ACTIVE
44
+ chat["interjection"] = "failed" if ended else interjection["status"]
45
+ chat["reason"] = "送达前那次执行已结束" if ended else interjection["reason"]
46
+ return chat
47
+
48
+
49
+ def member_chat(connection, room_id, member_id, token, payload=None):
50
+ """Read one member's private chat, or add the owner's next line to it."""
51
+ require_owner(connection, room_id, token)
52
+ if payload is None:
53
+ chat_member(connection, room_id, member_id)
54
+ rows = connection.execute("SELECT * FROM (SELECT * FROM private_chats WHERE room_id=? AND member_id=? ORDER BY id DESC LIMIT ?) ORDER BY id",
55
+ (room_id, member_id, SHOWN_CHATS)).fetchall()
56
+ return {"member_id": member_id, "chats": [public_chat(connection, row) for row in rows]}
57
+ begin_write(connection)
58
+ member = chat_member(connection, room_id, member_id)
59
+ text, submission_id = payload.get("text"), payload.get("submission_id")
60
+ if not isinstance(text, str) or not 0 < len(text.strip()) <= MAX_CHAT_LENGTH:
61
+ raise ValueError(f"私聊内容须为 1–{MAX_CHAT_LENGTH} 字")
62
+ if not isinstance(submission_id, str) or not 0 < len(submission_id) <= 100:
63
+ raise ValueError("私聊须带 submission_id;重试沿用原编号")
64
+ earlier = connection.execute("SELECT * FROM private_chats WHERE room_id=? AND submission_id=?", (room_id, submission_id)).fetchone()
65
+ if earlier:
66
+ if earlier["member_id"] != member_id or earlier["text"] != text:
67
+ raise FileExistsError("submission_id 已用于另一句私聊")
68
+ return public_chat(connection, earlier)
69
+ now = int(time.time())
70
+ # A room execution that takes interjections hears it now; one that cannot waits, like a turn already running.
71
+ execution = connection.execute("""SELECT e.* FROM member_executions e JOIN execution_capabilities c ON c.execution_id=e.id
72
+ WHERE e.member_id=? AND e.status IN ('dispatched','started') AND c.steer=1 ORDER BY e.id DESC LIMIT 1""", (member["id"],)).fetchone()
73
+ status, execution_id = "pending", None
74
+ if execution:
75
+ add_interjection(connection, room_id, execution, {"text": text, "submission_id": INTERJECTION_PREFIX + submission_id}, now)
76
+ status, execution_id = "interjected", execution["id"]
77
+ cursor = connection.execute("""INSERT INTO private_chats (room_id,member_id,submission_id,text,status,execution_id,created_at,updated_at)
78
+ VALUES (?,?,?,?,?,?,?,?)""", (room_id, member_id, submission_id, text, status, execution_id, now, now))
79
+ return public_chat(connection, connection.execute("SELECT * FROM private_chats WHERE id=?", (cursor.lastrowid,)).fetchone())
80
+
81
+
82
+ def chat_action(connection, room_id, chat_id, token, payload):
83
+ """The runner's progress on one chat line (start, steered, reply, fail), or the owner's stop."""
84
+ require_owner(connection, room_id, token)
85
+ begin_write(connection)
86
+ action = payload.get("action")
87
+ if action not in TRANSITIONS:
88
+ raise ValueError(f"action 须为 {'、'.join(TRANSITIONS)}")
89
+ row = connection.execute("SELECT * FROM private_chats WHERE room_id=? AND id=?", (room_id, chat_id)).fetchone()
90
+ if not row:
91
+ raise LookupError("私聊不存在")
92
+ allowed, target = TRANSITIONS[action]
93
+ if row["status"] == target:
94
+ return public_chat(connection, row)
95
+ if row["status"] not in (allowed if isinstance(allowed, tuple) else (allowed,)):
96
+ raise FileExistsError(f"这句私聊当前为 {row['status']},不能 {action}")
97
+ reply, reason = payload.get("text", ""), payload.get("reason", "")
98
+ if not isinstance(reply, str) or len(reply) > MAX_REPLY_LENGTH or not isinstance(reason, str) or len(reason) > MAX_REASON_LENGTH:
99
+ raise ValueError(f"text 最多 {MAX_REPLY_LENGTH} 字,reason 最多 {MAX_REASON_LENGTH} 字")
100
+ connection.execute("UPDATE private_chats SET status=?,reply=?,reason=?,updated_at=? WHERE id=?",
101
+ (target, reply if action == "reply" else row["reply"], reason, int(time.time()), chat_id))
102
+ return public_chat(connection, connection.execute("SELECT * FROM private_chats WHERE id=?", (chat_id,)).fetchone())
103
+
104
+
105
+ def open_chats(connection, member_id):
106
+ """The lines the runner still has to run or is running for one member, in order."""
107
+ return [dict(row) for row in connection.execute(
108
+ "SELECT id,text,status FROM private_chats WHERE member_id=? AND status IN ('pending','running') ORDER BY id", (member_id,))]
@@ -461,6 +461,26 @@ CREATE TABLE IF NOT EXISTS execution_interjections (
461
461
  UNIQUE(room_id,submission_id)
462
462
  );
463
463
  CREATE INDEX IF NOT EXISTS execution_interjections_execution ON execution_interjections(execution_id);
464
+ -- The owner's private chat with one room member: each row is one thing the owner said and the member's reply, outside
465
+ -- the room's messages, so other members never read it. It runs in the member's own session: while the member works on
466
+ -- a room execution that takes interjections it goes in as one (`interjected`, execution_id); otherwise it waits
467
+ -- (`pending`) until the member is idle and the runner runs it as its own turn (`running`), taking the reply from the
468
+ -- software's own output (`answered`). The owner may stop it (`stopped`); the runner reports `failed` with a reason.
469
+ CREATE TABLE IF NOT EXISTS private_chats (
470
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
471
+ room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
472
+ member_id INTEGER NOT NULL REFERENCES room_members(id) ON DELETE CASCADE,
473
+ submission_id TEXT NOT NULL,
474
+ text TEXT NOT NULL,
475
+ status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','answered','failed','stopped','interjected')),
476
+ reply TEXT NOT NULL DEFAULT '',
477
+ reason TEXT NOT NULL DEFAULT '',
478
+ execution_id INTEGER,
479
+ created_at INTEGER NOT NULL,
480
+ updated_at INTEGER NOT NULL,
481
+ UNIQUE(room_id,submission_id)
482
+ );
483
+ CREATE INDEX IF NOT EXISTS private_chats_member ON private_chats(member_id,id);
464
484
  -- What a rewind does to each runner member's session, as its adapter reported: cut by the runner, rebuilt each time
465
485
  -- (fresh), or kept as it is. A member without a report is kept; manual and online members are always kept.
466
486
  CREATE TABLE IF NOT EXISTS member_capabilities (
@@ -1131,6 +1151,19 @@ BEGIN
1131
1151
  INSERT INTO room_revisions (room_id,revision) SELECT NEW.room_id,(SELECT COALESCE(MAX(revision),0)+1 FROM room_revisions)
1132
1152
  WHERE NOT EXISTS (SELECT 1 FROM room_revisions WHERE room_id=NEW.room_id);
1133
1153
  END;
1154
+ CREATE TRIGGER IF NOT EXISTS revision_chat_insert AFTER INSERT ON private_chats
1155
+ BEGIN
1156
+ UPDATE room_revisions SET revision=(SELECT MAX(revision)+1 FROM room_revisions) WHERE room_id=NEW.room_id;
1157
+ INSERT INTO room_revisions (room_id,revision) SELECT NEW.room_id,(SELECT COALESCE(MAX(revision),0)+1 FROM room_revisions)
1158
+ WHERE NOT EXISTS (SELECT 1 FROM room_revisions WHERE room_id=NEW.room_id);
1159
+ END;
1160
+ CREATE TRIGGER IF NOT EXISTS revision_chat_update AFTER UPDATE OF status ON private_chats
1161
+ WHEN OLD.status IS NOT NEW.status
1162
+ BEGIN
1163
+ UPDATE room_revisions SET revision=(SELECT MAX(revision)+1 FROM room_revisions) WHERE room_id=NEW.room_id;
1164
+ INSERT INTO room_revisions (room_id,revision) SELECT NEW.room_id,(SELECT COALESCE(MAX(revision),0)+1 FROM room_revisions)
1165
+ WHERE NOT EXISTS (SELECT 1 FROM room_revisions WHERE room_id=NEW.room_id);
1166
+ END;
1134
1167
  CREATE TRIGGER IF NOT EXISTS revision_interjection_insert AFTER INSERT ON execution_interjections
1135
1168
  BEGIN
1136
1169
  UPDATE room_revisions SET revision=(SELECT MAX(revision)+1 FROM room_revisions) WHERE room_id=NEW.room_id;