ai_co_discuss 0.2.33 → 0.2.34
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/free_room.py +16 -1
- package/backend/schema.sql +7 -0
- package/backend/server.py +21 -3
- package/docs/architecture.md +1 -1
- package/docs/cli.md +1 -1
- package/docs/runner.md +5 -4
- package/docs/workflow.md +157 -157
- package/package.json +1 -1
- package/runner/discuss.py +5 -0
- package/runner/discussion_launch.mjs +20 -1
- package/runner/discussion_runner.mjs +43 -9
- package/runner/discussion_service.mjs +19 -0
- package/runner/free_runner.mjs +1 -1
- package/runner/scheduler.mjs +45 -19
- package/web/app.js +11 -5
- package/web/modern.css +1 -1
- package/web/modern.js +655 -536
|
@@ -11,7 +11,7 @@ import { parseArgs } from 'node:util';
|
|
|
11
11
|
import { atomic_json } from './discussion_adapters.mjs';
|
|
12
12
|
import { software_models } from './discussion_models.mjs';
|
|
13
13
|
import { API_PROVIDERS, api_check, api_models, save_api_key, saved_api_keys, validate_api_profile } from './discussion_api.mjs';
|
|
14
|
-
import { SCHEDULER_PROVIDERS } from './scheduler.mjs';
|
|
14
|
+
import { SCHEDULER_PROVIDERS, scheduler_check } from './scheduler.mjs';
|
|
15
15
|
import { acquire_lock, api, CODEX_ADAPTERS, DiscussionStore, service_drafts, task_files, validate_config } from './discussion_runner.mjs';
|
|
16
16
|
import { assert_shared_home, CONNECTION_FIELDS, configuration_snapshot, read_registry, registered_identity, register_room, service_home, service_pid, start_service } from './discussion_service.mjs';
|
|
17
17
|
|
|
@@ -46,6 +46,7 @@ const HELP = `用法:codiscuss launch settings [--file FILE]
|
|
|
46
46
|
codiscuss launch models --file FILE
|
|
47
47
|
codiscuss launch api-key --file FILE
|
|
48
48
|
codiscuss launch api-check --file FILE
|
|
49
|
+
codiscuss launch scheduler-check --file FILE
|
|
49
50
|
codiscuss launch room-member --file FILE
|
|
50
51
|
codiscuss launch pick-directory
|
|
51
52
|
|
|
@@ -56,6 +57,7 @@ room-models 只读指定 site、room_id 的本机登记模型与思考强度,
|
|
|
56
57
|
models 按 adapter、executable 或 API profile 读取模型目录,不启动讨论或生成回答。
|
|
57
58
|
api-key 将 base_url、api_key 保存于本机私有目录,只返回 api_key_ref。
|
|
58
59
|
api-check 用 API profile 向模型发送一次极短请求,核对地址、密钥、协议与模型是否可用。
|
|
60
|
+
scheduler-check 用智能调度配置(provider、model、api_key_ref)向决策模型发送一道是非题,核对供应商、模型与密钥是否可用。
|
|
59
61
|
pick-directory 在本机弹出系统目录选择窗口,返回所选路径;取消时不返回路径。
|
|
60
62
|
room-member 为运行中的自由房间或讨论邀请、请回一位成员(讨论只接受本机唤醒的成员);须提供 site、room_id、token、action(add/rejoin)、name。
|
|
61
63
|
JSON 指定 site、entry(conversation/web)、room(建房内容,含 submission_id)
|
|
@@ -409,6 +411,22 @@ export async function room_member(home, raw, locked = false) {
|
|
|
409
411
|
} finally { release(); }
|
|
410
412
|
}
|
|
411
413
|
|
|
414
|
+
/**
|
|
415
|
+
* Register a manual Claude expert's own session as a hosted participant, so the service resumes it for later turns.
|
|
416
|
+
* Throws when the local Claude software is not configured or the session is not found on this machine; the member then stays manual.
|
|
417
|
+
*/
|
|
418
|
+
export function adopt_member_profile(home, path, name, session_id) {
|
|
419
|
+
const release = acquire_lock(join(dirname(path), 'configuration-edit'));
|
|
420
|
+
try {
|
|
421
|
+
const config = configuration_snapshot(path).config;
|
|
422
|
+
const input = { cwd: config.cwd, participants: { [name]: { adapter: 'claude', session_id } } };
|
|
423
|
+
const profile = borrowed_session(name, participant_profile(name, launch_settings(home).participants, input, false), config.cwd);
|
|
424
|
+
const next = { ...config, participants: { ...config.participants, [name]: profile } };
|
|
425
|
+
validate_config(next, dirname(path));
|
|
426
|
+
atomic_json(path, next);
|
|
427
|
+
} finally { release(); }
|
|
428
|
+
}
|
|
429
|
+
|
|
412
430
|
/** Stable signatures make changed retry requests explicit without putting credentials in receipts. */
|
|
413
431
|
function signature(value) {
|
|
414
432
|
if (Array.isArray(value)) return `[${value.map(signature).join(',')}]`;
|
|
@@ -718,6 +736,7 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
|
|
|
718
736
|
scheduler_providers: SCHEDULER_PROVIDERS, api_keys: saved_api_keys(home) };
|
|
719
737
|
else if (positionals[0] === 'api-key' && input) result = save_api_key(home, input);
|
|
720
738
|
else if (positionals[0] === 'api-check' && input) result = await api_check(home, input);
|
|
739
|
+
else if (positionals[0] === 'scheduler-check' && input) result = await scheduler_check(home, input);
|
|
721
740
|
else if (positionals[0] === 'room-models' && input) result = room_models(home, input);
|
|
722
741
|
else if (positionals[0] === 'models' && input?.adapter === 'api') result = await api_models(home, input);
|
|
723
742
|
else if (positionals[0] === 'models' && input) {
|
|
@@ -308,7 +308,7 @@ export class DiscussionRunner {
|
|
|
308
308
|
async smart_schedule(row, event) {
|
|
309
309
|
if (!this.config.scheduler) return {};
|
|
310
310
|
let plan;
|
|
311
|
-
try { plan = await this.plan_schedule(); }
|
|
311
|
+
try { plan = await this.plan_schedule(event); }
|
|
312
312
|
catch (error) { this.log(`智能调度判断失败,交给主持人:${error.message}`); return {}; }
|
|
313
313
|
if (!plan) return {};
|
|
314
314
|
const suggestions = plan.message_id ? [{ message_id: plan.message_id, suggestion: plan.label, confidence: plan.confidence }] : [];
|
|
@@ -337,22 +337,30 @@ export class DiscussionRunner {
|
|
|
337
337
|
}
|
|
338
338
|
return { done: true };
|
|
339
339
|
}
|
|
340
|
-
/**
|
|
341
|
-
|
|
340
|
+
/**
|
|
341
|
+
* Read the room and decide one interjection, restore a plan voided by a roster change, or decide the next round
|
|
342
|
+
* when named follow-ups are off. Anything left to the host says why; null (the opening, or nothing to do) records nothing.
|
|
343
|
+
*/
|
|
344
|
+
async plan_schedule(event) {
|
|
345
|
+
if (String(event.cause || '').startsWith('start')) return null;
|
|
342
346
|
const room = await api(this.config, '');
|
|
343
|
-
if (
|
|
347
|
+
if (!ACTIVE_PHASES.includes(room.phase)) return null;
|
|
344
348
|
if (room.current_turn && ['pending', 'notified'].includes(room.current_turn.status)) return null;
|
|
349
|
+
// Every record sits at the end of the latest expert answer, the card that just finished speaking.
|
|
350
|
+
const anchor = room.current_turn?.answer_id || room.latest_id;
|
|
351
|
+
const skip = (kind, reason) => ({ action: 'host', kind, confidence: null, label: `未判断:${reason},交给主持人`, after_message_id: anchor });
|
|
352
|
+
if (room.limits?.reached) return skip('round', '已到讨论上限');
|
|
345
353
|
const page = await api(this.config, `/messages?after=${Math.max(0, room.latest_id - CONTEXT_MESSAGES)}`);
|
|
346
354
|
const experts = Object.keys(room.experts || {});
|
|
347
|
-
const context = { title: room.title, context: page.messages, host: room.host };
|
|
355
|
+
const context = { title: room.title, goal: room.goal, user_request: room.user_request, context: page.messages, host: room.host };
|
|
348
356
|
if (room.held_since) {
|
|
349
357
|
const pending = page.messages.filter(item => item.id >= room.held_since && item.author === '用户');
|
|
350
358
|
// Topic edits and several messages at once need the host's judgement.
|
|
351
|
-
if (pending.length !== 1) return
|
|
359
|
+
if (pending.length !== 1) return skip('interjection', pending.length ? '同时有多条留言' : '话题设置有修改');
|
|
352
360
|
const message = pending[0];
|
|
353
361
|
const decision = await decide_interjection(this.config.api_home, this.config.scheduler,
|
|
354
362
|
{ ...context, message, experts: experts.filter(name => name !== room.host) }, this.fetch);
|
|
355
|
-
const plan = { ...decision, kind: 'interjection', message_id: message.id, after_message_id:
|
|
363
|
+
const plan = { ...decision, kind: 'interjection', message_id: message.id, after_message_id: anchor };
|
|
356
364
|
if (decision.action === 'ask') {
|
|
357
365
|
return { ...plan, turns: [{ expert: decision.expert, question: interjection_question(message) },
|
|
358
366
|
...room.queued_turns.map(({ expert, question }) => ({ expert, question }))].slice(0, MAX_PLAN_TURNS) };
|
|
@@ -361,16 +369,41 @@ export class DiscussionRunner {
|
|
|
361
369
|
// A note after the plan ran out falls through to deciding the next round.
|
|
362
370
|
if (decision.action !== 'note') return plan;
|
|
363
371
|
}
|
|
364
|
-
if (room.
|
|
372
|
+
if (room.queued_turns.length) return null;
|
|
373
|
+
const round_open = !room.rounds?.at(-1)?.completed;
|
|
374
|
+
if (round_open) {
|
|
375
|
+
const restored = await this.restore_voided_plan(room, experts, anchor);
|
|
376
|
+
return restored || skip('round', '本轮还有专家未发言但没有后续安排');
|
|
377
|
+
}
|
|
378
|
+
if (room.named_followup) return skip('round', '点名追问已开启,由主持人安排下一轮');
|
|
365
379
|
const decision = await decide_round(this.config.api_home, this.config.scheduler,
|
|
366
380
|
{ ...context, experts, last_speaker: room.current_turn?.expert }, this.fetch);
|
|
367
|
-
const plan = { ...decision, kind: 'round', after_message_id:
|
|
381
|
+
const plan = { ...decision, kind: 'round', after_message_id: anchor };
|
|
368
382
|
if (decision.action !== 'continue') return plan;
|
|
369
383
|
const round = room.completed_rounds + 1;
|
|
370
384
|
const order = [decision.first, ...experts.filter(name => name !== decision.first)].slice(0, MAX_PLAN_TURNS);
|
|
371
385
|
return { ...plan, phase: room.phase === 'initial' ? 'discussing' : undefined,
|
|
372
386
|
turns: order.map(expert => ({ expert, question: peer_round_question(round) })) };
|
|
373
387
|
}
|
|
388
|
+
/**
|
|
389
|
+
* A roster change voids queued turns, leaving them skipped after the last real arrangement. Put back the ones whose
|
|
390
|
+
* experts are still present, then ask each expert who has not spoken this round (such as a new member) to open.
|
|
391
|
+
* This needs no model: it only reinstates what the host had already planned.
|
|
392
|
+
*/
|
|
393
|
+
async restore_voided_plan(room, experts, anchor) {
|
|
394
|
+
const turns = (await api(this.config, '/turns')).turns || [];
|
|
395
|
+
const last_real = Math.max(0, ...turns.filter(turn => turn.status !== 'skipped').map(turn => turn.id));
|
|
396
|
+
const voided = turns.filter(turn => turn.status === 'skipped' && turn.id > last_real && experts.includes(turn.expert));
|
|
397
|
+
if (!turns.some(turn => turn.status === 'skipped' && turn.id > last_real)) return null;
|
|
398
|
+
const spoken = new Set(room.rounds?.at(-1)?.speakers || []);
|
|
399
|
+
const planned = new Set(voided.map(turn => turn.expert));
|
|
400
|
+
const joining = experts.filter(name => !spoken.has(name) && !planned.has(name));
|
|
401
|
+
const opening = room.phase === 'initial' ? '欢迎加入:请先读取议题,独立给出你的开场观点。' : '欢迎加入:请先读取议题和已有回答,给出你的观点。';
|
|
402
|
+
const plan_turns = [...voided.map(({ expert, question }) => ({ expert, question })), ...joining.map(expert => ({ expert, question: opening }))].slice(0, MAX_PLAN_TURNS);
|
|
403
|
+
if (!plan_turns.length) return null;
|
|
404
|
+
const label = `成员有变化:${voided.length ? `补回原计划(${voided.map(turn => turn.expert).join('、')})` : '原计划成员已离开'}${joining.length ? `,请 ${joining.join('、')} 开场` : ''}`;
|
|
405
|
+
return { action: 'restore', kind: 'round', confidence: null, label, after_message_id: anchor, turns: plan_turns };
|
|
406
|
+
}
|
|
374
407
|
/** Perform the host's writes for a decided plan under its own execution credential. */
|
|
375
408
|
async apply_schedule(plan, token) {
|
|
376
409
|
const room = await api(this.config, '');
|
|
@@ -468,6 +501,7 @@ export class DiscussionRunner {
|
|
|
468
501
|
if (room.runtime.generation !== generation) this.stop_children();
|
|
469
502
|
}
|
|
470
503
|
this.room_title = room.title;
|
|
504
|
+
this.members = room.members || [];
|
|
471
505
|
const run = room.runtime;
|
|
472
506
|
if (run.run_id) this.store.scope(this.config, run.run_id);
|
|
473
507
|
if (run.mode !== 'automatic' || run.status !== 'running' || run.expired) {
|
|
@@ -173,6 +173,7 @@ class RoomWorker {
|
|
|
173
173
|
this.create_runner = create_runner;
|
|
174
174
|
this.log = log;
|
|
175
175
|
this.state = { config: path, status: 'starting', error: '' };
|
|
176
|
+
this.adopt_failed = new Set();
|
|
176
177
|
}
|
|
177
178
|
/** Open lazily so a busy lock or broken file is retried instead of stopping the service. */
|
|
178
179
|
open() {
|
|
@@ -219,6 +220,23 @@ class RoomWorker {
|
|
|
219
220
|
}
|
|
220
221
|
this.state.config_version = snapshot.version;
|
|
221
222
|
}
|
|
223
|
+
/** A manual Claude expert whose CLI reported its session is handed to the service, which resumes that session from the next turn on. */
|
|
224
|
+
async adopt_manual_members() {
|
|
225
|
+
if (this.config.mode === 'free') return;
|
|
226
|
+
for (const member of this.runner.members || []) {
|
|
227
|
+
const key = `${member.name}:${member.reported_session}`;
|
|
228
|
+
if (member.access !== 'manual' || member.status !== 'active' || !member.reported_session || this.adopt_failed.has(key)) continue;
|
|
229
|
+
try {
|
|
230
|
+
const { adopt_member_profile } = await import('./discussion_launch.mjs');
|
|
231
|
+
adopt_member_profile(this.home, this.path, member.name, member.reported_session);
|
|
232
|
+
await api(this.config, `/members/${member.id}`, { action: 'adopt', adapter: 'claude', session_id: member.reported_session });
|
|
233
|
+
this.log(`${this.config.room}: ${member.name} 已转为本机托管`);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
this.adopt_failed.add(key);
|
|
236
|
+
this.log(`${this.config.room}: ${member.name} 未能转为托管,保持手动:${error.message}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
222
240
|
/** Run one bounded dispatcher iteration and keep the last result for status. */
|
|
223
241
|
async tick() {
|
|
224
242
|
const previous_status = this.state.status;
|
|
@@ -226,6 +244,7 @@ class RoomWorker {
|
|
|
226
244
|
if (!this.runner) this.open();
|
|
227
245
|
await this.refresh_control();
|
|
228
246
|
const result = await this.runner.tick();
|
|
247
|
+
await this.adopt_manual_members();
|
|
229
248
|
Object.assign(this.state, { status: TICK_STATES[result], error: '' });
|
|
230
249
|
} catch (error) {
|
|
231
250
|
if (await this.deleted(error)) { this.removed = true; return; }
|
package/runner/free_runner.mjs
CHANGED
|
@@ -179,7 +179,7 @@ export class FreeRoomRunner {
|
|
|
179
179
|
const message = page.messages.find(item => item.id === request.message_id);
|
|
180
180
|
if (!message) return;
|
|
181
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);
|
|
182
|
+
{ title: room.title, goal: room.goal, context: page.messages, message, host: host.name, members }, this.fetch);
|
|
183
183
|
this.log(`智能调度 #${message.id}:${decision.member ? `推荐 ${decision.member}` : '不推荐,交主持人'}(选择 ${decision.choice ?? '-'},置信度 ${decision.confidence ?? '-'})`);
|
|
184
184
|
if (!decision.member) return;
|
|
185
185
|
await api(this.config, '/recommendations', { message_id: message.id, member_id: members.find(member => member.name === decision.member).id,
|
package/runner/scheduler.mjs
CHANGED
|
@@ -6,7 +6,7 @@ export const SCHEDULER_PROVIDERS = {
|
|
|
6
6
|
jev: { label: 'TypeSafe Jev', base_url: 'https://api.typesafe.ai/v1', path: '/systemone', models: ['jev-latest'] },
|
|
7
7
|
decisions: { label: 'OpenAI Decisions', base_url: 'https://api.openai.com/v1', path: '/decisions', models: ['gpt-6-luna'] },
|
|
8
8
|
};
|
|
9
|
-
const REQUEST_TIMEOUT_MS =
|
|
9
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
10
10
|
/** Below this confidence a free-room message stays with the host, exactly as without scheduling. */
|
|
11
11
|
export const ROUTE_CONFIDENCE = 0.8;
|
|
12
12
|
const HOST_CHOICE = '__host__';
|
|
@@ -59,66 +59,92 @@ export async function decide(home, scheduler, state, questions, fetch_impl = fet
|
|
|
59
59
|
return scheduler_answers(scheduler, await response.json());
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/** Send one tiny yes/no question so provider, model and key are checked together before a room relies on them. */
|
|
63
|
+
export async function scheduler_check(home, input, fetch_impl = fetch) {
|
|
64
|
+
const scheduler = validate_scheduler(home, input);
|
|
65
|
+
const answers = await decide(home, scheduler, '这是一条连通性检查消息。', [{ type: 'predicate', name: 'check', instructions: '这条内容是否是连通性检查?' }], fetch_impl);
|
|
66
|
+
if (typeof answers.check?.probability !== 'number') throw new Error('决策模型未返回可用答案,请核对供应商与模型');
|
|
67
|
+
return { ok: true };
|
|
68
|
+
}
|
|
69
|
+
|
|
62
70
|
/** Shorten a message so recent context stays small; the decision never needs whole answers. */
|
|
63
71
|
function excerpt(message) {
|
|
64
72
|
const text = String(message.text || '').replace(/\s+/g, ' ').trim();
|
|
65
73
|
return `#${message.id} ${message.author}:${text.length > MAX_MESSAGE_CHARS ? `${text.slice(0, MAX_MESSAGE_CHARS)}…` : text}`;
|
|
66
74
|
}
|
|
67
75
|
|
|
76
|
+
/** The room's purpose in the user's words, so a decision can tell drifting from progress. */
|
|
77
|
+
function topic_lines(title, goal, user_request) {
|
|
78
|
+
return [`房间:${title || ''}`, goal && `目标:${goal}`, user_request && `用户原文:${String(user_request).slice(0, MAX_MESSAGE_CHARS)}`].filter(Boolean).join('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
68
81
|
/** State shared by both uses: the room, a few earlier messages, and the message to place. */
|
|
69
|
-
export function scheduler_state(title, context, message) {
|
|
82
|
+
export function scheduler_state(title, context, message, goal = '', user_request = '') {
|
|
70
83
|
const earlier = context.filter(item => item.id < message.id).slice(-CONTEXT_MESSAGES).map(excerpt);
|
|
71
|
-
return [
|
|
84
|
+
return [topic_lines(title, goal, user_request), earlier.length ? `近期消息(旧→新):\n${earlier.join('\n')}` : '近期没有其他消息。', `待处理的用户消息:\n${excerpt(message)}`].join('\n\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* How sure the provider is of its pick: the chosen option's probability when given. Jev's separate `confidence`
|
|
89
|
+
* runs lower than that probability on two-way choices, so it is only the fallback.
|
|
90
|
+
*/
|
|
91
|
+
export function certainty(answer) {
|
|
92
|
+
const probability = answer?.probabilities?.[answer.choice];
|
|
93
|
+
return typeof probability === 'number' ? probability : typeof answer?.confidence === 'number' ? answer.confidence : null;
|
|
72
94
|
}
|
|
73
95
|
|
|
74
96
|
/**
|
|
75
97
|
* Pick the member for one unnamed free-room message, or null to leave it with the host.
|
|
76
98
|
* Members are [{name, description}] without the host; only a confident member choice is returned.
|
|
77
99
|
*/
|
|
78
|
-
export async function route_free_message(home, scheduler, { title, context, message, host, members }, fetch_impl) {
|
|
100
|
+
export async function route_free_message(home, scheduler, { title, goal, context, message, host, members }, fetch_impl) {
|
|
79
101
|
const choices = [...members.map(member => ({ value: member.name, description: member.description })),
|
|
80
102
|
{ value: HOST_CHOICE, description: `交回主持人 ${host}:需要拆分任务、补充说明、多人协作,或无法确定由谁处理` }];
|
|
81
|
-
const answers = await decide(home, scheduler, scheduler_state(title, context, message), [{ type: 'choice', name: 'handler',
|
|
103
|
+
const answers = await decide(home, scheduler, scheduler_state(title, context, message, goal), [{ type: 'choice', name: 'handler',
|
|
82
104
|
instructions: '这条用户消息没有点名成员。判断它能否直接交给一位成员独立处理;需要主持人先理解、拆分或协调时选交回主持人。', choices }], fetch_impl);
|
|
83
105
|
const answer = answers.handler || {};
|
|
84
|
-
const
|
|
85
|
-
|
|
106
|
+
const confidence = certainty(answer);
|
|
107
|
+
const member = members.some(item => item.name === answer.choice) && confidence >= ROUTE_CONFIDENCE ? answer.choice : null;
|
|
108
|
+
return { member, choice: answer.choice ?? null, confidence };
|
|
86
109
|
}
|
|
87
110
|
|
|
88
111
|
/**
|
|
89
112
|
* Decide one interjection in a discussion: continue the plan (note), ask one expert (ask) or wake the host (host).
|
|
90
113
|
* Anything but a confident note or ask is returned as host.
|
|
91
114
|
*/
|
|
92
|
-
export async function decide_interjection(home, scheduler, { title, context, message, host, experts }, fetch_impl) {
|
|
115
|
+
export async function decide_interjection(home, scheduler, { title, goal, user_request, context, message, host, experts }, fetch_impl) {
|
|
93
116
|
const choices = [...experts.map(name => ({ value: name, description: `请 ${name} 回应这条留言` })),
|
|
94
117
|
{ value: HOST_CHOICE, description: `由主持人 ${host} 处理:调整目标、方向、安排,或需要主持人决定` },
|
|
95
118
|
{ value: NOTE_CHOICE, description: '仅补充信息:不需要专人回应,按原计划继续' }];
|
|
96
|
-
const answers = await decide(home, scheduler, scheduler_state(title, context, message), [{ type: 'choice', name: 'handling',
|
|
97
|
-
instructions: '
|
|
98
|
-
const
|
|
119
|
+
const answers = await decide(home, scheduler, scheduler_state(title, context, message, goal, user_request), [{ type: 'choice', name: 'handling',
|
|
120
|
+
instructions: '讨论进行中用户发来这条留言。结合讨论目标判断它最适合怎样处理。', choices }], fetch_impl);
|
|
121
|
+
const choice = answers.handling?.choice;
|
|
122
|
+
const confidence = certainty(answers.handling);
|
|
99
123
|
const sure = confidence !== null && confidence >= ROUTE_CONFIDENCE;
|
|
100
124
|
if (sure && choice === NOTE_CHOICE) return { action: 'note', confidence, label: '仅补充信息,按原计划继续' };
|
|
101
125
|
if (sure && experts.includes(choice)) return { action: 'ask', expert: choice, confidence, label: `先请 ${choice} 回应这条留言` };
|
|
102
|
-
return { action: 'host', confidence, label: '
|
|
126
|
+
return { action: 'host', confidence, label: choice === HOST_CHOICE && sure ? '这条留言需要主持人处理' : '拿不准这条留言如何处理,交给主持人' };
|
|
103
127
|
}
|
|
104
128
|
|
|
105
129
|
/**
|
|
106
130
|
* At the end of a round without named follow-ups: continue with another peer round, or wake the host
|
|
107
131
|
* (to conclude, or because the discussion drifted, repeats itself or is stuck). The first speaker is never the last one.
|
|
108
132
|
*/
|
|
109
|
-
export async function decide_round(home, scheduler, { title, context, host, experts, last_speaker }, fetch_impl) {
|
|
133
|
+
export async function decide_round(home, scheduler, { title, goal, user_request, context, host, experts, last_speaker }, fetch_impl) {
|
|
110
134
|
const candidates = experts.filter(name => name !== last_speaker);
|
|
111
135
|
const recent = context.slice(-CONTEXT_MESSAGES).map(excerpt);
|
|
112
|
-
const state = [
|
|
113
|
-
const questions = [{ type: 'choice', name: 'next', instructions: '
|
|
114
|
-
{ value: 'continue', description: '
|
|
115
|
-
{ value: HOST_CHOICE, description: `交给主持人 ${host}
|
|
136
|
+
const state = [topic_lines(title, goal, user_request), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
|
|
137
|
+
const questions = [{ type: 'choice', name: 'next', instructions: '一轮发言刚结束。结合讨论目标判断:是否值得让大家继续下一轮,互相回应、追问或补充?', choices: [
|
|
138
|
+
{ value: 'continue', description: '继续下一轮:还有可以互相回应、追问、补充或展开的内容' },
|
|
139
|
+
{ value: HOST_CHOICE, description: `交给主持人 ${host}:目标已经达成可以收束,或讨论偏离目标、反复重复、卡住,需要主持人引导` }] }];
|
|
116
140
|
if (candidates.length > 1) questions.push({ type: 'choice', name: 'first', instructions: '下一轮应先请谁发言?优先选被其他专家点名追问、最需要回应的人。',
|
|
117
141
|
choices: candidates.map(name => ({ value: name, description: `先请 ${name}` })) });
|
|
118
142
|
const answers = await decide(home, scheduler, state, questions, fetch_impl);
|
|
119
|
-
const
|
|
143
|
+
const choice = answers.next?.choice;
|
|
144
|
+
const confidence = certainty(answers.next);
|
|
120
145
|
if (choice !== 'continue' || confidence === null || confidence < ROUTE_CONFIDENCE || !candidates.length) {
|
|
121
|
-
|
|
146
|
+
const sure = confidence !== null && confidence >= ROUTE_CONFIDENCE;
|
|
147
|
+
return { action: 'host', confidence, label: choice === HOST_CHOICE && sure ? '建议收束或调整方向,交给主持人' : '拿不准是否继续下一轮,交给主持人' };
|
|
122
148
|
}
|
|
123
149
|
const first = candidates.includes(answers.first?.choice) ? answers.first.choice : candidates[0];
|
|
124
150
|
return { action: 'continue', first, confidence, label: `继续下一轮,先请 ${first} 发言` };
|
package/web/app.js
CHANGED
|
@@ -946,7 +946,7 @@ async function invite_member({ name, access, project_access = "read", session_id
|
|
|
946
946
|
finish_member_request(room_id, token, record, result);
|
|
947
947
|
if (state.room?.id === room_id) {
|
|
948
948
|
state.free_notice = access === "online" ? `${record.member.name} 已加入,阅读通知与真实回答由你或授权主持人转交。`
|
|
949
|
-
: access === "manual" ? `${record.member.name}
|
|
949
|
+
: access === "manual" ? `${record.member.name} 已加入;点它的“复制启动指令”,粘贴到你要用的 AI 会话即可。`
|
|
950
950
|
: record.options?.session_id ? `${record.member.name} 已接入原会话;它工作时请不要在原窗口继续操作。`
|
|
951
951
|
: state.room.mode === "free" ? `${record.member.name} 已加入,之后点名它即会由本机服务唤醒。`
|
|
952
952
|
: `${record.member.name} 已加入讨论,主持人会重新安排发言。`;
|
|
@@ -1437,8 +1437,8 @@ async function local_launch_request(action, payload) {
|
|
|
1437
1437
|
response = await fetch(`${local_connection?.endpoint || ""}/api/local/${action}`, {
|
|
1438
1438
|
method: payload === undefined ? "GET" : "POST", headers, credentials: "omit", redirect: "error",
|
|
1439
1439
|
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
1440
|
-
...(payload === undefined || ["room-models", "models", "api-check"].includes(action)
|
|
1441
|
-
? { signal: AbortSignal.timeout(["models", "api-check"].includes(action) ? MODEL_CATALOG_TIMEOUT_MS : LOCAL_CONNECTION_TIMEOUT_MS) } : {}) });
|
|
1440
|
+
...(payload === undefined || ["room-models", "models", "api-check", "scheduler-check"].includes(action)
|
|
1441
|
+
? { signal: AbortSignal.timeout(["models", "api-check", "scheduler-check"].includes(action) ? MODEL_CATALOG_TIMEOUT_MS : LOCAL_CONNECTION_TIMEOUT_MS) } : {}) });
|
|
1442
1442
|
} catch (error) {
|
|
1443
1443
|
if (!local_connection) throw error;
|
|
1444
1444
|
throw new Error("无法连接本机服务:请在这台电脑运行 codiscuss local open,并允许浏览器访问本机网络,再重试原请求");
|
|
@@ -1778,6 +1778,12 @@ function write_scheduler(field, value) {
|
|
|
1778
1778
|
publish_view();
|
|
1779
1779
|
}
|
|
1780
1780
|
|
|
1781
|
+
/** Ask the local service to send the decision model one tiny question; throws its reason on failure. */
|
|
1782
|
+
async function check_scheduler() {
|
|
1783
|
+
const { provider, model, api_key_ref } = state.launch.scheduler;
|
|
1784
|
+
await local_launch_request("scheduler-check", { provider, model, api_key_ref });
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1781
1787
|
/** Save the scheduling provider's key to the local private store, under that provider's official root. */
|
|
1782
1788
|
async function save_scheduler_api_key(api_key) {
|
|
1783
1789
|
const scheduler = state.launch.scheduler;
|
|
@@ -2200,7 +2206,7 @@ ${instruction}
|
|
|
2200
2206
|
/** How a manual member learns it is its turn: free rooms are pulled by the member, discussions only on the host's assignment. */
|
|
2201
2207
|
function manual_trigger_instruction(room) {
|
|
2202
2208
|
return room.mode === "free" ? "首次复制后,可由人工或软件原生定时触发;自行提交,不必每轮复制回答。"
|
|
2203
|
-
: "这是全自动讨论:只在主持人给你安排发言时才参与,先核对 turn current
|
|
2209
|
+
: "这是全自动讨论:只在主持人给你安排发言时才参与,先核对 turn current 是否轮到你,再按参与技能认领发言权并提交;没有安排就结束本次检查。首次提交后,若你运行在 Claude Code 中,本机服务会自动接管你的会话并在后续轮次唤醒你;未被接管时才需要你或软件原生定时触发。";
|
|
2204
2210
|
}
|
|
2205
2211
|
|
|
2206
2212
|
/** Download a private per-room setup file for the independent local runner. */
|
|
@@ -3078,7 +3084,7 @@ function change_view_theme(theme) {
|
|
|
3078
3084
|
|
|
3079
3085
|
window.discuss_view = { snapshot: view_snapshot, write_field: write_view_field,
|
|
3080
3086
|
write_launch: write_launch_field, write_room_connection, write_room_adapter, launch_clients: LAUNCH_CLIENTS,
|
|
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,
|
|
3087
|
+
launch_model_state, load_launch_models, write_launch_model, save_launch_api_key, write_scheduler, save_scheduler_api_key, check_scheduler, 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,
|
|
3082
3088
|
start_drag: start_directory_drag,
|
|
3083
3089
|
attachment_markup, attachment_size, attachment_file_icon, drop_attachments, paste_attachments,
|
|
3084
3090
|
write_expert: write_view_expert, add_instance: add_view_instance, platform_of, execute: execute_view_action, theme: change_view_theme,
|