ai_co_discuss 0.2.69 → 0.2.71

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.
@@ -253,6 +253,15 @@ export function kill_process_tree(child, plain_kill, platform = process.platform
253
253
  return plain_kill();
254
254
  }
255
255
 
256
+ /** Make a delivered process's kill end its whole tree on Windows, so a stopped turn leaves no tool commands running. */
257
+ function tree_killable(child) {
258
+ if (typeof child.kill === 'function') {
259
+ const plain_kill = child.kill.bind(child);
260
+ child.kill = () => kill_process_tree(child, plain_kill);
261
+ }
262
+ return child;
263
+ }
264
+
256
265
  async function claude_delivery(config, binding, task, spawn_process = spawn) {
257
266
  const session_id = binding.session_id || randomUUID();
258
267
  try { bind_access(task.access_path, session_id); }
@@ -266,10 +275,7 @@ async function claude_delivery(config, binding, task, spawn_process = spawn) {
266
275
  env: { ...standalone_claude_env(), DISCUSS_ACCESS_FILE: task.access_path },
267
276
  });
268
277
  } catch (error) { throw not_sent(error); }
269
- if (typeof child.kill === 'function') {
270
- const plain_kill = child.kill.bind(child);
271
- child.kill = () => kill_process_tree(child, plain_kill);
272
- }
278
+ tree_killable(child);
273
279
  let spawned = false;
274
280
  const log_path = resolve(task.directory, `claude-${task.id}.log`);
275
281
  let output = '';
@@ -497,6 +503,7 @@ async function cli_delivery(config, binding, task, spawn_process, user_home) {
497
503
  env: { ...process.env, DISCUSS_ACCESS_FILE: task.access_path },
498
504
  });
499
505
  } catch (error) { throw not_sent(error); }
506
+ tree_killable(child);
500
507
  const state = monitor_cli(child, config, binding, task);
501
508
  task.on_process(child);
502
509
  await new Promise((resolve_spawn, reject) => {
@@ -462,7 +462,7 @@ export class DiscussionRunner {
462
462
  async record_decision(plan, applied) {
463
463
  try {
464
464
  await api(this.config, '/scheduler-decisions', { kind: plan.kind, label: plan.label, confidence: plan.confidence ?? null,
465
- applied, provider: this.config.scheduler.provider, after_message_id: plan.after_message_id || 0, detail: plan.detail });
465
+ applied, provider: this.config.scheduler.provider, after_message_id: plan.after_message_id || 0, detail: plan.detail, input: plan.input });
466
466
  } catch (error) { this.log(`智能调度决策记录未保存:${error.message}`); }
467
467
  }
468
468
  /** Enter once per delivery; a failure is recorded so reconcile revokes the execution. */
@@ -5,7 +5,7 @@ import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
5
5
  import { basename, join, resolve } from 'node:path';
6
6
  import { atomic_json, deliver_task, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
7
7
  import { api } from './discussion_runner.mjs';
8
- import { ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, route_free_message } from './scheduler.mjs';
8
+ import { ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, decide_group_order, route_free_message } from './scheduler.mjs';
9
9
 
10
10
  const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
11
11
  const RENEW_MS = 30_000;
@@ -121,6 +121,8 @@ export class FreeRoomRunner {
121
121
  this.log = log;
122
122
  this.fetch = fetch_impl;
123
123
  this.routed = new Set();
124
+ // Group requests (@全体成员) being arranged; a failed arrangement leaves the set so the next tick retries.
125
+ this.grouped = new Set();
124
126
  this.decisions = new Set();
125
127
  // Routed requests whose decision is still pending: their host waits, so one pick replaces the host's start.
126
128
  this.routing = new Set();
@@ -164,8 +166,9 @@ export class FreeRoomRunner {
164
166
  async tick() {
165
167
  const inbox = await api(this.config, '/inbox');
166
168
  const room = await api(this.config, '');
167
- // Smart scheduling runs beside delivery: the host gets the message now, a recommendation may follow.
169
+ // Smart scheduling runs beside delivery: the host waits for its answer (or failure) before starting, see `routing`.
168
170
  if (this.config.scheduler && !inbox.archived) this.recommend_unnamed(inbox, room);
171
+ if (!inbox.archived) this.arrange_groups(inbox, room);
169
172
  this.room_title = room.title;
170
173
  const active = new Map(inbox.executions.map(item => [item.id, item]));
171
174
  const present = new Set(inbox.members.map(member => member.name));
@@ -177,7 +180,7 @@ export class FreeRoomRunner {
177
180
  for (const member of inbox.members) {
178
181
  const row = this.row(member.name);
179
182
  if (row && LIVE.includes(row.status)) await this.reconcile(member, row, active, inbox.execution_minutes);
180
- else if (!inbox.archived && member.pending.length && !this.members.get(member.name)?.size
183
+ else if (!inbox.archived && member.pending.some(request => request.purpose !== 'group') && !this.members.get(member.name)?.size
181
184
  && !member.pending.some(request => this.routing.has(request.id))) await this.start(member, row);
182
185
  }
183
186
  }
@@ -202,6 +205,39 @@ export class FreeRoomRunner {
202
205
  decision.finally(() => this.decisions.delete(decision));
203
206
  }
204
207
  }
208
+ /** For each message naming everyone, have them answer at once, or one after another when smart scheduling says so. */
209
+ arrange_groups(inbox, room) {
210
+ const everyone = [...inbox.members, ...(inbox.observers || [])];
211
+ for (const request of everyone.flatMap(member => member.pending).filter(item => item.purpose === 'group' && !this.grouped.has(item.id))) {
212
+ this.grouped.add(request.id);
213
+ const arranged = (this.config.scheduler ? this.mark_routing(request.message_id, true).then(() => this.order_group(request, room, everyone))
214
+ .finally(() => this.mark_routing(request.message_id, false)) : this.apply_group(request.message_id, { action: 'all' }))
215
+ .catch(error => { this.grouped.delete(request.id); this.log(`全体成员 #${request.message_id} 未能安排:${error.message}`); });
216
+ this.decisions.add(arranged);
217
+ arranged.finally(() => this.decisions.delete(arranged));
218
+ }
219
+ }
220
+ /** Ask the scheduler how everyone answers; a failed call still has them answer at once. */
221
+ async order_group(request, room, everyone) {
222
+ const page = await api(this.config, `/messages?after=${Math.max(0, request.message_id - ROUTE_CONTEXT_MESSAGES - 1)}`);
223
+ const message = page.messages.find(item => item.id === request.message_id);
224
+ const members = (room.speaking_order?.items || []).map(item => item.name).filter(name => everyone.some(member => member.name === name));
225
+ const decision = await decide_group_order(this.config.api_home, this.config.scheduler,
226
+ { title: room.title, goal: room.goal, context: page.messages, message, members: members.length ? members : everyone.map(member => member.name) }, this.fetch)
227
+ .catch(error => { this.log(`智能调度 #${request.message_id} 未判断全体回答方式:${error.message}`); return { action: 'all' }; });
228
+ await this.apply_group(request.message_id, decision);
229
+ if (decision.detail) {
230
+ const label = decision.action === 'sequential' ? '请全体按发言顺序依次回答' : '请全体同时回答';
231
+ await this.record_decision(request.message_id, decision, label, true);
232
+ }
233
+ }
234
+ /** Replace the host's group request with everyone's requests; a message already arranged elsewhere is left as is. */
235
+ async apply_group(message_id, decision) {
236
+ this.log(`全体成员 #${message_id}:${decision.action === 'sequential' ? '依次回答' : '同时回答'}`);
237
+ await api(this.config, `/recommendations/${message_id}`, { action: decision.action }).catch(error => {
238
+ if (!/HTTP (404|409)/.test(error.message)) throw error;
239
+ });
240
+ }
205
241
  /** Tell the website a message is being analysed so it can show the card; a failed mark never blocks routing. */
206
242
  async mark_routing(message_id, active) {
207
243
  try { await api(this.config, '/scheduler-routing', { message_id, active }); }
@@ -248,7 +284,7 @@ export class FreeRoomRunner {
248
284
  async record_decision(message_id, decision, label, applied) {
249
285
  try {
250
286
  await api(this.config, '/scheduler-decisions', { kind: 'interjection', label, confidence: decision.confidence ?? null,
251
- applied, provider: this.config.scheduler.provider, after_message_id: message_id, detail: decision.detail,
287
+ applied, provider: this.config.scheduler.provider, after_message_id: message_id, detail: decision.detail, input: decision.input,
252
288
  context: { model: this.config.scheduler.model, threshold: ROUTE_CONFIDENCE, wide_threshold: ROUTE_WIDE_CONFIDENCE,
253
289
  prompt: ROUTE_PROMPT_VERSION, package: PACKAGE_VERSION } });
254
290
  } catch (error) { this.log(`智能调度 #${message_id} 决策记录未保存:${error.message}`); }
@@ -12,7 +12,7 @@ export const ROUTE_CONFIDENCE = 0.7;
12
12
  /** Waking everyone or no one costs more when wrong (N runs, or an ignored message), so it needs more certainty. */
13
13
  export const ROUTE_WIDE_CONFIDENCE = 0.85;
14
14
  /** Bump when the routing question or its option descriptions change, so ratings can be compared across wordings. */
15
- export const ROUTE_PROMPT_VERSION = 3;
15
+ export const ROUTE_PROMPT_VERSION = 4;
16
16
  const HOST_CHOICE = '__host__';
17
17
  const NOTE_CHOICE = '__note__';
18
18
  const ALL_CHOICE = '__all__';
@@ -95,6 +95,11 @@ function detail_of(questions, answers) {
95
95
  });
96
96
  }
97
97
 
98
+ /** The shown detail plus the exact input (state and full questions), kept so a rated-bad decision can be reviewed later. */
99
+ function judged_of(state, questions, answers) {
100
+ return { detail: detail_of(questions, answers), input: { state, questions } };
101
+ }
102
+
98
103
  /** Say which option the provider leaned to when it was not sure enough, so the transcript shows how close it was. */
99
104
  function unsure_label(lean) {
100
105
  return lean ? `倾向${lean},把握不足,交给主持人` : '拿不准,交给主持人';
@@ -108,8 +113,8 @@ function excerpt(message) {
108
113
  }
109
114
 
110
115
  /** The room's purpose in the user's words, so a decision can tell drifting from progress. */
111
- function topic_lines(title, goal, user_request, { brief, focus, arrangement, progress } = {}) {
112
- return [`房间:${title || ''}`, goal && `目标:${goal}`, user_request && `用户原文:${String(user_request).slice(0, MAX_MESSAGE_CHARS)}`,
116
+ function topic_lines(title, goal, user_request, { roster, brief, focus, arrangement, progress } = {}) {
117
+ return [`房间:${title || ''}`, roster && `房间成员(名字不区分大小写):${roster}`, goal && `目标:${goal}`, user_request && `用户原文:${String(user_request).slice(0, MAX_MESSAGE_CHARS)}`,
113
118
  focus && `当前焦点:${focus}`, brief && `主持简报(含分工与规则):${String(brief).slice(0, MAX_BRIEF_CHARS)}`,
114
119
  arrangement && `主持人最近的安排:${arrangement}`, progress].filter(Boolean).join('\n');
115
120
  }
@@ -136,21 +141,39 @@ export function certainty(answer) {
136
141
  */
137
142
  export async function route_free_message(home, scheduler, { title, goal, context, message, host, members }, fetch_impl) {
138
143
  const choices = [...members.map(member => ({ value: member.name, description: member.description })),
139
- { value: HOST_CHOICE, description: `主持人 ${host.description}:消息在延续主持人自己的回答,或对象不明确,或需要分工、排先后、互相回应` },
140
- { value: ALL_CHOICE, description: '全体成员(含主持人)各自回应一次,彼此不需要配合,如打招呼、自我介绍、各说一句看法' },
144
+ { value: HOST_CHOICE, description: `主持人 ${host.description}:没有指向任何人(如单独一句“hi”“你好”这类问候、闲聊,由主持人代表房间回应),或需要分工、排先后、互相回应` },
145
+ { value: ALL_CHOICE, description: '全体成员(含主持人)各自回应一次,彼此不需要配合;须是用户明确要求大家各自回应,如“大家/都出来打个招呼”“各自介绍一下”“各说一句看法”' },
141
146
  { value: NONE_CHOICE, description: '无需回应:只是确认、致谢或旁注,不需要任何人回答' }];
142
147
  const questions = [{ type: 'choice', name: 'handler', choices,
143
- instructions: '这条用户消息没有用 @ 点名成员。判断它的回应对象是否已经确定:正文叫了某位成员的名字或在向其提问;在延续某位成员的回答(追问、要求修改、引用,或“回复”指向其消息);在补充或纠正一条正由某位成员处理的消息。以上情况选该成员,是主持人就选主持人。要求所有成员各自回应一次选全体;只是确认、致谢或旁注选无需回应;对象不明确,或需要分工、排先后、互相回应时选主持人。' }];
144
- const answers = await decide(home, scheduler, scheduler_state(title, context, message, goal, '', {}, ROUTE_CONTEXT_MESSAGES), questions, fetch_impl);
145
- const detail = detail_of(questions, answers);
148
+ instructions: '这条用户消息没有用 @ 点名成员。判断它的回应对象,按下面的顺序:1)正文叫了某位成员的名字,或在向其提问(包括“X在吗”“X还在吗”“X你怎么看”),选该成员,即使上一条是别人的回答;2)正文没有指向任何人时,看它是否在延续某位成员的回答(追问、要求修改、引用,或“回复”指向其消息),或在补充、纠正一条正由某位成员处理的消息,是则选该成员,是主持人就选主持人;3)明确要求所有成员各自回应一次(用了“大家”“都”“各自”等)选全体;4)单独的问候或闲聊(如“hi”)既没有指向某位成员,也没有要求大家一起回应,选主持人;5)只是确认、致谢或旁注选无需回应;6)对象不明确,或需要分工、排先后、互相回应时选主持人。' }];
149
+ const roster = [`${host.name}(主持人)`, ...members.map(member => member.name)].join('、');
150
+ const state = scheduler_state(title, context, message, goal, '', { roster }, ROUTE_CONTEXT_MESSAGES);
151
+ const answers = await decide(home, scheduler, state, questions, fetch_impl);
152
+ const judged = judged_of(state, questions, answers);
146
153
  const choice = answers.handler?.choice ?? null;
147
154
  const confidence = certainty(answers.handler);
148
155
  const wide = choice === ALL_CHOICE || choice === NONE_CHOICE;
149
156
  const sure = confidence !== null && confidence >= (wide ? ROUTE_WIDE_CONFIDENCE : ROUTE_CONFIDENCE);
150
- if (sure && choice === ALL_CHOICE) return { action: 'all', choice, confidence, detail };
151
- if (sure && choice === NONE_CHOICE) return { action: 'none', choice, confidence, detail };
152
- if (sure && members.some(item => item.name === choice)) return { action: 'member', member: choice, choice, confidence, detail };
153
- return { action: 'host', choice, confidence, detail };
157
+ if (sure && choice === ALL_CHOICE) return { action: 'all', choice, confidence, ...judged };
158
+ if (sure && choice === NONE_CHOICE) return { action: 'none', choice, confidence, ...judged };
159
+ if (sure && members.some(item => item.name === choice)) return { action: 'member', member: choice, choice, confidence, ...judged };
160
+ return { action: 'host', choice, confidence, ...judged };
161
+ }
162
+
163
+ /**
164
+ * The user named everyone (@全体成员): decide whether they answer at once (parallel) or one after another (sequential).
165
+ * Anything but a confident sequential stays parallel, the cheaper and earlier behaviour of naming everyone.
166
+ */
167
+ export async function decide_group_order(home, scheduler, { title, goal, context, message, members }, fetch_impl) {
168
+ const questions = [{ type: 'choice', name: 'order', choices: [
169
+ { value: 'parallel', description: '同时回答:各自独立回应即可,不需要看到别人的回答,如打招呼、各自介绍、各说一句看法、各自投票' },
170
+ { value: 'sequential', description: `依次回答:后发言的需要看到前面的回答,如接龙、轮流补充或反驳、逐个评审上一位的结果、按顺序分工接力;按房间发言顺序(${members.join('、')})逐个回答` }],
171
+ instructions: '用户用 @全体成员 请房间里所有成员回应这条消息。判断大家应同时各自回答,还是需要按顺序依次回答(后一位看到前一位的回答再接着说)。' }];
172
+ const state = scheduler_state(title, context, message, goal, '', {}, ROUTE_CONTEXT_MESSAGES);
173
+ const answers = await decide(home, scheduler, state, questions, fetch_impl);
174
+ const confidence = certainty(answers.order);
175
+ const sequential = answers.order?.choice === 'sequential' && confidence !== null && confidence >= ROUTE_CONFIDENCE;
176
+ return { action: sequential ? 'sequential' : 'all', choice: answers.order?.choice ?? null, confidence, ...judged_of(state, questions, answers) };
154
177
  }
155
178
 
156
179
  /**
@@ -162,15 +185,16 @@ export async function decide_interjection(home, scheduler, { title, goal, user_r
162
185
  { value: HOST_CHOICE, description: `由主持人 ${host} 处理:留言要改变讨论目标、规则、分工、安排或结束讨论,或同时涉及多位专家` },
163
186
  { value: NOTE_CHOICE, description: '仅补充信息:留言只是旁注、信息或鼓励,不要求任何人回应,按当前安排继续' }];
164
187
  const questions = [{ type: 'choice', name: 'handling', instructions: '讨论进行中用户发来这条留言。对照当前焦点、主持简报和安排,判断它最适合怎样处理。', choices }];
165
- const answers = await decide(home, scheduler, scheduler_state(title, context, message, goal, user_request, extra), questions, fetch_impl);
166
- const detail = detail_of(questions, answers);
188
+ const state = scheduler_state(title, context, message, goal, user_request, extra);
189
+ const answers = await decide(home, scheduler, state, questions, fetch_impl);
190
+ const judged = judged_of(state, questions, answers);
167
191
  const choice = answers.handling?.choice;
168
192
  const confidence = certainty(answers.handling);
169
193
  const sure = confidence !== null && confidence >= ROUTE_CONFIDENCE;
170
- if (sure && choice === NOTE_CHOICE) return { action: 'note', confidence, detail, label: '仅补充信息,按原计划继续' };
171
- if (sure && experts.includes(choice)) return { action: 'ask', expert: choice, confidence, detail, label: `先请 ${choice} 回应这条留言` };
194
+ if (sure && choice === NOTE_CHOICE) return { action: 'note', confidence, ...judged, label: '仅补充信息,按原计划继续' };
195
+ if (sure && experts.includes(choice)) return { action: 'ask', expert: choice, confidence, ...judged, label: `先请 ${choice} 回应这条留言` };
172
196
  const lean = choice === NOTE_CHOICE ? '按原计划继续' : experts.includes(choice) ? `请 ${choice} 回应` : choice === HOST_CHOICE ? '由主持人处理' : '';
173
- return { action: 'host', confidence, detail, label: choice === HOST_CHOICE && sure ? '这条留言需要主持人处理' : unsure_label(lean) };
197
+ return { action: 'host', confidence, ...judged, label: choice === HOST_CHOICE && sure ? '这条留言需要主持人处理' : unsure_label(lean) };
174
198
  }
175
199
 
176
200
  /**
@@ -189,17 +213,17 @@ export async function decide_next_speaker(home, scheduler, { title, goal, user_r
189
213
  { type: 'predicate', name: 'obligation',
190
214
  instructions: `是否存在明确依据要求 ${candidates.join('、')} 之一接着发言:简报分工或流程规定轮到其人,或最近发言点名追问了其人?仅仅是“本轮还没发言”不算依据。` }];
191
215
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
192
- const detail = detail_of(questions, answers);
216
+ const judged = judged_of(state, questions, answers);
193
217
  const choice = answers.speaker?.choice;
194
218
  const confidence = certainty(answers.speaker);
195
219
  const obligation = answers.obligation?.probability;
196
220
  if (candidates.includes(choice) && confidence !== null && confidence >= ROUTE_CONFIDENCE && obligation >= ROUTE_CONFIDENCE) {
197
- return { action: 'next', expert: choice, confidence, detail, label: `本轮继续,请 ${choice} 接着发言` };
221
+ return { action: 'next', expert: choice, confidence, ...judged, label: `本轮继续,请 ${choice} 接着发言` };
198
222
  }
199
223
  if (typeof obligation === 'number' && obligation < ROUTE_CONFIDENCE) {
200
- return { action: 'host', confidence, detail, label: `有明确依据的概率 ${obligation.toFixed(2)},未达 ${ROUTE_CONFIDENCE.toFixed(2)},交给主持人安排` };
224
+ return { action: 'host', confidence, ...judged, label: `有明确依据的概率 ${obligation.toFixed(2)},未达 ${ROUTE_CONFIDENCE.toFixed(2)},交给主持人安排` };
201
225
  }
202
- return { action: 'host', confidence, detail, label: choice === HOST_CHOICE && confidence >= ROUTE_CONFIDENCE ? '建议由主持人调整安排,交给主持人' : unsure_label(candidates.includes(choice) ? `请 ${choice} 发言` : choice === HOST_CHOICE ? '交给主持人' : '') };
226
+ return { action: 'host', confidence, ...judged, label: choice === HOST_CHOICE && confidence >= ROUTE_CONFIDENCE ? '建议由主持人调整安排,交给主持人' : unsure_label(candidates.includes(choice) ? `请 ${choice} 发言` : choice === HOST_CHOICE ? '交给主持人' : '') };
203
227
  }
204
228
 
205
229
  /**
@@ -216,13 +240,13 @@ export async function decide_round(home, scheduler, { title, goal, user_request,
216
240
  if (candidates.length > 1) questions.push({ type: 'choice', name: 'first', instructions: '下一轮应先请谁发言?按简报分工或接龙顺序判断;没有规定时选被其他专家点名追问、最需要回应的人。',
217
241
  choices: candidates.map(name => ({ value: name, description: `先请 ${name}` })) });
218
242
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
219
- const detail = detail_of(questions, answers);
243
+ const judged = judged_of(state, questions, answers);
220
244
  const choice = answers.next?.choice;
221
245
  const confidence = certainty(answers.next);
222
246
  if (choice !== 'continue' || confidence === null || confidence < ROUTE_CONFIDENCE || !candidates.length) {
223
247
  const sure = confidence !== null && confidence >= ROUTE_CONFIDENCE;
224
- return { action: 'host', confidence, detail, label: choice === HOST_CHOICE && sure ? '建议收束或调整方向,交给主持人' : unsure_label(choice === 'continue' ? '继续下一轮' : choice === HOST_CHOICE ? '收束或调整方向' : '') };
248
+ return { action: 'host', confidence, ...judged, label: choice === HOST_CHOICE && sure ? '建议收束或调整方向,交给主持人' : unsure_label(choice === 'continue' ? '继续下一轮' : choice === HOST_CHOICE ? '收束或调整方向' : '') };
225
249
  }
226
250
  const first = candidates.includes(answers.first?.choice) ? answers.first.choice : candidates[0];
227
- return { action: 'continue', first, confidence, detail, label: `继续下一轮,先请 ${first} 发言` };
251
+ return { action: 'continue', first, confidence, ...judged, label: `继续下一轮,先请 ${first} 发言` };
228
252
  }
package/web/app.js CHANGED
@@ -47,7 +47,7 @@ const PLATFORM_IDENTITIES = {
47
47
  Grok: { initials: "GK", style: "grok", description: "AI 身份" },
48
48
  };
49
49
  const IDENTITIES = by_platform(PLATFORM_IDENTITIES, (identity, name) => ({ ...identity, label: name, description: "AI 实例" }));
50
- const state = { rooms: [], folders: [], room: null, page: 1, pages: 1, query: "", mention_only: false, free_notice: "", free_error: "",
50
+ const state = { rooms: [], folders: [], room: null, page: 1, pages: 1, query: "", mention_only: false, free_notice: "", free_error: "", order_error: null,
51
51
  free_proxy: "", create_parent: null, named_followup: false, create_discussion: false,
52
52
  archive_view: false, directory_sort: "manual", management: null, dragged_room: null, folder_moves: new Map(), folder_version: 0,
53
53
  reply_to: null, turn_reply_to: null, tab: "topic", refresh_version: 0, context_dirty: false, topic_dirty: false,
@@ -939,12 +939,14 @@ function message_payload() {
939
939
  request_ids: state.room.free.requests.filter(item => item.member === online.name && item.status === "pending").map(item => item.id) };
940
940
  const payload = { author: "用户", text, reply_to: state.reply_to, kind: "chat", submitted_by: "用户" };
941
941
  const active = (state.room.free?.members || []).filter(member => member.status === "active" && (member.access !== "manual" || member.registered_at)).map(member => member.name);
942
- // @mentions outside code name members (@全体成员 or @all names them all; @全体依次 or @all-seq has them answer one after another, the host first);
943
- // without any, the server gives the message to the only member or the host.
942
+ // @mentions outside code name members (@全体成员 or @all names them all, answering at once or in turn as smart scheduling decides;
943
+ // @全体依次 or @all-seq has them answer one after another, the host first); without any, the server gives the message to the only member or the host.
944
944
  const sequential = mentioned(text, "全体依次") || mentioned(text, "all-seq");
945
- const named = sequential || mentioned(text, "全体成员") || mentioned(text, "all") ? active : active.filter(name => mentioned(text, name));
945
+ const everyone = !sequential && (mentioned(text, "全体成员") || mentioned(text, "all"));
946
+ const named = sequential || everyone ? active : active.filter(name => mentioned(text, name));
946
947
  if (named.length) payload.to = named;
947
948
  if (sequential && named.length) payload.sequential = true;
949
+ if (everyone && named.length) payload.order = "auto";
948
950
  return payload;
949
951
  }
950
952
  const mode = element("message-kind").value;
@@ -1067,6 +1069,18 @@ async function change_member({ member_id, name, action, role, latest_id }) {
1067
1069
  } catch (error) { if (state.room?.id === room_id) state.free_error = error.message; }
1068
1070
  }
1069
1071
 
1072
+ /** Reorder who has not spoken yet against the revision the board showed; a changed order is refused and the board reloads. */
1073
+ async function reorder_speakers({ revision, order }) {
1074
+ const room_id = state.room.id;
1075
+ state.order_error = null;
1076
+ try { await request_json(`/api/rooms/${room_id}/speaking-order`, { revision, order }); }
1077
+ catch (error) {
1078
+ // A conflict means someone else's change landed first; the board reloads below, so ask for the move again (same text as the board's own notice).
1079
+ state.order_error = { room_id, message: error.status === 409 ? "发言顺序刚有变化,已更新为最新顺序,请重新调整" : error.message };
1080
+ }
1081
+ if (state.room?.id === room_id) await refresh_room();
1082
+ }
1083
+
1070
1084
  /** Invite an independent member, retaining its credential before either delivery path runs. */
1071
1085
  async function invite_member({ name, access, project_access = "read", session_id = "" }) {
1072
1086
  const room_id = state.room.id;
@@ -3158,7 +3172,7 @@ function view_snapshot() {
3158
3172
  attachment_drafts: state.attachment_drafts.map(item => ({ id: item.id, file_name: item.file.name, size: item.file.size, preview_url: item.preview_url })),
3159
3173
  composer_error: state.composer_error, upload_status: state.upload_status, sending_message: state.sending_message,
3160
3174
  mention_only: state.mention_only, reply_to: state.reply_to, turn_reply_to: state.turn_reply_to,
3161
- free_notice: state.free_notice, free_error: state.free_error, free_proxy: state.free_proxy,
3175
+ free_notice: state.free_notice, free_error: state.free_error, free_proxy: state.free_proxy, order_error: state.order_error,
3162
3176
  create_parent: state.create_parent, access: access_snapshot(), admin: admin_snapshot(),
3163
3177
  tab: state.tab, snapshot_id: state.snapshot_id, context_dirty: state.context_dirty,
3164
3178
  theme: document.documentElement.dataset.theme, fields, experts, launch: structuredClone(state.launch), named_followup: state.named_followup, create_discussion: state.create_discussion };
@@ -3331,7 +3345,7 @@ async function execute_view_action(name, value) {
3331
3345
  copy_online: () => copy_context(), copy_cli: () => copy_context("cli"), copy_background,
3332
3346
  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, conclude_discussion, start_room_discussion, end_room_discussion, reopen_discussion,
3333
3347
  save_launch_defaults, launch_current_room, resume_launch, discard_launch, retry_launch_connection, check_launch_connection: load_launch_settings,
3334
- load_room_configuration, save_room_configuration, interrupt_member, change_member, invite_member, set_proxy, copy_online_notice,
3348
+ load_room_configuration, save_room_configuration, interrupt_member, change_member, reorder_speakers, invite_member, set_proxy, copy_online_notice,
3335
3349
  prepare_brainstorm, return_conclusion, convert_suggestion, adopt_recommendation, rate_decision,
3336
3350
  busy: () => { throw new Error("上一个操作仍在处理中,请稍候;长时间无响应请刷新页面"); },
3337
3351
  copy_reading: () => copy_text(`${location.origin}/r/${state.room.id}`), copy: copy_text };