ai_co_discuss 0.2.35 → 0.2.37
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/README.md +1 -1
- package/backend/runtime.py +10 -0
- package/backend/server.py +4 -0
- package/docs/architecture.md +4 -4
- package/docs/cli.md +1 -1
- package/docs/runner.md +441 -441
- package/docs/workflow.md +3 -3
- package/package.json +1 -1
- package/runner/api_participant.mjs +38 -2
- package/runner/discuss.py +8 -1
- package/runner/discussion_api.mjs +4 -2
- package/runner/discussion_runner.mjs +31 -8
- package/runner/scheduler.mjs +19 -0
- package/web/app.js +55 -6
- package/web/modern.css +1 -1
- package/web/modern.js +1504 -1415
package/docs/workflow.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## 选择推进方式
|
|
6
6
|
|
|
7
|
-
- **讨论(全自动)**:主持人与专家都由本机服务唤醒(CLI / App / API)。本机软件与权限配置完成后明确启动,可按轮次、总时长上限或手动停止结束。主持创建正式安排即结束本步;专家自行认领、回答,程序再续接原主持。自动任务不另建原生定时。项目及 cwd 可选,分别属于本机接入配置。本地专家可单独选“手动接入”:成员记录带 `adapter: manual`,不被唤醒,凭自己的成员凭证认领发言权并提交安排内的回答,接入程序不派发它的事件,回答保存后照常续接主持人;主持人必须由本机唤醒。手动专家若运行在 Claude Code 或 Codex
|
|
7
|
+
- **讨论(全自动)**:主持人与专家都由本机服务唤醒(CLI / App / API)。本机软件与权限配置完成后明确启动,可按轮次、总时长上限或手动停止结束。主持创建正式安排即结束本步;专家自行认领、回答,程序再续接原主持。自动任务不另建原生定时。项目及 cwd 可选,分别属于本机接入配置。本地专家可单独选“手动接入”:成员记录带 `adapter: manual`,不被唤醒,凭自己的成员凭证认领发言权并提交安排内的回答,接入程序不派发它的事件,回答保存后照常续接主持人;主持人必须由本机唤醒。手动专家若运行在 Claude Code 或 Codex 中,任一带凭据的 CLI 调用(首条通常是 `room view`)都会附带该会话的 `CLAUDE_CODE_SESSION_ID` / `CODEX_THREAD_ID`(Codex 桌面应用会话转为 `codex`,其余转为 `codex_cli`),本机服务核对本机存在该会话后自动把它转为托管(`access=runner`、借用会话),之后每轮照常唤醒;服务没有 Claude 配置、找不到会话或其他软件的手动专家仍停在手动。手动专家不回答时讨论停在该安排,由用户取消安排(主持人被唤醒重新安排),或受轮次与总时长上限约束,没有单独的自动超时。模式在手动与全自动间切换时,带 `adapter: manual` 的专家保持手动(转为托管后不再带此标记)。网页 AI 会打断自动推进,只进入自由协作。
|
|
8
8
|
- **自由协作**:你或 AI 主持人点名一位或多位成员,各自在原会话处理请求;没有讨论发言权、安排和轮次。每位成员单独选择本机唤醒、手动接入或网页代发,见下方 [自由协作房间](#自由协作房间)。
|
|
9
9
|
- **手动接入**:自由协作成员的一种接入方式。本地成员持自己的凭据文件自行读写,可由人工或软件原生定时触发;网页成员只能阅读房间,回答需转交代发;网页 AI 从其服务器读取阅读链接,本机网站对它不可达,此时由用户粘贴材料。只有这两种成员时建房无需本机接入服务。复制参与指令、下载凭据文件或复制凭证文本都不代表已经送达、接入或唤醒。
|
|
10
10
|
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
新建讨论时可选“点名追问”,默认关闭;之前创建的房间视为开启,保持原有方式。
|
|
44
44
|
|
|
45
45
|
- **开启**:每轮结束后主持人读完回答,给每位专家写针对性的追问。
|
|
46
|
-
- **关闭**:首轮仍由主持人拆题出题;之后专家统一按“先读上一轮回答、回应不同意或需补充的观点、可 @
|
|
46
|
+
- **关闭**:首轮仍由主持人拆题出题;之后专家统一按“先读上一轮回答、回应不同意或需补充的观点、可 @ 彼此提问”发言,主持人用自己的主持说明引导方向。同时开启“智能调度”时,每轮结束由决策模型判断:还有分歧就自动开下一轮(先请最需要回应的专家,不是上一轮最后发言者),可以收束、跑偏或卡住才唤醒主持人。决策模型每次只安排一位发言人,那位答完后再判断下一位(含点“跳过”、邀请或成员变动后队列为空的情况);手动接入的专家不由它安排。自动开轮受轮次与时长上限约束,到限照常交给主持人处理。
|
|
47
47
|
|
|
48
48
|
## 讨论上限
|
|
49
49
|
|
|
@@ -110,7 +110,7 @@ stateDiagram-v2
|
|
|
110
110
|
|
|
111
111
|
主持人可以一次排好本轮顺序(`turn create --plan-file`):第一项立即生效,其余为 `queued`。专家保存回答后,网站在同一事务中把发言权交给下一位排队专家;末位答完才交回主持人。接力不发通知,需要逐个转交的在线专家不放进整轮。
|
|
112
112
|
|
|
113
|
-
用户留言或修改目标、背景、边界、焦点会记为待主持人处理(`held_since`),不打断正在发言的专家;该专家保存回答后交回主持人而不接力。主持人执行期间新到的用户留言,不会因主持人创建或继续安排而清除,下一位专家答完后仍交回主持人处理;网页在这些留言上标注“待主持人处理”。要立即生效,用户在进度看板点「跳过」:当前安排取消、本次执行作废并交回主持人,排队安排保留。主持人处理后用 `turn continue` 按原计划继续,或直接创建新安排,剩余排队改为 `skipped
|
|
113
|
+
用户留言或修改目标、背景、边界、焦点会记为待主持人处理(`held_since`),不打断正在发言的专家;该专家保存回答后交回主持人而不接力。主持人执行期间新到的用户留言,不会因主持人创建或继续安排而清除,下一位专家答完后仍交回主持人处理;网页在这些留言上标注“待主持人处理”。要立即生效,用户在进度看板点「跳过」:当前安排取消、本次执行作废并交回主持人,排队安排保留。主持人处理后用 `turn continue` 按原计划继续,或直接创建新安排,剩余排队改为 `skipped`。建房时开启“智能调度”的讨论,留言由决策模型先判断:有把握是“仅补充信息”就按原计划继续,是在问某位专家就先请该专家回应,其余情况才交回主持人;每次判断都在对话中留一行“智能调度”记录。暂停保留当前与排队安排,点“重启”由原专家继续;若要跳过当前安排,可点“跳过”,取消该安排后恢复讨论并交回主持人,后续队列保留;邀请只在队尾追加,提前结束、到限处理、收束、更换主持人或专家暂离、请回、移除才会作废全部排队安排。
|
|
114
114
|
|
|
115
115
|
本地专家自行领取与归还;在线专家由主持人以自身操作者身份、指定原专家代操作。主持人不能代领另一位本地专家的发言权。主动归还后未完成安排仍存在,主持人需取消或处理。正常推进不超时夺取;配置的到限处理会取消未完成安排并撤销旧自动执行,再交回主持人。
|
|
116
116
|
|
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { EventEmitter } from 'node:events';
|
|
4
4
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
+
import { setTimeout } from 'node:timers/promises';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import { api_generate, api_history, api_tool_results, read_api_key } from './discussion_api.mjs';
|
|
8
9
|
|
|
@@ -10,6 +11,10 @@ const MAX_MODEL_TURNS = 24;
|
|
|
10
11
|
// API participants follow the same skills that local software loads, read fresh for every execution.
|
|
11
12
|
const SKILLS = fileURLToPath(new URL('../.agents/skills/', import.meta.url));
|
|
12
13
|
const SITE_TIMEOUT_MS = 10_000;
|
|
14
|
+
const REPLY_EXCERPT_CHARS = 300;
|
|
15
|
+
const MODEL_RETRIES = 2;
|
|
16
|
+
const MODEL_RETRY_DELAY_MS = 3000;
|
|
17
|
+
const NO_TOOL_REMINDER = '普通文本不会被保存。请调用写入工具提交真实结果;确实无法继续才不调用工具直接结束。';
|
|
13
18
|
const TEXT = { type: 'string' };
|
|
14
19
|
const NUMBER = { type: 'integer', minimum: 1 };
|
|
15
20
|
const ROOM_FIELDS = ['id', 'title', 'goal', 'host', 'user_request', 'background', 'boundaries', 'experts', 'phase', 'focus',
|
|
@@ -150,6 +155,24 @@ export function system_prompt(context) {
|
|
|
150
155
|
return [environment, skill_text('discuss-participant'), ...(host && context.mode !== 'free' ? [skill_text('discuss-host')] : [])].join('\n\n---\n\n');
|
|
151
156
|
}
|
|
152
157
|
|
|
158
|
+
/** Plain text of the model's latest reply, so a stop without a write can show its stated reason. */
|
|
159
|
+
function last_reply_text(history) {
|
|
160
|
+
const item = history.at(-1) || {};
|
|
161
|
+
const parts = item.parts || (Array.isArray(item.content) ? item.content : [{ text: item.content }]);
|
|
162
|
+
return parts.map(part => (typeof part === 'string' ? part : part.text || '')).join('').trim().slice(0, REPLY_EXCERPT_CHARS);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Retry a model request that failed before any room write; history is only extended on success. */
|
|
166
|
+
async function generate_with_retry(profile, key, state, signal) {
|
|
167
|
+
for (let attempt = 0; ; attempt++) {
|
|
168
|
+
try { return await api_generate(profile, key, state, signal); }
|
|
169
|
+
catch (error) {
|
|
170
|
+
if (!error.retryable || attempt >= MODEL_RETRIES) throw error;
|
|
171
|
+
await setTimeout(MODEL_RETRY_DELAY_MS * (attempt + 1), undefined, { signal });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
153
176
|
/** Run bounded tool turns while the parent dispatcher continues renewing leases and observing pause. */
|
|
154
177
|
async function run_participant(profile, key, context) {
|
|
155
178
|
const room = await read_room(context);
|
|
@@ -158,9 +181,22 @@ async function run_participant(profile, key, context) {
|
|
|
158
181
|
execution_id: context.execution_id, requests: context.requests, read_after: context.read_after,
|
|
159
182
|
...(context.scheduler_suggestions?.length ? { scheduler_suggestions: context.scheduler_suggestions } : {}) } });
|
|
160
183
|
const state = { system: system_prompt(context), history: api_history(profile, prompt), tools: definitions };
|
|
184
|
+
let reminded = false;
|
|
185
|
+
let used_tools = false;
|
|
161
186
|
for (let index = 0; index < MAX_MODEL_TURNS; index++) {
|
|
162
|
-
const calls = await
|
|
163
|
-
if (!calls.length)
|
|
187
|
+
const calls = await generate_with_retry(profile, key, state, context.signal);
|
|
188
|
+
if (!calls.length) {
|
|
189
|
+
// Some models answer in plain text mid-run; one reminder avoids pausing on a recoverable slip.
|
|
190
|
+
if (reminded) {
|
|
191
|
+
const reply = last_reply_text(state.history);
|
|
192
|
+
throw new Error(used_tools ? `模型未提交结果就结束了本次执行,可能在等你决定或补充${reply ? `。模型说明:${reply}` : ''}`
|
|
193
|
+
: `模型未调用房间工具保存结果,请核对模型是否支持工具调用${reply ? `(其回复:${reply})` : ''}`);
|
|
194
|
+
}
|
|
195
|
+
reminded = true;
|
|
196
|
+
state.history.push(profile.api_protocol === 'gemini' ? { role: 'user', parts: [{ text: NO_TOOL_REMINDER }] } : { role: 'user', content: NO_TOOL_REMINDER });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
used_tools = true;
|
|
164
200
|
const results = [];
|
|
165
201
|
for (const call of calls) {
|
|
166
202
|
context.signal.throwIfAborted();
|
package/runner/discuss.py
CHANGED
|
@@ -894,7 +894,14 @@ def access_headers(args, site, path):
|
|
|
894
894
|
token = access.get("token")
|
|
895
895
|
if not isinstance(token, str) or not 32 <= len(token) <= 256:
|
|
896
896
|
raise CliError("凭据文件中的 token 无效")
|
|
897
|
-
|
|
897
|
+
headers = {"X-Discussion-Token": token}
|
|
898
|
+
# A hosting Claude Code or Codex session reports itself on any call, so a manual member can be taken over before it first speaks.
|
|
899
|
+
for variable, client in (("CLAUDE_CODE_SESSION_ID", "claude"), ("CODEX_THREAD_ID", "codex")):
|
|
900
|
+
session_id = os.getenv(variable, "").strip()
|
|
901
|
+
if session_id:
|
|
902
|
+
headers.update({"X-Discussion-Session": session_id, "X-Discussion-Session-Client": client})
|
|
903
|
+
break
|
|
904
|
+
return headers
|
|
898
905
|
|
|
899
906
|
|
|
900
907
|
def preview_body(value):
|
|
@@ -116,8 +116,10 @@ async function provider_fetch(profile, key, request, signal) {
|
|
|
116
116
|
body: request.body === undefined ? undefined : JSON.stringify(request.body),
|
|
117
117
|
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
118
118
|
});
|
|
119
|
-
} catch {
|
|
120
|
-
|
|
119
|
+
} catch {
|
|
120
|
+
throw Object.assign(new Error(signal?.aborted ? 'API 执行已取消' : 'API 请求超时或连接失败:可能是网络暂时不通或 Base URL 有误,请核对'), { retryable: !signal?.aborted });
|
|
121
|
+
}
|
|
122
|
+
if (!response.ok) throw Object.assign(new Error(api_status_message(response.status)), { retryable: response.status === 429 || response.status >= 500 });
|
|
121
123
|
try { return await response.json(); } catch { throw new Error('模型 API 返回的内容不是有效 JSON'); }
|
|
122
124
|
}
|
|
123
125
|
|
|
@@ -9,7 +9,7 @@ import { parseArgs } from 'node:util';
|
|
|
9
9
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
10
10
|
import { atomic_json, deliver_task, remove_agy_projects, setup_sidecar, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
|
|
11
11
|
import { read_api_key } from './discussion_api.mjs';
|
|
12
|
-
import { decide_interjection, decide_round, validate_scheduler } from './scheduler.mjs';
|
|
12
|
+
import { decide_interjection, decide_next_speaker, decide_round, validate_scheduler } from './scheduler.mjs';
|
|
13
13
|
|
|
14
14
|
const POLL_MS = 2000;
|
|
15
15
|
/** Headless CLIs know their session at start, so the runner enters the execution instead of spending model turns on it. */
|
|
@@ -41,6 +41,11 @@ export function peer_round_question(round) {
|
|
|
41
41
|
return `第 ${round} 轮:先读取上一轮其他专家的最新回答,回应你不同意或需要补充的观点;被其他专家 @ 提问时优先回答,也可以 @ 某位专家提出问题。`;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** The prompt for an expert speaking for the first time, such as a newly invited one. */
|
|
45
|
+
function opening_question(phase) {
|
|
46
|
+
return phase === 'initial' ? '欢迎加入:请先读取议题,独立给出你的开场观点。' : '欢迎加入:请先读取议题和已有回答,给出你的观点。';
|
|
47
|
+
}
|
|
48
|
+
|
|
44
49
|
/** Ask one expert to answer a user's interjection before the rest of the plan continues. */
|
|
45
50
|
function interjection_question(message) {
|
|
46
51
|
const text = String(message.text || '').replace(/\s+/g, ' ').trim();
|
|
@@ -356,6 +361,8 @@ export class DiscussionRunner {
|
|
|
356
361
|
if (room.limits?.reached) return skip('round', '已到讨论上限');
|
|
357
362
|
const page = await api(this.config, `/messages?after=${Math.max(0, room.latest_id - CONTEXT_MESSAGES)}`);
|
|
358
363
|
const experts = Object.keys(room.experts || {});
|
|
364
|
+
// Only experts the runner can wake are offered to the decision model; manual ones wait for the host.
|
|
365
|
+
const automatic = experts.filter(name => this.config.participants[name]);
|
|
359
366
|
const context = { title: room.title, goal: room.goal, user_request: room.user_request, context: page.messages, host: room.host };
|
|
360
367
|
if (room.held_since) {
|
|
361
368
|
const pending = page.messages.filter(item => item.id >= room.held_since && item.author === '用户');
|
|
@@ -363,7 +370,7 @@ export class DiscussionRunner {
|
|
|
363
370
|
if (pending.length !== 1) return skip('interjection', pending.length ? '同时有多条留言' : '话题设置有修改');
|
|
364
371
|
const message = pending[0];
|
|
365
372
|
const decision = await decide_interjection(this.config.api_home, this.config.scheduler,
|
|
366
|
-
{ ...context, message, experts:
|
|
373
|
+
{ ...context, message, experts: automatic.filter(name => name !== room.host) }, this.fetch);
|
|
367
374
|
const plan = { ...decision, kind: 'interjection', message_id: message.id, after_message_id: anchor };
|
|
368
375
|
if (decision.action === 'ask') {
|
|
369
376
|
return { ...plan, turns: [{ expert: decision.expert, question: interjection_question(message) },
|
|
@@ -377,17 +384,33 @@ export class DiscussionRunner {
|
|
|
377
384
|
const round_open = !room.rounds?.at(-1)?.completed;
|
|
378
385
|
if (round_open) {
|
|
379
386
|
const restored = await this.restore_voided_plan(room, experts, anchor);
|
|
380
|
-
|
|
387
|
+
if (restored) return restored;
|
|
388
|
+
const spoken = new Set(room.rounds?.at(-1)?.speakers || []);
|
|
389
|
+
// Experts without local software cannot be woken by the runner, so only automatic ones are candidates.
|
|
390
|
+
const waiting = experts.filter(name => name !== room.host && !spoken.has(name) && this.config.participants[name]);
|
|
391
|
+
if (!waiting.length) return skip('round', '本轮未发言的专家都是手动接入,无法自动安排');
|
|
392
|
+
// An expert the user just skipped is not picked again unless nobody else is left.
|
|
393
|
+
const skipped = room.current_turn?.status === 'cancelled' ? room.current_turn.expert : null;
|
|
394
|
+
const candidates = waiting.filter(name => name !== skipped);
|
|
395
|
+
return this.next_speaker_plan(room, context, candidates.length ? candidates : waiting, anchor);
|
|
381
396
|
}
|
|
382
397
|
if (room.named_followup) return skip('round', '点名追问已开启,由主持人安排下一轮');
|
|
383
398
|
const decision = await decide_round(this.config.api_home, this.config.scheduler,
|
|
384
|
-
{ ...context, experts, last_speaker: room.current_turn?.expert }, this.fetch);
|
|
399
|
+
{ ...context, experts: automatic, last_speaker: room.current_turn?.expert }, this.fetch);
|
|
385
400
|
const plan = { ...decision, kind: 'round', after_message_id: anchor };
|
|
386
401
|
if (decision.action !== 'continue') return plan;
|
|
387
|
-
|
|
388
|
-
const order = [decision.first, ...experts.filter(name => name !== decision.first)].slice(0, MAX_PLAN_TURNS);
|
|
402
|
+
// Only the first speaker is arranged; each answer ends with an empty queue, so the next one is judged then.
|
|
389
403
|
return { ...plan, phase: room.phase === 'initial' ? 'discussing' : undefined,
|
|
390
|
-
turns:
|
|
404
|
+
turns: [{ expert: decision.first, question: peer_round_question(room.completed_rounds + 1) }] };
|
|
405
|
+
}
|
|
406
|
+
/** Ask the decision model for the next speaker among candidates; an expert who never spoke gets the opening prompt. */
|
|
407
|
+
async next_speaker_plan(room, context, candidates, anchor) {
|
|
408
|
+
const next = await decide_next_speaker(this.config.api_home, this.config.scheduler, { ...context, candidates }, this.fetch);
|
|
409
|
+
const plan = { ...next, kind: 'round', after_message_id: anchor };
|
|
410
|
+
if (next.action !== 'next') return plan;
|
|
411
|
+
const has_spoken = (room.rounds || []).some(round => round.speakers.includes(next.expert));
|
|
412
|
+
const question = has_spoken ? peer_round_question(room.completed_rounds + 1) : opening_question(room.phase);
|
|
413
|
+
return { ...plan, turns: [{ expert: next.expert, question }] };
|
|
391
414
|
}
|
|
392
415
|
/**
|
|
393
416
|
* A roster change voids queued turns, leaving them skipped after the last real arrangement. Put back the ones whose
|
|
@@ -402,7 +425,7 @@ export class DiscussionRunner {
|
|
|
402
425
|
const spoken = new Set(room.rounds?.at(-1)?.speakers || []);
|
|
403
426
|
const planned = new Set(voided.map(turn => turn.expert));
|
|
404
427
|
const joining = experts.filter(name => !spoken.has(name) && !planned.has(name));
|
|
405
|
-
const opening = room.phase
|
|
428
|
+
const opening = opening_question(room.phase);
|
|
406
429
|
const plan_turns = [...voided.map(({ expert, question }) => ({ expert, question })), ...joining.map(expert => ({ expert, question: opening }))].slice(0, MAX_PLAN_TURNS);
|
|
407
430
|
if (!plan_turns.length) return null;
|
|
408
431
|
const label = `成员有变化:${voided.length ? `补回原计划(${voided.map(turn => turn.expert).join('、')})` : '原计划成员已离开'}${joining.length ? `,请 ${joining.join('、')} 开场` : ''}`;
|
package/runner/scheduler.mjs
CHANGED
|
@@ -126,6 +126,25 @@ export async function decide_interjection(home, scheduler, { title, goal, user_r
|
|
|
126
126
|
return { action: 'host', confidence, label: choice === HOST_CHOICE && sure ? '这条留言需要主持人处理' : '拿不准这条留言如何处理,交给主持人' };
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Mid-round with no queued turn left: pick the next speaker among the experts who can be woken and have not spoken
|
|
131
|
+
* this round, or hand back to the host when unsure or when the discussion needs steering.
|
|
132
|
+
*/
|
|
133
|
+
export async function decide_next_speaker(home, scheduler, { title, goal, user_request, context, host, candidates }, fetch_impl) {
|
|
134
|
+
const recent = context.slice(-CONTEXT_MESSAGES).map(excerpt);
|
|
135
|
+
const state = [topic_lines(title, goal, user_request), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
|
|
136
|
+
const choices = [...candidates.map(name => ({ value: name, description: `请 ${name} 接着发言` })),
|
|
137
|
+
{ value: HOST_CHOICE, description: `交给主持人 ${host}:讨论偏离目标、卡住,或需要主持人调整安排` }];
|
|
138
|
+
const answers = await decide(home, scheduler, state, [{ type: 'choice', name: 'speaker',
|
|
139
|
+
instructions: '本轮还有专家没发言,安排被取消或用完。结合已有发言判断下一位应请谁发言:优先选被点名追问、最能回应当前焦点的人。', choices }], fetch_impl);
|
|
140
|
+
const choice = answers.speaker?.choice;
|
|
141
|
+
const confidence = certainty(answers.speaker);
|
|
142
|
+
if (candidates.includes(choice) && confidence !== null && confidence >= ROUTE_CONFIDENCE) {
|
|
143
|
+
return { action: 'next', expert: choice, confidence, label: `本轮继续,请 ${choice} 接着发言` };
|
|
144
|
+
}
|
|
145
|
+
return { action: 'host', confidence, label: '拿不准下一位该请谁发言,交给主持人' };
|
|
146
|
+
}
|
|
147
|
+
|
|
129
148
|
/**
|
|
130
149
|
* At the end of a round without named follow-ups: continue with another peer round, or wake the host
|
|
131
150
|
* (to conclude, or because the discussion drifted, repeats itself or is stuck). The first speaker is never the last one.
|
package/web/app.js
CHANGED
|
@@ -1610,7 +1610,7 @@ function participant_model(name, room, saved) {
|
|
|
1610
1610
|
function participant_access(name, room) {
|
|
1611
1611
|
const member = (room?.members || []).find(item => item.name === name);
|
|
1612
1612
|
const adapter = member?.adapter;
|
|
1613
|
-
if (member?.access === "manual") return "
|
|
1613
|
+
if (member?.access === "manual") return "手动启动";
|
|
1614
1614
|
if (adapter === "api") return "API";
|
|
1615
1615
|
if (["codex", "antigravity"].includes(adapter)) return "App";
|
|
1616
1616
|
return room?.experts?.[name] === "cli" ? "CLI" : "在线";
|
|
@@ -2121,6 +2121,55 @@ async function restart_discussion() {
|
|
|
2121
2121
|
show_toast("已重启,原安排会重新交给对应 AI;需本机接入程序保持运行");
|
|
2122
2122
|
}
|
|
2123
2123
|
|
|
2124
|
+
/** Only the failed attempt for the still-retained task can restart this discussion. */
|
|
2125
|
+
function can_retry_execution(execution, room) {
|
|
2126
|
+
const runtime = room.runtime;
|
|
2127
|
+
if (!execution || room.archived || room.phase !== "paused" || runtime?.mode !== "automatic"
|
|
2128
|
+
|| runtime.status !== "paused" || runtime.latest_execution?.id !== execution.id
|
|
2129
|
+
|| runtime.latest_execution.status !== "failed") return false;
|
|
2130
|
+
const turn = room.current_turn;
|
|
2131
|
+
const active_turn = turn && ["pending", "notified"].includes(turn.status);
|
|
2132
|
+
return execution.role === "host" ? !active_turn && execution.participant === room.host
|
|
2133
|
+
: active_turn && turn.id === execution.turn_id && turn.expert === execution.participant;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
/** Recheck the selected failed attempt before resuming its retained task with a new execution. */
|
|
2137
|
+
async function retry_execution(event_id) {
|
|
2138
|
+
const room_id = state.room.id;
|
|
2139
|
+
const room = await request_json(`/api/rooms/${room_id}`);
|
|
2140
|
+
const execution = room.runtime?.failures?.find(item => item.id === event_id);
|
|
2141
|
+
if (!can_retry_execution(execution, room)) throw new Error("执行或安排已变化,请刷新后重试");
|
|
2142
|
+
await request_json(`/api/rooms/${room_id}/runtime`, {
|
|
2143
|
+
action: "start", mode: "automatic", revision: room.runtime.revision, deadline: 0,
|
|
2144
|
+
});
|
|
2145
|
+
if (state.room?.id !== room_id) return;
|
|
2146
|
+
await refresh_room();
|
|
2147
|
+
show_toast(`已重新安排 ${execution.participant} 执行,原失败记录保留`);
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
/** Skip the selected assignment; a paused automatic run resumes with the host instead of retrying it. */
|
|
2151
|
+
async function skip_turn() {
|
|
2152
|
+
const room = state.room;
|
|
2153
|
+
const turn = room.current_turn;
|
|
2154
|
+
if (!turn || !["pending", "notified"].includes(turn.status)) throw new Error("当前没有可跳过的发言安排,请刷新后核对");
|
|
2155
|
+
const room_id = room.id, path = `/api/rooms/${room_id}`;
|
|
2156
|
+
const runtime = room.runtime || {};
|
|
2157
|
+
const resume = runtime.mode === "automatic" && (runtime.status === "paused" || runtime.expired);
|
|
2158
|
+
const revision = runtime.revision;
|
|
2159
|
+
let cancelled = false;
|
|
2160
|
+
try {
|
|
2161
|
+
await request_json(`${path}/turns/${turn.id}`, { actor: "用户", status: "cancelled" });
|
|
2162
|
+
cancelled = true;
|
|
2163
|
+
if (resume) await request_json(`${path}/runtime`, { action: "start", mode: "automatic", revision, deadline: 0 });
|
|
2164
|
+
} catch (error) {
|
|
2165
|
+
if (cancelled && resume) throw new Error(`本次发言已跳过,但未能继续讨论:${error.message}。核对后可点“重启”继续`);
|
|
2166
|
+
throw error;
|
|
2167
|
+
} finally {
|
|
2168
|
+
if (state.room?.id === room_id) await refresh_room();
|
|
2169
|
+
}
|
|
2170
|
+
if (state.room?.id === room_id) show_toast(resume ? "已跳过本次发言并恢复讨论,等待主持人继续安排" : "已跳过本次发言,等待主持人继续安排");
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2124
2173
|
/** End early with a host summary limited to the configured closing reserve. */
|
|
2125
2174
|
async function force_close() {
|
|
2126
2175
|
await request_json(`/api/rooms/${state.room.id}/limits`, { actor: "用户", force: true });
|
|
@@ -2187,7 +2236,7 @@ async function copy_member_join(name, with_credential = false) {
|
|
|
2187
2236
|
let instruction;
|
|
2188
2237
|
if (member.access === "online") instruction = "你是在线只读成员,请在原聊天回答,由用户或授权主持人代发;不要尝试直接写入房间。模型与思考强度在原网页设置。";
|
|
2189
2238
|
else if (member.access === "runner") instruction = `${skills}等待本机程序交付有效任务,按 task 中的 access_file 和 execution 凭据操作${room.mode === "free" ? ";自由协作的回答必须绑定本次有效 execution_id" : ""}。不要另建定时任务;只处理本次有效执行,失效或撤销后停止提交。下载的持久成员私有文件不能替代自动执行凭据。`;
|
|
2190
|
-
else if (with_credential) instruction = `${skills}
|
|
2239
|
+
else if (with_credential) instruction = `${skills}凭证文本在下方:每条 codiscuss 命令都在同一命令内把它原样设为环境变量 DISCUSS_ACCESS(不回显;首条 room view 也要带,否则无法识别你),命令省略 --access-file;先执行 codiscuss room view ${room.id} --site ${location.origin} --as ${name},后续命令保持本站点、房间和身份。模型与思考强度在原软件中设置。${manual_trigger_instruction(room)}${workspace_instruction()}`;
|
|
2191
2240
|
else instruction = `${skills}使用属于你的私有文件 member-${room.id}-${name}.json,由用户提供它在本机的实际完整路径;先执行 codiscuss room view ${room.id} --site ${location.origin} --as ${name} --access-file "实际完整路径",后续命令保持本站点、房间和身份。不要读取或公开文件中的 token;若用户给的是凭证文本而非文件,把它设为环境变量 DISCUSS_ACCESS(不要回显)并省略 --access-file。模型与思考强度在原软件中设置。${manual_trigger_instruction(room)}${workspace_instruction()}`;
|
|
2192
2241
|
const credential = with_credential ? `
|
|
2193
2242
|
凭证文本(含 token,仅限你使用):${JSON.stringify({ site: location.origin, room: room.id, token: member_credentials(room.id)[name].token })}` : "";
|
|
@@ -2205,7 +2254,7 @@ ${instruction}
|
|
|
2205
2254
|
/** How a manual member learns it is its turn: free rooms are pulled by the member, discussions only on the host's assignment. */
|
|
2206
2255
|
function manual_trigger_instruction(room) {
|
|
2207
2256
|
return room.mode === "free" ? "首次复制后,可由人工或软件原生定时触发;自行提交,不必每轮复制回答。"
|
|
2208
|
-
: "
|
|
2257
|
+
: "这是全自动讨论。现在只先了解房间(议题、参与者、已有发言),不要发言,不要自建定时任务;轮到你时会被唤醒。唤醒后先重读最新内容(turn current、新增发言),确认轮到你再认领发言权并提交;没有安排就结束本次检查。";
|
|
2209
2258
|
}
|
|
2210
2259
|
|
|
2211
2260
|
/** Download a private per-room setup file for the independent local runner. */
|
|
@@ -3054,10 +3103,10 @@ async function execute_view_action(name, value) {
|
|
|
3054
3103
|
save_context: () => save_context(event), save_topic: () => save_topic(event),
|
|
3055
3104
|
schedule: () => schedule_turn(event), filter: filter_view, reply: set_view_reply,
|
|
3056
3105
|
cancel_reply, cancel_answer: cancel_turn_reply, prepare_answer: prepare_turn_answer,
|
|
3057
|
-
copy_turn, notify: () => change_turn_status("notified"), cancel_turn: () => change_turn_status("cancelled"),
|
|
3106
|
+
copy_turn, notify: () => change_turn_status("notified"), cancel_turn: () => change_turn_status("cancelled"), skip_turn,
|
|
3058
3107
|
take_floor: () => change_floor("take"), return_floor: () => change_floor("return"),
|
|
3059
3108
|
copy_online: () => copy_context(), copy_cli: () => copy_context("cli"), copy_background,
|
|
3060
|
-
choose_runtime, export_runtime, download_member_access, copy_member_access_text, copy_member_join, copy_member_launch: name => copy_member_join(name, true), pause_runtime, check_limits, pause_discussion, restart_discussion, force_close, conclude_discussion,
|
|
3109
|
+
choose_runtime, export_runtime, download_member_access, copy_member_access_text, copy_member_join, copy_member_launch: name => copy_member_join(name, true), pause_runtime, check_limits, pause_discussion, restart_discussion, retry_execution, force_close, conclude_discussion,
|
|
3061
3110
|
save_launch_defaults, launch_current_room, resume_launch, discard_launch, retry_launch_connection, check_launch_connection: load_launch_settings,
|
|
3062
3111
|
load_room_configuration, save_room_configuration, interrupt_member, change_member, invite_member, set_proxy, copy_online_notice,
|
|
3063
3112
|
prepare_brainstorm, return_conclusion, convert_suggestion, adopt_recommendation,
|
|
@@ -3088,7 +3137,7 @@ window.discuss_view = { snapshot: view_snapshot, write_field: write_view_field,
|
|
|
3088
3137
|
attachment_markup, attachment_size, attachment_file_icon, drop_attachments, paste_attachments,
|
|
3089
3138
|
write_expert: write_view_expert, add_instance: add_view_instance, platform_of, execute: execute_view_action, theme: change_view_theme,
|
|
3090
3139
|
request: request_json, identities: IDENTITIES, display_name, sources: SOURCE_LABELS, phases: PHASE_LABELS,
|
|
3091
|
-
runtime_label, execution_label, limits_label, participant_model, participant_access,
|
|
3140
|
+
runtime_label, execution_label, limits_label, participant_model, participant_access, can_retry_execution,
|
|
3092
3141
|
turns: TURN_LABELS, turn_label, turn_guidance, floor_status, progress_round, progress_speaker, brief_meta, max_message_length: MAX_MESSAGE_LENGTH, provenance: message_provenance, format_time };
|
|
3093
3142
|
|
|
3094
3143
|
restore_theme();
|