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.
- package/.agents/skills/discuss-participant/SKILL.md +1 -1
- package/backend/free_room.py +2410 -2274
- package/backend/schema.sql +27 -0
- package/backend/server.py +21 -11
- package/docs/architecture.md +2 -1
- package/docs/deployment.md +10 -0
- package/docs/en/deployment.md +10 -0
- package/docs/en/workflow.md +1 -1
- package/docs/runner.md +1 -1
- package/docs/workflow.md +2 -2
- package/package.json +3 -2
- package/runner/adapters/antigravity_app.mjs +91 -0
- package/runner/adapters/antigravity_cli.mjs +320 -0
- package/runner/adapters/antigravity_sidecar.mjs +50 -0
- package/runner/{api_participant.mjs → adapters/api.mjs} +19 -5
- package/runner/adapters/claude.mjs +313 -0
- package/runner/adapters/cli.mjs +126 -0
- package/runner/adapters/codex_app.mjs +70 -0
- package/runner/adapters/codex_cli.mjs +286 -0
- package/runner/adapters/common.mjs +196 -0
- package/runner/adapters/index.mjs +143 -0
- package/runner/adapters/profiles.mjs +116 -0
- package/runner/admin_account.mjs +1 -1
- package/runner/discussion_launch.mjs +69 -79
- package/runner/discussion_local.mjs +1 -1
- package/runner/discussion_runner.mjs +14 -13
- package/runner/discussion_service.mjs +3 -3
- package/runner/discussion_setup.mjs +1 -1
- package/runner/discussion_sidecar.mjs +5 -50
- package/runner/discussion_skills.mjs +1 -1
- package/runner/execution_progress.mjs +99 -0
- package/runner/expert_prompts.mjs +19 -29
- package/runner/files.mjs +10 -0
- package/runner/free_runner.mjs +136 -30
- package/runner/remote_launch.mjs +10 -5
- package/runner/scheduler.mjs +124 -32
- package/runner/session_driver.mjs +5 -3
- package/web/app.js +116 -23
- package/web/backup.js +6 -6
- package/web/index.html +2 -0
- package/web/modern.css +1 -1
- package/web/modern.js +1699 -1353
- package/runner/discussion_adapters.mjs +0 -959
- package/runner/discussion_models.mjs +0 -271
- package/runner/session_cut.mjs +0 -167
- /package/runner/{codex_app_client.mjs → adapters/codex_app_client.mjs} +0 -0
package/runner/remote_launch.mjs
CHANGED
|
@@ -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 {
|
|
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]) =>
|
|
86
|
-
|
|
87
|
-
|
|
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 } : {}) };
|
package/runner/scheduler.mjs
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
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 =
|
|
207
|
-
{ value:
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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)
|
|
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 =
|
|
251
|
-
{ value: HOST_CHOICE, description: `交给主持人 ${host}
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
{
|
|
276
|
-
|
|
277
|
-
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1440
|
-
|
|
1441
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
3930
|
-
const room_members = (body.tables?.room_members || []).map(row =>
|
|
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.
|
|
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,
|