ai_co_discuss 0.3.11 → 0.3.13

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.
Files changed (46) hide show
  1. package/.agents/skills/discuss-participant/SKILL.md +1 -1
  2. package/backend/free_room.py +2410 -2274
  3. package/backend/schema.sql +27 -0
  4. package/backend/server.py +21 -11
  5. package/docs/architecture.md +2 -1
  6. package/docs/deployment.md +10 -0
  7. package/docs/en/deployment.md +10 -0
  8. package/docs/en/workflow.md +1 -1
  9. package/docs/runner.md +1 -1
  10. package/docs/workflow.md +2 -2
  11. package/package.json +3 -2
  12. package/runner/adapters/antigravity_app.mjs +91 -0
  13. package/runner/adapters/antigravity_cli.mjs +320 -0
  14. package/runner/adapters/antigravity_sidecar.mjs +50 -0
  15. package/runner/{api_participant.mjs → adapters/api.mjs} +19 -5
  16. package/runner/adapters/claude.mjs +313 -0
  17. package/runner/adapters/cli.mjs +126 -0
  18. package/runner/adapters/codex_app.mjs +70 -0
  19. package/runner/adapters/codex_cli.mjs +286 -0
  20. package/runner/adapters/common.mjs +196 -0
  21. package/runner/adapters/index.mjs +143 -0
  22. package/runner/adapters/profiles.mjs +116 -0
  23. package/runner/admin_account.mjs +1 -1
  24. package/runner/discussion_launch.mjs +69 -79
  25. package/runner/discussion_local.mjs +1 -1
  26. package/runner/discussion_runner.mjs +14 -13
  27. package/runner/discussion_service.mjs +3 -3
  28. package/runner/discussion_setup.mjs +1 -1
  29. package/runner/discussion_sidecar.mjs +5 -50
  30. package/runner/discussion_skills.mjs +1 -1
  31. package/runner/execution_progress.mjs +99 -0
  32. package/runner/expert_prompts.mjs +19 -29
  33. package/runner/files.mjs +10 -0
  34. package/runner/free_runner.mjs +136 -30
  35. package/runner/remote_launch.mjs +10 -5
  36. package/runner/scheduler.mjs +124 -32
  37. package/runner/session_driver.mjs +5 -3
  38. package/web/app.js +116 -23
  39. package/web/backup.js +6 -6
  40. package/web/index.html +2 -0
  41. package/web/modern.css +1 -1
  42. package/web/modern.js +1699 -1353
  43. package/runner/discussion_adapters.mjs +0 -959
  44. package/runner/discussion_models.mjs +0 -271
  45. package/runner/session_cut.mjs +0 -167
  46. /package/runner/{codex_app_client.mjs → adapters/codex_app_client.mjs} +0 -0
@@ -9,7 +9,8 @@ import { hostname } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
 
12
- import { atomic_json } from './discussion_adapters.mjs';
12
+ import { adapter_profile } from './adapters/profiles.mjs';
13
+ import { atomic_json } from './files.mjs';
13
14
  import { saved_api_keys } from './discussion_api.mjs';
14
15
  import { launch_discussion, launch_projects, launch_settings, platform_of } from './discussion_launch.mjs';
15
16
 
@@ -82,9 +83,13 @@ function api_key_ref(profile, keys) {
82
83
  /** Platforms this computer can wake without further setup, and the client each uses. */
83
84
  export function ready_clients(home) {
84
85
  const keys = saved_api_keys(home);
85
- return Object.fromEntries(Object.entries(launch_settings(home).participants).filter(([, profile]) => profile.adapter === 'api'
86
- ? Boolean(profile.base_url && profile.api_protocol && profile.model && api_key_ref(profile, keys))
87
- : Boolean(profile.adapter && (profile.adapter === 'antigravity' ? profile.ipc_directory : profile.executable))).map(([name, profile]) => [name, profile.adapter]));
86
+ return Object.fromEntries(Object.entries(launch_settings(home).participants).filter(([, profile]) => {
87
+ const facts = adapter_profile(profile.adapter);
88
+ if (!facts) return false;
89
+ // A model API needs its endpoint and key; other software needs its installed location.
90
+ return facts.kind === 'api' ? Boolean(profile.base_url && profile.api_protocol && profile.model && api_key_ref(profile, keys))
91
+ : Boolean(profile[facts.location]);
92
+ }).map(([name, profile]) => [name, profile.adapter]));
88
93
  }
89
94
 
90
95
  /** Turn a relayed request into the request the connected browser would send, using this computer's directory and keys. */
@@ -97,7 +102,7 @@ export function local_request(home, site, request) {
97
102
  const participants = { ...(input.participants || {}) };
98
103
  for (const member of input.room?.members || []) {
99
104
  const profile = defaults[platform_of(member.name)];
100
- const ref = profile?.adapter === 'api' && !participants[member.name]?.api_key_ref ? api_key_ref(profile, keys) : undefined;
105
+ const ref = adapter_profile(profile?.adapter)?.kind === 'api' && !participants[member.name]?.api_key_ref ? api_key_ref(profile, keys) : undefined;
101
106
  if (ref) participants[member.name] = { ...participants[member.name], api_key_ref: ref };
102
107
  }
103
108
  return { ...input, participants, site, entry: 'web', ...(project ? { cwd: project.path } : {}) };
@@ -12,9 +12,27 @@ 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 = 10;
15
+ export const ROUTE_PROMPT_VERSION = 12;
16
+ /**
17
+ * How the free-room routing question is worded. guided (experimental, the default while it is being refined) states the
18
+ * purpose and the evidence in prose; stable keeps the ordered checklist that passed the regression set.
19
+ */
20
+ export const SCHEDULER_MODES = { guided: '引导(试验)', stable: '稳定' };
21
+ export const DEFAULT_SCHEDULER_MODE = 'guided';
22
+ /** The guided wording has its own version, so its ratings are never mixed with the stable checklist's. */
23
+ export const ROUTE_GUIDED_PROMPT_VERSION = 4;
24
+ /** The routing prompt version that a decision in this mode was made with. */
25
+ export function route_prompt_version(mode) {
26
+ return mode === 'stable' ? ROUTE_PROMPT_VERSION : ROUTE_GUIDED_PROMPT_VERSION;
27
+ }
16
28
  /** The same for the three discussion questions (interjection, next speaker, next round). */
17
- export const DISCUSSION_PROMPT_VERSION = 4;
29
+ export const DISCUSSION_PROMPT_VERSION = 5;
30
+ /** The guided wording of the discussion questions, versioned apart from the stable one. */
31
+ export const DISCUSSION_GUIDED_PROMPT_VERSION = 1;
32
+ /** The discussion prompt version that a decision in this mode was made with. */
33
+ export function discussion_prompt_version(mode) {
34
+ return mode === 'stable' ? DISCUSSION_PROMPT_VERSION : DISCUSSION_GUIDED_PROMPT_VERSION;
35
+ }
18
36
  /** Completion of a preauthorized implementation batch is separate from routing and discussion. */
19
37
  export const REVIEW_PROMPT_VERSION = 1;
20
38
  export const REVIEW_CONFIDENCE = 0.85;
@@ -29,13 +47,23 @@ export const ROUTE_CONTEXT_MESSAGES = 20;
29
47
  const MAX_MESSAGE_CHARS = 600;
30
48
  const MAX_BRIEF_CHARS = 1200;
31
49
  const MAX_PROFILE_CHARS = 500;
50
+ /**
51
+ * What the room is and how its people talk, so a decision reads the user's words the way a member would.
52
+ * Shared by every scene built from topic_lines; bump both prompt versions when it changes.
53
+ */
54
+ const ROOM_GUIDE = ['这是 AI CoDiscuss 的协作房间。用户是房主和需求方;其余成员都是 AI,各自运行在 Claude Code、Codex 等软件或模型 API 上,用户也称它们为专家或 AI。',
55
+ '其中一位是主持人,负责接收没有点名的消息并分派、协调,也可以自己回答;其他成员是专家,在各自的会话里工作并回复。',
56
+ '用户用 @名字 点名成员,用 @全体成员 请所有人回应。每唤醒一位成员就要花一次执行(数十秒);几位成员同时回答时彼此看不到对方的回答。',
57
+ '房间可以临时开启讨论:主持人按目标安排专家轮流发言,到达轮次或时长上限后回到自由协作。'].join('\n');
32
58
 
33
59
  /** Normalize a launch's scheduler choice and prove its key exists for that provider's root. */
34
60
  export function validate_scheduler(home, scheduler) {
35
61
  const provider = SCHEDULER_PROVIDERS[scheduler?.provider];
36
62
  if (!provider) throw new Error(`智能调度供应商只能为 ${Object.keys(SCHEDULER_PROVIDERS).join(' / ')}`);
37
63
  if (!scheduler.model || typeof scheduler.model !== 'string') throw new Error('请填写智能调度模型');
38
- const normalized = { provider: scheduler.provider, model: scheduler.model.trim(), base_url: provider.base_url, api_key_ref: scheduler.api_key_ref };
64
+ if (scheduler.mode !== undefined && !SCHEDULER_MODES[scheduler.mode]) throw new Error(`智能调度模式只能为 ${Object.keys(SCHEDULER_MODES).join(' / ')}`);
65
+ const normalized = { provider: scheduler.provider, model: scheduler.model.trim(), base_url: provider.base_url, api_key_ref: scheduler.api_key_ref,
66
+ mode: scheduler.mode || DEFAULT_SCHEDULER_MODE };
39
67
  read_saved_api_key(home, normalized.base_url, normalized.api_key_ref);
40
68
  return normalized;
41
69
  }
@@ -125,10 +153,23 @@ function excerpt(message) {
125
153
  }
126
154
 
127
155
  /** The room's purpose in the user's words, so a decision can tell drifting from progress. */
128
- function topic_lines(title, goal, user_request, { roster, profiles, brief, focus, arrangement, progress } = {}) {
129
- return [`房间:${title || ''}`, roster && `房间成员(名字不区分大小写):${roster}`, profile_lines(profiles), goal && `目标:${goal}`, user_request && `用户原文:${String(user_request).slice(0, MAX_MESSAGE_CHARS)}`,
156
+ function topic_lines(title, goal, user_request, { guide = ROOM_GUIDE, roster, people, profiles, brief, focus, arrangement, progress, upcoming } = {}) {
157
+ return [guide, `房间:${title || ''}`, roster && `房间成员(名字不区分大小写):${roster}`, people, profile_lines(profiles), goal && `目标:${goal}`, user_request && `用户原文:${String(user_request).slice(0, MAX_MESSAGE_CHARS)}`,
130
158
  focus && `当前焦点:${focus}`, brief && `主持简报(含分工与规则):${String(brief).slice(0, MAX_BRIEF_CHARS)}`,
131
- arrangement && `主持人最近的安排:${arrangement}`, progress].filter(Boolean).join('\n');
159
+ arrangement && `主持人最近的安排:${arrangement}`, progress, upcoming && `接下来按安排发言:${upcoming.length ? upcoming.join(' → ') : '暂无,没有已安排的发言'}`].filter(Boolean).join('\n');
160
+ }
161
+
162
+ /**
163
+ * Guided discussion background: how a discussion runs and what each step costs, so the model weighs the situation
164
+ * instead of matching rules. Used by the three discussion questions in the guided mode.
165
+ */
166
+ const DISCUSSION_GUIDED_GUIDE = ['这是 AI CoDiscuss 协作房间里临时开启的一场讨论。用户是房主,给出讨论目标;其余成员都是 AI,用户常称它们为专家。',
167
+ '主持人按目标安排专家轮流发言:每轮每位专家发言一次,后发言的人能看到前面所有发言;主持简报里记着分工、顺序和规则。到达轮次或时长上限、目标达成或用户结束时,回到自由协作。',
168
+ '每请一位专家发言或回答,都要花一次执行(通常几十秒)。用户在讨论中随时可能留言:补充约束、追问某人、调整方向,或者只是鼓励。'].join('\n');
169
+ /** The member line of the guided routing state: the shared facts plus the model and the last finished message. */
170
+ function guided_member_line(role, member) {
171
+ const extra = [member.model && `模型 ${member.model}`, member.finished && `最近完成 ${member.finished}`].filter(Boolean).join(';');
172
+ return `- ${role} ${extra ? String(member.description).replace(/)$/, `;${extra})`) : member.description}`;
132
173
  }
133
174
 
134
175
  /** The owner's descriptions of members, so a decision can weigh who is good at what. */
@@ -152,20 +193,44 @@ export function certainty(answer) {
152
193
  return typeof probability === 'number' ? probability : typeof answer?.confidence === 'number' ? answer.confidence : null;
153
194
  }
154
195
 
196
+ /** Stable routing wording: an ordered checklist of evidence, each item settling the addressee before the next is read. */
197
+ const ROUTE_STABLE_STEM = '这条用户消息没有用 @ 点名成员。判断它的回应对象,按下面的顺序:1)正文叫了某位成员的名字或用其他说法指代他(对照房间成员名单和前文只可能是他),或在向其提问(包括“X在吗”“X还在吗”“X你怎么看”),选该成员,即使上一条是别人的回答;同时提到几位成员时,选被直接称呼、要求其回应或做事的那位(如“A 你让 B 去改”是在对 A 说);2)正文没有指向任何人时,看它是否在延续某位成员的回答(追问、要求修改、引用,或“回复”指向其消息),或在补充、纠正一条正由某位成员处理的消息,是则选该成员,是主持人就选主持人;3)查看各成员的用户描述:已经明确由唯一一位成员负责这类任务时,直接选该成员,不需要再次点名;仅有擅长、多人分工重叠或任务还需要拆分时交主持人;4)符合“全体”选项的条件时选全体;要求成员之间讨论或互相回应的不算,按第 7 条处理;5)单独的问候或闲聊(如“hi”)既没有指向某位成员,也没有要求大家一起回应,选主持人;6)只是确认、致谢或旁注选无需回应;7)对象不明确,或要讨论、互相回应、分工、排先后时选主持人,由主持人安排。';
198
+ /**
199
+ * Guided routing: instead of rules, the model gets what a member of the room would know (who is who, what each is busy
200
+ * with, what each choice leads to) and the purpose of this step, and weighs the message itself.
201
+ */
202
+ const ROUTE_GUIDED_GUIDE = ['这是 AI CoDiscuss 的协作房间。用户是房主,也是提出需求的人;其余成员都是 AI,各自运行在 Claude Code、Codex 等软件或模型 API 上,用户常称它们为专家。',
203
+ '主持人是其中一位:平时接收没人点名的消息,自己回答或分派给别人,也负责协调需要几个人配合的事。专家们在各自的会话里工作,回复都发在房间里,所有人都看得到。',
204
+ '用户找某人时通常会 @ 他,但聊顺了也常直接说话:叫名字、换个说法指代,或者接着刚才的话往下说。用户也可能事先在成员描述里交代过谁负责什么。',
205
+ '唤醒一位成员要花一次执行,通常几十秒;交给了不对的人,就得打断后重来。'].join('\n');
206
+ const ROUTE_GUIDED_STEM = '用户发来上面这条消息,没有 @ 任何人。请你像熟悉这个房间的成员一样读懂它:用户想让谁来回应?'
207
+ + '用户心里已经有明确的对象时,直接交给对方,省掉主持人转一手的时间;需要有人先理解、拆分或协调时交给主持人。'
208
+ + '用户想听每个人各自说一句时,交给全体能让大家同时开始,比主持人逐个点名快得多;但大家看不到彼此的回答,需要互相接话、讨论的事仍要主持人安排。用户只是表示收到或感谢时,不必唤醒任何人。';
209
+
210
+ /** The routing question in one mode: option texts, the stem, and what the state adds about the members. */
211
+ function route_question(mode, host, members) {
212
+ if (mode === 'stable') return { extra: { roster: [`${host.name}(主持人)`, ...members.map(member => `${member.name}(专家)`)].join('、') },
213
+ instructions: ROUTE_STABLE_STEM, choices: [...members.map(member => ({ value: member.name, description: member.description })),
214
+ { value: HOST_CHOICE, description: `主持人 ${host.description}:没有指向任何人且用户没有确定负责人(如单独一句“hi”“你好”这类问候、闲聊,由主持人代表房间回应),或诉求模糊、复杂,需要主持人判断怎么安排` },
215
+ { value: ALL_CHOICE, description: '全体成员(含主持人)各自回应一次,彼此不需要看到别人的回答;只在要求简单明确、每人独立回应一次就算完成时选,如“大家打个招呼”“各自报一下在用的模型”' },
216
+ { value: NONE_CHOICE, description: '无需回应:只是确认、致谢或旁注,不需要任何人回答' }] };
217
+ const people = ['成员(名字不区分大小写):', guided_member_line('主持人', host), ...members.map(member => guided_member_line('专家', member))].join('\n');
218
+ return { extra: { guide: ROUTE_GUIDED_GUIDE, people }, instructions: ROUTE_GUIDED_STEM,
219
+ choices: [...members.map(member => ({ value: member.name, description: `交给 ${member.name} 回应` })),
220
+ { value: HOST_CHOICE, description: `交给主持人 ${host.name}:由他回应,或由他安排别人` },
221
+ { value: ALL_CHOICE, description: '全体成员(含主持人)同时收到这句原话,各自回应一次,彼此看不到对方的回答,也不会被分到不同的任务' },
222
+ { value: NONE_CHOICE, description: '不唤醒任何人' }] };
223
+ }
224
+
155
225
  /**
156
226
  * Decide who answers one unnamed free-room message: whether its addressee is already settled by the text or the thread.
157
227
  * Members are [{name, description}] without the host; host is {name, description}. Returns action member (with member),
158
228
  * all, none, or host for anything unsettled or not confident enough.
159
229
  */
160
230
  export async function route_free_message(home, scheduler, { title, goal, context, message, host, members }, fetch_impl) {
161
- const choices = [...members.map(member => ({ value: member.name, description: member.description })),
162
- { value: HOST_CHOICE, description: `主持人 ${host.description}:没有指向任何人且用户没有确定负责人(如单独一句“hi”“你好”这类问候、闲聊,由主持人代表房间回应),或诉求模糊、复杂,需要主持人判断怎么安排` },
163
- { value: ALL_CHOICE, description: '全体成员(含主持人)各自回应一次,彼此不需要看到别人的回答;只在要求简单明确、每人独立回应一次就算完成时选,如“大家打个招呼”“各自报一下在用的模型”' },
164
- { value: NONE_CHOICE, description: '无需回应:只是确认、致谢或旁注,不需要任何人回答' }];
165
- const questions = [{ type: 'choice', name: 'handler', choices,
166
- instructions: '这条用户消息没有用 @ 点名成员。判断它的回应对象,按下面的顺序:1)正文叫了某位成员的名字,或在向其提问(包括“X在吗”“X还在吗”“X你怎么看”),选该成员,即使上一条是别人的回答;同时提到几位成员时,选被直接称呼、要求其回应或做事的那位(如“A 你让 B 去改”是在对 A 说);2)正文没有指向任何人时,看它是否在延续某位成员的回答(追问、要求修改、引用,或“回复”指向其消息),或在补充、纠正一条正由某位成员处理的消息,是则选该成员,是主持人就选主持人;3)查看各成员的用户描述:已经明确由唯一一位成员负责这类任务时,直接选该成员,不需要再次点名;仅有擅长、多人分工重叠或任务还需要拆分时交主持人;4)符合“全体”选项的条件时选全体;要求成员之间讨论或互相回应的不算,按第 7 条处理;5)单独的问候或闲聊(如“hi”)既没有指向某位成员,也没有要求大家一起回应,选主持人;6)只是确认、致谢或旁注选无需回应;7)对象不明确,或要讨论、互相回应、分工、排先后时选主持人,由主持人安排。' }];
167
- const roster = [`${host.name}(主持人)`, ...members.map(member => member.name)].join('、');
168
- const state = scheduler_state(title, context, message, goal, '', { roster }, ROUTE_CONTEXT_MESSAGES);
231
+ const { choices, instructions, extra } = route_question(scheduler.mode || DEFAULT_SCHEDULER_MODE, host, members);
232
+ const questions = [{ type: 'choice', name: 'handler', choices, instructions }];
233
+ const state = scheduler_state(title, context, message, goal, '', extra, ROUTE_CONTEXT_MESSAGES);
169
234
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
170
235
  const judged = judged_of(state, questions, answers);
171
236
  const choice = answers.handler?.choice ?? null;
@@ -201,13 +266,24 @@ export async function decide_group_order(home, scheduler, { title, goal, context
201
266
  * Decide one interjection in a discussion: continue the plan (note), ask one expert (ask) or wake the host (host).
202
267
  * Anything but a confident note or ask is returned as host.
203
268
  */
204
- export async function decide_interjection(home, scheduler, { title, goal, user_request, context, message, host, experts, spoken = [], ...extra }, fetch_impl) {
269
+ export async function decide_interjection(home, scheduler, { title, goal, user_request, context, message, host, experts, spoken = [], upcoming = [], ...extra }, fetch_impl) {
270
+ const guided = (scheduler.mode || DEFAULT_SCHEDULER_MODE) !== 'stable';
205
271
  // Ordinary asks do not consume a discussion turn; all present members are eligible.
206
- const choices = [...experts.map(name => ({ value: name, description: `请 ${name} 回应这条留言:留言是向该专家(或其负责的内容)提问、质疑或补充要求` })),
207
- { value: HOST_CHOICE, description: `由主持人 ${host} 处理:留言要改变讨论目标、规则、分工、安排或结束讨论,同时涉及多位专家,或针对的是不在选项里的人` },
208
- { value: NOTE_CHOICE, description: '仅补充信息:留言补充背景、约束或偏好,或只是旁注、鼓励,没有要求某人立即回应;后续发言的专家都能看到它,按当前安排继续' }];
209
- const questions = [{ type: 'choice', name: 'handling', instructions: '讨论进行中用户发来这条留言。对照当前焦点、主持简报和安排,判断它最适合怎样处理。', choices }];
210
- const state = scheduler_state(title, context, message, goal, user_request, { ...extra, progress: round_progress(spoken, experts.filter(name => !spoken.includes(name))) });
272
+ const choices = guided
273
+ ? [...experts.map(name => ({ value: name, description: `现在单独请 ${name} 回应这条留言:不占讨论轮次,多花一次执行` })),
274
+ { value: HOST_CHOICE, description: `交给主持人 ${host} 处理` },
275
+ { value: NOTE_CHOICE, description: '按原安排继续:留言留在房间里,后面发言的专家都会看到并可在发言中回应' }]
276
+ : [...experts.map(name => ({ value: name, description: `请 ${name} 回应这条留言:留言是向该专家(或其负责的内容)提问、质疑或补充要求` })),
277
+ { value: HOST_CHOICE, description: `由主持人 ${host} 处理:留言要改变讨论目标、规则、分工、安排或结束讨论,同时涉及多位专家,或针对的是不在选项里的人` },
278
+ { value: NOTE_CHOICE, description: '仅补充信息:留言补充背景、约束或偏好,或只是旁注、鼓励,没有要求某人立即回应;后续发言的专家都能看到它,按当前安排继续' }];
279
+ const instructions = guided
280
+ ? '讨论进行中,用户发来上面这条留言。请你像熟悉这场讨论的人一样读懂它,选出最合适的处理方式。'
281
+ + '用户向某位专家要回应时,现在单独请他回答能马上得到答复,但要多花一次执行;如果他就在接下来要发言的人里,留到他发言时一并回应更省,也不打乱讨论。'
282
+ + '补充的约束、偏好和鼓励,后面发言的人都看得到。要改目标、规则、分工或安排,或需要几位专家协调的,由主持人处理。'
283
+ : '讨论进行中用户发来这条留言。对照当前焦点、主持简报和安排,判断它最适合怎样处理。';
284
+ const questions = [{ type: 'choice', name: 'handling', instructions, choices }];
285
+ const state = scheduler_state(title, context, message, goal, user_request, { ...extra, progress: round_progress(spoken, experts.filter(name => !spoken.includes(name))),
286
+ ...(guided ? { guide: DISCUSSION_GUIDED_GUIDE, upcoming } : {}) });
211
287
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
212
288
  const judged = judged_of(state, questions, answers);
213
289
  const choice = answers.handling?.choice;
@@ -244,13 +320,20 @@ export async function decide_review_completion(home, scheduler, ready, fetch_imp
244
320
  * this round, or hand back to the host when unsure or when the discussion needs steering.
245
321
  */
246
322
  export async function decide_next_speaker(home, scheduler, { title, goal, user_request, context, host, candidates, spoken = [], ...extra }, fetch_impl) {
323
+ const guided = (scheduler.mode || DEFAULT_SCHEDULER_MODE) !== 'stable';
247
324
  const recent = context.slice(-CONTEXT_MESSAGES).map(excerpt);
248
- const state = [topic_lines(title, goal, user_request, { ...extra, progress: round_progress(spoken, candidates) }), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
325
+ const state = [topic_lines(title, goal, user_request, { ...extra, progress: round_progress(spoken, candidates), ...(guided ? { guide: DISCUSSION_GUIDED_GUIDE } : {}) }),
326
+ recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
249
327
  // Having not spoken yet is no reason to speak: the host's plan may have ended on purpose, so a candidate needs an explicit basis.
250
- const choices = [...candidates.map(name => ({ value: name, description: `请 ${name} 接着发言:简报的分工或流程规定轮到 ${name},或最近的发言点名追问了 ${name}` })),
251
- { value: HOST_CHOICE, description: `交给主持人 ${host}:简报和最近发言都没有明确规定下一位(仅仅是本轮还没发言不算依据),或需要主持人重新安排` }];
252
- const questions = [{ type: 'choice', name: 'speaker',
253
- instructions: '主持人预排的安排已用完或被取消,主持人可能有意只安排到这里。对照主持简报、当前焦点和主持人最近的安排,判断下一位应请谁发言。', choices }];
328
+ const choices = guided
329
+ ? [...candidates.map(name => ({ value: name, description: `请 ${name} 接着发言` })), { value: HOST_CHOICE, description: `交给主持人 ${host} 重新安排` }]
330
+ : [...candidates.map(name => ({ value: name, description: `请 ${name} 接着发言:简报的分工或流程规定轮到 ${name},或最近的发言点名追问了 ${name}` })),
331
+ { value: HOST_CHOICE, description: `交给主持人 ${host}:简报和最近发言都没有明确规定下一位(仅仅是本轮还没发言不算依据),或需要主持人重新安排` }];
332
+ const instructions = guided
333
+ ? '主持人预排的发言安排已经用完或被取消。主持人有时有意只安排到这里,有时只是还没排下一位。请判断下一位该请谁发言:'
334
+ + '简报的分工、顺序,或最近有人被点名追问,都说明下一位已经明确;看不出来就交给主持人重新安排,请错人会白花一次执行,还可能违背主持人的安排。'
335
+ : '主持人预排的安排已用完或被取消,主持人可能有意只安排到这里。对照主持简报、当前焦点和主持人最近的安排,判断下一位应请谁发言。';
336
+ const questions = [{ type: 'choice', name: 'speaker', instructions, choices }];
254
337
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
255
338
  const judged = judged_of(state, questions, answers);
256
339
  const choice = answers.speaker?.choice;
@@ -268,14 +351,23 @@ export async function decide_round(home, scheduler, { title, goal, user_request,
268
351
  const candidates = experts.filter(name => name !== last_speaker);
269
352
  if (!candidates.length) return { action: 'host', confidence: null, label: '没有可选的下一轮首位,交给主持人' };
270
353
  // Telling repetition or drift needs at least the last two rounds side by side.
354
+ const guided = (scheduler.mode || DEFAULT_SCHEDULER_MODE) !== 'stable';
271
355
  const recent = context.slice(-ROUTE_CONTEXT_MESSAGES).map(excerpt);
272
- const state = [topic_lines(title, goal, user_request, extra), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
273
- const questions = [{ type: 'choice', name: 'next', instructions: '一轮发言刚结束。对照讨论目标、主持简报与当前焦点判断:下一轮是否还有明确的内容可以让大家互相回应、追问或补充?', choices: [
274
- { value: 'continue', description: '继续下一轮:最近发言里有未回应的分歧、追问或可展开的点,或简报规定的流程还没走完' },
275
- { value: HOST_CHOICE, description: `交给主持人 ${host}:目标已经达成可以收束,或最近发言在重复、偏离目标、卡住,需要主持人引导` }] }];
276
- questions.push({ type: 'choice', name: 'first', instructions: '下一轮应先请谁发言?按简报分工或接龙顺序判断;没有规定时选被其他专家点名追问、需要回应的人;没有明确依据时交主持人安排。',
277
- choices: [...candidates.map(name => ({ value: name, description: `先请 ${name}:简报规定下一轮由其开头,或最近发言点名追问了该成员` })),
278
- { value: HOST_CHOICE, description: `交给主持人 ${host}:没有明确规定首位,也没有点名追问;仅仅有未决分歧不足以确定先请谁` }] });
356
+ const state = [topic_lines(title, goal, user_request, guided ? { ...extra, guide: DISCUSSION_GUIDED_GUIDE } : extra),
357
+ recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
358
+ const questions = guided ? [
359
+ { type: 'choice', name: 'next', instructions: '一轮发言刚结束。请判断讨论值不值得马上再进行一轮:还有没回应的分歧、追问,或简报规定的流程没走完时,再来一轮能推进讨论;'
360
+ + '已经达成一致、在重复、偏离目标或卡住时,多一轮只是让每位专家白花一次执行,应交给主持人收束或引导。', choices: [
361
+ { value: 'continue', description: '直接开始下一轮,每位专家再发言一次' },
362
+ { value: HOST_CHOICE, description: `交给主持人 ${host} 收束或引导` }] },
363
+ { type: 'choice', name: 'first', instructions: '如果开始下一轮,先请谁发言?简报规定了顺序,或有人被点名追问、需要先回应时,先请他;看不出依据就交给主持人决定。',
364
+ choices: [...candidates.map(name => ({ value: name, description: `先请 ${name}` })), { value: HOST_CHOICE, description: `交给主持人 ${host} 决定首位` }] }] : [
365
+ { type: 'choice', name: 'next', instructions: '一轮发言刚结束。对照讨论目标、主持简报与当前焦点判断:下一轮是否还有明确的内容可以让大家互相回应、追问或补充?', choices: [
366
+ { value: 'continue', description: '继续下一轮:最近发言里有未回应的分歧、追问或可展开的点,或简报规定的流程还没走完' },
367
+ { value: HOST_CHOICE, description: `交给主持人 ${host}:目标已经达成可以收束,或最近发言在重复、偏离目标、卡住,需要主持人引导` }] },
368
+ { type: 'choice', name: 'first', instructions: '下一轮应先请谁发言?按简报分工或接龙顺序判断;没有规定时选被其他专家点名追问、需要回应的人;没有明确依据时交主持人安排。',
369
+ choices: [...candidates.map(name => ({ value: name, description: `先请 ${name}:简报规定下一轮由其开头,或最近发言点名追问了该成员` })),
370
+ { value: HOST_CHOICE, description: `交给主持人 ${host}:没有明确规定首位,也没有点名追问;仅仅有未决分歧不足以确定先请谁` }] }];
279
371
  const answers = await decide(home, scheduler, state, questions, fetch_impl);
280
372
  const judged = judged_of(state, questions, answers);
281
373
  const choice = answers.next?.choice;
@@ -1,7 +1,7 @@
1
1
  /** Smart scheduling and limit checks for a discussion hosted inside a free room. */
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { api, peer_round_question } from './discussion_runner.mjs';
4
- import { DISCUSSION_PROMPT_VERSION, ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_WIDE_CONFIDENCE, decide_interjection, decide_next_speaker, decide_round } from './scheduler.mjs';
4
+ import { DEFAULT_SCHEDULER_MODE, ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_WIDE_CONFIDENCE, decide_interjection, decide_next_speaker, decide_round, discussion_prompt_version } from './scheduler.mjs';
5
5
 
6
6
  const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
7
7
  /** How many of the latest plans describe "the host's recent arrangement" to the scheduler. */
@@ -164,8 +164,10 @@ export class SessionDriver {
164
164
  if (!message) return null;
165
165
  const spoken = turns.filter(turn => turn.round === roster.round && turn.status === 'answered').map(turn => turn.expert);
166
166
  const experts = roster.experts.filter(item => item.registered).map(item => item.name);
167
+ // Who the plan will ask next, in order, so a question to one of them can wait for that turn.
168
+ const upcoming = turns.filter(turn => ['open', 'queued'].includes(turn.status)).sort((left, right) => Number(left.id || 0) - Number(right.id || 0)).map(turn => turn.expert);
167
169
  const result = await decide_interjection(this.config.api_home, this.config.scheduler,
168
- { ...base, message, experts, spoken }, this.fetch);
170
+ { ...base, message, experts, spoken, upcoming }, this.fetch);
169
171
  if (result.action === 'note') {
170
172
  // Continuing needs a plan to continue; with nothing open or queued the discussion would stall without the host.
171
173
  if (turns.some(turn => ['open', 'queued'].includes(turn.status))) return { ...result, action: 'continue', kind: 'interjection', after_message_id: message.id };
@@ -199,7 +201,7 @@ export class SessionDriver {
199
201
  confidence: plan.confidence ?? null, applied, provider: this.config.scheduler.provider,
200
202
  after_message_id, detail: plan.detail, input: plan.input,
201
203
  context: { model: this.config.scheduler.model, threshold: ROUTE_CONFIDENCE, wide_threshold: ROUTE_WIDE_CONFIDENCE,
202
- prompt: DISCUSSION_PROMPT_VERSION, package: PACKAGE_VERSION } });
204
+ mode: this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE, prompt: discussion_prompt_version(this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE), package: PACKAGE_VERSION } });
203
205
  } catch (error) { this.log(`智能调度决策记录未保存:${error.message}`); }
204
206
  }
205
207
  }
package/web/app.js CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  const REFRESH_INTERVAL_MS = 60000;
4
4
  const FALLBACK_REFRESH_INTERVAL_MS = 12000;
5
+ // The site caps one message batch at 200.
6
+ const MESSAGE_HISTORY_BATCH = 200;
5
7
  // A poll reads the room in full only when it changed or an AI is working in it; this full read still comes at least this often.
6
8
  const SAFETY_REFRESH_MS = 5 * 60 * 1000;
7
9
  const WORKING_STATUSES = ["dispatched", "started"];
@@ -75,6 +77,9 @@ state.reconnecting = false;
75
77
  state.attachment_drafts = [];
76
78
  state.upload_status = "";
77
79
  state.sending_message = false;
80
+ // The message being sent, shown in the transcript as soon as Send is pressed until the site answers;
81
+ // a failed send keeps it in place, marked with its reason, until it is retried or discarded.
82
+ state.sending_preview = null;
78
83
  state.attachment_preview = null;
79
84
  state.archive_changes = new Map();
80
85
  state.archive_version = 0;
@@ -828,7 +833,8 @@ function render_messages(result) {
828
833
  state.messages = result.messages.map((message) => {
829
834
  const saved = previous.get(message.id);
830
835
  const kind = withdrawn.get(message.id) || message.withdrawn || "";
831
- if (saved) return (saved.withdrawn || "") === kind ? saved : { ...saved, withdrawn: kind };
836
+ // A message shown straight from its send response is replaced by the copy the transcript reads.
837
+ if (saved && !saved.just_sent) return (saved.withdrawn || "") === kind ? saved : { ...saved, withdrawn: kind };
832
838
  return { ...message, withdrawn: kind, rendered_text: window.discuss_markdown.render_markdown(message.text),
833
839
  rendered_summary: window.discuss_markdown.render_markdown(message.summary || "") };
834
840
  });
@@ -852,6 +858,8 @@ async function load_message_history(room_id, version) {
852
858
  const params = new URLSearchParams({ q: state.query, withdrawn: "include" });
853
859
  if (state.mention_only) params.set("mention", element("author-select").value);
854
860
  const key = `${room_id}?${params}`;
861
+ // Large batches: a long transcript loads in one round-trip, and the site counts the room's messages once, not per batch.
862
+ params.set("limit", MESSAGE_HISTORY_BATCH);
855
863
  const saved = state.message_history?.key === key ? state.message_history.messages : [];
856
864
  const messages = [...saved];
857
865
  let has_more = true;
@@ -906,6 +914,7 @@ async function select_room(room_id) {
906
914
  state.navigation_version++;
907
915
  state.refresh_version++;
908
916
  state.room = state.rooms.find((room) => room.id === room_id);
917
+ state.sending_preview = null;
909
918
  state.page = 1;
910
919
  state.query = "";
911
920
  state.mention_only = false;
@@ -927,7 +936,7 @@ async function select_room(room_id) {
927
936
  cancel_turn_reply();
928
937
  element("turn-form").reset();
929
938
  element("message-kind").value = "opinion";
930
- select_tab("discussion");
939
+ select_tab("discussion", { refresh: false });
931
940
  history.replaceState(null, "", state.room ? `/?room=${encodeURIComponent(room_id)}` : "/");
932
941
  if (state.room) state.archive_view = Boolean(state.room.archived);
933
942
  else {
@@ -942,8 +951,8 @@ async function select_room(room_id) {
942
951
  await refresh_room();
943
952
  }
944
953
 
945
- /** Switch between history, manually maintained state, and participation guidance. */
946
- function select_tab(tab_name) {
954
+ /** Switch between history, manually maintained state, and participation guidance; `refresh: false` when the caller reads the room itself. */
955
+ function select_tab(tab_name, { refresh = true } = {}) {
947
956
  state.navigation_version++;
948
957
  state.tab = tab_name;
949
958
  document.querySelectorAll("[data-tab]").forEach((tab) => {
@@ -951,7 +960,7 @@ function select_tab(tab_name) {
951
960
  tab.setAttribute("aria-selected", String(tab.dataset.tab === tab_name));
952
961
  });
953
962
  document.querySelectorAll("[data-panel]").forEach((panel) => { panel.hidden = panel.dataset.panel !== tab_name; });
954
- if (tab_name === "discussion") run_action(refresh_room);
963
+ if (tab_name === "discussion" && refresh) run_action(refresh_room);
955
964
  publish_view();
956
965
  }
957
966
 
@@ -1411,8 +1420,14 @@ async function finish_message(result, room_id, drafts) {
1411
1420
  element("search-input").value = "";
1412
1421
  element("mention-filter").setAttribute("aria-pressed", "false");
1413
1422
  state.page = 1;
1414
- await refresh_room();
1423
+ state.sending_preview = null;
1424
+ // Show the saved message at once; the room, its requests and other new messages follow from a background refresh,
1425
+ // so the composer does not wait for further round-trips on a slow connection.
1426
+ if (!state.messages.some(message => message.id === result.message.id)) {
1427
+ render_messages({ messages: [...state.messages, { ...result.message, just_sent: true }], total: state.message_total + 1, page: 1, pages: 1 });
1428
+ }
1415
1429
  element("messages").scrollTop = element("messages").scrollHeight;
1430
+ run_action(refresh_room);
1416
1431
  }
1417
1432
 
1418
1433
  /** Submit one message; reuse its identifier if a network failure requires a retry. */
@@ -1429,6 +1444,9 @@ async function send_message(event) {
1429
1444
  element("send-message").disabled = true;
1430
1445
  state.sending_message = true;
1431
1446
  element("message-input").disabled = true;
1447
+ state.sending_preview = { author: payload.author, text: payload.text, files: drafts.map(file => file.file.name) };
1448
+ let sent;
1449
+ state.sending_done = new Promise(resolve => { sent = resolve; });
1432
1450
  publish_view();
1433
1451
  try {
1434
1452
  payload.attachment_ids = await upload_message_attachments(room_id, payload.submitted_by, drafts);
@@ -1436,12 +1454,18 @@ async function send_message(event) {
1436
1454
  saved = true;
1437
1455
  await finish_message(result, room_id, drafts);
1438
1456
  } catch (error) {
1439
- if (state.room?.id === room_id) notify_failure(error, saved
1440
- ? `留言已保存,但页面刷新失败:${error.message}`
1441
- : `${error.message};草稿已保留,可重试发送`);
1457
+ if (state.room?.id !== room_id) throw error;
1458
+ // An unsaved message stays as a failed card in place, the one signal of this failure; the draft is kept for the retry.
1459
+ if (saved) notify_failure(error, `留言已保存,但页面刷新失败:${error.message}`);
1460
+ else {
1461
+ state.sending_preview = { ...state.sending_preview, failed: error.message };
1462
+ error.notified = true;
1463
+ }
1442
1464
  throw error;
1443
1465
  } finally {
1444
1466
  state.sending_message = false;
1467
+ sent();
1468
+ if (!state.sending_preview?.failed) state.sending_preview = null;
1445
1469
  state.upload_status = "";
1446
1470
  element("send-message").disabled = false;
1447
1471
  element("message-input").disabled = false;
@@ -2091,7 +2115,7 @@ async function load_launch_settings() {
2091
2115
  state.launch.saved_profiles = JSON.parse(JSON.stringify(settings.participants || {}));
2092
2116
  Object.assign(state.launch, { available: true, profiles: settings.participants, projects: settings.projects || [], api_providers: settings.api_providers || {}, api_keys: settings.api_keys || {},
2093
2117
  presets: settings.presets || [], builtin_presets: settings.builtin_presets || [], description_templates: settings.description_templates || {},
2094
- scheduler_providers: settings.scheduler_providers || {}, error: "",
2118
+ scheduler_providers: settings.scheduler_providers || {}, scheduler_modes: settings.scheduler_modes || {}, scheduler_default_mode: settings.scheduler_default_mode || "", error: "",
2095
2119
  notice: relay_machine ? `经电脑「${relay_machine.name}」执行本机操作` : local_connection ? "已连接本机服务,讨论保存在当前网站" : "" });
2096
2120
  Object.values(state.launch.profiles).forEach(fill_api_key);
2097
2121
  if (local_connection) pair_local_machine();
@@ -2396,8 +2420,8 @@ function launch_options() {
2396
2420
 
2397
2421
  /** A new room also carries the optional smart scheduling choice; only a complete, enabled choice is sent. */
2398
2422
  function creation_launch_options() {
2399
- const { enabled, provider, model, api_key_ref } = state.launch.scheduler;
2400
- return { ...launch_options(), ...(enabled && provider && model && api_key_ref ? { scheduler: { provider, model, api_key_ref } } : {}) };
2423
+ const { enabled, provider, model, api_key_ref, mode } = state.launch.scheduler;
2424
+ return { ...launch_options(), ...(enabled && provider && model && api_key_ref ? { scheduler: { provider, model, api_key_ref, ...(mode ? { mode } : {}) } } : {}) };
2401
2425
  }
2402
2426
 
2403
2427
  /** Choose whether the new discussion's host writes named follow-ups for each expert between rounds. */
@@ -2428,7 +2452,7 @@ function default_scheduler() {
2428
2452
  const provider = Object.keys(providers).find((name) => state.launch.api_keys?.[providers[name].base_url]);
2429
2453
  if (!provider) return {};
2430
2454
  const { base_url, models } = providers[provider];
2431
- return { enabled: true, provider, model: models[0] || "", api_key_ref: state.launch.api_keys[base_url] };
2455
+ return { enabled: true, provider, model: models[0] || "", api_key_ref: state.launch.api_keys[base_url], mode: state.launch.scheduler_default_mode || "" };
2432
2456
  }
2433
2457
 
2434
2458
  /** Change the smart scheduling draft; a new provider starts from its preset model and any key saved for its root. */
@@ -2966,6 +2990,40 @@ async function interrupt_member(member_id) {
2966
2990
  if (state.room?.id === room_id) await refresh_room();
2967
2991
  }
2968
2992
 
2993
+ /**
2994
+ * How a voided execution's stop really went, from what the runner's adapter reported for it (`capabilities`): stopped
2995
+ * once the runner ended its software, stopping until it says so, and only voided where it cannot stop the software (a
2996
+ * window or App) or never reported (an older runner). See `.dev/docs/Design_适配能力.md`.
2997
+ */
2998
+ function stop_label(execution) {
2999
+ if (execution.stop_result === "stopped") return "已停止";
3000
+ if (execution.stop_result === "unconfirmed") return "停止未确认";
3001
+ if (execution.stop_result === "unstoppable") return "已作废";
3002
+ if (!execution.capabilities?.stoppable) return "已作废";
3003
+ return "正在停止";
3004
+ }
3005
+
3006
+ /** What stopping some executions really did: who is stopping, who may keep generating, and whose software never said. */
3007
+ function stop_summary(executions) {
3008
+ const names = items => [...new Set(items.map(item => display_name(item.member)))].join("、");
3009
+ const stopping = executions.filter(item => item.capabilities?.stoppable === true);
3010
+ const unstoppable = executions.filter(item => item.capabilities?.stoppable === false);
3011
+ const unknown = executions.filter(item => !item.capabilities);
3012
+ return [stopping.length ? `正在停止 ${names(stopping)}` : "",
3013
+ unstoppable.length ? `${names(unstoppable)} 在自己的窗口或 App 里本机无法停止,可能仍在生成,之后的回答会被拒收` : "",
3014
+ unknown.length ? `${names(unknown)} 的本次工作已作废` : ""].filter(Boolean).join(";");
3015
+ }
3016
+
3017
+ /** Stop the whole free room: running work and everything still owed end; a new message starts again. */
3018
+ async function stop_room() {
3019
+ const room_id = state.room.id;
3020
+ const live = (state.room.free?.executions || []).filter(item => ["dispatched", "started"].includes(item.status));
3021
+ await request_json(`/api/rooms/${room_id}/stop`, {});
3022
+ // Quiet when everything is stopping; a window or App that keeps generating is said at once.
3023
+ if (live.some(item => !item.capabilities?.stoppable)) notify("warning", stop_summary(live));
3024
+ if (state.room?.id === room_id) await refresh_room();
3025
+ }
3026
+
2969
3027
  /** Answer a manual member's waiting retry: deliver now, or stop so the runner fails the execution. */
2970
3028
  async function retry_delivery({ execution_id, action }) {
2971
3029
  const room_id = state.room.id;
@@ -2973,15 +3031,50 @@ async function retry_delivery({ execution_id, action }) {
2973
3031
  if (state.room?.id === room_id) await refresh_room();
2974
3032
  }
2975
3033
 
2976
- /** Take back the user's message before any member handled it; its text returns to an empty input for editing. */
3034
+ /** Take back the user's message, its text returning to an empty input for editing; once members started on it, see `stop_and_rewind`. */
2977
3035
  async function retract_message(message_id) {
2978
3036
  const room_id = state.room.id;
2979
- const result = await request_json(`/api/rooms/${room_id}/messages/${message_id}/retract`, {});
3037
+ let result;
3038
+ try { result = await request_json(`/api/rooms/${room_id}/messages/${message_id}/retract`, {}); }
3039
+ catch (error) {
3040
+ if (error.status !== 409) throw error;
3041
+ result = await stop_and_rewind(room_id, message_id, error);
3042
+ }
2980
3043
  if (state.room?.id !== room_id) return;
2981
3044
  if (!element("message-input").value.trim()) element("message-input").value = result.text;
2982
3045
  await refresh_room();
2983
3046
  }
2984
3047
 
3048
+ /**
3049
+ * Take back the latest message members already started on, like interrupting a turn: stop that work and rewind to just
3050
+ * before the message, cutting their sessions. Reads the rewind plan itself and sends its revision; anything wider (a later
3051
+ * message, or running work on other messages) is refused with the site's reason, which points to editing.
3052
+ */
3053
+ async function stop_and_rewind(room_id, message_id, refusal) {
3054
+ const [plan, room] = await Promise.all([rewind_plan(message_id), request_json(`/api/rooms/${room_id}`)]);
3055
+ const own = new Set(room.free.requests.filter(item => item.message_id === message_id && item.execution_id).map(item => item.execution_id));
3056
+ const touched = plan.members.filter(member => member.executions.length);
3057
+ if (plan.messages !== 1 || touched.some(member => member.executions.some(id => !own.has(id)))) throw refusal;
3058
+ const taken = new Set(touched.flatMap(member => member.executions));
3059
+ const live = room.free.executions.filter(item => taken.has(item.id) && ["dispatched", "started"].includes(item.status));
3060
+ const text = state.messages.find(message => message.id === message_id)?.text ?? "";
3061
+ await request_json(`/api/rooms/${room_id}/rewind`, { message_id, revision: plan.revision });
3062
+ const kept = touched.filter(member => member.session === "kept").map(member => display_name(member.name));
3063
+ if (live.length) notify("info", `已撤回;${stop_summary(live)}`
3064
+ + `${kept.length ? `;${kept.join("、")} 的会话未回退,仍记得这条消息` : ""};文件改动不会恢复`);
3065
+ return { text };
3066
+ }
3067
+
3068
+ /** Take back the owner's latest message, after a send still in flight is saved; the site refuses once a member started on it. */
3069
+ async function retract_latest() {
3070
+ if (state.sending_message) {
3071
+ await state.sending_done;
3072
+ if (state.sending_preview?.failed) return;
3073
+ }
3074
+ const message = state.messages.findLast(item => item.author === "用户" && !item.withdrawn);
3075
+ if (message) await retract_message(message.id);
3076
+ }
3077
+
2985
3078
  /** What editing a message would take back, with the revision the rewind must carry. */
2986
3079
  function rewind_plan(message_id) {
2987
3080
  return request_json(`/api/rooms/${state.room.id}/rewind?message_id=${message_id}`);
@@ -3796,7 +3889,7 @@ function view_snapshot() {
3796
3889
  directory_pending: [...state.directory_pending.keys()], folder_pending: [...state.folder_moves.keys()], creating_room: state.creating_room,
3797
3890
  connected: state.connected, reconnecting: state.reconnecting, backup_pending: Boolean(state.backup_pending), page: state.page, pages: state.pages, query: state.query,
3798
3891
  attachment_drafts: state.attachment_drafts.map(item => ({ id: item.id, file_name: item.file.name, size: item.file.size, preview_url: item.preview_url })),
3799
- upload_status: state.upload_status, sending_message: state.sending_message,
3892
+ upload_status: state.upload_status, sending_message: state.sending_message, sending_preview: state.sending_preview,
3800
3893
  mention_only: state.mention_only, reply_to: state.reply_to, turn_reply_to: state.turn_reply_to,
3801
3894
  free_proxy: state.free_proxy,
3802
3895
  create_parent: state.create_parent, access: access_snapshot(), admin: admin_snapshot(),
@@ -3922,12 +4015,12 @@ async function attach_local_members(snapshot, room_id) {
3922
4015
  } catch (error) { throw new Error(`读取本机成员会话失败,未生成备份:${error.message}`); }
3923
4016
  }
3924
4017
 
3925
- /** The website copy of a backup: local paths and sessions stay in the browser, and Claude Code members return as manual. */
4018
+ /** The website copy of a backup: keep local data private and apply each adapter's restore policy. */
3926
4019
  function import_body(snapshot) {
3927
4020
  const { local_members, ...body } = snapshot;
3928
4021
  if (local_members?.mode !== "free") return body;
3929
- const claude = new Set(Object.entries(local_members.participants || {}).filter(([, profile]) => profile.adapter === "claude").map(([name]) => name));
3930
- const room_members = (body.tables?.room_members || []).map(row => claude.has(row.name) && row.status !== "removed"
4022
+ const manual = new Set(Object.entries(local_members.participants || {}).filter(([, profile]) => window.discuss_backup.adapter_profile(profile.adapter)?.restore_manual).map(([name]) => name));
4023
+ const room_members = (body.tables?.room_members || []).map(row => manual.has(row.name) && row.status !== "removed"
3931
4024
  ? { ...row, access: "manual", adapter: "", session_mode: "new" } : row);
3932
4025
  return { ...body, tables: { ...body.tables, room_members } };
3933
4026
  }
@@ -3939,7 +4032,7 @@ async function restore_local_members(room, token, local_members) {
3939
4032
  const result = await local_launch_request("room-restore", { room_id: room.id, token, local_members });
3940
4033
  return { members: result.members.map(member => ({ name: member.name, failed: member.status === "failed",
3941
4034
  text: member.status === "failed" ? `未恢复:${member.error}` : member.status === "resumed" ? "请回后接续原会话" : "已改为手动接入,请回后在原窗口重新注册",
3942
- resume: member.status === "manual" && member.session_id ? { command: `claude --resume ${member.session_id}`, cwd: member.cwd } : null })) };
4035
+ resume: member.resume || null })) };
3943
4036
  } catch (error) { return { members: [], problem: `成员会话未恢复:${error.message}` }; }
3944
4037
  }
3945
4038
 
@@ -4004,7 +4097,7 @@ async function execute_view_action(name, value) {
4004
4097
  const event = { preventDefault() {} };
4005
4098
  const actions = { refresh: refresh_room, room: select_room, tab: select_tab,
4006
4099
  archives: toggle_archives, backup: backup_room, backup_rooms, import_rooms, manage_channel, move_channel, create_folder, move_folder, rename_folder, delete_folder, sort_directory,
4007
- send: () => send_message(event), choose_attachments: () => element("attachment-input").click(), remove_attachment, preview_attachment,
4100
+ send: () => send_message(event), discard_sending: () => { state.sending_preview = null; }, choose_attachments: () => element("attachment-input").click(), remove_attachment, preview_attachment,
4008
4101
  create: (folder_id) => submit_room(event, folder_id),
4009
4102
  save_context: () => save_context(event), save_topic: () => save_topic(event),
4010
4103
  schedule: () => schedule_turn(event), filter: filter_view, reply: set_view_reply,
@@ -4013,7 +4106,7 @@ async function execute_view_action(name, value) {
4013
4106
  copy_online: () => copy_context(), copy_cli: () => copy_context("cli"), copy_background,
4014
4107
  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, conclude_discussion, start_room_discussion, end_room_discussion, reopen_discussion,
4015
4108
  save_launch_defaults, launch_current_room, resume_launch, discard_launch, retry_launch_connection, check_launch_connection: load_launch_settings,
4016
- load_room_configuration, save_room_configuration, interrupt_member, retry_delivery, retract_message, rewind_and_send, fork_room, change_member, reorder_speakers, invite_member, set_proxy, copy_online_notice,
4109
+ load_room_configuration, save_room_configuration, interrupt_member, retry_delivery, retract_message, retract_latest, stop_room, rewind_and_send, fork_room, change_member, reorder_speakers, invite_member, set_proxy, copy_online_notice,
4017
4110
  prepare_brainstorm, return_conclusion, adopt_recommendation, rate_decision,
4018
4111
  busy: () => { throw new Error("上一个操作仍在处理中,请稍候;长时间无响应请刷新页面"); },
4019
4112
  copy_reading: () => copy_text(`${location.origin}/r/${state.room.id}`), copy: copy_text };
@@ -4035,7 +4128,7 @@ function change_view_theme(theme) {
4035
4128
  window.discuss_view = { snapshot: view_snapshot, write_field: write_view_field, notify,
4036
4129
  write_launch: write_launch_field, default_launch_entry, description_template, save_description_template, description_draft, write_description_draft, describe_member, set_chain_rounds, continue_chain, expert_presets, apply_expert_preset, unique_instance, member_label, preset_identity, save_expert_preset, delete_expert_preset, write_room_connection, write_room_adapter, launch_clients: LAUNCH_CLIENTS,
4037
4130
  launch_model_state, load_launch_models, write_launch_model, save_launch_api_key, write_scheduler, save_scheduler_api_key, check_scheduler, write_named_followup, write_create_discussion, write_create_chain_rounds, check_launch_api, launch_summary, launch_can_set_access, pick_launch_directory, browse_local_directory, save_launch_projects, restore_launch_profile, reset_create_form, empty_discussion_message,
4038
- start_drag: start_directory_drag, reconnect: reconnect_live_updates,
4131
+ start_drag: start_directory_drag, reconnect: reconnect_live_updates, stop_label,
4039
4132
  attachment_markup, attachment_size, attachment_file_icon, drop_attachments, paste_attachments,
4040
4133
  write_expert: write_view_expert, add_instance: add_view_instance, add_preset: add_preset_instance, platform_of, execute: execute_view_action, theme: change_view_theme,
4041
4134
  admin: { register: register_admin, login: login_admin, logout: logout_admin,