ai_co_discuss 0.2.13 → 0.2.14
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-host/SKILL.md +2 -2
- package/.agents/skills/discuss-participant/SKILL.md +1 -1
- package/README.md +4 -2
- package/backend/identity.py +31 -31
- package/backend/runtime.py +364 -364
- package/backend/schema.sql +580 -580
- package/backend/server.py +1763 -1760
- package/docs/architecture.md +6 -2
- package/docs/cli.md +2 -0
- package/docs/runner.md +392 -363
- package/docs/workflow.md +148 -148
- package/package.json +2 -2
- package/runner/api_participant.mjs +176 -0
- package/runner/discuss.py +946 -946
- package/runner/discussion_adapters.mjs +1 -0
- package/runner/discussion_api.mjs +146 -0
- package/runner/discussion_launch.mjs +20 -9
- package/runner/discussion_runner.mjs +20 -4
- package/runner/discussion_service.mjs +394 -394
- package/runner/free_runner.mjs +6 -2
- package/web/app.js +57 -11
- package/web/backup.js +1 -1
- package/web/markdown.js +1 -1
- package/web/modern.css +1 -1
- package/web/modern.js +680 -545
package/backend/server.py
CHANGED
|
@@ -1,1760 +1,1763 @@
|
|
|
1
|
-
"""AI CoDiscuss 的零依赖 HTTP / SQLite 概念验证服务。"""
|
|
2
|
-
|
|
3
|
-
import argparse
|
|
4
|
-
import hashlib
|
|
5
|
-
import hmac
|
|
6
|
-
import json
|
|
7
|
-
import logging
|
|
8
|
-
import os
|
|
9
|
-
import re
|
|
10
|
-
import socket
|
|
11
|
-
import sqlite3
|
|
12
|
-
import subprocess
|
|
13
|
-
import threading
|
|
14
|
-
import time
|
|
15
|
-
import uuid
|
|
16
|
-
from contextlib import closing
|
|
17
|
-
from datetime import datetime, timedelta, timezone
|
|
18
|
-
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
19
|
-
from pathlib import Path
|
|
20
|
-
from urllib.parse import parse_qs, quote, urlsplit
|
|
21
|
-
|
|
22
|
-
from .free_room import (add_member, after_free_message, authorize_free_write, check_answer, check_proxy, execution_action, free_view,
|
|
23
|
-
inbox, interrupt_member, member_action, member_values, migrate_free_tables, proxy_member, reserve_execution,
|
|
24
|
-
discussion_members, room_mode, save_room_access, sync_discussion_members, view_execution)
|
|
25
|
-
from .free_room import convert_suggestion, message_suggestions, save_suggestions
|
|
26
|
-
from .identity import MAX_ROOM_AGENTS, PARTICIPANTS, is_ai, is_participant
|
|
27
|
-
from .runtime import (authorize_write, begin_write, configure_runtime, event_action,
|
|
28
|
-
LIMIT_DEFAULTS, LIMITS_SQL, limit_values, limits_view, require_within_limits,
|
|
29
|
-
list_events, require_control, runtime_view, token_hash)
|
|
30
|
-
|
|
31
|
-
ROOT = Path(__file__).resolve().parent.parent
|
|
32
|
-
WEB_ROOT = ROOT / "web"
|
|
33
|
-
DEFAULT_PORT = 8765
|
|
34
|
-
UPDATE_HEARTBEAT_SECONDS = 15
|
|
35
|
-
MAX_BODY_BYTES = 512 * 1024
|
|
36
|
-
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
|
37
|
-
MAX_MESSAGE_ATTACHMENTS = 10
|
|
38
|
-
MAX_FILENAME_LENGTH = 255
|
|
39
|
-
LOCAL_LAUNCH_TIMEOUT = 60
|
|
40
|
-
# Ordering and filing a channel only rearrange the shared directory, like folder edits, so they need no room credential.
|
|
41
|
-
DIRECTORY_OPERATIONS = ("move", "folder")
|
|
42
|
-
DIRECTORY_PICKER_TIMEOUT = 600 # the folder dialog waits for the person at the computer
|
|
43
|
-
MAX_TEXT_LENGTH = 32000
|
|
44
|
-
MAX_STATE_LENGTH = 4000
|
|
45
|
-
MAX_BRIEF_LENGTH = 1600
|
|
46
|
-
MAX_SUMMARY_LENGTH = 1000
|
|
47
|
-
PAGE_SIZE = 20
|
|
48
|
-
UNCATEGORIZED_FOLDER_ID = "__uncategorized__"
|
|
49
|
-
ASSETS = {"/": ("index.html", "text/html"),
|
|
50
|
-
"/app.js": ("app.js", "text/javascript"),
|
|
51
|
-
"/modern.js": ("modern.js", "text/javascript"),
|
|
52
|
-
"/modern.css": ("modern.css", "text/css"),
|
|
53
|
-
"/markdown.js": ("markdown.js", "text/javascript"),
|
|
54
|
-
"/backup.js": ("backup.js", "text/javascript"),
|
|
55
|
-
"/markdown.css": ("markdown.css", "text/css"),
|
|
56
|
-
"/icons/LICENSE.txt": ("icons/LICENSE.txt", "text/plain")}
|
|
57
|
-
ASSETS.update({f"/icons/{name}.svg": (f"icons/{name}.svg", "image/svg+xml")
|
|
58
|
-
for name in ("openai", "claude", "gemini", "deepseek", "qwen", "user")})
|
|
59
|
-
ASSETS.update({"/pdfjs/" + file.relative_to(WEB_ROOT / "pdfjs").as_posix():
|
|
60
|
-
(file.relative_to(WEB_ROOT).as_posix(),
|
|
61
|
-
{".mjs": "text/javascript", ".js": "text/javascript", ".wasm": "application/wasm"}.get(file.suffix, "application/octet-stream"))
|
|
62
|
-
for file in (WEB_ROOT / "pdfjs").rglob("*") if file.is_file()})
|
|
63
|
-
SCHEMA = (Path(__file__).resolve().parent / "schema.sql").read_text(encoding="utf-8")
|
|
64
|
-
PHASES = {"preparing": "准备中", "initial": "独立观点", "discussing": "深入讨论",
|
|
65
|
-
"reviewing": "结论复核", "paused": "已暂停", "concluded": "已收束"}
|
|
66
|
-
TURN_STATUSES = {"pending": "待处理", "notified": "已通知,待回答", "answered": "已回答", "cancelled": "已取消",
|
|
67
|
-
"queued": "排队中", "skipped": "未开始即取消"}
|
|
68
|
-
MAX_TOPIC_DETAIL = 4000
|
|
69
|
-
MAX_QUESTION_LENGTH = 1000
|
|
70
|
-
MAX_PLAN_TURNS = 10
|
|
71
|
-
ROOM_BACKUP_QUERIES = {
|
|
72
|
-
"rooms": "SELECT * FROM rooms WHERE id=?",
|
|
73
|
-
**{table: f"SELECT * FROM {table} WHERE room_id=?" for table in (
|
|
74
|
-
"room_directory", "room_folder_assignments", "room_creations", "room_briefs", "room_user_requests",
|
|
75
|
-
"topic_settings", "room_floor", "discussion_limits")},
|
|
76
|
-
"topic_folders": "SELECT * FROM topic_folders WHERE id IN (SELECT folder_id FROM room_folder_assignments WHERE room_id=?)",
|
|
77
|
-
"messages": "SELECT * FROM messages WHERE room_id=? ORDER BY id",
|
|
78
|
-
"attachments": "SELECT * FROM attachments WHERE room_id=? AND message_id IS NOT NULL ORDER BY message_id,position",
|
|
79
|
-
"discussion_turns": "SELECT * FROM discussion_turns WHERE room_id=? ORDER BY id",
|
|
80
|
-
**{table: f"SELECT * FROM {table} WHERE message_id IN (SELECT id FROM messages WHERE room_id=?) ORDER BY message_id"
|
|
81
|
-
for table in ("message_details", "message_summaries", "message_timings")},
|
|
82
|
-
}
|
|
83
|
-
ROOM_BACKUP_QUERIES["room_creations"] = "SELECT submission_id,room_id FROM room_creations WHERE room_id=?"
|
|
84
|
-
# Shared membership plus free-room requests; credential hashes are never exported.
|
|
85
|
-
FREE_BACKUP_QUERIES = {
|
|
86
|
-
"room_modes": "SELECT room_id,mode FROM room_modes WHERE room_id=?",
|
|
87
|
-
"room_members": "SELECT id,room_id,name,adapter,role,status,read_until,created_at FROM room_members WHERE room_id=? ORDER BY id",
|
|
88
|
-
"member_requests": "SELECT * FROM member_requests WHERE room_id=? ORDER BY id",
|
|
89
|
-
"member_suggestions": "SELECT s.* FROM member_suggestions s JOIN messages m ON m.id=s.message_id WHERE m.room_id=? ORDER BY s.message_id,s.member_id",
|
|
90
|
-
}
|
|
91
|
-
MESSAGE_QUERY = """SELECT messages.*, COALESCE(details.kind,'opinion') AS kind,
|
|
92
|
-
COALESCE(details.submitted_by, CASE WHEN messages.source IN ('cli','url','user')
|
|
93
|
-
THEN messages.author ELSE '用户' END) AS submitted_by, details.turn_id,
|
|
94
|
-
COALESCE(summaries.text,'') AS summary, COALESCE(summaries.author,'') AS summary_by, timing.elapsed_seconds
|
|
95
|
-
FROM messages LEFT JOIN message_details AS details ON details.message_id=messages.id
|
|
96
|
-
LEFT JOIN message_summaries AS summaries ON summaries.message_id=messages.id
|
|
97
|
-
LEFT JOIN message_timings AS timing ON timing.message_id=messages.id"""
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def room_backup(connection, room_id):
|
|
101
|
-
"""Read all records belonging to one room in a single database snapshot.
|
|
102
|
-
|
|
103
|
-
Args:
|
|
104
|
-
connection: A fresh SQLite connection, with no active transaction.
|
|
105
|
-
room_id: The exact room to export, including archived rooms.
|
|
106
|
-
|
|
107
|
-
Returns:
|
|
108
|
-
A versioned backup containing original rows and their relationships.
|
|
109
|
-
"""
|
|
110
|
-
connection.execute("BEGIN")
|
|
111
|
-
queries = {**ROOM_BACKUP_QUERIES, **FREE_BACKUP_QUERIES}
|
|
112
|
-
tables = {table: [dict(row) for row in connection.execute(query, (room_id,))]
|
|
113
|
-
for table, query in queries.items()}
|
|
114
|
-
if not tables["rooms"]:
|
|
115
|
-
raise LookupError("房间不存在")
|
|
116
|
-
return {"format": "ai-codiscuss-room-backup", "version": 1,
|
|
117
|
-
"exported_at": utc_now(), "tables": tables}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def utc_now():
|
|
121
|
-
"""Return the current UTC timestamp in ISO format."""
|
|
122
|
-
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def connect_db(database):
|
|
126
|
-
"""Open a database connection with named rows and foreign keys enabled."""
|
|
127
|
-
connection = sqlite3.connect(database, timeout=10)
|
|
128
|
-
connection.row_factory = sqlite3.Row
|
|
129
|
-
connection.execute("PRAGMA foreign_keys = ON")
|
|
130
|
-
return connection
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def seed_demo(connection):
|
|
134
|
-
"""Insert clearly labeled sample content into a newly initialized database."""
|
|
135
|
-
connection.execute("INSERT INTO rooms (id,title,goal,created_at) VALUES (?,?,?,?)",
|
|
136
|
-
("welcome", "AI 留言板 · 设计讨论", "让不同 AI 共享讨论信息,控制每轮上下文,并保留可检索的完整历史。", utc_now()))
|
|
137
|
-
samples = [
|
|
138
|
-
("用户", "我们先验证一个简单场景:不同 AI 能否通过同一个房间阅读、检索和留言?\n首轮只讨论信息如何流转,不接入真实模型。", None),
|
|
139
|
-
("Codex", "建议把完整历史保留下来,每轮先给目标、当前状态和需要回应的消息。\n@Claude 如果摘要遗漏了重要条件,参与者如何核对?", 1),
|
|
140
|
-
("Claude", "每条结论都应引用原始消息编号。AI 可以主动读取原文,也能按关键词检索。\n摘要由主持人维护,未解决的分歧不能写成共识。", 2),
|
|
141
|
-
("Gemini", "在线 AI 只读取讨论记录,在自己的聊天窗口给出观点,由用户代发。\n@DeepSeek 请检查阅读入口和原文引用是否清楚。", 1),
|
|
142
|
-
("DeepSeek", "如果只能阅读,回复可以由用户代发。需要保留原作者、引用关系和代发方式,便于其他参与者继续讨论。", 4),
|
|
143
|
-
("Qwen", "无联网时,可以由用户传入本轮材料。我仍可提出检索请求,例如:请提供 #2 的完整原文。", 2),
|
|
144
|
-
]
|
|
145
|
-
base_time = datetime.now(timezone.utc) - timedelta(minutes=len(samples) * 3)
|
|
146
|
-
connection.execute("INSERT INTO room_floor (room_id,holder,next_speaker) VALUES ('welcome','用户','用户')")
|
|
147
|
-
for index, (author, text, reply_to) in enumerate(samples):
|
|
148
|
-
connection.execute("UPDATE room_floor SET holder=? WHERE room_id='welcome'", (author,))
|
|
149
|
-
created_at = (base_time + timedelta(minutes=index * 3)).isoformat(timespec="seconds")
|
|
150
|
-
connection.execute("INSERT INTO messages (room_id,author,text,reply_to,source,submission_id,created_at) VALUES (?,?,?,?,?,?,?)",
|
|
151
|
-
("welcome", author, text, reply_to, "demo", f"demo-{index}", created_at))
|
|
152
|
-
summary = "已达成共识\n• 完整历史只追加,支持按编号查阅和关键词检索。〔#2、#3〕\n• 本地 CLI AI 读写记录;在线 AI 只读,观点由用户代发。〔#4、#5〕\n\n待验证\n• 在线 AI 是否能直接读取和检索原文。〔#4〕\n• 复制材料与代发流程是否清楚。〔#5、#6〕"
|
|
153
|
-
connection.execute("UPDATE rooms SET state_text=?,state_until=6 WHERE id='welcome'", (summary,))
|
|
154
|
-
connection.execute("UPDATE room_floor SET holder='用户' WHERE room_id='welcome'")
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def init_database(database, seed=True):
|
|
158
|
-
"""Create durable storage; seed only a brand-new database when requested."""
|
|
159
|
-
database = Path(database)
|
|
160
|
-
is_new = not database.exists()
|
|
161
|
-
database.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
-
with closing(connect_db(database)) as connection, connection:
|
|
163
|
-
connection.execute("PRAGMA journal_mode = WAL")
|
|
164
|
-
connection.executescript(SCHEMA)
|
|
165
|
-
migrate_free_tables(connection)
|
|
166
|
-
if seed and is_new:
|
|
167
|
-
seed_demo(connection)
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
def require_room(connection, room_id):
|
|
171
|
-
"""Return a room and its current message count, or raise a lookup error."""
|
|
172
|
-
row = connection.execute("SELECT rooms.*, COUNT(messages.id) AS message_count, COALESCE(MAX(messages.id),0) AS latest_id FROM rooms LEFT JOIN messages ON rooms.id=messages.room_id WHERE rooms.id=? GROUP BY rooms.id", (room_id,)).fetchone()
|
|
173
|
-
if row is None:
|
|
174
|
-
raise LookupError("房间不存在")
|
|
175
|
-
room = dict(row)
|
|
176
|
-
room["archived"] = bool(connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0])
|
|
177
|
-
room["folder_id"] = connection.execute("SELECT folder_id FROM room_folder_assignments WHERE room_id=?", (room_id,)).fetchone()[0]
|
|
178
|
-
settings = connection.execute("SELECT * FROM topic_settings WHERE room_id=?", (room_id,)).fetchone()
|
|
179
|
-
room.update(dict(settings) if settings else topic_defaults())
|
|
180
|
-
original = connection.execute("SELECT text FROM room_user_requests WHERE room_id=?", (room_id,)).fetchone()
|
|
181
|
-
room.update(topic_context(room["background"], original["text"] if original else None))
|
|
182
|
-
if settings:
|
|
183
|
-
room["experts"] = json.loads(room["experts"])
|
|
184
|
-
turn = connection.execute("""SELECT *, (SELECT COUNT(*) FROM discussion_turns WHERE room_id=? AND status='answered') AS answered_turns
|
|
185
|
-
FROM discussion_turns WHERE room_id=? AND status NOT IN ('queued','skipped')
|
|
186
|
-
ORDER BY status IN ('pending','notified') DESC, id DESC LIMIT 1""", (room_id, room_id)).fetchone()
|
|
187
|
-
room["current_turn"] = dict(turn) if turn else None
|
|
188
|
-
room["answered_turns"] = room["current_turn"].pop("answered_turns") if turn else 0
|
|
189
|
-
if turn:
|
|
190
|
-
room["current_turn"]["status_label"] = turn_status_label(turn, room)
|
|
191
|
-
room["queued_turns"] = [dict(row) for row in connection.execute(
|
|
192
|
-
"SELECT id,expert,question FROM discussion_turns WHERE room_id=? AND status='queued' ORDER BY id", (room_id,))]
|
|
193
|
-
held = connection.execute("SELECT held_since FROM turn_relay WHERE room_id=?", (room_id,)).fetchone()
|
|
194
|
-
room["held_since"] = held[0] if held else 0
|
|
195
|
-
room["latest_receipts"] = latest_receipts(connection, room_id)
|
|
196
|
-
brief = connection.execute("SELECT text,until,updated_at FROM room_briefs WHERE room_id=?", (room_id,)).fetchone()
|
|
197
|
-
room["host_brief"] = dict(brief) if brief else None
|
|
198
|
-
room["rounds"] = discussion_rounds(connection, room_id, room["experts"])
|
|
199
|
-
room["completed_rounds"] = sum(item["completed"] for item in room["rounds"])
|
|
200
|
-
room["floor"] = dict(connection.execute("SELECT holder,next_speaker,revision FROM room_floor WHERE room_id=?", (room_id,)).fetchone())
|
|
201
|
-
room["runtime"] = runtime_view(connection, room_id)
|
|
202
|
-
room["limits"] = limits_view(connection.execute(LIMITS_SQL, (room_id,)).fetchone(), room)
|
|
203
|
-
room["mode"] = room_mode(connection, room_id)
|
|
204
|
-
room["members"] = [dict(row) for row in connection.execute(
|
|
205
|
-
"SELECT id,name,adapter,role,status,access,session_mode FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,))]
|
|
206
|
-
parent = connection.execute("""SELECT rooms.id,rooms.title FROM room_links JOIN rooms ON rooms.id=room_links.parent_id
|
|
207
|
-
WHERE room_links.room_id=?""", (room_id,)).fetchone()
|
|
208
|
-
room["parent_room"] = dict(parent) if parent else None
|
|
209
|
-
if room["mode"] == "free":
|
|
210
|
-
room["linked_rooms"] = [dict(row) for row in connection.execute("""SELECT rooms.id,rooms.title,COALESCE(topic_settings.phase,'preparing') AS phase
|
|
211
|
-
FROM room_links JOIN rooms ON rooms.id=room_links.room_id LEFT JOIN topic_settings ON topic_settings.room_id=rooms.id
|
|
212
|
-
WHERE room_links.parent_id=? ORDER BY room_links.created_at""", (room_id,))]
|
|
213
|
-
# Free rooms have no host hand-off: user messages go straight to the named members.
|
|
214
|
-
room["held_since"] = 0
|
|
215
|
-
room["free"] = free_view(connection, room_id)
|
|
216
|
-
return room
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def update_floor(connection, room_id, payload):
|
|
220
|
-
"""Claim or return the nominated speaker's floor using a checked revision."""
|
|
221
|
-
begin_write(connection)
|
|
222
|
-
room = require_room(connection, room_id)
|
|
223
|
-
actor = payload.get("actor")
|
|
224
|
-
speaker = payload.get("speaker", actor)
|
|
225
|
-
action = payload.get("action")
|
|
226
|
-
revision = payload.get("revision")
|
|
227
|
-
proxy = actor == room["host"] and room["experts"].get(speaker) == "online"
|
|
228
|
-
if not is_participant(speaker) or (actor not in (speaker, "用户") and not proxy):
|
|
229
|
-
raise ValueError("请以本人、主持人或用户身份操作被点名者的发言权")
|
|
230
|
-
if action not in ("take", "return") or type(revision) is not int or revision < 0:
|
|
231
|
-
raise ValueError("请提供 take/return 和最新的发言权 revision")
|
|
232
|
-
floor = room["floor"]
|
|
233
|
-
if revision != floor["revision"]:
|
|
234
|
-
raise FileExistsError("发言权已变化,请重新读取")
|
|
235
|
-
if action == "take":
|
|
236
|
-
if floor["holder"] is not None or floor["next_speaker"] != speaker:
|
|
237
|
-
raise FileExistsError("仅被点名的下一位可在无人持有时领取发言权")
|
|
238
|
-
connection.execute("UPDATE room_floor SET holder=?,revision=revision+1 WHERE room_id=?", (speaker, room_id))
|
|
239
|
-
else:
|
|
240
|
-
if floor["holder"] != speaker:
|
|
241
|
-
raise FileExistsError("只能归还自己持有的发言权")
|
|
242
|
-
connection.execute("UPDATE room_floor SET holder=NULL,next_speaker=?,revision=revision+1 WHERE room_id=?", (room["host"], room_id))
|
|
243
|
-
return require_room(connection, room_id)["floor"]
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
def require_floor(room, speaker):
|
|
247
|
-
"""Reject a declared speaker who does not currently hold the floor."""
|
|
248
|
-
if room["floor"]["holder"] != speaker:
|
|
249
|
-
raise FileExistsError("请先领取本人发言权;他人持有或尚未被点名时不能发言或安排")
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
def discussion_rounds(connection, room_id, experts):
|
|
253
|
-
"""Group complete history metadata until every configured expert has spoken.
|
|
254
|
-
|
|
255
|
-
Repeated opinions count once per round; other roles retain their position
|
|
256
|
-
without advancing the round. No message bodies are loaded or rewritten.
|
|
257
|
-
"""
|
|
258
|
-
if not experts:
|
|
259
|
-
return []
|
|
260
|
-
rows = connection.execute("""SELECT messages.id,messages.author,
|
|
261
|
-
COALESCE(details.kind,'opinion') AS kind FROM messages
|
|
262
|
-
LEFT JOIN message_details AS details ON details.message_id=messages.id
|
|
263
|
-
WHERE room_id=? ORDER BY messages.id""", (room_id,))
|
|
264
|
-
rounds = []
|
|
265
|
-
next_start = None
|
|
266
|
-
for message in rows:
|
|
267
|
-
if next_start is None:
|
|
268
|
-
next_start = message["id"]
|
|
269
|
-
if message["kind"] != "opinion" or message["author"] not in experts:
|
|
270
|
-
continue
|
|
271
|
-
if not rounds or rounds[-1]["completed"]:
|
|
272
|
-
rounds.append({"number": len(rounds) + 1, "start_id": next_start,
|
|
273
|
-
"speakers": [], "completed": False})
|
|
274
|
-
current = rounds[-1]
|
|
275
|
-
if message["author"] not in current["speakers"]:
|
|
276
|
-
current["speakers"].append(message["author"])
|
|
277
|
-
current["completed"] = len(current["speakers"]) == len(experts)
|
|
278
|
-
if current["completed"]:
|
|
279
|
-
next_start = None
|
|
280
|
-
return rounds
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
def latest_receipts(connection, room_id):
|
|
284
|
-
"""Return each author's latest receipt reference without loading its body."""
|
|
285
|
-
rows = connection.execute("""SELECT messages.id,messages.author,messages.created_at,details.submitted_by
|
|
286
|
-
FROM messages JOIN message_details AS details ON details.message_id=messages.id
|
|
287
|
-
WHERE messages.id IN (SELECT MAX(messages.id) FROM messages
|
|
288
|
-
JOIN message_details AS details ON details.message_id=messages.id
|
|
289
|
-
WHERE room_id=? AND details.kind='receipt' GROUP BY author)
|
|
290
|
-
ORDER BY messages.id""", (room_id,))
|
|
291
|
-
return [dict(row) for row in rows]
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
def turn_status_label(turn, room):
|
|
295
|
-
"""Describe pending work using the expert's configured participation mode."""
|
|
296
|
-
if turn["status"] == "pending":
|
|
297
|
-
mode = room["experts"].get(turn["expert"])
|
|
298
|
-
return {"cli": "待处理(本地 CLI)", "online": "待联系(在线专家)"}.get(mode, "待处理")
|
|
299
|
-
return TURN_STATUSES[turn["status"]]
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
def receipt_references_text(room):
|
|
303
|
-
"""Link to current receipt reports independently of an older host summary."""
|
|
304
|
-
lines = ["最新运行回执(每位参与者最近一条,运行结果以原文为准):"]
|
|
305
|
-
for receipt in room["latest_receipts"]:
|
|
306
|
-
suffix = " · 晚于摘要" if receipt["id"] > room["state_until"] else ""
|
|
307
|
-
lines.append(f"{receipt['author']} · {receipt['created_at']}{suffix}:/r/{room['id']}/messages/{receipt['id']}")
|
|
308
|
-
return "\n".join(lines) if room["latest_receipts"] else "尚无运行回执。"
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
def topic_context(background, user_request=None):
|
|
312
|
-
"""Separate explicitly marked legacy requests without guessing unmarked text."""
|
|
313
|
-
if user_request is None:
|
|
314
|
-
match = re.match(r"^用户原文[::][ \t]*(?:\r?\n)?([\s\S]*?)(?:\r?\n){2,}((?:项目背景与入口|背景)[::][\s\S]*)$", background)
|
|
315
|
-
if match:
|
|
316
|
-
user_request, background = match.group(1), match.group(2)
|
|
317
|
-
return {"user_request": user_request or "", "background": background}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
def topic_defaults():
|
|
321
|
-
"""Return independent participation defaults for existing rooms."""
|
|
322
|
-
return {"host": "用户", "background": "", "boundaries": "", "phase": "preparing", "focus": "",
|
|
323
|
-
"experts": {name: "cli" if name == "Codex" else "online" for name in PARTICIPANTS[1:]}}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
def topic_values(payload, current=None):
|
|
327
|
-
"""Validate topic fields and require an AI host to have CLI capability."""
|
|
328
|
-
result = {**topic_defaults(), **(current or {})}
|
|
329
|
-
limits = {"title": 80, "goal": 1000, "user_request": MAX_TOPIC_DETAIL, "background": MAX_TOPIC_DETAIL,
|
|
330
|
-
"boundaries": MAX_TOPIC_DETAIL, "focus": MAX_QUESTION_LENGTH}
|
|
331
|
-
for field, limit in limits.items():
|
|
332
|
-
value = str(payload.get(field, result.get(field, "")))
|
|
333
|
-
result[field] = value if field == "user_request" else value.strip()
|
|
334
|
-
if len(result[field]) > limit or (field == "title" and not result[field]):
|
|
335
|
-
raise ValueError(f"{field} 内容为空或超过 {limit} 字")
|
|
336
|
-
result["host"] = payload.get("host", result["host"])
|
|
337
|
-
result["phase"] = payload.get("phase", result["phase"])
|
|
338
|
-
result["experts"] = payload.get("experts", result["experts"])
|
|
339
|
-
experts = result["experts"]
|
|
340
|
-
if (not isinstance(experts, dict) or len(experts) > MAX_ROOM_AGENTS
|
|
341
|
-
or any(not is_ai(name) or mode not in ("cli", "online") for name, mode in experts.items())):
|
|
342
|
-
raise ValueError("专家需为有效 AI 身份,参与方式为 cli 或 online")
|
|
343
|
-
if result["host"] != "用户" and experts.get(result["host"]) != "cli":
|
|
344
|
-
raise ValueError("AI 主持人必须是可读可写的本地 CLI 参与者")
|
|
345
|
-
if result["phase"] not in PHASES:
|
|
346
|
-
raise ValueError("话题阶段无效")
|
|
347
|
-
if "limits" in payload:
|
|
348
|
-
result["limits"] = limit_values(payload["limits"], (current or {}).get("limits"))
|
|
349
|
-
changed = any(result["limits"][key] != current["limits"][key] for key in LIMIT_DEFAULTS) if current else False
|
|
350
|
-
if changed and current["phase"] not in ("preparing", "paused"):
|
|
351
|
-
raise ValueError("请先暂停讨论,再修改上限参数")
|
|
352
|
-
result["save_limits"] = not current or changed
|
|
353
|
-
result["reset_closing"] = not current or any(result["limits"][key] != current["limits"][key]
|
|
354
|
-
for key in LIMIT_DEFAULTS if key != "execution_minutes")
|
|
355
|
-
return result
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
def save_topic_settings(connection, room_id, settings):
|
|
359
|
-
"""Persist topic settings without modifying any original messages."""
|
|
360
|
-
if settings.get("save_limits"):
|
|
361
|
-
connection.execute("""UPDATE discussion_limits SET max_rounds=?,max_minutes=?,execution_minutes=?,on_limit=?,summary_minutes=?,
|
|
362
|
-
closing_at=CASE WHEN ? THEN 0 ELSE closing_at END,stop_reason=CASE WHEN ? THEN '' ELSE stop_reason END,
|
|
363
|
-
revision=revision+1 WHERE room_id=?""",
|
|
364
|
-
[*(settings["limits"][key] for key in LIMIT_DEFAULTS), settings["reset_closing"], settings["reset_closing"], room_id])
|
|
365
|
-
values = [settings[key] for key in ("host", "background", "boundaries")]
|
|
366
|
-
values.extend([json.dumps(settings["experts"], ensure_ascii=False), settings["phase"], settings["focus"]])
|
|
367
|
-
connection.execute("INSERT INTO topic_settings (room_id,host,background,boundaries,experts,phase,focus) VALUES (?,?,?,?,?,?,?) ON CONFLICT(room_id) DO UPDATE SET host=excluded.host,background=excluded.background,boundaries=excluded.boundaries,experts=excluded.experts,phase=excluded.phase,focus=excluded.focus", [room_id, *values])
|
|
368
|
-
connection.execute("INSERT INTO room_user_requests (room_id,text) VALUES (?,?) ON CONFLICT(room_id) DO UPDATE SET text=excluded.text",
|
|
369
|
-
(room_id, settings.get("user_request", "")))
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
def require_host(room, payload):
|
|
373
|
-
"""Check the declared operator; this is a workflow rule, not authentication."""
|
|
374
|
-
if payload.get("actor") not in (room["host"], "用户"):
|
|
375
|
-
raise ValueError("请使用当前主持人或用户身份管理话题")
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
def update_topic(connection, room_id, payload):
|
|
379
|
-
"""Update topic direction and participants through a declared host action."""
|
|
380
|
-
begin_write(connection)
|
|
381
|
-
room = require_room(connection, room_id)
|
|
382
|
-
require_host(room, payload)
|
|
383
|
-
settings = topic_values(payload, room)
|
|
384
|
-
if room["archived"] and settings["phase"] not in ("paused", "concluded"):
|
|
385
|
-
raise ValueError("请先恢复已存档频道,再调整讨论阶段")
|
|
386
|
-
turn = room["current_turn"]
|
|
387
|
-
if turn and turn["status"] in ("pending", "notified"):
|
|
388
|
-
if settings["phase"] == "concluded" or turn["expert"] not in settings["experts"]:
|
|
389
|
-
raise ValueError("请先完成或取消当前发言安排")
|
|
390
|
-
connection.execute("UPDATE rooms SET title=?,goal=? WHERE id=?", (settings["title"], settings["goal"], room_id))
|
|
391
|
-
sync_discussion_members(connection, room_id, payload, settings)
|
|
392
|
-
save_topic_settings(connection, room_id, settings)
|
|
393
|
-
return require_room(connection, room_id)
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
def submission_value(value):
|
|
397
|
-
"""Validate a replay identifier for writes or exact result lookup."""
|
|
398
|
-
identifier = str(value or "").strip()
|
|
399
|
-
if not re.fullmatch(r"[A-Za-z0-9_-]{8,100}", identifier):
|
|
400
|
-
raise ValueError("submission_id 必须是 8–100 位字母、数字、下划线或连字符")
|
|
401
|
-
return identifier
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
def list_rooms(connection, params=None):
|
|
405
|
-
"""Return rooms, optionally locating a single persisted creation request."""
|
|
406
|
-
params = params or {}
|
|
407
|
-
identifier = submission_value(params["submission_id"]) if "submission_id" in params else None
|
|
408
|
-
where = "WHERE EXISTS (SELECT 1 FROM room_creations WHERE room_id=rooms.id AND submission_id=?)" if identifier else ""
|
|
409
|
-
rows = connection.execute(f"SELECT rooms.*,COUNT(messages.id) AS message_count,COALESCE(MAX(messages.id),0) AS latest_id FROM rooms LEFT JOIN messages ON rooms.id=messages.room_id {where} GROUP BY rooms.id ORDER BY rooms.created_at, rooms.id", (identifier,) if identifier else ())
|
|
410
|
-
directory = {row["room_id"]: dict(row) for row in connection.execute("SELECT * FROM room_directory")}
|
|
411
|
-
assignments = {row["room_id"]: row["folder_id"] for row in connection.execute("SELECT room_id,folder_id FROM room_folder_assignments")}
|
|
412
|
-
rooms = [{**dict(row), "archived": bool(directory[row["id"]]["archived"]), "folder_id": assignments[row["id"]]} for row in rows]
|
|
413
|
-
return {"rooms": sorted(rooms, key=lambda room: directory[room["id"]]["position"]),
|
|
414
|
-
**folder_directory(connection)}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
def folder_directory(connection):
|
|
418
|
-
"""Return real folders and the virtual group's index (null keeps it last)."""
|
|
419
|
-
settings = connection.execute("SELECT uncategorized_position FROM directory_settings WHERE id=1").fetchone()
|
|
420
|
-
return {"folders": [dict(row) for row in connection.execute("SELECT id,name,position FROM topic_folders ORDER BY position,name,id")],
|
|
421
|
-
"uncategorized_position": settings[0] if settings else None}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
def folder_name_value(payload):
|
|
425
|
-
"""Validate a shared folder name for creation and renaming."""
|
|
426
|
-
raw_name = payload.get("name")
|
|
427
|
-
if not isinstance(raw_name, str):
|
|
428
|
-
raise ValueError("文件夹名称必须是文本")
|
|
429
|
-
name = raw_name.strip()
|
|
430
|
-
if not name or len(name) > 40:
|
|
431
|
-
raise ValueError("文件夹名称须为 1–40 个字符")
|
|
432
|
-
return name
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
def rename_folder(connection, folder_id, payload):
|
|
436
|
-
"""Rename one folder, retaining its position and all room assignments."""
|
|
437
|
-
begin_write(connection)
|
|
438
|
-
name = folder_name_value(payload)
|
|
439
|
-
if connection.execute("UPDATE topic_folders SET name=? WHERE id=?", (name, folder_id)).rowcount == 0:
|
|
440
|
-
raise LookupError("文件夹不存在")
|
|
441
|
-
return dict(connection.execute("SELECT id,name,position FROM topic_folders WHERE id=?", (folder_id,)).fetchone())
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
def delete_folder(connection, folder_id):
|
|
445
|
-
"""Remove a group while retaining its rooms and the virtual group's position."""
|
|
446
|
-
begin_write(connection)
|
|
447
|
-
directory = folder_directory(connection)
|
|
448
|
-
identifiers = [folder["id"] for folder in directory["folders"]]
|
|
449
|
-
if folder_id not in identifiers:
|
|
450
|
-
raise LookupError("文件夹不存在")
|
|
451
|
-
position = directory["uncategorized_position"]
|
|
452
|
-
if position is not None:
|
|
453
|
-
position -= int(identifiers.index(folder_id) < position)
|
|
454
|
-
if position >= len(identifiers) - 1:
|
|
455
|
-
position = None
|
|
456
|
-
connection.execute("DELETE FROM topic_folders WHERE id=?", (folder_id,))
|
|
457
|
-
connection.execute("UPDATE directory_settings SET uncategorized_position=? WHERE id=1", (position,))
|
|
458
|
-
return {"deleted": True, "id": folder_id}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
def move_folder(connection, folder_id, payload):
|
|
462
|
-
"""Persist a folder before another folder, or last when before_id is null.
|
|
463
|
-
|
|
464
|
-
Args:
|
|
465
|
-
connection: Writable SQLite connection.
|
|
466
|
-
folder_id: Existing folder or the reserved uncategorized group ID.
|
|
467
|
-
payload: Optional before_id identifying an existing destination folder.
|
|
468
|
-
|
|
469
|
-
Returns:
|
|
470
|
-
The complete folder list in its saved order.
|
|
471
|
-
"""
|
|
472
|
-
begin_write(connection)
|
|
473
|
-
directory = folder_directory(connection)
|
|
474
|
-
folders = directory["folders"]
|
|
475
|
-
position = directory["uncategorized_position"]
|
|
476
|
-
folders.insert(min(position, len(folders)) if position is not None else len(folders), {"id": UNCATEGORIZED_FOLDER_ID})
|
|
477
|
-
moving = next((folder for folder in folders if folder["id"] == folder_id), None)
|
|
478
|
-
if moving is None:
|
|
479
|
-
raise LookupError("文件夹不存在")
|
|
480
|
-
remaining = [folder for folder in folders if folder["id"] != folder_id]
|
|
481
|
-
before = payload.get("before_id")
|
|
482
|
-
identifiers = [folder["id"] for folder in remaining]
|
|
483
|
-
if before is not None and before not in identifiers:
|
|
484
|
-
raise ValueError("目标文件夹不存在或与当前文件夹相同")
|
|
485
|
-
remaining.insert(identifiers.index(before) if before is not None else len(remaining), moving)
|
|
486
|
-
uncategorized_position = next(index for index, folder in enumerate(remaining) if folder["id"] == UNCATEGORIZED_FOLDER_ID)
|
|
487
|
-
ordered = [folder for folder in remaining if folder["id"] != UNCATEGORIZED_FOLDER_ID]
|
|
488
|
-
uncategorized_position = None if uncategorized_position == len(ordered) else uncategorized_position
|
|
489
|
-
for position, folder in enumerate(ordered, 1):
|
|
490
|
-
folder["position"] = position
|
|
491
|
-
connection.executemany("UPDATE topic_folders SET position=? WHERE id=?",
|
|
492
|
-
[(folder["position"], folder["id"]) for folder in ordered])
|
|
493
|
-
connection.execute("INSERT INTO directory_settings (id,uncategorized_position) VALUES (1,?) "
|
|
494
|
-
"ON CONFLICT(id) DO UPDATE SET uncategorized_position=excluded.uncategorized_position",
|
|
495
|
-
(uncategorized_position,))
|
|
496
|
-
return {"folders": ordered, "uncategorized_position": uncategorized_position}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
def manage_room(connection, room_id, action, payload):
|
|
500
|
-
"""Move, archive, restore or permanently delete one declared host's room."""
|
|
501
|
-
begin_write(connection)
|
|
502
|
-
room = require_room(connection, room_id)
|
|
503
|
-
require_host(room, payload)
|
|
504
|
-
if action == "move":
|
|
505
|
-
rooms = [item["id"] for item in list_rooms(connection)["rooms"] if item["id"] != room_id]
|
|
506
|
-
before = payload.get("before_id")
|
|
507
|
-
if before is not None and before not in rooms:
|
|
508
|
-
raise ValueError("目标频道不存在或与当前频道相同")
|
|
509
|
-
rooms.insert(rooms.index(before) if before else len(rooms), room_id)
|
|
510
|
-
connection.executemany("UPDATE room_directory SET position=? WHERE room_id=?", enumerate(rooms, 1))
|
|
511
|
-
elif action == "folder":
|
|
512
|
-
folder_id = payload.get("folder_id")
|
|
513
|
-
if folder_id is not None and not isinstance(folder_id, str):
|
|
514
|
-
raise ValueError("文件夹编号格式无效")
|
|
515
|
-
if folder_id is not None and connection.execute("SELECT 1 FROM topic_folders WHERE id=?", (folder_id,)).fetchone() is None:
|
|
516
|
-
raise ValueError("文件夹不存在")
|
|
517
|
-
connection.execute("UPDATE room_folder_assignments SET folder_id=? WHERE room_id=?", (folder_id, room_id))
|
|
518
|
-
elif action in ("archive", "restore"):
|
|
519
|
-
if action == "archive" and room["phase"] != "concluded":
|
|
520
|
-
save_topic_settings(connection, room_id, {**room, "phase": "paused"})
|
|
521
|
-
connection.execute("UPDATE room_directory SET archived=? WHERE room_id=?", (int(action == "archive"), room_id))
|
|
522
|
-
elif action == "delete":
|
|
523
|
-
if payload.get("confirm_title") != room["title"]:
|
|
524
|
-
raise ValueError("请输入完整频道名称确认删除")
|
|
525
|
-
delete_room_records(connection, room_id)
|
|
526
|
-
return {"deleted": True, "id": room_id}
|
|
527
|
-
else:
|
|
528
|
-
raise LookupError("地址不存在")
|
|
529
|
-
return require_room(connection, room_id)
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
def delete_room_records(connection, room_id):
|
|
533
|
-
"""Remove a room's dependent records in the caller's transaction."""
|
|
534
|
-
for table in ("member_requests", "member_executions", "room_members", "room_modes", "free_batches", "free_chains", "room_links"):
|
|
535
|
-
connection.execute(f"DELETE FROM {table} WHERE room_id=?", (room_id,))
|
|
536
|
-
connection.execute("DELETE FROM message_timings WHERE message_id IN (SELECT id FROM messages WHERE room_id=?)", (room_id,))
|
|
537
|
-
connection.execute("DELETE FROM message_summaries WHERE message_id IN (SELECT id FROM messages WHERE room_id=?)", (room_id,))
|
|
538
|
-
connection.execute("DELETE FROM message_details WHERE message_id IN (SELECT id FROM messages WHERE room_id=?)", (room_id,))
|
|
539
|
-
connection.execute("DELETE FROM discussion_turns WHERE room_id=?", (room_id,))
|
|
540
|
-
connection.execute("UPDATE messages SET reply_to=NULL WHERE room_id=?", (room_id,))
|
|
541
|
-
connection.execute("DELETE FROM messages WHERE room_id=?", (room_id,))
|
|
542
|
-
for table in ("room_floor", "topic_settings", "room_creations", "room_directory", "room_folder_assignments", "room_briefs", "room_user_requests"):
|
|
543
|
-
connection.execute(f"DELETE FROM {table} WHERE room_id=?", (room_id,))
|
|
544
|
-
connection.execute("DELETE FROM rooms WHERE id=?", (room_id,))
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
def creation_request(settings):
|
|
548
|
-
"""Freeze normalized creation inputs independently of later topic edits."""
|
|
549
|
-
fields = ("title", "goal", "host", "background", "boundaries", "phase", "focus")
|
|
550
|
-
values = [settings[field] for field in fields] + [sorted(settings["experts"].items())]
|
|
551
|
-
if settings.get("save_limits"):
|
|
552
|
-
values.append([settings["limits"][key] for key in LIMIT_DEFAULTS])
|
|
553
|
-
request = json.dumps(values, ensure_ascii=False, separators=(",", ":"))
|
|
554
|
-
return request + (":user_request:" + json.dumps(settings["user_request"], ensure_ascii=False)
|
|
555
|
-
if settings.get("user_request") else "")
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
def create_room(connection, payload, control_token=""):
|
|
559
|
-
"""Create a credential-protected room, binding retries to the original credentials."""
|
|
560
|
-
if payload.get("mode", "brainstorm") not in ("free", "brainstorm"):
|
|
561
|
-
raise ValueError("房间模式须为 free 或 brainstorm")
|
|
562
|
-
free = payload.get("mode") == "free"
|
|
563
|
-
members = member_values(payload.get("members")) if free else []
|
|
564
|
-
if free:
|
|
565
|
-
payload = {**payload, "phase": "discussing", "experts": {member["name"]: "online" if member["access"] == "online" else "cli" for member in members}}
|
|
566
|
-
settings = topic_values(payload)
|
|
567
|
-
if not free:
|
|
568
|
-
members = discussion_members(payload, settings)
|
|
569
|
-
automatic = payload.get("runtime_mode") == "automatic"
|
|
570
|
-
if payload.get("runtime_mode", "manual") not in ("manual", "automatic") or not token_hash(control_token):
|
|
571
|
-
raise ValueError("建房须提供有效的运行模式和房主控制凭据")
|
|
572
|
-
if free and (automatic or not token_hash(control_token)):
|
|
573
|
-
raise ValueError("自由房间须提供房间控制凭据,且不使用讨论运行模式")
|
|
574
|
-
parent_id = payload.get("parent_room_id")
|
|
575
|
-
if parent_id is not None and (free or not isinstance(parent_id, str) or room_mode(connection, parent_id) != "free"):
|
|
576
|
-
raise ValueError("只有从自由协作房间发起的讨论房间可以关联来源房间")
|
|
577
|
-
identifier = submission_value(payload["submission_id"]) if "submission_id" in payload else None
|
|
578
|
-
folder_id = payload.get("folder_id")
|
|
579
|
-
if folder_id is not None and not isinstance(folder_id, str):
|
|
580
|
-
raise ValueError("文件夹编号必须是文本或 null")
|
|
581
|
-
request_text = creation_request(settings) + (":automatic" if automatic else "")
|
|
582
|
-
request_text += ":access:" + token_hash(control_token) + ":" + json.dumps(
|
|
583
|
-
[[member["name"], member["adapter"], member["access"], member["session_mode"], token_hash(member["token"])] for member in members], ensure_ascii=False)
|
|
584
|
-
if free:
|
|
585
|
-
request_text += ":free"
|
|
586
|
-
if parent_id is not None:
|
|
587
|
-
request_text += ":parent:" + parent_id
|
|
588
|
-
if folder_id is not None:
|
|
589
|
-
request_text += ":folder:" + json.dumps(folder_id, ensure_ascii=False)
|
|
590
|
-
begin_write(connection)
|
|
591
|
-
if identifier:
|
|
592
|
-
previous = connection.execute("SELECT * FROM room_creations WHERE submission_id=?", (identifier,)).fetchone()
|
|
593
|
-
if previous:
|
|
594
|
-
if previous["request_text"] != request_text:
|
|
595
|
-
raise FileExistsError("该建房提交编号已被其他内容使用")
|
|
596
|
-
return {**require_room(connection, previous["room_id"]), "duplicate": True, "submission_id": identifier}
|
|
597
|
-
if folder_id is not None and connection.execute("SELECT 1 FROM topic_folders WHERE id=?", (folder_id,)).fetchone() is None:
|
|
598
|
-
raise ValueError("文件夹不存在")
|
|
599
|
-
room_id = uuid.uuid4().hex[:12]
|
|
600
|
-
connection.execute("INSERT INTO rooms (id,title,goal,created_at) VALUES (?,?,?,?)", (room_id, settings["title"], settings["goal"], utc_now()))
|
|
601
|
-
connection.execute("UPDATE room_folder_assignments SET folder_id=? WHERE room_id=?", (folder_id, room_id))
|
|
602
|
-
if identifier:
|
|
603
|
-
connection.execute("INSERT INTO room_creations (submission_id,room_id,request_text) VALUES (?,?,?)", (identifier, room_id, request_text))
|
|
604
|
-
save_topic_settings(connection, room_id, settings)
|
|
605
|
-
save_room_access(connection, room_id, control_token, members,
|
|
606
|
-
{"mode": "free" if free else "brainstorm", "host": settings["host"], "created_at": utc_now()})
|
|
607
|
-
if parent_id is not None:
|
|
608
|
-
connection.execute("INSERT INTO room_links (room_id,parent_id,created_at) VALUES (?,?,?)", (room_id, parent_id, utc_now()))
|
|
609
|
-
if automatic:
|
|
610
|
-
configure_runtime(connection, room_id, control_token, {"revision": 0, "mode": "automatic"})
|
|
611
|
-
return {**require_room(connection, room_id), "duplicate": False, "submission_id": identifier}
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
def message_values(payload):
|
|
615
|
-
"""Validate a message and normalize its optional reply reference."""
|
|
616
|
-
author = str(payload.get("author", "")).strip()
|
|
617
|
-
text = str(payload.get("text", "")).strip()
|
|
618
|
-
submission_id = submission_value(payload.get("submission_id"))
|
|
619
|
-
if not is_participant(author):
|
|
620
|
-
raise ValueError("请选择有效的发言者")
|
|
621
|
-
if (not text and not attachment_ids(payload)) or len(text) > MAX_TEXT_LENGTH:
|
|
622
|
-
raise ValueError(f"发言需包含正文或附件,正文最多 {MAX_TEXT_LENGTH} 字")
|
|
623
|
-
reply_to = payload.get("reply_to")
|
|
624
|
-
return author, text, submission_id, int(reply_to) if reply_to else None
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
def attachment_ids(payload):
|
|
628
|
-
"""Validate the ordered, unique attachment references of one message."""
|
|
629
|
-
identifiers = payload.get("attachment_ids", [])
|
|
630
|
-
if (not isinstance(identifiers, list) or len(identifiers) > MAX_MESSAGE_ATTACHMENTS
|
|
631
|
-
or any(not isinstance(item, str) or not re.fullmatch(r"[a-f0-9]{32}", item) for item in identifiers)
|
|
632
|
-
or len(set(identifiers)) != len(identifiers)):
|
|
633
|
-
raise ValueError(f"附件编号无效;每条消息最多 {MAX_MESSAGE_ATTACHMENTS} 个,不能重复")
|
|
634
|
-
return identifiers
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
def attachment_metadata(params):
|
|
638
|
-
"""Validate upload metadata independently of the untrusted MIME header."""
|
|
639
|
-
name = params.get("file_name", "")
|
|
640
|
-
actor = params.get("actor")
|
|
641
|
-
if (not isinstance(name, str) or not name.strip() or len(name) > MAX_FILENAME_LENGTH
|
|
642
|
-
or name in (".", "..") or re.search(r"[/\\\x00-\x1f\x7f]", name)):
|
|
643
|
-
raise ValueError(f"文件名需为 1–{MAX_FILENAME_LENGTH} 字,不能包含路径或控制字符")
|
|
644
|
-
if not is_participant(actor):
|
|
645
|
-
raise ValueError("上传附件须声明有效的 actor")
|
|
646
|
-
return {"file_name": name, "actor": actor, "submission_id": submission_value(params.get("submission_id"))}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
def attachment_content_type(data):
|
|
650
|
-
"""Allow inline preview only for recognized raster images and PDF bytes."""
|
|
651
|
-
signatures = ((b"\x89PNG\r\n\x1a\n", "image/png"), (b"\xff\xd8\xff", "image/jpeg"),
|
|
652
|
-
(b"GIF87a", "image/gif"), (b"GIF89a", "image/gif"), (b"%PDF-", "application/pdf"))
|
|
653
|
-
for signature, content_type in signatures:
|
|
654
|
-
if data.startswith(signature):
|
|
655
|
-
return content_type
|
|
656
|
-
if data.startswith(b"RIFF") and data[8:12] == b"WEBP":
|
|
657
|
-
return "image/webp"
|
|
658
|
-
return "application/octet-stream"
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
def attachment_view(row):
|
|
662
|
-
"""Return attachment metadata and room-scoped content URLs."""
|
|
663
|
-
result = dict(row)
|
|
664
|
-
base = f"/api/rooms/{result['room_id']}/attachments/{result['id']}/content"
|
|
665
|
-
result.update(url=base, download_url=base + "?download=1")
|
|
666
|
-
return result
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
def message_with_attachments(connection, row):
|
|
670
|
-
"""Attach the published file list to an original message."""
|
|
671
|
-
message = dict(row)
|
|
672
|
-
message["suggestions"] = message_suggestions(connection, message["id"])
|
|
673
|
-
message["attachments"] = [attachment_view(item) for item in connection.execute(
|
|
674
|
-
"SELECT * FROM attachments WHERE message_id=? ORDER BY position,id", (message["id"],))]
|
|
675
|
-
return message
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
def query_attachments(connection, room_id, params):
|
|
679
|
-
"""List published files, or recover one upload by its exact submission ID."""
|
|
680
|
-
require_room(connection, room_id)
|
|
681
|
-
where, values = "room_id=?", [room_id]
|
|
682
|
-
if "submission_id" in params:
|
|
683
|
-
where += " AND submission_id=?"
|
|
684
|
-
values.append(submission_value(params["submission_id"]))
|
|
685
|
-
else:
|
|
686
|
-
where += " AND message_id IS NOT NULL"
|
|
687
|
-
if "message_id" in params:
|
|
688
|
-
where += " AND message_id=?"
|
|
689
|
-
values.append(int(params["message_id"]))
|
|
690
|
-
page = max(1, int(params.get("page", 1)))
|
|
691
|
-
total = connection.execute(f"SELECT COUNT(*) FROM attachments WHERE {where}", values).fetchone()[0]
|
|
692
|
-
rows = connection.execute(f"SELECT * FROM attachments WHERE {where} ORDER BY created_at,id LIMIT ? OFFSET ?",
|
|
693
|
-
[*values, PAGE_SIZE, (page - 1) * PAGE_SIZE])
|
|
694
|
-
return {"attachments": [attachment_view(row) for row in rows], "page": page, "total": total,
|
|
695
|
-
"pages": max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
def get_attachment(connection, room_id, identifier):
|
|
699
|
-
"""Resolve a file only within the explicitly selected room."""
|
|
700
|
-
row = connection.execute("SELECT * FROM attachments WHERE room_id=? AND id=?", (room_id, identifier)).fetchone()
|
|
701
|
-
if not row:
|
|
702
|
-
raise LookupError("附件不存在")
|
|
703
|
-
return attachment_view(row)
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
def attachment_path(database, attachment):
|
|
707
|
-
"""Keep opaque file IDs in storage belonging to this exact SQLite database."""
|
|
708
|
-
root = Path(str(Path(database).resolve()) + ".attachments")
|
|
709
|
-
return root / attachment["id"]
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
def store_attachment(connection, database, room_id, metadata, data):
|
|
713
|
-
"""Persist one immutable upload; exact retries return the original file."""
|
|
714
|
-
begin_write(connection)
|
|
715
|
-
require_room(connection, room_id)
|
|
716
|
-
expected = {**metadata, "size": len(data), "sha256": hashlib.sha256(data).hexdigest(),
|
|
717
|
-
"content_type": attachment_content_type(data)}
|
|
718
|
-
previous = connection.execute("SELECT * FROM attachments WHERE room_id=? AND submission_id=?",
|
|
719
|
-
(room_id, metadata["submission_id"])).fetchone()
|
|
720
|
-
if previous:
|
|
721
|
-
if any(previous[key] != value for key, value in expected.items()):
|
|
722
|
-
raise FileExistsError("这个附件提交编号已被其他文件使用")
|
|
723
|
-
return {"attachment": attachment_view(previous), "duplicate": True}
|
|
724
|
-
record = {"id": uuid.uuid4().hex, "room_id": room_id, **expected, "created_at": utc_now()}
|
|
725
|
-
path = attachment_path(database, record)
|
|
726
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
727
|
-
try:
|
|
728
|
-
with path.open("xb") as target:
|
|
729
|
-
target.write(data)
|
|
730
|
-
connection.execute("""INSERT INTO attachments
|
|
731
|
-
(id,room_id,file_name,actor,submission_id,size,sha256,content_type,created_at)
|
|
732
|
-
VALUES (:id,:room_id,:file_name,:actor,:submission_id,:size,:sha256,:content_type,:created_at)""", record)
|
|
733
|
-
connection.commit()
|
|
734
|
-
except Exception:
|
|
735
|
-
path.unlink(missing_ok=True)
|
|
736
|
-
raise
|
|
737
|
-
return {"attachment": get_attachment(connection, room_id, record["id"]), "duplicate": False}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
def check_message_attachments(connection, room_id, actor, identifiers):
|
|
741
|
-
"""Reject references to another room, uploader, or already published message."""
|
|
742
|
-
for identifier in identifiers:
|
|
743
|
-
attachment = get_attachment(connection, room_id, identifier)
|
|
744
|
-
if attachment["actor"] != actor or attachment["message_id"] is not None:
|
|
745
|
-
raise FileExistsError("附件已用于其他消息,或不属于本次实际提交者")
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
def message_details(room, payload):
|
|
749
|
-
"""Validate declared author, moderator role, and forwarding attribution."""
|
|
750
|
-
author = payload["author"]
|
|
751
|
-
kind = payload.get("kind", "opinion")
|
|
752
|
-
submitted_by = payload.get("submitted_by", author if payload.get("source")
|
|
753
|
-
if kind not in ("opinion", "moderation", "receipt") or not is_participant(submitted_by):
|
|
754
|
-
raise ValueError("发言职责或提交者无效")
|
|
755
|
-
if submitted_by not in ("用户", room["host"], author):
|
|
756
|
-
raise ValueError("只有用户或主持人可以代发其他专家的观点")
|
|
757
|
-
if kind == "moderation" and (author != room["host"] or submitted_by not in ("用户", room["host"])):
|
|
758
|
-
raise ValueError("主持发言必须保留当前主持人署名")
|
|
759
|
-
if submitted_by == author and payload.get("source")
|
|
760
|
-
raise ValueError("当前专家配置为在线只读,请由用户或主持人代发")
|
|
761
|
-
source = "host_proxy" if submitted_by == room["host"] != author and submitted_by != "用户" else (
|
|
762
|
-
"
|
|
763
|
-
return {"kind": kind, "submitted_by": submitted_by, "turn_id": int(payload["turn_id"]) if payload.get("turn_id") else None, "source": source}
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
def message_summary(payload, message):
|
|
767
|
-
"""Preserve a supplied summary and distinguish its writer from the answer's author."""
|
|
768
|
-
summary = str(payload.get("summary", "")).strip()
|
|
769
|
-
if len(summary) > MAX_SUMMARY_LENGTH:
|
|
770
|
-
raise ValueError(f"回答摘要最多 {MAX_SUMMARY_LENGTH} 字")
|
|
771
|
-
summary_by = payload.get("summary_by", message["submitted_by"]) if summary else ""
|
|
772
|
-
if summary and (message["kind"] not in ("opinion", "chat") or summary_by not in (message["author"], message["submitted_by"])):
|
|
773
|
-
raise ValueError("摘要仅用于观点,撰写者须为原作者或实际提交者")
|
|
774
|
-
return {"summary": summary, "summary_by": summary_by}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
def check_message_references(connection, room_id, message):
|
|
778
|
-
"""Keep replies and scheduled answers within their declared topic."""
|
|
779
|
-
if message["reply_to"] and not connection.execute("SELECT id FROM messages WHERE room_id=? AND id=?", (room_id, message["reply_to"])).fetchone():
|
|
780
|
-
raise ValueError("回复的消息不在当前房间中")
|
|
781
|
-
if message["turn_id"]:
|
|
782
|
-
turn = get_turn(connection, room_id, message["turn_id"])
|
|
783
|
-
if turn["expert"] != message["author"] or message["kind"] != "opinion":
|
|
784
|
-
raise ValueError("回答需使用被安排专家的身份和专家观点职责")
|
|
785
|
-
if turn["status"] not in ("pending", "notified"):
|
|
786
|
-
raise FileExistsError("该发言安排已完成或取消")
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
def record_message_timing(connection, message_id, token):
|
|
790
|
-
"""Freeze elapsed time only for the execution that actually authored this message."""
|
|
791
|
-
if not token:
|
|
792
|
-
return
|
|
793
|
-
connection.execute("""INSERT INTO message_timings(message_id,elapsed_seconds)
|
|
794
|
-
SELECT m.id,MAX(0,CAST(strftime('%s',m.created_at) AS INTEGER)-t.dispatched_at)
|
|
795
|
-
FROM messages AS m JOIN message_details AS d ON d.message_id=m.id
|
|
796
|
-
JOIN discussion_events AS e ON e.room_id=m.room_id AND e.participant=m.author
|
|
797
|
-
JOIN event_timings AS t ON t.event_id=e.id
|
|
798
|
-
WHERE m.id=? AND m.source
|
|
799
|
-
AND e.status='started' AND e.token_hash=? AND t.dispatched_at>0
|
|
800
|
-
ON CONFLICT(message_id) DO NOTHING""", (message_id, token_hash(token)))
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
def append_free_message(connection, room, payload, actor):
|
|
804
|
-
"""Append a free-room message: the owner's creates requests, a member's completes its execution.
|
|
805
|
-
|
|
806
|
-
The owner may also forward an online member's answer; it keeps the member as author and the
|
|
807
|
-
owner as submitter.
|
|
808
|
-
"""
|
|
809
|
-
author, text, submission_id, reply_to = message_values(payload)
|
|
810
|
-
room_id = room["id"]
|
|
811
|
-
proxy = None
|
|
812
|
-
if actor == "用户" and author != "用户":
|
|
813
|
-
proxy = proxy_member(connection, room_id, author)
|
|
814
|
-
if payload.get("submitted_by", "用户") != "用户":
|
|
815
|
-
raise PermissionError("代发在线成员的回答须由用户提交")
|
|
816
|
-
submitted_by = "用户" if proxy else author
|
|
817
|
-
expected = {"author": author, "text": text, "reply_to": reply_to, "kind": "chat", "submitted_by": submitted_by,
|
|
818
|
-
"turn_id": None, "source": "proxy" if proxy else ("user" if author == "用户" else "cli")}
|
|
819
|
-
expected.update(message_summary(payload, expected))
|
|
820
|
-
identifiers = attachment_ids(payload)
|
|
821
|
-
previous = connection.execute(f"{MESSAGE_QUERY} WHERE room_id=? AND submission_id=?", (room_id, submission_id)).fetchone()
|
|
822
|
-
if previous:
|
|
823
|
-
previous = message_with_attachments(connection, previous)
|
|
824
|
-
if (any(previous[key] != expected[key] for key in ("author", "text", "reply_to", "kind", "submitted_by", "summary", "summary_by"))
|
|
825
|
-
or [item["id"] for item in previous["attachments"]] != identifiers):
|
|
826
|
-
raise FileExistsError("这个提交编号已被另一条内容使用,请为新发言生成新编号")
|
|
827
|
-
return {"message": previous, "duplicate": True}
|
|
828
|
-
if room["archived"]:
|
|
829
|
-
raise ValueError("频道已存档;恢复后才能继续协作")
|
|
830
|
-
check_message_references(connection, room_id, expected)
|
|
831
|
-
check_message_attachments(connection, room_id, submitted_by, identifiers)
|
|
832
|
-
if proxy:
|
|
833
|
-
check_proxy(connection, room_id, proxy, payload)
|
|
834
|
-
elif actor != "用户":
|
|
835
|
-
check_answer(connection, room_id, actor, payload)
|
|
836
|
-
cursor = connection.execute("INSERT INTO messages (room_id,author,text,reply_to,source,submission_id,created_at) VALUES (?,?,?,?,?,?,?)",
|
|
837
|
-
(room_id, author, text, reply_to, expected["source"], submission_id, utc_now()))
|
|
838
|
-
connection.execute("INSERT INTO message_details (message_id,kind,submitted_by,turn_id) VALUES (?,?,?,NULL)",
|
|
839
|
-
(cursor.lastrowid, "chat", submitted_by))
|
|
840
|
-
connection.execute("INSERT INTO message_summaries (message_id,text,author) VALUES (?,?,?)",
|
|
841
|
-
(cursor.lastrowid, expected["summary"], expected["summary_by"]))
|
|
842
|
-
for position, identifier in enumerate(identifiers):
|
|
843
|
-
connection.execute("UPDATE attachments SET message_id=?,position=? WHERE id=?", (cursor.lastrowid, position, identifier))
|
|
844
|
-
save_suggestions(connection, room_id, cursor.lastrowid, proxy or actor, payload)
|
|
845
|
-
after_free_message(connection, room_id, cursor.lastrowid, proxy or actor, payload)
|
|
846
|
-
return {"message": get_message(connection, room_id, cursor.lastrowid), "duplicate": False}
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
def append_message(connection, room_id, payload, token="", free_actor=None):
|
|
850
|
-
"""Atomically append an attributed message and complete its scheduled turn."""
|
|
851
|
-
author, text, submission_id, reply_to = message_values(payload)
|
|
852
|
-
begin_write(connection)
|
|
853
|
-
room = require_room(connection, room_id)
|
|
854
|
-
if room["mode"] == "free":
|
|
855
|
-
return append_free_message(connection, room, payload, free_actor)
|
|
856
|
-
details = message_details(room, {**payload, "author": author})
|
|
857
|
-
expected = {"author": author, "text": text, "reply_to": reply_to, **details}
|
|
858
|
-
expected.update(message_summary(payload, expected))
|
|
859
|
-
identifiers = attachment_ids(payload)
|
|
860
|
-
previous = connection.execute(f"{MESSAGE_QUERY} WHERE room_id=? AND submission_id=?", (room_id, submission_id)).fetchone()
|
|
861
|
-
if previous:
|
|
862
|
-
previous = message_with_attachments(connection, previous)
|
|
863
|
-
if (any(previous[key] != expected[key] for key in ("author", "text", "reply_to", "kind", "submitted_by", "turn_id", "summary", "summary_by"))
|
|
864
|
-
or [item["id"] for item in previous["attachments"]] != identifiers):
|
|
865
|
-
raise FileExistsError("这个提交编号已被另一条内容使用,请为新发言生成新编号")
|
|
866
|
-
return {"message": previous, "duplicate": True}
|
|
867
|
-
if author != "用户" and details["kind"] == "opinion":
|
|
868
|
-
require_within_limits(room)
|
|
869
|
-
if room["phase"] in ("paused", "concluded") and details["kind"] == "opinion":
|
|
870
|
-
raise ValueError("话题已暂停或收束;恢复阶段后才能提交新观点,运行回执请用 receipt")
|
|
871
|
-
check_message_references(connection, room_id, expected)
|
|
872
|
-
check_message_attachments(connection, room_id, details["submitted_by"], identifiers)
|
|
873
|
-
if author != "用户":
|
|
874
|
-
if details["kind"] == "opinion" and not details["turn_id"]:
|
|
875
|
-
raise ValueError("专家观点必须关联当前有效的 turn_id")
|
|
876
|
-
require_floor(room, author)
|
|
877
|
-
cursor = connection.execute("INSERT INTO messages (room_id,author,text,reply_to,source,submission_id,created_at) VALUES (?,?,?,?,?,?,?)",
|
|
878
|
-
(room_id, author, text, reply_to, details["source"], submission_id, utc_now()))
|
|
879
|
-
connection.execute("INSERT INTO message_details (message_id,kind,submitted_by,turn_id) VALUES (?,?,?,?)",
|
|
880
|
-
(cursor.lastrowid, details["kind"], details["submitted_by"], details["turn_id"]))
|
|
881
|
-
connection.execute("INSERT INTO message_summaries (message_id,text,author) VALUES (?,?,?)",
|
|
882
|
-
(cursor.lastrowid, expected["summary"], expected["summary_by"]))
|
|
883
|
-
for position, identifier in enumerate(identifiers):
|
|
884
|
-
connection.execute("UPDATE attachments SET message_id=?,position=? WHERE id=?",
|
|
885
|
-
(cursor.lastrowid, position, identifier))
|
|
886
|
-
record_message_timing(connection, cursor.lastrowid, token)
|
|
887
|
-
if details["turn_id"]:
|
|
888
|
-
connection.execute("UPDATE discussion_turns SET status='answered',answer_id=? WHERE id=?", (cursor.lastrowid, details["turn_id"]))
|
|
889
|
-
return {"message": get_message(connection, room_id, cursor.lastrowid), "duplicate": False}
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
def get_turn(connection, room_id, turn_id):
|
|
893
|
-
"""Read a single speaking assignment inside its topic."""
|
|
894
|
-
row = connection.execute("SELECT * FROM discussion_turns WHERE room_id=? AND id=?", (room_id, int(turn_id))).fetchone()
|
|
895
|
-
if not row:
|
|
896
|
-
raise LookupError("发言安排不存在")
|
|
897
|
-
return dict(row)
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
def query_turns(connection, room_id, params):
|
|
901
|
-
"""Read speaking assignments in bounded chronological pages."""
|
|
902
|
-
require_room(connection, room_id)
|
|
903
|
-
page = max(1, int(params.get("page", 1)))
|
|
904
|
-
where, values = "room_id=?", [room_id]
|
|
905
|
-
if "submission_id" in params:
|
|
906
|
-
where += " AND submission_id=?"
|
|
907
|
-
values.append(submission_value(params["submission_id"]))
|
|
908
|
-
total = connection.execute(f"SELECT COUNT(*) FROM discussion_turns WHERE {where}", values).fetchone()[0]
|
|
909
|
-
rows = connection.execute(f"SELECT * FROM discussion_turns WHERE {where} ORDER BY id LIMIT ? OFFSET ?", [*values, PAGE_SIZE, (page - 1) * PAGE_SIZE])
|
|
910
|
-
return {"turns": [dict(row) for row in rows], "page": page, "pages": max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE), "total": total}
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
def plan_values(payload):
|
|
914
|
-
"""Normalize one arrangement or an ordered round plan into (expert, question, submission_id) items."""
|
|
915
|
-
items = payload.get("turns")
|
|
916
|
-
if items is None:
|
|
917
|
-
items = [{"expert": payload.get("expert", ""), "question": payload.get("question", "")}]
|
|
918
|
-
if not isinstance(items, list) or not 1 <= len(items) <= MAX_PLAN_TURNS:
|
|
919
|
-
raise ValueError(f"整轮安排需要 1–{MAX_PLAN_TURNS} 项")
|
|
920
|
-
submission_id = submission_value(payload.get("submission_id"))
|
|
921
|
-
plan = []
|
|
922
|
-
for index, item in enumerate(items):
|
|
923
|
-
if not isinstance(item, dict):
|
|
924
|
-
raise ValueError("整轮安排的每一项需要 expert 和 question")
|
|
925
|
-
expert, question, _, _ = message_values({"author": item.get("expert", ""), "text": item.get("question", ""),
|
|
926
|
-
"submission_id": submission_id})
|
|
927
|
-
plan.append((expert, question, submission_id if index == 0 else f"{submission_id}-q{index + 1}"))
|
|
928
|
-
return plan
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
def create_turn(connection, room_id, payload):
|
|
932
|
-
"""Schedule one expert, or a round whose later turns relay without waking the host."""
|
|
933
|
-
begin_write(connection)
|
|
934
|
-
room = require_room(connection, room_id)
|
|
935
|
-
require_host(room, payload)
|
|
936
|
-
plan = plan_values(payload)
|
|
937
|
-
previous = [connection.execute("SELECT * FROM discussion_turns WHERE room_id=? AND submission_id=?",
|
|
938
|
-
(room_id, item[2])).fetchone() for item in plan]
|
|
939
|
-
if previous[0]:
|
|
940
|
-
if any(not row or (row["expert"], row["question"]) != item[:2] for row, item in zip(previous, plan)):
|
|
941
|
-
raise FileExistsError("该安排编号已被其他内容使用")
|
|
942
|
-
return {"turn": dict(previous[0]), "queued": [dict(row) for row in previous[1:]], "duplicate": True}
|
|
943
|
-
require_within_limits(room)
|
|
944
|
-
if any(expert not in room["experts"] or len(question) > MAX_QUESTION_LENGTH for expert, question, _ in plan):
|
|
945
|
-
raise ValueError("请选择参与专家,待回应问题最多 1000 字")
|
|
946
|
-
if room["phase"] in ("paused", "concluded"):
|
|
947
|
-
raise ValueError("话题已暂停或收束,请先调整阶段")
|
|
948
|
-
if room["current_turn"] and room["current_turn"]["status"] in ("pending", "notified"):
|
|
949
|
-
raise FileExistsError("请先完成或取消当前发言安排")
|
|
950
|
-
require_floor(room, room["host"])
|
|
951
|
-
ids = []
|
|
952
|
-
for index, (expert, question, submission_id) in enumerate(plan):
|
|
953
|
-
cursor = connection.execute(
|
|
954
|
-
"INSERT INTO discussion_turns (room_id,expert,question,status,submission_id,created_at) VALUES (?,?,?,?,?,?)",
|
|
955
|
-
(room_id, expert, question, "pending" if index == 0 else "queued", submission_id, utc_now()))
|
|
956
|
-
ids.append(cursor.lastrowid)
|
|
957
|
-
return {"turn": get_turn(connection, room_id, ids[0]),
|
|
958
|
-
"queued": [get_turn(connection, room_id, turn_id) for turn_id in ids[1:]], "duplicate": False}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
def continue_turns(connection, room_id, payload):
|
|
962
|
-
"""Resume the remaining round plan after the host has handled user actions or an interruption."""
|
|
963
|
-
begin_write(connection)
|
|
964
|
-
room = require_room(connection, room_id)
|
|
965
|
-
require_host(room, payload)
|
|
966
|
-
if room["phase"] in ("paused", "concluded"):
|
|
967
|
-
raise ValueError("话题已暂停或收束,请先调整阶段")
|
|
968
|
-
if room["current_turn"] and room["current_turn"]["status"] in ("pending", "notified"):
|
|
969
|
-
raise FileExistsError("当前已有进行中的发言安排")
|
|
970
|
-
if not room["queued_turns"]:
|
|
971
|
-
raise FileExistsError("没有排队中的安排;请创建新的安排")
|
|
972
|
-
require_within_limits(room)
|
|
973
|
-
require_floor(room, room["host"])
|
|
974
|
-
turn_id = room["queued_turns"][0]["id"]
|
|
975
|
-
connection.execute("UPDATE discussion_turns SET status='pending' WHERE id=? AND status='queued'", (turn_id,))
|
|
976
|
-
return {"turn": get_turn(connection, room_id, turn_id), "queued": room["queued_turns"][1:]}
|
|
977
|
-
|
|
978
|
-
def check_limits(connection, room_id, payload):
|
|
979
|
-
"""Apply a reached boundary once; the host must author the actual summary."""
|
|
980
|
-
begin_write(connection)
|
|
981
|
-
room = require_room(connection, room_id)
|
|
982
|
-
require_host(room, payload)
|
|
983
|
-
limits = room["limits"]
|
|
984
|
-
force = payload.get("force") is True
|
|
985
|
-
if force and (room["phase"] in ("preparing", "paused", "concluded") or room["archived"]):
|
|
986
|
-
raise ValueError("仅进行中的讨论可以提前收尾")
|
|
987
|
-
if (not limits["reached"] and not force) or room["phase"] in ("paused", "concluded") or room["archived"]:
|
|
988
|
-
return room
|
|
989
|
-
reason = limits["reason"] or "user_stop"
|
|
990
|
-
closing = (force or limits["on_limit"] == "summarize") and reason != "time_limit"
|
|
991
|
-
if closing and limits["closing"]:
|
|
992
|
-
return room
|
|
993
|
-
connection.execute("UPDATE discussion_limits SET closing_at=?,stop_reason=?,revision=revision+1 WHERE room_id=?",
|
|
994
|
-
(int(time.time()) if closing else limits["closing_at"], reason, room_id))
|
|
995
|
-
connection.execute("UPDATE discussion_turns SET status='cancelled' WHERE room_id=? AND status IN ('pending','notified')", (room_id,))
|
|
996
|
-
connection.execute("UPDATE discussion_turns SET status='skipped' WHERE room_id=? AND status='queued'", (room_id,))
|
|
997
|
-
connection.execute("UPDATE room_floor SET holder=NULL,next_speaker=?,revision=revision+1 WHERE room_id=?", (room["host"], room_id))
|
|
998
|
-
if closing:
|
|
999
|
-
summary_deadline = int(time.time()) + limits["summary_minutes"] * 60
|
|
1000
|
-
connection.execute("UPDATE discussion_runtime SET generation=generation+1,revision=revision+1,deadline=CASE WHEN deadline=0 THEN ? ELSE MIN(deadline,?) END WHERE room_id=? AND status='running'",
|
|
1001
|
-
(summary_deadline, summary_deadline, room_id))
|
|
1002
|
-
else:
|
|
1003
|
-
connection.execute("UPDATE topic_settings SET phase='paused' WHERE room_id=?", (room_id,))
|
|
1004
|
-
return require_room(connection, room_id)
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
def update_turn(connection, room_id, turn_id, payload):
|
|
1008
|
-
"""Record a host's explicit notification confirmation or cancellation."""
|
|
1009
|
-
begin_write(connection)
|
|
1010
|
-
room = require_room(connection, room_id)
|
|
1011
|
-
require_host(room, payload)
|
|
1012
|
-
turn = get_turn(connection, room_id, turn_id)
|
|
1013
|
-
status = payload.get("status")
|
|
1014
|
-
if status not in ("notified", "cancelled"):
|
|
1015
|
-
raise ValueError("安排状态仅可显式改为 notified 或 cancelled")
|
|
1016
|
-
if turn["status"] == status:
|
|
1017
|
-
return turn
|
|
1018
|
-
if status == "notified" and room["phase"] in ("paused", "concluded"):
|
|
1019
|
-
raise ValueError("话题已暂停或收束,不能继续通知")
|
|
1020
|
-
if turn["status"] not in ("pending", "notified"):
|
|
1021
|
-
raise FileExistsError("该发言安排已结束")
|
|
1022
|
-
if status == "cancelled" and payload.get("actor") != "用户":
|
|
1023
|
-
require_floor(room, room["host"])
|
|
1024
|
-
notified_at = utc_now() if status == "notified" else turn["notified_at"]
|
|
1025
|
-
connection.execute("UPDATE discussion_turns SET status=?,notified_at=? WHERE id=?", (status, notified_at, turn["id"]))
|
|
1026
|
-
return get_turn(connection, room_id, turn_id)
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
def message_filters(room_id, params):
|
|
1030
|
-
"""Build exact and incremental filters without expanding the read scope."""
|
|
1031
|
-
query = str(params.get("q", "")).strip()
|
|
1032
|
-
author = str(params.get("author", "")).strip()
|
|
1033
|
-
mention = str(params.get("mention", "")).strip()
|
|
1034
|
-
clauses = ["room_id=?"]
|
|
1035
|
-
values = [room_id]
|
|
1036
|
-
if query:
|
|
1037
|
-
clauses.append("(instr(lower(messages.text), lower(?)) > 0 OR CAST(messages.id AS TEXT)=?)")
|
|
1038
|
-
values.extend([query, query.lstrip("#")])
|
|
1039
|
-
if author:
|
|
1040
|
-
clauses.append("messages.author=?")
|
|
1041
|
-
values.append(author)
|
|
1042
|
-
if mention:
|
|
1043
|
-
clauses.append("instr(messages.text, ?) > 0")
|
|
1044
|
-
values.append(f"@{mention}")
|
|
1045
|
-
if "kind" in params:
|
|
1046
|
-
if params["kind"] not in ("opinion", "moderation", "receipt", "chat"):
|
|
1047
|
-
raise ValueError("发言职责无效")
|
|
1048
|
-
clauses.append("COALESCE(details.kind,'opinion')=?")
|
|
1049
|
-
values.append(params["kind"])
|
|
1050
|
-
if "submission_id" in params:
|
|
1051
|
-
clauses.append("submission_id=?")
|
|
1052
|
-
values.append(submission_value(params["submission_id"]))
|
|
1053
|
-
if "after" in params:
|
|
1054
|
-
after = int(params["after"])
|
|
1055
|
-
if after < 0 or int(params.get("page", 1)) != 1:
|
|
1056
|
-
raise ValueError("after 需为非负消息编号,不能与翻页同时使用")
|
|
1057
|
-
clauses.append("messages.id>?")
|
|
1058
|
-
values.append(after)
|
|
1059
|
-
return " AND ".join(clauses), values
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
def query_messages(connection, room_id, params):
|
|
1063
|
-
"""Read one bounded page; incremental cursors advance only through returned rows."""
|
|
1064
|
-
require_room(connection, room_id)
|
|
1065
|
-
page = max(1, int(params.get("page", 1)))
|
|
1066
|
-
where, values = message_filters(room_id, params)
|
|
1067
|
-
total = connection.execute(f"SELECT COUNT(*) FROM ({MESSAGE_QUERY} WHERE {where})", values).fetchone()[0]
|
|
1068
|
-
rows = connection.execute(f"{MESSAGE_QUERY} WHERE {where} ORDER BY messages.id LIMIT ? OFFSET ?", [*values, PAGE_SIZE + 1, (page - 1) * PAGE_SIZE]).fetchall()
|
|
1069
|
-
messages = [message_with_attachments(connection, row) for row in rows[:PAGE_SIZE]]
|
|
1070
|
-
result = {"messages": messages, "page": page, "total": total,
|
|
1071
|
-
"pages": max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)}
|
|
1072
|
-
if "after" in params:
|
|
1073
|
-
result.update(next_after=messages[-1]["id"] if messages else int(params["after"]), has_more=len(rows) > PAGE_SIZE)
|
|
1074
|
-
return result
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
def get_message(connection, room_id, message_id):
|
|
1078
|
-
"""Read an exact message, constrained to the specified room."""
|
|
1079
|
-
row = connection.execute(f"{MESSAGE_QUERY} WHERE room_id=? AND messages.id=?", (room_id, message_id)).fetchone()
|
|
1080
|
-
if row is None:
|
|
1081
|
-
raise LookupError("消息不存在")
|
|
1082
|
-
return message_with_attachments(connection, row)
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
def update_state(connection, room_id, payload):
|
|
1086
|
-
"""Save detailed state and an optional human-facing brief in one transaction."""
|
|
1087
|
-
text = str(payload.get("text", "")).strip()
|
|
1088
|
-
brief = str(payload.get("brief", "")).strip()
|
|
1089
|
-
if len(text) > MAX_STATE_LENGTH or len(brief) > MAX_BRIEF_LENGTH:
|
|
1090
|
-
raise ValueError(f"讨论状态最多 {MAX_STATE_LENGTH} 字,主持简报最多 {MAX_BRIEF_LENGTH} 字")
|
|
1091
|
-
room = require_room(connection, room_id)
|
|
1092
|
-
require_host(room, {"actor": payload.get("actor", "用户")})
|
|
1093
|
-
until = int(payload.get("until", room["latest_id"]))
|
|
1094
|
-
if until < 0 or until > room["latest_id"]:
|
|
1095
|
-
raise ValueError("讨论状态的截止编号无效")
|
|
1096
|
-
connection.execute("UPDATE rooms SET state_text=?,state_until=? WHERE id=?", (text, until, room_id))
|
|
1097
|
-
if "brief" in payload:
|
|
1098
|
-
connection.execute("""INSERT INTO room_briefs (room_id,text,until,updated_at) VALUES (?,?,?,?)
|
|
1099
|
-
ON CONFLICT(room_id) DO UPDATE SET text=excluded.text,until=excluded.until,updated_at=excluded.updated_at""",
|
|
1100
|
-
(room_id, brief, until, utc_now()))
|
|
1101
|
-
return require_room(connection, room_id)
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
def state_reading_text(room):
|
|
1105
|
-
"""Read the host's brief explicitly without adding it to independent-entry overviews."""
|
|
1106
|
-
brief = room.get("host_brief")
|
|
1107
|
-
overview = f"主持简报(整理至 #{brief['until']},{brief['updated_at']})\n{brief['text']}" if brief and brief["text"] else "主持简报:尚未更新"
|
|
1108
|
-
return f"{overview}\n\n主持人维护的状态(整理至 #{room['state_until']})\n\n{room['state_text'] or '尚未整理'}\n\n{receipt_references_text(room)}"
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
def message_text(message):
|
|
1112
|
-
"""Render an original message with its provenance and reply reference."""
|
|
1113
|
-
reply = f" · 回复 #{message['reply_to']}" if message["reply_to"] else ""
|
|
1114
|
-
role = {"moderation": "主持发言", "receipt": "运行回执", "chat": "协作消息"}.get(message.get("kind"), "专家观点")
|
|
1115
|
-
summary = f"\n回答摘要({message['summary_by']}):\n{message['summary']}\n\n完整正文:\n" if message.get("summary") else "\n"
|
|
1116
|
-
attachments = "".join(f"\n附件:{item['file_name']}({item['size']} 字节;ID: {item['id']})\n{item['download_url']}"
|
|
1117
|
-
for item in message.get("attachments", []))
|
|
1118
|
-
return f"#{message['id']} · {message['author']} · {message['created_at']}{reply}\n职责:{role};来源:{message['source']};提交者:{message.get('submitted_by', '用户')};安排:{message.get('turn_id') or '无'}{summary}{message['text']}{attachments}"
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
def room_text(connection, room_id, params):
|
|
1122
|
-
"""Expose only an overview; readers choose which supporting pages to open."""
|
|
1123
|
-
room = require_room(connection, room_id)
|
|
1124
|
-
base = f"/r/{room_id}"
|
|
1125
|
-
content = [f"# {room['title']}", f"目标:{room['goal']}",
|
|
1126
|
-
f"主持人:{room['host']};阶段:{PHASES[room['phase']]};当前焦点:{room['focus'] or '尚未填写'}",
|
|
1127
|
-
f"当前记录截止 #{room['latest_id']};共 {room['message_count']} 条;已完成 {room['completed_rounds']} 轮讨论(所有参与专家各发言一次)。"]
|
|
1128
|
-
limits = room["limits"]
|
|
1129
|
-
content.append(f"讨论上限:{limits['max_rounds'] or '不限'} 轮;总时长 {limits['max_minutes'] or '不限'} 分钟;单次自动执行 {str(limits['execution_minutes']) + ' 分钟' if limits['execution_minutes'] else '不限时长'};到限 {limits['on_limit']};收束预留 {limits['summary_minutes']} 分钟(计入总时长)。到限:{limits['reached']};收束中:{limits['closing']}。")
|
|
1130
|
-
if room["mode"] == "free":
|
|
1131
|
-
members = ";".join(f"{item['name']}(已读至 #{item['read_until']},待投递 {item['pending']})" for item in room["free"]["members"])
|
|
1132
|
-
content.append(f"自由协作房间:无发言权、安排与轮次。成员:{members or '无'}。用户的消息点名成员后生成请求;成员回答须带本次执行编号,执行结束、被打断或过期后的回答会被拒绝,不能改作普通发言。")
|
|
1133
|
-
return "\n\n".join(content)
|
|
1134
|
-
turn = room["current_turn"]
|
|
1135
|
-
floor = room["floor"]
|
|
1136
|
-
content.append(f"发言权:{floor['holder'] + ' 持有' if floor['holder'] else '待 ' + floor['next_speaker'] + ' 领取'};版本 {floor['revision']}。用户本人可随时留言,AI 发言前须领取。")
|
|
1137
|
-
if room["phase"] == "initial":
|
|
1138
|
-
content.append("本阶段先独立形成详细观点:仅阅读目标、背景、边界和自己的已有讨论;暂不阅读他人观点或主持总结。提交后再交叉阅读。这是参与约定,不是权限隔离。")
|
|
1139
|
-
if room["archived"]:
|
|
1140
|
-
content.append("频道已存档,记录仍可查阅;先恢复频道,再由用户明确恢复讨论阶段。")
|
|
1141
|
-
if room["phase"] in ("paused", "concluded"):
|
|
1142
|
-
content.append("停止处理发言安排,取消本讨论自己的后台任务并核对实际状态;非必要不生成回执,正常停止无需回执。不要因旧通知恢复讨论。")
|
|
1143
|
-
if turn:
|
|
1144
|
-
content.extend([f"当前安排 #{turn['id']}:{turn['expert']} · {turn_status_label(turn, room)}", f"待回应问题:{turn['question']}", f"安排详情:{base}/turns/{turn['id']}"])
|
|
1145
|
-
if room["queued_turns"]:
|
|
1146
|
-
content.append("本轮排队:" + " → ".join(f"#{item['id']} {item['expert']}" for item in room["queued_turns"]))
|
|
1147
|
-
if room["held_since"]:
|
|
1148
|
-
content.append(f"用户自 #{room['held_since']} 起有待主持人处理的操作;当前回答完成后交给主持人,不自动接力。")
|
|
1149
|
-
content.extend(["## 按需展开", "请自主选择需要的材料,不必读取全部历史。",
|
|
1150
|
-
f"背景、边界与参与专家:{base}/brief",
|
|
1151
|
-
f"共识与分歧(整理至 #{room['state_until']}):{base}/state"])
|
|
1152
|
-
if params.get("participant"):
|
|
1153
|
-
content.append(f"与 @{params['participant']} 相关的观点:{base}/search?mention={params['participant']}&page=1")
|
|
1154
|
-
content.extend(["", "## 读取与检索", f"完整历史(分页):{base}/messages?page=1",
|
|
1155
|
-
f"单条原文:{base}/messages/消息编号", f"检索:{base}/search?q=关键词&page=1",
|
|
1156
|
-
f"按作者检索:{base}/search?author=Claude", f"发言安排历史:{base}/turns?page=1"])
|
|
1157
|
-
if params.get("mode") == "cli":
|
|
1158
|
-
content.extend(["", "## 本地 CLI AI · 可读可写",
|
|
1159
|
-
f"POST /api/rooms/{room_id}/messages", "Content-Type: application/json",
|
|
1160
|
-
'JSON 字段:author、text、submission_id(新 UUID)、source(填写 "cli");可选 reply_to、turn_id。',
|
|
1161
|
-
f"完整写入与主持协议:{base}/protocol",
|
|
1162
|
-
"重试沿用编号和正文;收到回执后按 message.id 重新读取核对。"])
|
|
1163
|
-
else:
|
|
1164
|
-
content.extend(["", "## 在线 AI · 只读参与",
|
|
1165
|
-
"请读取议题、状态和相关原文,在你自己的聊天窗口输出观点:先给通常 1–3 句的回答摘要,再给完整正文,保留关键条件、不确定性及引用编号;需要留档时由用户代发。",
|
|
1166
|
-
"不要操作网页表单、调用写入接口或生成发布链接,不要求获取发布回执。读取失败时如实报告,由用户提供复制的材料。"])
|
|
1167
|
-
content.append("参与方式按运行环境区分;主持人可以兼任专家;作者、职责与来源尚未认证。不调用模型服务 API。")
|
|
1168
|
-
return "\n\n".join(content)
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
def brief_text(room):
|
|
1172
|
-
"""Render the detailed brief only when explicitly requested."""
|
|
1173
|
-
experts = "\n".join(f"- {name}:{'本地 CLI 读写' if mode == 'cli' else '在线只读'}" for name, mode in room["experts"].items())
|
|
1174
|
-
return f"# {room['title']} · 话题资料\n\n目标:{room['goal']}\n\n用户原文:\n{room['user_request'] or '未填写'}\n\n背景:\n{room['background'] or '未填写'}\n\n边界:\n{room['boundaries'] or '未填写'}\n\n主持人:{room['host']}(可兼任专家)\n\n参与专家:\n{experts}"
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
def turn_text(turn, room):
|
|
1178
|
-
"""Render a speaking assignment without embedding discussion context."""
|
|
1179
|
-
room_id = room["id"]
|
|
1180
|
-
answer = f"\n回答原文:/r/{room_id}/messages/{turn['answer_id']}" if turn["answer_id"] else ""
|
|
1181
|
-
return f"安排 #{turn['id']} · {turn['expert']} · {turn_status_label(turn, room)}\n待回应问题:{turn['question']}\n话题入口:/r/{room_id}?participant={turn['expert']}\n回答关联 turn_id:{turn['id']}{answer}\n回答附简短摘要及完整正文,保留关键条件和不确定性。\n已接入的本地 CLI 专家可自行检查安排;在线专家需联系。复制通知不代表已送达;仅实际通知后由主持人确认。"
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
def protocol_text(room):
|
|
1185
|
-
"""Describe explicit CLI and host operations separately from topic content."""
|
|
1186
|
-
base = f"/api/rooms/{room['id']}"
|
|
1187
|
-
if room["mode"] == "free":
|
|
1188
|
-
return "\n\n".join([
|
|
1189
|
-
"# 自由协作参与协议", f"当前主持人:{room['host']}。没有讨论发言权、正式安排或轮次。",
|
|
1190
|
-
"先用 room view 读取成员、职责和 free.requests,再按需读取 message list / message view 原文;通知只给对象、问题和入口。",
|
|
1191
|
-
"用户写入和成员管理须房主控制凭据;本地成员写入须本人的 --access-file,不能借用用户或其他成员身份。凭据文件私下交付,不公开 token。",
|
|
1192
|
-
"runner 成员等待本机接入程序交付有效任务,不另建定时。用 message post --as 本人 --kind chat --execution 本次编号 --access-file FILE 回答;不执行 execution enter。执行失效后保留草稿,不能去掉编号改作普通发言。",
|
|
1193
|
-
"manual 成员可由人工或软件原生定时触发,用本人成员文件自行提交。回答带 --request 请求编号(可重复)关闭自己的请求,不带则为普通发言。不得编造 execution 编号。",
|
|
1194
|
-
"online 成员只读,在原聊天回答,由用户以房主凭据代发原文;作者仍是在线成员、提交者为用户,不能用成员凭证自行写入。复制说明不表示已送达或接入。",
|
|
1195
|
-
"获授 host 职责的本地成员可在回答中用 --to 点名其他成员,不能点名自己或扩大权限;route 表示分配任务,handoff 表示派出的成员已结束。链上 AI 请求受房间预算限制,到限等待用户。",
|
|
1196
|
-
"普通成员的 --to 或正文 @ 只保存为回应建议,不唤醒;代码与引用中的正文 @ 不算。房主在网页转为请求后建立关联原消息的新用户任务链,重复点击不重复派发。",
|
|
1197
|
-
f"附件使用 attachment upload/download;上传使用本人成员文件,消息带 attachment_ids。原始接口为 {base}/attachments 与 {base}/messages,自动成员在回答完成前上传。",
|
|
1198
|
-
"每次新内容用新 submission_id,同次重试沿用编号、原文、凭据和执行/请求关联;失败先核对服务端结果,不换身份或编号绕过。用户打断、成员离开或移除后停止本次工作。",
|
|
1199
|
-
])
|
|
1200
|
-
return "\n\n".join([
|
|
1201
|
-
"# 本地 CLI 读写与主持协议", f"当前主持人:{room['host']}。以下为留言板接口,不是模型服务 API。",
|
|
1202
|
-
"
|
|
1203
|
-
|
|
1204
|
-
"
|
|
1205
|
-
"
|
|
1206
|
-
|
|
1207
|
-
"
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
f"
|
|
1211
|
-
f"
|
|
1212
|
-
f"
|
|
1213
|
-
f"
|
|
1214
|
-
f"
|
|
1215
|
-
f"
|
|
1216
|
-
f"
|
|
1217
|
-
f"
|
|
1218
|
-
"
|
|
1219
|
-
|
|
1220
|
-
"
|
|
1221
|
-
"
|
|
1222
|
-
"
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
self.
|
|
1236
|
-
self.
|
|
1237
|
-
self.
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
self.
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
self.
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
self.
|
|
1284
|
-
self.send_header("Content-
|
|
1285
|
-
self.send_header("
|
|
1286
|
-
self.send_header("
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
self.send_header("
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
self.send_header("Access-Control-Allow-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
self.
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
self.
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
self.
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
self.
|
|
1364
|
-
self.send_header("
|
|
1365
|
-
self.send_header("
|
|
1366
|
-
self.
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
self.wfile.
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
self.wfile.
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
if
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
if
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
if
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
self.
|
|
1636
|
-
self.send_header("
|
|
1637
|
-
self.send_header("
|
|
1638
|
-
self.send_header("Content-
|
|
1639
|
-
self.send_header("
|
|
1640
|
-
self.send_header("
|
|
1641
|
-
self.
|
|
1642
|
-
self.
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
result =
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
result = (
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
result =
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
result =
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
if len(parts)
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
body =
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
parser
|
|
1744
|
-
parser.add_argument("--
|
|
1745
|
-
parser.add_argument("--
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
logging.
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1
|
+
"""AI CoDiscuss 的零依赖 HTTP / SQLite 概念验证服务。"""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import socket
|
|
11
|
+
import sqlite3
|
|
12
|
+
import subprocess
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from contextlib import closing
|
|
17
|
+
from datetime import datetime, timedelta, timezone
|
|
18
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from urllib.parse import parse_qs, quote, urlsplit
|
|
21
|
+
|
|
22
|
+
from .free_room import (add_member, after_free_message, authorize_free_write, check_answer, check_proxy, execution_action, free_view,
|
|
23
|
+
inbox, interrupt_member, member_action, member_values, migrate_free_tables, proxy_member, reserve_execution,
|
|
24
|
+
discussion_members, room_mode, save_room_access, sync_discussion_members, view_execution)
|
|
25
|
+
from .free_room import convert_suggestion, message_suggestions, save_suggestions
|
|
26
|
+
from .identity import MAX_ROOM_AGENTS, PARTICIPANTS, is_ai, is_participant
|
|
27
|
+
from .runtime import (authorize_write, begin_write, configure_runtime, event_action,
|
|
28
|
+
LIMIT_DEFAULTS, LIMITS_SQL, limit_values, limits_view, require_within_limits,
|
|
29
|
+
list_events, require_control, runtime_view, token_hash)
|
|
30
|
+
|
|
31
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
32
|
+
WEB_ROOT = ROOT / "web"
|
|
33
|
+
DEFAULT_PORT = 8765
|
|
34
|
+
UPDATE_HEARTBEAT_SECONDS = 15
|
|
35
|
+
MAX_BODY_BYTES = 512 * 1024
|
|
36
|
+
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
|
37
|
+
MAX_MESSAGE_ATTACHMENTS = 10
|
|
38
|
+
MAX_FILENAME_LENGTH = 255
|
|
39
|
+
LOCAL_LAUNCH_TIMEOUT = 60
|
|
40
|
+
# Ordering and filing a channel only rearrange the shared directory, like folder edits, so they need no room credential.
|
|
41
|
+
DIRECTORY_OPERATIONS = ("move", "folder")
|
|
42
|
+
DIRECTORY_PICKER_TIMEOUT = 600 # the folder dialog waits for the person at the computer
|
|
43
|
+
MAX_TEXT_LENGTH = 32000
|
|
44
|
+
MAX_STATE_LENGTH = 4000
|
|
45
|
+
MAX_BRIEF_LENGTH = 1600
|
|
46
|
+
MAX_SUMMARY_LENGTH = 1000
|
|
47
|
+
PAGE_SIZE = 20
|
|
48
|
+
UNCATEGORIZED_FOLDER_ID = "__uncategorized__"
|
|
49
|
+
ASSETS = {"/": ("index.html", "text/html"),
|
|
50
|
+
"/app.js": ("app.js", "text/javascript"),
|
|
51
|
+
"/modern.js": ("modern.js", "text/javascript"),
|
|
52
|
+
"/modern.css": ("modern.css", "text/css"),
|
|
53
|
+
"/markdown.js": ("markdown.js", "text/javascript"),
|
|
54
|
+
"/backup.js": ("backup.js", "text/javascript"),
|
|
55
|
+
"/markdown.css": ("markdown.css", "text/css"),
|
|
56
|
+
"/icons/LICENSE.txt": ("icons/LICENSE.txt", "text/plain")}
|
|
57
|
+
ASSETS.update({f"/icons/{name}.svg": (f"icons/{name}.svg", "image/svg+xml")
|
|
58
|
+
for name in ("openai", "claude", "gemini", "deepseek", "qwen", "user")})
|
|
59
|
+
ASSETS.update({"/pdfjs/" + file.relative_to(WEB_ROOT / "pdfjs").as_posix():
|
|
60
|
+
(file.relative_to(WEB_ROOT).as_posix(),
|
|
61
|
+
{".mjs": "text/javascript", ".js": "text/javascript", ".wasm": "application/wasm"}.get(file.suffix, "application/octet-stream"))
|
|
62
|
+
for file in (WEB_ROOT / "pdfjs").rglob("*") if file.is_file()})
|
|
63
|
+
SCHEMA = (Path(__file__).resolve().parent / "schema.sql").read_text(encoding="utf-8")
|
|
64
|
+
PHASES = {"preparing": "准备中", "initial": "独立观点", "discussing": "深入讨论",
|
|
65
|
+
"reviewing": "结论复核", "paused": "已暂停", "concluded": "已收束"}
|
|
66
|
+
TURN_STATUSES = {"pending": "待处理", "notified": "已通知,待回答", "answered": "已回答", "cancelled": "已取消",
|
|
67
|
+
"queued": "排队中", "skipped": "未开始即取消"}
|
|
68
|
+
MAX_TOPIC_DETAIL = 4000
|
|
69
|
+
MAX_QUESTION_LENGTH = 1000
|
|
70
|
+
MAX_PLAN_TURNS = 10
|
|
71
|
+
ROOM_BACKUP_QUERIES = {
|
|
72
|
+
"rooms": "SELECT * FROM rooms WHERE id=?",
|
|
73
|
+
**{table: f"SELECT * FROM {table} WHERE room_id=?" for table in (
|
|
74
|
+
"room_directory", "room_folder_assignments", "room_creations", "room_briefs", "room_user_requests",
|
|
75
|
+
"topic_settings", "room_floor", "discussion_limits")},
|
|
76
|
+
"topic_folders": "SELECT * FROM topic_folders WHERE id IN (SELECT folder_id FROM room_folder_assignments WHERE room_id=?)",
|
|
77
|
+
"messages": "SELECT * FROM messages WHERE room_id=? ORDER BY id",
|
|
78
|
+
"attachments": "SELECT * FROM attachments WHERE room_id=? AND message_id IS NOT NULL ORDER BY message_id,position",
|
|
79
|
+
"discussion_turns": "SELECT * FROM discussion_turns WHERE room_id=? ORDER BY id",
|
|
80
|
+
**{table: f"SELECT * FROM {table} WHERE message_id IN (SELECT id FROM messages WHERE room_id=?) ORDER BY message_id"
|
|
81
|
+
for table in ("message_details", "message_summaries", "message_timings")},
|
|
82
|
+
}
|
|
83
|
+
ROOM_BACKUP_QUERIES["room_creations"] = "SELECT submission_id,room_id FROM room_creations WHERE room_id=?"
|
|
84
|
+
# Shared membership plus free-room requests; credential hashes are never exported.
|
|
85
|
+
FREE_BACKUP_QUERIES = {
|
|
86
|
+
"room_modes": "SELECT room_id,mode FROM room_modes WHERE room_id=?",
|
|
87
|
+
"room_members": "SELECT id,room_id,name,adapter,role,status,read_until,created_at FROM room_members WHERE room_id=? ORDER BY id",
|
|
88
|
+
"member_requests": "SELECT * FROM member_requests WHERE room_id=? ORDER BY id",
|
|
89
|
+
"member_suggestions": "SELECT s.* FROM member_suggestions s JOIN messages m ON m.id=s.message_id WHERE m.room_id=? ORDER BY s.message_id,s.member_id",
|
|
90
|
+
}
|
|
91
|
+
MESSAGE_QUERY = """SELECT messages.*, COALESCE(details.kind,'opinion') AS kind,
|
|
92
|
+
COALESCE(details.submitted_by, CASE WHEN messages.source IN ('cli','api','url','user')
|
|
93
|
+
THEN messages.author ELSE '用户' END) AS submitted_by, details.turn_id,
|
|
94
|
+
COALESCE(summaries.text,'') AS summary, COALESCE(summaries.author,'') AS summary_by, timing.elapsed_seconds
|
|
95
|
+
FROM messages LEFT JOIN message_details AS details ON details.message_id=messages.id
|
|
96
|
+
LEFT JOIN message_summaries AS summaries ON summaries.message_id=messages.id
|
|
97
|
+
LEFT JOIN message_timings AS timing ON timing.message_id=messages.id"""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def room_backup(connection, room_id):
|
|
101
|
+
"""Read all records belonging to one room in a single database snapshot.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
connection: A fresh SQLite connection, with no active transaction.
|
|
105
|
+
room_id: The exact room to export, including archived rooms.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
A versioned backup containing original rows and their relationships.
|
|
109
|
+
"""
|
|
110
|
+
connection.execute("BEGIN")
|
|
111
|
+
queries = {**ROOM_BACKUP_QUERIES, **FREE_BACKUP_QUERIES}
|
|
112
|
+
tables = {table: [dict(row) for row in connection.execute(query, (room_id,))]
|
|
113
|
+
for table, query in queries.items()}
|
|
114
|
+
if not tables["rooms"]:
|
|
115
|
+
raise LookupError("房间不存在")
|
|
116
|
+
return {"format": "ai-codiscuss-room-backup", "version": 1,
|
|
117
|
+
"exported_at": utc_now(), "tables": tables}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def utc_now():
|
|
121
|
+
"""Return the current UTC timestamp in ISO format."""
|
|
122
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def connect_db(database):
|
|
126
|
+
"""Open a database connection with named rows and foreign keys enabled."""
|
|
127
|
+
connection = sqlite3.connect(database, timeout=10)
|
|
128
|
+
connection.row_factory = sqlite3.Row
|
|
129
|
+
connection.execute("PRAGMA foreign_keys = ON")
|
|
130
|
+
return connection
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def seed_demo(connection):
|
|
134
|
+
"""Insert clearly labeled sample content into a newly initialized database."""
|
|
135
|
+
connection.execute("INSERT INTO rooms (id,title,goal,created_at) VALUES (?,?,?,?)",
|
|
136
|
+
("welcome", "AI 留言板 · 设计讨论", "让不同 AI 共享讨论信息,控制每轮上下文,并保留可检索的完整历史。", utc_now()))
|
|
137
|
+
samples = [
|
|
138
|
+
("用户", "我们先验证一个简单场景:不同 AI 能否通过同一个房间阅读、检索和留言?\n首轮只讨论信息如何流转,不接入真实模型。", None),
|
|
139
|
+
("Codex", "建议把完整历史保留下来,每轮先给目标、当前状态和需要回应的消息。\n@Claude 如果摘要遗漏了重要条件,参与者如何核对?", 1),
|
|
140
|
+
("Claude", "每条结论都应引用原始消息编号。AI 可以主动读取原文,也能按关键词检索。\n摘要由主持人维护,未解决的分歧不能写成共识。", 2),
|
|
141
|
+
("Gemini", "在线 AI 只读取讨论记录,在自己的聊天窗口给出观点,由用户代发。\n@DeepSeek 请检查阅读入口和原文引用是否清楚。", 1),
|
|
142
|
+
("DeepSeek", "如果只能阅读,回复可以由用户代发。需要保留原作者、引用关系和代发方式,便于其他参与者继续讨论。", 4),
|
|
143
|
+
("Qwen", "无联网时,可以由用户传入本轮材料。我仍可提出检索请求,例如:请提供 #2 的完整原文。", 2),
|
|
144
|
+
]
|
|
145
|
+
base_time = datetime.now(timezone.utc) - timedelta(minutes=len(samples) * 3)
|
|
146
|
+
connection.execute("INSERT INTO room_floor (room_id,holder,next_speaker) VALUES ('welcome','用户','用户')")
|
|
147
|
+
for index, (author, text, reply_to) in enumerate(samples):
|
|
148
|
+
connection.execute("UPDATE room_floor SET holder=? WHERE room_id='welcome'", (author,))
|
|
149
|
+
created_at = (base_time + timedelta(minutes=index * 3)).isoformat(timespec="seconds")
|
|
150
|
+
connection.execute("INSERT INTO messages (room_id,author,text,reply_to,source,submission_id,created_at) VALUES (?,?,?,?,?,?,?)",
|
|
151
|
+
("welcome", author, text, reply_to, "demo", f"demo-{index}", created_at))
|
|
152
|
+
summary = "已达成共识\n• 完整历史只追加,支持按编号查阅和关键词检索。〔#2、#3〕\n• 本地 CLI AI 读写记录;在线 AI 只读,观点由用户代发。〔#4、#5〕\n\n待验证\n• 在线 AI 是否能直接读取和检索原文。〔#4〕\n• 复制材料与代发流程是否清楚。〔#5、#6〕"
|
|
153
|
+
connection.execute("UPDATE rooms SET state_text=?,state_until=6 WHERE id='welcome'", (summary,))
|
|
154
|
+
connection.execute("UPDATE room_floor SET holder='用户' WHERE room_id='welcome'")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def init_database(database, seed=True):
|
|
158
|
+
"""Create durable storage; seed only a brand-new database when requested."""
|
|
159
|
+
database = Path(database)
|
|
160
|
+
is_new = not database.exists()
|
|
161
|
+
database.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
with closing(connect_db(database)) as connection, connection:
|
|
163
|
+
connection.execute("PRAGMA journal_mode = WAL")
|
|
164
|
+
connection.executescript(SCHEMA)
|
|
165
|
+
migrate_free_tables(connection)
|
|
166
|
+
if seed and is_new:
|
|
167
|
+
seed_demo(connection)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def require_room(connection, room_id):
|
|
171
|
+
"""Return a room and its current message count, or raise a lookup error."""
|
|
172
|
+
row = connection.execute("SELECT rooms.*, COUNT(messages.id) AS message_count, COALESCE(MAX(messages.id),0) AS latest_id FROM rooms LEFT JOIN messages ON rooms.id=messages.room_id WHERE rooms.id=? GROUP BY rooms.id", (room_id,)).fetchone()
|
|
173
|
+
if row is None:
|
|
174
|
+
raise LookupError("房间不存在")
|
|
175
|
+
room = dict(row)
|
|
176
|
+
room["archived"] = bool(connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0])
|
|
177
|
+
room["folder_id"] = connection.execute("SELECT folder_id FROM room_folder_assignments WHERE room_id=?", (room_id,)).fetchone()[0]
|
|
178
|
+
settings = connection.execute("SELECT * FROM topic_settings WHERE room_id=?", (room_id,)).fetchone()
|
|
179
|
+
room.update(dict(settings) if settings else topic_defaults())
|
|
180
|
+
original = connection.execute("SELECT text FROM room_user_requests WHERE room_id=?", (room_id,)).fetchone()
|
|
181
|
+
room.update(topic_context(room["background"], original["text"] if original else None))
|
|
182
|
+
if settings:
|
|
183
|
+
room["experts"] = json.loads(room["experts"])
|
|
184
|
+
turn = connection.execute("""SELECT *, (SELECT COUNT(*) FROM discussion_turns WHERE room_id=? AND status='answered') AS answered_turns
|
|
185
|
+
FROM discussion_turns WHERE room_id=? AND status NOT IN ('queued','skipped')
|
|
186
|
+
ORDER BY status IN ('pending','notified') DESC, id DESC LIMIT 1""", (room_id, room_id)).fetchone()
|
|
187
|
+
room["current_turn"] = dict(turn) if turn else None
|
|
188
|
+
room["answered_turns"] = room["current_turn"].pop("answered_turns") if turn else 0
|
|
189
|
+
if turn:
|
|
190
|
+
room["current_turn"]["status_label"] = turn_status_label(turn, room)
|
|
191
|
+
room["queued_turns"] = [dict(row) for row in connection.execute(
|
|
192
|
+
"SELECT id,expert,question FROM discussion_turns WHERE room_id=? AND status='queued' ORDER BY id", (room_id,))]
|
|
193
|
+
held = connection.execute("SELECT held_since FROM turn_relay WHERE room_id=?", (room_id,)).fetchone()
|
|
194
|
+
room["held_since"] = held[0] if held else 0
|
|
195
|
+
room["latest_receipts"] = latest_receipts(connection, room_id)
|
|
196
|
+
brief = connection.execute("SELECT text,until,updated_at FROM room_briefs WHERE room_id=?", (room_id,)).fetchone()
|
|
197
|
+
room["host_brief"] = dict(brief) if brief else None
|
|
198
|
+
room["rounds"] = discussion_rounds(connection, room_id, room["experts"])
|
|
199
|
+
room["completed_rounds"] = sum(item["completed"] for item in room["rounds"])
|
|
200
|
+
room["floor"] = dict(connection.execute("SELECT holder,next_speaker,revision FROM room_floor WHERE room_id=?", (room_id,)).fetchone())
|
|
201
|
+
room["runtime"] = runtime_view(connection, room_id)
|
|
202
|
+
room["limits"] = limits_view(connection.execute(LIMITS_SQL, (room_id,)).fetchone(), room)
|
|
203
|
+
room["mode"] = room_mode(connection, room_id)
|
|
204
|
+
room["members"] = [dict(row) for row in connection.execute(
|
|
205
|
+
"SELECT id,name,adapter,role,status,access,session_mode FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,))]
|
|
206
|
+
parent = connection.execute("""SELECT rooms.id,rooms.title FROM room_links JOIN rooms ON rooms.id=room_links.parent_id
|
|
207
|
+
WHERE room_links.room_id=?""", (room_id,)).fetchone()
|
|
208
|
+
room["parent_room"] = dict(parent) if parent else None
|
|
209
|
+
if room["mode"] == "free":
|
|
210
|
+
room["linked_rooms"] = [dict(row) for row in connection.execute("""SELECT rooms.id,rooms.title,COALESCE(topic_settings.phase,'preparing') AS phase
|
|
211
|
+
FROM room_links JOIN rooms ON rooms.id=room_links.room_id LEFT JOIN topic_settings ON topic_settings.room_id=rooms.id
|
|
212
|
+
WHERE room_links.parent_id=? ORDER BY room_links.created_at""", (room_id,))]
|
|
213
|
+
# Free rooms have no host hand-off: user messages go straight to the named members.
|
|
214
|
+
room["held_since"] = 0
|
|
215
|
+
room["free"] = free_view(connection, room_id)
|
|
216
|
+
return room
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def update_floor(connection, room_id, payload):
|
|
220
|
+
"""Claim or return the nominated speaker's floor using a checked revision."""
|
|
221
|
+
begin_write(connection)
|
|
222
|
+
room = require_room(connection, room_id)
|
|
223
|
+
actor = payload.get("actor")
|
|
224
|
+
speaker = payload.get("speaker", actor)
|
|
225
|
+
action = payload.get("action")
|
|
226
|
+
revision = payload.get("revision")
|
|
227
|
+
proxy = actor == room["host"] and room["experts"].get(speaker) == "online"
|
|
228
|
+
if not is_participant(speaker) or (actor not in (speaker, "用户") and not proxy):
|
|
229
|
+
raise ValueError("请以本人、主持人或用户身份操作被点名者的发言权")
|
|
230
|
+
if action not in ("take", "return") or type(revision) is not int or revision < 0:
|
|
231
|
+
raise ValueError("请提供 take/return 和最新的发言权 revision")
|
|
232
|
+
floor = room["floor"]
|
|
233
|
+
if revision != floor["revision"]:
|
|
234
|
+
raise FileExistsError("发言权已变化,请重新读取")
|
|
235
|
+
if action == "take":
|
|
236
|
+
if floor["holder"] is not None or floor["next_speaker"] != speaker:
|
|
237
|
+
raise FileExistsError("仅被点名的下一位可在无人持有时领取发言权")
|
|
238
|
+
connection.execute("UPDATE room_floor SET holder=?,revision=revision+1 WHERE room_id=?", (speaker, room_id))
|
|
239
|
+
else:
|
|
240
|
+
if floor["holder"] != speaker:
|
|
241
|
+
raise FileExistsError("只能归还自己持有的发言权")
|
|
242
|
+
connection.execute("UPDATE room_floor SET holder=NULL,next_speaker=?,revision=revision+1 WHERE room_id=?", (room["host"], room_id))
|
|
243
|
+
return require_room(connection, room_id)["floor"]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def require_floor(room, speaker):
|
|
247
|
+
"""Reject a declared speaker who does not currently hold the floor."""
|
|
248
|
+
if room["floor"]["holder"] != speaker:
|
|
249
|
+
raise FileExistsError("请先领取本人发言权;他人持有或尚未被点名时不能发言或安排")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def discussion_rounds(connection, room_id, experts):
|
|
253
|
+
"""Group complete history metadata until every configured expert has spoken.
|
|
254
|
+
|
|
255
|
+
Repeated opinions count once per round; other roles retain their position
|
|
256
|
+
without advancing the round. No message bodies are loaded or rewritten.
|
|
257
|
+
"""
|
|
258
|
+
if not experts:
|
|
259
|
+
return []
|
|
260
|
+
rows = connection.execute("""SELECT messages.id,messages.author,
|
|
261
|
+
COALESCE(details.kind,'opinion') AS kind FROM messages
|
|
262
|
+
LEFT JOIN message_details AS details ON details.message_id=messages.id
|
|
263
|
+
WHERE room_id=? ORDER BY messages.id""", (room_id,))
|
|
264
|
+
rounds = []
|
|
265
|
+
next_start = None
|
|
266
|
+
for message in rows:
|
|
267
|
+
if next_start is None:
|
|
268
|
+
next_start = message["id"]
|
|
269
|
+
if message["kind"] != "opinion" or message["author"] not in experts:
|
|
270
|
+
continue
|
|
271
|
+
if not rounds or rounds[-1]["completed"]:
|
|
272
|
+
rounds.append({"number": len(rounds) + 1, "start_id": next_start,
|
|
273
|
+
"speakers": [], "completed": False})
|
|
274
|
+
current = rounds[-1]
|
|
275
|
+
if message["author"] not in current["speakers"]:
|
|
276
|
+
current["speakers"].append(message["author"])
|
|
277
|
+
current["completed"] = len(current["speakers"]) == len(experts)
|
|
278
|
+
if current["completed"]:
|
|
279
|
+
next_start = None
|
|
280
|
+
return rounds
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def latest_receipts(connection, room_id):
|
|
284
|
+
"""Return each author's latest receipt reference without loading its body."""
|
|
285
|
+
rows = connection.execute("""SELECT messages.id,messages.author,messages.created_at,details.submitted_by
|
|
286
|
+
FROM messages JOIN message_details AS details ON details.message_id=messages.id
|
|
287
|
+
WHERE messages.id IN (SELECT MAX(messages.id) FROM messages
|
|
288
|
+
JOIN message_details AS details ON details.message_id=messages.id
|
|
289
|
+
WHERE room_id=? AND details.kind='receipt' GROUP BY author)
|
|
290
|
+
ORDER BY messages.id""", (room_id,))
|
|
291
|
+
return [dict(row) for row in rows]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def turn_status_label(turn, room):
|
|
295
|
+
"""Describe pending work using the expert's configured participation mode."""
|
|
296
|
+
if turn["status"] == "pending":
|
|
297
|
+
mode = room["experts"].get(turn["expert"])
|
|
298
|
+
return {"cli": "待处理(本地 CLI)", "online": "待联系(在线专家)"}.get(mode, "待处理")
|
|
299
|
+
return TURN_STATUSES[turn["status"]]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def receipt_references_text(room):
|
|
303
|
+
"""Link to current receipt reports independently of an older host summary."""
|
|
304
|
+
lines = ["最新运行回执(每位参与者最近一条,运行结果以原文为准):"]
|
|
305
|
+
for receipt in room["latest_receipts"]:
|
|
306
|
+
suffix = " · 晚于摘要" if receipt["id"] > room["state_until"] else ""
|
|
307
|
+
lines.append(f"{receipt['author']} · {receipt['created_at']}{suffix}:/r/{room['id']}/messages/{receipt['id']}")
|
|
308
|
+
return "\n".join(lines) if room["latest_receipts"] else "尚无运行回执。"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def topic_context(background, user_request=None):
|
|
312
|
+
"""Separate explicitly marked legacy requests without guessing unmarked text."""
|
|
313
|
+
if user_request is None:
|
|
314
|
+
match = re.match(r"^用户原文[::][ \t]*(?:\r?\n)?([\s\S]*?)(?:\r?\n){2,}((?:项目背景与入口|背景)[::][\s\S]*)$", background)
|
|
315
|
+
if match:
|
|
316
|
+
user_request, background = match.group(1), match.group(2)
|
|
317
|
+
return {"user_request": user_request or "", "background": background}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def topic_defaults():
|
|
321
|
+
"""Return independent participation defaults for existing rooms."""
|
|
322
|
+
return {"host": "用户", "background": "", "boundaries": "", "phase": "preparing", "focus": "",
|
|
323
|
+
"experts": {name: "cli" if name == "Codex" else "online" for name in PARTICIPANTS[1:] if name != "Grok"}}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def topic_values(payload, current=None):
|
|
327
|
+
"""Validate topic fields and require an AI host to have CLI capability."""
|
|
328
|
+
result = {**topic_defaults(), **(current or {})}
|
|
329
|
+
limits = {"title": 80, "goal": 1000, "user_request": MAX_TOPIC_DETAIL, "background": MAX_TOPIC_DETAIL,
|
|
330
|
+
"boundaries": MAX_TOPIC_DETAIL, "focus": MAX_QUESTION_LENGTH}
|
|
331
|
+
for field, limit in limits.items():
|
|
332
|
+
value = str(payload.get(field, result.get(field, "")))
|
|
333
|
+
result[field] = value if field == "user_request" else value.strip()
|
|
334
|
+
if len(result[field]) > limit or (field == "title" and not result[field]):
|
|
335
|
+
raise ValueError(f"{field} 内容为空或超过 {limit} 字")
|
|
336
|
+
result["host"] = payload.get("host", result["host"])
|
|
337
|
+
result["phase"] = payload.get("phase", result["phase"])
|
|
338
|
+
result["experts"] = payload.get("experts", result["experts"])
|
|
339
|
+
experts = result["experts"]
|
|
340
|
+
if (not isinstance(experts, dict) or len(experts) > MAX_ROOM_AGENTS
|
|
341
|
+
or any(not is_ai(name) or mode not in ("cli", "online") for name, mode in experts.items())):
|
|
342
|
+
raise ValueError("专家需为有效 AI 身份,参与方式为 cli 或 online")
|
|
343
|
+
if result["host"] != "用户" and experts.get(result["host"]) != "cli":
|
|
344
|
+
raise ValueError("AI 主持人必须是可读可写的本地 CLI 参与者")
|
|
345
|
+
if result["phase"] not in PHASES:
|
|
346
|
+
raise ValueError("话题阶段无效")
|
|
347
|
+
if "limits" in payload:
|
|
348
|
+
result["limits"] = limit_values(payload["limits"], (current or {}).get("limits"))
|
|
349
|
+
changed = any(result["limits"][key] != current["limits"][key] for key in LIMIT_DEFAULTS) if current else False
|
|
350
|
+
if changed and current["phase"] not in ("preparing", "paused"):
|
|
351
|
+
raise ValueError("请先暂停讨论,再修改上限参数")
|
|
352
|
+
result["save_limits"] = not current or changed
|
|
353
|
+
result["reset_closing"] = not current or any(result["limits"][key] != current["limits"][key]
|
|
354
|
+
for key in LIMIT_DEFAULTS if key != "execution_minutes")
|
|
355
|
+
return result
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def save_topic_settings(connection, room_id, settings):
|
|
359
|
+
"""Persist topic settings without modifying any original messages."""
|
|
360
|
+
if settings.get("save_limits"):
|
|
361
|
+
connection.execute("""UPDATE discussion_limits SET max_rounds=?,max_minutes=?,execution_minutes=?,on_limit=?,summary_minutes=?,
|
|
362
|
+
closing_at=CASE WHEN ? THEN 0 ELSE closing_at END,stop_reason=CASE WHEN ? THEN '' ELSE stop_reason END,
|
|
363
|
+
revision=revision+1 WHERE room_id=?""",
|
|
364
|
+
[*(settings["limits"][key] for key in LIMIT_DEFAULTS), settings["reset_closing"], settings["reset_closing"], room_id])
|
|
365
|
+
values = [settings[key] for key in ("host", "background", "boundaries")]
|
|
366
|
+
values.extend([json.dumps(settings["experts"], ensure_ascii=False), settings["phase"], settings["focus"]])
|
|
367
|
+
connection.execute("INSERT INTO topic_settings (room_id,host,background,boundaries,experts,phase,focus) VALUES (?,?,?,?,?,?,?) ON CONFLICT(room_id) DO UPDATE SET host=excluded.host,background=excluded.background,boundaries=excluded.boundaries,experts=excluded.experts,phase=excluded.phase,focus=excluded.focus", [room_id, *values])
|
|
368
|
+
connection.execute("INSERT INTO room_user_requests (room_id,text) VALUES (?,?) ON CONFLICT(room_id) DO UPDATE SET text=excluded.text",
|
|
369
|
+
(room_id, settings.get("user_request", "")))
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def require_host(room, payload):
|
|
373
|
+
"""Check the declared operator; this is a workflow rule, not authentication."""
|
|
374
|
+
if payload.get("actor") not in (room["host"], "用户"):
|
|
375
|
+
raise ValueError("请使用当前主持人或用户身份管理话题")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def update_topic(connection, room_id, payload):
|
|
379
|
+
"""Update topic direction and participants through a declared host action."""
|
|
380
|
+
begin_write(connection)
|
|
381
|
+
room = require_room(connection, room_id)
|
|
382
|
+
require_host(room, payload)
|
|
383
|
+
settings = topic_values(payload, room)
|
|
384
|
+
if room["archived"] and settings["phase"] not in ("paused", "concluded"):
|
|
385
|
+
raise ValueError("请先恢复已存档频道,再调整讨论阶段")
|
|
386
|
+
turn = room["current_turn"]
|
|
387
|
+
if turn and turn["status"] in ("pending", "notified"):
|
|
388
|
+
if settings["phase"] == "concluded" or turn["expert"] not in settings["experts"]:
|
|
389
|
+
raise ValueError("请先完成或取消当前发言安排")
|
|
390
|
+
connection.execute("UPDATE rooms SET title=?,goal=? WHERE id=?", (settings["title"], settings["goal"], room_id))
|
|
391
|
+
sync_discussion_members(connection, room_id, payload, settings)
|
|
392
|
+
save_topic_settings(connection, room_id, settings)
|
|
393
|
+
return require_room(connection, room_id)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def submission_value(value):
|
|
397
|
+
"""Validate a replay identifier for writes or exact result lookup."""
|
|
398
|
+
identifier = str(value or "").strip()
|
|
399
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]{8,100}", identifier):
|
|
400
|
+
raise ValueError("submission_id 必须是 8–100 位字母、数字、下划线或连字符")
|
|
401
|
+
return identifier
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def list_rooms(connection, params=None):
|
|
405
|
+
"""Return rooms, optionally locating a single persisted creation request."""
|
|
406
|
+
params = params or {}
|
|
407
|
+
identifier = submission_value(params["submission_id"]) if "submission_id" in params else None
|
|
408
|
+
where = "WHERE EXISTS (SELECT 1 FROM room_creations WHERE room_id=rooms.id AND submission_id=?)" if identifier else ""
|
|
409
|
+
rows = connection.execute(f"SELECT rooms.*,COUNT(messages.id) AS message_count,COALESCE(MAX(messages.id),0) AS latest_id FROM rooms LEFT JOIN messages ON rooms.id=messages.room_id {where} GROUP BY rooms.id ORDER BY rooms.created_at, rooms.id", (identifier,) if identifier else ())
|
|
410
|
+
directory = {row["room_id"]: dict(row) for row in connection.execute("SELECT * FROM room_directory")}
|
|
411
|
+
assignments = {row["room_id"]: row["folder_id"] for row in connection.execute("SELECT room_id,folder_id FROM room_folder_assignments")}
|
|
412
|
+
rooms = [{**dict(row), "archived": bool(directory[row["id"]]["archived"]), "folder_id": assignments[row["id"]]} for row in rows]
|
|
413
|
+
return {"rooms": sorted(rooms, key=lambda room: directory[room["id"]]["position"]),
|
|
414
|
+
**folder_directory(connection)}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def folder_directory(connection):
|
|
418
|
+
"""Return real folders and the virtual group's index (null keeps it last)."""
|
|
419
|
+
settings = connection.execute("SELECT uncategorized_position FROM directory_settings WHERE id=1").fetchone()
|
|
420
|
+
return {"folders": [dict(row) for row in connection.execute("SELECT id,name,position FROM topic_folders ORDER BY position,name,id")],
|
|
421
|
+
"uncategorized_position": settings[0] if settings else None}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def folder_name_value(payload):
|
|
425
|
+
"""Validate a shared folder name for creation and renaming."""
|
|
426
|
+
raw_name = payload.get("name")
|
|
427
|
+
if not isinstance(raw_name, str):
|
|
428
|
+
raise ValueError("文件夹名称必须是文本")
|
|
429
|
+
name = raw_name.strip()
|
|
430
|
+
if not name or len(name) > 40:
|
|
431
|
+
raise ValueError("文件夹名称须为 1–40 个字符")
|
|
432
|
+
return name
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def rename_folder(connection, folder_id, payload):
|
|
436
|
+
"""Rename one folder, retaining its position and all room assignments."""
|
|
437
|
+
begin_write(connection)
|
|
438
|
+
name = folder_name_value(payload)
|
|
439
|
+
if connection.execute("UPDATE topic_folders SET name=? WHERE id=?", (name, folder_id)).rowcount == 0:
|
|
440
|
+
raise LookupError("文件夹不存在")
|
|
441
|
+
return dict(connection.execute("SELECT id,name,position FROM topic_folders WHERE id=?", (folder_id,)).fetchone())
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def delete_folder(connection, folder_id):
|
|
445
|
+
"""Remove a group while retaining its rooms and the virtual group's position."""
|
|
446
|
+
begin_write(connection)
|
|
447
|
+
directory = folder_directory(connection)
|
|
448
|
+
identifiers = [folder["id"] for folder in directory["folders"]]
|
|
449
|
+
if folder_id not in identifiers:
|
|
450
|
+
raise LookupError("文件夹不存在")
|
|
451
|
+
position = directory["uncategorized_position"]
|
|
452
|
+
if position is not None:
|
|
453
|
+
position -= int(identifiers.index(folder_id) < position)
|
|
454
|
+
if position >= len(identifiers) - 1:
|
|
455
|
+
position = None
|
|
456
|
+
connection.execute("DELETE FROM topic_folders WHERE id=?", (folder_id,))
|
|
457
|
+
connection.execute("UPDATE directory_settings SET uncategorized_position=? WHERE id=1", (position,))
|
|
458
|
+
return {"deleted": True, "id": folder_id}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def move_folder(connection, folder_id, payload):
|
|
462
|
+
"""Persist a folder before another folder, or last when before_id is null.
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
connection: Writable SQLite connection.
|
|
466
|
+
folder_id: Existing folder or the reserved uncategorized group ID.
|
|
467
|
+
payload: Optional before_id identifying an existing destination folder.
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
The complete folder list in its saved order.
|
|
471
|
+
"""
|
|
472
|
+
begin_write(connection)
|
|
473
|
+
directory = folder_directory(connection)
|
|
474
|
+
folders = directory["folders"]
|
|
475
|
+
position = directory["uncategorized_position"]
|
|
476
|
+
folders.insert(min(position, len(folders)) if position is not None else len(folders), {"id": UNCATEGORIZED_FOLDER_ID})
|
|
477
|
+
moving = next((folder for folder in folders if folder["id"] == folder_id), None)
|
|
478
|
+
if moving is None:
|
|
479
|
+
raise LookupError("文件夹不存在")
|
|
480
|
+
remaining = [folder for folder in folders if folder["id"] != folder_id]
|
|
481
|
+
before = payload.get("before_id")
|
|
482
|
+
identifiers = [folder["id"] for folder in remaining]
|
|
483
|
+
if before is not None and before not in identifiers:
|
|
484
|
+
raise ValueError("目标文件夹不存在或与当前文件夹相同")
|
|
485
|
+
remaining.insert(identifiers.index(before) if before is not None else len(remaining), moving)
|
|
486
|
+
uncategorized_position = next(index for index, folder in enumerate(remaining) if folder["id"] == UNCATEGORIZED_FOLDER_ID)
|
|
487
|
+
ordered = [folder for folder in remaining if folder["id"] != UNCATEGORIZED_FOLDER_ID]
|
|
488
|
+
uncategorized_position = None if uncategorized_position == len(ordered) else uncategorized_position
|
|
489
|
+
for position, folder in enumerate(ordered, 1):
|
|
490
|
+
folder["position"] = position
|
|
491
|
+
connection.executemany("UPDATE topic_folders SET position=? WHERE id=?",
|
|
492
|
+
[(folder["position"], folder["id"]) for folder in ordered])
|
|
493
|
+
connection.execute("INSERT INTO directory_settings (id,uncategorized_position) VALUES (1,?) "
|
|
494
|
+
"ON CONFLICT(id) DO UPDATE SET uncategorized_position=excluded.uncategorized_position",
|
|
495
|
+
(uncategorized_position,))
|
|
496
|
+
return {"folders": ordered, "uncategorized_position": uncategorized_position}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def manage_room(connection, room_id, action, payload):
|
|
500
|
+
"""Move, archive, restore or permanently delete one declared host's room."""
|
|
501
|
+
begin_write(connection)
|
|
502
|
+
room = require_room(connection, room_id)
|
|
503
|
+
require_host(room, payload)
|
|
504
|
+
if action == "move":
|
|
505
|
+
rooms = [item["id"] for item in list_rooms(connection)["rooms"] if item["id"] != room_id]
|
|
506
|
+
before = payload.get("before_id")
|
|
507
|
+
if before is not None and before not in rooms:
|
|
508
|
+
raise ValueError("目标频道不存在或与当前频道相同")
|
|
509
|
+
rooms.insert(rooms.index(before) if before else len(rooms), room_id)
|
|
510
|
+
connection.executemany("UPDATE room_directory SET position=? WHERE room_id=?", enumerate(rooms, 1))
|
|
511
|
+
elif action == "folder":
|
|
512
|
+
folder_id = payload.get("folder_id")
|
|
513
|
+
if folder_id is not None and not isinstance(folder_id, str):
|
|
514
|
+
raise ValueError("文件夹编号格式无效")
|
|
515
|
+
if folder_id is not None and connection.execute("SELECT 1 FROM topic_folders WHERE id=?", (folder_id,)).fetchone() is None:
|
|
516
|
+
raise ValueError("文件夹不存在")
|
|
517
|
+
connection.execute("UPDATE room_folder_assignments SET folder_id=? WHERE room_id=?", (folder_id, room_id))
|
|
518
|
+
elif action in ("archive", "restore"):
|
|
519
|
+
if action == "archive" and room["phase"] != "concluded":
|
|
520
|
+
save_topic_settings(connection, room_id, {**room, "phase": "paused"})
|
|
521
|
+
connection.execute("UPDATE room_directory SET archived=? WHERE room_id=?", (int(action == "archive"), room_id))
|
|
522
|
+
elif action == "delete":
|
|
523
|
+
if payload.get("confirm_title") != room["title"]:
|
|
524
|
+
raise ValueError("请输入完整频道名称确认删除")
|
|
525
|
+
delete_room_records(connection, room_id)
|
|
526
|
+
return {"deleted": True, "id": room_id}
|
|
527
|
+
else:
|
|
528
|
+
raise LookupError("地址不存在")
|
|
529
|
+
return require_room(connection, room_id)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def delete_room_records(connection, room_id):
|
|
533
|
+
"""Remove a room's dependent records in the caller's transaction."""
|
|
534
|
+
for table in ("member_requests", "member_executions", "room_members", "room_modes", "free_batches", "free_chains", "room_links"):
|
|
535
|
+
connection.execute(f"DELETE FROM {table} WHERE room_id=?", (room_id,))
|
|
536
|
+
connection.execute("DELETE FROM message_timings WHERE message_id IN (SELECT id FROM messages WHERE room_id=?)", (room_id,))
|
|
537
|
+
connection.execute("DELETE FROM message_summaries WHERE message_id IN (SELECT id FROM messages WHERE room_id=?)", (room_id,))
|
|
538
|
+
connection.execute("DELETE FROM message_details WHERE message_id IN (SELECT id FROM messages WHERE room_id=?)", (room_id,))
|
|
539
|
+
connection.execute("DELETE FROM discussion_turns WHERE room_id=?", (room_id,))
|
|
540
|
+
connection.execute("UPDATE messages SET reply_to=NULL WHERE room_id=?", (room_id,))
|
|
541
|
+
connection.execute("DELETE FROM messages WHERE room_id=?", (room_id,))
|
|
542
|
+
for table in ("room_floor", "topic_settings", "room_creations", "room_directory", "room_folder_assignments", "room_briefs", "room_user_requests"):
|
|
543
|
+
connection.execute(f"DELETE FROM {table} WHERE room_id=?", (room_id,))
|
|
544
|
+
connection.execute("DELETE FROM rooms WHERE id=?", (room_id,))
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def creation_request(settings):
|
|
548
|
+
"""Freeze normalized creation inputs independently of later topic edits."""
|
|
549
|
+
fields = ("title", "goal", "host", "background", "boundaries", "phase", "focus")
|
|
550
|
+
values = [settings[field] for field in fields] + [sorted(settings["experts"].items())]
|
|
551
|
+
if settings.get("save_limits"):
|
|
552
|
+
values.append([settings["limits"][key] for key in LIMIT_DEFAULTS])
|
|
553
|
+
request = json.dumps(values, ensure_ascii=False, separators=(",", ":"))
|
|
554
|
+
return request + (":user_request:" + json.dumps(settings["user_request"], ensure_ascii=False)
|
|
555
|
+
if settings.get("user_request") else "")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def create_room(connection, payload, control_token=""):
|
|
559
|
+
"""Create a credential-protected room, binding retries to the original credentials."""
|
|
560
|
+
if payload.get("mode", "brainstorm") not in ("free", "brainstorm"):
|
|
561
|
+
raise ValueError("房间模式须为 free 或 brainstorm")
|
|
562
|
+
free = payload.get("mode") == "free"
|
|
563
|
+
members = member_values(payload.get("members")) if free else []
|
|
564
|
+
if free:
|
|
565
|
+
payload = {**payload, "phase": "discussing", "experts": {member["name"]: "online" if member["access"] == "online" else "cli" for member in members}}
|
|
566
|
+
settings = topic_values(payload)
|
|
567
|
+
if not free:
|
|
568
|
+
members = discussion_members(payload, settings)
|
|
569
|
+
automatic = payload.get("runtime_mode") == "automatic"
|
|
570
|
+
if payload.get("runtime_mode", "manual") not in ("manual", "automatic") or not token_hash(control_token):
|
|
571
|
+
raise ValueError("建房须提供有效的运行模式和房主控制凭据")
|
|
572
|
+
if free and (automatic or not token_hash(control_token)):
|
|
573
|
+
raise ValueError("自由房间须提供房间控制凭据,且不使用讨论运行模式")
|
|
574
|
+
parent_id = payload.get("parent_room_id")
|
|
575
|
+
if parent_id is not None and (free or not isinstance(parent_id, str) or room_mode(connection, parent_id) != "free"):
|
|
576
|
+
raise ValueError("只有从自由协作房间发起的讨论房间可以关联来源房间")
|
|
577
|
+
identifier = submission_value(payload["submission_id"]) if "submission_id" in payload else None
|
|
578
|
+
folder_id = payload.get("folder_id")
|
|
579
|
+
if folder_id is not None and not isinstance(folder_id, str):
|
|
580
|
+
raise ValueError("文件夹编号必须是文本或 null")
|
|
581
|
+
request_text = creation_request(settings) + (":automatic" if automatic else "")
|
|
582
|
+
request_text += ":access:" + token_hash(control_token) + ":" + json.dumps(
|
|
583
|
+
[[member["name"], member["adapter"], member["access"], member["session_mode"], token_hash(member["token"])] for member in members], ensure_ascii=False)
|
|
584
|
+
if free:
|
|
585
|
+
request_text += ":free"
|
|
586
|
+
if parent_id is not None:
|
|
587
|
+
request_text += ":parent:" + parent_id
|
|
588
|
+
if folder_id is not None:
|
|
589
|
+
request_text += ":folder:" + json.dumps(folder_id, ensure_ascii=False)
|
|
590
|
+
begin_write(connection)
|
|
591
|
+
if identifier:
|
|
592
|
+
previous = connection.execute("SELECT * FROM room_creations WHERE submission_id=?", (identifier,)).fetchone()
|
|
593
|
+
if previous:
|
|
594
|
+
if previous["request_text"] != request_text:
|
|
595
|
+
raise FileExistsError("该建房提交编号已被其他内容使用")
|
|
596
|
+
return {**require_room(connection, previous["room_id"]), "duplicate": True, "submission_id": identifier}
|
|
597
|
+
if folder_id is not None and connection.execute("SELECT 1 FROM topic_folders WHERE id=?", (folder_id,)).fetchone() is None:
|
|
598
|
+
raise ValueError("文件夹不存在")
|
|
599
|
+
room_id = uuid.uuid4().hex[:12]
|
|
600
|
+
connection.execute("INSERT INTO rooms (id,title,goal,created_at) VALUES (?,?,?,?)", (room_id, settings["title"], settings["goal"], utc_now()))
|
|
601
|
+
connection.execute("UPDATE room_folder_assignments SET folder_id=? WHERE room_id=?", (folder_id, room_id))
|
|
602
|
+
if identifier:
|
|
603
|
+
connection.execute("INSERT INTO room_creations (submission_id,room_id,request_text) VALUES (?,?,?)", (identifier, room_id, request_text))
|
|
604
|
+
save_topic_settings(connection, room_id, settings)
|
|
605
|
+
save_room_access(connection, room_id, control_token, members,
|
|
606
|
+
{"mode": "free" if free else "brainstorm", "host": settings["host"], "created_at": utc_now()})
|
|
607
|
+
if parent_id is not None:
|
|
608
|
+
connection.execute("INSERT INTO room_links (room_id,parent_id,created_at) VALUES (?,?,?)", (room_id, parent_id, utc_now()))
|
|
609
|
+
if automatic:
|
|
610
|
+
configure_runtime(connection, room_id, control_token, {"revision": 0, "mode": "automatic"})
|
|
611
|
+
return {**require_room(connection, room_id), "duplicate": False, "submission_id": identifier}
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def message_values(payload):
|
|
615
|
+
"""Validate a message and normalize its optional reply reference."""
|
|
616
|
+
author = str(payload.get("author", "")).strip()
|
|
617
|
+
text = str(payload.get("text", "")).strip()
|
|
618
|
+
submission_id = submission_value(payload.get("submission_id"))
|
|
619
|
+
if not is_participant(author):
|
|
620
|
+
raise ValueError("请选择有效的发言者")
|
|
621
|
+
if (not text and not attachment_ids(payload)) or len(text) > MAX_TEXT_LENGTH:
|
|
622
|
+
raise ValueError(f"发言需包含正文或附件,正文最多 {MAX_TEXT_LENGTH} 字")
|
|
623
|
+
reply_to = payload.get("reply_to")
|
|
624
|
+
return author, text, submission_id, int(reply_to) if reply_to else None
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def attachment_ids(payload):
|
|
628
|
+
"""Validate the ordered, unique attachment references of one message."""
|
|
629
|
+
identifiers = payload.get("attachment_ids", [])
|
|
630
|
+
if (not isinstance(identifiers, list) or len(identifiers) > MAX_MESSAGE_ATTACHMENTS
|
|
631
|
+
or any(not isinstance(item, str) or not re.fullmatch(r"[a-f0-9]{32}", item) for item in identifiers)
|
|
632
|
+
or len(set(identifiers)) != len(identifiers)):
|
|
633
|
+
raise ValueError(f"附件编号无效;每条消息最多 {MAX_MESSAGE_ATTACHMENTS} 个,不能重复")
|
|
634
|
+
return identifiers
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def attachment_metadata(params):
|
|
638
|
+
"""Validate upload metadata independently of the untrusted MIME header."""
|
|
639
|
+
name = params.get("file_name", "")
|
|
640
|
+
actor = params.get("actor")
|
|
641
|
+
if (not isinstance(name, str) or not name.strip() or len(name) > MAX_FILENAME_LENGTH
|
|
642
|
+
or name in (".", "..") or re.search(r"[/\\\x00-\x1f\x7f]", name)):
|
|
643
|
+
raise ValueError(f"文件名需为 1–{MAX_FILENAME_LENGTH} 字,不能包含路径或控制字符")
|
|
644
|
+
if not is_participant(actor):
|
|
645
|
+
raise ValueError("上传附件须声明有效的 actor")
|
|
646
|
+
return {"file_name": name, "actor": actor, "submission_id": submission_value(params.get("submission_id"))}
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def attachment_content_type(data):
|
|
650
|
+
"""Allow inline preview only for recognized raster images and PDF bytes."""
|
|
651
|
+
signatures = ((b"\x89PNG\r\n\x1a\n", "image/png"), (b"\xff\xd8\xff", "image/jpeg"),
|
|
652
|
+
(b"GIF87a", "image/gif"), (b"GIF89a", "image/gif"), (b"%PDF-", "application/pdf"))
|
|
653
|
+
for signature, content_type in signatures:
|
|
654
|
+
if data.startswith(signature):
|
|
655
|
+
return content_type
|
|
656
|
+
if data.startswith(b"RIFF") and data[8:12] == b"WEBP":
|
|
657
|
+
return "image/webp"
|
|
658
|
+
return "application/octet-stream"
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def attachment_view(row):
|
|
662
|
+
"""Return attachment metadata and room-scoped content URLs."""
|
|
663
|
+
result = dict(row)
|
|
664
|
+
base = f"/api/rooms/{result['room_id']}/attachments/{result['id']}/content"
|
|
665
|
+
result.update(url=base, download_url=base + "?download=1")
|
|
666
|
+
return result
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def message_with_attachments(connection, row):
|
|
670
|
+
"""Attach the published file list to an original message."""
|
|
671
|
+
message = dict(row)
|
|
672
|
+
message["suggestions"] = message_suggestions(connection, message["id"])
|
|
673
|
+
message["attachments"] = [attachment_view(item) for item in connection.execute(
|
|
674
|
+
"SELECT * FROM attachments WHERE message_id=? ORDER BY position,id", (message["id"],))]
|
|
675
|
+
return message
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def query_attachments(connection, room_id, params):
|
|
679
|
+
"""List published files, or recover one upload by its exact submission ID."""
|
|
680
|
+
require_room(connection, room_id)
|
|
681
|
+
where, values = "room_id=?", [room_id]
|
|
682
|
+
if "submission_id" in params:
|
|
683
|
+
where += " AND submission_id=?"
|
|
684
|
+
values.append(submission_value(params["submission_id"]))
|
|
685
|
+
else:
|
|
686
|
+
where += " AND message_id IS NOT NULL"
|
|
687
|
+
if "message_id" in params:
|
|
688
|
+
where += " AND message_id=?"
|
|
689
|
+
values.append(int(params["message_id"]))
|
|
690
|
+
page = max(1, int(params.get("page", 1)))
|
|
691
|
+
total = connection.execute(f"SELECT COUNT(*) FROM attachments WHERE {where}", values).fetchone()[0]
|
|
692
|
+
rows = connection.execute(f"SELECT * FROM attachments WHERE {where} ORDER BY created_at,id LIMIT ? OFFSET ?",
|
|
693
|
+
[*values, PAGE_SIZE, (page - 1) * PAGE_SIZE])
|
|
694
|
+
return {"attachments": [attachment_view(row) for row in rows], "page": page, "total": total,
|
|
695
|
+
"pages": max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)}
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def get_attachment(connection, room_id, identifier):
|
|
699
|
+
"""Resolve a file only within the explicitly selected room."""
|
|
700
|
+
row = connection.execute("SELECT * FROM attachments WHERE room_id=? AND id=?", (room_id, identifier)).fetchone()
|
|
701
|
+
if not row:
|
|
702
|
+
raise LookupError("附件不存在")
|
|
703
|
+
return attachment_view(row)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def attachment_path(database, attachment):
|
|
707
|
+
"""Keep opaque file IDs in storage belonging to this exact SQLite database."""
|
|
708
|
+
root = Path(str(Path(database).resolve()) + ".attachments")
|
|
709
|
+
return root / attachment["id"]
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def store_attachment(connection, database, room_id, metadata, data):
|
|
713
|
+
"""Persist one immutable upload; exact retries return the original file."""
|
|
714
|
+
begin_write(connection)
|
|
715
|
+
require_room(connection, room_id)
|
|
716
|
+
expected = {**metadata, "size": len(data), "sha256": hashlib.sha256(data).hexdigest(),
|
|
717
|
+
"content_type": attachment_content_type(data)}
|
|
718
|
+
previous = connection.execute("SELECT * FROM attachments WHERE room_id=? AND submission_id=?",
|
|
719
|
+
(room_id, metadata["submission_id"])).fetchone()
|
|
720
|
+
if previous:
|
|
721
|
+
if any(previous[key] != value for key, value in expected.items()):
|
|
722
|
+
raise FileExistsError("这个附件提交编号已被其他文件使用")
|
|
723
|
+
return {"attachment": attachment_view(previous), "duplicate": True}
|
|
724
|
+
record = {"id": uuid.uuid4().hex, "room_id": room_id, **expected, "created_at": utc_now()}
|
|
725
|
+
path = attachment_path(database, record)
|
|
726
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
727
|
+
try:
|
|
728
|
+
with path.open("xb") as target:
|
|
729
|
+
target.write(data)
|
|
730
|
+
connection.execute("""INSERT INTO attachments
|
|
731
|
+
(id,room_id,file_name,actor,submission_id,size,sha256,content_type,created_at)
|
|
732
|
+
VALUES (:id,:room_id,:file_name,:actor,:submission_id,:size,:sha256,:content_type,:created_at)""", record)
|
|
733
|
+
connection.commit()
|
|
734
|
+
except Exception:
|
|
735
|
+
path.unlink(missing_ok=True)
|
|
736
|
+
raise
|
|
737
|
+
return {"attachment": get_attachment(connection, room_id, record["id"]), "duplicate": False}
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def check_message_attachments(connection, room_id, actor, identifiers):
|
|
741
|
+
"""Reject references to another room, uploader, or already published message."""
|
|
742
|
+
for identifier in identifiers:
|
|
743
|
+
attachment = get_attachment(connection, room_id, identifier)
|
|
744
|
+
if attachment["actor"] != actor or attachment["message_id"] is not None:
|
|
745
|
+
raise FileExistsError("附件已用于其他消息,或不属于本次实际提交者")
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def message_details(room, payload):
|
|
749
|
+
"""Validate declared author, moderator role, and forwarding attribution."""
|
|
750
|
+
author = payload["author"]
|
|
751
|
+
kind = payload.get("kind", "opinion")
|
|
752
|
+
submitted_by = payload.get("submitted_by", author if payload.get("source") in ("cli", "api") else "用户")
|
|
753
|
+
if kind not in ("opinion", "moderation", "receipt") or not is_participant(submitted_by):
|
|
754
|
+
raise ValueError("发言职责或提交者无效")
|
|
755
|
+
if submitted_by not in ("用户", room["host"], author):
|
|
756
|
+
raise ValueError("只有用户或主持人可以代发其他专家的观点")
|
|
757
|
+
if kind == "moderation" and (author != room["host"] or submitted_by not in ("用户", room["host"])):
|
|
758
|
+
raise ValueError("主持发言必须保留当前主持人署名")
|
|
759
|
+
if submitted_by == author and payload.get("source") in ("cli", "api") and author != "用户" and room["experts"].get(author) != "cli":
|
|
760
|
+
raise ValueError("当前专家配置为在线只读,请由用户或主持人代发")
|
|
761
|
+
source = "host_proxy" if submitted_by == room["host"] != author and submitted_by != "用户" else (
|
|
762
|
+
payload["source"] if payload.get("source") in ("cli", "api") and submitted_by == author else ("user" if author == "用户" else "proxy"))
|
|
763
|
+
return {"kind": kind, "submitted_by": submitted_by, "turn_id": int(payload["turn_id"]) if payload.get("turn_id") else None, "source": source}
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def message_summary(payload, message):
|
|
767
|
+
"""Preserve a supplied summary and distinguish its writer from the answer's author."""
|
|
768
|
+
summary = str(payload.get("summary", "")).strip()
|
|
769
|
+
if len(summary) > MAX_SUMMARY_LENGTH:
|
|
770
|
+
raise ValueError(f"回答摘要最多 {MAX_SUMMARY_LENGTH} 字")
|
|
771
|
+
summary_by = payload.get("summary_by", message["submitted_by"]) if summary else ""
|
|
772
|
+
if summary and (message["kind"] not in ("opinion", "chat") or summary_by not in (message["author"], message["submitted_by"])):
|
|
773
|
+
raise ValueError("摘要仅用于观点,撰写者须为原作者或实际提交者")
|
|
774
|
+
return {"summary": summary, "summary_by": summary_by}
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def check_message_references(connection, room_id, message):
|
|
778
|
+
"""Keep replies and scheduled answers within their declared topic."""
|
|
779
|
+
if message["reply_to"] and not connection.execute("SELECT id FROM messages WHERE room_id=? AND id=?", (room_id, message["reply_to"])).fetchone():
|
|
780
|
+
raise ValueError("回复的消息不在当前房间中")
|
|
781
|
+
if message["turn_id"]:
|
|
782
|
+
turn = get_turn(connection, room_id, message["turn_id"])
|
|
783
|
+
if turn["expert"] != message["author"] or message["kind"] != "opinion":
|
|
784
|
+
raise ValueError("回答需使用被安排专家的身份和专家观点职责")
|
|
785
|
+
if turn["status"] not in ("pending", "notified"):
|
|
786
|
+
raise FileExistsError("该发言安排已完成或取消")
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def record_message_timing(connection, message_id, token):
|
|
790
|
+
"""Freeze elapsed time only for the execution that actually authored this message."""
|
|
791
|
+
if not token:
|
|
792
|
+
return
|
|
793
|
+
connection.execute("""INSERT INTO message_timings(message_id,elapsed_seconds)
|
|
794
|
+
SELECT m.id,MAX(0,CAST(strftime('%s',m.created_at) AS INTEGER)-t.dispatched_at)
|
|
795
|
+
FROM messages AS m JOIN message_details AS d ON d.message_id=m.id
|
|
796
|
+
JOIN discussion_events AS e ON e.room_id=m.room_id AND e.participant=m.author
|
|
797
|
+
JOIN event_timings AS t ON t.event_id=e.id
|
|
798
|
+
WHERE m.id=? AND m.source IN ('cli','api') AND d.submitted_by=m.author
|
|
799
|
+
AND e.status='started' AND e.token_hash=? AND t.dispatched_at>0
|
|
800
|
+
ON CONFLICT(message_id) DO NOTHING""", (message_id, token_hash(token)))
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def append_free_message(connection, room, payload, actor):
|
|
804
|
+
"""Append a free-room message: the owner's creates requests, a member's completes its execution.
|
|
805
|
+
|
|
806
|
+
The owner may also forward an online member's answer; it keeps the member as author and the
|
|
807
|
+
owner as submitter.
|
|
808
|
+
"""
|
|
809
|
+
author, text, submission_id, reply_to = message_values(payload)
|
|
810
|
+
room_id = room["id"]
|
|
811
|
+
proxy = None
|
|
812
|
+
if actor == "用户" and author != "用户":
|
|
813
|
+
proxy = proxy_member(connection, room_id, author)
|
|
814
|
+
if payload.get("submitted_by", "用户") != "用户":
|
|
815
|
+
raise PermissionError("代发在线成员的回答须由用户提交")
|
|
816
|
+
submitted_by = "用户" if proxy else author
|
|
817
|
+
expected = {"author": author, "text": text, "reply_to": reply_to, "kind": "chat", "submitted_by": submitted_by,
|
|
818
|
+
"turn_id": None, "source": "proxy" if proxy else ("user" if author == "用户" else "api" if payload.get("source") == "api" else "cli")}
|
|
819
|
+
expected.update(message_summary(payload, expected))
|
|
820
|
+
identifiers = attachment_ids(payload)
|
|
821
|
+
previous = connection.execute(f"{MESSAGE_QUERY} WHERE room_id=? AND submission_id=?", (room_id, submission_id)).fetchone()
|
|
822
|
+
if previous:
|
|
823
|
+
previous = message_with_attachments(connection, previous)
|
|
824
|
+
if (any(previous[key] != expected[key] for key in ("author", "text", "reply_to", "kind", "submitted_by", "summary", "summary_by"))
|
|
825
|
+
or [item["id"] for item in previous["attachments"]] != identifiers):
|
|
826
|
+
raise FileExistsError("这个提交编号已被另一条内容使用,请为新发言生成新编号")
|
|
827
|
+
return {"message": previous, "duplicate": True}
|
|
828
|
+
if room["archived"]:
|
|
829
|
+
raise ValueError("频道已存档;恢复后才能继续协作")
|
|
830
|
+
check_message_references(connection, room_id, expected)
|
|
831
|
+
check_message_attachments(connection, room_id, submitted_by, identifiers)
|
|
832
|
+
if proxy:
|
|
833
|
+
check_proxy(connection, room_id, proxy, payload)
|
|
834
|
+
elif actor != "用户":
|
|
835
|
+
check_answer(connection, room_id, actor, payload)
|
|
836
|
+
cursor = connection.execute("INSERT INTO messages (room_id,author,text,reply_to,source,submission_id,created_at) VALUES (?,?,?,?,?,?,?)",
|
|
837
|
+
(room_id, author, text, reply_to, expected["source"], submission_id, utc_now()))
|
|
838
|
+
connection.execute("INSERT INTO message_details (message_id,kind,submitted_by,turn_id) VALUES (?,?,?,NULL)",
|
|
839
|
+
(cursor.lastrowid, "chat", submitted_by))
|
|
840
|
+
connection.execute("INSERT INTO message_summaries (message_id,text,author) VALUES (?,?,?)",
|
|
841
|
+
(cursor.lastrowid, expected["summary"], expected["summary_by"]))
|
|
842
|
+
for position, identifier in enumerate(identifiers):
|
|
843
|
+
connection.execute("UPDATE attachments SET message_id=?,position=? WHERE id=?", (cursor.lastrowid, position, identifier))
|
|
844
|
+
save_suggestions(connection, room_id, cursor.lastrowid, proxy or actor, payload)
|
|
845
|
+
after_free_message(connection, room_id, cursor.lastrowid, proxy or actor, payload)
|
|
846
|
+
return {"message": get_message(connection, room_id, cursor.lastrowid), "duplicate": False}
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def append_message(connection, room_id, payload, token="", free_actor=None):
|
|
850
|
+
"""Atomically append an attributed message and complete its scheduled turn."""
|
|
851
|
+
author, text, submission_id, reply_to = message_values(payload)
|
|
852
|
+
begin_write(connection)
|
|
853
|
+
room = require_room(connection, room_id)
|
|
854
|
+
if room["mode"] == "free":
|
|
855
|
+
return append_free_message(connection, room, payload, free_actor)
|
|
856
|
+
details = message_details(room, {**payload, "author": author})
|
|
857
|
+
expected = {"author": author, "text": text, "reply_to": reply_to, **details}
|
|
858
|
+
expected.update(message_summary(payload, expected))
|
|
859
|
+
identifiers = attachment_ids(payload)
|
|
860
|
+
previous = connection.execute(f"{MESSAGE_QUERY} WHERE room_id=? AND submission_id=?", (room_id, submission_id)).fetchone()
|
|
861
|
+
if previous:
|
|
862
|
+
previous = message_with_attachments(connection, previous)
|
|
863
|
+
if (any(previous[key] != expected[key] for key in ("author", "text", "reply_to", "kind", "submitted_by", "turn_id", "summary", "summary_by"))
|
|
864
|
+
or [item["id"] for item in previous["attachments"]] != identifiers):
|
|
865
|
+
raise FileExistsError("这个提交编号已被另一条内容使用,请为新发言生成新编号")
|
|
866
|
+
return {"message": previous, "duplicate": True}
|
|
867
|
+
if author != "用户" and details["kind"] == "opinion":
|
|
868
|
+
require_within_limits(room)
|
|
869
|
+
if room["phase"] in ("paused", "concluded") and details["kind"] == "opinion":
|
|
870
|
+
raise ValueError("话题已暂停或收束;恢复阶段后才能提交新观点,运行回执请用 receipt")
|
|
871
|
+
check_message_references(connection, room_id, expected)
|
|
872
|
+
check_message_attachments(connection, room_id, details["submitted_by"], identifiers)
|
|
873
|
+
if author != "用户":
|
|
874
|
+
if details["kind"] == "opinion" and not details["turn_id"]:
|
|
875
|
+
raise ValueError("专家观点必须关联当前有效的 turn_id")
|
|
876
|
+
require_floor(room, author)
|
|
877
|
+
cursor = connection.execute("INSERT INTO messages (room_id,author,text,reply_to,source,submission_id,created_at) VALUES (?,?,?,?,?,?,?)",
|
|
878
|
+
(room_id, author, text, reply_to, details["source"], submission_id, utc_now()))
|
|
879
|
+
connection.execute("INSERT INTO message_details (message_id,kind,submitted_by,turn_id) VALUES (?,?,?,?)",
|
|
880
|
+
(cursor.lastrowid, details["kind"], details["submitted_by"], details["turn_id"]))
|
|
881
|
+
connection.execute("INSERT INTO message_summaries (message_id,text,author) VALUES (?,?,?)",
|
|
882
|
+
(cursor.lastrowid, expected["summary"], expected["summary_by"]))
|
|
883
|
+
for position, identifier in enumerate(identifiers):
|
|
884
|
+
connection.execute("UPDATE attachments SET message_id=?,position=? WHERE id=?",
|
|
885
|
+
(cursor.lastrowid, position, identifier))
|
|
886
|
+
record_message_timing(connection, cursor.lastrowid, token)
|
|
887
|
+
if details["turn_id"]:
|
|
888
|
+
connection.execute("UPDATE discussion_turns SET status='answered',answer_id=? WHERE id=?", (cursor.lastrowid, details["turn_id"]))
|
|
889
|
+
return {"message": get_message(connection, room_id, cursor.lastrowid), "duplicate": False}
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def get_turn(connection, room_id, turn_id):
|
|
893
|
+
"""Read a single speaking assignment inside its topic."""
|
|
894
|
+
row = connection.execute("SELECT * FROM discussion_turns WHERE room_id=? AND id=?", (room_id, int(turn_id))).fetchone()
|
|
895
|
+
if not row:
|
|
896
|
+
raise LookupError("发言安排不存在")
|
|
897
|
+
return dict(row)
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def query_turns(connection, room_id, params):
|
|
901
|
+
"""Read speaking assignments in bounded chronological pages."""
|
|
902
|
+
require_room(connection, room_id)
|
|
903
|
+
page = max(1, int(params.get("page", 1)))
|
|
904
|
+
where, values = "room_id=?", [room_id]
|
|
905
|
+
if "submission_id" in params:
|
|
906
|
+
where += " AND submission_id=?"
|
|
907
|
+
values.append(submission_value(params["submission_id"]))
|
|
908
|
+
total = connection.execute(f"SELECT COUNT(*) FROM discussion_turns WHERE {where}", values).fetchone()[0]
|
|
909
|
+
rows = connection.execute(f"SELECT * FROM discussion_turns WHERE {where} ORDER BY id LIMIT ? OFFSET ?", [*values, PAGE_SIZE, (page - 1) * PAGE_SIZE])
|
|
910
|
+
return {"turns": [dict(row) for row in rows], "page": page, "pages": max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE), "total": total}
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def plan_values(payload):
|
|
914
|
+
"""Normalize one arrangement or an ordered round plan into (expert, question, submission_id) items."""
|
|
915
|
+
items = payload.get("turns")
|
|
916
|
+
if items is None:
|
|
917
|
+
items = [{"expert": payload.get("expert", ""), "question": payload.get("question", "")}]
|
|
918
|
+
if not isinstance(items, list) or not 1 <= len(items) <= MAX_PLAN_TURNS:
|
|
919
|
+
raise ValueError(f"整轮安排需要 1–{MAX_PLAN_TURNS} 项")
|
|
920
|
+
submission_id = submission_value(payload.get("submission_id"))
|
|
921
|
+
plan = []
|
|
922
|
+
for index, item in enumerate(items):
|
|
923
|
+
if not isinstance(item, dict):
|
|
924
|
+
raise ValueError("整轮安排的每一项需要 expert 和 question")
|
|
925
|
+
expert, question, _, _ = message_values({"author": item.get("expert", ""), "text": item.get("question", ""),
|
|
926
|
+
"submission_id": submission_id})
|
|
927
|
+
plan.append((expert, question, submission_id if index == 0 else f"{submission_id}-q{index + 1}"))
|
|
928
|
+
return plan
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def create_turn(connection, room_id, payload):
|
|
932
|
+
"""Schedule one expert, or a round whose later turns relay without waking the host."""
|
|
933
|
+
begin_write(connection)
|
|
934
|
+
room = require_room(connection, room_id)
|
|
935
|
+
require_host(room, payload)
|
|
936
|
+
plan = plan_values(payload)
|
|
937
|
+
previous = [connection.execute("SELECT * FROM discussion_turns WHERE room_id=? AND submission_id=?",
|
|
938
|
+
(room_id, item[2])).fetchone() for item in plan]
|
|
939
|
+
if previous[0]:
|
|
940
|
+
if any(not row or (row["expert"], row["question"]) != item[:2] for row, item in zip(previous, plan)):
|
|
941
|
+
raise FileExistsError("该安排编号已被其他内容使用")
|
|
942
|
+
return {"turn": dict(previous[0]), "queued": [dict(row) for row in previous[1:]], "duplicate": True}
|
|
943
|
+
require_within_limits(room)
|
|
944
|
+
if any(expert not in room["experts"] or len(question) > MAX_QUESTION_LENGTH for expert, question, _ in plan):
|
|
945
|
+
raise ValueError("请选择参与专家,待回应问题最多 1000 字")
|
|
946
|
+
if room["phase"] in ("paused", "concluded"):
|
|
947
|
+
raise ValueError("话题已暂停或收束,请先调整阶段")
|
|
948
|
+
if room["current_turn"] and room["current_turn"]["status"] in ("pending", "notified"):
|
|
949
|
+
raise FileExistsError("请先完成或取消当前发言安排")
|
|
950
|
+
require_floor(room, room["host"])
|
|
951
|
+
ids = []
|
|
952
|
+
for index, (expert, question, submission_id) in enumerate(plan):
|
|
953
|
+
cursor = connection.execute(
|
|
954
|
+
"INSERT INTO discussion_turns (room_id,expert,question,status,submission_id,created_at) VALUES (?,?,?,?,?,?)",
|
|
955
|
+
(room_id, expert, question, "pending" if index == 0 else "queued", submission_id, utc_now()))
|
|
956
|
+
ids.append(cursor.lastrowid)
|
|
957
|
+
return {"turn": get_turn(connection, room_id, ids[0]),
|
|
958
|
+
"queued": [get_turn(connection, room_id, turn_id) for turn_id in ids[1:]], "duplicate": False}
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def continue_turns(connection, room_id, payload):
|
|
962
|
+
"""Resume the remaining round plan after the host has handled user actions or an interruption."""
|
|
963
|
+
begin_write(connection)
|
|
964
|
+
room = require_room(connection, room_id)
|
|
965
|
+
require_host(room, payload)
|
|
966
|
+
if room["phase"] in ("paused", "concluded"):
|
|
967
|
+
raise ValueError("话题已暂停或收束,请先调整阶段")
|
|
968
|
+
if room["current_turn"] and room["current_turn"]["status"] in ("pending", "notified"):
|
|
969
|
+
raise FileExistsError("当前已有进行中的发言安排")
|
|
970
|
+
if not room["queued_turns"]:
|
|
971
|
+
raise FileExistsError("没有排队中的安排;请创建新的安排")
|
|
972
|
+
require_within_limits(room)
|
|
973
|
+
require_floor(room, room["host"])
|
|
974
|
+
turn_id = room["queued_turns"][0]["id"]
|
|
975
|
+
connection.execute("UPDATE discussion_turns SET status='pending' WHERE id=? AND status='queued'", (turn_id,))
|
|
976
|
+
return {"turn": get_turn(connection, room_id, turn_id), "queued": room["queued_turns"][1:]}
|
|
977
|
+
|
|
978
|
+
def check_limits(connection, room_id, payload):
|
|
979
|
+
"""Apply a reached boundary once; the host must author the actual summary."""
|
|
980
|
+
begin_write(connection)
|
|
981
|
+
room = require_room(connection, room_id)
|
|
982
|
+
require_host(room, payload)
|
|
983
|
+
limits = room["limits"]
|
|
984
|
+
force = payload.get("force") is True
|
|
985
|
+
if force and (room["phase"] in ("preparing", "paused", "concluded") or room["archived"]):
|
|
986
|
+
raise ValueError("仅进行中的讨论可以提前收尾")
|
|
987
|
+
if (not limits["reached"] and not force) or room["phase"] in ("paused", "concluded") or room["archived"]:
|
|
988
|
+
return room
|
|
989
|
+
reason = limits["reason"] or "user_stop"
|
|
990
|
+
closing = (force or limits["on_limit"] == "summarize") and reason != "time_limit"
|
|
991
|
+
if closing and limits["closing"]:
|
|
992
|
+
return room
|
|
993
|
+
connection.execute("UPDATE discussion_limits SET closing_at=?,stop_reason=?,revision=revision+1 WHERE room_id=?",
|
|
994
|
+
(int(time.time()) if closing else limits["closing_at"], reason, room_id))
|
|
995
|
+
connection.execute("UPDATE discussion_turns SET status='cancelled' WHERE room_id=? AND status IN ('pending','notified')", (room_id,))
|
|
996
|
+
connection.execute("UPDATE discussion_turns SET status='skipped' WHERE room_id=? AND status='queued'", (room_id,))
|
|
997
|
+
connection.execute("UPDATE room_floor SET holder=NULL,next_speaker=?,revision=revision+1 WHERE room_id=?", (room["host"], room_id))
|
|
998
|
+
if closing:
|
|
999
|
+
summary_deadline = int(time.time()) + limits["summary_minutes"] * 60
|
|
1000
|
+
connection.execute("UPDATE discussion_runtime SET generation=generation+1,revision=revision+1,deadline=CASE WHEN deadline=0 THEN ? ELSE MIN(deadline,?) END WHERE room_id=? AND status='running'",
|
|
1001
|
+
(summary_deadline, summary_deadline, room_id))
|
|
1002
|
+
else:
|
|
1003
|
+
connection.execute("UPDATE topic_settings SET phase='paused' WHERE room_id=?", (room_id,))
|
|
1004
|
+
return require_room(connection, room_id)
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def update_turn(connection, room_id, turn_id, payload):
|
|
1008
|
+
"""Record a host's explicit notification confirmation or cancellation."""
|
|
1009
|
+
begin_write(connection)
|
|
1010
|
+
room = require_room(connection, room_id)
|
|
1011
|
+
require_host(room, payload)
|
|
1012
|
+
turn = get_turn(connection, room_id, turn_id)
|
|
1013
|
+
status = payload.get("status")
|
|
1014
|
+
if status not in ("notified", "cancelled"):
|
|
1015
|
+
raise ValueError("安排状态仅可显式改为 notified 或 cancelled")
|
|
1016
|
+
if turn["status"] == status:
|
|
1017
|
+
return turn
|
|
1018
|
+
if status == "notified" and room["phase"] in ("paused", "concluded"):
|
|
1019
|
+
raise ValueError("话题已暂停或收束,不能继续通知")
|
|
1020
|
+
if turn["status"] not in ("pending", "notified"):
|
|
1021
|
+
raise FileExistsError("该发言安排已结束")
|
|
1022
|
+
if status == "cancelled" and payload.get("actor") != "用户":
|
|
1023
|
+
require_floor(room, room["host"])
|
|
1024
|
+
notified_at = utc_now() if status == "notified" else turn["notified_at"]
|
|
1025
|
+
connection.execute("UPDATE discussion_turns SET status=?,notified_at=? WHERE id=?", (status, notified_at, turn["id"]))
|
|
1026
|
+
return get_turn(connection, room_id, turn_id)
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def message_filters(room_id, params):
|
|
1030
|
+
"""Build exact and incremental filters without expanding the read scope."""
|
|
1031
|
+
query = str(params.get("q", "")).strip()
|
|
1032
|
+
author = str(params.get("author", "")).strip()
|
|
1033
|
+
mention = str(params.get("mention", "")).strip()
|
|
1034
|
+
clauses = ["room_id=?"]
|
|
1035
|
+
values = [room_id]
|
|
1036
|
+
if query:
|
|
1037
|
+
clauses.append("(instr(lower(messages.text), lower(?)) > 0 OR CAST(messages.id AS TEXT)=?)")
|
|
1038
|
+
values.extend([query, query.lstrip("#")])
|
|
1039
|
+
if author:
|
|
1040
|
+
clauses.append("messages.author=?")
|
|
1041
|
+
values.append(author)
|
|
1042
|
+
if mention:
|
|
1043
|
+
clauses.append("instr(messages.text, ?) > 0")
|
|
1044
|
+
values.append(f"@{mention}")
|
|
1045
|
+
if "kind" in params:
|
|
1046
|
+
if params["kind"] not in ("opinion", "moderation", "receipt", "chat"):
|
|
1047
|
+
raise ValueError("发言职责无效")
|
|
1048
|
+
clauses.append("COALESCE(details.kind,'opinion')=?")
|
|
1049
|
+
values.append(params["kind"])
|
|
1050
|
+
if "submission_id" in params:
|
|
1051
|
+
clauses.append("submission_id=?")
|
|
1052
|
+
values.append(submission_value(params["submission_id"]))
|
|
1053
|
+
if "after" in params:
|
|
1054
|
+
after = int(params["after"])
|
|
1055
|
+
if after < 0 or int(params.get("page", 1)) != 1:
|
|
1056
|
+
raise ValueError("after 需为非负消息编号,不能与翻页同时使用")
|
|
1057
|
+
clauses.append("messages.id>?")
|
|
1058
|
+
values.append(after)
|
|
1059
|
+
return " AND ".join(clauses), values
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def query_messages(connection, room_id, params):
|
|
1063
|
+
"""Read one bounded page; incremental cursors advance only through returned rows."""
|
|
1064
|
+
require_room(connection, room_id)
|
|
1065
|
+
page = max(1, int(params.get("page", 1)))
|
|
1066
|
+
where, values = message_filters(room_id, params)
|
|
1067
|
+
total = connection.execute(f"SELECT COUNT(*) FROM ({MESSAGE_QUERY} WHERE {where})", values).fetchone()[0]
|
|
1068
|
+
rows = connection.execute(f"{MESSAGE_QUERY} WHERE {where} ORDER BY messages.id LIMIT ? OFFSET ?", [*values, PAGE_SIZE + 1, (page - 1) * PAGE_SIZE]).fetchall()
|
|
1069
|
+
messages = [message_with_attachments(connection, row) for row in rows[:PAGE_SIZE]]
|
|
1070
|
+
result = {"messages": messages, "page": page, "total": total,
|
|
1071
|
+
"pages": max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)}
|
|
1072
|
+
if "after" in params:
|
|
1073
|
+
result.update(next_after=messages[-1]["id"] if messages else int(params["after"]), has_more=len(rows) > PAGE_SIZE)
|
|
1074
|
+
return result
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def get_message(connection, room_id, message_id):
|
|
1078
|
+
"""Read an exact message, constrained to the specified room."""
|
|
1079
|
+
row = connection.execute(f"{MESSAGE_QUERY} WHERE room_id=? AND messages.id=?", (room_id, message_id)).fetchone()
|
|
1080
|
+
if row is None:
|
|
1081
|
+
raise LookupError("消息不存在")
|
|
1082
|
+
return message_with_attachments(connection, row)
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def update_state(connection, room_id, payload):
|
|
1086
|
+
"""Save detailed state and an optional human-facing brief in one transaction."""
|
|
1087
|
+
text = str(payload.get("text", "")).strip()
|
|
1088
|
+
brief = str(payload.get("brief", "")).strip()
|
|
1089
|
+
if len(text) > MAX_STATE_LENGTH or len(brief) > MAX_BRIEF_LENGTH:
|
|
1090
|
+
raise ValueError(f"讨论状态最多 {MAX_STATE_LENGTH} 字,主持简报最多 {MAX_BRIEF_LENGTH} 字")
|
|
1091
|
+
room = require_room(connection, room_id)
|
|
1092
|
+
require_host(room, {"actor": payload.get("actor", "用户")})
|
|
1093
|
+
until = int(payload.get("until", room["latest_id"]))
|
|
1094
|
+
if until < 0 or until > room["latest_id"]:
|
|
1095
|
+
raise ValueError("讨论状态的截止编号无效")
|
|
1096
|
+
connection.execute("UPDATE rooms SET state_text=?,state_until=? WHERE id=?", (text, until, room_id))
|
|
1097
|
+
if "brief" in payload:
|
|
1098
|
+
connection.execute("""INSERT INTO room_briefs (room_id,text,until,updated_at) VALUES (?,?,?,?)
|
|
1099
|
+
ON CONFLICT(room_id) DO UPDATE SET text=excluded.text,until=excluded.until,updated_at=excluded.updated_at""",
|
|
1100
|
+
(room_id, brief, until, utc_now()))
|
|
1101
|
+
return require_room(connection, room_id)
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def state_reading_text(room):
|
|
1105
|
+
"""Read the host's brief explicitly without adding it to independent-entry overviews."""
|
|
1106
|
+
brief = room.get("host_brief")
|
|
1107
|
+
overview = f"主持简报(整理至 #{brief['until']},{brief['updated_at']})\n{brief['text']}" if brief and brief["text"] else "主持简报:尚未更新"
|
|
1108
|
+
return f"{overview}\n\n主持人维护的状态(整理至 #{room['state_until']})\n\n{room['state_text'] or '尚未整理'}\n\n{receipt_references_text(room)}"
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def message_text(message):
|
|
1112
|
+
"""Render an original message with its provenance and reply reference."""
|
|
1113
|
+
reply = f" · 回复 #{message['reply_to']}" if message["reply_to"] else ""
|
|
1114
|
+
role = {"moderation": "主持发言", "receipt": "运行回执", "chat": "协作消息"}.get(message.get("kind"), "专家观点")
|
|
1115
|
+
summary = f"\n回答摘要({message['summary_by']}):\n{message['summary']}\n\n完整正文:\n" if message.get("summary") else "\n"
|
|
1116
|
+
attachments = "".join(f"\n附件:{item['file_name']}({item['size']} 字节;ID: {item['id']})\n{item['download_url']}"
|
|
1117
|
+
for item in message.get("attachments", []))
|
|
1118
|
+
return f"#{message['id']} · {message['author']} · {message['created_at']}{reply}\n职责:{role};来源:{message['source']};提交者:{message.get('submitted_by', '用户')};安排:{message.get('turn_id') or '无'}{summary}{message['text']}{attachments}"
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def room_text(connection, room_id, params):
|
|
1122
|
+
"""Expose only an overview; readers choose which supporting pages to open."""
|
|
1123
|
+
room = require_room(connection, room_id)
|
|
1124
|
+
base = f"/r/{room_id}"
|
|
1125
|
+
content = [f"# {room['title']}", f"目标:{room['goal']}",
|
|
1126
|
+
f"主持人:{room['host']};阶段:{PHASES[room['phase']]};当前焦点:{room['focus'] or '尚未填写'}",
|
|
1127
|
+
f"当前记录截止 #{room['latest_id']};共 {room['message_count']} 条;已完成 {room['completed_rounds']} 轮讨论(所有参与专家各发言一次)。"]
|
|
1128
|
+
limits = room["limits"]
|
|
1129
|
+
content.append(f"讨论上限:{limits['max_rounds'] or '不限'} 轮;总时长 {limits['max_minutes'] or '不限'} 分钟;单次自动执行 {str(limits['execution_minutes']) + ' 分钟' if limits['execution_minutes'] else '不限时长'};到限 {limits['on_limit']};收束预留 {limits['summary_minutes']} 分钟(计入总时长)。到限:{limits['reached']};收束中:{limits['closing']}。")
|
|
1130
|
+
if room["mode"] == "free":
|
|
1131
|
+
members = ";".join(f"{item['name']}(已读至 #{item['read_until']},待投递 {item['pending']})" for item in room["free"]["members"])
|
|
1132
|
+
content.append(f"自由协作房间:无发言权、安排与轮次。成员:{members or '无'}。用户的消息点名成员后生成请求;成员回答须带本次执行编号,执行结束、被打断或过期后的回答会被拒绝,不能改作普通发言。")
|
|
1133
|
+
return "\n\n".join(content)
|
|
1134
|
+
turn = room["current_turn"]
|
|
1135
|
+
floor = room["floor"]
|
|
1136
|
+
content.append(f"发言权:{floor['holder'] + ' 持有' if floor['holder'] else '待 ' + floor['next_speaker'] + ' 领取'};版本 {floor['revision']}。用户本人可随时留言,AI 发言前须领取。")
|
|
1137
|
+
if room["phase"] == "initial":
|
|
1138
|
+
content.append("本阶段先独立形成详细观点:仅阅读目标、背景、边界和自己的已有讨论;暂不阅读他人观点或主持总结。提交后再交叉阅读。这是参与约定,不是权限隔离。")
|
|
1139
|
+
if room["archived"]:
|
|
1140
|
+
content.append("频道已存档,记录仍可查阅;先恢复频道,再由用户明确恢复讨论阶段。")
|
|
1141
|
+
if room["phase"] in ("paused", "concluded"):
|
|
1142
|
+
content.append("停止处理发言安排,取消本讨论自己的后台任务并核对实际状态;非必要不生成回执,正常停止无需回执。不要因旧通知恢复讨论。")
|
|
1143
|
+
if turn:
|
|
1144
|
+
content.extend([f"当前安排 #{turn['id']}:{turn['expert']} · {turn_status_label(turn, room)}", f"待回应问题:{turn['question']}", f"安排详情:{base}/turns/{turn['id']}"])
|
|
1145
|
+
if room["queued_turns"]:
|
|
1146
|
+
content.append("本轮排队:" + " → ".join(f"#{item['id']} {item['expert']}" for item in room["queued_turns"]))
|
|
1147
|
+
if room["held_since"]:
|
|
1148
|
+
content.append(f"用户自 #{room['held_since']} 起有待主持人处理的操作;当前回答完成后交给主持人,不自动接力。")
|
|
1149
|
+
content.extend(["## 按需展开", "请自主选择需要的材料,不必读取全部历史。",
|
|
1150
|
+
f"背景、边界与参与专家:{base}/brief",
|
|
1151
|
+
f"共识与分歧(整理至 #{room['state_until']}):{base}/state"])
|
|
1152
|
+
if params.get("participant"):
|
|
1153
|
+
content.append(f"与 @{params['participant']} 相关的观点:{base}/search?mention={params['participant']}&page=1")
|
|
1154
|
+
content.extend(["", "## 读取与检索", f"完整历史(分页):{base}/messages?page=1",
|
|
1155
|
+
f"单条原文:{base}/messages/消息编号", f"检索:{base}/search?q=关键词&page=1",
|
|
1156
|
+
f"按作者检索:{base}/search?author=Claude", f"发言安排历史:{base}/turns?page=1"])
|
|
1157
|
+
if params.get("mode") == "cli":
|
|
1158
|
+
content.extend(["", "## 本地 CLI AI · 可读可写",
|
|
1159
|
+
f"POST /api/rooms/{room_id}/messages", "Content-Type: application/json",
|
|
1160
|
+
'JSON 字段:author、text、submission_id(新 UUID)、source(填写 "cli");可选 reply_to、turn_id。',
|
|
1161
|
+
f"完整写入与主持协议:{base}/protocol",
|
|
1162
|
+
"重试沿用编号和正文;收到回执后按 message.id 重新读取核对。"])
|
|
1163
|
+
else:
|
|
1164
|
+
content.extend(["", "## 在线 AI · 只读参与",
|
|
1165
|
+
"请读取议题、状态和相关原文,在你自己的聊天窗口输出观点:先给通常 1–3 句的回答摘要,再给完整正文,保留关键条件、不确定性及引用编号;需要留档时由用户代发。",
|
|
1166
|
+
"不要操作网页表单、调用写入接口或生成发布链接,不要求获取发布回执。读取失败时如实报告,由用户提供复制的材料。"])
|
|
1167
|
+
content.append("参与方式按运行环境区分;主持人可以兼任专家;作者、职责与来源尚未认证。不调用模型服务 API。")
|
|
1168
|
+
return "\n\n".join(content)
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
def brief_text(room):
|
|
1172
|
+
"""Render the detailed brief only when explicitly requested."""
|
|
1173
|
+
experts = "\n".join(f"- {name}:{'本地 CLI 读写' if mode == 'cli' else '在线只读'}" for name, mode in room["experts"].items())
|
|
1174
|
+
return f"# {room['title']} · 话题资料\n\n目标:{room['goal']}\n\n用户原文:\n{room['user_request'] or '未填写'}\n\n背景:\n{room['background'] or '未填写'}\n\n边界:\n{room['boundaries'] or '未填写'}\n\n主持人:{room['host']}(可兼任专家)\n\n参与专家:\n{experts}"
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def turn_text(turn, room):
|
|
1178
|
+
"""Render a speaking assignment without embedding discussion context."""
|
|
1179
|
+
room_id = room["id"]
|
|
1180
|
+
answer = f"\n回答原文:/r/{room_id}/messages/{turn['answer_id']}" if turn["answer_id"] else ""
|
|
1181
|
+
return f"安排 #{turn['id']} · {turn['expert']} · {turn_status_label(turn, room)}\n待回应问题:{turn['question']}\n话题入口:/r/{room_id}?participant={turn['expert']}\n回答关联 turn_id:{turn['id']}{answer}\n回答附简短摘要及完整正文,保留关键条件和不确定性。\n已接入的本地 CLI 专家可自行检查安排;在线专家需联系。复制通知不代表已送达;仅实际通知后由主持人确认。"
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def protocol_text(room):
|
|
1185
|
+
"""Describe explicit CLI and host operations separately from topic content."""
|
|
1186
|
+
base = f"/api/rooms/{room['id']}"
|
|
1187
|
+
if room["mode"] == "free":
|
|
1188
|
+
return "\n\n".join([
|
|
1189
|
+
"# 自由协作参与协议", f"当前主持人:{room['host']}。没有讨论发言权、正式安排或轮次。",
|
|
1190
|
+
"先用 room view 读取成员、职责和 free.requests,再按需读取 message list / message view 原文;通知只给对象、问题和入口。",
|
|
1191
|
+
"用户写入和成员管理须房主控制凭据;本地成员写入须本人的 --access-file,不能借用用户或其他成员身份。凭据文件私下交付,不公开 token。",
|
|
1192
|
+
"runner 成员等待本机接入程序交付有效任务,不另建定时。用 message post --as 本人 --kind chat --execution 本次编号 --access-file FILE 回答;不执行 execution enter。执行失效后保留草稿,不能去掉编号改作普通发言。",
|
|
1193
|
+
"manual 成员可由人工或软件原生定时触发,用本人成员文件自行提交。回答带 --request 请求编号(可重复)关闭自己的请求,不带则为普通发言。不得编造 execution 编号。",
|
|
1194
|
+
"online 成员只读,在原聊天回答,由用户以房主凭据代发原文;作者仍是在线成员、提交者为用户,不能用成员凭证自行写入。复制说明不表示已送达或接入。",
|
|
1195
|
+
"获授 host 职责的本地成员可在回答中用 --to 点名其他成员,不能点名自己或扩大权限;route 表示分配任务,handoff 表示派出的成员已结束。链上 AI 请求受房间预算限制,到限等待用户。",
|
|
1196
|
+
"普通成员的 --to 或正文 @ 只保存为回应建议,不唤醒;代码与引用中的正文 @ 不算。房主在网页转为请求后建立关联原消息的新用户任务链,重复点击不重复派发。",
|
|
1197
|
+
f"附件使用 attachment upload/download;上传使用本人成员文件,消息带 attachment_ids。原始接口为 {base}/attachments 与 {base}/messages,自动成员在回答完成前上传。",
|
|
1198
|
+
"每次新内容用新 submission_id,同次重试沿用编号、原文、凭据和执行/请求关联;失败先核对服务端结果,不换身份或编号绕过。用户打断、成员离开或移除后停止本次工作。",
|
|
1199
|
+
])
|
|
1200
|
+
return "\n\n".join([
|
|
1201
|
+
"# 本地 CLI 读写与主持协议", f"当前主持人:{room['host']}。以下为留言板接口,不是模型服务 API。",
|
|
1202
|
+
"API 参与者由本机 runner 使用相同房间授权和执行约束提交,消息 source=api;CLI 仍使用 source=cli。兼容字段 experts 中的 cli 表示可自行提交,实际接入方式见成员 adapter。",
|
|
1203
|
+
"写入使用 POST,通常为 Content-Type: application/json;附件上传使用 application/octet-stream 原始字节。主持操作填写 actor 为当前主持人或用户;这些是声明身份,不是认证凭据。",
|
|
1204
|
+
f"附件:POST {base}/attachments?actor=身份&file_name=文件名&submission_id=新编号,原文件最多 20 MiB;GET 同路径默认列已发送附件,加 submission_id 精确核对上传。GET {base}/attachments/ID 读元数据,/ID/content 下载原文件。消息可带 attachment_ids(最多 10 个有序编号);只关联同房间、同提交者的未使用附件,可只有附件无正文。自动任务须在回答完成前上传;消息查询包含附件,按需下载后由当前软件读取,不自动解析。",
|
|
1205
|
+
"讨论上限:room.limits 返回轮次、总时长、单次执行、到限方式、收束预留和实际进度。准备/暂停时通过 topic 的 limits 对象修改;0 表示不预设轮次/总时长/单次执行时限。POST /api/rooms/ROOM/limits(actor)或 room limits ROOM --check 检查到限处理;附 force:true 可在进行中提前进入收束,由主持人在收束预留时间内总结。到限停止新专家安排与观点;总结由真实主持人完成,预留计入总时长;暂停期间不计时也不消耗总时长。",
|
|
1206
|
+
"运行方式:room.runtime 显示 manual/automatic、状态及真实执行回执。所有房间写入须带 X-Discussion-Token,CLI 用 --access-file 读取私有文件。用户及运行控制使用房主凭据,手动本地成员使用自己的成员凭证,不能相互冒充;在线成员只读,由房主或讨论主持人代发。自动讨论先 execution enter,创建安排或保存关联回答即完成本步,不创建原生定时任务;持久成员凭证不能替代自动执行,暂停、停止、换代、租约到期后的旧执行失效。run view/start/pause/stop/configure 字段查 CLI schema。",
|
|
1207
|
+
f"发言权:GET {base}/floor 返回 holder、next_speaker、revision;POST 同路径,字段 actor、speaker(默认 actor)、action=take/return、revision(刚读到的整数版本)。仅无人持有且 next_speaker 为本人时可领取;持有者可主动归还,下一位回到主持人。版本变化返回 409,先读回核对。主持人仅可代操作 online 专家,cli 专家自行操作;用户可协助。",
|
|
1208
|
+
"新房间由主持人持有发言权。创建安排须主持人持有,保存成功即归还并点名该专家;AI 的 opinion、moderation、receipt 都须实际作者持有,代发不豁免,opinion 必须关联有效 turn_id。消息保存成功自动归还,下一位为主持人。失败不归还,同编号重试不再次交接。用户本人发言不受发言权限制,也不改变发言权;@ 不改变顺序。维护状态、阶段及通知回执不占用发言权。取消安排须主持人持有,用户可直接取消失效安排;取消后归还给主持人领取。",
|
|
1209
|
+
'创建话题:POST /api/rooms,携带房主控制凭据;字段 title、goal、user_request、background、boundaries、host、experts、members、submission_id(新 UUID)。members 与 experts 名单一致,各项含 name、独立 token、access(手动本地 manual、自动 runner、在线 online)。AI 主持人必须为本地可写成员。CLI room create --access-file 私有房主文件 在请求前保存凭据,确认后返回各本地成员文件路径;同编号重试沿用原资料和全部凭据。名单、主持职责变更仅房主可做。',
|
|
1210
|
+
f"查询提交:GET /api/rooms?submission_id=编号 查建房结果;GET {base}/messages?submission_id=编号 或 {base}/turns?submission_id=编号 查消息或安排。查询成功且列表为空表示尚未查到,读取失败不表示未写入。",
|
|
1211
|
+
f"增量读取:GET {base}/messages?after=已读消息编号,每次最多 20 条,按 next_after 续读,has_more 表示还有一批;可加 kind=opinion/moderation/receipt、author、mention、q。after 不与翻页混用,过滤后的游标只代表该查询范围。",
|
|
1212
|
+
f"维护话题:POST {base}/topic;actor 加需要修改的上述字段,另可更新 phase(preparing/initial/discussing/reviewing/paused/concluded)、focus。收束前先完成或取消当前安排。暂停保留安排;恢复阶段后再继续。",
|
|
1213
|
+
f"安排发言:POST {base}/turns;字段 actor、expert、question、submission_id(新 UUID)。同一话题同时只有一个未完成安排;重试沿用同一编号和问题。整轮安排改传 turns=[{{expert,question}},…](最多 {MAX_PLAN_TURNS} 项):第一项立即生效,其余排队(queued);每个回答保存后自动交给下一位,最后一位答完再交回主持人。用户留言或修改目标、背景、边界、焦点后(room.held_since 非 0),当前回答完成即交回主持人;主持人处理后用 POST {base}/turns/continue(actor)按原计划继续,或直接创建新安排替换剩余排队(原排队改为 skipped)。",
|
|
1214
|
+
f"通知:仅把对象、问题、话题入口交给对应专家,不附背景、摘要或历史。使用已有且获准的会话通道;没有通道时交由用户协助。实际送达后 POST {base}/turns/安排编号,字段 actor、status=notified;取消用 status=cancelled。复制不算送达。",
|
|
1215
|
+
f"专家观点:POST {base}/messages;author、text、submission_id、source=cli,kind=opinion;必须关联 turn_id(回答的安排编号),可选 reply_to(本话题原文编号)。每次回答同时提供 summary(最多 {MAX_SUMMARY_LENGTH} 字),通常 1–3 句结论先行,保留关键理由、条件和不确定性;summary_by 为原作者或提交者,默认提交者。text 保留完整正文,最多 {MAX_TEXT_LENGTH} 个 Unicode 字符,请求最多 {MAX_BODY_BYTES} 字节;不截断或用摘要完成安排。旧客户端缺少 summary 时不自动生成。",
|
|
1216
|
+
f"主持发言:同一消息接口,author={room['host']}、submitted_by={room['host']}、kind=moderation、source=cli;个人观点使用 kind=opinion,不能自动视为共识。",
|
|
1217
|
+
f"代发只读专家观点:author 保留原专家,submitted_by={room['host']}、kind=opinion、text 为原文;须关联 turn_id,可附 reply_to。服务保存代发来源;不要冒充专家直接提交。",
|
|
1218
|
+
f"整理状态:POST {base}/state;actor、text(详细状态,最多 {MAX_STATE_LENGTH} 字)、until(依据的消息截止编号);brief(最多 {MAX_BRIEF_LENGTH} 字)为面向人的主持简报,说明结论、共识、歧义和下一步意图。进度或安排变化后更新;省略 brief 保留旧简报,空字符串清除。所有参与专家各保存一次完整观点才完成一轮;同轮重复发言只计该专家一次,主持说明、用户留言、回执及取消安排不推进轮次。房间 rounds 按当前配置专家和完整历史生成,completed_rounds 为完成轮数,answered_turns 仅为已回答安排数。状态不覆盖原文,也不代表全体一致同意。",
|
|
1219
|
+
"讨论方法:initial 先独立给出结论、依据、假设和风险,不回应他人;discussing 围绕具体分歧引用原文,说明维持或修正及原因;reviewing 检查拟定结论是否误述自己、遗漏条件或仍有阻碍。沉默不算同意。",
|
|
1220
|
+
f"运行回执:非必要不生成。正常启停、首次检查、无变化轮询和已提交回答不另发回执;仅用户明确要求、异常影响协作且需要处理或交接确需补充确认时使用。POST {base}/messages;author 使用实际身份、kind=receipt、source=cli、text 简述实际结果、影响和下一步,有任务时附本次运行标识与真实任务编号;已有状态或消息足够说明时不重复。submission_id 同次重试不变,不填 turn_id。回执不算专家观点或已回答。",
|
|
1221
|
+
"后台参与:先立即检查,再用本会话真实可用的原生任务按约定频率和自身运行上限检查。专家只答自己未完成的安排,无任务时等待;主持人仍需处理新回答、网页回复和下一安排。paused/concluded、用户叫停或达到自身上限时停止自己的任务并核验,实际结果保留在本地接续信息,是否发回执按上述规则判断;单个专家任务停止不等于整场讨论结束。失败时保留未提交原文,重新读取阶段后再决定是否重试。",
|
|
1222
|
+
"收束:结论须有原文依据,区分明确共识、未决分歧与主持人判断;完成约定复核或达到已说明的时间/轮次上限后收束。讨论完成与后台任务退出分别核验;不为追求全员确认无限续轮。",
|
|
1223
|
+
"房间、消息与安排都支持 submission_id 去重,同次重试沿用编号和原始内容,新内容使用新编号。写入后重新读取服务器返回的结果,核对身份、内容与引用。",
|
|
1224
|
+
])
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
class DiscussionServer(ThreadingHTTPServer):
|
|
1228
|
+
"""HTTP server carrying the configured SQLite path."""
|
|
1229
|
+
|
|
1230
|
+
daemon_threads = True
|
|
1231
|
+
allow_reuse_address = os.name != "nt"
|
|
1232
|
+
allow_reuse_port = False
|
|
1233
|
+
|
|
1234
|
+
def __init__(self, address, database, local_launch=False):
|
|
1235
|
+
self.database = Path(database)
|
|
1236
|
+
self.updates = threading.Condition()
|
|
1237
|
+
self.update_version = 0
|
|
1238
|
+
self.updates_closed = False
|
|
1239
|
+
if local_launch and address[0] != "127.0.0.1":
|
|
1240
|
+
raise ValueError("本机启动入口须绑定 127.0.0.1")
|
|
1241
|
+
self.local_launch = local_launch
|
|
1242
|
+
service_home = os.getenv("DISCUSS_SERVICE_HOME")
|
|
1243
|
+
self.connections_file = Path(service_home) / "site-connections.json" if service_home else None
|
|
1244
|
+
super().__init__(address, DiscussionHandler)
|
|
1245
|
+
|
|
1246
|
+
def server_bind(self):
|
|
1247
|
+
"""Keep each listening port exclusive while allowing normal Unix restarts."""
|
|
1248
|
+
if os.name == "nt":
|
|
1249
|
+
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
|
1250
|
+
super().server_bind()
|
|
1251
|
+
|
|
1252
|
+
def notify_update(self):
|
|
1253
|
+
"""Wake browser subscribers after a committed business write."""
|
|
1254
|
+
with self.updates:
|
|
1255
|
+
self.update_version += 1
|
|
1256
|
+
self.updates.notify_all()
|
|
1257
|
+
|
|
1258
|
+
def server_close(self):
|
|
1259
|
+
"""Release streaming clients when the HTTP server stops."""
|
|
1260
|
+
with self.updates:
|
|
1261
|
+
self.updates_closed = True
|
|
1262
|
+
self.updates.notify_all()
|
|
1263
|
+
super().server_close()
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
class DiscussionHandler(BaseHTTPRequestHandler):
|
|
1267
|
+
"""Serve the browser UI and explicit plain-text discussion endpoints."""
|
|
1268
|
+
|
|
1269
|
+
def log_message(self, message_format, *args):
|
|
1270
|
+
"""Log request paths without message text stored in URL queries."""
|
|
1271
|
+
logging.info("%s %s", self.command, urlsplit(self.path).path)
|
|
1272
|
+
|
|
1273
|
+
def respond(self, body, status=200, content_type="application/json"):
|
|
1274
|
+
"""Send a UTF-8 response that browsers and proxies must not cache."""
|
|
1275
|
+
parts = urlsplit(self.path).path.strip("/").split("/")
|
|
1276
|
+
quiet_renewal = parts[3:4] == ["events"] and (self.payload_cache or {}).get("action") == "renew"
|
|
1277
|
+
if (self.command == "POST" and 200 <= status < 300 and parts[:2] in (["api", "rooms"], ["api", "folders"])
|
|
1278
|
+
and not quiet_renewal):
|
|
1279
|
+
self.server.notify_update()
|
|
1280
|
+
if content_type == "application/json":
|
|
1281
|
+
body = json.dumps(body, ensure_ascii=False)
|
|
1282
|
+
data = body.encode("utf-8") if isinstance(body, str) else body
|
|
1283
|
+
self.send_response(status)
|
|
1284
|
+
self.send_header("Content-Type", f"{content_type}; charset=utf-8")
|
|
1285
|
+
self.send_header("Content-Length", str(len(data)))
|
|
1286
|
+
self.send_header("Cache-Control", "no-store")
|
|
1287
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
1288
|
+
if getattr(self, "bridge_origin", None):
|
|
1289
|
+
self.send_header("Access-Control-Allow-Origin", self.bridge_origin)
|
|
1290
|
+
self.send_header("Vary", "Origin")
|
|
1291
|
+
if self.command == "OPTIONS":
|
|
1292
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST")
|
|
1293
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-CoDiscuss-Local, X-CoDiscuss-Connection")
|
|
1294
|
+
if self.headers.get("Access-Control-Request-Private-Network") == "true":
|
|
1295
|
+
self.send_header("Access-Control-Allow-Private-Network", "true")
|
|
1296
|
+
self.end_headers()
|
|
1297
|
+
self.wfile.write(data)
|
|
1298
|
+
|
|
1299
|
+
def read_payload(self):
|
|
1300
|
+
"""Read a bounded JSON object from an incoming form API request."""
|
|
1301
|
+
if getattr(self, "payload_cache", None) is not None:
|
|
1302
|
+
return self.payload_cache
|
|
1303
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
1304
|
+
if not 0 < length <= MAX_BODY_BYTES:
|
|
1305
|
+
raise ValueError("请求内容为空或过大")
|
|
1306
|
+
payload = json.loads(self.rfile.read(length))
|
|
1307
|
+
if not isinstance(payload, dict):
|
|
1308
|
+
raise ValueError("请求必须是 JSON 对象")
|
|
1309
|
+
self.payload_cache = payload
|
|
1310
|
+
return payload
|
|
1311
|
+
|
|
1312
|
+
def dispatch(self, method):
|
|
1313
|
+
"""Route a request and consistently report validation or lookup errors."""
|
|
1314
|
+
self.payload_cache = None
|
|
1315
|
+
self.bridge_origin = None
|
|
1316
|
+
parsed = urlsplit(self.path)
|
|
1317
|
+
params = {key: values[-1] for key, values in parse_qs(parsed.query).items()}
|
|
1318
|
+
parts = parsed.path.strip("/").split("/")
|
|
1319
|
+
text_mode = parts[0] == "r"
|
|
1320
|
+
try:
|
|
1321
|
+
if parts[:2] == ["api", "local"]:
|
|
1322
|
+
return self.handle_local_launch(parts, method)
|
|
1323
|
+
if parts == ["api", "updates"] and method == "GET":
|
|
1324
|
+
return self.stream_updates()
|
|
1325
|
+
if method == "GET" and parsed.path in ASSETS:
|
|
1326
|
+
filename, content_type = ASSETS[parsed.path]
|
|
1327
|
+
return self.respond((WEB_ROOT / filename).read_bytes(), content_type=content_type)
|
|
1328
|
+
if method == "GET" and parsed.path == "/robots.txt":
|
|
1329
|
+
return self.respond("User-agent: *\nAllow: /\n", content_type="text/plain")
|
|
1330
|
+
with closing(connect_db(self.server.database)) as connection, connection:
|
|
1331
|
+
if text_mode and method == "GET":
|
|
1332
|
+
return self.handle_text(connection, parts, params)
|
|
1333
|
+
return self.handle_api(connection, parts, params, method)
|
|
1334
|
+
except PermissionError as error:
|
|
1335
|
+
return self.respond({"error": str(error)}, 403)
|
|
1336
|
+
except (ValueError, TypeError) as error:
|
|
1337
|
+
return self.respond(str(error) if text_mode else {"error": str(error)}, 400,
|
|
1338
|
+
"text/plain" if text_mode else "application/json")
|
|
1339
|
+
except LookupError as error:
|
|
1340
|
+
return self.respond(str(error) if text_mode else {"error": str(error)}, 404,
|
|
1341
|
+
"text/plain" if text_mode else "application/json")
|
|
1342
|
+
except FileExistsError as error:
|
|
1343
|
+
return self.respond(str(error) if text_mode else {"error": str(error)}, 409,
|
|
1344
|
+
"text/plain" if text_mode else "application/json")
|
|
1345
|
+
except Exception:
|
|
1346
|
+
logging.exception("Request failed")
|
|
1347
|
+
return self.respond({"error": "服务器内部错误"}, 500)
|
|
1348
|
+
|
|
1349
|
+
def do_GET(self):
|
|
1350
|
+
"""Handle read-only GET requests."""
|
|
1351
|
+
self.dispatch("GET")
|
|
1352
|
+
|
|
1353
|
+
def do_POST(self):
|
|
1354
|
+
"""Handle JSON mutations and explicit binary attachment uploads."""
|
|
1355
|
+
self.dispatch("POST")
|
|
1356
|
+
|
|
1357
|
+
def stream_updates(self):
|
|
1358
|
+
"""Stream invalidations without holding a database connection or exposing records."""
|
|
1359
|
+
self.close_connection = True
|
|
1360
|
+
self.connection.settimeout(5)
|
|
1361
|
+
with self.server.updates:
|
|
1362
|
+
version = self.server.update_version
|
|
1363
|
+
self.send_response(200)
|
|
1364
|
+
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
1365
|
+
self.send_header("Cache-Control", "no-store")
|
|
1366
|
+
self.send_header("X-Accel-Buffering", "no")
|
|
1367
|
+
self.end_headers()
|
|
1368
|
+
try:
|
|
1369
|
+
self.wfile.write(b"retry: 3000\nevent: ready\ndata: {}\n\n")
|
|
1370
|
+
self.wfile.flush()
|
|
1371
|
+
while True:
|
|
1372
|
+
with self.server.updates:
|
|
1373
|
+
self.server.updates.wait_for(
|
|
1374
|
+
lambda: self.server.update_version != version or self.server.updates_closed,
|
|
1375
|
+
timeout=UPDATE_HEARTBEAT_SECONDS)
|
|
1376
|
+
if self.server.updates_closed:
|
|
1377
|
+
return
|
|
1378
|
+
changed = self.server.update_version != version
|
|
1379
|
+
version = self.server.update_version
|
|
1380
|
+
self.wfile.write(b"event: change\ndata: {}\n\n" if changed else b": keepalive\n\n")
|
|
1381
|
+
self.wfile.flush()
|
|
1382
|
+
except OSError:
|
|
1383
|
+
pass
|
|
1384
|
+
|
|
1385
|
+
def do_OPTIONS(self):
|
|
1386
|
+
"""Handle preflight only for explicitly connected websites on the local bridge."""
|
|
1387
|
+
if urlsplit(self.path).path.startswith("/api/local/"):
|
|
1388
|
+
return self.dispatch("OPTIONS")
|
|
1389
|
+
return self.respond({"error": "地址不存在"}, 404)
|
|
1390
|
+
|
|
1391
|
+
def local_request_site(self, method):
|
|
1392
|
+
"""Authenticate a local page or a site-bound browser connection before reading configuration."""
|
|
1393
|
+
if not self.server.local_launch:
|
|
1394
|
+
raise LookupError("本机启动未启用,请使用 python server.py --local-launch")
|
|
1395
|
+
allowed_hosts = {f"127.0.0.1:{self.server.server_port}", f"localhost:{self.server.server_port}"}
|
|
1396
|
+
host = self.headers.get("Host", "")
|
|
1397
|
+
origin = self.headers.get("Origin")
|
|
1398
|
+
if self.client_address[0] != "127.0.0.1" or host not in allowed_hosts:
|
|
1399
|
+
raise PermissionError("本机启动只接受本机同源请求")
|
|
1400
|
+
if origin and origin != f"http://{host}":
|
|
1401
|
+
return self.connected_request_site(origin, method)
|
|
1402
|
+
if (method == "OPTIONS" or self.headers.get("X-CoDiscuss-Local") != "1"
|
|
1403
|
+
or self.headers.get("Sec-Fetch-Site", "same-origin") not in ("same-origin", "none")):
|
|
1404
|
+
raise PermissionError("本机启动只接受本机同源请求")
|
|
1405
|
+
return f"http://{host}"
|
|
1406
|
+
|
|
1407
|
+
def connected_request_site(self, origin, method):
|
|
1408
|
+
"""Require both an explicitly registered origin and its private connection credential."""
|
|
1409
|
+
path = self.server.connections_file
|
|
1410
|
+
settings = json.loads(path.read_text(encoding="utf-8")) if path and path.exists() else {}
|
|
1411
|
+
expected = settings.get("sites", {}).get(origin, {}).get("token_hash")
|
|
1412
|
+
if not isinstance(expected, str) or not expected:
|
|
1413
|
+
raise PermissionError("此网站尚未连接本机;请运行 codiscuss local connect 站点地址")
|
|
1414
|
+
self.bridge_origin = origin
|
|
1415
|
+
if method == "OPTIONS":
|
|
1416
|
+
headers = {value.strip().lower() for value in self.headers.get("Access-Control-Request-Headers", "").split(",")}
|
|
1417
|
+
allowed = {"content-type", "x-codiscuss-local", "x-codiscuss-connection"}
|
|
1418
|
+
if (self.headers.get("Access-Control-Request-Method") not in ("GET", "POST")
|
|
1419
|
+
or not {"x-codiscuss-local", "x-codiscuss-connection"}.issubset(headers) or headers - allowed):
|
|
1420
|
+
raise PermissionError("连接请求不受支持")
|
|
1421
|
+
return origin
|
|
1422
|
+
credential = self.headers.get("X-CoDiscuss-Connection", "")
|
|
1423
|
+
if (self.headers.get("X-CoDiscuss-Local") != "1" or not re.fullmatch(r"[a-f0-9]{64}", credential)
|
|
1424
|
+
or not hmac.compare_digest(token_hash(credential), expected)):
|
|
1425
|
+
raise PermissionError("本机连接已失效,请重新运行 codiscuss local connect 站点地址")
|
|
1426
|
+
return origin
|
|
1427
|
+
|
|
1428
|
+
def handle_local_launch(self, parts, method):
|
|
1429
|
+
"""Forward authenticated browser requests to the shared Node entrypoint and pin their website."""
|
|
1430
|
+
site = self.local_request_site(method)
|
|
1431
|
+
route = parts[2:]
|
|
1432
|
+
if method == "OPTIONS" and route in (["settings"], ["launch"], ["room-models"], ["room-member"], ["models"], ["api-key"], ["pick-directory"]):
|
|
1433
|
+
return self.respond({"ok": True})
|
|
1434
|
+
if route == ["settings"] and method in ("GET", "POST"):
|
|
1435
|
+
command = "settings"
|
|
1436
|
+
elif route == ["launch"] and method == "POST":
|
|
1437
|
+
command = "start"
|
|
1438
|
+
elif route == ["room-config"] and method == "POST" and not self.bridge_origin:
|
|
1439
|
+
command = "room-config"
|
|
1440
|
+
elif route == ["room-models"] and method == "POST":
|
|
1441
|
+
command = "room-models"
|
|
1442
|
+
elif route == ["models"] and method == "POST":
|
|
1443
|
+
command = "models"
|
|
1444
|
+
elif route == ["api-key"] and method == "POST":
|
|
1445
|
+
command = "api-key"
|
|
1446
|
+
elif route == ["room-member"] and method == "POST":
|
|
1447
|
+
command = "room-member"
|
|
1448
|
+
elif route == ["pick-directory"] and method == "POST":
|
|
1449
|
+
command = "pick-directory"
|
|
1450
|
+
else:
|
|
1451
|
+
raise LookupError("本机入口不存在")
|
|
1452
|
+
payload = None
|
|
1453
|
+
if method == "POST":
|
|
1454
|
+
if self.headers.get_content_type() != "application/json":
|
|
1455
|
+
raise ValueError("本机操作须使用 application/json")
|
|
1456
|
+
payload = self.read_payload()
|
|
1457
|
+
if command in ("start", "room-config", "room-models", "room-member"):
|
|
1458
|
+
payload.pop("access_file", None)
|
|
1459
|
+
payload.update(site=site, entry="web")
|
|
1460
|
+
if command == "room-config" and "participants" in payload:
|
|
1461
|
+
return self.save_local_room_configuration(command, payload)
|
|
1462
|
+
return self.run_local_command(command, payload)
|
|
1463
|
+
|
|
1464
|
+
def save_local_room_configuration(self, command, payload):
|
|
1465
|
+
"""Keep pause/revision validation and local file replacement ahead of any runtime write."""
|
|
1466
|
+
with closing(connect_db(self.server.database)) as connection, connection:
|
|
1467
|
+
begin_write(connection)
|
|
1468
|
+
room = require_room(connection, payload.get("room_id", ""))
|
|
1469
|
+
runtime = room["runtime"]
|
|
1470
|
+
if not runtime["protected"]:
|
|
1471
|
+
raise PermissionError("本场配置修正需要原控制凭据")
|
|
1472
|
+
require_control(connection, room["id"], payload.get("token", ""))
|
|
1473
|
+
if (room["archived"] or room["phase"] == "concluded" or runtime["mode"] != "automatic"
|
|
1474
|
+
or runtime["status"] != "paused" or runtime["revision"] != payload.get("runtime_revision")):
|
|
1475
|
+
raise FileExistsError("房间状态已变化,请暂停后重新读取本场配置")
|
|
1476
|
+
return self.run_local_command(command, payload, configuration_locked=True)
|
|
1477
|
+
|
|
1478
|
+
def run_local_command(self, command, payload, configuration_locked=False):
|
|
1479
|
+
"""Run the fixed local Node entrypoint after the same-origin request checks."""
|
|
1480
|
+
arguments = ["node", str(ROOT / "runner" / "discussion_launch.mjs"), command]
|
|
1481
|
+
if command in ("start", "room-config"):
|
|
1482
|
+
arguments.append("--browser")
|
|
1483
|
+
if configuration_locked:
|
|
1484
|
+
arguments.append("--configuration-locked")
|
|
1485
|
+
if payload is not None:
|
|
1486
|
+
arguments.append("--stdin")
|
|
1487
|
+
try:
|
|
1488
|
+
result = subprocess.run(arguments, input=json.dumps(payload) if payload is not None else None,
|
|
1489
|
+
capture_output=True, encoding="utf-8", timeout=DIRECTORY_PICKER_TIMEOUT if command == "pick-directory" else LOCAL_LAUNCH_TIMEOUT,
|
|
1490
|
+
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
|
|
1491
|
+
except FileNotFoundError as error:
|
|
1492
|
+
raise ValueError("本机启动需要 Node.js 24+,请安装并加入 PATH") from error
|
|
1493
|
+
except subprocess.TimeoutExpired as error:
|
|
1494
|
+
if command == "pick-directory":
|
|
1495
|
+
raise ValueError("目录选择窗口已超时,请重试或直接输入完整路径") from error
|
|
1496
|
+
raise ValueError("启动结果未确认,请核对房间状态后沿用原配置重试") from error
|
|
1497
|
+
if result.returncode:
|
|
1498
|
+
try:
|
|
1499
|
+
failure = json.loads(result.stderr.strip())
|
|
1500
|
+
except ValueError:
|
|
1501
|
+
raise ValueError(result.stderr.strip())
|
|
1502
|
+
return self.respond(failure, 400)
|
|
1503
|
+
return self.respond(json.loads(result.stdout))
|
|
1504
|
+
|
|
1505
|
+
def handle_api(self, connection, parts, params, method):
|
|
1506
|
+
"""Handle room, state, and message operations used by the web UI."""
|
|
1507
|
+
if parts == ["api", "health"] and method == "GET":
|
|
1508
|
+
return self.respond({"ok": True})
|
|
1509
|
+
if parts == ["api", "rooms"]:
|
|
1510
|
+
if method == "GET":
|
|
1511
|
+
return self.respond({**list_rooms(connection, params), "participants": PARTICIPANTS})
|
|
1512
|
+
room = create_room(connection, self.read_payload(), self.headers.get("X-Discussion-Token", ""))
|
|
1513
|
+
connection.commit()
|
|
1514
|
+
return self.respond(room, 200 if room["duplicate"] else 201)
|
|
1515
|
+
if parts == ["api", "folders"] and method == "POST":
|
|
1516
|
+
name = folder_name_value(self.read_payload())
|
|
1517
|
+
folder_id = uuid.uuid4().hex
|
|
1518
|
+
position = connection.execute("SELECT COALESCE(MAX(position),0)+1 FROM topic_folders").fetchone()[0]
|
|
1519
|
+
connection.execute("INSERT INTO topic_folders (id,name,position) VALUES (?,?,?)", (folder_id, name, position))
|
|
1520
|
+
connection.commit()
|
|
1521
|
+
return self.respond({"id": folder_id, "name": name, "position": position}, 201)
|
|
1522
|
+
if len(parts) == 4 and parts[:2] == ["api", "folders"] and parts[3] == "move" and method == "POST":
|
|
1523
|
+
result = move_folder(connection, parts[2], self.read_payload())
|
|
1524
|
+
connection.commit()
|
|
1525
|
+
return self.respond(result)
|
|
1526
|
+
if len(parts) == 4 and parts[:2] == ["api", "folders"] and parts[3] == "rename" and method == "POST":
|
|
1527
|
+
result = rename_folder(connection, parts[2], self.read_payload())
|
|
1528
|
+
connection.commit()
|
|
1529
|
+
return self.respond(result)
|
|
1530
|
+
if len(parts) == 4 and parts[:2] == ["api", "folders"] and parts[3] == "delete" and method == "POST":
|
|
1531
|
+
result = delete_folder(connection, parts[2])
|
|
1532
|
+
connection.commit()
|
|
1533
|
+
return self.respond(result)
|
|
1534
|
+
if len(parts) < 3 or parts[:2] != ["api", "rooms"]:
|
|
1535
|
+
raise LookupError("地址不存在")
|
|
1536
|
+
room_id = parts[2]
|
|
1537
|
+
token = self.headers.get("X-Discussion-Token", "")
|
|
1538
|
+
free = room_mode(connection, room_id) == "free"
|
|
1539
|
+
if free and parts[3:4] in (["inbox"], ["executions"], ["members"], ["suggestions"]):
|
|
1540
|
+
return self.handle_free_api(connection, parts, method)
|
|
1541
|
+
if parts[3:4] == ["attachments"]:
|
|
1542
|
+
return self.handle_attachments(connection, parts, params, method)
|
|
1543
|
+
if parts[3:4] in (["runtime"], ["events"]):
|
|
1544
|
+
if free and method == "POST":
|
|
1545
|
+
raise ValueError("自由房间不使用讨论运行控制")
|
|
1546
|
+
return self.handle_runtime_api(connection, parts, params, method)
|
|
1547
|
+
free_actor = None
|
|
1548
|
+
if method == "POST" and len(parts) == 4 and parts[3] in DIRECTORY_OPERATIONS:
|
|
1549
|
+
pass
|
|
1550
|
+
elif method == "POST" and free:
|
|
1551
|
+
free_actor = authorize_free_write(connection, room_id, token, parts[3] if len(parts) > 3 else "", self.read_payload())
|
|
1552
|
+
elif method == "POST":
|
|
1553
|
+
authorize_write(connection, room_id, token, parts[3] if len(parts) > 3 else "", self.read_payload())
|
|
1554
|
+
if parts[3:] == ["backup"] and method == "GET":
|
|
1555
|
+
return self.respond(room_backup(connection, room_id))
|
|
1556
|
+
if len(parts) == 4 and parts[3] in ("move", "folder", "archive", "restore", "delete") and method == "POST":
|
|
1557
|
+
files = connection.execute("SELECT id FROM attachments WHERE room_id=?", (room_id,)).fetchall() if parts[3] == "delete" else []
|
|
1558
|
+
result = manage_room(connection, room_id, parts[3], self.read_payload())
|
|
1559
|
+
connection.commit()
|
|
1560
|
+
for attachment in files:
|
|
1561
|
+
try:
|
|
1562
|
+
attachment_path(self.server.database, attachment).unlink(missing_ok=True)
|
|
1563
|
+
except OSError:
|
|
1564
|
+
logging.exception("Attachment cleanup failed: %s", attachment["id"])
|
|
1565
|
+
return self.respond(result)
|
|
1566
|
+
if len(parts) == 3 and method == "GET":
|
|
1567
|
+
return self.respond(require_room(connection, room_id))
|
|
1568
|
+
if parts[3:] == ["limits"]:
|
|
1569
|
+
if method == "GET":
|
|
1570
|
+
return self.respond(require_room(connection, room_id)["limits"])
|
|
1571
|
+
result = check_limits(connection, room_id, self.read_payload())
|
|
1572
|
+
connection.commit()
|
|
1573
|
+
return self.respond(result)
|
|
1574
|
+
if parts[3:] == ["floor"]:
|
|
1575
|
+
if method == "GET":
|
|
1576
|
+
return self.respond(require_room(connection, room_id)["floor"])
|
|
1577
|
+
result = update_floor(connection, room_id, self.read_payload())
|
|
1578
|
+
connection.commit()
|
|
1579
|
+
return self.respond(result)
|
|
1580
|
+
if parts[3:4] in (["topic"], ["turns"]):
|
|
1581
|
+
return self.handle_host_api(connection, parts, params, method)
|
|
1582
|
+
if parts[3:] == ["messages"]:
|
|
1583
|
+
if method == "GET":
|
|
1584
|
+
return self.respond(query_messages(connection, room_id, params))
|
|
1585
|
+
payload = self.read_payload()
|
|
1586
|
+
result = append_message(connection, room_id, payload, self.headers.get("X-Discussion-Token", ""), free_actor)
|
|
1587
|
+
connection.commit()
|
|
1588
|
+
return self.respond(result, 200 if result["duplicate"] else 201)
|
|
1589
|
+
if len(parts) == 5 and parts[3] == "messages" and method == "GET":
|
|
1590
|
+
return self.respond(get_message(connection, room_id, int(parts[4])))
|
|
1591
|
+
if parts[3:] == ["state"] and method == "POST":
|
|
1592
|
+
result = update_state(connection, room_id, self.read_payload())
|
|
1593
|
+
connection.commit()
|
|
1594
|
+
return self.respond(result)
|
|
1595
|
+
raise LookupError("地址不存在")
|
|
1596
|
+
|
|
1597
|
+
def handle_attachments(self, connection, parts, params, method):
|
|
1598
|
+
"""Handle bounded upload, discovery, metadata, and binary download."""
|
|
1599
|
+
room_id = parts[2]
|
|
1600
|
+
if len(parts) == 4:
|
|
1601
|
+
if method == "GET":
|
|
1602
|
+
return self.respond(query_attachments(connection, room_id, params))
|
|
1603
|
+
metadata = attachment_metadata(params)
|
|
1604
|
+
token = self.headers.get("X-Discussion-Token", "")
|
|
1605
|
+
if room_mode(connection, room_id) == "free":
|
|
1606
|
+
actor = authorize_free_write(connection, room_id, token, "attachments", {"author": metadata["actor"]})
|
|
1607
|
+
if (actor == "用户") != (metadata["actor"] == "用户"):
|
|
1608
|
+
raise PermissionError("附件上传者须与凭据身份一致")
|
|
1609
|
+
else:
|
|
1610
|
+
authorize_write(connection, room_id, token, "attachments", metadata)
|
|
1611
|
+
require_room(connection, room_id)
|
|
1612
|
+
length = int(self.headers.get("Content-Length", "-1"))
|
|
1613
|
+
if not 0 <= length <= MAX_ATTACHMENT_BYTES:
|
|
1614
|
+
return self.respond({"error": "单个附件最多 20 MiB,须提供 Content-Length"}, 413)
|
|
1615
|
+
data = self.rfile.read(length)
|
|
1616
|
+
if len(data) != length:
|
|
1617
|
+
raise ValueError("附件内容不完整")
|
|
1618
|
+
result = store_attachment(connection, self.server.database, room_id, metadata, data)
|
|
1619
|
+
return self.respond(result, 200 if result["duplicate"] else 201)
|
|
1620
|
+
if method == "GET" and len(parts) in (5, 6):
|
|
1621
|
+
attachment = get_attachment(connection, room_id, parts[4])
|
|
1622
|
+
if len(parts) == 5:
|
|
1623
|
+
return self.respond(attachment)
|
|
1624
|
+
if parts[5] == "content":
|
|
1625
|
+
return self.respond_attachment(attachment, params.get("download") == "1")
|
|
1626
|
+
raise LookupError("地址不存在")
|
|
1627
|
+
|
|
1628
|
+
def respond_attachment(self, attachment, download):
|
|
1629
|
+
"""Serve stored bytes with a safe disposition, never as executable HTML."""
|
|
1630
|
+
path = attachment_path(self.server.database, attachment)
|
|
1631
|
+
if not path.is_file():
|
|
1632
|
+
raise LookupError("附件原文件缺失")
|
|
1633
|
+
data = path.read_bytes()
|
|
1634
|
+
disposition = "attachment" if download or attachment["content_type"] == "application/octet-stream" else "inline"
|
|
1635
|
+
self.send_response(200)
|
|
1636
|
+
self.send_header("Content-Type", attachment["content_type"])
|
|
1637
|
+
self.send_header("Content-Length", str(len(data)))
|
|
1638
|
+
self.send_header("Content-Disposition", f"{disposition}; filename*=UTF-8''{quote(attachment['file_name'], safe='')}")
|
|
1639
|
+
self.send_header("X-Content-SHA256", attachment["sha256"])
|
|
1640
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
1641
|
+
self.send_header("Content-Security-Policy", "default-src 'none'; sandbox")
|
|
1642
|
+
self.send_header("Cache-Control", "no-store")
|
|
1643
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
1644
|
+
self.end_headers()
|
|
1645
|
+
self.wfile.write(data)
|
|
1646
|
+
|
|
1647
|
+
def handle_runtime_api(self, connection, parts, params, method):
|
|
1648
|
+
"""Expose explicit run controls and durable, capability-scoped executions."""
|
|
1649
|
+
room_id = parts[2]
|
|
1650
|
+
require_room(connection, room_id)
|
|
1651
|
+
token = self.headers.get("X-Discussion-Token", "")
|
|
1652
|
+
if parts[3:] == ["runtime"]:
|
|
1653
|
+
result = runtime_view(connection, room_id) if method == "GET" else configure_runtime(
|
|
1654
|
+
connection, room_id, token, self.read_payload())
|
|
1655
|
+
elif parts[3:] == ["events"] and method == "GET":
|
|
1656
|
+
result = list_events(connection, room_id, token, params.get("after", 0))
|
|
1657
|
+
elif len(parts) == 5 and parts[3] == "events" and method == "POST":
|
|
1658
|
+
result = event_action(connection, room_id, int(parts[4]), token, self.read_payload())
|
|
1659
|
+
else:
|
|
1660
|
+
raise LookupError("地址不存在")
|
|
1661
|
+
connection.commit()
|
|
1662
|
+
return self.respond(result)
|
|
1663
|
+
|
|
1664
|
+
def handle_free_api(self, connection, parts, method):
|
|
1665
|
+
"""Runner inbox, per-member executions and the owner's interrupt for free rooms."""
|
|
1666
|
+
room_id = parts[2]
|
|
1667
|
+
token = self.headers.get("X-Discussion-Token", "")
|
|
1668
|
+
if parts[3:] == ["inbox"] and method == "GET":
|
|
1669
|
+
result = inbox(connection, room_id, token)
|
|
1670
|
+
elif parts[3:] == ["suggestions"] and method == "POST":
|
|
1671
|
+
result = convert_suggestion(connection, room_id, token, self.read_payload())
|
|
1672
|
+
elif parts[3:] == ["executions"] and method == "POST":
|
|
1673
|
+
result = reserve_execution(connection, room_id, token, self.read_payload())
|
|
1674
|
+
elif len(parts) == 5 and parts[3] == "executions":
|
|
1675
|
+
execution_id = int(parts[4])
|
|
1676
|
+
result = (view_execution(connection, room_id, execution_id, token) if method == "GET"
|
|
1677
|
+
else execution_action(connection, room_id, execution_id, token, self.read_payload()))
|
|
1678
|
+
elif parts[3:] == ["members"] and method == "POST":
|
|
1679
|
+
result = add_member(connection, room_id, token, self.read_payload(), utc_now())
|
|
1680
|
+
elif len(parts) == 5 and parts[3] == "members" and method == "POST":
|
|
1681
|
+
result = member_action(connection, room_id, int(parts[4]), token, self.read_payload())
|
|
1682
|
+
elif len(parts) == 6 and parts[3] == "members" and parts[5] == "interrupt" and method == "POST":
|
|
1683
|
+
result = interrupt_member(connection, room_id, int(parts[4]), token)
|
|
1684
|
+
else:
|
|
1685
|
+
raise LookupError("地址不存在")
|
|
1686
|
+
connection.commit()
|
|
1687
|
+
return self.respond(result)
|
|
1688
|
+
|
|
1689
|
+
def handle_host_api(self, connection, parts, params, method):
|
|
1690
|
+
"""Route topic maintenance and sequential speaking assignments."""
|
|
1691
|
+
room_id = parts[2]
|
|
1692
|
+
status = 200
|
|
1693
|
+
if parts[3:] == ["topic"] and method == "POST":
|
|
1694
|
+
result = update_topic(connection, room_id, self.read_payload())
|
|
1695
|
+
elif parts[3:] == ["turns"]:
|
|
1696
|
+
if method == "GET":
|
|
1697
|
+
return self.respond(query_turns(connection, room_id, params))
|
|
1698
|
+
result = create_turn(connection, room_id, self.read_payload())
|
|
1699
|
+
status = 200 if result["duplicate"] else 201
|
|
1700
|
+
elif parts[3:] == ["turns", "continue"] and method == "POST":
|
|
1701
|
+
result = continue_turns(connection, room_id, self.read_payload())
|
|
1702
|
+
elif len(parts) == 5 and parts[3] == "turns":
|
|
1703
|
+
result = get_turn(connection, room_id, parts[4]) if method == "GET" else update_turn(connection, room_id, parts[4], self.read_payload())
|
|
1704
|
+
else:
|
|
1705
|
+
raise LookupError("地址不存在")
|
|
1706
|
+
connection.commit()
|
|
1707
|
+
return self.respond(result, status)
|
|
1708
|
+
|
|
1709
|
+
def handle_text(self, connection, parts, params):
|
|
1710
|
+
"""Expose read-only history and search without JavaScript or MCP."""
|
|
1711
|
+
if len(parts) < 2:
|
|
1712
|
+
raise LookupError("请指定房间")
|
|
1713
|
+
room_id = parts[1]
|
|
1714
|
+
if len(parts) == 2:
|
|
1715
|
+
body = room_text(connection, room_id, params)
|
|
1716
|
+
elif parts[2:] in (["brief"], ["state"], ["protocol"]):
|
|
1717
|
+
room = require_room(connection, room_id)
|
|
1718
|
+
body = brief_text(room) if parts[2] == "brief" else protocol_text(room)
|
|
1719
|
+
if parts[2] == "state":
|
|
1720
|
+
body = state_reading_text(room)
|
|
1721
|
+
elif parts[2:] == ["turns"]:
|
|
1722
|
+
result = query_turns(connection, room_id, params)
|
|
1723
|
+
room = require_room(connection, room_id)
|
|
1724
|
+
body = f"第 {result['page']} / {result['pages']} 页,共 {result['total']} 项。\n\n" + "\n\n".join(turn_text(turn, room) for turn in result["turns"])
|
|
1725
|
+
elif len(parts) == 4 and parts[2] == "turns":
|
|
1726
|
+
body = turn_text(get_turn(connection, room_id, parts[3]), require_room(connection, room_id))
|
|
1727
|
+
elif parts[2:] in (["messages"], ["search"]):
|
|
1728
|
+
result = query_messages(connection, room_id, params)
|
|
1729
|
+
items = [message_text(message) for message in result["messages"]]
|
|
1730
|
+
next_page = f"第 {result['page']} / {result['pages']} 页,共 {result['total']} 条。翻页:保留查询条件,修改 page 参数。"
|
|
1731
|
+
body = next_page + "\n\n" + "\n\n---\n\n".join(items or ["没有匹配的消息。"])
|
|
1732
|
+
elif len(parts) == 4 and parts[2] == "messages":
|
|
1733
|
+
body = message_text(get_message(connection, room_id, int(parts[3])))
|
|
1734
|
+
elif parts[2:] == ["publish"]:
|
|
1735
|
+
return self.respond("URL 发布入口已停用。在线 AI 只读;本地 CLI AI 请使用 POST 留言接口。", 410, "text/plain")
|
|
1736
|
+
else:
|
|
1737
|
+
raise LookupError("地址不存在")
|
|
1738
|
+
return self.respond(body, content_type="text/plain")
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
def main():
|
|
1742
|
+
"""Start the configurable local or server-hosted POC service."""
|
|
1743
|
+
parser = argparse.ArgumentParser(description="AI CoDiscuss POC")
|
|
1744
|
+
parser.add_argument("--host", default=os.getenv("HOST", "127.0.0.1"))
|
|
1745
|
+
parser.add_argument("--port", type=int, default=int(os.getenv("PORT", DEFAULT_PORT)))
|
|
1746
|
+
parser.add_argument("--database", default=os.getenv("DATABASE_PATH", str(ROOT / "data" / "discuss.sqlite3")))
|
|
1747
|
+
parser.add_argument("--no-demo", action="store_true", help="Do not seed sample data in a new database")
|
|
1748
|
+
parser.add_argument("--local-launch", action="store_true", help="Enable local AI launch and configuration (127.0.0.1 only)")
|
|
1749
|
+
args = parser.parse_args()
|
|
1750
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
|
|
1751
|
+
init_database(args.database, seed=not args.no_demo)
|
|
1752
|
+
server = DiscussionServer((args.host, args.port), args.database, local_launch=args.local_launch)
|
|
1753
|
+
logging.info("AI CoDiscuss ready at http://%s:%s", args.host, args.port)
|
|
1754
|
+
try:
|
|
1755
|
+
server.serve_forever()
|
|
1756
|
+
except KeyboardInterrupt:
|
|
1757
|
+
logging.info("Server stopped")
|
|
1758
|
+
finally:
|
|
1759
|
+
server.server_close()
|
|
1760
|
+
|
|
1761
|
+
|
|
1762
|
+
if __name__ == "__main__":
|
|
1763
|
+
main()
|