ai_co_discuss 0.2.32 → 0.2.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/discuss-host/SKILL.md +2 -1
- package/backend/free_room.py +70 -19
- package/backend/room_import.py +11 -2
- package/backend/schema.sql +28 -4
- package/backend/server.py +46 -8
- package/docs/architecture.md +7 -1
- package/docs/runner.md +7 -3
- package/docs/workflow.md +9 -2
- package/package.json +1 -1
- package/runner/api_participant.mjs +4 -2
- package/runner/discussion_launch.mjs +2 -1
- package/runner/discussion_runner.mjs +115 -36
- package/runner/free_runner.mjs +24 -28
- package/runner/scheduler.mjs +32 -6
- package/web/app.js +19 -5
- package/web/modern.css +1 -1
- package/web/modern.js +978 -856
|
@@ -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 {
|
|
12
|
+
import { decide_interjection, 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. */
|
|
@@ -29,6 +29,23 @@ export const CODEX_ADAPTERS = ['codex', 'codex_cli'];
|
|
|
29
29
|
const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
30
30
|
/** Project files in cwd are read-only unless the launch or participant explicitly grants write. */
|
|
31
31
|
const PROJECT_ACCESS = ['read', 'write'];
|
|
32
|
+
const ACTIVE_PHASES = ['initial', 'discussing', 'reviewing'];
|
|
33
|
+
const MAX_PLAN_TURNS = 10;
|
|
34
|
+
const CONTEXT_MESSAGES = 12;
|
|
35
|
+
/** Session recorded when smart scheduling performs the host's writes without waking its software. */
|
|
36
|
+
const SCHEDULER_SESSION = 'smart-scheduler';
|
|
37
|
+
const MAX_QUOTE_CHARS = 300;
|
|
38
|
+
|
|
39
|
+
/** The shared prompt for rounds bridged without named follow-ups: experts answer and question each other. */
|
|
40
|
+
export function peer_round_question(round) {
|
|
41
|
+
return `第 ${round} 轮:先读取上一轮其他专家的最新回答,回应你不同意或需要补充的观点;被其他专家 @ 提问时优先回答,也可以 @ 某位专家提出问题。`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Ask one expert to answer a user's interjection before the rest of the plan continues. */
|
|
45
|
+
function interjection_question(message) {
|
|
46
|
+
const text = String(message.text || '').replace(/\s+/g, ' ').trim();
|
|
47
|
+
return `用户在 #${message.id} 留言:「${text.length > MAX_QUOTE_CHARS ? `${text.slice(0, MAX_QUOTE_CHARS)}…` : text}」请先读取原文并回应这条留言,再继续你的观点。`;
|
|
48
|
+
}
|
|
32
49
|
|
|
33
50
|
/** Explicit per-room configuration is the only source of software paths and capabilities. */
|
|
34
51
|
export function load_config(path) {
|
|
@@ -180,7 +197,7 @@ export function acquire_lock(directory) {
|
|
|
180
197
|
}
|
|
181
198
|
|
|
182
199
|
/** Write a non-secret task separately from its CLI-only credential file; suggestions are smart-scheduling hints for the host. */
|
|
183
|
-
export function task_files(config, event, token, binding, suggestions = []) {
|
|
200
|
+
export function task_files(config, event, token, binding, suggestions = [], entered = false) {
|
|
184
201
|
const id = `event-${event.id}`;
|
|
185
202
|
const draft_directory = config.draft_directory || `${config.directory}-drafts`;
|
|
186
203
|
mkdirSync(draft_directory, { recursive: true });
|
|
@@ -190,7 +207,7 @@ export function task_files(config, event, token, binding, suggestions = []) {
|
|
|
190
207
|
const skills = [join(config.project, '.agents/skills/discuss-participant/SKILL.md')];
|
|
191
208
|
if (event.role === 'host') skills.push(join(config.project, '.agents/skills/discuss-host/SKILL.md'));
|
|
192
209
|
const adapter = config.participants[event.participant];
|
|
193
|
-
const auto_enter = AUTO_ENTER_ADAPTERS.has(adapter.adapter);
|
|
210
|
+
const auto_enter = entered || AUTO_ENTER_ADAPTERS.has(adapter.adapter);
|
|
194
211
|
const skills_directory = join(config.project, '.agents', 'skills');
|
|
195
212
|
// Codex and agy run commands through PowerShell on Windows, whose execution policy blocks npm's codiscuss.ps1 shim.
|
|
196
213
|
const cli = process.platform === 'win32' && adapter.adapter !== 'claude' ? 'codiscuss.cmd' : 'codiscuss';
|
|
@@ -216,7 +233,7 @@ ${readFileSync(task_path, 'utf8')}
|
|
|
216
233
|
|
|
217
234
|
/** A running dispatcher retains only the processes it created itself. */
|
|
218
235
|
export class DiscussionRunner {
|
|
219
|
-
constructor(config, store, deliver = deliver_task, log = () => {}) {
|
|
236
|
+
constructor(config, store, deliver = deliver_task, log = () => {}, fetch_impl = fetch) {
|
|
220
237
|
this.config = config;
|
|
221
238
|
this.store = store;
|
|
222
239
|
this.deliver = deliver;
|
|
@@ -226,7 +243,7 @@ export class DiscussionRunner {
|
|
|
226
243
|
this.running = new Map();
|
|
227
244
|
this.seen = new Set();
|
|
228
245
|
this.blocked = new Set();
|
|
229
|
-
this.
|
|
246
|
+
this.fetch = fetch_impl;
|
|
230
247
|
}
|
|
231
248
|
/** Append one timestamped stage to the event's trace file when CODISCUSS_TRACE=1; tracing must never affect delivery. */
|
|
232
249
|
trace(id, stage, detail = {}) {
|
|
@@ -235,33 +252,34 @@ export class DiscussionRunner {
|
|
|
235
252
|
catch { /* Diagnostics only. */ }
|
|
236
253
|
}
|
|
237
254
|
/** Reserve with a durable token so a lost HTTP response can be reconciled. */
|
|
238
|
-
async dispatch(row, event) {
|
|
255
|
+
async dispatch(row, event, options = {}) {
|
|
239
256
|
const token = row.token || randomUUID();
|
|
240
257
|
this.store.record(row.id, { token, status: 'reserving' });
|
|
241
258
|
this.log(`派发 #${row.id} → ${event.participant}(${event.role},turn ${event.turn_id ?? '-'})`);
|
|
242
259
|
this.trace(row.id, 'reserve_start');
|
|
243
260
|
await api(this.config, `/events/${row.id}`, { action: 'reserve', execution_token: token });
|
|
244
261
|
this.trace(row.id, 'reserve_done');
|
|
245
|
-
await this.deliver_reserved({ ...row, token }, event);
|
|
262
|
+
await this.deliver_reserved({ ...row, token }, event, options);
|
|
246
263
|
}
|
|
247
|
-
/**
|
|
248
|
-
|
|
264
|
+
/**
|
|
265
|
+
* Retry only proven non-delivery, keeping the event, token and task unchanged. `entered` marks an execution
|
|
266
|
+
* smart scheduling already entered before handing it back, so the software must not enter it again.
|
|
267
|
+
*/
|
|
268
|
+
async deliver_reserved(row, event, { suggestions = [], entered: pre_entered = false } = {}) {
|
|
249
269
|
const adapter = this.config.participants[event.participant];
|
|
250
270
|
if (!adapter) throw new Error(`未配置 ${event.participant} 的本机接入`);
|
|
251
271
|
const binding = this.store.binding(event.participant);
|
|
252
272
|
if (!binding.session_id && adapter.session_id) binding.session_id = adapter.session_id;
|
|
253
273
|
const attempts = row.attempts + 1;
|
|
254
|
-
// Ask before recording the dispatch, so a slow or failed suggestion never leaves an uncertain delivery behind.
|
|
255
|
-
const suggestions = event.role === 'host' ? await this.interjection_suggestions() : [];
|
|
256
274
|
this.store.record(row.id, { status: 'dispatching', sent_at: Date.now(), attempts, error: '', retry_at: 0 });
|
|
257
275
|
let invoked = false;
|
|
258
|
-
let entered =
|
|
276
|
+
let entered = pre_entered;
|
|
259
277
|
try {
|
|
260
|
-
const task = task_files(this.config, event, row.token, binding, suggestions);
|
|
278
|
+
const task = task_files(this.config, event, row.token, binding, suggestions, pre_entered);
|
|
261
279
|
invoked = true;
|
|
262
280
|
const result = await this.deliver(adapter, binding, { ...task, room_title: this.room_title,
|
|
263
281
|
api_context: { site: this.config.site, room: this.config.room, token: row.token, home: this.config.api_home,
|
|
264
|
-
event_id: row.id, role: event.role, turn_id: event.turn_id, scheduler_suggestions: suggestions },
|
|
282
|
+
event_id: row.id, role: event.role, turn_id: event.turn_id, scheduler_suggestions: suggestions, entered: pre_entered },
|
|
265
283
|
on_trace: (stage, detail) => this.trace(row.id, stage, detail),
|
|
266
284
|
on_binding: session => {
|
|
267
285
|
this.store.bind(event.participant, session);
|
|
@@ -283,31 +301,91 @@ export class DiscussionRunner {
|
|
|
283
301
|
}
|
|
284
302
|
}
|
|
285
303
|
/**
|
|
286
|
-
*
|
|
287
|
-
*
|
|
304
|
+
* Smart scheduling before waking the host: decide read-only first; only a confident decision reserves the host's
|
|
305
|
+
* execution, enters it and performs the host's own writes with that credential. Returns {done} when the host
|
|
306
|
+
* need not be woken, otherwise the suggestions to hand over. Every decision is recorded for the transcript.
|
|
288
307
|
*/
|
|
289
|
-
async
|
|
290
|
-
if (!this.config.scheduler) return
|
|
308
|
+
async smart_schedule(row, event) {
|
|
309
|
+
if (!this.config.scheduler) return {};
|
|
310
|
+
let plan;
|
|
311
|
+
try { plan = await this.plan_schedule(); }
|
|
312
|
+
catch (error) { this.log(`智能调度判断失败,交给主持人:${error.message}`); return {}; }
|
|
313
|
+
if (!plan) return {};
|
|
314
|
+
const suggestions = plan.message_id ? [{ message_id: plan.message_id, suggestion: plan.label, confidence: plan.confidence }] : [];
|
|
315
|
+
if (plan.action === 'host') {
|
|
316
|
+
await this.record_decision(plan, false);
|
|
317
|
+
return { suggestions };
|
|
318
|
+
}
|
|
319
|
+
const token = randomUUID();
|
|
320
|
+
this.store.record(row.id, { token, status: 'reserving' });
|
|
321
|
+
await api(this.config, `/events/${row.id}`, { action: 'reserve', execution_token: token });
|
|
322
|
+
try { await api(this.config, `/events/${row.id}`, { action: 'enter', session_id: SCHEDULER_SESSION }, token); }
|
|
323
|
+
catch (error) {
|
|
324
|
+
this.log(`智能调度进入执行失败,交给主持人:${error.message}`);
|
|
325
|
+
await this.deliver_reserved({ ...row, token }, event, { suggestions });
|
|
326
|
+
return { done: true };
|
|
327
|
+
}
|
|
328
|
+
this.store.record(row.id, { status: 'started', last_renew: Date.now() });
|
|
291
329
|
try {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const experts = Object.keys(room.experts || {}).filter(name => name !== room.host);
|
|
296
|
-
const suggestions = [];
|
|
297
|
-
for (const message of page.messages.filter(item => item.id >= room.held_since && item.author === '用户')) {
|
|
298
|
-
if (!this.suggestions.has(message.id)) {
|
|
299
|
-
const suggestion = await suggest_interjection(this.config.api_home, this.config.scheduler,
|
|
300
|
-
{ title: room.title, context: page.messages, message, host: room.host, experts });
|
|
301
|
-
this.suggestions.set(message.id, suggestion);
|
|
302
|
-
this.log(`智能调度建议 #${message.id}:${suggestion ? `${suggestion.suggestion}(置信度 ${suggestion.confidence})` : '无可用建议'}`);
|
|
303
|
-
}
|
|
304
|
-
if (this.suggestions.get(message.id)) suggestions.push(this.suggestions.get(message.id));
|
|
305
|
-
}
|
|
306
|
-
return suggestions;
|
|
330
|
+
await this.apply_schedule(plan, token);
|
|
331
|
+
await this.record_decision(plan, true);
|
|
332
|
+
this.log(`智能调度 #${row.id}:${plan.label}(置信度 ${plan.confidence})`);
|
|
307
333
|
} catch (error) {
|
|
308
|
-
this.log(
|
|
309
|
-
|
|
334
|
+
this.log(`智能调度执行失败,交给主持人:${error.message}`);
|
|
335
|
+
await this.record_decision({ ...plan, label: `${plan.label};执行未成功,已交给主持人` }, false);
|
|
336
|
+
await this.deliver_reserved({ ...row, token }, event, { suggestions, entered: true });
|
|
310
337
|
}
|
|
338
|
+
return { done: true };
|
|
339
|
+
}
|
|
340
|
+
/** Read the room and decide one interjection, or the next round when named follow-ups are off. Null leaves the host alone. */
|
|
341
|
+
async plan_schedule() {
|
|
342
|
+
const room = await api(this.config, '');
|
|
343
|
+
if (room.limits?.reached || !ACTIVE_PHASES.includes(room.phase)) return null;
|
|
344
|
+
if (room.current_turn && ['pending', 'notified'].includes(room.current_turn.status)) return null;
|
|
345
|
+
const page = await api(this.config, `/messages?after=${Math.max(0, room.latest_id - CONTEXT_MESSAGES)}`);
|
|
346
|
+
const experts = Object.keys(room.experts || {});
|
|
347
|
+
const context = { title: room.title, context: page.messages, host: room.host };
|
|
348
|
+
if (room.held_since) {
|
|
349
|
+
const pending = page.messages.filter(item => item.id >= room.held_since && item.author === '用户');
|
|
350
|
+
// Topic edits and several messages at once need the host's judgement.
|
|
351
|
+
if (pending.length !== 1) return null;
|
|
352
|
+
const message = pending[0];
|
|
353
|
+
const decision = await decide_interjection(this.config.api_home, this.config.scheduler,
|
|
354
|
+
{ ...context, message, experts: experts.filter(name => name !== room.host) }, this.fetch);
|
|
355
|
+
const plan = { ...decision, kind: 'interjection', message_id: message.id, after_message_id: message.id };
|
|
356
|
+
if (decision.action === 'ask') {
|
|
357
|
+
return { ...plan, turns: [{ expert: decision.expert, question: interjection_question(message) },
|
|
358
|
+
...room.queued_turns.map(({ expert, question }) => ({ expert, question }))].slice(0, MAX_PLAN_TURNS) };
|
|
359
|
+
}
|
|
360
|
+
if (decision.action === 'note' && room.queued_turns.length) return { ...plan, continue_plan: true };
|
|
361
|
+
// A note after the plan ran out falls through to deciding the next round.
|
|
362
|
+
if (decision.action !== 'note') return plan;
|
|
363
|
+
}
|
|
364
|
+
if (room.named_followup || room.queued_turns.length || !room.rounds?.at(-1)?.completed) return null;
|
|
365
|
+
const decision = await decide_round(this.config.api_home, this.config.scheduler,
|
|
366
|
+
{ ...context, experts, last_speaker: room.current_turn?.expert }, this.fetch);
|
|
367
|
+
const plan = { ...decision, kind: 'round', after_message_id: room.latest_id };
|
|
368
|
+
if (decision.action !== 'continue') return plan;
|
|
369
|
+
const round = room.completed_rounds + 1;
|
|
370
|
+
const order = [decision.first, ...experts.filter(name => name !== decision.first)].slice(0, MAX_PLAN_TURNS);
|
|
371
|
+
return { ...plan, phase: room.phase === 'initial' ? 'discussing' : undefined,
|
|
372
|
+
turns: order.map(expert => ({ expert, question: peer_round_question(round) })) };
|
|
373
|
+
}
|
|
374
|
+
/** Perform the host's writes for a decided plan under its own execution credential. */
|
|
375
|
+
async apply_schedule(plan, token) {
|
|
376
|
+
const room = await api(this.config, '');
|
|
377
|
+
const actor = room.host;
|
|
378
|
+
if (room.floor.holder !== actor) await api(this.config, '/floor', { actor, speaker: actor, action: 'take', revision: room.floor.revision }, token);
|
|
379
|
+
if (plan.phase) await api(this.config, '/topic', { actor, phase: plan.phase }, token);
|
|
380
|
+
if (plan.continue_plan) await api(this.config, '/turns/continue', { actor }, token);
|
|
381
|
+
else await api(this.config, '/turns', { actor, turns: plan.turns, submission_id: randomUUID() }, token);
|
|
382
|
+
}
|
|
383
|
+
/** Show the decision in the transcript; a failed record never affects the discussion. */
|
|
384
|
+
async record_decision(plan, applied) {
|
|
385
|
+
try {
|
|
386
|
+
await api(this.config, '/scheduler-decisions', { kind: plan.kind, label: plan.label, confidence: plan.confidence ?? null,
|
|
387
|
+
applied, provider: this.config.scheduler.provider, after_message_id: plan.after_message_id || 0 });
|
|
388
|
+
} catch (error) { this.log(`智能调度决策记录未保存:${error.message}`); }
|
|
311
389
|
}
|
|
312
390
|
/** Enter once per delivery; a failure is recorded so reconcile revokes the execution. */
|
|
313
391
|
async enter(row, session_id) {
|
|
@@ -431,7 +509,8 @@ export class DiscussionRunner {
|
|
|
431
509
|
if (next && !this.running.get(next.event.participant)) {
|
|
432
510
|
const fresh = await api(this.config, `/events/${next.row.id}`, { action: 'view' });
|
|
433
511
|
if (fresh.status === 'pending') {
|
|
434
|
-
await this.
|
|
512
|
+
const scheduled = fresh.role === 'host' ? await this.smart_schedule(next.row, fresh) : {};
|
|
513
|
+
if (!scheduled.done) await this.dispatch(next.row, fresh, scheduled);
|
|
435
514
|
}
|
|
436
515
|
else this.store.record(next.row.id, { status: fresh.status });
|
|
437
516
|
}
|
package/runner/free_runner.mjs
CHANGED
|
@@ -69,15 +69,13 @@ export function free_task_files(config, member, execution, binding) {
|
|
|
69
69
|
const host_rule = member.role === 'host'
|
|
70
70
|
? `你是本房间的主持人:purpose 为 route 的请求是用户未点名的消息,handoff 表示你派出的成员都已结束。先用 ${cli} room view ${config.room} 查看成员与状态,再决定由谁处理;需要派活时在回答中加 --to 成员名(可重复,不能点名自己),被点名的成员都结束后你会再被唤醒一次。不需要他人时不加 --to,本次即结束。只派分给各成员的文件或模块,避免多人改同一文件。`
|
|
71
71
|
: '';
|
|
72
|
-
const scheduled_rule = execution.requests.some(item => item.purpose === 'scheduled')
|
|
73
|
-
? 'purpose 为 scheduled 的消息用户没有点名,是智能调度判断交给你的;若它其实需要主持人拆分或协调,简短说明后结束即可。' : '';
|
|
74
72
|
atomic_json(task_path, { site: config.site, room: config.room, mode: 'free', member: member.name,
|
|
75
73
|
execution_id: execution.id, request_messages: execution.requests.map(item => item.message_id),
|
|
76
74
|
request_purposes: execution.requests.map(item => item.purpose || 'ask'), role: member.role || 'member',
|
|
77
75
|
read_after: execution.read_until, cwd: profile.cwd || null, project_access: profile.project_access, draft_directory,
|
|
78
76
|
skills: [join(config.project, '.agents/skills/discuss-participant/SKILL.md')], cli, subcommands: TASK_SUBCOMMANDS,
|
|
79
77
|
access_file: access_path,
|
|
80
|
-
instructions: `自由协作房间:用户点名了你(${messages})。先用 ${cli} message list ${config.room} --after ${execution.read_until} --access-file 凭据路径 读取新消息,按时间顺序处理,后面的消息可能修正前面的,以最新指令为准。${host_rule}
|
|
78
|
+
instructions: `自由协作房间:用户点名了你(${messages})。先用 ${cli} message list ${config.room} --after ${execution.read_until} --access-file 凭据路径 读取新消息,按时间顺序处理,后面的消息可能修正前面的,以最新指令为准。${host_rule}完成后用 ${cli} message post ${config.room} --as ${member.name} --kind chat --execution ${execution.id} --body-file 草稿 --submission-id 新UUID --access-file 凭据路径 提交一次回答。提交被拒绝说明本次执行已被打断或过期:保留草稿并结束,不要去掉 --execution 改作普通发言。草稿只写入 draft_directory;${project_rule}不读取或打印凭据内容,不建定时任务。` });
|
|
81
79
|
const prompt = `用户在自由协作房间点名了你(${member.name}),对应消息 ${messages}。请先读取任务文件:${task_path},按其中说明读取新消息、完成请求,并用 ${cli} message post 带 --execution ${execution.id} 提交一次回答后结束。命令只用任务内 subcommands 列出的 ${cli} 子命令;${project_rule}不读取凭据内容。`;
|
|
82
80
|
return { id, participant: member.name, directory: config.directory, draft_directory,
|
|
83
81
|
skills_directory: join(config.project, '.agents', 'skills'), access_path, prompt };
|
|
@@ -101,6 +99,7 @@ export class FreeRoomRunner {
|
|
|
101
99
|
this.log = log;
|
|
102
100
|
this.fetch = fetch_impl;
|
|
103
101
|
this.routed = new Set();
|
|
102
|
+
this.decisions = new Set();
|
|
104
103
|
this.children = new Set();
|
|
105
104
|
this.members = new Map();
|
|
106
105
|
this.exited = new Set();
|
|
@@ -139,10 +138,10 @@ export class FreeRoomRunner {
|
|
|
139
138
|
}
|
|
140
139
|
/** One bounded iteration: reconcile live executions, then deliver idle members with open requests. */
|
|
141
140
|
async tick() {
|
|
142
|
-
|
|
141
|
+
const inbox = await api(this.config, '/inbox');
|
|
143
142
|
const room = await api(this.config, '');
|
|
144
|
-
//
|
|
145
|
-
if (this.config.scheduler && !inbox.archived
|
|
143
|
+
// Smart scheduling runs beside delivery: the host gets the message now, a recommendation may follow.
|
|
144
|
+
if (this.config.scheduler && !inbox.archived) this.recommend_unnamed(inbox, room);
|
|
146
145
|
this.room_title = room.title;
|
|
147
146
|
const active = new Map(inbox.executions.map(item => [item.id, item]));
|
|
148
147
|
const present = new Set(inbox.members.map(member => member.name));
|
|
@@ -158,36 +157,33 @@ export class FreeRoomRunner {
|
|
|
158
157
|
}
|
|
159
158
|
}
|
|
160
159
|
/**
|
|
161
|
-
* Smart scheduling:
|
|
162
|
-
*
|
|
160
|
+
* Smart scheduling: for each new unnamed message routed to the host, ask the provider in the background whether
|
|
161
|
+
* another woken member suits it better, and post a recommendation the user may adopt. Nothing waits on it.
|
|
163
162
|
*/
|
|
164
|
-
|
|
163
|
+
recommend_unnamed(inbox, room) {
|
|
165
164
|
const host = inbox.members.find(member => member.role === 'host');
|
|
166
165
|
const waiting = host?.pending.filter(request => request.purpose === 'route' && !this.routed.has(request.id)) || [];
|
|
167
|
-
const members = inbox.members.filter(member => member !== host && this.config.participants[member.name]).map(member => ({ name: member.name,
|
|
166
|
+
const members = inbox.members.filter(member => member !== host && this.config.participants[member.name]).map(member => ({ id: member.id, name: member.name,
|
|
168
167
|
description: member_description(member.name, this.config.participants[member.name]) }));
|
|
169
|
-
let moved = false;
|
|
170
168
|
for (const request of waiting) {
|
|
171
169
|
this.routed.add(request.id);
|
|
172
170
|
if (!members.length) continue;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (!message) continue;
|
|
177
|
-
const decision = await route_free_message(this.config.api_home, this.config.scheduler,
|
|
178
|
-
{ title: room.title, context: page.messages, message, host: host.name, members }, this.fetch);
|
|
179
|
-
this.log(`智能调度 #${message.id}:${decision.member ? `派给 ${decision.member}` : '交回主持人'}(选择 ${decision.choice ?? '-'},置信度 ${decision.confidence ?? '-'})`);
|
|
180
|
-
if (!decision.member) continue;
|
|
181
|
-
const target = inbox.members.find(member => member.name === decision.member);
|
|
182
|
-
await api(this.config, `/requests/${request.id}`, { action: 'schedule', member_id: target.id,
|
|
183
|
-
provider: this.config.scheduler.provider, confidence: decision.confidence });
|
|
184
|
-
moved = true;
|
|
185
|
-
} catch (error) {
|
|
186
|
-
// A request the host already took, or any provider failure, simply stays with the host.
|
|
187
|
-
this.log(`智能调度 #${request.message_id} 未改派,交回主持人:${error.message}`);
|
|
188
|
-
}
|
|
171
|
+
const decision = this.recommend(request, room, host, members).catch(error => this.log(`智能调度 #${request.message_id} 未给出推荐:${error.message}`));
|
|
172
|
+
this.decisions.add(decision);
|
|
173
|
+
decision.finally(() => this.decisions.delete(decision));
|
|
189
174
|
}
|
|
190
|
-
|
|
175
|
+
}
|
|
176
|
+
/** Decide one message and record a confident recommendation; the host's handling is unaffected either way. */
|
|
177
|
+
async recommend(request, room, host, members) {
|
|
178
|
+
const page = await api(this.config, `/messages?after=${Math.max(0, request.message_id - 11)}`);
|
|
179
|
+
const message = page.messages.find(item => item.id === request.message_id);
|
|
180
|
+
if (!message) return;
|
|
181
|
+
const decision = await route_free_message(this.config.api_home, this.config.scheduler,
|
|
182
|
+
{ title: room.title, context: page.messages, message, host: host.name, members }, this.fetch);
|
|
183
|
+
this.log(`智能调度 #${message.id}:${decision.member ? `推荐 ${decision.member}` : '不推荐,交主持人'}(选择 ${decision.choice ?? '-'},置信度 ${decision.confidence ?? '-'})`);
|
|
184
|
+
if (!decision.member) return;
|
|
185
|
+
await api(this.config, '/recommendations', { message_id: message.id, member_id: members.find(member => member.name === decision.member).id,
|
|
186
|
+
provider: this.config.scheduler.provider, confidence: decision.confidence });
|
|
191
187
|
}
|
|
192
188
|
/** Reserve with a persisted client id before invoking any software. */
|
|
193
189
|
async start(member, row) {
|
package/runner/scheduler.mjs
CHANGED
|
@@ -85,15 +85,41 @@ export async function route_free_message(home, scheduler, { title, context, mess
|
|
|
85
85
|
return { member, choice: answer.choice ?? null, confidence: answer.confidence ?? null };
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/**
|
|
89
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Decide one interjection in a discussion: continue the plan (note), ask one expert (ask) or wake the host (host).
|
|
90
|
+
* Anything but a confident note or ask is returned as host.
|
|
91
|
+
*/
|
|
92
|
+
export async function decide_interjection(home, scheduler, { title, context, message, host, experts }, fetch_impl) {
|
|
90
93
|
const choices = [...experts.map(name => ({ value: name, description: `请 ${name} 回应这条留言` })),
|
|
91
94
|
{ value: HOST_CHOICE, description: `由主持人 ${host} 处理:调整目标、方向、安排,或需要主持人决定` },
|
|
92
95
|
{ value: NOTE_CHOICE, description: '仅补充信息:不需要专人回应,按原计划继续' }];
|
|
93
96
|
const answers = await decide(home, scheduler, scheduler_state(title, context, message), [{ type: 'choice', name: 'handling',
|
|
94
97
|
instructions: '讨论进行中用户发来这条留言。判断它最适合怎样处理。', choices }], fetch_impl);
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
const { choice, confidence = null } = answers.handling || {};
|
|
99
|
+
const sure = confidence !== null && confidence >= ROUTE_CONFIDENCE;
|
|
100
|
+
if (sure && choice === NOTE_CHOICE) return { action: 'note', confidence, label: '仅补充信息,按原计划继续' };
|
|
101
|
+
if (sure && experts.includes(choice)) return { action: 'ask', expert: choice, confidence, label: `先请 ${choice} 回应这条留言` };
|
|
102
|
+
return { action: 'host', confidence, label: '交给主持人处理这条留言' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* At the end of a round without named follow-ups: continue with another peer round, or wake the host
|
|
107
|
+
* (to conclude, or because the discussion drifted, repeats itself or is stuck). The first speaker is never the last one.
|
|
108
|
+
*/
|
|
109
|
+
export async function decide_round(home, scheduler, { title, context, host, experts, last_speaker }, fetch_impl) {
|
|
110
|
+
const candidates = experts.filter(name => name !== last_speaker);
|
|
111
|
+
const recent = context.slice(-CONTEXT_MESSAGES).map(excerpt);
|
|
112
|
+
const state = [`讨论:${title || ''}`, recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
|
|
113
|
+
const questions = [{ type: 'choice', name: 'next', instructions: '一轮发言刚结束。判断下一步:专家之间是否还有值得继续交锋的分歧?', choices: [
|
|
114
|
+
{ value: 'continue', description: '继续下一轮:仍有未回应的质疑、分歧或新观点' },
|
|
115
|
+
{ value: HOST_CHOICE, description: `交给主持人 ${host}:已可收束,或讨论跑偏、重复立场、卡住,需要主持人引导` }] }];
|
|
116
|
+
if (candidates.length > 1) questions.push({ type: 'choice', name: 'first', instructions: '下一轮应先请谁发言?优先选被其他专家点名追问、最需要回应的人。',
|
|
117
|
+
choices: candidates.map(name => ({ value: name, description: `先请 ${name}` })) });
|
|
118
|
+
const answers = await decide(home, scheduler, state, questions, fetch_impl);
|
|
119
|
+
const { choice, confidence = null } = answers.next || {};
|
|
120
|
+
if (choice !== 'continue' || confidence === null || confidence < ROUTE_CONFIDENCE || !candidates.length) {
|
|
121
|
+
return { action: 'host', confidence, label: '交给主持人:判断是否收束或调整方向' };
|
|
122
|
+
}
|
|
123
|
+
const first = candidates.includes(answers.first?.choice) ? answers.first.choice : candidates[0];
|
|
124
|
+
return { action: 'continue', first, confidence, label: `继续下一轮,先请 ${first} 发言` };
|
|
99
125
|
}
|
package/web/app.js
CHANGED
|
@@ -38,7 +38,7 @@ const PLATFORM_IDENTITIES = {
|
|
|
38
38
|
};
|
|
39
39
|
const IDENTITIES = by_platform(PLATFORM_IDENTITIES, (identity, name) => ({ ...identity, label: name, description: "AI 实例" }));
|
|
40
40
|
const state = { rooms: [], folders: [], room: null, page: 1, pages: 1, query: "", mention_only: false, free_notice: "", free_error: "",
|
|
41
|
-
free_proxy: "", create_parent: null,
|
|
41
|
+
free_proxy: "", create_parent: null, named_followup: false,
|
|
42
42
|
archive_view: false, directory_sort: "manual", management: null, dragged_room: null, folder_moves: new Map(), folder_version: 0,
|
|
43
43
|
reply_to: null, turn_reply_to: null, tab: "topic", refresh_version: 0, context_dirty: false, topic_dirty: false,
|
|
44
44
|
snapshot_id: 0, pending_submission: null, pending_turn: null, pending_room: null, toast_timer: null,
|
|
@@ -841,6 +841,7 @@ function reset_create_form() {
|
|
|
841
841
|
state.launch.create = true;
|
|
842
842
|
state.launch.workspace = {};
|
|
843
843
|
state.launch.scheduler = {};
|
|
844
|
+
state.named_followup = false;
|
|
844
845
|
state.launch.profiles = JSON.parse(JSON.stringify(state.launch.saved_profiles || {}));
|
|
845
846
|
Object.values(state.launch.profiles).forEach(fill_api_key);
|
|
846
847
|
state.launch.notice = "";
|
|
@@ -1342,7 +1343,7 @@ function room_creation_payload(folder_id) {
|
|
|
1342
1343
|
// Free rooms carry their task in the conversation, so no opening brief is sent even if one was typed for a discussion.
|
|
1343
1344
|
if (element("new-room-kind").value === "free") return { ...common, user_request: "", mode: "free", host: common.host || "用户" };
|
|
1344
1345
|
return { ...common, mode: "brainstorm", background: element("new-room-background").value,
|
|
1345
|
-
boundaries: element("new-room-boundaries").value, runtime_mode: "automatic", limits: read_limits("new-room"),
|
|
1346
|
+
boundaries: element("new-room-boundaries").value, runtime_mode: "automatic", limits: read_limits("new-room"), named_followup: state.named_followup,
|
|
1346
1347
|
...(state.create_parent ? { parent_room_id: state.create_parent } : {}) };
|
|
1347
1348
|
}
|
|
1348
1349
|
|
|
@@ -1758,6 +1759,12 @@ function creation_launch_options() {
|
|
|
1758
1759
|
return { ...launch_options(), ...(enabled && provider && model && api_key_ref ? { scheduler: { provider, model, api_key_ref } } : {}) };
|
|
1759
1760
|
}
|
|
1760
1761
|
|
|
1762
|
+
/** Choose whether the new discussion's host writes named follow-ups for each expert between rounds. */
|
|
1763
|
+
function write_named_followup(value) {
|
|
1764
|
+
state.named_followup = Boolean(value);
|
|
1765
|
+
publish_view();
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1761
1768
|
/** Change the smart scheduling draft; a new provider starts from its preset model and any key saved for its root. */
|
|
1762
1769
|
function write_scheduler(field, value) {
|
|
1763
1770
|
const scheduler = state.launch.scheduler;
|
|
@@ -2260,6 +2267,13 @@ async function convert_suggestion({ message_id, member_id }) {
|
|
|
2260
2267
|
await refresh_room();
|
|
2261
2268
|
}
|
|
2262
2269
|
|
|
2270
|
+
/** Adopt smart scheduling's recommendation: interrupt the host now, or ask the member once the host is done. */
|
|
2271
|
+
async function adopt_recommendation({ message_id, mode }) {
|
|
2272
|
+
const room_id = state.room.id;
|
|
2273
|
+
await request_json(`/api/rooms/${room_id}/recommendations/${message_id}`, { action: "adopt", mode });
|
|
2274
|
+
if (state.room?.id === room_id) await refresh_room();
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2263
2277
|
/** A completed creation starts the next form with fresh mode defaults. */
|
|
2264
2278
|
function reset_creation_mode() {
|
|
2265
2279
|
state.creation_mode = "brainstorm";
|
|
@@ -2869,7 +2883,7 @@ function view_snapshot() {
|
|
|
2869
2883
|
free_notice: state.free_notice, free_error: state.free_error, free_proxy: state.free_proxy,
|
|
2870
2884
|
create_parent: state.create_parent, access: access_snapshot(),
|
|
2871
2885
|
tab: state.tab, snapshot_id: state.snapshot_id, context_dirty: state.context_dirty,
|
|
2872
|
-
theme: document.documentElement.dataset.theme, fields, experts, launch: structuredClone(state.launch) };
|
|
2886
|
+
theme: document.documentElement.dataset.theme, fields, experts, launch: structuredClone(state.launch), named_followup: state.named_followup };
|
|
2873
2887
|
}
|
|
2874
2888
|
|
|
2875
2889
|
/** Update a shared draft using the same input events as the original controls. */
|
|
@@ -3041,7 +3055,7 @@ async function execute_view_action(name, value) {
|
|
|
3041
3055
|
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,
|
|
3042
3056
|
save_launch_defaults, launch_current_room, resume_launch, discard_launch, retry_launch_connection, check_launch_connection: load_launch_settings,
|
|
3043
3057
|
load_room_configuration, save_room_configuration, interrupt_member, change_member, invite_member, interrupt_free, set_proxy, copy_online_notice,
|
|
3044
|
-
prepare_brainstorm, return_conclusion, convert_suggestion,
|
|
3058
|
+
prepare_brainstorm, return_conclusion, convert_suggestion, adopt_recommendation,
|
|
3045
3059
|
busy: () => { throw new Error("上一个操作仍在处理中,请稍候;长时间无响应请刷新页面"); },
|
|
3046
3060
|
copy_reading: () => copy_text(`${location.origin}/r/${state.room.id}`), copy: copy_text };
|
|
3047
3061
|
if (!["refresh", "busy"].includes(name)) state.free_error = "";
|
|
@@ -3064,7 +3078,7 @@ function change_view_theme(theme) {
|
|
|
3064
3078
|
|
|
3065
3079
|
window.discuss_view = { snapshot: view_snapshot, write_field: write_view_field,
|
|
3066
3080
|
write_launch: write_launch_field, write_room_connection, write_room_adapter, launch_clients: LAUNCH_CLIENTS,
|
|
3067
|
-
launch_model_state, load_launch_models, write_launch_model, save_launch_api_key, write_scheduler, save_scheduler_api_key, check_launch_api, launch_summary, launch_can_set_access, launch_can_bind_session, pick_launch_directory, restore_launch_profile, reset_create_form, empty_discussion_message,
|
|
3081
|
+
launch_model_state, load_launch_models, write_launch_model, save_launch_api_key, write_scheduler, save_scheduler_api_key, write_named_followup, check_launch_api, launch_summary, launch_can_set_access, launch_can_bind_session, pick_launch_directory, restore_launch_profile, reset_create_form, empty_discussion_message,
|
|
3068
3082
|
start_drag: start_directory_drag,
|
|
3069
3083
|
attachment_markup, attachment_size, attachment_file_icon, drop_attachments, paste_attachments,
|
|
3070
3084
|
write_expert: write_view_expert, add_instance: add_view_instance, platform_of, execute: execute_view_action, theme: change_view_theme,
|