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.
@@ -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
- /** 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() {
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 (room.limits?.reached || !ACTIVE_PHASES.includes(room.phase)) return null;
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 null;
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: 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.named_followup || room.queued_turns.length || !room.rounds?.at(-1)?.completed) return null;
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: room.latest_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; }
@@ -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,
@@ -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 = 15_000;
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 [`房间:${title || ''}`, earlier.length ? `近期消息(旧→新):\n${earlier.join('\n')}` : '近期没有其他消息。', `待处理的用户消息:\n${excerpt(message)}`].join('\n\n');
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 member = members.some(item => item.name === answer.choice) && answer.confidence >= ROUTE_CONFIDENCE ? answer.choice : null;
85
- return { member, choice: answer.choice ?? null, confidence: answer.confidence ?? null };
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: '讨论进行中用户发来这条留言。判断它最适合怎样处理。', choices }], fetch_impl);
98
- const { choice, confidence = null } = answers.handling || {};
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 = [`讨论:${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}:已可收束,或讨论跑偏、重复立场、卡住,需要主持人引导` }] }];
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 { choice, confidence = null } = answers.next || {};
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
- return { action: 'host', confidence, label: '交给主持人:判断是否收束或调整方向' };
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} 已加入;点它的“启动指令”复制含凭证的整段说明,粘贴到你要用的 AI 会话即可。`
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,