ai_co_discuss 0.2.14 → 0.2.15

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.
@@ -61,11 +61,12 @@ description: "AI CoDiscuss 的主持技能,仅主持人调用,且须同时
61
61
  | --- | --- |
62
62
  | 还需专家回应 | 先保存本次判断与下一步意图,再持有发言权提交下文的发言计划(`turn create --plan-file`);网站在专家间自动接力,末位答完才交回主持;接力不发通知,需要逐个转交的在线专家不放进计划。创建成功即结束本次执行。 |
63
63
  | 本轮仍有排队安排(`queued_turns` 非空) | 说明用户留言、改资料(`held_since` 非 0)或打断让本轮提前交回。先处理这些新内容;原计划仍适用就 `turn continue`,否则直接创建新安排,剩余排队随之作废。 |
64
+ | 专家名单有变化(用户邀请、请回、暂离或移除专家) | 以 `room view` 中最新的 `experts` 为准重排本轮:暂离或移除的专家不再安排,新加入或请回的专家先补独立观点,再进入交锋。`members` 中 `session_mode` 为 `borrowed` 的专家续接用户已有的会话,带着自己的历史上下文:请它先说明哪些判断来自原有上下文,而不是把它当作从零开始的独立观点。排队安排已随名单变化作废,不必取消。 |
64
65
  | 主持人需要以专家身份发言 | 给自己创建正式安排后结束;同一会话收到新的专家任务,再凭新资格发表 `opinion`。 |
65
66
  | 已可收束,或正在限时收束 | 先发表有原文依据的最终 `moderation`、保存 state 与简报,最后设为 `concluded`。 |
66
67
  | 缺少必要用户决定或无法继续 | 保留已有判断与具体阻碍,用 `execution fail` 暂停,向用户说明需要什么。 |
67
68
 
68
- **发言计划(默认做法)**:每次需要专家回应时,先写好发言计划再一次提交,不逐位安排。计划是 `[{"expert":"名字","question":"…"}]`,按顺序列出本轮每位应发言的专家各一次;固定轮流类任务(如依次报数)直接排满整个序列,最多 10 项。后一位的问题取决于前一位回答时也不必等前一位答完:把问题写成“先读取最新回答,再按……回应”,由专家自己读取。用户留言或打断时服务端会自动把控制权交回主持,不需要主持为此逐位把关。只有在线专家须逐个转交、或必须等用户先决定时,才只排一位,并在 state 里写明原因。创建安排或收束成功后立即结束,只回一句话,不再输出总结。
69
+ **发言计划(默认做法)**:每次需要专家回应时,先写好发言计划再一次提交,不逐位安排。计划是 `[{"expert":"名字","question":"…"}]`,按顺序列出本轮每位应发言的专家各一次,最多 10 项。计划只覆盖当前一轮,跨轮追问要看本轮回答后再定;只有各轮问题不依赖回答的固定流程(如依次报数)才可排满多轮。后一位的问题取决于前一位回答时也不必等前一位答完:把问题写成“先读取最新回答,再按……回应”,由专家自己读取。用户留言或打断时服务端会自动把控制权交回主持,不需要主持为此逐位把关。只有在线专家须逐个转交、或必须等用户先决定时,才只排一位,并在 state 里写明原因。创建安排或收束成功后立即结束,只回一句话,不再输出总结。
69
70
 
70
71
  创建安排、完成收束或执行失败会结束本次写入资格;此后只允许回读核验,不再补 state、回执或归还已交接的发言权。是否完成以网站业务结果为准;结果不明按参与技能核对原提交,不另创安排。
71
72
 
package/README.md CHANGED
@@ -65,13 +65,15 @@ codiscuss setup
65
65
 
66
66
  1. 安装完成后进入自动打开的网站;以后运行 `codiscuss local open` 打开或复用本机网站,默认地址为 `http://127.0.0.1:8765`。
67
67
  2. 点击“新建讨论”,填写**话题名称**和**用户原文**。把问题、背景与顾虑写在原文中,讨论目标、背景和边界由 AI 主持人据此补全。
68
- 3. 房间用法选择“头脑风暴”,接入方式默认“程序唤醒”,从卡片下方的“添加”行加入本场专家,点卡片上的皇冠指定 AI 主持人,点击“移出”调整名单。自动 CLI 卡片背面从真实软件目录下拉选择模型,并按模型支持的档位设置思考强度;留空使用 CLI 默认值,App 在原软件设置。项目文件只读 / 可写也在每张自动 CLI 卡片上分别设置(默认只读),在线专家始终只读;软件路径缺失时卡片背面直接提示填写。分别设置轮次和时长上限,点击数值展开滚轮或直接输入。新建默认 **3 轮 / 不限时长**,按轮次上限或手动停止结束;单次 AI 执行也默认不限时,可按需设置 1–60 分钟上限。
68
+ 3. 房间用法选择“讨论”,从卡片下方的“添加”行加入本场专家,点卡片上的皇冠指定 AI 主持人,点击“移出”调整名单。讨论全程由本机服务唤醒 AI,每张卡片可选 App、CLI 或 API;手动接入和网页 AI 只用于自由协作。自动 CLI 卡片背面从真实软件目录下拉选择模型,并按模型支持的档位设置思考强度;留空使用 CLI 默认值,App 在原软件设置。项目文件只读 / 可写也在每张自动 CLI 卡片上分别设置(默认只读);App 的文件权限在原软件设置,API 专家不访问项目文件;软件路径缺失时卡片背面直接提示填写。分别设置轮次和时长上限,点击数值展开滚轮或直接输入。新建默认 **3 轮 / 不限时长**,按轮次上限或手动停止结束;单次 AI 执行也默认不限时,可按需设置 1–60 分钟上限。
69
69
  4. “创建后立即启动 AI”默认已勾选。需要一起查看项目时填写项目目录,可直接输入完整路径,也可点输入框右侧的文件夹图标在本机弹窗选取。复用已有会话等高级覆盖在建房后的“接入”页设置。
70
70
  5. 点击“创建并启动讨论”。程序完成建房、登记与首次任务交付;在进度看板核对 AI 是否实际进入,并查看保存的发言。
71
71
 
72
- 只想让 AI 干活时,在第 3 步将房间用法改为“自由协作”,只需填写话题名称,任务在对话中随时补充:选择一位或多位本地 AI,默认由你主持,也可指定 AI 主持人;使用“程序唤醒”时点击“创建并连接”,之后在频道里直接发消息。多位时点名或在正文中 `@` 它们,处理中可随时停止。运行中可在右侧成员栏邀请、请离或请回 AI,指定主持人,或发起关联的头脑风暴。详见 [自由协作房间](docs/workflow.md#自由协作房间)。
72
+ 讨论进行中可在右侧进度看板邀请新专家(沿用本机软件配置);Codex、Claude 可填写已有会话 ID,请一个带着自己上下文的原会话加入,它工作时不要在原窗口继续操作。也可从成员的“…”菜单让其暂离、请回或移除。暂离保留位置与会话,不参与安排;这些操作不暂停讨论,主持人会被唤醒重新安排。主持人不能暂离,需先更换。
73
73
 
74
- **通过 API 接入**:在“程序唤醒”下添加 Codex(OpenAI)、Claude、Gemini、DeepSeek、Qwen 或 Grok,翻开卡片选择 API,填写并保存 API Key,选择或输入模型 ID。默认提供官方 Base URL,也可填写 OpenRouter 等兼容服务的地址并选择对应请求协议。Grok 始终显示,无需安装 Cursor 或 Grok Bot。API 可以担任主持人,与 CLI / App 专家混用,也可进入自由协作房间。API 与网页专家的项目权限固定只读,只有 CLI 专家可选择只读或写入;API 当前不访问本机项目文件,仍可保存讨论发言和主持安排。调用由本机服务发出,费用按所选 API 账号计费;密钥仅保存于本机。模型与配置说明见 [API 接入](docs/runner.md#api-接入)。
74
+ 只想让 AI 干活时,在第 3 步将房间用法改为“自由协作”,只需填写话题名称,任务在对话中随时补充:选择一位或多位 AI,并在每张卡片背面选择接入方式:本机唤醒(App / CLI / API)、手动接入(凭据交给你自己的会话)或网页代发(回答由你转发)。默认由你主持,也可指定 AI 主持人;有本机唤醒的成员时点击“创建并连接”,之后在频道里直接发消息。多位时点名或在正文中 `@` 它们,处理中可随时停止。运行中可在右侧成员栏邀请、请离或请回 AI,指定主持人,或发起关联的头脑风暴。详见 [自由协作房间](docs/workflow.md#自由协作房间)。
75
+
76
+ **通过 API 接入**:添加 Codex(OpenAI)、Claude、Gemini、DeepSeek、Qwen 或 Grok,翻开卡片选择 API,填写并保存 API Key,选择或输入模型 ID。默认提供官方 Base URL,也可填写 OpenRouter 等兼容服务的地址并选择对应请求协议。Grok 始终显示,无需安装 Cursor 或 Grok Bot。API 可以担任主持人,与 CLI / App 专家混用,也可进入自由协作房间。API 与网页专家不访问项目文件,只有 CLI 专家可选择只读或写入;API 仍可通过房间工具保存讨论发言和主持安排。调用由本机服务发出,费用按所选 API 账号计费;密钥仅保存于本机。模型与配置说明见 [API 接入](docs/runner.md#api-接入)。
75
77
 
76
78
  也可不勾选立即启动,先建房,再从“接入”页点击“启动讨论”。本机网页正常启动无需下载 JSON、手动登记房间或另执行首次启动命令。
77
79
 
@@ -113,7 +115,7 @@ codiscuss local connect https://你的站点
113
115
 
114
116
  软件失败时先查看暂停原因,登录和权限问题在对应软件中处理。本机网站的已登记房间,可从进度看板 Codex 旁的“…”菜单直接选择“切换 App / CLI”,在弹窗内暂停并保存;软件路径在“接入 → 修正本场软件连接”修改。原会话与讨论进度保留,待服务确认采用后重启;线上连接暂不提供此修正入口。切到 App 后模型和文件权限按 App 设置,切回 CLI 前须让 App 释放原聊天。本场修改不影响后续讨论的默认配置,详见 [配置修正](docs/runner.md#已启动后的配置修正)。
115
117
 
116
- **手动接入:** 先选“自由协作”或“头脑风暴”,再选手动接入(手动复制冷启动)。从“接入”页下载各本地成员自己的凭据文件,连同参与指令私下交给对应 AI 会话;建房无需本机接入服务,后续可由软件原生定时或人工触发,无需每轮复制本地 AI 的回答。在线专家只读,由主持人操作浏览器或由用户转交回答,首次确定分工后沿用。详见 [讨论工作流](docs/workflow.md)。
118
+ **手动接入与网页 AI:** 只用于“自由协作”,在成员卡片背面选择。手动成员从成员菜单下载自己的凭据文件,连同参与说明私下交给对应 AI 会话,由人工或软件原生定时触发;网页 AI 只能阅读房间,回答由你代发;它从自己的服务器读取阅读链接,只在本机运行的网站(`127.0.0.1`)它读不到,需由你粘贴材料。只有手动和网页成员时建房无需本机接入服务。讨论房间需要全程自动推进,暂不接受这两种成员。详见 [自由协作房间](docs/workflow.md#自由协作房间)。
117
119
 
118
120
  ## 在线部署
119
121
 
@@ -135,10 +137,10 @@ codiscuss local connect https://你的站点
135
137
 
136
138
  | 方式 | 由谁推进 | 前提 |
137
139
  | --- | --- | --- |
138
- | 自由协作 | 你或 AI 主持人点名成员,按成员接入方式处理 | 默认由你主持,可增设一位本地 AI 主持人;自动成员由本机服务唤醒,手动成员自行读取请求,在线成员回答由你代发 |
139
- | 事件驱动 | 本机服务按网站事件唤醒对应 AI 会话 | 选择“全自动讨论”,主持人与专家均为支持读写的本地 AI(CLI / App) |
140
- | 软件原生定时 | AI 软件定时检查当前安排 | 选择“手动复制冷启动”,软件具备原生定时能力 |
141
- | 人工触发 | 用户要求对应会话继续 | 选择“手动复制冷启动”;在线回答需代发 |
140
+ | 讨论 | 本机服务按网站事件唤醒主持人与专家 | 主持人与专家均由本机服务唤醒(CLI / App / API) |
141
+ | 自由协作 · 本机唤醒 | 你或 AI 主持人点名后,本机服务唤醒该成员 | 成员接入为 CLI / App / API |
142
+ | 自由协作 · 手动接入 | 成员自行读取请求,由软件原生定时或人工触发 | 成员持自己的凭据文件 |
143
+ | 自由协作 · 网页代发 | 你把阅读通知交给网页 AI,再代发回答 | 无需本地软件,不能自行发言 |
142
144
 
143
145
  默认上限为 **3 轮、不限时长、到限暂停**,可改为请主持人总结,并设置单次执行上限与收束时间。计数和停止规则见 [讨论上限](docs/workflow.md#讨论上限)。
144
146
 
@@ -673,6 +673,70 @@ def add_member(connection, room_id, token, payload, now):
673
673
  return free_view(connection, room_id)
674
674
 
675
675
 
676
+ def discussion_roster(connection, room_id):
677
+ """Return a discussion's host, active experts and phase; away experts keep only their member row."""
678
+ row = connection.execute("SELECT host,experts,phase FROM topic_settings WHERE room_id=?", (room_id,)).fetchone()
679
+ return row["host"], json.loads(row["experts"]), row["phase"]
680
+
681
+
682
+ def save_discussion_experts(connection, room_id, experts):
683
+ """Write the active roster; a running discussion keeps going and its host is asked to re-plan."""
684
+ connection.execute("UPDATE topic_settings SET experts=? WHERE room_id=?", (json.dumps(experts, ensure_ascii=False), room_id))
685
+
686
+
687
+ def add_discussion_member(connection, room_id, token, payload, now):
688
+ """Owner invites one expert into a discussion using the room's delivery method."""
689
+ require_owner(connection, room_id, token)
690
+ begin_write(connection)
691
+ member = member_value(payload)
692
+ _, experts, phase = discussion_roster(connection, room_id)
693
+ run = connection.execute("SELECT mode FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
694
+ if member["access"] != ("runner" if run and run["mode"] == "automatic" else "manual"):
695
+ raise ValueError("新专家的接入方式须与讨论的推进方式一致")
696
+ existing = connection.execute("SELECT * FROM room_members WHERE room_id=? AND name=? AND status<>'removed'", (room_id, member["name"])).fetchone()
697
+ if existing and existing["status"] == "active" and existing["token_hash"] == token_hash(member["token"]):
698
+ return
699
+ if existing:
700
+ raise FileExistsError(f"{member['name']} 已是本房间成员;离开的成员请用请回")
701
+ if phase == "concluded":
702
+ raise ValueError("讨论已收束,不能再邀请专家")
703
+ if len(experts) >= MAX_ROOM_AGENTS:
704
+ raise ValueError("房间成员已达上限")
705
+ require_fresh_member_token(connection, room_id, member["token"])
706
+ insert_member(connection, room_id, member, now)
707
+ save_discussion_experts(connection, room_id, {**experts, member["name"]: "cli"})
708
+
709
+
710
+ def discussion_member_action(connection, room_id, member_id, token, payload):
711
+ """Owner sends an expert away (keeping its seat), brings it back with a new credential, or removes it.
712
+
713
+ Leaving cancels the expert's open assignments; the host keeps its role and cannot leave.
714
+ """
715
+ member = owned_member(connection, room_id, member_id, token)
716
+ host, experts, _ = discussion_roster(connection, room_id)
717
+ action = payload.get("action")
718
+ if action in ("away", "remove"):
719
+ if member["name"] == host:
720
+ raise ValueError("主持人不能离开或移除;请先更换主持人")
721
+ connection.execute("UPDATE discussion_turns SET status='cancelled' WHERE room_id=? AND expert=? AND status IN ('pending','notified')",
722
+ (room_id, member["name"]))
723
+ connection.execute("UPDATE discussion_turns SET status='skipped' WHERE room_id=? AND expert=? AND status='queued'", (room_id, member["name"]))
724
+ connection.execute("UPDATE room_members SET status=? WHERE id=?", ("away" if action == "away" else "removed", member_id))
725
+ experts.pop(member["name"], None)
726
+ elif action == "active":
727
+ hashed = token_hash(payload.get("token"))
728
+ if member["status"] == "active" and hashed and hmac.compare_digest(hashed, member["token_hash"]):
729
+ return
730
+ if member["status"] != "away" or not hashed or hmac.compare_digest(hashed, member["token_hash"]):
731
+ raise ValueError("只有离开的成员可以请回,且须提供新的成员凭证")
732
+ require_fresh_member_token(connection, room_id, payload.get("token"))
733
+ connection.execute("UPDATE room_members SET status='active',token_hash=? WHERE id=?", (hashed, member_id))
734
+ experts[member["name"]] = "online" if member["access"] == "online" else "cli"
735
+ else:
736
+ raise ValueError("action 须为 away、active 或 remove")
737
+ save_discussion_experts(connection, room_id, experts)
738
+
739
+
676
740
  def require_fresh_member_token(connection, room_id, token):
677
741
  """Prevent owner or another member's credential from being reused for a new identity."""
678
742
  hashed = token_hash(token)
@@ -347,11 +347,15 @@ CREATE TABLE IF NOT EXISTS turn_relay (
347
347
  room_id TEXT PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
348
348
  held_since INTEGER NOT NULL DEFAULT 0
349
349
  );
350
- CREATE TRIGGER IF NOT EXISTS relay_turn_promoted AFTER UPDATE OF status ON discussion_turns
350
+ -- Both triggers below clear the hold only when no user message arrived after the running host execution began; the host cannot have read it.
351
+ DROP TRIGGER IF EXISTS relay_turn_promoted;
352
+ CREATE TRIGGER relay_turn_promoted AFTER UPDATE OF status ON discussion_turns
351
353
  WHEN OLD.status='queued' AND NEW.status='pending'
352
354
  BEGIN
353
355
  UPDATE room_floor SET holder=NULL,next_speaker=NEW.expert,revision=revision+1 WHERE room_id=NEW.room_id;
354
- UPDATE turn_relay SET held_since=0 WHERE room_id=NEW.room_id;
356
+ UPDATE turn_relay SET held_since=0 WHERE room_id=NEW.room_id
357
+ AND NOT EXISTS(SELECT 1 FROM messages m JOIN discussion_events e ON e.room_id=m.room_id AND e.role='host' AND e.status='started'
358
+ WHERE m.room_id=NEW.room_id AND m.author='用户' AND CAST(strftime('%s',m.created_at) AS INTEGER)>e.started_at);
355
359
  UPDATE discussion_events SET status='completed',result='turn:'||NEW.id
356
360
  WHERE room_id=NEW.room_id AND role='host' AND status IN ('pending','dispatched','started');
357
361
  INSERT OR IGNORE INTO discussion_events(room_id,run_id,generation,cause,participant,role,turn_id)
@@ -359,11 +363,14 @@ BEGIN
359
363
  FROM discussion_runtime WHERE room_id=NEW.room_id AND status='running';
360
364
  END;
361
365
  -- A new arrangement is the host's response: it replaces any older plan and clears the hold.
362
- CREATE TRIGGER IF NOT EXISTS relay_plan_replaced AFTER INSERT ON discussion_turns
366
+ DROP TRIGGER IF EXISTS relay_plan_replaced;
367
+ CREATE TRIGGER relay_plan_replaced AFTER INSERT ON discussion_turns
363
368
  WHEN NEW.status<>'queued'
364
369
  BEGIN
365
370
  UPDATE discussion_turns SET status='skipped' WHERE room_id=NEW.room_id AND status='queued' AND id<NEW.id;
366
- UPDATE turn_relay SET held_since=0 WHERE room_id=NEW.room_id;
371
+ UPDATE turn_relay SET held_since=0 WHERE room_id=NEW.room_id
372
+ AND NOT EXISTS(SELECT 1 FROM messages m JOIN discussion_events e ON e.room_id=m.room_id AND e.role='host' AND e.status='started'
373
+ WHERE m.room_id=NEW.room_id AND m.author='用户' AND CAST(strftime('%s',m.created_at) AS INTEGER)>e.started_at);
367
374
  END;
368
375
  CREATE TRIGGER IF NOT EXISTS relay_hold_user_message AFTER INSERT ON message_details
369
376
  WHEN EXISTS(SELECT 1 FROM messages WHERE id=NEW.message_id AND author='用户')
@@ -451,12 +458,24 @@ BEGIN
451
458
  UPDATE discussion_runtime SET status='stopped',generation=generation+1,revision=revision+1
452
459
  WHERE room_id=NEW.room_id;
453
460
  END;
454
- CREATE TRIGGER IF NOT EXISTS runtime_participants_changed AFTER UPDATE OF host,experts ON topic_settings
455
- WHEN NEW.host<>OLD.host OR NEW.experts<>OLD.experts
461
+ DROP TRIGGER IF EXISTS runtime_participants_changed;
462
+ -- A new host needs a new run. Experts joining or leaving keep the run going; the host is woken to re-plan.
463
+ CREATE TRIGGER runtime_participants_changed AFTER UPDATE OF host ON topic_settings
464
+ WHEN NEW.host<>OLD.host
456
465
  BEGIN
457
466
  UPDATE discussion_runtime SET status='paused',generation=generation+1,revision=revision+1
458
467
  WHERE room_id=NEW.room_id AND status='running';
459
468
  END;
469
+ DROP TRIGGER IF EXISTS runtime_experts_changed;
470
+ CREATE TRIGGER runtime_experts_changed AFTER UPDATE OF experts ON topic_settings
471
+ WHEN NEW.host=OLD.host AND NEW.experts<>OLD.experts
472
+ BEGIN
473
+ INSERT OR IGNORE INTO discussion_events(room_id,run_id,generation,cause,participant,role)
474
+ SELECT NEW.room_id,run_id,generation,'members:'||COALESCE((SELECT MAX(id) FROM discussion_events),0),NEW.host,'host'
475
+ FROM discussion_runtime WHERE room_id=NEW.room_id AND status='running'
476
+ AND NOT EXISTS(SELECT 1 FROM discussion_events WHERE room_id=NEW.room_id AND role='host' AND status IN ('pending','dispatched','started'))
477
+ AND NOT EXISTS(SELECT 1 FROM discussion_turns WHERE room_id=NEW.room_id AND status IN ('pending','notified'));
478
+ END;
460
479
  CREATE TRIGGER IF NOT EXISTS runtime_goal_changed AFTER UPDATE OF goal ON rooms
461
480
  WHEN NEW.goal<>OLD.goal
462
481
  BEGIN
package/backend/server.py CHANGED
@@ -22,7 +22,7 @@ from urllib.parse import parse_qs, quote, urlsplit
22
22
  from .free_room import (add_member, after_free_message, authorize_free_write, check_answer, check_proxy, execution_action, free_view,
23
23
  inbox, interrupt_member, member_action, member_values, migrate_free_tables, proxy_member, reserve_execution,
24
24
  discussion_members, room_mode, save_room_access, sync_discussion_members, view_execution)
25
- from .free_room import convert_suggestion, message_suggestions, save_suggestions
25
+ from .free_room import add_discussion_member, convert_suggestion, discussion_member_action, message_suggestions, save_suggestions
26
26
  from .identity import MAX_ROOM_AGENTS, PARTICIPANTS, is_ai, is_participant
27
27
  from .runtime import (authorize_write, begin_write, configure_runtime, event_action,
28
28
  LIMIT_DEFAULTS, LIMITS_SQL, limit_values, limits_view, require_within_limits,
@@ -387,6 +387,9 @@ def update_topic(connection, room_id, payload):
387
387
  if turn and turn["status"] in ("pending", "notified"):
388
388
  if settings["phase"] == "concluded" or turn["expert"] not in settings["experts"]:
389
389
  raise ValueError("请先完成或取消当前发言安排")
390
+ running = connection.execute("SELECT 1 FROM discussion_runtime WHERE room_id=? AND status='running'", (room_id,)).fetchone()
391
+ if running and settings["experts"] != room["experts"]:
392
+ raise ValueError("讨论运行中请在进度看板邀请、暂离或移除专家")
390
393
  connection.execute("UPDATE rooms SET title=?,goal=? WHERE id=?", (settings["title"], settings["goal"], room_id))
391
394
  sync_discussion_members(connection, room_id, payload, settings)
392
395
  save_topic_settings(connection, room_id, settings)
@@ -1130,6 +1133,8 @@ def room_text(connection, room_id, params):
1130
1133
  if room["mode"] == "free":
1131
1134
  members = ";".join(f"{item['name']}(已读至 #{item['read_until']},待投递 {item['pending']})" for item in room["free"]["members"])
1132
1135
  content.append(f"自由协作房间:无发言权、安排与轮次。成员:{members or '无'}。用户的消息点名成员后生成请求;成员回答须带本次执行编号,执行结束、被打断或过期后的回答会被拒绝,不能改作普通发言。")
1136
+ content.extend(["", "## 读取", f"完整历史(分页):{base}/messages?page=1", f"单条原文:{base}/messages/消息编号"])
1137
+ content.extend(participation_text(params, free=True))
1133
1138
  return "\n\n".join(content)
1134
1139
  turn = room["current_turn"]
1135
1140
  floor = room["floor"]
@@ -1154,20 +1159,26 @@ def room_text(connection, room_id, params):
1154
1159
  content.extend(["", "## 读取与检索", f"完整历史(分页):{base}/messages?page=1",
1155
1160
  f"单条原文:{base}/messages/消息编号", f"检索:{base}/search?q=关键词&page=1",
1156
1161
  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。")
1162
+ content.extend(participation_text(params))
1168
1163
  return "\n\n".join(content)
1169
1164
 
1170
1165
 
1166
+ def participation_text(params, free=False):
1167
+ """Local AIs follow the skills; web AIs read this short guide in place of them."""
1168
+ footer = "参与方式按运行环境区分;主持人可以兼任专家;作者、职责与来源尚未认证。"
1169
+ if params.get("mode") == "cli":
1170
+ return ["", "## 本地 AI", "本地 AI 请调用 discuss-participant(主持人另调用 discuss-host),用 codiscuss 命令读写;"
1171
+ "字段与写入规则以 codiscuss room protocol 为准,不直接调用网站接口。", footer]
1172
+ return ["", "## 在线 AI · 只读参与",
1173
+ "你是只读成员:读取议题、状态和与你相关的原文,在自己的聊天窗口回答;回答由用户代发留档。",
1174
+ "先回答真正的问题,给出理由、证据、适用条件与不确定性;区分事实、推断和建议,引用原文编号,不编造他人的回答。"
1175
+ "讨论初始阶段先独立判断,暂不读他人观点;深入讨论先理解对方主张及其最有力的依据,再说明同意、分歧或需要补的证据。"
1176
+ "可以坚持有依据的少数意见,也可因新证据修改判断并说明变化。",
1177
+ "输出:先 1–3 句摘要,再给完整正文。",
1178
+ "不要操作网页表单、调用写入接口或生成发布链接。读取失败时如实报告,由用户提供复制的材料。",
1179
+ *(["自由协作:只回应点名给你的消息(编号见阅读通知),按时间顺序处理,后面的消息可以修正前面的。"] if free else []), footer]
1180
+
1181
+
1171
1182
  def brief_text(room):
1172
1183
  """Render the detailed brief only when explicitly requested."""
1173
1184
  experts = "\n".join(f"- {name}:{'本地 CLI 读写' if mode == 'cli' else '在线只读'}" for name, mode in room["experts"].items())
@@ -1538,6 +1549,14 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1538
1549
  free = room_mode(connection, room_id) == "free"
1539
1550
  if free and parts[3:4] in (["inbox"], ["executions"], ["members"], ["suggestions"]):
1540
1551
  return self.handle_free_api(connection, parts, method)
1552
+ if parts[3:4] == ["members"] and method == "POST" and len(parts) in (4, 5):
1553
+ if len(parts) == 4:
1554
+ add_discussion_member(connection, room_id, token, self.read_payload(), utc_now())
1555
+ else:
1556
+ discussion_member_action(connection, room_id, int(parts[4]), token, self.read_payload())
1557
+ result = require_room(connection, room_id)
1558
+ connection.commit()
1559
+ return self.respond(result)
1541
1560
  if parts[3:4] == ["attachments"]:
1542
1561
  return self.handle_attachments(connection, parts, params, method)
1543
1562
  if parts[3:4] in (["runtime"], ["events"]):
@@ -150,9 +150,9 @@ Python 在一个只读事务快照中查询;D1 使用同一个 batch 事务读
150
150
 
151
151
  ### 主题与交互边界
152
152
 
153
- 新建表单独立选择房间用法(头脑风暴/自由协作)与接入方式(程序唤醒/手动),共用内联角色库。接入方式以分段切换放在参与区标题旁;每次添加生成独立实例,已加入成员以 `ExpertCard.vue` 渲染为翻面卡片:正面是身份与只读配置(状态、软件、模型、强度、项目权限),背面是设置,项目只读 / 可写按专家分别设置,在线专家固定只读,缺少的软件路径在背面直接填写;卡片上的皇冠指定主持人,移出按钮同步修复主持人选择,角色库为卡片下方的“添加”标签行。自由协作默认用户主持(“我来主持”),可改由一位本地 AI 主持。支持本地参与的卡片翻面设置参与能力和软件入口,纯网页角色不翻面。自动 CLI 从本机软件目录下拉选择模型,不要求手填 ID;模型与强度同层显示,恢复默认只清除当前实例覆盖。App、网页和手动会话在原软件内设置。`EffortSlider.vue` 按所选模型的实际档位显示,首档为空值的 CLI 默认,显示标签与提交值分离(Extra High 对应 xhigh);标题处的档位名随拖动实时预览、松手才提交,悬停、拖动与键盘聚焦分别有光晕反馈,减少动态效果时不做过渡;agy 的强度随模型,不另显示滑杆。目录不可用时显示原因并保留软件默认,不虚构列表。
153
+ 新建表单独立选择房间用法(头脑风暴/自由协作)与接入方式(程序唤醒/手动),共用内联角色库。接入方式以分段切换放在参与区标题旁;每次添加生成独立实例,已加入成员以 `ExpertCard.vue` 渲染为翻面卡片:正面是身份与只读配置(状态、软件、模型、强度、项目权限),背面是设置,项目只读 / 可写按专家分别设置,App 与手动会话显示“由软件设置”,API 与网页专家显示“不访问”,缺少的软件路径在背面直接填写;卡片上的皇冠指定主持人,移出按钮同步修复主持人选择,角色库为卡片下方的“添加”标签行。自由协作默认用户主持(“我来主持”),可改由一位本地 AI 主持。支持本地参与的卡片翻面设置参与能力和软件入口,纯网页角色不翻面。自动 CLI 从本机软件目录下拉选择模型,不要求手填 ID;模型与强度同层显示,恢复默认只清除当前实例覆盖。App、网页和手动会话在原软件内设置。`EffortSlider.vue` 按所选模型的实际档位显示,首档为空值的 CLI 默认,显示标签与提交值分离(Extra High 对应 xhigh);标题处的档位名随拖动实时预览、松手才提交,悬停、拖动与键盘聚焦分别有光晕反馈,减少动态效果时不做过渡;agy 的强度随模型,不另显示滑杆。目录不可用时显示原因并保留软件默认,不虚构列表。
154
154
 
155
- 项目权限选择仅对 CLI 入口开放;API 与网页专家固定显示只读。创建卡片、接入设置、经典主题和自由房间邀请沿用同一 CLI 入口判断,API 不继承本场可写配置,执行器强制归一为只读。App 的实际文件权限在原软件中配置。
155
+ 项目权限选择仅对 CLI 入口开放;API 与网页专家不访问项目文件,界面显示“不访问”。创建卡片、接入设置、经典主题和自由房间邀请沿用同一 CLI 入口判断,API 不继承本场可写配置,执行器强制归一为只读。App 的实际文件权限在原软件中配置。
156
156
 
157
157
  正反面占用同一网格单元,避免翻转引起布局跳动。角度由现有 Motion 弹簧驱动:质量 1、频率 2.615 Hz、阻尼比 0.96(刚度 270.032、阻尼 31.551);目标在 0° / 180° 之间切换,中途反向保留速度,无固定时长。隐藏面设为 inert 并退出辅助技术读取,转过 90° 后移交焦点;Escape 返回,减少动态效果时直接到位。滑块采用单位质量、4.5 Hz 临界阻尼弹簧,拖动由 6 Hz 手部弹簧牵引、8 Hz 端点力限制;松手依据真实位置与速度的投影选择档位,点击直接选档,方向键和 Home / End 覆盖全部档位,取消拖动恢复原选项。模型与强度仅在创建前配置,讨论进度中保持只读。
158
158
 
@@ -238,7 +238,7 @@ running 状态即使只有 pending 事件,也必须先暂停才能重新启动
238
238
  - **改动记录**:接入程序在执行前后对成员 cwd 做 Git 工作区快照(状态与内容哈希),执行结束且进程退出后用 `report {changes}` 记录一次。房间视图的 `free.conflicts` 列出不同成员在同一 cwd、时段重叠的执行均观察到变化的文件,只作“可能冲突”提示;非 Git 目录不记录。快照也会包含其他进程在同一目录的写入,不能确定修改者;分工修改不同文件仍可能出现提示,不代表已发生覆盖。
239
239
  - **回答**:接入程序投递的成员回答须带 `execution_id` 并使用成员凭证;消息保存、执行完成、请求完成和成员已读游标在同一事务中写入。执行结束、被打断或过期后的回答返回 409;缺少执行编号返回 400,不能改作普通发言。消息类型为 `chat`,不计入讨论轮次。
240
240
  - **打断**:`POST members/ID/interrupt`(控制凭据)作废该成员的进行中执行及所有未投递请求,不会自动重新开始;成员的已读游标不前移,下一次请求从同一位置读起。执行失败只影响该成员的请求,房间不暂停。
241
- - **讨论成员变更**:房主修改讨论名单或读写能力时,为新增或改变能力的成员提供新凭证,未变成员保留原凭证;删去的成员立即停用。成员主持不能自行扩大名单或更改主持职责。
241
+ - **讨论成员变更**:房主修改讨论名单或读写能力时,为新增或改变能力的成员提供新凭证,未变成员保留原凭证;删去的成员立即停用。成员主持不能自行扩大名单或更改主持职责。运行中名单只能经 `POST members`(邀请,接入方式须与推进方式一致)与 `POST members/ID {action: away|active|remove}` 变更,“编辑议题”改名单返回 400。暂离从 `experts` 中移出、成员行保持 `away`,同事务取消它进行中与排队的安排;请回须新凭证;主持人不能暂离或移除。`experts` 变化不再暂停运行:触发器 `runtime_experts_changed` 在无进行中安排和主持执行时为主持人生成 `members:` 事件,排队安排照常作废;只有更换主持人才暂停并换代。本机接入程序接受只新增参与者的配置变化,已绑定会话的条目仍须保持不变。
242
242
  - **范围**:成员名称是 AI 实例名(见下文“参与者身份”),一个房间最多 12 位 AI。早期创建的自由房间表在 Python 启动时补齐新列。Cloudflare Worker 实现同一协议:成员与执行状态的校验放在同一 D1 批次的断言中,校验后状态变化则整批回滚。备份只导出成员名称、状态与请求,不导出凭据哈希。
243
243
 
244
244
  ## 参与者身份