ai_co_discuss 0.3.18 → 0.3.19
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/agents/openai.yaml +4 -4
- package/.agents/skills/discuss-host/references/launch.md +2 -2
- package/.agents/skills/discuss-participant/SKILL.md +1 -1
- package/.agents/skills/discuss-participant/references/discussion.md +1 -1
- package/.agents/skills/discuss-participant/references/writing.md +2 -2
- package/backend/free_room.py +36 -297
- package/backend/migrations/drop_legacy_discussion.sql +107 -0
- package/backend/room_import.py +25 -51
- package/backend/runtime.py +10 -185
- package/backend/schema.sql +38 -254
- package/backend/server.py +208 -818
- package/backend/session_room.py +1153 -1154
- package/docs/architecture.md +1 -1
- package/docs/deployment.md +21 -2
- package/docs/en/architecture.md +1 -1
- package/docs/en/deployment.md +21 -2
- package/docs/en/workflow.md +101 -101
- package/docs/workflow.md +101 -101
- package/package.json +2 -1
- package/runner/adapters/antigravity_app.mjs +1 -2
- package/runner/adapters/antigravity_sidecar.mjs +0 -1
- package/runner/adapters/api.mjs +3 -3
- package/runner/discuss.py +13 -24
- package/runner/discussion_launch.mjs +17 -23
- package/runner/discussion_runner.mjs +9 -8
- package/runner/discussion_service.mjs +1 -6
- package/runner/free_runner.mjs +1 -1
- package/runner/session_driver.mjs +1 -1
- package/web/app.js +80 -493
- package/web/backup.js +2 -2
- package/web/index.html +6 -21
- package/web/modern.css +1 -1
- package/web/modern.js +2280 -2404
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
interface:
|
|
2
|
-
display_name: "讨论主持"
|
|
3
|
-
short_description: "主持人同时调用参与技能,围绕关键争点推进讨论并形成有依据的结论"
|
|
4
|
-
default_prompt: "使用 $discuss-host 和 $discuss-participant
|
|
1
|
+
interface:
|
|
2
|
+
display_name: "讨论主持"
|
|
3
|
+
short_description: "主持人同时调用参与技能,围绕关键争点推进讨论并形成有依据的结论"
|
|
4
|
+
default_prompt: "使用 $discuss-host 和 $discuss-participant,由当前会话主持这个话题,沿用用户选择的推进方式,组织观点交锋并形成有依据的结论,保留重要分歧。"
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## 定位与新建
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
当前实际会话承担主持,不另建调度聊天。沿用用户指定的房间和推进方式;新讨论尚未选择时,只确认“全自动讨论 / 手动复制冷启动”。入口与推进方式、CLI / App 客户端分别判断。
|
|
8
8
|
|
|
9
9
|
未指定房间时,保存目标、背景、参与配置和上限,核验后再接入专家。全自动使用下文启动入口,手动模式沿用 CLI 建房与接入流程。全自动要求主持和专家均具备本地 CLI 读写能力;配置完成后依据用户的启动指令运行,普通建房本身不代表已启动。在线只读专家使用手动接入分工,不静默转换模式。
|
|
10
10
|
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
|
|
49
49
|
此示例的主持在 Codex App;若当前是独立 Codex CLI,应使用 `codex_cli`。可执行文件、App 来源聊天或 Sidecar 等缺项仍须来自实际本机配置。主持也须出现在 `experts` 中并设为 `cli`。示例中的上限不能覆盖用户已经指定的参数;总时长为 0 时可不限总时长运行,按轮次上限或手动停止结束。单次执行默认 0(不限),主动设置的单次执行超时仍有效。只有用户另设本次时长时才提供顶层 `minutes`,不能用它延长房间既定截止。
|
|
50
50
|
|
|
51
|
-
已有房间改为指定顶层 `room_id`,省略 `room
|
|
51
|
+
已有房间改为指定顶层 `room_id`,省略 `room`,并提供原私有配置的 `access_file` 路径(含房间控制凭据),不读取或打印令牌。首次启动命令不替代已暂停房间的继续操作,已有运行按当前房间状态接续。
|
|
52
52
|
|
|
53
53
|
### 启动与交接
|
|
54
54
|
|
|
@@ -27,7 +27,7 @@ description: "AI CoDiscuss 的通用参与技能,所有参与者(包括主
|
|
|
27
27
|
1. **不读取或打印凭据内容**,token 不进入提示或房间。每个成员只用自己的凭据,AI 不借用用户身份发言,房主控制凭据不能代替成员署名。(身份与权限由网站按凭据校验。)
|
|
28
28
|
2. **执行失效后不再写入**:暂停、到期或被打断的旧任务保留草稿并结束,不换身份、职责或编号绕过拒绝,也不去掉 `--execution` 改作普通发言。任务文件的 `stopped_executions` 列出你此前被用户停止或跳过的执行:会话里如有它们未完成的工作,不接着做、不为它们补交,只处理本次请求。(失效的执行若还能产生可见结果,用户的打断与暂停就失去意义。)
|
|
29
29
|
3. **写入前定好 `--submission-id`**:同次重试沿用编号和原内容,新内容换新编号;结果不明时先按原编号查询,再按 [写入与恢复](references/writing.md) 处理。(网站靠它去重,换号会产生重复发言。)
|
|
30
|
-
4.
|
|
30
|
+
4. **不创建定时任务、不操作其他软件、不发状态回执**(就绪、开始、完成、停止等说明),除非用户要求或出现需要记录的异常;回执不能作为观点、完成或共识的证据。(这些动作有副作用,回执只会淹没真正的回答。)
|
|
31
31
|
|
|
32
32
|
## 命令入口与身份
|
|
33
33
|
|
|
@@ -38,6 +38,6 @@ codiscuss message post ROOM --as 本人 --kind chat --execution 执行编号 --b
|
|
|
38
38
|
codiscuss message post ROOM --as 本人 --kind chat --request 请求编号 --body-file draft.md --submission-id 新UUID --access-file FILE
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
需要摘要时一并使用 `--summary` 或 `--summary-file`。成功提交后当前请求即完成;不要再补一条普通 chat
|
|
41
|
+
需要摘要时一并使用 `--summary` 或 `--summary-file`。成功提交后当前请求即完成;不要再补一条普通 chat 或状态回执。
|
|
42
42
|
|
|
43
43
|
任务执行失败时,用 `execution fail ROOM 执行编号 --reason 原因 --access-file FILE` 失败本次执行并保留草稿。该操作只处理当前请求,不自动暂停整场讨论;主持人会收到专家请求失败并判断后续安排。不要改用其他成员、请求编号或身份绕过拒绝。
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
| 自由协作回答或讨论专家回答 | `message post --kind chat`;自动执行使用 `--execution 执行编号`,手动成员使用 `--request 请求编号` |
|
|
22
22
|
| 主持说明 | `message post --kind moderation`,仅主持人在允许的房间状态下使用;该消息本身不结束讨论 |
|
|
23
23
|
| 网页来源成员代发(自由协作或讨论) | 使用 `message post --kind chat --author 网页成员`,讨论回答同时用 `--request 请求编号` 关联安排 |
|
|
24
|
-
| 创建 / 维护话题 | `room create` / `room update
|
|
24
|
+
| 创建 / 维护话题 | `room create` / `room update`;成员请通过成员管理调整 |
|
|
25
25
|
| 安排发言 / 取消 | `turn create` / `turn cancel`;同房间只能有一个未完成安排 |
|
|
26
26
|
| 创建或继续安排 | `turn roster` 读取最新名单与 `revision`,随后 `turn create` 或 `turn continue` 携带该版本 |
|
|
27
27
|
| 保存摘要 | `state set`,`--until` 只覆盖实际已读消息 |
|
|
@@ -49,4 +49,4 @@
|
|
|
49
49
|
|
|
50
50
|
这些核对覆盖网站 HTTP 写入;浏览器向在线专家发送问题时,由主持人另行核对实际送达结果。
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
自动任务的状态以本次执行及服务端请求状态为准;不要创建原生定时任务或补写状态回执。影响协作的异常直接向用户报告。
|
package/backend/free_room.py
CHANGED
|
@@ -49,12 +49,8 @@ CHAIN_PROGRESS_SQL = """SELECT b.chain_id,COUNT(*) AS used,? + COALESCE((SELECT
|
|
|
49
49
|
GROUP BY b.chain_id ORDER BY b.chain_id DESC LIMIT 10"""
|
|
50
50
|
TIMELINE_STATES = {"completed": "done", "failed": "failed", "cancelled": "cancelled"}
|
|
51
51
|
TIMELINE_FREE_IDS = "SELECT id FROM member_executions WHERE room_id=?1 AND (?3=0 OR finished_at=0 OR finished_at>=?3) ORDER BY id DESC LIMIT ?2"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
ORDER BY e.id DESC LIMIT ?2"""
|
|
55
|
-
# Besides times: the task an execution took on (its request message, or the discussion turn's question), its result
|
|
56
|
-
# (the answer's summary, else the answer's opening), its latest progress note, how many files it changed (NULL when
|
|
57
|
-
# unobserved), its last output time (free executions only) and the discussion run it belongs to (discussion only).
|
|
52
|
+
# Besides times: the task an execution took on (its request message), its result (the answer's summary, else the
|
|
53
|
+
# answer's opening), its latest progress note, how many files it changed (NULL when unobserved) and its last output time.
|
|
58
54
|
TIMELINE_SQL = f"""SELECT x.*,(SELECT COALESCE(NULLIF(s.text,''),substr(a.text,1,200)) FROM messages a
|
|
59
55
|
LEFT JOIN message_summaries s ON s.message_id=a.id WHERE a.id=x.answer_id) AS result FROM (
|
|
60
56
|
SELECT 'free' AS source,e.id,m.name AS member,m.role,e.status,
|
|
@@ -67,16 +63,10 @@ TIMELINE_SQL = f"""SELECT x.*,(SELECT COALESCE(NULLIF(s.text,''),substr(a.text,1
|
|
|
67
63
|
(SELECT substr(q.text,1,160) FROM messages q WHERE q.id=(SELECT MIN(r.message_id) FROM member_requests r WHERE r.execution_id=e.id)) AS task,
|
|
68
64
|
(SELECT MAX(r.answer_id) FROM member_requests r WHERE r.execution_id=e.id) AS answer_id,
|
|
69
65
|
(SELECT n.text FROM execution_notes n WHERE n.execution_id=e.id ORDER BY n.seq DESC LIMIT 1) AS last_note,
|
|
70
|
-
CASE WHEN e.changes='' THEN NULL ELSE json_array_length(e.changes) END AS changes
|
|
71
|
-
FROM member_executions e JOIN room_members m ON m.id=e.member_id WHERE e.id IN ({TIMELINE_FREE_IDS})
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
0,NULL,(SELECT substr(q.question,1,160) FROM discussion_turns q WHERE q.id=e.turn_id),
|
|
75
|
-
CASE WHEN e.result LIKE 'message:%' THEN CAST(substr(e.result,9) AS INTEGER) END,NULL,NULL,e.run_id
|
|
76
|
-
FROM discussion_events e LEFT JOIN event_timings t ON t.event_id=e.id WHERE e.id IN ({TIMELINE_DISCUSSION_IDS})) x"""
|
|
77
|
-
TIMELINE_SEGMENTS_SQL = f"""SELECT source,execution_id,phase,tool,start_ms,end_ms FROM execution_segments WHERE room_id=?1 AND (
|
|
78
|
-
(source='free' AND execution_id IN ({TIMELINE_FREE_IDS})) OR (source='discussion' AND execution_id IN ({TIMELINE_DISCUSSION_IDS})))
|
|
79
|
-
ORDER BY source,execution_id,seq"""
|
|
66
|
+
CASE WHEN e.changes='' THEN NULL ELSE json_array_length(e.changes) END AS changes
|
|
67
|
+
FROM member_executions e JOIN room_members m ON m.id=e.member_id WHERE e.id IN ({TIMELINE_FREE_IDS})) x"""
|
|
68
|
+
TIMELINE_SEGMENTS_SQL = f"""SELECT source,execution_id,phase,tool,start_ms,end_ms FROM execution_segments WHERE room_id=?1
|
|
69
|
+
AND source='free' AND execution_id IN ({TIMELINE_FREE_IDS}) ORDER BY source,execution_id,seq"""
|
|
80
70
|
# Only the times of progress notes: the texts are read when the reader hovers one.
|
|
81
71
|
TIMELINE_NOTES_SQL = f"""SELECT execution_id,created_at FROM execution_notes
|
|
82
72
|
WHERE room_id=?1 AND execution_id IN ({TIMELINE_FREE_IDS}) ORDER BY execution_id,seq"""
|
|
@@ -102,6 +92,7 @@ MAX_RETRIES = 3
|
|
|
102
92
|
MAX_RETRY_REASON = 500
|
|
103
93
|
ACTIVE = ("dispatched", "started")
|
|
104
94
|
ACCESS_MODES = ("runner", "manual")
|
|
95
|
+
# A member given as `online` (older CLI room creation) becomes a manual member relayed from the web.
|
|
105
96
|
LEGACY_ACCESS_MODES = ("runner", "manual", "online")
|
|
106
97
|
SCHEDULER_PROVIDERS = ("jev", "decisions")
|
|
107
98
|
RECOMMEND_SECONDS = 60
|
|
@@ -159,10 +150,6 @@ def migrate_free_tables(connection):
|
|
|
159
150
|
connection.execute("""CREATE TABLE IF NOT EXISTS member_manual_sources (
|
|
160
151
|
member_id INTEGER PRIMARY KEY REFERENCES room_members(id) ON DELETE CASCADE,
|
|
161
152
|
source TEXT NOT NULL CHECK(source='web'))""")
|
|
162
|
-
connection.execute("""INSERT OR IGNORE INTO member_manual_sources(member_id,source)
|
|
163
|
-
SELECT id,'web' FROM room_members WHERE access='online'""")
|
|
164
|
-
connection.execute("UPDATE room_members SET access='manual' WHERE access='online'")
|
|
165
|
-
connection.execute("UPDATE room_modes SET mode='free' WHERE mode='brainstorm'")
|
|
166
153
|
migrate_stop_results(connection)
|
|
167
154
|
|
|
168
155
|
|
|
@@ -178,12 +165,6 @@ def migrate_stop_results(connection):
|
|
|
178
165
|
connection.execute("ALTER TABLE execution_capabilities_next RENAME TO execution_capabilities")
|
|
179
166
|
|
|
180
167
|
|
|
181
|
-
def room_mode(connection, room_id):
|
|
182
|
-
"""Return 'free' for free rooms; every legacy room is a brainstorm room."""
|
|
183
|
-
row = connection.execute("SELECT mode FROM room_modes WHERE room_id=?", (room_id,)).fetchone()
|
|
184
|
-
return row[0] if row else "brainstorm"
|
|
185
|
-
|
|
186
|
-
|
|
187
168
|
def review_member_revision(member, generation=0):
|
|
188
169
|
"""Bind an attestation to the exact member identity and access configuration without exposing credentials."""
|
|
189
170
|
state = ":".join(str(member[key]) for key in ("id", "status", "adapter", "access", "session_mode", "token_hash")) + f":{generation}"
|
|
@@ -458,7 +439,7 @@ def insert_member(connection, room_id, member, now):
|
|
|
458
439
|
connection.execute("""INSERT INTO room_members (room_id,name,adapter,token_hash,access,session_mode,created_at)
|
|
459
440
|
VALUES (?,?,?,?,?,?,?)""", (room_id, member["name"], member["adapter"], token_hash(member["token"]),
|
|
460
441
|
member["access"], member["session_mode"], now))
|
|
461
|
-
if member.get("manual_source") == "web"
|
|
442
|
+
if member.get("manual_source") == "web":
|
|
462
443
|
member_id = connection.execute("SELECT id FROM room_members WHERE room_id=? AND name=? AND token_hash=?",
|
|
463
444
|
(room_id, member["name"], token_hash(member["token"]))).fetchone()[0]
|
|
464
445
|
connection.execute("INSERT OR IGNORE INTO member_manual_sources(member_id,source) VALUES(?,'web')", (member_id,))
|
|
@@ -471,69 +452,12 @@ def save_room_access(connection, room_id, control_token, members, settings):
|
|
|
471
452
|
raise ValueError("建房须提供 32–256 字符的房间控制凭据")
|
|
472
453
|
if any(token_hash(member["token"]) == control for member in members):
|
|
473
454
|
raise ValueError("成员凭证不能与房主控制凭据相同")
|
|
474
|
-
connection.execute("INSERT INTO room_modes (room_id,mode,control_hash) VALUES (
|
|
455
|
+
connection.execute("INSERT INTO room_modes (room_id,mode,control_hash) VALUES (?,'free',?)", (room_id, control))
|
|
475
456
|
for member in members:
|
|
476
457
|
insert_member(connection, room_id, member, settings["created_at"])
|
|
477
458
|
connection.execute("UPDATE room_members SET role='host' WHERE room_id=? AND name=?", (room_id, settings["host"]))
|
|
478
459
|
|
|
479
460
|
|
|
480
|
-
def discussion_access_ok(access, capability, automatic, is_host, manual_source=""):
|
|
481
|
-
"""Require web relays to stay read-only while local participants use runner or manual access."""
|
|
482
|
-
if capability == "online":
|
|
483
|
-
return access == "manual" and manual_source == "web" and not is_host
|
|
484
|
-
if manual_source == "web":
|
|
485
|
-
return False
|
|
486
|
-
if not automatic:
|
|
487
|
-
return access == "manual"
|
|
488
|
-
return access == "runner" or (access == "manual" and not is_host)
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
def mark_manual(member, automatic):
|
|
492
|
-
"""A manual expert of a woken discussion carries adapter 'manual', so mode switches never turn it into a woken member."""
|
|
493
|
-
return {**member, "adapter": "manual"} if automatic and member["access"] == "manual" else member
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
def discussion_members(payload, settings):
|
|
497
|
-
"""Validate credentials against the room's declared participants and delivery mode."""
|
|
498
|
-
members = member_values(payload.get("members")) if settings["experts"] else []
|
|
499
|
-
if {member["name"] for member in members} != set(settings["experts"]):
|
|
500
|
-
raise ValueError("成员凭证名单须与参与者名单一致")
|
|
501
|
-
for member in members:
|
|
502
|
-
if not discussion_access_ok(member["access"], settings["experts"][member["name"]],
|
|
503
|
-
payload.get("runtime_mode") == "automatic", member["name"] == settings["host"],
|
|
504
|
-
member["manual_source"]):
|
|
505
|
-
raise ValueError("成员接入方式与房间参与能力、任务交付方式不符")
|
|
506
|
-
return [mark_manual(member, payload.get("runtime_mode") == "automatic") for member in members]
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
def sync_discussion_members(connection, room_id, payload, settings):
|
|
510
|
-
"""Keep existing credentials; require fresh credentials for added or changed members."""
|
|
511
|
-
current = {row["name"]: row for row in connection.execute("""SELECT m.*,
|
|
512
|
-
COALESCE(s.source,'') AS manual_source FROM room_members m
|
|
513
|
-
LEFT JOIN member_manual_sources s ON s.member_id=m.id WHERE m.room_id=? AND m.status='active'""", (room_id,))}
|
|
514
|
-
supplied = {member["name"]: member_value(member) for member in payload.get("members", [])}
|
|
515
|
-
run = connection.execute("SELECT mode FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
|
|
516
|
-
for name, capability in settings["experts"].items():
|
|
517
|
-
automatic = bool(run and run[0] == "automatic")
|
|
518
|
-
old = current.get(name)
|
|
519
|
-
if old and (old["manual_source"] == "web") == (capability == "online"):
|
|
520
|
-
if name in supplied and token_hash(supplied[name]["token"]) != old["token_hash"]:
|
|
521
|
-
raise FileExistsError("成员凭证与已登记身份不符;请核对原接入文件")
|
|
522
|
-
continue
|
|
523
|
-
if name not in supplied or not discussion_access_ok(supplied[name]["access"], capability, automatic,
|
|
524
|
-
name == settings["host"], supplied[name]["manual_source"]):
|
|
525
|
-
raise ValueError(f"新增或改变接入方式的成员 {name} 须提供独立的新凭证")
|
|
526
|
-
member = mark_manual(supplied[name], automatic)
|
|
527
|
-
hashed = token_hash(member["token"])
|
|
528
|
-
if connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND token_hash=? UNION ALL SELECT 1 FROM room_modes WHERE room_id=? AND control_hash=?", (room_id, hashed, room_id, hashed)).fetchone():
|
|
529
|
-
raise ValueError("新成员凭证不能复用已有凭证")
|
|
530
|
-
connection.execute("UPDATE room_members SET status='removed' WHERE room_id=? AND name=?", (room_id, name))
|
|
531
|
-
insert_member(connection, room_id, member, int(time.time()))
|
|
532
|
-
for name in current.keys() - settings["experts"].keys():
|
|
533
|
-
connection.execute("UPDATE room_members SET status='removed' WHERE room_id=? AND name=?", (room_id, name))
|
|
534
|
-
connection.execute("UPDATE room_members SET role=CASE WHEN name=? THEN 'host' ELSE 'member' END WHERE room_id=?", (settings["host"], room_id))
|
|
535
|
-
|
|
536
|
-
|
|
537
461
|
def sync_participants(connection, room_id):
|
|
538
462
|
"""Keep one active AI host, then mirror member capabilities and the host into shared room metadata."""
|
|
539
463
|
connection.execute(RELEASE_HOST, (room_id,))
|
|
@@ -660,10 +584,10 @@ def execution_notes(connection, room_id, execution_id, token=""):
|
|
|
660
584
|
|
|
661
585
|
|
|
662
586
|
def execution_timeline(connection, room_id, after=0):
|
|
663
|
-
"""Read the room's recent executions
|
|
587
|
+
"""Read the room's recent executions with their phase segments and note times, for the timeline page.
|
|
664
588
|
|
|
665
|
-
Times are epoch seconds and segments are millisecond offsets from `dispatched_at`.
|
|
666
|
-
execution that wrote the message it answers
|
|
589
|
+
Times are epoch seconds and segments are millisecond offsets from `dispatched_at`. An execution's parent is the
|
|
590
|
+
execution that wrote the message it answers. `state` is running, done, failed
|
|
667
591
|
or cancelled, so the page never interprets execution statuses itself. A positive `after` reads only executions
|
|
668
592
|
still running or ended since then, so a page following live work rereads little.
|
|
669
593
|
"""
|
|
@@ -680,7 +604,7 @@ def execution_timeline(connection, room_id, after=0):
|
|
|
680
604
|
for row in rows:
|
|
681
605
|
row["state"] = TIMELINE_STATES.get(row["status"], "running")
|
|
682
606
|
row["segments"] = segments.get((row["source"], row["id"]), [])
|
|
683
|
-
row["notes"] = notes.get(row["id"], [])
|
|
607
|
+
row["notes"] = notes.get(row["id"], [])
|
|
684
608
|
return {"now": int(time.time()), "after": params[2], "executions": rows, "execution_limit": TIMELINE_EXECUTIONS,
|
|
685
609
|
"segment_limit": MAX_EXECUTION_SEGMENTS}
|
|
686
610
|
|
|
@@ -906,20 +830,6 @@ def recipients(connection, room_id, payload):
|
|
|
906
830
|
return [(host["id"], "route")] if host else []
|
|
907
831
|
|
|
908
832
|
|
|
909
|
-
def route_held_messages(connection, room_id, host):
|
|
910
|
-
"""When a discussion ends, hand the user's messages its host never answered to the free room's recipients, as if just sent."""
|
|
911
|
-
held = connection.execute("SELECT held_since FROM turn_relay WHERE room_id=?", (room_id,)).fetchone()
|
|
912
|
-
if held and held[0]:
|
|
913
|
-
rows = connection.execute("""SELECT id FROM messages m WHERE room_id=? AND author='用户' AND id>=?
|
|
914
|
-
AND id>COALESCE((SELECT MAX(id) FROM messages WHERE room_id=? AND author=?),0)
|
|
915
|
-
AND NOT EXISTS(SELECT 1 FROM member_requests r WHERE r.message_id=m.id) ORDER BY id""",
|
|
916
|
-
(room_id, held[0], room_id, host)).fetchall()
|
|
917
|
-
for row in rows:
|
|
918
|
-
for member_id, purpose in recipients(connection, room_id, {}):
|
|
919
|
-
insert_request(connection, room_id, member_id, row["id"], row["id"], purpose, "user")
|
|
920
|
-
connection.execute("UPDATE turn_relay SET held_since=0 WHERE room_id=?", (room_id,))
|
|
921
|
-
|
|
922
|
-
|
|
923
833
|
def proxy_member(connection, room_id, author):
|
|
924
834
|
"""Return the active online member the owner may forward an answer for."""
|
|
925
835
|
row = connection.execute("""SELECT m.*,s.source AS manual_source FROM room_members m
|
|
@@ -940,8 +850,6 @@ def check_answer(connection, room_id, member, payload):
|
|
|
940
850
|
named_members(active_members(connection, room_id), payload["to"], exclude=member["name"])
|
|
941
851
|
if payload.get("handoff") not in (None, False) and (payload["handoff"] is not True or member["role"] != "host" or not payload.get("to")):
|
|
942
852
|
raise ValueError("handoff 只能由主持人与 to 一起使用,值为 true")
|
|
943
|
-
if member["access"] == "online":
|
|
944
|
-
raise PermissionError("在线成员只读,回答由用户代发")
|
|
945
853
|
if member["access"] == "manual":
|
|
946
854
|
request_ids = payload.get("request_ids", [])
|
|
947
855
|
if payload.get("execution_id") is not None or not isinstance(request_ids, list) or any(type(item) is not int for item in request_ids):
|
|
@@ -1216,46 +1124,33 @@ def default_order(connection, room_id, members):
|
|
|
1216
1124
|
|
|
1217
1125
|
|
|
1218
1126
|
def speaking_order(connection, room):
|
|
1219
|
-
"""Who spoke, who speaks now and who is next
|
|
1127
|
+
"""Who spoke, who speaks now and who is next; only the not-yet-spoken part may be reordered.
|
|
1220
1128
|
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
later rounds lists them apart in `later` (one name list per round), read-only. The
|
|
1129
|
+
Without an open discussion the room shows its latest unfinished one-after-another queue, else every member in the room's
|
|
1130
|
+
default order; an open discussion shows its open round: speakers so far, the current speaker, then this round's queued
|
|
1131
|
+
speakers; a plan reaching into later rounds lists them apart in `later` (one name list per round), read-only. The
|
|
1132
|
+
revision changes with any of it.
|
|
1224
1133
|
"""
|
|
1225
1134
|
active = [member for member in room["members"] if member["status"] == "active"]
|
|
1226
1135
|
by_id = {member["id"]: member for member in active}
|
|
1227
1136
|
ordered = [member["name"] for member in default_order(connection, room["id"], active)]
|
|
1228
1137
|
items, kind, target, later = [], "default", 0, []
|
|
1229
1138
|
session_plan_state = []
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
if session:
|
|
1233
|
-
kind = "turns"
|
|
1234
|
-
target = session["id"]
|
|
1235
|
-
items, later, session_plan_state = discussion.session_speaking_order(connection, session)
|
|
1236
|
-
else:
|
|
1237
|
-
target = connection.execute("""SELECT MAX(q.message_id) FROM free_queue q LEFT JOIN member_requests r ON r.message_id=q.message_id
|
|
1238
|
-
AND r.member_id=q.member_id WHERE q.room_id=? AND (r.id IS NULL OR r.status IN ('pending','delivering'))""", (room["id"],)).fetchone()[0] or 0
|
|
1239
|
-
if target:
|
|
1240
|
-
kind = "queue"
|
|
1241
|
-
for row in connection.execute("""SELECT q.member_id,r.status FROM free_queue q LEFT JOIN member_requests r
|
|
1242
|
-
ON r.message_id=q.message_id AND r.member_id=q.member_id WHERE q.message_id=? ORDER BY q.id""", (target,)):
|
|
1243
|
-
if row["member_id"] in by_id:
|
|
1244
|
-
state = "queued" if row["status"] is None else "speaking" if row["status"] in ("pending", "delivering") else "done"
|
|
1245
|
-
items.append({"name": by_id[row["member_id"]]["name"], "state": state})
|
|
1246
|
-
else:
|
|
1139
|
+
session = discussion.current_session(connection, room["id"], active_only=True)
|
|
1140
|
+
if session:
|
|
1247
1141
|
kind = "turns"
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1142
|
+
target = session["id"]
|
|
1143
|
+
items, later, session_plan_state = discussion.session_speaking_order(connection, session)
|
|
1144
|
+
else:
|
|
1145
|
+
target = connection.execute("""SELECT MAX(q.message_id) FROM free_queue q LEFT JOIN member_requests r ON r.message_id=q.message_id
|
|
1146
|
+
AND r.member_id=q.member_id WHERE q.room_id=? AND (r.id IS NULL OR r.status IN ('pending','delivering'))""", (room["id"],)).fetchone()[0] or 0
|
|
1147
|
+
if target:
|
|
1148
|
+
kind = "queue"
|
|
1149
|
+
for row in connection.execute("""SELECT q.member_id,r.status FROM free_queue q LEFT JOIN member_requests r
|
|
1150
|
+
ON r.message_id=q.message_id AND r.member_id=q.member_id WHERE q.message_id=? ORDER BY q.id""", (target,)):
|
|
1151
|
+
if row["member_id"] in by_id:
|
|
1152
|
+
state = "queued" if row["status"] is None else "speaking" if row["status"] in ("pending", "delivering") else "done"
|
|
1153
|
+
items.append({"name": by_id[row["member_id"]]["name"], "state": state})
|
|
1259
1154
|
listed = {item["name"] for item in items}
|
|
1260
1155
|
items += [{"name": name, "state": "idle"} for name in dict.fromkeys(ordered) if name not in listed]
|
|
1261
1156
|
movable = "queued" if kind != "default" else "idle"
|
|
@@ -1265,18 +1160,6 @@ def speaking_order(connection, room):
|
|
|
1265
1160
|
"revision": hashlib.sha256(state.encode("utf-8")).hexdigest()[:12]}
|
|
1266
1161
|
|
|
1267
1162
|
|
|
1268
|
-
def split_rounds(names, spoken):
|
|
1269
|
-
"""Cut a turn queue into rounds: an expert who already spoke in the round being filled starts the next one."""
|
|
1270
|
-
rounds, seen = [[]], set(spoken)
|
|
1271
|
-
for name in names:
|
|
1272
|
-
if name in seen:
|
|
1273
|
-
rounds.append([])
|
|
1274
|
-
seen = set()
|
|
1275
|
-
rounds[-1].append(name)
|
|
1276
|
-
seen.add(name)
|
|
1277
|
-
return rounds
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
1163
|
def reorder_speakers(connection, room, token, payload):
|
|
1281
1164
|
"""Owner reorders the not-yet-spoken part of the speaking order; who already spoke or speaks now stays put.
|
|
1282
1165
|
|
|
@@ -1302,34 +1185,12 @@ def reorder_speakers(connection, room, token, payload):
|
|
|
1302
1185
|
for name in order:
|
|
1303
1186
|
connection.execute("INSERT INTO free_queue (room_id,message_id,member_id) VALUES (?,?,?)", (room["id"], current["message_id"], names[name]))
|
|
1304
1187
|
elif current["kind"] == "turns":
|
|
1305
|
-
|
|
1306
|
-
if session:
|
|
1307
|
-
discussion.reorder_session_turns(connection, session, order, names)
|
|
1308
|
-
else:
|
|
1309
|
-
reorder_turns(connection, room["id"], order)
|
|
1188
|
+
discussion.reorder_session_turns(connection, discussion.current_session(connection, room["id"], active_only=True), order, names)
|
|
1310
1189
|
else:
|
|
1311
1190
|
connection.execute("INSERT OR REPLACE INTO speaking_orders (room_id,member_ids) VALUES (?,?)",
|
|
1312
1191
|
(room["id"], json.dumps([names[name] for name in order])))
|
|
1313
1192
|
|
|
1314
1193
|
|
|
1315
|
-
def reorder_turns(connection, room_id, order):
|
|
1316
|
-
"""Move this round's queued turns into the new order; ids keep the relay order, so each turn's content moves to the id at its new place.
|
|
1317
|
-
|
|
1318
|
-
This round's turns lead the queue, so they are its first rows; later rounds keep theirs.
|
|
1319
|
-
"""
|
|
1320
|
-
rows = [dict(row) for row in connection.execute("SELECT * FROM discussion_turns WHERE room_id=? AND status='queued' ORDER BY id LIMIT ?",
|
|
1321
|
-
(room_id, len(order)))]
|
|
1322
|
-
remaining, moved = list(rows), []
|
|
1323
|
-
for name in order:
|
|
1324
|
-
moved.append(remaining.pop(next(index for index, row in enumerate(remaining) if row["expert"] == name)))
|
|
1325
|
-
# Submission ids are unique, so free them before writing them to their new rows.
|
|
1326
|
-
for slot in rows:
|
|
1327
|
-
connection.execute("UPDATE discussion_turns SET submission_id=submission_id||':moving:'||id WHERE id=?", (slot["id"],))
|
|
1328
|
-
for slot, content in zip(rows, moved):
|
|
1329
|
-
connection.execute("UPDATE discussion_turns SET expert=?,question=?,submission_id=?,created_at=? WHERE id=?",
|
|
1330
|
-
(content["expert"], content["question"], content["submission_id"], content["created_at"], slot["id"]))
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
1194
|
def answered_chain(connection, payload, actor):
|
|
1334
1195
|
"""The chain an answer continues: the newest chain among the requests it completes."""
|
|
1335
1196
|
if actor["access"] == "manual":
|
|
@@ -1375,40 +1236,6 @@ def after_free_message(connection, room_id, message_id, actor, payload):
|
|
|
1375
1236
|
settle_batches(connection, room_id)
|
|
1376
1237
|
|
|
1377
1238
|
|
|
1378
|
-
def discussion_host_handles(connection, room):
|
|
1379
|
-
"""A running discussion already wakes its host for every user message, so a question to the host needs no request of its own."""
|
|
1380
|
-
return room["phase"] not in ("paused", "concluded") and bool(connection.execute(
|
|
1381
|
-
"SELECT 1 FROM discussion_runtime WHERE room_id=? AND status='running'", (room["id"],)).fetchone())
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
def checked_asks(connection, room, payload):
|
|
1385
|
-
"""Validate the user's direct questions to members of a discussion before the message is stored."""
|
|
1386
|
-
names = payload.get("to")
|
|
1387
|
-
if names is None:
|
|
1388
|
-
return []
|
|
1389
|
-
return named_members(active_members(connection, room["id"]), names)
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
def ask_members(connection, room, message_id, member_ids):
|
|
1393
|
-
"""Queue one 'ask' request per named member, outside the discussion's turns, rounds and limits."""
|
|
1394
|
-
host_busy = discussion_host_handles(connection, room)
|
|
1395
|
-
for member_id in member_ids:
|
|
1396
|
-
row = connection.execute("SELECT name FROM room_members WHERE id=?", (member_id,)).fetchone()
|
|
1397
|
-
if host_busy and row["name"] == room["host"]:
|
|
1398
|
-
continue
|
|
1399
|
-
insert_request(connection, room["id"], member_id, message_id, message_id, "ask", "user")
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
def asks_view(connection, room_id):
|
|
1403
|
-
"""Direct questions of a discussion room: recent requests with their member, for status badges beside messages."""
|
|
1404
|
-
names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
|
|
1405
|
-
requests = [{**dict(row), "member": names.get(row["member_id"], "")} for row in connection.execute(
|
|
1406
|
-
"""SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by,created_at,
|
|
1407
|
-
(SELECT reason FROM request_cancel_reasons c WHERE c.request_id=member_requests.id) AS cancel_reason FROM member_requests
|
|
1408
|
-
WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
|
|
1409
|
-
return {"requests": requests[::-1], "executions": recent_executions(connection, room_id)}
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
1239
|
def expire_executions(connection, room_id):
|
|
1413
1240
|
"""Fail executions whose lease lapsed; their requests stay visible as failed, never silently retried."""
|
|
1414
1241
|
now = int(time.time())
|
|
@@ -2001,8 +1828,6 @@ def owner_message(connection, room_id, message_id):
|
|
|
2001
1828
|
raise LookupError("消息不存在")
|
|
2002
1829
|
if row["author"] != "用户":
|
|
2003
1830
|
raise ValueError("只能撤回或编辑用户自己的消息")
|
|
2004
|
-
if room_mode(connection, room_id) != "free":
|
|
2005
|
-
raise ValueError("只有协作房间的消息可以撤回或编辑")
|
|
2006
1831
|
if connection.execute("SELECT 1 FROM room_directory WHERE room_id=? AND archived=1", (room_id,)).fetchone():
|
|
2007
1832
|
raise ValueError("房间已存档;恢复后才能撤回或编辑")
|
|
2008
1833
|
if discussion.blocks_message_change(connection, room_id, message_id):
|
|
@@ -2135,17 +1960,6 @@ def add_member(connection, room_id, token, payload, now):
|
|
|
2135
1960
|
return free_view(connection, room_id)
|
|
2136
1961
|
|
|
2137
1962
|
|
|
2138
|
-
def discussion_roster(connection, room_id):
|
|
2139
|
-
"""Return a discussion's host, active experts and phase; away experts keep only their member row."""
|
|
2140
|
-
row = connection.execute("SELECT host,experts,phase FROM topic_settings WHERE room_id=?", (room_id,)).fetchone()
|
|
2141
|
-
return row["host"], json.loads(row["experts"]), row["phase"]
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
def save_discussion_experts(connection, room_id, experts):
|
|
2145
|
-
"""Write the active roster; additions preserve its plan, other changes ask the host to re-plan."""
|
|
2146
|
-
connection.execute("UPDATE topic_settings SET experts=? WHERE room_id=?", (json.dumps(experts, ensure_ascii=False), room_id))
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
1963
|
def queue_invited_expert(connection, room_id, name, now):
|
|
2150
1964
|
"""Queue a new expert at the tail of an existing live plan, without changing the current speaker or hold.
|
|
2151
1965
|
|
|
@@ -2157,94 +1971,19 @@ def queue_invited_expert(connection, room_id, name, now):
|
|
|
2157
1971
|
session = discussion.current_session(connection, room_id)
|
|
2158
1972
|
if session and session["status"] != "concluded":
|
|
2159
1973
|
discussion.queue_session_invite(connection, session, member)
|
|
2160
|
-
return
|
|
2161
|
-
connection.execute("""INSERT OR IGNORE INTO discussion_turns (room_id,expert,question,status,submission_id,created_at)
|
|
2162
|
-
SELECT settings.room_id,member.name,
|
|
2163
|
-
CASE WHEN settings.phase='initial' THEN '欢迎加入:请先读取议题,独立给出你的开场观点。'
|
|
2164
|
-
ELSE '欢迎加入:请先读取议题和已有回答,给出你的观点。' END,
|
|
2165
|
-
'queued','invite:'||member.id,?
|
|
2166
|
-
FROM topic_settings AS settings JOIN room_members AS member ON member.room_id=settings.room_id
|
|
2167
|
-
WHERE settings.room_id=? AND member.name=? AND member.status='active'
|
|
2168
|
-
AND settings.phase NOT IN ('paused','concluded')
|
|
2169
|
-
AND EXISTS (SELECT 1 FROM discussion_turns WHERE room_id=settings.room_id AND status IN ('pending','notified','queued'))""",
|
|
2170
|
-
(now, room_id, name))
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
def add_discussion_member(connection, room_id, token, payload, now):
|
|
2174
|
-
"""Owner invites one expert into a discussion using the room's delivery method."""
|
|
2175
|
-
require_owner(connection, room_id, token)
|
|
2176
|
-
begin_write(connection)
|
|
2177
|
-
member = member_value(payload)
|
|
2178
|
-
_, experts, phase = discussion_roster(connection, room_id)
|
|
2179
|
-
run = connection.execute("SELECT mode FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
|
|
2180
|
-
if not discussion_access_ok(member["access"], "cli", bool(run and run["mode"] == "automatic"), False):
|
|
2181
|
-
raise ValueError("新专家的接入方式须与讨论的推进方式一致")
|
|
2182
|
-
existing = connection.execute("SELECT * FROM room_members WHERE room_id=? AND name=? AND status<>'removed'", (room_id, member["name"])).fetchone()
|
|
2183
|
-
if existing and existing["status"] == "active" and existing["token_hash"] == token_hash(member["token"]):
|
|
2184
|
-
return
|
|
2185
|
-
if existing:
|
|
2186
|
-
raise FileExistsError(f"{member['name']} 已是本房间成员;离开的成员请用请回")
|
|
2187
|
-
if phase == "concluded":
|
|
2188
|
-
raise ValueError("讨论已收束,不能再邀请专家")
|
|
2189
|
-
if len(experts) >= MAX_ROOM_AGENTS:
|
|
2190
|
-
raise ValueError("房间成员已达上限")
|
|
2191
|
-
require_fresh_member_token(connection, room_id, member["token"])
|
|
2192
|
-
insert_member(connection, room_id, mark_manual(member, bool(run and run["mode"] == "automatic")), now)
|
|
2193
|
-
save_discussion_experts(connection, room_id, {**experts, member["name"]: "cli"})
|
|
2194
|
-
queue_invited_expert(connection, room_id, member["name"], now)
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
def discussion_member_action(connection, room_id, member_id, token, payload):
|
|
2198
|
-
"""Owner sends an expert away (keeping its seat), brings it back with a new credential, or removes it.
|
|
2199
|
-
|
|
2200
|
-
Leaving cancels the expert's open assignments; the host keeps its role and cannot leave, and an
|
|
2201
|
-
leaving cancels its active plan and asks the host to re-plan without pausing the discussion.
|
|
2202
|
-
"""
|
|
2203
|
-
member = owned_member(connection, room_id, member_id, token)
|
|
2204
|
-
host, experts, phase = discussion_roster(connection, room_id)
|
|
2205
|
-
action = payload.get("action")
|
|
2206
|
-
if action in ("away", "remove"):
|
|
2207
|
-
if member["name"] == host:
|
|
2208
|
-
raise ValueError("主持人不能离开或移除;请先更换主持人")
|
|
2209
|
-
discussion.session_member_change(connection, room_id, member, action)
|
|
2210
|
-
connection.execute("UPDATE discussion_turns SET status='cancelled' WHERE room_id=? AND expert=? AND status IN ('pending','notified')",
|
|
2211
|
-
(room_id, member["name"]))
|
|
2212
|
-
connection.execute("UPDATE discussion_turns SET status='skipped' WHERE room_id=? AND expert=? AND status='queued'", (room_id, member["name"]))
|
|
2213
|
-
connection.execute("UPDATE room_members SET status=? WHERE id=?", ("away" if action == "away" else "removed", member_id))
|
|
2214
|
-
experts.pop(member["name"], None)
|
|
2215
|
-
elif action == "active":
|
|
2216
|
-
hashed = token_hash(payload.get("token"))
|
|
2217
|
-
if member["status"] == "active" and hashed and hmac.compare_digest(hashed, member["token_hash"]):
|
|
2218
|
-
return
|
|
2219
|
-
if member["status"] != "away" or not hashed or hmac.compare_digest(hashed, member["token_hash"]):
|
|
2220
|
-
raise ValueError("只有离开的成员可以请回,且须提供新的成员凭证")
|
|
2221
|
-
require_fresh_member_token(connection, room_id, payload.get("token"))
|
|
2222
|
-
connection.execute("UPDATE room_members SET status='active',token_hash=? WHERE id=?", (hashed, member_id))
|
|
2223
|
-
experts[member["name"]] = "online" if member["access"] == "online" else "cli"
|
|
2224
|
-
if discussion.current_session(connection, room_id, active_only=True):
|
|
2225
|
-
discussion.session_member_change(connection, room_id, member, action)
|
|
2226
|
-
else:
|
|
2227
|
-
queue_invited_expert(connection, room_id, member["name"], int(time.time()))
|
|
2228
|
-
elif action == "adopt":
|
|
2229
|
-
adopt_manual_member(connection, room_id, member, payload)
|
|
2230
|
-
return
|
|
2231
|
-
else:
|
|
2232
|
-
raise ValueError("action 须为 away、active、remove 或 adopt")
|
|
2233
|
-
save_discussion_experts(connection, room_id, experts)
|
|
2234
1974
|
|
|
2235
1975
|
|
|
2236
1976
|
def adopt_manual_member(connection, room_id, member, payload):
|
|
2237
1977
|
"""Hand a manual expert to the local service once its CLI reported the session it runs in; the owner's service resumes that session."""
|
|
2238
1978
|
reported = connection.execute("SELECT session_id FROM member_sessions WHERE member_id=?", (member["id"],)).fetchone()
|
|
2239
|
-
run = connection.execute("SELECT mode FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
|
|
2240
1979
|
if not registered_at(connection, member):
|
|
2241
1980
|
raise ValueError("成员尚未注册,不能转为托管")
|
|
2242
1981
|
adapter = payload.get("adapter")
|
|
2243
1982
|
if (not reported or reported["session_id"] != payload.get("session_id") or not isinstance(adapter, str)
|
|
2244
1983
|
or not ADAPTER_VALUE.fullmatch(adapter) or adapter == "manual"):
|
|
2245
1984
|
raise ValueError("成员尚未上报与之相符的会话,不能转为托管")
|
|
2246
|
-
if member["access"] != "manual" or member["status"] != "active"
|
|
2247
|
-
raise ValueError("
|
|
1985
|
+
if member["access"] != "manual" or member["status"] != "active":
|
|
1986
|
+
raise ValueError("只有在场的手动成员可以转为托管")
|
|
2248
1987
|
connection.execute("UPDATE room_members SET access='runner',adapter=?,session_mode='borrowed' WHERE id=?", (payload["adapter"], member["id"]))
|
|
2249
1988
|
connection.execute("DELETE FROM member_sessions WHERE member_id=?", (member["id"],))
|
|
2250
1989
|
# A free room's service answers as the member, so it takes over with a credential the owner's side generated.
|
|
@@ -2299,7 +2038,7 @@ def member_action(connection, room_id, member_id, token, payload):
|
|
|
2299
2038
|
adopt_manual_member(connection, room_id, member, payload)
|
|
2300
2039
|
elif action == "role":
|
|
2301
2040
|
role = payload.get("role")
|
|
2302
|
-
if role not in ("host", "member") or (role == "host" and
|
|
2041
|
+
if role not in ("host", "member") or (role == "host" and is_web_member):
|
|
2303
2042
|
raise ValueError("role 须为 host 或 member;在线成员不能担任主持人")
|
|
2304
2043
|
if role == "member" and member["role"] == "host":
|
|
2305
2044
|
raise ValueError("自由房间须由一位 AI 主持;请指定另一位成员担任主持人")
|