ai_co_discuss 0.2.62 → 0.2.64
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-participant/references/free.md +1 -1
- package/backend/free_room.py +1042 -994
- package/backend/server.py +1 -1
- package/docs/architecture.md +1 -1
- package/docs/cli.md +1 -1
- package/docs/runner.md +7 -1
- package/docs/workflow.md +2 -2
- package/package.json +1 -1
- package/runner/discussion_service.mjs +3 -2
- package/runner/free_runner.mjs +63 -22
- package/runner/scheduler.mjs +25 -11
- package/web/app.js +12 -1
- package/web/modern.css +1 -1
- package/web/modern.js +1046 -972
package/backend/server.py
CHANGED
|
@@ -738,7 +738,7 @@ def start_discussion(connection, room_id, token, payload):
|
|
|
738
738
|
if room["archived"]:
|
|
739
739
|
raise ValueError("房间已存档;恢复后才能开启讨论")
|
|
740
740
|
sync_participants(connection, room_id)
|
|
741
|
-
active = active_members(connection, room_id)
|
|
741
|
+
active = active_members(connection, room_id, registered_only=False)
|
|
742
742
|
host = next((row for row in active.values() if row["role"] == "host"), None)
|
|
743
743
|
if not host or host["access"] != "runner":
|
|
744
744
|
raise ValueError("开启讨论需要一位由本机托管的 AI 主持人;先在成员栏指定或转为自动")
|
package/docs/architecture.md
CHANGED
|
@@ -258,7 +258,7 @@ running 状态即使只有 pending 事件,也必须先暂停才能重新启动
|
|
|
258
258
|
- **请求**:用户消息按结构化接收者 `to`(成员名)为每位成员生成一条请求;省略且仅有一位在场成员时默认点名该成员,多位成员时交给主持人(`route`),只有在线成员而没有主持人时只作留言,不广播。网页只从正文中代码与引用之外的 `@成员` 生成 `to`,没有 @ 时省略 `to`。请求状态为 `pending`(待投递)→ `delivering`(处理中)→ `completed` / `cancelled` / `failed`。
|
|
259
259
|
- **执行**:`member_executions` 每位成员最多一个 `dispatched/started`(部分唯一索引),不同成员可并行。接入程序用控制凭据 `GET inbox`、`POST executions {member_id, client_id}`(同一 `client_id` 返回同一执行)把成员的全部待投递请求打包;`POST executions/ID {action}` 支持 `renew`(租约 120 秒,不超过单次执行时限,默认 20 分钟)、`fail`(请求标记失败)和 `release`(仅确认未送达时退回队列)。租约过期的执行在下次读取或领取时标记失败,不自动重发。
|
|
260
260
|
- **主持人与任务链**:用户是需求方,不担任主持人。只要有在场的非在线成员,房间就保持恰好一位主持人:成员变动时,离开或移出的主持人失去角色,由第一位在场的 runner 成员(没有则手动成员)接任;建房和每条房主消息前也做同样的补位,因此旧房间在下一条消息时自动获得主持人。房主用 `POST members/ID {action:"role", role:"host"}` 更换主持人(在线成员不能担任),把现任主持人改为 `member` 会被拒绝。多位成员时未点名的房主消息生成给主持人的 `route` 请求。主持人的回答可带 `to` 点名其他成员(不能点名自己),这些请求组成一个批次(`free_batches`);批次内全部进入终态后:只有一个请求的批次不再回交(该成员的回答直接面向用户);多个请求的批次只要有一个完成就给主持人生成一次 `handoff` 请求;全部失败则停下该任务链。每条房主消息开启一条任务链(`chain_id` 为该消息编号),链上 AI 发起的请求(派活与回交)最多 8 次,超出即停下(`free_chains`,原因 `budget` / `all_failed`),房主发新消息开启新链。Worker 用同一批次内的集合 SQL 完成回交,回交请求按(派活消息,主持人)唯一,重复执行不会重复唤醒。
|
|
261
|
-
- **智能调度推荐**:接入程序用控制凭据 `POST recommendations {message_id, member_id, provider, confidence}` 为一条已交给主持人(`route`)的未点名消息记录推荐对象(在场的非该主持人成员,含手动与在线成员),写入 `member_recommendations`(每条消息一条,重复提交忽略;独立表,D1 重新应用 schema 即可),有效期 60 秒,主持人的请求不受影响。房主用 `POST recommendations/MESSAGE_ID {action:"adopt", mode}` 采纳:`interrupt` 取消主持人这条请求(已投递则作废主持人本次整个执行),`after` 等主持人这条请求进入终态。两种方式都先置为 `waiting`,由 `release`(采纳时与每次 `inbox` 读取时执行)在主持人请求已终态时为推荐成员建一条 `ask` 请求(`created_by=user`、`chain_id` 为原消息),状态改为 `adopted`。过期 5 秒宽限后采纳返回 409
|
|
261
|
+
- **智能调度推荐**:接入程序用控制凭据 `POST recommendations {message_id, member_id, provider, confidence}` 为一条已交给主持人(`route`)的未点名消息记录推荐对象(在场的非该主持人成员,含手动与在线成员),写入 `member_recommendations`(每条消息一条,重复提交忽略;独立表,D1 重新应用 schema 即可),有效期 60 秒,主持人的请求不受影响。房主用 `POST recommendations/MESSAGE_ID {action:"adopt", mode}` 采纳:`interrupt` 取消主持人这条请求(已投递则作废主持人本次整个执行),`after` 等主持人这条请求进入终态。两种方式都先置为 `waiting`,由 `release`(采纳时与每次 `inbox` 读取时执行)在主持人请求已终态时为推荐成员建一条 `ask` 请求(`created_by=user`、`chain_id` 为原消息),状态改为 `adopted`。过期 5 秒宽限后采纳返回 409,重复采纳返回原结果。同一入口的 `{action:"all"}` / `{action:"none"}` 不经推荐表:全体把主持人尚未开始的 `route` 请求原地改为 `purpose=all`(每条消息每位成员只有一条请求),并为其余在场成员各建一条 `all` 请求(`created_by=user`、`chain_id` 为原消息、不进 `free_batches`,因此没有 `handoff`);无需回应取消该 `route` 请求;主持人请求已不是 `pending` 时返回 409。主持人本次回答的请求全是 `all` 时,带 `to` 的回答被拒绝(400),避免重复请求其他成员。房间视图 `free.recommendations` 带成员名、状态、方式、置信度与剩余秒数 `expires_in`;备份、导入(仅作历史,不再生效)与删除一并处理。`inbox` 的待投递项带 `purpose` 供接入程序识别。`inbox.members` 只列由本机唤醒的成员;手动与在线成员另列于 `inbox.observers`(含待处理请求,供智能调度识别主持人与推荐对象),接入程序从不向它们投递。
|
|
262
262
|
- **回应建议**:普通成员新发言的结构化 `to` 与正文在场成员 @ 合并保存到 `member_suggestions`(原消息编号、稳定成员编号),排除自己、代码与引用;不会创建请求。消息读取返回 `suggestions`,包含目标在场状态及已转换的请求编号。房主用 `POST suggestions {message_id,member_id}` 转换:同一事务记录用户确认消息,调用现有建请求路径,`message_id` 保留原回答、`chain_id` 使用用户确认消息编号、`created_by=user`。`UNIQUE(message_id,member_id)` 使重复或并发点击只生成一个请求及一条确认,终态不重新派发。权限、在场状态和未存档条件在写入事务内校验;Worker 使用批次断言。升级须应用包含新表的 schema;Python 重启时自动建表,D1 发布前显式执行。已有历史消息不补写建议。
|
|
263
263
|
- **讨论中的直接提问**:讨论房间的用户消息带 `to`(点名成员名列表)时,除保存消息外为每位被点名的在场成员建一条 `ask` 请求(`created_by=user`,不入 `free_batches`,不占安排);讨论运行中点名主持人不重复建请求。`GET inbox`、`POST executions` 与 `executions/ID` 对讨论房间同样可用,成员以 `execution_id`(接入程序交付)或 `request_ids`(手动 / 代发)提交 `chat` 回答,走自由房间的写入校验。同一成员的 `member_executions` 与 `discussion_events` 互斥(任一方领取时另一方有进行中则返回 409)。房间视图 `asks` 给出最近请求与执行,供消息旁的状态徽标。本机服务以 `DiscussionWithQuestions`(`runner/discussion_service.mjs`)并行运行讨论 runner 与 `FreeRoomRunner`,共用同一成员会话绑定。
|
|
264
264
|
- **在线成员**:`access=online` 的成员只读,不进入 `inbox`,不能用自己的凭证发言;房主以控制凭据代发其回答(`author` 为该成员、`submitted_by` 为用户、`source=proxy`),可带 `request_ids` 关闭它的待处理请求。
|
package/docs/cli.md
CHANGED
|
@@ -150,7 +150,7 @@ codiscuss message post ROOM --as Claude --kind chat --execution 执行编号 --b
|
|
|
150
150
|
|
|
151
151
|
手动接入成员首次读取房间后执行一次 `member register ROOM --as 本人 --access-file FILE`:网站记录该成员已用自己的凭证注册,重复执行保留首次时间,房主凭据或他人凭证被拒绝(403),自动接入成员不需要注册(400)。命令不带会话 ID,CLI 在每次请求头中自动附带宿主会话(`CLAUDE_CODE_SESSION_ID` / `CODEX_THREAD_ID`)。未注册的成员在网页显示“待注册”,也不会被本机服务接管。
|
|
152
152
|
|
|
153
|
-
主持人在回答中用 `--to 成员名`(可重复)点名派活,例如 `--to Codex --to Gemini`;不能点名自己。只点名一位成员时,主持人不再收到后续请求;点名多位成员时,都结束后主持人会再收到一次 `handoff`
|
|
153
|
+
主持人在回答中用 `--to 成员名`(可重复)点名派活,例如 `--to Codex --to Gemini`;不能点名自己。只点名一位成员时,主持人不再收到后续请求;点名多位成员时,都结束后主持人会再收到一次 `handoff` 请求。本次回答的请求全是 `all`(智能调度请全体各自回应,其他成员已同时收到)时,主持人只回答自己的部分,带 `--to` 会被拒绝(400)。普通成员也可用 `--to` 或正文 `@成员` 提出回应建议,但不会派发;房主在网页点击“转为请求”后才建立关联原消息的新用户任务链。代码与引用里的正文 @ 不生成建议。
|
|
154
154
|
|
|
155
155
|
## 写入核对与错误处理
|
|
156
156
|
|
package/docs/runner.md
CHANGED
|
@@ -330,7 +330,13 @@ Codex 要求 CLI 0.163.0 或更新版本,使用 permission profiles 和本地
|
|
|
330
330
|
|
|
331
331
|
`runner/scheduler.mjs` 用中立的题目(`choice` 带选项,`predicate` 为是非题)组装两家的请求,并把答案统一为 `{choice, confidence, probabilities}` 或 `{probability}`。上下文是房间标题、目标与用户原文、最近 10 条消息(自由房间的路由判断为 20 条;每条截断到 600 字)和待处理的那条用户消息。把握程度取所选项的概率(缺失时才用 `confidence`;Jev 的两项选择 `confidence` 明显低于该概率,实测概率 0.82 时只有 0.64),阈值 0.7。讨论中的判断(留言、下一位发言人、下一轮)另带当前焦点、主持简报(前 1200 字)、主持人最近的安排,下一位发言人还带本轮已发言/未发言名单,并多问一道是非题“是否有明确依据要求候选人发言”(简报分工或流程轮到、或被点名追问;仅“本轮还没发言”不算),低于 0.7 即交回主持人,因为主持人预排的安排用完可能是有意结束;选项描述写明各选项的适用条件,“交给主持人”只在偏离目标、卡住或候选都不合适时选。每次调用计时,毫秒数写入决策记录 detail 的 `duration_ms`,决策卡片显示“耗时”;主持人安排卡片的耗时为上一条回答到安排保存的间隔,两种卡片样式一致。请求 30 秒超时,错误只报 HTTP 状态,不含响应正文或密钥。
|
|
332
332
|
|
|
333
|
-
-
|
|
333
|
+
- **自由房间(推荐,用户决定)**:未点名的消息先判断、后投递:主持人要等判断结果(含失败)出来才启动,因此改派时主持人不会先启动再被打断;判断失败或超时(30 秒)时按未开启处理。每条 `route` 请求只判断一次,题目是“回应对象是否已确定”(正文叫了某位成员、在延续某位成员的回答、在补充或纠正正由某位成员处理的消息),上下文为房间标题、目标和该消息前最近 20 条消息(每条带作者,用“回复”发出的标“回复 #N”)。选项:
|
|
334
|
+
- 每位非主持成员:描述为软件、项目读写权限(未设项目目录写明“未指定项目目录”)、手动或网页成员注明“由用户代为触发”,以及正在处理 / 待处理的其他消息编号;
|
|
335
|
+
- `__host__` 主持人:延续主持人自己的回答,或对象不明确,或需要分工、排先后、互相回应;
|
|
336
|
+
- `__all__` 全体:全体成员(含主持人)各自回应一次,彼此不需要配合;
|
|
337
|
+
- `__none__` 无需回应:只是确认、致谢或旁注。
|
|
338
|
+
|
|
339
|
+
选中成员且 `confidence ≥ 0.7` 时,用 `POST recommendations` 记录推荐,并随即由接入程序以 `adopt` + `interrupt` 自动采纳:打断主持人,改由该成员回答,自动采纳失败时保留原推荐与 60 秒倒计时,用户仍可手动选“打断主持人”或“主持人答完后再回答”。选中全体或无需回应时阈值为 0.85(`ROUTE_WIDE_CONFIDENCE`,选错分别浪费 N 次执行或让用户的话没人理),由 `POST recommendations/MESSAGE_ID {action:"all"|"none"}` 一步生效:全体把主持人这条请求原地改为 `all`,并为其他在场成员各建一条 `all` 请求(以用户名义,不进派发批次,所以不会唤醒主持人汇总);无需回应取消主持人这条请求。主持人已开始处理时两者都返回 409,消息仍由主持人处理。每次判断(含交回主持人)都用房主凭据写入 `POST scheduler-decisions`(`kind` 为 `interjection`,`after_message_id` 为该用户消息,`detail` 为题目、选项与各选项概率),在消息下显示为“智能调度:改由 B 回答 / 请全体各自回应 / 无需回应 / 交给主持人 A”及置信度;房主可点 👍 / 👎 评价(`POST scheduler-decisions/ID`,`rating` 为 `good` / `bad` / `null` 撤回);评为 👎 时可再选填“应该由谁回答”(`should_be`)和不超过 200 字的说明(`note`)。评价存于 `scheduler_feedback`,每条判断产生时的模型、阈值、提示词版本(`ROUTE_PROMPT_VERSION`,改动题目或选项描述时递增)和包版本存于 `scheduler_decision_context`;两者随房间备份导出与导入,用于日后跨版本校准 0.7 阈值。判断失败、超时或置信度不足时不显示推荐,行为与未开启相同。
|
|
334
340
|
- **讨论房间(有把握即执行)**:投递主持事件前先只读判断,置信度 ≥ 0.7 才执行,否则照常唤醒主持人:
|
|
335
341
|
- **插话**:`held_since` 后恰有一条用户留言时判断。仅补充信息且有排队安排 → `turns/continue`;在问某位非主持专家 → 新计划先请该专家回应这条留言(问题引用原文 300 字),原排队安排按原顺序跟在后面(最多 10 项);改变方向、多条留言或话题修改 → 主持人。仅补充但计划已结束时,按下一条处理。
|
|
336
342
|
- **轮次衔接**:仅 `named_followup` 为 false、无排队、上一轮已完成时判断。继续 → 先请模型选出的专家(不是上一轮最后发言者),其余按房间专家顺序,统一使用 `peer_round_question`;第 1 轮后自动把阶段从 `initial` 改为 `discussing`。可收束、跑偏、重复或卡住 → 主持人。
|
package/docs/workflow.md
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
自由协作房间从同一角色库选择一位或多位成员。用户是需求方而不是主持人:只要有能自行发言的成员(本机唤醒或手动接入),房间就始终有一位 AI 主持人,默认是第一位本机唤醒的成员,可点皇冠或在成员菜单中更换;主持人不能直接卸任,离开或被移出时由下一位成员接任,原先由用户主持的旧房间在下一条消息时自动指定。只有一位成员时,每条消息都交给它;有多位时,你的消息默认发给主持人;在正文中 `@成员` 才让其他成员发言(代码和引用中的 @ 不算,输入框中会生效的 @ 显示为蓝色),被点名的成员各自处理,主持人也可以 @ 其他成员派活。输入框下方的备注行显示这条消息会发给谁。只有网页 AI 成员时没有主持人,未点名的消息只作留言。输入 `@` 后,输入框上方弹出在场成员菜单,继续输入可筛选;用方向键与 Enter / Tab 或点击补全,Esc 关闭。选择只修改草稿。输入框 Enter 发送、Shift + Enter 换行;发送按钮始终用于发言,无正文或附件时禁用,成员处理中也不切换为停止按钮。自动成员空闲时,服务把它名下所有未处理的消息打包成一次执行交给原会话;执行期间再发的消息排队,下一次一起投递。手动成员自行读取自己的请求,在线成员由用户转交。成员按时间顺序处理,后面的消息可以修正前面的。
|
|
27
27
|
|
|
28
|
-
运行中可在右侧成员栏邀请 AI(沿用本机软件配置和本房间工作目录),也可让成员离开、请回或移除。自动成员的项目文件权限默认只读;需要它修改项目时,在邀请前选择“项目文件可写”,权限只应用于这位成员;App 与手动成员的文件权限由其软件设置,API 与网页成员不访问项目文件。填写已有会话 ID
|
|
28
|
+
运行中可在右侧成员栏邀请 AI(沿用本机软件配置和本房间工作目录),也可让成员离开、请回或移除。自动成员的项目文件权限默认只读;需要它修改项目时,在邀请前选择“项目文件可写”,权限只应用于这位成员;App 与手动成员的文件权限由其软件设置,API 与网页成员不访问项目文件。填写已有会话 ID 时续接该原会话,不新建。离开会立即停止它的工作;请回时换发新凭证并续接它原来的会话。邀请时可选“手动接入”:成员不会被自动唤醒,而是得到一份私有凭据文件,由你在它的会话中按参与技能读写。自由协作房间里,手动成员执行 `member register` 之前视为不存在:不能被点名,不收请求,不进“全体各自回应”和智能调度候选;注册后与其他成员一样可被点名,其 CLI 上报会话后本机服务按软件转为托管(Claude Code、Codex、agy),此后与自动成员无异。绑定你正在使用的会话的成员会标注“借用会话”:它工作时不要在原窗口继续操作。
|
|
29
29
|
|
|
30
30
|
可以把一位本机成员设为主持人。之后没有点名的消息交给主持人分配;主持人在回答中点名成员派活,只点名一位成员时,其回答直接面向用户,主持人不再被唤醒;点名多位成员时,都结束后主持人再被唤醒一次,继续汇总或安排下一步。派出的成员全部失败时不再唤醒主持人;同一条消息引出的 AI 请求最多 8 次,用完即停,成员栏会提示,发一条新消息即重新开始。
|
|
31
31
|
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
|
|
34
34
|
在线 AI 以“网页代发”成员加入:点名后,在成员栏复制阅读通知交给它;拿到回答后,在输入框选择“代发”该成员并粘贴原文,记录保留它为作者、你为提交者,并关闭它的待处理请求。
|
|
35
35
|
|
|
36
|
-
新建房间时可勾选“智能调度”(可选,需本机服务,供应商为 TypeSafe Jev 或 OpenAI Decisions
|
|
36
|
+
新建房间时可勾选“智能调度”(可选,需本机服务,供应商为 TypeSafe Jev 或 OpenAI Decisions,按次计费)。开启后,没点名的消息先由决策模型判断(通常约 1 秒),主持人等结果出来才启动;消息明确在对某位成员说(叫了名字、追问其回答、补充其正在处理的消息,置信度 ≥ 0.7)就直接改由该成员回答;要求全体各自回应(如“都出来打个招呼”)就同时请所有成员各答一次,主持人不再汇总;只是“好的”“谢谢”就不唤醒任何成员(这两种置信度须 ≥ 0.85);拿不准或需要分工协调时仍由主持人处理。每次判断都在该消息下留一行“智能调度”记录,可点 👍 / 👎 评价这次判断是否合适。自动改派失败时退回为 60 秒内可手动采纳的建议。目前只能在建房时开启。开启智能调度只需连接本机服务,成员可以全是手动接入或网页成员:本机服务仍登记该房间并运行决策;被推荐的手动或网页成员与其他成员一样,采纳后在成员栏出现待处理请求,由你或其自行处理(网页成员需你复制通知并代发)。
|
|
37
37
|
|
|
38
38
|
需要深入讨论时,在成员栏点“开启讨论”(或建房时勾选“创建后开启讨论”):在弹窗里填目标、轮次和时长等上限,由本机唤醒的主持人安排成员轮流发言。到限、结束或用户点“回到自由协作”后,房间继续自由协作,成员、历史与结论保留,可再次开启;不再另建讨论房间。
|
|
39
39
|
|
package/package.json
CHANGED
|
@@ -266,8 +266,9 @@ class RoomWorker {
|
|
|
266
266
|
}
|
|
267
267
|
/** A manual Claude or Codex expert that has registered and whose CLI reported its session is handed to the service, which resumes that session from the next turn on. */
|
|
268
268
|
async adopt_manual_members() {
|
|
269
|
-
|
|
270
|
-
|
|
269
|
+
// A discussion runner lists members itself; a free room's registered manual members come from the room view.
|
|
270
|
+
const members = this.config.mode === 'free' ? (await api(this.config, '')).free?.members || [] : this.runner.members || [];
|
|
271
|
+
for (const member of members) {
|
|
271
272
|
const key = `${member.name}:${member.reported_session}`;
|
|
272
273
|
if (member.access !== 'manual' || member.status !== 'active' || !member.registered_at || !member.reported_session || this.adopt_failed.has(key)) continue;
|
|
273
274
|
try {
|
package/runner/free_runner.mjs
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
4
|
import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
5
|
-
import { join, resolve } from 'node:path';
|
|
5
|
+
import { basename, join, resolve } from 'node:path';
|
|
6
6
|
import { atomic_json, deliver_task, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
|
|
7
7
|
import { api } from './discussion_runner.mjs';
|
|
8
|
-
import { ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, route_free_message } from './scheduler.mjs';
|
|
8
|
+
import { ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, route_free_message } from './scheduler.mjs';
|
|
9
9
|
|
|
10
10
|
const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
11
11
|
const RENEW_MS = 30_000;
|
|
@@ -72,7 +72,7 @@ export function free_task_files(config, member, execution, binding) {
|
|
|
72
72
|
// The host routes unnamed messages and receives hand-backs; it may name members in its answer.
|
|
73
73
|
const discussion = config.mode !== 'free';
|
|
74
74
|
const host_rule = member.role === 'host' && !discussion
|
|
75
|
-
? `你是本房间的主持人:purpose 为 route 的请求是用户未点名的消息,handoff
|
|
75
|
+
? `你是本房间的主持人:purpose 为 route 的请求是用户未点名的消息,handoff 表示你派出的成员都已结束,all 表示用户请全体成员各自回应一次(其他成员已同时收到,你只回答自己的部分,不加 --to)。先用 ${cli} room view ${config.room} 查看成员与状态,再决定由谁处理;需要派活时在回答中加 --to 成员名(可重复,不能点名自己),只点名一位成员时,其回答直接面向用户,你不会再被唤醒;同时点名多位时,全部结束后你会再被唤醒一次汇总。不需要他人时不加 --to,本次即结束。只派分给各成员的文件或模块,避免多人改同一文件。`
|
|
76
76
|
: '';
|
|
77
77
|
atomic_json(task_path, { site: config.site, room: config.room, mode: 'free', member: member.name,
|
|
78
78
|
execution_id: execution.id, request_messages: execution.requests.map(item => item.message_id),
|
|
@@ -89,10 +89,27 @@ export function free_task_files(config, member, execution, binding) {
|
|
|
89
89
|
const ADAPTER_LABELS = { codex: 'Codex App', codex_cli: 'Codex CLI', claude: 'Claude Code', antigravity: 'Antigravity App',
|
|
90
90
|
antigravity_cli: 'Antigravity CLI', api: '模型 API,只能读写房间消息' };
|
|
91
91
|
|
|
92
|
-
/** What a scheduler can know about a member without a role field: its software and
|
|
93
|
-
function member_description(name, profile) {
|
|
94
|
-
const
|
|
95
|
-
|
|
92
|
+
/** What a scheduler can know about a member without a role field: its software, project access and the messages it is on. */
|
|
93
|
+
function member_description(name, profile, work) {
|
|
94
|
+
const project = profile.project_name || (profile.cwd ? basename(profile.cwd) : '');
|
|
95
|
+
const access = profile.adapter === 'api' ? '' : !profile.cwd ? ',未指定项目目录'
|
|
96
|
+
: profile.project_access === 'write' ? `,可修改项目 ${project} 的文件` : `,项目 ${project} 只读`;
|
|
97
|
+
return `${name}(${ADAPTER_LABELS[profile.adapter] || profile.adapter}${access}${work})`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The other messages a member is answering or has queued, so a correction can follow the message it corrects. */
|
|
101
|
+
function member_work(requests, name, message_id) {
|
|
102
|
+
const open = requests.filter(item => item.member === name && item.message_id !== message_id);
|
|
103
|
+
const ids = status => [...new Set(open.filter(item => item.status === status).map(item => `#${item.message_id}`))].join('、');
|
|
104
|
+
return [ids('delivering') && `;正在处理 ${ids('delivering')}`, ids('pending') && `;待处理 ${ids('pending')}`].filter(Boolean).join('');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Why a message stayed with the host: the scheduler chose the host, or leaned elsewhere without enough certainty. */
|
|
108
|
+
function route_host_label(decision, host, members) {
|
|
109
|
+
if (decision.choice === '__host__' || decision.confidence === null) return `交给主持人 ${host}`;
|
|
110
|
+
const lean = decision.choice === '__all__' ? '全体各自回应' : decision.choice === '__none__' ? '无需回应'
|
|
111
|
+
: members.some(member => member.name === decision.choice) ? `请 ${decision.choice} 回答` : '';
|
|
112
|
+
return lean ? `倾向${lean},把握不足,交给主持人 ${host}` : `交给主持人 ${host}`;
|
|
96
113
|
}
|
|
97
114
|
|
|
98
115
|
/** Delivers free-room requests through the same software adapters as discussions. */
|
|
@@ -105,6 +122,8 @@ export class FreeRoomRunner {
|
|
|
105
122
|
this.fetch = fetch_impl;
|
|
106
123
|
this.routed = new Set();
|
|
107
124
|
this.decisions = new Set();
|
|
125
|
+
// Routed requests whose decision is still pending: their host waits, so one pick replaces the host's start.
|
|
126
|
+
this.routing = new Set();
|
|
108
127
|
this.children = new Set();
|
|
109
128
|
this.members = new Map();
|
|
110
129
|
this.exited = new Set();
|
|
@@ -158,38 +177,59 @@ export class FreeRoomRunner {
|
|
|
158
177
|
for (const member of inbox.members) {
|
|
159
178
|
const row = this.row(member.name);
|
|
160
179
|
if (row && LIVE.includes(row.status)) await this.reconcile(member, row, active, inbox.execution_minutes);
|
|
161
|
-
else if (!inbox.archived && member.pending.length && !this.members.get(member.name)?.size
|
|
180
|
+
else if (!inbox.archived && member.pending.length && !this.members.get(member.name)?.size
|
|
181
|
+
&& !member.pending.some(request => this.routing.has(request.id))) await this.start(member, row);
|
|
162
182
|
}
|
|
163
183
|
}
|
|
164
184
|
/**
|
|
165
|
-
* Smart scheduling: for each new unnamed message routed to the host, ask the provider
|
|
166
|
-
*
|
|
185
|
+
* Smart scheduling: for each new unnamed message routed to the host, ask the provider whether another woken
|
|
186
|
+
* member suits it better and apply a confident pick. The host waits for the answer (or its failure) before starting.
|
|
167
187
|
*/
|
|
168
188
|
recommend_unnamed(inbox, room) {
|
|
169
189
|
// Manual and online members are listed apart; they are never delivered to but may host or be recommended.
|
|
170
190
|
const everyone = [...inbox.members, ...(inbox.observers || [])];
|
|
171
191
|
const host = everyone.find(member => member.role === 'host');
|
|
172
192
|
const waiting = host?.pending.filter(request => request.purpose === 'route' && !this.routed.has(request.id)) || [];
|
|
173
|
-
const members = everyone.filter(member => member !== host).map(member => ({ id: member.id, name: member.name,
|
|
174
|
-
description: this.config.participants[member.name] ? member_description(member.name, this.config.participants[member.name])
|
|
175
|
-
: `${member.name}(${member.access === 'online' ? '网页 AI' : '手动接入'},由用户代为触发)` }));
|
|
176
193
|
for (const request of waiting) {
|
|
177
194
|
this.routed.add(request.id);
|
|
178
|
-
if (
|
|
179
|
-
|
|
195
|
+
if (everyone.length < 2) continue;
|
|
196
|
+
this.routing.add(request.id);
|
|
197
|
+
const decision = this.recommend(request, room, everyone).catch(error => this.log(`智能调度 #${request.message_id} 未给出推荐:${error.message}`))
|
|
198
|
+
.finally(() => this.routing.delete(request.id));
|
|
180
199
|
this.decisions.add(decision);
|
|
181
200
|
decision.finally(() => this.decisions.delete(decision));
|
|
182
201
|
}
|
|
183
202
|
}
|
|
184
|
-
/**
|
|
185
|
-
|
|
203
|
+
/** Describe each member as the scheduler sees it: software, project access and the other messages it is on. */
|
|
204
|
+
route_members(everyone, room, message_id) {
|
|
205
|
+
const requests = (room.free?.requests || []).filter(item => ['pending', 'delivering'].includes(item.status));
|
|
206
|
+
return everyone.map(member => {
|
|
207
|
+
const work = member_work(requests, member.name, message_id);
|
|
208
|
+
const profile = this.config.participants[member.name];
|
|
209
|
+
return { id: member.id, name: member.name, role: member.role, description: profile ? member_description(member.name, profile, work)
|
|
210
|
+
: `${member.name}(${member.access === 'online' ? '网页 AI' : '手动接入'},由用户代为触发${work})` };
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/** Decide one message and apply a confident pick: one member, everyone, or no one; otherwise the host keeps it. */
|
|
214
|
+
async recommend(request, room, everyone) {
|
|
186
215
|
const page = await api(this.config, `/messages?after=${Math.max(0, request.message_id - ROUTE_CONTEXT_MESSAGES - 1)}`);
|
|
187
216
|
const message = page.messages.find(item => item.id === request.message_id);
|
|
188
217
|
if (!message) return;
|
|
218
|
+
const described = this.route_members(everyone, room, message.id);
|
|
219
|
+
const host = described.find(member => member.role === 'host');
|
|
220
|
+
const members = described.filter(member => member !== host);
|
|
189
221
|
const decision = await route_free_message(this.config.api_home, this.config.scheduler,
|
|
190
|
-
{ title: room.title, goal: room.goal, context: page.messages, message, host
|
|
191
|
-
this.log(`智能调度 #${message.id}:${decision.member ?
|
|
192
|
-
if (
|
|
222
|
+
{ title: room.title, goal: room.goal, context: page.messages, message, host, members }, this.fetch);
|
|
223
|
+
this.log(`智能调度 #${message.id}:${decision.action}${decision.member ? ` ${decision.member}` : ''}(选择 ${decision.choice ?? '-'},置信度 ${decision.confidence ?? '-'})`);
|
|
224
|
+
if (decision.action === 'host') return this.record_decision(message.id, decision, route_host_label(decision, host.name, members), false);
|
|
225
|
+
if (decision.action !== 'member') {
|
|
226
|
+
// Everyone or no one replaces the host's request in one step; a host that already started keeps the message.
|
|
227
|
+
const applied = await api(this.config, `/recommendations/${message.id}`, { action: decision.action })
|
|
228
|
+
.then(() => true, error => { this.log(`智能调度 #${message.id} 未能改派:${error.message}`); return false; });
|
|
229
|
+
const names = described.map(member => member.name).join('、');
|
|
230
|
+
const label = decision.action === 'all' ? `请全体各自回应(${names})` : '无需回应,未唤醒任何成员';
|
|
231
|
+
return this.record_decision(message.id, decision, applied ? label : `${label}(未生效,仍由主持人处理)`, applied);
|
|
232
|
+
}
|
|
193
233
|
await api(this.config, '/recommendations', { message_id: message.id, member_id: members.find(member => member.name === decision.member).id,
|
|
194
234
|
provider: this.config.scheduler.provider, confidence: decision.confidence });
|
|
195
235
|
// A confident pick is applied at once, interrupting the host; if that fails the recommendation stays for the user to adopt.
|
|
@@ -201,8 +241,9 @@ export class FreeRoomRunner {
|
|
|
201
241
|
async record_decision(message_id, decision, label, applied) {
|
|
202
242
|
try {
|
|
203
243
|
await api(this.config, '/scheduler-decisions', { kind: 'interjection', label, confidence: decision.confidence ?? null,
|
|
204
|
-
applied, provider: this.config.scheduler.provider, after_message_id: message_id,
|
|
205
|
-
context: { model: this.config.scheduler.model, threshold: ROUTE_CONFIDENCE,
|
|
244
|
+
applied, provider: this.config.scheduler.provider, after_message_id: message_id, detail: decision.detail,
|
|
245
|
+
context: { model: this.config.scheduler.model, threshold: ROUTE_CONFIDENCE, wide_threshold: ROUTE_WIDE_CONFIDENCE,
|
|
246
|
+
prompt: ROUTE_PROMPT_VERSION, package: PACKAGE_VERSION } });
|
|
206
247
|
} catch (error) { this.log(`智能调度 #${message_id} 决策记录未保存:${error.message}`); }
|
|
207
248
|
}
|
|
208
249
|
/** Reserve with a persisted client id before invoking any software. */
|
package/runner/scheduler.mjs
CHANGED
|
@@ -9,10 +9,14 @@ export const SCHEDULER_PROVIDERS = {
|
|
|
9
9
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
10
10
|
/** Below this confidence a decision stays with the host, exactly as without scheduling. */
|
|
11
11
|
export const ROUTE_CONFIDENCE = 0.7;
|
|
12
|
+
/** Waking everyone or no one costs more when wrong (N runs, or an ignored message), so it needs more certainty. */
|
|
13
|
+
export const ROUTE_WIDE_CONFIDENCE = 0.85;
|
|
12
14
|
/** Bump when the routing question or its option descriptions change, so ratings can be compared across wordings. */
|
|
13
|
-
export const ROUTE_PROMPT_VERSION =
|
|
15
|
+
export const ROUTE_PROMPT_VERSION = 3;
|
|
14
16
|
const HOST_CHOICE = '__host__';
|
|
15
17
|
const NOTE_CHOICE = '__note__';
|
|
18
|
+
const ALL_CHOICE = '__all__';
|
|
19
|
+
const NONE_CHOICE = '__none__';
|
|
16
20
|
const CONTEXT_MESSAGES = 10;
|
|
17
21
|
/** A free-room route sees a longer stretch, enough to tell who has not spoken for a while. */
|
|
18
22
|
export const ROUTE_CONTEXT_MESSAGES = 20;
|
|
@@ -99,7 +103,8 @@ function unsure_label(lean) {
|
|
|
99
103
|
/** Shorten a message so recent context stays small; the decision never needs whole answers. */
|
|
100
104
|
function excerpt(message) {
|
|
101
105
|
const text = String(message.text || '').replace(/\s+/g, ' ').trim();
|
|
102
|
-
|
|
106
|
+
const reply = message.reply_to ? `(回复 #${message.reply_to})` : '';
|
|
107
|
+
return `#${message.id} ${message.author}${reply}:${text.length > MAX_MESSAGE_CHARS ? `${text.slice(0, MAX_MESSAGE_CHARS)}…` : text}`;
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
/** The room's purpose in the user's words, so a decision can tell drifting from progress. */
|
|
@@ -125,18 +130,27 @@ export function certainty(answer) {
|
|
|
125
130
|
}
|
|
126
131
|
|
|
127
132
|
/**
|
|
128
|
-
*
|
|
129
|
-
* Members are [{name, description}] without the host;
|
|
133
|
+
* Decide who answers one unnamed free-room message: whether its addressee is already settled by the text or the thread.
|
|
134
|
+
* Members are [{name, description}] without the host; host is {name, description}. Returns action member (with member),
|
|
135
|
+
* all, none, or host for anything unsettled or not confident enough.
|
|
130
136
|
*/
|
|
131
137
|
export async function route_free_message(home, scheduler, { title, goal, context, message, host, members }, fetch_impl) {
|
|
132
138
|
const choices = [...members.map(member => ({ value: member.name, description: member.description })),
|
|
133
|
-
{ value: HOST_CHOICE, description:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
139
|
+
{ value: HOST_CHOICE, description: `主持人 ${host.description}:消息在延续主持人自己的回答,或对象不明确,或需要分工、排先后、互相回应` },
|
|
140
|
+
{ value: ALL_CHOICE, description: '全体成员(含主持人)各自回应一次,彼此不需要配合,如打招呼、自我介绍、各说一句看法' },
|
|
141
|
+
{ value: NONE_CHOICE, description: '无需回应:只是确认、致谢或旁注,不需要任何人回答' }];
|
|
142
|
+
const questions = [{ type: 'choice', name: 'handler', choices,
|
|
143
|
+
instructions: '这条用户消息没有用 @ 点名成员。判断它的回应对象是否已经确定:正文叫了某位成员的名字或在向其提问;在延续某位成员的回答(追问、要求修改、引用,或“回复”指向其消息);在补充或纠正一条正由某位成员处理的消息。以上情况选该成员,是主持人就选主持人。要求所有成员各自回应一次选全体;只是确认、致谢或旁注选无需回应;对象不明确,或需要分工、排先后、互相回应时选主持人。' }];
|
|
144
|
+
const answers = await decide(home, scheduler, scheduler_state(title, context, message, goal, '', {}, ROUTE_CONTEXT_MESSAGES), questions, fetch_impl);
|
|
145
|
+
const detail = detail_of(questions, answers);
|
|
146
|
+
const choice = answers.handler?.choice ?? null;
|
|
147
|
+
const confidence = certainty(answers.handler);
|
|
148
|
+
const wide = choice === ALL_CHOICE || choice === NONE_CHOICE;
|
|
149
|
+
const sure = confidence !== null && confidence >= (wide ? ROUTE_WIDE_CONFIDENCE : ROUTE_CONFIDENCE);
|
|
150
|
+
if (sure && choice === ALL_CHOICE) return { action: 'all', choice, confidence, detail };
|
|
151
|
+
if (sure && choice === NONE_CHOICE) return { action: 'none', choice, confidence, detail };
|
|
152
|
+
if (sure && members.some(item => item.name === choice)) return { action: 'member', member: choice, choice, confidence, detail };
|
|
153
|
+
return { action: 'host', choice, confidence, detail };
|
|
140
154
|
}
|
|
141
155
|
|
|
142
156
|
/**
|
package/web/app.js
CHANGED
|
@@ -989,7 +989,7 @@ function reset_create_form() {
|
|
|
989
989
|
state.create_parent = null;
|
|
990
990
|
state.launch.create = true;
|
|
991
991
|
state.launch.workspace = {};
|
|
992
|
-
state.launch.scheduler =
|
|
992
|
+
state.launch.scheduler = default_scheduler();
|
|
993
993
|
state.named_followup = false;
|
|
994
994
|
state.create_discussion = false;
|
|
995
995
|
state.launch.profiles = JSON.parse(JSON.stringify(state.launch.saved_profiles || {}));
|
|
@@ -1744,6 +1744,8 @@ async function load_launch_settings() {
|
|
|
1744
1744
|
scheduler_providers: settings.scheduler_providers || {}, error: "",
|
|
1745
1745
|
notice: local_connection ? "已连接本机服务,讨论保存在当前网站" : "" });
|
|
1746
1746
|
Object.values(state.launch.profiles).forEach(fill_api_key);
|
|
1747
|
+
// Settings arrive after the form was reset; only an untouched draft gets the default.
|
|
1748
|
+
if (!Object.keys(state.launch.scheduler).length) state.launch.scheduler = default_scheduler();
|
|
1747
1749
|
load_room_models();
|
|
1748
1750
|
} catch (error) { state.launch.available = false; state.launch.room_models = null; state.launch.error = error.message; }
|
|
1749
1751
|
finally { refresh_launch_recoveries(); render_launch_settings(); publish_view(); }
|
|
@@ -1953,6 +1955,15 @@ function write_create_discussion(value) {
|
|
|
1953
1955
|
publish_view();
|
|
1954
1956
|
}
|
|
1955
1957
|
|
|
1958
|
+
/** A fresh form turns smart scheduling on when some provider already has a saved key. */
|
|
1959
|
+
function default_scheduler() {
|
|
1960
|
+
const providers = state.launch.scheduler_providers || {};
|
|
1961
|
+
const provider = Object.keys(providers).find((name) => state.launch.api_keys?.[providers[name].base_url]);
|
|
1962
|
+
if (!provider) return {};
|
|
1963
|
+
const { base_url, models } = providers[provider];
|
|
1964
|
+
return { enabled: true, provider, model: models[0] || "", api_key_ref: state.launch.api_keys[base_url] };
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1956
1967
|
/** Change the smart scheduling draft; a new provider starts from its preset model and any key saved for its root. */
|
|
1957
1968
|
function write_scheduler(field, value) {
|
|
1958
1969
|
const scheduler = state.launch.scheduler;
|