ai_co_discuss 0.2.16 → 0.2.17

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
@@ -146,7 +146,7 @@ codiscuss local connect https://你的站点
146
146
 
147
147
  消息支持选择文件、拖拽或粘贴截图,每条最多 10 个附件、单个最多 20 MiB。PNG、JPEG、GIF、WebP 可放大查看,PDF 可在站内翻页、缩放,其他文件提供下载(不在线解析 Office 文件)。本地 AI 可通过 CLI 按需下载原文件;实际能否理解图片或文档取决于参与软件,上传不代表 AI 已读取。
148
148
 
149
- 讨论结束后,可在左上方频道条目的“更多操作 → 导出对话”下载原始数据、全文及已发送附件原文件,窄屏从频道导航进入。也可用 `discuss-report` 整理带来源的综合报告。备份目前不支持导入恢复,也不下载外链内容。
149
+ 讨论结束后,可在左上方频道条目的“更多操作 → 导出对话”下载原始数据、全文及已发送附件原文件,窄屏从频道导航进入。频道目录菜单可“导出全部频道”,文件夹菜单可“导出文件夹”。在另一个网站(如本机与线上之间)用频道目录菜单的“导入频道…”选择这些 ZIP,即建成可继续使用的副本:导入者成为房主,成员需“请回”换发凭证,已有同一频道时跳过。两边数据不自动同步,导入是一次性复制。也可用 `discuss-report` 整理带来源的综合报告。备份不下载外链内容。
150
150
 
151
151
  网站目前**没有用户登录和读取权限隔离**;公开部署前请按 [安全说明](.github/SECURITY.md) 配置访问边界。两种房间的用户写入均需要创建时保存的房间控制凭据,未持有凭据的浏览器只能阅读;本地 AI 使用自己的成员凭证或本次自动执行凭据。凭据不能代替软件登录、工具许可或本机启动能力。同一平台可以有多个实例(如 `Claude` 与 `Claude-评审`),各自有独立会话和凭据;自动 CLI 可在卡片背面配置模型与强度,App、网页和手动会话在原软件中设置。讨论安排串行,自由协作的不同成员可并行。在线读取、原生定时和浏览器操作能力取决于参与软件。
152
152
 
@@ -0,0 +1,213 @@
1
+ """Import one room backup as a new, owner-controlled room on this site.
2
+
3
+ The room keeps its ID, so importing the same backup twice is skipped. Message, turn, member and
4
+ request IDs are renumbered after this database's current maximum, and `#ID` references in texts
5
+ follow the messages. Rows are written in an order that keeps the floor, turn and runtime triggers
6
+ from treating history as new activity; the room arrives without a run, its brainstorm phase paused
7
+ and its members away, so the owner invites them back with new credentials.
8
+ """
9
+
10
+ import hashlib
11
+ import json
12
+ import re
13
+ import sqlite3
14
+ import uuid
15
+
16
+ from .runtime import begin_write, require_owner, token_hash
17
+
18
+ BACKUP_FORMAT = "ai-codiscuss-room-backup"
19
+ ROOM_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
20
+ REFERENCE = re.compile(r"#(\d+)")
21
+ ACTIVE_PHASES = ("initial", "discussing", "reviewing")
22
+ OPEN_REQUESTS = ("pending", "delivering")
23
+ LIMIT_FIELDS = ("max_rounds", "max_minutes", "execution_minutes", "on_limit", "summary_minutes",
24
+ "started_at", "closing_at", "stop_reason", "revision")
25
+ # Tables whose integer IDs are renumbered, with the backup table that holds their rows.
26
+ RENUMBERED = {"messages": "messages", "discussion_turns": "discussion_turns",
27
+ "room_members": "room_members", "member_requests": "member_requests"}
28
+
29
+
30
+ def backup_tables(backup):
31
+ """Validate the backup envelope and return its tables and the single room row."""
32
+ if not isinstance(backup, dict) or backup.get("format") != BACKUP_FORMAT or backup.get("version") != 1:
33
+ raise ValueError("不是可导入的房间备份(需要 ai-codiscuss-room-backup 第 1 版)")
34
+ tables = backup.get("tables")
35
+ if not isinstance(tables, dict) or not isinstance(tables.get("rooms"), list) or len(tables["rooms"]) != 1:
36
+ raise ValueError("备份须恰好包含一个房间")
37
+ for name, rows in tables.items():
38
+ if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
39
+ raise ValueError(f"备份表 {name} 格式无效")
40
+ room = tables["rooms"][0]
41
+ if not isinstance(room.get("id"), str) or not ROOM_ID_PATTERN.match(room["id"]):
42
+ raise ValueError("备份中的房间编号无效")
43
+ return tables, room
44
+
45
+
46
+ def id_maps(tables, bases):
47
+ """Number each renumbered table's rows after the target's current maximum, in backup order; files get new IDs."""
48
+ maps = {table: {row["id"]: bases[table] + index for index, row in enumerate(tables.get(source, []), 1)}
49
+ for table, source in RENUMBERED.items()}
50
+ maps["attachments"] = {row["id"]: uuid.uuid4().hex for row in tables.get("attachments", [])}
51
+ return maps
52
+
53
+
54
+ def remap_text(value, messages):
55
+ """Point `#ID` references at the renumbered messages; other numbers stay as written."""
56
+ return REFERENCE.sub(lambda match: f"#{messages[int(match[1])]}" if int(match[1]) in messages else match[0], value or "")
57
+
58
+
59
+ def remap_until(value, messages):
60
+ """Map a "covers messages up to ID" mark to the last renumbered message it covered."""
61
+ return max((new for old, new in messages.items() if old <= (value or 0)), default=0)
62
+
63
+
64
+ def room_statements(tables, room, maps, control_hash):
65
+ """Room, directory, folder and a temporary free mode so history needs no speaking floor."""
66
+ messages, room_id = maps["messages"], room["id"]
67
+ directory = (tables.get("room_directory") or [{}])[0]
68
+ statements = [("INSERT INTO rooms (id,title,goal,state_text,state_until,created_at) VALUES (?,?,?,?,?,?)",
69
+ (room_id, room["title"], remap_text(room["goal"], messages), remap_text(room.get("state_text"), messages),
70
+ remap_until(room.get("state_until"), messages), room["created_at"])),
71
+ ("UPDATE room_directory SET archived=? WHERE room_id=?", (int(bool(directory.get("archived"))), room_id)),
72
+ ("INSERT INTO room_modes (room_id,mode,control_hash) VALUES (?,'free',?)", (room_id, control_hash))]
73
+ for folder in tables.get("topic_folders", [])[:1]:
74
+ statements += [("""INSERT INTO topic_folders (id,name,position) SELECT ?,?,COALESCE(MAX(position),0)+1 FROM topic_folders
75
+ WHERE NOT EXISTS (SELECT 1 FROM topic_folders WHERE id=?)""", (folder["id"], folder["name"], folder["id"])),
76
+ ("UPDATE room_folder_assignments SET folder_id=? WHERE room_id=?", (folder["id"], room_id))]
77
+ return statements
78
+
79
+
80
+ def member_statements(tables, room_id, maps):
81
+ """Members arrive away with unusable credential hashes; removed members stay removed."""
82
+ experts = json.loads((tables.get("topic_settings") or [{"experts": "{}"}])[0]["experts"])
83
+ statements = []
84
+ for member in tables.get("room_members", []):
85
+ access = member.get("access") or ("online" if experts.get(member["name"]) == "online" else "manual")
86
+ statements.append(("""INSERT INTO room_members (id,room_id,name,adapter,role,status,token_hash,read_until,created_at,access,session_mode)
87
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)""", (maps["room_members"][member["id"]], room_id, member["name"], member.get("adapter", ""),
88
+ member.get("role", "member"), "removed" if member.get("status") == "removed" else "away",
89
+ uuid.uuid4().hex * 2, remap_until(member.get("read_until"), maps["messages"]),
90
+ member["created_at"], access, member.get("session_mode") or "new")))
91
+ return statements
92
+
93
+
94
+ def history_statements(tables, room_id, maps):
95
+ """Messages and turns as queued placeholders, then their real kinds, links and statuses."""
96
+ messages, turns = maps["messages"], maps["discussion_turns"]
97
+ details = {row["message_id"]: row for row in tables.get("message_details", [])}
98
+ statements = [("INSERT INTO messages (id,room_id,author,text,reply_to,source,submission_id,created_at) VALUES (?,?,?,?,?,?,?,?)",
99
+ (messages[row["id"]], room_id, row["author"], remap_text(row["text"], messages), messages.get(row.get("reply_to")),
100
+ row["source"], row["submission_id"], row["created_at"])) for row in tables.get("messages", [])]
101
+ statements += [("""INSERT INTO discussion_turns (id,room_id,expert,question,status,submission_id,created_at,notified_at,answer_id)
102
+ VALUES (?,?,?,?,'queued',?,?,?,?)""", (turns[row["id"]], room_id, row["expert"], remap_text(row["question"], messages),
103
+ row["submission_id"], row["created_at"], row.get("notified_at", ""),
104
+ messages.get(row.get("answer_id")))) for row in tables.get("discussion_turns", [])]
105
+ for old, row in details.items():
106
+ statements += [("INSERT INTO message_details (message_id,kind,submitted_by) VALUES (?,'import',?)", (messages[old], row["submitted_by"])),
107
+ ("UPDATE message_details SET kind=?,turn_id=? WHERE message_id=?", (row["kind"], turns.get(row.get("turn_id")), messages[old]))]
108
+ # A held relay stops an answered turn from promoting the next queued one; open turns are restored last.
109
+ statements.append(("INSERT INTO turn_relay (room_id,held_since) VALUES (?,1) ON CONFLICT(room_id) DO UPDATE SET held_since=1", (room_id,)))
110
+ order = {"notified": 1, "pending": 2}
111
+ for row in sorted(tables.get("discussion_turns", []), key=lambda turn: order.get(turn["status"], 0)):
112
+ if row["status"] != "queued":
113
+ statements.append(("UPDATE discussion_turns SET status=? WHERE id=?", (row["status"], turns[row["id"]])))
114
+ return statements
115
+
116
+
117
+ def settings_statements(tables, room, maps, now):
118
+ """Topic, floor, limits and idle clock; an active brainstorm arrives paused because it has no run."""
119
+ room_id, messages, statements = room["id"], maps["messages"], []
120
+ free = any(row.get("mode") == "free" for row in tables.get("room_modes", []))
121
+ for row in tables.get("topic_settings", [])[:1]:
122
+ phase = "paused" if not free and row["phase"] in ACTIVE_PHASES else row["phase"]
123
+ statements += [("INSERT INTO topic_settings (room_id,host,background,boundaries,experts,phase,focus) VALUES (?,?,?,?,?,?,?)",
124
+ (room_id, row["host"], remap_text(row.get("background"), messages), remap_text(row.get("boundaries"), messages),
125
+ row["experts"], phase, remap_text(row.get("focus"), messages))),
126
+ ("INSERT INTO discussion_pauses (room_id,paused_at) VALUES (?,?)",
127
+ (room_id, 0 if phase in ACTIVE_PHASES else now))]
128
+ for row in tables.get("room_floor", [])[:1]:
129
+ statements.append(("""INSERT INTO room_floor (room_id,holder,next_speaker,revision) VALUES (?,?,?,?) ON CONFLICT(room_id)
130
+ DO UPDATE SET holder=excluded.holder,next_speaker=excluded.next_speaker,revision=excluded.revision""",
131
+ (room_id, row.get("holder"), row["next_speaker"], row.get("revision", 0))))
132
+ for row in tables.get("discussion_limits", [])[:1]:
133
+ statements.append((f"UPDATE discussion_limits SET {','.join(f'{field}=?' for field in LIMIT_FIELDS)} WHERE room_id=?",
134
+ (*(row[field] for field in LIMIT_FIELDS), room_id)))
135
+ for row in tables.get("room_user_requests", [])[:1]:
136
+ statements.append(("INSERT INTO room_user_requests (room_id,text) VALUES (?,?)", (room_id, remap_text(row["text"], messages))))
137
+ for row in tables.get("room_briefs", [])[:1]:
138
+ statements.append(("INSERT INTO room_briefs (room_id,text,until,updated_at) VALUES (?,?,?,?)",
139
+ (room_id, remap_text(row["text"], messages), remap_until(row["until"], messages), row["updated_at"])))
140
+ return statements
141
+
142
+
143
+ def record_statements(tables, room_id, maps):
144
+ """Summaries, timings, attachment records and free-room requests attached to renumbered rows."""
145
+ messages, members, requests = maps["messages"], maps["room_members"], maps["member_requests"]
146
+ statements = [("INSERT INTO message_summaries (message_id,text,author) VALUES (?,?,?)",
147
+ (messages[row["message_id"]], remap_text(row["text"], messages), row["author"])) for row in tables.get("message_summaries", [])]
148
+ statements += [("INSERT INTO message_timings (message_id,elapsed_seconds) VALUES (?,?)", (messages[row["message_id"]], row["elapsed_seconds"]))
149
+ for row in tables.get("message_timings", [])]
150
+ statements += [("""INSERT INTO attachments (id,room_id,message_id,position,file_name,content_type,size,sha256,actor,submission_id,created_at)
151
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)""", (maps["attachments"][row["id"]], room_id, messages[row["message_id"]], row["position"], row["file_name"],
152
+ row["content_type"], row["size"], row["sha256"], row["actor"], row["submission_id"],
153
+ row["created_at"])) for row in tables.get("attachments", [])]
154
+ statements += [("""INSERT INTO member_requests (id,room_id,member_id,message_id,status,answer_id,created_at,chain_id,batch_id,purpose,created_by)
155
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)""", (requests[row["id"]], room_id, members[row["member_id"]], messages[row["message_id"]],
156
+ "cancelled" if row["status"] in OPEN_REQUESTS else row["status"],
157
+ messages.get(row.get("answer_id")), row["created_at"], row.get("chain_id", 0),
158
+ row.get("batch_id", 0), row.get("purpose", "ask"), row.get("created_by", "user")))
159
+ for row in tables.get("member_requests", [])]
160
+ statements += [("INSERT INTO member_suggestions (message_id,member_id) VALUES (?,?)", (messages[row["message_id"]], members[row["member_id"]]))
161
+ for row in tables.get("member_suggestions", [])]
162
+ return statements
163
+
164
+
165
+ def import_statements(tables, room, maps, control_hash, now):
166
+ """Build the ordered writes that recreate one backup; the D1 adapter builds the same sequence."""
167
+ mode = next((row["mode"] for row in tables.get("room_modes", [])), "brainstorm")
168
+ return [*room_statements(tables, room, maps, control_hash), *member_statements(tables, room["id"], maps),
169
+ *history_statements(tables, room["id"], maps), *settings_statements(tables, room, maps, now),
170
+ *record_statements(tables, room["id"], maps),
171
+ ("UPDATE room_modes SET mode=? WHERE room_id=?", (mode, room["id"])),
172
+ ("DELETE FROM turn_relay WHERE room_id=?", (room["id"],))]
173
+
174
+
175
+ def import_room(connection, backup, control_token, now):
176
+ """Recreate a backup under the importer's control credential, or skip a room that already exists.
177
+
178
+ Args:
179
+ connection: SQLite connection; the caller commits.
180
+ backup: The `完整数据.json` object of one room backup.
181
+ control_token: The importer's new room control credential.
182
+ now: Current Unix seconds, used to start the idle clock of a paused room.
183
+
184
+ Returns:
185
+ `{"id", "title", "imported": True, "attachments": {backup ID: new ID}}`, or `{"id", "title", "skipped": True}` when the room exists.
186
+ """
187
+ control_hash = token_hash(control_token)
188
+ if not control_hash:
189
+ raise ValueError("导入须提供 32–256 字符的房间控制凭据")
190
+ tables, room = backup_tables(backup)
191
+ begin_write(connection)
192
+ if connection.execute("SELECT 1 FROM rooms WHERE id=?", (room["id"],)).fetchone():
193
+ return {"id": room["id"], "title": room["title"], "skipped": True}
194
+ bases = {table: connection.execute(f"SELECT COALESCE(MAX(id),0) FROM {table}").fetchone()[0] for table in RENUMBERED}
195
+ maps = id_maps(tables, bases)
196
+ try:
197
+ for sql, params in import_statements(tables, room, maps, control_hash, now):
198
+ connection.execute(sql, params)
199
+ except (KeyError, sqlite3.Error) as error:
200
+ raise ValueError(f"备份内容不完整或与本站数据冲突,未导入:{error}") from error
201
+ return {"id": room["id"], "title": room["title"], "imported": True,
202
+ "attachments": maps["attachments"]}
203
+
204
+
205
+ def imported_attachment(connection, room_id, attachment_id, token, data):
206
+ """Return an imported attachment record once the owner's uploaded bytes match it exactly."""
207
+ require_owner(connection, room_id, token)
208
+ row = connection.execute("SELECT * FROM attachments WHERE room_id=? AND id=?", (room_id, attachment_id)).fetchone()
209
+ if row is None:
210
+ raise LookupError("附件不存在")
211
+ if len(data) != row["size"] or hashlib.sha256(data).hexdigest() != row["sha256"]:
212
+ raise ValueError("附件内容与备份记录不一致")
213
+ return dict(row)