ai_co_discuss 0.2.41 → 0.2.43

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/backend/server.py CHANGED
@@ -7,6 +7,7 @@ import json
7
7
  import logging
8
8
  import os
9
9
  import re
10
+ import secrets
10
11
  import socket
11
12
  import sqlite3
12
13
  import subprocess
@@ -19,6 +20,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
19
20
  from pathlib import Path
20
21
  from urllib.parse import parse_qs, quote, urlsplit
21
22
 
23
+ from .admin import AdminError, admin_status, login_admin, logout_admin, register_admin, session_valid, update_profile
22
24
  from .free_room import (add_member, after_free_message, authorize_free_write, check_answer, check_proxy, execution_action, free_view,
23
25
  inbox, interrupt_member, member_action, member_values, migrate_free_tables, proxy_member, reserve_execution,
24
26
  discussion_members, room_mode, save_room_access, sync_discussion_members, view_execution)
@@ -42,6 +44,7 @@ MAX_FILENAME_LENGTH = 255
42
44
  LOCAL_LAUNCH_TIMEOUT = 60
43
45
  # Ordering and filing a channel only rearrange the shared directory, like folder edits, so they need no room credential.
44
46
  DIRECTORY_OPERATIONS = ("move", "folder")
47
+ ADMIN_OPERATIONS = ("archive", "restore", "delete")
45
48
  DIRECTORY_PICKER_TIMEOUT = 600 # the folder dialog waits for the person at the computer
46
49
  MAX_TEXT_LENGTH = 32000
47
50
  SESSION_ID_PATTERN = re.compile(r"[0-9A-Za-z_-]{8,128}")
@@ -427,6 +430,10 @@ def update_topic(connection, room_id, payload):
427
430
  if turn and turn["status"] in ("pending", "notified"):
428
431
  if settings["phase"] == "concluded" or turn["expert"] not in settings["experts"]:
429
432
  raise ValueError("请先完成或取消当前发言安排")
433
+ limits = room["limits"]
434
+ if settings["phase"] == "concluded" and room["phase"] != "concluded" and payload.get("actor") != "用户" \
435
+ and limits["reached"] and not limits["closing"] and limits["on_limit"] == "pause":
436
+ raise ValueError("已到讨论上限且到限方式为暂停保留现场,请先执行 room limits --check,不得直接收束")
430
437
  running = connection.execute("SELECT 1 FROM discussion_runtime WHERE room_id=? AND status='running'", (room_id,)).fetchone()
431
438
  if running and settings["experts"] != room["experts"]:
432
439
  raise ValueError("讨论运行中请在进度看板邀请、暂离或移除专家")
@@ -1340,6 +1347,8 @@ class DiscussionServer(ThreadingHTTPServer):
1340
1347
  if local_launch and address[0] != "127.0.0.1":
1341
1348
  raise ValueError("本机启动入口须绑定 127.0.0.1")
1342
1349
  self.local_launch = local_launch
1350
+ self.local_site = address[0] in ("127.0.0.1", "::1", "localhost")
1351
+ self.admin_setup_key = os.getenv("DISCUSS_ADMIN_SETUP_KEY") or secrets.token_urlsafe(18)
1343
1352
  service_home = os.getenv("DISCUSS_SERVICE_HOME")
1344
1353
  self.connections_file = Path(service_home) / "site-connections.json" if service_home else None
1345
1354
  super().__init__(address, DiscussionHandler)
@@ -1432,6 +1441,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1432
1441
  if text_mode and method == "GET":
1433
1442
  return self.handle_text(connection, parts, params)
1434
1443
  return self.handle_api(connection, parts, params, method)
1444
+ except AdminError as error:
1445
+ return self.respond({"error": str(error)}, error.status)
1435
1446
  except PermissionError as error:
1436
1447
  return self.respond({"error": str(error)}, 403)
1437
1448
  except (ValueError, TypeError) as error:
@@ -1608,10 +1619,43 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1608
1619
  return self.respond(failure, 400)
1609
1620
  return self.respond(json.loads(result.stdout))
1610
1621
 
1622
+ def admin_token(self):
1623
+ """The administrator session token supplied by the browser."""
1624
+ return self.headers.get("X-Discussion-Admin", "")
1625
+
1626
+ def admin_session(self, connection):
1627
+ """Whether the request is from the administrator: any visitor of a loopback-only site, else a valid session."""
1628
+ return self.server.local_site or session_valid(connection, self.admin_token())
1629
+
1630
+ def handle_admin_api(self, connection, parts, method):
1631
+ """Serve administrator registration, login sessions and profile."""
1632
+ token = self.admin_token()
1633
+ if parts == ["api", "admin"] and method == "GET":
1634
+ result = admin_status(connection, token, True, self.server.local_site)
1635
+ connection.commit()
1636
+ return self.respond(result)
1637
+ if len(parts) != 3 or method != "POST":
1638
+ raise LookupError("地址不存在")
1639
+ payload = self.read_payload()
1640
+ now = int(time.time())
1641
+ if self.server.local_site and parts[2] != "profile":
1642
+ raise ValueError("本地站点无需管理员账号")
1643
+ actions = {"register": lambda: register_admin(connection, payload, self.server.admin_setup_key, now),
1644
+ "login": lambda: login_admin(connection, payload, now),
1645
+ "logout": lambda: logout_admin(connection, token),
1646
+ "profile": lambda: update_profile(connection, token, payload, self.server.local_site)}
1647
+ if parts[2] not in actions:
1648
+ raise LookupError("地址不存在")
1649
+ result = actions[parts[2]]()
1650
+ connection.commit()
1651
+ return self.respond(result, 201 if parts[2] == "register" else 200)
1652
+
1611
1653
  def handle_api(self, connection, parts, params, method):
1612
1654
  """Handle room, state, and message operations used by the web UI."""
1613
1655
  if parts == ["api", "health"] and method == "GET":
1614
1656
  return self.respond({"ok": True})
1657
+ if parts[:2] == ["api", "admin"]:
1658
+ return self.handle_admin_api(connection, parts, method)
1615
1659
  if parts == ["api", "rooms"]:
1616
1660
  if method == "GET":
1617
1661
  return self.respond({**list_rooms(connection, params), "participants": PARTICIPANTS})
@@ -1672,7 +1716,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1672
1716
  raise ValueError("自由房间不使用讨论运行控制")
1673
1717
  return self.handle_runtime_api(connection, parts, params, method)
1674
1718
  free_actor = None
1675
- if method == "POST" and len(parts) == 4 and parts[3] in DIRECTORY_OPERATIONS:
1719
+ if method == "POST" and len(parts) == 4 and (parts[3] in DIRECTORY_OPERATIONS
1720
+ or parts[3] in ADMIN_OPERATIONS and self.admin_session(connection)):
1676
1721
  pass
1677
1722
  elif method == "POST" and free:
1678
1723
  free_actor = authorize_free_write(connection, room_id, token, parts[3] if len(parts) > 3 else "", self.read_payload())
@@ -1894,11 +1939,22 @@ def main():
1894
1939
  parser.add_argument("--database", default=os.getenv("DATABASE_PATH", str(ROOT / "data" / "discuss.sqlite3")))
1895
1940
  parser.add_argument("--no-demo", action="store_true", help="Do not seed sample data in a new database")
1896
1941
  parser.add_argument("--local-launch", action="store_true", help="Enable local AI launch and configuration (127.0.0.1 only)")
1942
+ parser.add_argument("--reset-admin", action="store_true", help="Delete the administrator account and sessions, then exit")
1897
1943
  args = parser.parse_args()
1898
1944
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
1899
1945
  init_database(args.database, seed=not args.no_demo)
1946
+ if args.reset_admin:
1947
+ with closing(connect_db(args.database)) as connection, connection:
1948
+ connection.execute("DELETE FROM admin_sessions")
1949
+ connection.execute("DELETE FROM site_admin")
1950
+ logging.info("管理员账号已清除;再次打开网站时可用安装令牌重新注册")
1951
+ return
1900
1952
  server = DiscussionServer((args.host, args.port), args.database, local_launch=args.local_launch)
1901
1953
  logging.info("AI CoDiscuss ready at http://%s:%s", args.host, args.port)
1954
+ with closing(connect_db(args.database)) as connection:
1955
+ if not server.local_site and connection.execute("SELECT 1 FROM site_admin").fetchone() is None:
1956
+ logging.info("尚未注册管理员账号;注册所需安装令牌:%s",
1957
+ "见环境变量 DISCUSS_ADMIN_SETUP_KEY" if os.getenv("DISCUSS_ADMIN_SETUP_KEY") else server.admin_setup_key)
1902
1958
  try:
1903
1959
  server.serve_forever()
1904
1960
  except KeyboardInterrupt:
@@ -153,6 +153,7 @@ Python 在一个只读事务快照中查询;D1 使用同一个 batch 事务读
153
153
  | `CreateDiscussion.vue`、`LimitPicker.vue`、`LaunchSettings.vue` | 新建讨论表单及本机启动配置;推进方式与参与专家分组,单选方式使用强调色卡片,参与者通过“邀请 AI”加入并独立移出;轮次和时长独立配置,以“不限”按钮切换各自上限,按需展开滚轮,选择即时生效,并预览到限条件;鼠标滚轮逐格切换且不产生惯性,鼠标及触摸拖动松手后保留惯性,先阻尼滑行再吸附预计停点,轻点中间数字精确输入;键盘聚焦和直接输入时仅高亮中间选中行,输入与显示共用行高,避免整列描边与原生输入框叠加;时长分小时/分钟两列,收起与摘要共用时长格式;支持键盘,减少动态效果时保留拖动惯性并去掉三维翻转 |
154
154
  | `ProgressBoard.vue`、`ProgressPanel.vue` | 共享进度看板及抽屉容器;Codex 参与者菜单内直接打开 App / CLI 切换弹窗,显式暂停后复用本场配置的读取与保存,不跳转接入页或自动重启 |
155
155
  | `FreeBoard.vue` | 自由协作房间的右侧看板,版式与讨论进度看板一致:房间状态与请求/执行计数、当前任务、成员(主持人居首,显示接入方式、模型与状态)、邀请、发起头脑风暴和每次执行用时 |
156
+ | `AccountSettings.vue` | 设置弹窗的管理员账号分区(注册、登录、改名与头像、退出登录) |
156
157
  | `use-discussion.js` | 将 `web/app.js` 的共享控制器状态接入 Vue |
157
158
  | `theme.css`、`channel.css` | 组件主题、深浅配色与响应式布局 |
158
159
  | `markdown.mjs`、`markdown.css`、`backup.mjs` | 三个主题共用的内容渲染与本地导出 |
@@ -195,6 +196,8 @@ Python 在一个只读事务快照中查询;D1 使用同一个 batch 事务读
195
196
 
196
197
  等待提示文字使用局部明暗流光,文字与卡片位置保持稳定,深浅主题继承原有文字颜色。流光采用 Feel 的 `calm, smooth` 参数(质量 1、刚度 78.167、阻尼 18.567),以恒定驱动力 `k / 8` 与阻尼的解析解推进;循环衔接时亮带完全位于文字之外,不使用固定动画时长。每帧只更新文字的 CSS 变量,不触发消息流重绘;卡片移除或开启“减少动态效果”时取消帧循环并恢复普通文字。
197
198
 
199
+ 管理员账号界面:页面启动及标签页重新可见时读取公开的 `GET /api/admin`,用返回的名称和头像覆盖「用户」身份(所有访客可见,无自定义时仍是 User 与指纹图标),登录、注册、退出、改资料后直接更新身份与快照,不刷新页面。会话令牌只存浏览器 `localStorage` 的 `discuss-admin-session`,由控制器附加到写请求的 `X-Discussion-Admin`,不进入视图快照;会话不过期,前端仅在服务端判定未登录时清除本地令牌。左下角用户块可点击打开「设置」:未注册显示“设置管理员账号”并给出注册表单(安装令牌来源提示;服务端未配置时说明并禁止提交),已注册未登录显示“未登录 · 点击登录”,已登录显示名称、头像(居中裁成 96×96 JPEG 数据 URL,超过 60000 字符则提示过大)、恢复默认头像与退出登录;忘记密码时本地版运行 `python server.py --reset-admin` 后重新注册,Cloudflare 版需清空管理员表。表单校验与服务端规则一致,错误行内展示服务端中文原文。本地站点(`GET /api/admin` 返回 `local: true`)不需要账号:不显示注册、登录、退出,不发送也不保存会话令牌,设置里只保留名称与头像编辑,左下角只显示网站连接状态。没有房间控制凭据且未登录时,存档、恢复、删除被拒会提示“需要登录管理员账号或使用创建该房间的浏览器”。
200
+
198
201
  ### 网页实时更新
199
202
 
200
203
  浏览器通过 `GET /api/updates` 订阅 SSE。房间、消息、主持安排、运行状态及目录的成功写入提交后发送 `change`,只含空对象,不携带正文、身份或执行凭据;普通执行续租不通知。页面在 100ms 内合并连续通知,复用已有接口读取当前房间和目录,消息仍按最后编号增量读取;请求期间的新通知会再触发一次读取,拖动目录时延后处理。筛选、未发送草稿和原有滚动跟随规则保留。
@@ -247,7 +250,7 @@ running 状态即使只有 pending 事件,也必须先暂停才能重新启动
247
250
 
248
251
  自由房间(`room_modes.mode='free'`)与讨论房间共用房间、成员登记、消息、附件与目录,没有发言权、安排、阶段推进和轮次。没有控制凭据登记的旧房间只供阅读,不支持通过首次运行配置认领;本次不迁移历史房间。
249
252
 
250
- - **凭据**:建房时由客户端提供房间控制凭据和每位成员的成员凭证,服务端只存哈希。用户身份的房间写入(发言、附件、存档、恢复、删除、打断)都须控制凭据;房间排序和归入文件夹只改共享目录,与站点级文件夹操作一样不需要凭据;无凭据、错误凭据、成员凭证或他房凭据一律只读。成员凭证只能以该成员身份发言或上传附件。声明字段(`author`、`source`、localhost)不代替凭据。
253
+ - **凭据**:建房时由客户端提供房间控制凭据和每位成员的成员凭证,服务端只存哈希。用户身份的房间写入(发言、附件、存档、恢复、删除、打断)都须控制凭据(存档、恢复、删除也可凭管理员会话);房间排序和归入文件夹只改共享目录,与站点级文件夹操作一样不需要凭据;无凭据、错误凭据、成员凭证或他房凭据一律只读。**站点管理员**:绑定 127.0.0.1 的本地站点没有账号,访问者即管理员(存档、恢复、删除免凭据,名称与头像存在 `site_admin`,密码哈希为不可登录的占位值);其余部署需要账号:单个账号(`site_admin`,用户名+PBKDF2 密码哈希,会话令牌只存哈希于 `admin_sessions`,登录后一直有效直至退出)。首次注册须提供安装令牌(本地版读 `DISCUSS_ADMIN_SETUP_KEY`,未设置则启动时随机生成并写日志;Worker 读机密 `ADMIN_SETUP_KEY`,未配置则不能注册);连续 5 次登录失败锁定 5 分钟。请求头 `X-Discussion-Admin` 携带有效会话时,存档、恢复、永久删除(仍须确认房间名)免房间凭据;其余写入不变。`GET /api/admin` 公开,返回名称与头像供所有访客显示。接口为 `/api/admin` 下的 `register`、`login`、`logout`、`profile`。忘记密码:本地版 `python server.py --reset-admin`,Cloudflare 版执行 `DELETE FROM admin_sessions; DELETE FROM site_admin;`,之后重新注册。成员凭证只能以该成员身份发言或上传附件。声明字段(`author`、`source`、localhost)不代替凭据。
251
254
  - **成员**:`room_members` 以 `id` 为稳定主键,`access` 为 `runner`(接入程序投递)、`manual`(成员自行读写)或 `online`(只读、原文代发),`session_mode` 标记是否借用用户正在使用的会话。房主用控制凭据 `POST members` 运行中加入成员,`POST members/ID {action}` 让成员离开(`away`,作废其进行中执行与未投递请求)、请回(`active`,必须提供不同于原凭证的新凭证,旧凭证副本不再有效)或移除(`remove`,保留历史发言)。同一成员凭证不能复用于其他成员或房主;相同凭证与配置的邀请、请回重试返回已有结果。会话续接由本机接入程序负责,服务端不保存会话绑定;唯一例外是手动成员上报的会话:带凭据的请求可带请求头 `X-Discussion-Session` 与 `X-Discussion-Session-Client`,`message post` 也可在正文带 `session_id` 与 `session_client`(CLI 读取 `CLAUDE_CODE_SESSION_ID` 或 `CODEX_THREAD_ID`,取值 `claude` / `codex`),服务端在 `member_sessions` 保存该手动成员最近上报的会话,成员视图以 `reported_session` 暴露。房主用 `POST members/ID {action:"adopt", adapter:"claude", session_id}`(须与上报一致、仅全自动讨论中在场的手动成员)把成员改为 `runner`、`borrowed` 并清除上报,本机接入程序据此接管。两套后端行为一致。
252
255
  - **手动成员注册**:`member_registrations`(`member_id`、`room_id`、`token_hash`、`registered_at`)记录手动成员自己发起的注册,`POST /api/rooms/ID/register` 只接受该成员自己的凭证(房主凭据与他人凭证返回 403),重复调用保留首次时间。记录绑定注册时的凭证哈希:请回换发新凭证后旧记录不再匹配,视图中的 `registered_at` 回到空串,须重新注册。会话 ID 不属于注册内容,仍由 CLI 头部采集进 `member_sessions`;`adopt` 要求已注册。讨论房间的成员视图与自由房间 `free.members` 都带 `registered_at`;新表为独立表,D1 重新应用 schema 即可。
253
256
  - **请求**:用户消息按结构化接收者 `to`(成员名)为每位成员生成一条请求;省略且仅有一位在场成员时默认点名该成员,多位成员时交给主持人(`route`),只有在线成员而没有主持人时只作留言,不广播。网页只从正文中代码与引用之外的 `@成员` 生成 `to`,没有 @ 时省略 `to`。请求状态为 `pending`(待投递)→ `delivering`(处理中)→ `completed` / `cancelled` / `failed`。
@@ -26,6 +26,8 @@ python server.py --host 127.0.0.1 --port 8765 --no-demo
26
26
 
27
27
  命令行参数优先于环境变量。使用独立测试库可加 `--database data/test.sqlite3`。停止后重新启动沿用原库;`--no-demo` 不清理已有数据。长期部署应使用自己的进程管理和反向代理提供 HTTPS,按需要限制访问与请求规模。
28
28
 
29
+ 管理员账号:绑定 127.0.0.1 时无需账号,访问者即管理员,可存档、恢复、永久删除任意房间。绑定其他地址(如 `--host 0.0.0.0`)时须注册:首次打开网站在左下角注册,需要安装令牌。环境变量 `DISCUSS_ADMIN_SETUP_KEY` 可固定令牌,未设置时每次启动随机生成并写入启动日志(仅在尚未注册时打印)。注册后可存档、恢复、永久删除任意房间;忘记密码运行 `python server.py --reset-admin`(不停服务也可执行)后重新注册。
30
+
29
31
  网页 SSE 推送在 Python 服务中默认启用,无需额外进程或数据库迁移。反向代理应关闭 `/api/updates` 的响应缓冲并允许长连接;服务每 15 秒发送心跳。页面隐藏时断开,重新打开后补取最新数据;代理不支持推送时回退轮询。
30
32
 
31
33
  附件保存在数据库旁的 `<数据库文件名>.attachments/`,例如 `data/discuss.sqlite3.attachments/`;Docker 中同样位于数据卷内。备份时先停止对应写入进程,再一并复制数据库和附件目录;只备份 SQLite 不包含原文件。无附件时也可用 SQLite 的在线备份工具生成一致快照,不要仅复制正在写入的主文件而遗漏 WAL。恢复前保留原库和附件目录,再用 `--database` 指向恢复副本核验。
@@ -118,6 +120,8 @@ npx wrangler d1 execute DB --remote --config .wrangler/deploy/wrangler.jsonc --f
118
120
  npm run deploy -- --cwd .wrangler/deploy
119
121
  ```
120
122
 
123
+ 管理员注册:Cloudflare 站点须先设置安装令牌机密,未设置则无法注册管理员:`npx wrangler pages secret put ADMIN_SETUP_KEY --project-name YOUR_PROJECT_NAME`。忘记密码时在 D1 执行 `DELETE FROM admin_sessions; DELETE FROM site_admin;` 后重新注册。
124
+
121
125
  第一次对新库执行 schema;升级已有站点前先备份并在本地副本核验迁移。当前 schema 以增量表、索引和触发器扩展数据结构,重复执行不应清空已有记录或重置发言权;仍需按实际升级版本测试。
122
126
 
123
127
  `deploy` 会先构建,再将 `dist/pages/` 发布到 `main`。配置为真值前不要直接运行根目录默认部署。构建本身不发布、不改远端数据库。Git 集成部署需自行将实际 D1 配置提供给构建环境,不能直接用公开模板当作生产配置。
package/docs/runner.md CHANGED
@@ -324,7 +324,7 @@ Codex 要求 CLI 0.163.0 或更新版本,使用 permission profiles 和本地
324
324
 
325
325
  新建房间面板始终显示“智能调度”,可选供应商(TypeSafe Jev 的 `POST /v1/systemone`,或 OpenAI Decisions 的 `POST /v1/decisions`)、模型(预设 `jev-latest` / `gpt-6-luna`)并填写密钥。密钥经 `api-key` 保存到服务目录,绑定供应商官方根地址;密钥旁的闪电按钮调用 `scheduler-check`(`codiscuss launch scheduler-check`),向决策模型发一道是非题核对供应商、模型与密钥。启动请求只带 `scheduler: {provider, model, api_key_ref}`,`validate_config` 核对密钥存在后写入私有房间配置。只在本次由本机服务启动时可开启(自由房间至少有一位本机唤醒成员,讨论须勾选“创建后立即启动”),否则选项禁用并说明原因。目前只能在建房时开启,已建房间不能中途开启或关闭。
326
326
 
327
- `runner/scheduler.mjs` 用中立的题目(`choice` 带选项,`predicate` 为是非题)组装两家的请求,并把答案统一为 `{choice, confidence, probabilities}` 或 `{probability}`。上下文是房间标题、目标与用户原文、最近 10 条消息(每条截断到 600 字)和待处理的那条用户消息。把握程度取所选项的概率(缺失时才用 `confidence`;Jev 的两项选择 `confidence` 明显低于该概率,实测概率 0.82 时只有 0.64),阈值 0.8。请求 30 秒超时,错误只报 HTTP 状态,不含响应正文或密钥。
327
+ `runner/scheduler.mjs` 用中立的题目(`choice` 带选项,`predicate` 为是非题)组装两家的请求,并把答案统一为 `{choice, confidence, probabilities}` 或 `{probability}`。上下文是房间标题、目标与用户原文、最近 10 条消息(每条截断到 600 字)和待处理的那条用户消息。把握程度取所选项的概率(缺失时才用 `confidence`;Jev 的两项选择 `confidence` 明显低于该概率,实测概率 0.82 时只有 0.64),阈值 0.7。讨论中的判断(留言、下一位发言人、下一轮)另带当前焦点、主持简报(前 1200 字)、主持人最近的安排,下一位发言人还带本轮已发言/未发言名单,并多问一道是非题“是否有明确依据要求候选人发言”(简报分工或流程轮到、或被点名追问;仅“本轮还没发言”不算),低于 0.7 即交回主持人,因为主持人预排的安排用完可能是有意结束;选项描述写明各选项的适用条件,“交给主持人”只在偏离目标、卡住或候选都不合适时选。每次调用计时,毫秒数写入决策记录 detail 的 `duration_ms`,决策卡片显示“耗时”;主持人安排卡片的耗时为上一条回答到安排保存的间隔,两种卡片样式一致。请求 30 秒超时,错误只报 HTTP 状态,不含响应正文或密钥。
328
328
 
329
329
  - **自由房间(推荐,用户决定)**:未点名的消息照常交给主持人,判断在后台进行,不阻塞本次检查和任何投递。每条 `route` 请求只判断一次,选项为各位本机唤醒的非主持成员(描述为软件与项目读写权限)和“交回主持人”;只有选中成员且 `confidence ≥ 0.7` 时,才用 `POST recommendations` 记录推荐。网页在消息旁显示“智能调度建议让 B 回答”与 60 秒倒计时,用户可选“打断主持人,改由它回答”或“主持人答完后再回答”(主持人已答完时直接请 B 回答),过期后提示消失。判断失败、超时或置信度不足时不显示推荐,行为与未开启相同。
330
330
  - **讨论房间(有把握即执行)**:投递主持事件前先只读判断,置信度 ≥ 0.7 才执行,否则照常唤醒主持人:
package/docs/workflow.md CHANGED
@@ -61,7 +61,7 @@
61
61
 
62
62
  网页新建默认 3 轮、不限时长、单次执行不限时、到限暂停;收束预留默认 5 分钟。新建表单分别显示轮次和时长上限,点击数值才展开滚轮;鼠标滚轮逐格切换,停止滚动即停住,鼠标或手指在屏幕上拖动则保留松手惯性。选择即时生效,点击外部即可收起,无需确认。时长收起时显示小时及剩余分钟,展开后分为小时、分钟两列,按 1 小时、1 分钟选择;轻点各列中间数字可直接输入,启用时长上限后范围为 1–1440 分钟,24 小时时分钟固定为 0。拖动松手后先按阻尼减速,再按预计停止位置吸附;系统“减少动画”下仍保留拖动惯性,只去掉数字的三维翻转。两项各自提供“不限”,关闭“不限”恢复此前数值,首次启用时长上限为 1 小时,互不改动;下方实时说明哪个上限先到便触发所选动作。未传参数的 CLI 建房默认轮次、总时长与单次执行不限;旧房间保留已有设置。全自动支持不限总时长:按轮次上限或手动停止结束,重启不自动添加时长上限;主动设置的单次 AI 执行超时、租约和收束预留仍有效。CLI 可额外指定未来 24 小时内的运行截止,但不能延长话题已设的截止。
63
63
 
64
- “主持”及“接入”显示配置、已用/剩余时间、已完成轮次和到限状态。新专家安排及观点由服务端拒绝越界,原提交的幂等重试和用户留言保留。自动程序定期调用到限处理;手动主持每次原生检查时执行 `room limits ROOM --check --as 本人`,也可由用户点击界面中的检查按钮。仅打开页面不触发写入或启动 AI。
64
+ “主持”及“接入”显示配置、已用/剩余时间、已完成轮次和到限状态。新专家安排及观点由服务端拒绝越界,原提交的幂等重试和用户留言保留。自动程序定期调用到限处理;手动主持每次原生检查时执行 `room limits ROOM --check --as 本人`,也可由用户点击界面中的检查按钮。仅打开页面不触发写入或启动 AI。到限且方式为暂停时,服务端拒绝主持人直接把阶段设为 `concluded`,须先 `--check` 进入 `paused`;用户本人收束不受限。
65
65
 
66
66
  选择总结时,在轮次到限或进入预留时间后取消未完成安排、撤销旧自动执行并交回主持人;主持人只使用已有材料收束。提前达到轮次上限时,收束最多使用预留分钟数,且不越过总截止。总截止到达而总结未完成则暂停;页面到限提示不代表外部手动软件已停止。无总时长配置时,轮次到限后的总结仍受预留时长约束。
67
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.2.41",
3
+ "version": "0.2.43",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
@@ -363,7 +363,10 @@ export class DiscussionRunner {
363
363
  const experts = Object.keys(room.experts || {});
364
364
  // Only experts the runner can wake are offered to the decision model; manual ones wait for the host.
365
365
  const automatic = experts.filter(name => this.config.participants[name]);
366
- const context = { title: room.title, goal: room.goal, user_request: room.user_request, context: page.messages, host: room.host };
366
+ const last_plan = room.host_arrangements?.at(-1);
367
+ const context = { title: room.title, goal: room.goal, user_request: room.user_request, context: page.messages, host: room.host,
368
+ brief: room.host_brief?.text, focus: room.focus,
369
+ arrangement: last_plan?.turns.map(turn => `${turn.expert}:${String(turn.question).slice(0, 120)}`).join(';') };
367
370
  if (room.held_since) {
368
371
  const pending = page.messages.filter(item => item.id >= room.held_since && item.author === '用户');
369
372
  // Topic edits and several messages at once need the host's judgement.
@@ -392,7 +395,7 @@ export class DiscussionRunner {
392
395
  // An expert the user just skipped is not picked again unless nobody else is left.
393
396
  const skipped = room.current_turn?.status === 'cancelled' ? room.current_turn.expert : null;
394
397
  const candidates = waiting.filter(name => name !== skipped);
395
- return this.next_speaker_plan(room, context, candidates.length ? candidates : waiting, anchor);
398
+ return this.next_speaker_plan(room, { ...context, spoken: [...spoken] }, candidates.length ? candidates : waiting, anchor);
396
399
  }
397
400
  if (room.named_followup) return skip('round', '点名追问已开启,由主持人安排下一轮');
398
401
  const decision = await decide_round(this.config.api_home, this.config.scheduler,
@@ -13,6 +13,7 @@ const HOST_CHOICE = '__host__';
13
13
  const NOTE_CHOICE = '__note__';
14
14
  const CONTEXT_MESSAGES = 10;
15
15
  const MAX_MESSAGE_CHARS = 600;
16
+ const MAX_BRIEF_CHARS = 1200;
16
17
 
17
18
  /** Normalize a launch's scheduler choice and prove its key exists for that provider's root. */
18
19
  export function validate_scheduler(home, scheduler) {
@@ -50,13 +51,17 @@ export async function decide(home, scheduler, state, questions, fetch_impl = fet
50
51
  const key = read_saved_api_key(home, scheduler.base_url, scheduler.api_key_ref);
51
52
  const request = scheduler_request(scheduler, state, questions);
52
53
  let response;
54
+ const started = Date.now();
53
55
  try {
54
56
  response = await fetch_impl(`${scheduler.base_url}${request.path}`, { method: 'POST', redirect: 'error',
55
57
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
56
58
  body: JSON.stringify(request.body), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
57
59
  } catch { throw new Error('智能调度请求超时或连接失败'); }
58
60
  if (!response.ok) throw new Error(api_status_message(response.status));
59
- return scheduler_answers(scheduler, await response.json());
61
+ const answers = scheduler_answers(scheduler, await response.json());
62
+ // Timing rides along without being an answer, so callers iterating answers by question name never see it.
63
+ Object.defineProperty(answers, 'elapsed_ms', { value: Date.now() - started });
64
+ return answers;
60
65
  }
61
66
 
62
67
  /** Send one tiny yes/no question so provider, model and key are checked together before a room relies on them. */
@@ -67,10 +72,19 @@ export async function scheduler_check(home, input, fetch_impl = fetch) {
67
72
  return { ok: true };
68
73
  }
69
74
 
70
- /** What was asked (the instruction and options only, never the room excerpts) and what the provider chose, for the folded record. */
75
+ /** What was asked (the instruction and options only, never the room excerpts), what the provider chose and how long the call took. */
71
76
  function detail_of(questions, answers) {
72
- return questions.map(question => ({ question: question.instructions, choices: question.choices.map(choice => choice.value),
73
- choice: answers[question.name]?.choice ?? null, probabilities: answers[question.name]?.probabilities ?? {} }));
77
+ return questions.map(question => {
78
+ const answer = answers[question.name];
79
+ // A yes/no question is recorded as a two-way choice so the card reads it like any other.
80
+ if (question.type === 'predicate') {
81
+ const yes = typeof answer?.probability === 'number' ? answer.probability : null;
82
+ return { question: question.instructions, choices: ['是', '否'], choice: yes === null ? null : yes >= 0.5 ? '是' : '否',
83
+ probabilities: yes === null ? {} : { 是: yes, 否: Number((1 - yes).toFixed(4)) }, duration_ms: answers.elapsed_ms ?? null };
84
+ }
85
+ return { question: question.instructions, choices: question.choices.map(choice => choice.value),
86
+ choice: answer?.choice ?? null, probabilities: answer?.probabilities ?? {}, duration_ms: answers.elapsed_ms ?? null };
87
+ });
74
88
  }
75
89
 
76
90
  /** Say which option the provider leaned to when it was not sure enough, so the transcript shows how close it was. */
@@ -85,14 +99,16 @@ function excerpt(message) {
85
99
  }
86
100
 
87
101
  /** The room's purpose in the user's words, so a decision can tell drifting from progress. */
88
- function topic_lines(title, goal, user_request) {
89
- return [`房间:${title || ''}`, goal && `目标:${goal}`, user_request && `用户原文:${String(user_request).slice(0, MAX_MESSAGE_CHARS)}`].filter(Boolean).join('\n');
102
+ function topic_lines(title, goal, user_request, { brief, focus, arrangement, progress } = {}) {
103
+ return [`房间:${title || ''}`, goal && `目标:${goal}`, user_request && `用户原文:${String(user_request).slice(0, MAX_MESSAGE_CHARS)}`,
104
+ focus && `当前焦点:${focus}`, brief && `主持简报(含分工与规则):${String(brief).slice(0, MAX_BRIEF_CHARS)}`,
105
+ arrangement && `主持人最近的安排:${arrangement}`, progress].filter(Boolean).join('\n');
90
106
  }
91
107
 
92
108
  /** State shared by both uses: the room, a few earlier messages, and the message to place. */
93
- export function scheduler_state(title, context, message, goal = '', user_request = '') {
109
+ export function scheduler_state(title, context, message, goal = '', user_request = '', extra = {}) {
94
110
  const earlier = context.filter(item => item.id < message.id).slice(-CONTEXT_MESSAGES).map(excerpt);
95
- return [topic_lines(title, goal, user_request), earlier.length ? `近期消息(旧→新):\n${earlier.join('\n')}` : '近期没有其他消息。', `待处理的用户消息:\n${excerpt(message)}`].join('\n\n');
111
+ return [topic_lines(title, goal, user_request, extra), earlier.length ? `近期消息(旧→新):\n${earlier.join('\n')}` : '近期没有其他消息。', `待处理的用户消息:\n${excerpt(message)}`].join('\n\n');
96
112
  }
97
113
 
98
114
  /**
@@ -123,12 +139,12 @@ export async function route_free_message(home, scheduler, { title, goal, context
123
139
  * Decide one interjection in a discussion: continue the plan (note), ask one expert (ask) or wake the host (host).
124
140
  * Anything but a confident note or ask is returned as host.
125
141
  */
126
- export async function decide_interjection(home, scheduler, { title, goal, user_request, context, message, host, experts }, fetch_impl) {
127
- const choices = [...experts.map(name => ({ value: name, description: `请 ${name} 回应这条留言` })),
128
- { value: HOST_CHOICE, description: `由主持人 ${host} 处理:调整目标、方向、安排,或需要主持人决定` },
129
- { value: NOTE_CHOICE, description: '仅补充信息:不需要专人回应,按原计划继续' }];
130
- const questions = [{ type: 'choice', name: 'handling', instructions: '讨论进行中用户发来这条留言。结合讨论目标判断它最适合怎样处理。', choices }];
131
- const answers = await decide(home, scheduler, scheduler_state(title, context, message, goal, user_request), questions, fetch_impl);
142
+ export async function decide_interjection(home, scheduler, { title, goal, user_request, context, message, host, experts, ...extra }, fetch_impl) {
143
+ const choices = [...experts.map(name => ({ value: name, description: `请 ${name} 回应这条留言:留言是向该专家(或其负责的内容)提问、质疑或补充要求` })),
144
+ { value: HOST_CHOICE, description: `由主持人 ${host} 处理:留言要改变讨论目标、规则、分工、安排或结束讨论,或同时涉及多位专家` },
145
+ { value: NOTE_CHOICE, description: '仅补充信息:留言只是旁注、信息或鼓励,不要求任何人回应,按当前安排继续' }];
146
+ const questions = [{ type: 'choice', name: 'handling', instructions: '讨论进行中用户发来这条留言。对照当前焦点、主持简报和安排,判断它最适合怎样处理。', choices }];
147
+ const answers = await decide(home, scheduler, scheduler_state(title, context, message, goal, user_request, extra), questions, fetch_impl);
132
148
  const detail = detail_of(questions, answers);
133
149
  const choice = answers.handling?.choice;
134
150
  const confidence = certainty(answers.handling);
@@ -143,20 +159,28 @@ export async function decide_interjection(home, scheduler, { title, goal, user_r
143
159
  * Mid-round with no queued turn left: pick the next speaker among the experts who can be woken and have not spoken
144
160
  * this round, or hand back to the host when unsure or when the discussion needs steering.
145
161
  */
146
- export async function decide_next_speaker(home, scheduler, { title, goal, user_request, context, host, candidates }, fetch_impl) {
162
+ export async function decide_next_speaker(home, scheduler, { title, goal, user_request, context, host, candidates, spoken = [], ...extra }, fetch_impl) {
147
163
  const recent = context.slice(-CONTEXT_MESSAGES).map(excerpt);
148
- const state = [topic_lines(title, goal, user_request), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
149
- const choices = [...candidates.map(name => ({ value: name, description: `请 ${name} 接着发言` })),
150
- { value: HOST_CHOICE, description: `交给主持人 ${host}:讨论偏离目标、卡住,或需要主持人调整安排` }];
164
+ const progress = `本轮已发言:${spoken.join('、') || '无'};本轮尚未发言:${candidates.join('、')}。`;
165
+ const state = [topic_lines(title, goal, user_request, { ...extra, progress }), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
166
+ const choices = [...candidates.map(name => ({ value: name, description: `请 ${name} 接着发言:简报的分工或接龙顺序轮到 ${name},或刚才的发言点名追问了 ${name}` })),
167
+ { value: HOST_CHOICE, description: `交给主持人 ${host}:没有明确该谁发言,或需要主持人重新安排` }];
168
+ // Having not spoken yet is no reason to speak: the host's plan may have ended on purpose, so the scheduler acts only on an explicit obligation.
151
169
  const questions = [{ type: 'choice', name: 'speaker',
152
- instructions: '本轮还有专家没发言,安排被取消或用完。结合已有发言判断下一位应请谁发言:优先选被点名追问、最能回应当前焦点的人。', choices }];
170
+ instructions: '主持人预排的安排已用完或被取消。对照主持简报的分工、当前焦点和主持人最近的安排,判断下一位应请谁发言。', choices },
171
+ { type: 'predicate', name: 'obligation',
172
+ instructions: `是否存在明确依据要求 ${candidates.join('、')} 之一接着发言:简报分工或流程规定轮到其人,或最近发言点名追问了其人?仅仅是“本轮还没发言”不算依据。` }];
153
173
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
154
174
  const detail = detail_of(questions, answers);
155
175
  const choice = answers.speaker?.choice;
156
176
  const confidence = certainty(answers.speaker);
157
- if (candidates.includes(choice) && confidence !== null && confidence >= ROUTE_CONFIDENCE) {
177
+ const obligation = answers.obligation?.probability;
178
+ if (candidates.includes(choice) && confidence !== null && confidence >= ROUTE_CONFIDENCE && obligation >= ROUTE_CONFIDENCE) {
158
179
  return { action: 'next', expert: choice, confidence, detail, label: `本轮继续,请 ${choice} 接着发言` };
159
180
  }
181
+ if (typeof obligation === 'number' && obligation < ROUTE_CONFIDENCE) {
182
+ return { action: 'host', confidence, detail, label: '没有明确该谁接着发言的依据,交给主持人安排' };
183
+ }
160
184
  return { action: 'host', confidence, detail, label: choice === HOST_CHOICE && confidence >= ROUTE_CONFIDENCE ? '建议由主持人调整安排,交给主持人' : unsure_label(candidates.includes(choice) ? `请 ${choice} 发言` : choice === HOST_CHOICE ? '交给主持人' : '') };
161
185
  }
162
186
 
@@ -164,14 +188,14 @@ export async function decide_next_speaker(home, scheduler, { title, goal, user_r
164
188
  * At the end of a round without named follow-ups: continue with another peer round, or wake the host
165
189
  * (to conclude, or because the discussion drifted, repeats itself or is stuck). The first speaker is never the last one.
166
190
  */
167
- export async function decide_round(home, scheduler, { title, goal, user_request, context, host, experts, last_speaker }, fetch_impl) {
191
+ export async function decide_round(home, scheduler, { title, goal, user_request, context, host, experts, last_speaker, ...extra }, fetch_impl) {
168
192
  const candidates = experts.filter(name => name !== last_speaker);
169
193
  const recent = context.slice(-CONTEXT_MESSAGES).map(excerpt);
170
- const state = [topic_lines(title, goal, user_request), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
171
- const questions = [{ type: 'choice', name: 'next', instructions: '一轮发言刚结束。结合讨论目标判断:是否值得让大家继续下一轮,互相回应、追问或补充?', choices: [
172
- { value: 'continue', description: '继续下一轮:还有可以互相回应、追问、补充或展开的内容' },
173
- { value: HOST_CHOICE, description: `交给主持人 ${host}:目标已经达成可以收束,或讨论偏离目标、反复重复、卡住,需要主持人引导` }] }];
174
- if (candidates.length > 1) questions.push({ type: 'choice', name: 'first', instructions: '下一轮应先请谁发言?优先选被其他专家点名追问、最需要回应的人。',
194
+ const state = [topic_lines(title, goal, user_request, extra), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
195
+ const questions = [{ type: 'choice', name: 'next', instructions: '一轮发言刚结束。对照讨论目标、主持简报与当前焦点判断:下一轮是否还有明确的内容可以让大家互相回应、追问或补充?', choices: [
196
+ { value: 'continue', description: '继续下一轮:最近发言里有未回应的分歧、追问或可展开的点,或简报规定的流程还没走完' },
197
+ { value: HOST_CHOICE, description: `交给主持人 ${host}:目标已经达成可以收束,或最近发言在重复、偏离目标、卡住,需要主持人引导` }] }];
198
+ if (candidates.length > 1) questions.push({ type: 'choice', name: 'first', instructions: '下一轮应先请谁发言?按简报分工或接龙顺序判断;没有规定时选被其他专家点名追问、最需要回应的人。',
175
199
  choices: candidates.map(name => ({ value: name, description: `先请 ${name}` })) });
176
200
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
177
201
  const detail = detail_of(questions, answers);