ai_co_discuss 0.3.46 → 0.3.49
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-host/SKILL.md +1 -0
- package/.agents/skills/discuss-host/references/discussion.md +113 -113
- package/.agents/skills/discuss-host/references/free.md +36 -34
- package/.agents/skills/discuss-host/references/goal.md +35 -0
- package/.agents/skills/discuss-participant/SKILL.md +1 -0
- package/.agents/skills/workflow-creator/SKILL.md +2 -2
- package/README.md +3 -1
- package/backend/admin.py +104 -35
- package/backend/free_room.py +2170 -2136
- package/backend/goal_room.py +358 -0
- package/backend/mode_room.py +105 -0
- package/backend/schema.sql +1593 -1510
- package/backend/server.py +2636 -2555
- package/backend/session_room.py +1180 -1148
- package/docs/THIRD_PARTY_NOTICES.md +1293 -1264
- package/docs/deployment.md +5 -1
- package/docs/en/workflow.md +15 -1
- package/docs/runner.md +28 -7
- package/docs/workflow.md +15 -1
- package/package.json +3 -2
- package/runner/adapters/api.mjs +333 -313
- package/runner/adapters/claude.mjs +12 -16
- package/runner/admin_account.mjs +12 -19
- package/runner/device_auth.mjs +369 -0
- package/runner/discuss.py +1208 -1125
- package/runner/discussion_launch.mjs +107 -12
- package/runner/discussion_service.mjs +25 -1
- package/runner/expert_prompts.mjs +132 -132
- package/runner/free_runner.mjs +982 -910
- package/runner/pinyin_table.mjs +406 -0
- package/runner/project_files.mjs +52 -3
- package/runner/relay_channel.mjs +51 -7
- package/runner/remote_launch.mjs +221 -235
- package/runner/remote_policy.mjs +156 -0
- package/runner/scheduler.mjs +459 -429
- package/runner/stt.mjs +2 -2
- package/runner/tts.mjs +74 -113
- package/runner/tts_providers.mjs +205 -0
- package/runner/webauthn.mjs +155 -0
- package/runner/workflows.mjs +371 -348
- package/web/app.js +355 -51
- package/web/modern.css +1 -1
- package/web/modern.js +5764 -3816
package/runner/scheduler.mjs
CHANGED
|
@@ -1,429 +1,459 @@
|
|
|
1
|
-
/** Optional smart scheduling: typed decisions from TypeSafe Jev or OpenAI Decisions, never generated text. */
|
|
2
|
-
import { api_status_message, read_saved_api_key } from './discussion_api.mjs';
|
|
3
|
-
|
|
4
|
-
/** Each provider keeps its official root; the key saved for that root is the only credential used. */
|
|
5
|
-
export const SCHEDULER_PROVIDERS = {
|
|
6
|
-
jev: { label: 'TypeSafe Jev', base_url: 'https://api.typesafe.ai/v1', path: '/systemone', models: ['jev-latest'] },
|
|
7
|
-
decisions: { label: 'OpenAI Decisions', base_url: 'https://api.openai.com/v1', path: '/decisions', models: ['gpt-6-luna'] },
|
|
8
|
-
};
|
|
9
|
-
const REQUEST_TIMEOUT_MS = 30_000;
|
|
10
|
-
/** Below this confidence a decision stays with the host, exactly as without scheduling. */
|
|
11
|
-
export const ROUTE_CONFIDENCE = 0.7;
|
|
12
|
-
/** Waking everyone or no one costs more when wrong (N runs, or an ignored message), so it needs more certainty. */
|
|
13
|
-
export const ROUTE_WIDE_CONFIDENCE = 0.85;
|
|
14
|
-
/**
|
|
15
|
-
* The guided wording gives lower probabilities for the same correct pick, so it has its own thresholds, set where the
|
|
16
|
-
* regression set gains correct picks without any new wrong one (2026-10-31). @全体成员 and review keep their own.
|
|
17
|
-
*/
|
|
18
|
-
export const GUIDED_CONFIDENCE = 0.6;
|
|
19
|
-
export const GUIDED_WIDE_CONFIDENCE = 0.8;
|
|
20
|
-
/** The single-member and the everyone-or-no-one thresholds of one wording. */
|
|
21
|
-
export function scheduler_thresholds(mode) {
|
|
22
|
-
return mode === 'stable' ? { member: ROUTE_CONFIDENCE, wide: ROUTE_WIDE_CONFIDENCE } : { member: GUIDED_CONFIDENCE, wide: GUIDED_WIDE_CONFIDENCE };
|
|
23
|
-
}
|
|
24
|
-
/** Bump when the routing question or its option descriptions change, so ratings can be compared across wordings. */
|
|
25
|
-
export const ROUTE_PROMPT_VERSION = 14;
|
|
26
|
-
/**
|
|
27
|
-
* How the free-room routing question is worded. guided (experimental, the default while it is being refined) states the
|
|
28
|
-
* purpose and the evidence in prose; stable keeps the ordered checklist that passed the regression set.
|
|
29
|
-
*/
|
|
30
|
-
export const SCHEDULER_MODES = { guided: '引导(试验)', stable: '稳定' };
|
|
31
|
-
export const DEFAULT_SCHEDULER_MODE = 'guided';
|
|
32
|
-
/** The guided wording has its own version, so its ratings are never mixed with the stable checklist's. */
|
|
33
|
-
export const ROUTE_GUIDED_PROMPT_VERSION = 9;
|
|
34
|
-
/** The routing prompt version that a decision in this mode was made with. */
|
|
35
|
-
export function route_prompt_version(mode) {
|
|
36
|
-
return mode === 'stable' ? ROUTE_PROMPT_VERSION : ROUTE_GUIDED_PROMPT_VERSION;
|
|
37
|
-
}
|
|
38
|
-
/** The same for the three discussion questions (interjection, next speaker, next round). */
|
|
39
|
-
export const DISCUSSION_PROMPT_VERSION = 5;
|
|
40
|
-
/** The guided wording of the discussion questions, versioned apart from the stable one. */
|
|
41
|
-
export const DISCUSSION_GUIDED_PROMPT_VERSION = 2;
|
|
42
|
-
/** The discussion prompt version that a decision in this mode was made with. */
|
|
43
|
-
export function discussion_prompt_version(mode) {
|
|
44
|
-
return mode === 'stable' ? DISCUSSION_PROMPT_VERSION : DISCUSSION_GUIDED_PROMPT_VERSION;
|
|
45
|
-
}
|
|
46
|
-
/** Completion of a preauthorized implementation batch is separate from routing and discussion. */
|
|
47
|
-
export const REVIEW_PROMPT_VERSION = 1;
|
|
48
|
-
export const REVIEW_CONFIDENCE = 0.85;
|
|
49
|
-
export const MAX_REVIEW_INPUT = 60000;
|
|
50
|
-
const HOST_CHOICE = '__host__';
|
|
51
|
-
const NOTE_CHOICE = '__note__';
|
|
52
|
-
const ALL_CHOICE = '__all__';
|
|
53
|
-
const ALL_SEQUENTIAL_CHOICE = '__all_seq__';
|
|
54
|
-
const NONE_CHOICE = '__none__';
|
|
55
|
-
/** Prefix of the "only these few" options; the value lists the names after it, joined by SOME_JOINER. */
|
|
56
|
-
export const SOME_PREFIX = '__some__:';
|
|
57
|
-
const SOME_JOINER = '、';
|
|
58
|
-
/** At most this many named members are combined, so the options stay few (4 names give 11 groups). */
|
|
59
|
-
const MAX_SOME_NAMES = 4;
|
|
60
|
-
const CONTEXT_MESSAGES = 10;
|
|
61
|
-
/** A free-room route sees a longer stretch, enough to tell who has not spoken for a while. */
|
|
62
|
-
export const ROUTE_CONTEXT_MESSAGES = 20;
|
|
63
|
-
const MAX_MESSAGE_CHARS = 600;
|
|
64
|
-
const MAX_BRIEF_CHARS = 1200;
|
|
65
|
-
const MAX_PROFILE_CHARS = 500;
|
|
66
|
-
/**
|
|
67
|
-
* What the room is and how its people talk, so a decision reads the user's words the way a member would.
|
|
68
|
-
* Shared by every scene built from topic_lines; bump both prompt versions when it changes.
|
|
69
|
-
*/
|
|
70
|
-
const ROOM_GUIDE = ['这是 AI CoDiscuss 的协作房间。用户是房主和需求方;其余成员都是 AI,各自运行在 Claude Code、Codex 等软件或模型 API 上,用户也称它们为专家或 AI。',
|
|
71
|
-
'其中一位是主持人,负责接收没有点名的消息并分派、协调,也可以自己回答;其他成员是专家,在各自的会话里工作并回复。',
|
|
72
|
-
'用户用 @名字 点名成员,用 @全体成员 请所有人回应。每唤醒一位成员就要花一次执行(数十秒);几位成员同时回答时彼此看不到对方的回答。',
|
|
73
|
-
'房间可以临时开启讨论:主持人按目标安排专家轮流发言,到达轮次或时长上限后回到自由协作。'].join('\n');
|
|
74
|
-
|
|
75
|
-
/** Normalize a launch's scheduler choice and prove its key exists for that provider's root. */
|
|
76
|
-
export function validate_scheduler(home, scheduler) {
|
|
77
|
-
const provider = SCHEDULER_PROVIDERS[scheduler?.provider];
|
|
78
|
-
if (!provider) throw new Error(`智能调度供应商只能为 ${Object.keys(SCHEDULER_PROVIDERS).join(' / ')}`);
|
|
79
|
-
if (!scheduler.model || typeof scheduler.model !== 'string') throw new Error('请填写智能调度模型');
|
|
80
|
-
if (scheduler.mode !== undefined && !SCHEDULER_MODES[scheduler.mode]) throw new Error(`智能调度模式只能为 ${Object.keys(SCHEDULER_MODES).join(' / ')}`);
|
|
81
|
-
const normalized = { provider: scheduler.provider, model: scheduler.model.trim(), base_url: provider.base_url, api_key_ref: scheduler.api_key_ref,
|
|
82
|
-
mode: scheduler.mode || DEFAULT_SCHEDULER_MODE };
|
|
83
|
-
read_saved_api_key(home, normalized.base_url, normalized.api_key_ref);
|
|
84
|
-
return normalized;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Convert neutral questions ({type: choice|predicate, name, instructions, choices}) to one provider's body. */
|
|
88
|
-
export function scheduler_request(scheduler, state, questions) {
|
|
89
|
-
const { path } = SCHEDULER_PROVIDERS[scheduler.provider];
|
|
90
|
-
if (scheduler.provider === 'jev') return { path, body: { model: scheduler.model, state, questions: Object.fromEntries(questions.map(question => [question.name,
|
|
91
|
-
question.type === 'choice'
|
|
92
|
-
? { type: 'choice', instructions: question.instructions, criteria: Object.fromEntries(question.choices.map(choice => [choice.value, choice.description])) }
|
|
93
|
-
: { type: 'noul', instructions: question.instructions }])) } };
|
|
94
|
-
return { path, body: { model: scheduler.model, input: state, questions: questions.map(question => question.type === 'choice'
|
|
95
|
-
? { type: 'choice', name: question.name, instructions: question.instructions, choices: question.choices.map(({ value, description }) => ({ value, description })) }
|
|
96
|
-
: { type: 'predicate', name: question.name, instructions: question.instructions }) } };
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Read answers by question name: choices as {choice, confidence, probabilities}, predicates as {probability}. */
|
|
100
|
-
export function scheduler_answers(scheduler, data) {
|
|
101
|
-
if (scheduler.provider === 'jev') return Object.fromEntries(Object.entries(data?.answers || {}).map(([name, answer]) => [name,
|
|
102
|
-
answer.type === 'choice' ? { choice: answer.choice, confidence: answer.confidence, probabilities: answer.probabilities || {} } : { probability: answer.noul }]));
|
|
103
|
-
return Object.fromEntries((data?.answers || []).map(answer => [answer.name, answer.type === 'choice'
|
|
104
|
-
? { choice: answer.choice, confidence: answer.confidence, probabilities: Object.fromEntries((answer.probabilities || []).map(item => [item.value, item.probability])) }
|
|
105
|
-
: { probability: answer.probability }]));
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Ask the configured provider once; errors carry only the HTTP status, never response bodies or keys. */
|
|
109
|
-
export async function decide(home, scheduler, state, questions, fetch_impl = fetch) {
|
|
110
|
-
const key = read_saved_api_key(home, scheduler.base_url, scheduler.api_key_ref);
|
|
111
|
-
const request = scheduler_request(scheduler, state, questions);
|
|
112
|
-
let response;
|
|
113
|
-
const started = Date.now();
|
|
114
|
-
try {
|
|
115
|
-
response = await fetch_impl(`${scheduler.base_url}${request.path}`, { method: 'POST', redirect: 'error',
|
|
116
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
|
117
|
-
body: JSON.stringify(request.body), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
118
|
-
} catch { throw new Error('智能调度请求超时或连接失败'); }
|
|
119
|
-
if (!response.ok) throw new Error(api_status_message(response.status));
|
|
120
|
-
const answers = scheduler_answers(scheduler, await response.json());
|
|
121
|
-
// Timing rides along without being an answer, so callers iterating answers by question name never see it.
|
|
122
|
-
Object.defineProperty(answers, 'elapsed_ms', { value: Date.now() - started });
|
|
123
|
-
return answers;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Send one tiny yes/no question so provider, model and key are checked together before a room relies on them. */
|
|
127
|
-
export async function scheduler_check(home, input, fetch_impl = fetch) {
|
|
128
|
-
const scheduler = validate_scheduler(home, input);
|
|
129
|
-
const answers = await decide(home, scheduler, '这是一条连通性检查消息。', [{ type: 'predicate', name: 'check', instructions: '这条内容是否是连通性检查?' }], fetch_impl);
|
|
130
|
-
if (typeof answers.check?.probability !== 'number') throw new Error('决策模型未返回可用答案,请核对供应商与模型');
|
|
131
|
-
return { ok: true };
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** What was asked (the instruction and options only, never the room excerpts), what the provider chose and how long the call took. */
|
|
135
|
-
function detail_of(questions, answers) {
|
|
136
|
-
return questions.map(question => {
|
|
137
|
-
const answer = answers[question.name];
|
|
138
|
-
// A yes/no question is recorded as a two-way choice so the card reads it like any other.
|
|
139
|
-
if (question.type === 'predicate') {
|
|
140
|
-
const yes = typeof answer?.probability === 'number' ? answer.probability : null;
|
|
141
|
-
return { question: question.instructions, choices: ['是', '否'], choice: yes === null ? null : yes >= 0.5 ? '是' : '否',
|
|
142
|
-
probabilities: yes === null ? {} : { 是: yes, 否: Number((1 - yes).toFixed(4)) }, duration_ms: answers.elapsed_ms ?? null };
|
|
143
|
-
}
|
|
144
|
-
return { question: question.instructions, choices: question.choices.map(choice => choice.value),
|
|
145
|
-
choice: answer?.choice ?? null, probabilities: answer?.probabilities ?? {}, duration_ms: answers.elapsed_ms ?? null };
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** The shown detail plus the exact input (state and full questions), kept so a rated-bad decision can be reviewed later. */
|
|
150
|
-
function judged_of(state, questions, answers) {
|
|
151
|
-
return { detail: detail_of(questions, answers), input: { state, questions } };
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Who has and has not spoken this round, so a decision knows who the plan still owes a turn. */
|
|
155
|
-
function round_progress(spoken, waiting) {
|
|
156
|
-
return `本轮已发言:${spoken.join('、') || '无'};本轮尚未发言:${waiting.join('、') || '无'}。`;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** Say which option the provider leaned to when it was not sure enough, so the transcript shows how close it was. */
|
|
160
|
-
function unsure_label(lean) {
|
|
161
|
-
return lean ? `倾向${lean},把握不足,交给主持人` : '拿不准,交给主持人';
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** Shorten a message so recent context stays small; the decision never needs whole answers. */
|
|
165
|
-
function excerpt(message) {
|
|
166
|
-
const text = String(message.text || '').replace(/\s+/g, ' ').trim();
|
|
167
|
-
const reply = message.reply_to ? `(回复 #${message.reply_to})` : '';
|
|
168
|
-
return `#${message.id} ${message.author}${reply}:${text.length > MAX_MESSAGE_CHARS ? `${text.slice(0, MAX_MESSAGE_CHARS)}…` : text}`;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** The room's purpose in the user's words, so a decision can tell drifting from progress. */
|
|
172
|
-
function topic_lines(title, goal, user_request, { guide = ROOM_GUIDE, roster, people, profiles, brief, focus, arrangement, progress, upcoming } = {}) {
|
|
173
|
-
return [guide, `房间:${title || ''}`, roster && `房间成员(名字不区分大小写):${roster}`, people, profile_lines(profiles), goal && `目标:${goal}`, user_request && `用户原文:${String(user_request).slice(0, MAX_MESSAGE_CHARS)}`,
|
|
174
|
-
focus && `当前焦点:${focus}`, brief && `主持简报(含分工与规则):${String(brief).slice(0, MAX_BRIEF_CHARS)}`,
|
|
175
|
-
arrangement && `主持人最近的安排:${arrangement}`, progress, upcoming && `接下来按安排发言:${upcoming.length ? upcoming.join(' → ') : '暂无,没有已安排的发言'}`].filter(Boolean).join('\n');
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Guided discussion background: how a discussion runs and what each step costs, so the model weighs the situation
|
|
180
|
-
* instead of matching rules. Used by the three discussion questions in the guided mode.
|
|
181
|
-
*/
|
|
182
|
-
const DISCUSSION_GUIDED_GUIDE = ['这是 AI CoDiscuss 协作房间里临时开启的一场讨论。用户是房主,给出讨论目标;其余成员都是 AI,用户常称它们为专家。',
|
|
183
|
-
'主持人按目标安排专家轮流发言:每轮每位专家发言一次,后发言的人能看到前面所有发言;主持简报里记着分工、顺序和规则。到达轮次或时长上限、目标达成或用户结束时,回到自由协作。',
|
|
184
|
-
'每请一位专家发言或回答,都要花一次执行(通常几十秒)。用户在讨论中随时可能留言:补充约束、追问某人、调整方向,或者只是鼓励。'].join('\n');
|
|
185
|
-
/** The member line of the guided routing state: the shared facts plus the model and the last finished message. */
|
|
186
|
-
function guided_member_line(role, member) {
|
|
187
|
-
const extra = [member.model && `模型 ${member.model}`, member.finished && `最近完成 ${member.finished}`].filter(Boolean).join(';');
|
|
188
|
-
return `- ${role} ${extra ? String(member.description).replace(/)$/, `;${extra})`) : member.description}`;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** The owner's descriptions of members, so a decision can weigh who is good at what. */
|
|
192
|
-
export function profile_lines(profiles) {
|
|
193
|
-
const lines = Object.entries(profiles || {}).filter(([, text]) => text).map(([name, text]) => `- ${name}:${String(text).slice(0, MAX_PROFILE_CHARS)}`);
|
|
194
|
-
return lines.length ? `用户对成员的描述(擅长与分工,供参考):\n${lines.join('\n')}` : '';
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/** State shared by both uses: the room, a few earlier messages, and the message to place. */
|
|
198
|
-
export function scheduler_state(title, context, message, goal = '', user_request = '', extra = {}, limit = CONTEXT_MESSAGES) {
|
|
199
|
-
const earlier = context.filter(item => item.id < message.id).slice(-limit).map(excerpt);
|
|
200
|
-
return [topic_lines(title, goal, user_request, extra), earlier.length ? `近期消息(旧→新):\n${earlier.join('\n')}` : '近期没有其他消息。', `待处理的用户消息:\n${excerpt(message)}`].join('\n\n');
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* How sure the provider is of its pick: the chosen option's probability when given. Jev's separate `confidence`
|
|
205
|
-
* runs lower than that probability on two-way choices, so it is only the fallback.
|
|
206
|
-
*/
|
|
207
|
-
export function certainty(answer) {
|
|
208
|
-
const probability = answer?.probabilities?.[answer.choice];
|
|
209
|
-
return typeof probability === 'number' ? probability : typeof answer?.confidence === 'number' ? answer.confidence : null;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** Stable routing wording: an ordered checklist of evidence, each item settling the addressee before the next is read. */
|
|
213
|
-
const ROUTE_STABLE_STEM = '这条用户消息没有用 @ 点名成员。判断它的回应对象,按下面的顺序:1)正文叫了某位成员的名字或用其他说法指代他(对照房间成员名单和前文只可能是他),或在向其提问(包括“X在吗”“X还在吗”“X你怎么看”),选该成员,即使上一条是别人的回答;同时提到几位成员时,选被直接称呼、要求其回应或做事的那位(如“A 你让 B 去改”是在对 A 说);2)正文没有指向任何人时,看它是否在延续某位成员的回答(追问、要求修改、引用,或“回复”指向其消息),或在补充、纠正一条正由某位成员处理的消息,是则选该成员,是主持人就选主持人;3)查看各成员的用户描述:已经明确由唯一一位成员负责这类任务时,直接选该成员,不需要再次点名;仅有擅长、多人分工重叠或任务还需要拆分时交主持人;4)符合“全体”选项的条件时选全体;要求成员之间讨论或互相回应的不算,按第 7 条处理;5)单独的问候或闲聊(如“hi”)既没有指向某位成员,也没有要求大家一起回应,选主持人;6)只是确认、致谢或旁注选无需回应;7)对象不明确,或要讨论、互相回应、分工、排先后时选主持人,由主持人安排。';
|
|
214
|
-
/**
|
|
215
|
-
* Guided routing: instead of rules, the model gets what a member of the room would know (who is who, what each is busy
|
|
216
|
-
* with, what each choice leads to) and the purpose of this step, and weighs the message itself.
|
|
217
|
-
*/
|
|
218
|
-
const ROUTE_GUIDED_GUIDE = ['这是 AI CoDiscuss 的协作房间。用户是房主,也是提出需求的人;其余成员都是 AI,各自运行在 Claude Code、Codex 等软件或模型 API 上,用户常称它们为专家。',
|
|
219
|
-
'主持人是其中一位:平时接收没人点名的消息,自己回答或分派给别人,也负责协调需要几个人配合的事。专家们在各自的会话里工作,回复都发在房间里,所有人都看得到。',
|
|
220
|
-
'用户找某人时通常会 @ 他,但聊顺了也常直接说话:叫名字、换个说法指代,或者接着刚才的话往下说。用户也可能事先在成员描述里交代过谁负责什么。',
|
|
221
|
-
'唤醒一位成员要花一次执行,通常几十秒;交给了不对的人,就得打断后重来。'].join('\n');
|
|
222
|
-
const ROUTE_GUIDED_STEM = '用户发来上面这条消息,没有 @ 任何人。请你像熟悉这个房间的成员一样读懂它:用户想让谁来回应?'
|
|
223
|
-
+ '用户心里已经有明确的对象时,直接交给对方,省掉主持人转一手的时间;需要有人先理解、拆分或协调时交给主持人。'
|
|
224
|
-
+ '用户想听每个人各自说一句时,交给全体能让大家同时开始,比主持人逐个点名快得多;但大家看不到彼此的回答。每人只需按顺序接着前一位说一次时,交给全体依次回应;需要互相接话、讨论的事仍要主持人安排。用户只是表示收到或感谢时,不必唤醒任何人。'
|
|
225
|
-
+ '两种容易看错的情况:向在场的各位问好,是在等每个人回一声,和只对房间说一句“hi”不同;用户问“谁是……”“谁能……”时是在找符合条件的人,只需要符合的人出来回应(哪怕说“自己说一下”),成员信息里(含主持人)只有一位符合就交给他。';
|
|
226
|
-
|
|
227
|
-
/** The routing question in one mode: option texts, the stem, and what the state adds about the members. */
|
|
228
|
-
/** Every group of two or more among the members the text names, so "these few each answer" is one choice. */
|
|
229
|
-
function named_groups(text, host, members) {
|
|
230
|
-
const lower = String(text || '').toLowerCase();
|
|
231
|
-
const named = [host, ...members].map(member => member.name).filter(name => lower.includes(name.toLowerCase()));
|
|
232
|
-
if (named.length < 2 || named.length > MAX_SOME_NAMES) return [];
|
|
233
|
-
const groups = [];
|
|
234
|
-
for (let mask = 1; mask < 1 << named.length; mask += 1) {
|
|
235
|
-
const group = named.filter((_, index) => mask & (1 << index));
|
|
236
|
-
if (group.length >= 2) groups.push(group);
|
|
237
|
-
}
|
|
238
|
-
return groups;
|
|
239
|
-
}
|
|
240
|
-
/** The members of a "these few" choice, or null for any other value. */
|
|
241
|
-
export function some_members(choice) {
|
|
242
|
-
return typeof choice === 'string' && choice.startsWith(SOME_PREFIX) ? choice.slice(SOME_PREFIX.length).split(SOME_JOINER) : null;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function route_question(mode, host, members, text) {
|
|
246
|
-
if (mode === 'stable') return { extra: { roster: [`${host.name}(主持人)`, ...members.map(member => `${member.name}(专家)`)].join('、') },
|
|
247
|
-
instructions: ROUTE_STABLE_STEM, choices: [...members.map(member => ({ value: member.name, description: member.description })),
|
|
248
|
-
{ value: HOST_CHOICE, description: `主持人 ${host.description}:没有指向任何人且用户没有确定负责人(如单独一句“hi”“你好”这类问候、闲聊,由主持人代表房间回应),或诉求模糊、复杂,需要主持人判断怎么安排` },
|
|
249
|
-
{ value: ALL_CHOICE, description: '全体成员(含主持人)各自回应一次,彼此不需要看到别人的回答;只在要求简单明确、每人独立回应一次就算完成时选,如“大家打个招呼”“各自报一下在用的模型”' },
|
|
250
|
-
{ value: NONE_CHOICE, description: '无需回应:只是确认、致谢或旁注,不需要任何人回答;选它则用户这句话不会得到回应' }] };
|
|
251
|
-
const people = ['成员(名字不区分大小写):', guided_member_line('主持人', host), ...members.map(member => guided_member_line('专家', member))].join('\n');
|
|
252
|
-
return { extra: { guide: ROUTE_GUIDED_GUIDE, people }, instructions: ROUTE_GUIDED_STEM,
|
|
253
|
-
choices: [...members.map(member => ({ value: member.name, description: `交给 ${member.name} 回应` })),
|
|
254
|
-
{ value: HOST_CHOICE, description: `交给主持人 ${host.name}:由他回应,或由他安排别人` },
|
|
255
|
-
...named_groups(text, host, members).map(group => ({ value: `${SOME_PREFIX}${group.join(SOME_JOINER)}`,
|
|
256
|
-
description: `只请 ${group.join('、')} 各自回应一次:同时开始,每人都收到这句原话,彼此看不到对方的回答,其他成员不被唤醒` })),
|
|
257
|
-
{ value: ALL_CHOICE, description: '全体成员(含主持人)同时收到这句原话,各自回应一次,彼此看不到对方的回答,也不会被分到不同的任务' },
|
|
258
|
-
{ value: ALL_SEQUENTIAL_CHOICE, description: '全体成员(含主持人)按房间发言顺序逐个回应这句原话,每人一次:前一位答完下一位才开始,后一位能看到前面所有人的回答;比同时回应慢,也不会被分到不同的任务' },
|
|
259
|
-
{ value: NONE_CHOICE, description: '不唤醒任何人,用户这句话不会得到回应' }] };
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Decide who answers one unnamed free-room message: whether its addressee is already settled by the text or the thread.
|
|
264
|
-
* Members are [{name, description}] without the host; host is {name, description}. Returns action member (with member),
|
|
265
|
-
* all, sequential (guided only), some, none, or host for anything unsettled or not confident enough.
|
|
266
|
-
*/
|
|
267
|
-
export async function route_free_message(home, scheduler, { title, goal, context, message, host, members }, fetch_impl) {
|
|
268
|
-
const { choices, instructions, extra } = route_question(scheduler.mode || DEFAULT_SCHEDULER_MODE, host, members, message.text);
|
|
269
|
-
const questions = [{ type: 'choice', name: 'handler', choices, instructions }];
|
|
270
|
-
const state = scheduler_state(title, context, message, goal, '', extra, ROUTE_CONTEXT_MESSAGES);
|
|
271
|
-
const answers = await decide(home, scheduler, state, questions, fetch_impl);
|
|
272
|
-
const judged = judged_of(state, questions, answers);
|
|
273
|
-
const choice = answers.handler?.choice ?? null;
|
|
274
|
-
const confidence = certainty(answers.handler);
|
|
275
|
-
// Waking several members, everyone or no one costs more when wrong than moving the message to one member.
|
|
276
|
-
const group = some_members(choice);
|
|
277
|
-
const wide = [ALL_CHOICE, ALL_SEQUENTIAL_CHOICE, NONE_CHOICE].includes(choice) || Boolean(group);
|
|
278
|
-
const limits = scheduler_thresholds(scheduler.mode || DEFAULT_SCHEDULER_MODE);
|
|
279
|
-
const sure = confidence !== null && confidence >= (wide ? limits.wide : limits.member);
|
|
280
|
-
if (sure && choice === ALL_CHOICE) return { action: 'all', choice, confidence, ...judged };
|
|
281
|
-
if (sure && choice === ALL_SEQUENTIAL_CHOICE) return { action: 'sequential', choice, confidence, ...judged };
|
|
282
|
-
if (sure && choice === NONE_CHOICE) return { action: 'none', choice, confidence, ...judged };
|
|
283
|
-
if (sure && group) return { action: 'some', members: group, choice, confidence, ...judged };
|
|
284
|
-
if (sure && members.some(item => item.name === choice)) return { action: 'member', member: choice, choice, confidence, ...judged };
|
|
285
|
-
return { action: 'host', choice, confidence, ...judged };
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* The user named everyone (@全体成员): decide whether they answer at once (parallel) or one after another (sequential).
|
|
290
|
-
* Both automatic actions need confidence; unclear division of work or a weak answer returns control to the host.
|
|
291
|
-
*/
|
|
292
|
-
export async function decide_group_order(home, scheduler, { title, goal, context, message, members, descriptions = [] }, fetch_impl) {
|
|
293
|
-
const questions = [{ type: 'choice', name: 'order', choices: [
|
|
294
|
-
{ value: 'parallel', description: '同时回答:各自独立回应即可,不需要看到别人的回答,如打招呼、各自介绍、各说一句看法、各自投票' },
|
|
295
|
-
{ value: 'sequential', description: `依次回答:后发言的需要看到前面的回答,如接龙、轮流补充或反驳、逐个评审上一位的结果、按顺序分工接力;按房间发言顺序(${members.join('、')})逐个回答` },
|
|
296
|
-
{ value: HOST_CHOICE, description: '交给主持人:任务或分工还不清楚,需要先澄清、分配范围或协调;仅把大家排成依次回答不能解决这些问题' }],
|
|
297
|
-
instructions: '用户用 @全体成员 请房间里所有成员回应这条消息。根据任务是否已明确以及成员间的依赖,判断应同时各自回答、按既定顺序依次回答(后一位看到前一位的回答再接着说),还是先由主持人安排。' }];
|
|
298
|
-
const facts = descriptions.length ? `成员的软件、项目权限与当前任务:\n${descriptions.map(member => `- ${member.description}`).join('\n')}` : '';
|
|
299
|
-
const state = [scheduler_state(title, context, message, goal, '', {}, ROUTE_CONTEXT_MESSAGES), facts].filter(Boolean).join('\n\n');
|
|
300
|
-
const answers = await decide(home, scheduler, state, questions, fetch_impl);
|
|
301
|
-
const confidence = certainty(answers.order);
|
|
302
|
-
const choice = answers.order?.choice ?? null;
|
|
303
|
-
const action = confidence !== null && confidence >= ROUTE_CONFIDENCE ? { parallel: 'all', sequential: 'sequential' }[choice] || 'host' : 'host';
|
|
304
|
-
return { action, choice, confidence, ...judged_of(state, questions, answers) };
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* Decide one interjection in a discussion: continue the plan (note), ask one expert (ask) or wake the host (host).
|
|
309
|
-
* Anything but a confident note or ask is returned as host.
|
|
310
|
-
*/
|
|
311
|
-
export async function decide_interjection(home, scheduler, { title, goal, user_request, context, message, host, experts, spoken = [], upcoming = [], ...extra }, fetch_impl) {
|
|
312
|
-
const guided = (scheduler.mode || DEFAULT_SCHEDULER_MODE) !== 'stable';
|
|
313
|
-
// Ordinary asks do not consume a discussion turn; all present members are eligible.
|
|
314
|
-
const choices = guided
|
|
315
|
-
? [...experts.map(name => ({ value: name, description: `现在单独请 ${name} 回应这条留言:不占讨论轮次,多花一次执行` })),
|
|
316
|
-
{ value: HOST_CHOICE, description: `交给主持人 ${host} 处理` },
|
|
317
|
-
{ value: NOTE_CHOICE, description: '按原安排继续:讨论照常推进、不会暂停,留言留在房间里,后面发言的专家都会看到并可在发言中回应' }]
|
|
318
|
-
: [...experts.map(name => ({ value: name, description: `请 ${name} 回应这条留言:留言是向该专家(或其负责的内容)提问、质疑或补充要求` })),
|
|
319
|
-
{ value: HOST_CHOICE, description: `由主持人 ${host} 处理:留言要改变讨论目标、规则、分工、安排或结束讨论,同时涉及多位专家,或针对的是不在选项里的人` },
|
|
320
|
-
{ value: NOTE_CHOICE, description: '仅补充信息:留言补充背景、约束或偏好,或只是旁注、鼓励,没有要求某人立即回应;后续发言的专家都能看到它,按当前安排继续' }];
|
|
321
|
-
const instructions = guided
|
|
322
|
-
? '讨论进行中,用户发来上面这条留言。请你像熟悉这场讨论的人一样读懂它,选出最合适的处理方式。'
|
|
323
|
-
+ '用户向某位专家要回应时,现在单独请他回答能马上得到答复,但要多花一次执行;如果他就在接下来要发言的人里,留到他发言时一并回应更省,也不打乱讨论。'
|
|
324
|
-
+ '补充的约束、偏好和鼓励,后面发言的人都看得到。要暂停或结束讨论、改目标、规则、分工或安排,或需要几位专家协调的,由主持人处理。'
|
|
325
|
-
: '讨论进行中用户发来这条留言。对照当前焦点、主持简报和安排,判断它最适合怎样处理。';
|
|
326
|
-
const questions = [{ type: 'choice', name: 'handling', instructions, choices }];
|
|
327
|
-
const state = scheduler_state(title, context, message, goal, user_request, { ...extra, progress: round_progress(spoken, experts.filter(name => !spoken.includes(name))),
|
|
328
|
-
...(guided ? { guide: DISCUSSION_GUIDED_GUIDE, upcoming } : {}) });
|
|
329
|
-
const answers = await decide(home, scheduler, state, questions, fetch_impl);
|
|
330
|
-
const judged = judged_of(state, questions, answers);
|
|
331
|
-
const choice = answers.handling?.choice;
|
|
332
|
-
const confidence = certainty(answers.handling);
|
|
333
|
-
const sure = confidence !== null && confidence >= scheduler_thresholds(scheduler.mode || DEFAULT_SCHEDULER_MODE).member;
|
|
334
|
-
if (sure && choice === NOTE_CHOICE) return { action: 'note', confidence, ...judged, label: '仅补充信息,按原计划继续' };
|
|
335
|
-
if (sure && experts.includes(choice)) return { action: 'ask', expert: choice, confidence, ...judged, label: `先请 ${choice} 回应这条留言` };
|
|
336
|
-
const lean = choice === NOTE_CHOICE ? '按原计划继续' : experts.includes(choice) ? `请 ${choice} 回应` : choice === HOST_CHOICE ? '由主持人处理' : '';
|
|
337
|
-
return { action: 'host', confidence, ...judged, label: choice === HOST_CHOICE && sure ? '这条留言需要主持人处理' : unsure_label(lean) };
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
export
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
: '
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const
|
|
401
|
-
{
|
|
402
|
-
|
|
403
|
-
{ value:
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
}
|
|
1
|
+
/** Optional smart scheduling: typed decisions from TypeSafe Jev or OpenAI Decisions, never generated text. */
|
|
2
|
+
import { api_status_message, read_saved_api_key } from './discussion_api.mjs';
|
|
3
|
+
|
|
4
|
+
/** Each provider keeps its official root; the key saved for that root is the only credential used. */
|
|
5
|
+
export const SCHEDULER_PROVIDERS = {
|
|
6
|
+
jev: { label: 'TypeSafe Jev', base_url: 'https://api.typesafe.ai/v1', path: '/systemone', models: ['jev-latest'] },
|
|
7
|
+
decisions: { label: 'OpenAI Decisions', base_url: 'https://api.openai.com/v1', path: '/decisions', models: ['gpt-6-luna'] },
|
|
8
|
+
};
|
|
9
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
10
|
+
/** Below this confidence a decision stays with the host, exactly as without scheduling. */
|
|
11
|
+
export const ROUTE_CONFIDENCE = 0.7;
|
|
12
|
+
/** Waking everyone or no one costs more when wrong (N runs, or an ignored message), so it needs more certainty. */
|
|
13
|
+
export const ROUTE_WIDE_CONFIDENCE = 0.85;
|
|
14
|
+
/**
|
|
15
|
+
* The guided wording gives lower probabilities for the same correct pick, so it has its own thresholds, set where the
|
|
16
|
+
* regression set gains correct picks without any new wrong one (2026-10-31). @全体成员 and review keep their own.
|
|
17
|
+
*/
|
|
18
|
+
export const GUIDED_CONFIDENCE = 0.6;
|
|
19
|
+
export const GUIDED_WIDE_CONFIDENCE = 0.8;
|
|
20
|
+
/** The single-member and the everyone-or-no-one thresholds of one wording. */
|
|
21
|
+
export function scheduler_thresholds(mode) {
|
|
22
|
+
return mode === 'stable' ? { member: ROUTE_CONFIDENCE, wide: ROUTE_WIDE_CONFIDENCE } : { member: GUIDED_CONFIDENCE, wide: GUIDED_WIDE_CONFIDENCE };
|
|
23
|
+
}
|
|
24
|
+
/** Bump when the routing question or its option descriptions change, so ratings can be compared across wordings. */
|
|
25
|
+
export const ROUTE_PROMPT_VERSION = 14;
|
|
26
|
+
/**
|
|
27
|
+
* How the free-room routing question is worded. guided (experimental, the default while it is being refined) states the
|
|
28
|
+
* purpose and the evidence in prose; stable keeps the ordered checklist that passed the regression set.
|
|
29
|
+
*/
|
|
30
|
+
export const SCHEDULER_MODES = { guided: '引导(试验)', stable: '稳定' };
|
|
31
|
+
export const DEFAULT_SCHEDULER_MODE = 'guided';
|
|
32
|
+
/** The guided wording has its own version, so its ratings are never mixed with the stable checklist's. */
|
|
33
|
+
export const ROUTE_GUIDED_PROMPT_VERSION = 9;
|
|
34
|
+
/** The routing prompt version that a decision in this mode was made with. */
|
|
35
|
+
export function route_prompt_version(mode) {
|
|
36
|
+
return mode === 'stable' ? ROUTE_PROMPT_VERSION : ROUTE_GUIDED_PROMPT_VERSION;
|
|
37
|
+
}
|
|
38
|
+
/** The same for the three discussion questions (interjection, next speaker, next round). */
|
|
39
|
+
export const DISCUSSION_PROMPT_VERSION = 5;
|
|
40
|
+
/** The guided wording of the discussion questions, versioned apart from the stable one. */
|
|
41
|
+
export const DISCUSSION_GUIDED_PROMPT_VERSION = 2;
|
|
42
|
+
/** The discussion prompt version that a decision in this mode was made with. */
|
|
43
|
+
export function discussion_prompt_version(mode) {
|
|
44
|
+
return mode === 'stable' ? DISCUSSION_PROMPT_VERSION : DISCUSSION_GUIDED_PROMPT_VERSION;
|
|
45
|
+
}
|
|
46
|
+
/** Completion of a preauthorized implementation batch is separate from routing and discussion. */
|
|
47
|
+
export const REVIEW_PROMPT_VERSION = 1;
|
|
48
|
+
export const REVIEW_CONFIDENCE = 0.85;
|
|
49
|
+
export const MAX_REVIEW_INPUT = 60000;
|
|
50
|
+
const HOST_CHOICE = '__host__';
|
|
51
|
+
const NOTE_CHOICE = '__note__';
|
|
52
|
+
const ALL_CHOICE = '__all__';
|
|
53
|
+
const ALL_SEQUENTIAL_CHOICE = '__all_seq__';
|
|
54
|
+
const NONE_CHOICE = '__none__';
|
|
55
|
+
/** Prefix of the "only these few" options; the value lists the names after it, joined by SOME_JOINER. */
|
|
56
|
+
export const SOME_PREFIX = '__some__:';
|
|
57
|
+
const SOME_JOINER = '、';
|
|
58
|
+
/** At most this many named members are combined, so the options stay few (4 names give 11 groups). */
|
|
59
|
+
const MAX_SOME_NAMES = 4;
|
|
60
|
+
const CONTEXT_MESSAGES = 10;
|
|
61
|
+
/** A free-room route sees a longer stretch, enough to tell who has not spoken for a while. */
|
|
62
|
+
export const ROUTE_CONTEXT_MESSAGES = 20;
|
|
63
|
+
const MAX_MESSAGE_CHARS = 600;
|
|
64
|
+
const MAX_BRIEF_CHARS = 1200;
|
|
65
|
+
const MAX_PROFILE_CHARS = 500;
|
|
66
|
+
/**
|
|
67
|
+
* What the room is and how its people talk, so a decision reads the user's words the way a member would.
|
|
68
|
+
* Shared by every scene built from topic_lines; bump both prompt versions when it changes.
|
|
69
|
+
*/
|
|
70
|
+
const ROOM_GUIDE = ['这是 AI CoDiscuss 的协作房间。用户是房主和需求方;其余成员都是 AI,各自运行在 Claude Code、Codex 等软件或模型 API 上,用户也称它们为专家或 AI。',
|
|
71
|
+
'其中一位是主持人,负责接收没有点名的消息并分派、协调,也可以自己回答;其他成员是专家,在各自的会话里工作并回复。',
|
|
72
|
+
'用户用 @名字 点名成员,用 @全体成员 请所有人回应。每唤醒一位成员就要花一次执行(数十秒);几位成员同时回答时彼此看不到对方的回答。',
|
|
73
|
+
'房间可以临时开启讨论:主持人按目标安排专家轮流发言,到达轮次或时长上限后回到自由协作。'].join('\n');
|
|
74
|
+
|
|
75
|
+
/** Normalize a launch's scheduler choice and prove its key exists for that provider's root. */
|
|
76
|
+
export function validate_scheduler(home, scheduler) {
|
|
77
|
+
const provider = SCHEDULER_PROVIDERS[scheduler?.provider];
|
|
78
|
+
if (!provider) throw new Error(`智能调度供应商只能为 ${Object.keys(SCHEDULER_PROVIDERS).join(' / ')}`);
|
|
79
|
+
if (!scheduler.model || typeof scheduler.model !== 'string') throw new Error('请填写智能调度模型');
|
|
80
|
+
if (scheduler.mode !== undefined && !SCHEDULER_MODES[scheduler.mode]) throw new Error(`智能调度模式只能为 ${Object.keys(SCHEDULER_MODES).join(' / ')}`);
|
|
81
|
+
const normalized = { provider: scheduler.provider, model: scheduler.model.trim(), base_url: provider.base_url, api_key_ref: scheduler.api_key_ref,
|
|
82
|
+
mode: scheduler.mode || DEFAULT_SCHEDULER_MODE };
|
|
83
|
+
read_saved_api_key(home, normalized.base_url, normalized.api_key_ref);
|
|
84
|
+
return normalized;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Convert neutral questions ({type: choice|predicate, name, instructions, choices}) to one provider's body. */
|
|
88
|
+
export function scheduler_request(scheduler, state, questions) {
|
|
89
|
+
const { path } = SCHEDULER_PROVIDERS[scheduler.provider];
|
|
90
|
+
if (scheduler.provider === 'jev') return { path, body: { model: scheduler.model, state, questions: Object.fromEntries(questions.map(question => [question.name,
|
|
91
|
+
question.type === 'choice'
|
|
92
|
+
? { type: 'choice', instructions: question.instructions, criteria: Object.fromEntries(question.choices.map(choice => [choice.value, choice.description])) }
|
|
93
|
+
: { type: 'noul', instructions: question.instructions }])) } };
|
|
94
|
+
return { path, body: { model: scheduler.model, input: state, questions: questions.map(question => question.type === 'choice'
|
|
95
|
+
? { type: 'choice', name: question.name, instructions: question.instructions, choices: question.choices.map(({ value, description }) => ({ value, description })) }
|
|
96
|
+
: { type: 'predicate', name: question.name, instructions: question.instructions }) } };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Read answers by question name: choices as {choice, confidence, probabilities}, predicates as {probability}. */
|
|
100
|
+
export function scheduler_answers(scheduler, data) {
|
|
101
|
+
if (scheduler.provider === 'jev') return Object.fromEntries(Object.entries(data?.answers || {}).map(([name, answer]) => [name,
|
|
102
|
+
answer.type === 'choice' ? { choice: answer.choice, confidence: answer.confidence, probabilities: answer.probabilities || {} } : { probability: answer.noul }]));
|
|
103
|
+
return Object.fromEntries((data?.answers || []).map(answer => [answer.name, answer.type === 'choice'
|
|
104
|
+
? { choice: answer.choice, confidence: answer.confidence, probabilities: Object.fromEntries((answer.probabilities || []).map(item => [item.value, item.probability])) }
|
|
105
|
+
: { probability: answer.probability }]));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Ask the configured provider once; errors carry only the HTTP status, never response bodies or keys. */
|
|
109
|
+
export async function decide(home, scheduler, state, questions, fetch_impl = fetch) {
|
|
110
|
+
const key = read_saved_api_key(home, scheduler.base_url, scheduler.api_key_ref);
|
|
111
|
+
const request = scheduler_request(scheduler, state, questions);
|
|
112
|
+
let response;
|
|
113
|
+
const started = Date.now();
|
|
114
|
+
try {
|
|
115
|
+
response = await fetch_impl(`${scheduler.base_url}${request.path}`, { method: 'POST', redirect: 'error',
|
|
116
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
|
117
|
+
body: JSON.stringify(request.body), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
118
|
+
} catch { throw new Error('智能调度请求超时或连接失败'); }
|
|
119
|
+
if (!response.ok) throw new Error(api_status_message(response.status));
|
|
120
|
+
const answers = scheduler_answers(scheduler, await response.json());
|
|
121
|
+
// Timing rides along without being an answer, so callers iterating answers by question name never see it.
|
|
122
|
+
Object.defineProperty(answers, 'elapsed_ms', { value: Date.now() - started });
|
|
123
|
+
return answers;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Send one tiny yes/no question so provider, model and key are checked together before a room relies on them. */
|
|
127
|
+
export async function scheduler_check(home, input, fetch_impl = fetch) {
|
|
128
|
+
const scheduler = validate_scheduler(home, input);
|
|
129
|
+
const answers = await decide(home, scheduler, '这是一条连通性检查消息。', [{ type: 'predicate', name: 'check', instructions: '这条内容是否是连通性检查?' }], fetch_impl);
|
|
130
|
+
if (typeof answers.check?.probability !== 'number') throw new Error('决策模型未返回可用答案,请核对供应商与模型');
|
|
131
|
+
return { ok: true };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** What was asked (the instruction and options only, never the room excerpts), what the provider chose and how long the call took. */
|
|
135
|
+
function detail_of(questions, answers) {
|
|
136
|
+
return questions.map(question => {
|
|
137
|
+
const answer = answers[question.name];
|
|
138
|
+
// A yes/no question is recorded as a two-way choice so the card reads it like any other.
|
|
139
|
+
if (question.type === 'predicate') {
|
|
140
|
+
const yes = typeof answer?.probability === 'number' ? answer.probability : null;
|
|
141
|
+
return { question: question.instructions, choices: ['是', '否'], choice: yes === null ? null : yes >= 0.5 ? '是' : '否',
|
|
142
|
+
probabilities: yes === null ? {} : { 是: yes, 否: Number((1 - yes).toFixed(4)) }, duration_ms: answers.elapsed_ms ?? null };
|
|
143
|
+
}
|
|
144
|
+
return { question: question.instructions, choices: question.choices.map(choice => choice.value),
|
|
145
|
+
choice: answer?.choice ?? null, probabilities: answer?.probabilities ?? {}, duration_ms: answers.elapsed_ms ?? null };
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The shown detail plus the exact input (state and full questions), kept so a rated-bad decision can be reviewed later. */
|
|
150
|
+
function judged_of(state, questions, answers) {
|
|
151
|
+
return { detail: detail_of(questions, answers), input: { state, questions } };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Who has and has not spoken this round, so a decision knows who the plan still owes a turn. */
|
|
155
|
+
function round_progress(spoken, waiting) {
|
|
156
|
+
return `本轮已发言:${spoken.join('、') || '无'};本轮尚未发言:${waiting.join('、') || '无'}。`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Say which option the provider leaned to when it was not sure enough, so the transcript shows how close it was. */
|
|
160
|
+
function unsure_label(lean) {
|
|
161
|
+
return lean ? `倾向${lean},把握不足,交给主持人` : '拿不准,交给主持人';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Shorten a message so recent context stays small; the decision never needs whole answers. */
|
|
165
|
+
function excerpt(message) {
|
|
166
|
+
const text = String(message.text || '').replace(/\s+/g, ' ').trim();
|
|
167
|
+
const reply = message.reply_to ? `(回复 #${message.reply_to})` : '';
|
|
168
|
+
return `#${message.id} ${message.author}${reply}:${text.length > MAX_MESSAGE_CHARS ? `${text.slice(0, MAX_MESSAGE_CHARS)}…` : text}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The room's purpose in the user's words, so a decision can tell drifting from progress. */
|
|
172
|
+
function topic_lines(title, goal, user_request, { guide = ROOM_GUIDE, roster, people, profiles, brief, focus, arrangement, progress, upcoming } = {}) {
|
|
173
|
+
return [guide, `房间:${title || ''}`, roster && `房间成员(名字不区分大小写):${roster}`, people, profile_lines(profiles), goal && `目标:${goal}`, user_request && `用户原文:${String(user_request).slice(0, MAX_MESSAGE_CHARS)}`,
|
|
174
|
+
focus && `当前焦点:${focus}`, brief && `主持简报(含分工与规则):${String(brief).slice(0, MAX_BRIEF_CHARS)}`,
|
|
175
|
+
arrangement && `主持人最近的安排:${arrangement}`, progress, upcoming && `接下来按安排发言:${upcoming.length ? upcoming.join(' → ') : '暂无,没有已安排的发言'}`].filter(Boolean).join('\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Guided discussion background: how a discussion runs and what each step costs, so the model weighs the situation
|
|
180
|
+
* instead of matching rules. Used by the three discussion questions in the guided mode.
|
|
181
|
+
*/
|
|
182
|
+
const DISCUSSION_GUIDED_GUIDE = ['这是 AI CoDiscuss 协作房间里临时开启的一场讨论。用户是房主,给出讨论目标;其余成员都是 AI,用户常称它们为专家。',
|
|
183
|
+
'主持人按目标安排专家轮流发言:每轮每位专家发言一次,后发言的人能看到前面所有发言;主持简报里记着分工、顺序和规则。到达轮次或时长上限、目标达成或用户结束时,回到自由协作。',
|
|
184
|
+
'每请一位专家发言或回答,都要花一次执行(通常几十秒)。用户在讨论中随时可能留言:补充约束、追问某人、调整方向,或者只是鼓励。'].join('\n');
|
|
185
|
+
/** The member line of the guided routing state: the shared facts plus the model and the last finished message. */
|
|
186
|
+
function guided_member_line(role, member) {
|
|
187
|
+
const extra = [member.model && `模型 ${member.model}`, member.finished && `最近完成 ${member.finished}`].filter(Boolean).join(';');
|
|
188
|
+
return `- ${role} ${extra ? String(member.description).replace(/)$/, `;${extra})`) : member.description}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The owner's descriptions of members, so a decision can weigh who is good at what. */
|
|
192
|
+
export function profile_lines(profiles) {
|
|
193
|
+
const lines = Object.entries(profiles || {}).filter(([, text]) => text).map(([name, text]) => `- ${name}:${String(text).slice(0, MAX_PROFILE_CHARS)}`);
|
|
194
|
+
return lines.length ? `用户对成员的描述(擅长与分工,供参考):\n${lines.join('\n')}` : '';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** State shared by both uses: the room, a few earlier messages, and the message to place. */
|
|
198
|
+
export function scheduler_state(title, context, message, goal = '', user_request = '', extra = {}, limit = CONTEXT_MESSAGES) {
|
|
199
|
+
const earlier = context.filter(item => item.id < message.id).slice(-limit).map(excerpt);
|
|
200
|
+
return [topic_lines(title, goal, user_request, extra), earlier.length ? `近期消息(旧→新):\n${earlier.join('\n')}` : '近期没有其他消息。', `待处理的用户消息:\n${excerpt(message)}`].join('\n\n');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* How sure the provider is of its pick: the chosen option's probability when given. Jev's separate `confidence`
|
|
205
|
+
* runs lower than that probability on two-way choices, so it is only the fallback.
|
|
206
|
+
*/
|
|
207
|
+
export function certainty(answer) {
|
|
208
|
+
const probability = answer?.probabilities?.[answer.choice];
|
|
209
|
+
return typeof probability === 'number' ? probability : typeof answer?.confidence === 'number' ? answer.confidence : null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Stable routing wording: an ordered checklist of evidence, each item settling the addressee before the next is read. */
|
|
213
|
+
const ROUTE_STABLE_STEM = '这条用户消息没有用 @ 点名成员。判断它的回应对象,按下面的顺序:1)正文叫了某位成员的名字或用其他说法指代他(对照房间成员名单和前文只可能是他),或在向其提问(包括“X在吗”“X还在吗”“X你怎么看”),选该成员,即使上一条是别人的回答;同时提到几位成员时,选被直接称呼、要求其回应或做事的那位(如“A 你让 B 去改”是在对 A 说);2)正文没有指向任何人时,看它是否在延续某位成员的回答(追问、要求修改、引用,或“回复”指向其消息),或在补充、纠正一条正由某位成员处理的消息,是则选该成员,是主持人就选主持人;3)查看各成员的用户描述:已经明确由唯一一位成员负责这类任务时,直接选该成员,不需要再次点名;仅有擅长、多人分工重叠或任务还需要拆分时交主持人;4)符合“全体”选项的条件时选全体;要求成员之间讨论或互相回应的不算,按第 7 条处理;5)单独的问候或闲聊(如“hi”)既没有指向某位成员,也没有要求大家一起回应,选主持人;6)只是确认、致谢或旁注选无需回应;7)对象不明确,或要讨论、互相回应、分工、排先后时选主持人,由主持人安排。';
|
|
214
|
+
/**
|
|
215
|
+
* Guided routing: instead of rules, the model gets what a member of the room would know (who is who, what each is busy
|
|
216
|
+
* with, what each choice leads to) and the purpose of this step, and weighs the message itself.
|
|
217
|
+
*/
|
|
218
|
+
const ROUTE_GUIDED_GUIDE = ['这是 AI CoDiscuss 的协作房间。用户是房主,也是提出需求的人;其余成员都是 AI,各自运行在 Claude Code、Codex 等软件或模型 API 上,用户常称它们为专家。',
|
|
219
|
+
'主持人是其中一位:平时接收没人点名的消息,自己回答或分派给别人,也负责协调需要几个人配合的事。专家们在各自的会话里工作,回复都发在房间里,所有人都看得到。',
|
|
220
|
+
'用户找某人时通常会 @ 他,但聊顺了也常直接说话:叫名字、换个说法指代,或者接着刚才的话往下说。用户也可能事先在成员描述里交代过谁负责什么。',
|
|
221
|
+
'唤醒一位成员要花一次执行,通常几十秒;交给了不对的人,就得打断后重来。'].join('\n');
|
|
222
|
+
const ROUTE_GUIDED_STEM = '用户发来上面这条消息,没有 @ 任何人。请你像熟悉这个房间的成员一样读懂它:用户想让谁来回应?'
|
|
223
|
+
+ '用户心里已经有明确的对象时,直接交给对方,省掉主持人转一手的时间;需要有人先理解、拆分或协调时交给主持人。'
|
|
224
|
+
+ '用户想听每个人各自说一句时,交给全体能让大家同时开始,比主持人逐个点名快得多;但大家看不到彼此的回答。每人只需按顺序接着前一位说一次时,交给全体依次回应;需要互相接话、讨论的事仍要主持人安排。用户只是表示收到或感谢时,不必唤醒任何人。'
|
|
225
|
+
+ '两种容易看错的情况:向在场的各位问好,是在等每个人回一声,和只对房间说一句“hi”不同;用户问“谁是……”“谁能……”时是在找符合条件的人,只需要符合的人出来回应(哪怕说“自己说一下”),成员信息里(含主持人)只有一位符合就交给他。';
|
|
226
|
+
|
|
227
|
+
/** The routing question in one mode: option texts, the stem, and what the state adds about the members. */
|
|
228
|
+
/** Every group of two or more among the members the text names, so "these few each answer" is one choice. */
|
|
229
|
+
function named_groups(text, host, members) {
|
|
230
|
+
const lower = String(text || '').toLowerCase();
|
|
231
|
+
const named = [host, ...members].map(member => member.name).filter(name => lower.includes(name.toLowerCase()));
|
|
232
|
+
if (named.length < 2 || named.length > MAX_SOME_NAMES) return [];
|
|
233
|
+
const groups = [];
|
|
234
|
+
for (let mask = 1; mask < 1 << named.length; mask += 1) {
|
|
235
|
+
const group = named.filter((_, index) => mask & (1 << index));
|
|
236
|
+
if (group.length >= 2) groups.push(group);
|
|
237
|
+
}
|
|
238
|
+
return groups;
|
|
239
|
+
}
|
|
240
|
+
/** The members of a "these few" choice, or null for any other value. */
|
|
241
|
+
export function some_members(choice) {
|
|
242
|
+
return typeof choice === 'string' && choice.startsWith(SOME_PREFIX) ? choice.slice(SOME_PREFIX.length).split(SOME_JOINER) : null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function route_question(mode, host, members, text) {
|
|
246
|
+
if (mode === 'stable') return { extra: { roster: [`${host.name}(主持人)`, ...members.map(member => `${member.name}(专家)`)].join('、') },
|
|
247
|
+
instructions: ROUTE_STABLE_STEM, choices: [...members.map(member => ({ value: member.name, description: member.description })),
|
|
248
|
+
{ value: HOST_CHOICE, description: `主持人 ${host.description}:没有指向任何人且用户没有确定负责人(如单独一句“hi”“你好”这类问候、闲聊,由主持人代表房间回应),或诉求模糊、复杂,需要主持人判断怎么安排` },
|
|
249
|
+
{ value: ALL_CHOICE, description: '全体成员(含主持人)各自回应一次,彼此不需要看到别人的回答;只在要求简单明确、每人独立回应一次就算完成时选,如“大家打个招呼”“各自报一下在用的模型”' },
|
|
250
|
+
{ value: NONE_CHOICE, description: '无需回应:只是确认、致谢或旁注,不需要任何人回答;选它则用户这句话不会得到回应' }] };
|
|
251
|
+
const people = ['成员(名字不区分大小写):', guided_member_line('主持人', host), ...members.map(member => guided_member_line('专家', member))].join('\n');
|
|
252
|
+
return { extra: { guide: ROUTE_GUIDED_GUIDE, people }, instructions: ROUTE_GUIDED_STEM,
|
|
253
|
+
choices: [...members.map(member => ({ value: member.name, description: `交给 ${member.name} 回应` })),
|
|
254
|
+
{ value: HOST_CHOICE, description: `交给主持人 ${host.name}:由他回应,或由他安排别人` },
|
|
255
|
+
...named_groups(text, host, members).map(group => ({ value: `${SOME_PREFIX}${group.join(SOME_JOINER)}`,
|
|
256
|
+
description: `只请 ${group.join('、')} 各自回应一次:同时开始,每人都收到这句原话,彼此看不到对方的回答,其他成员不被唤醒` })),
|
|
257
|
+
{ value: ALL_CHOICE, description: '全体成员(含主持人)同时收到这句原话,各自回应一次,彼此看不到对方的回答,也不会被分到不同的任务' },
|
|
258
|
+
{ value: ALL_SEQUENTIAL_CHOICE, description: '全体成员(含主持人)按房间发言顺序逐个回应这句原话,每人一次:前一位答完下一位才开始,后一位能看到前面所有人的回答;比同时回应慢,也不会被分到不同的任务' },
|
|
259
|
+
{ value: NONE_CHOICE, description: '不唤醒任何人,用户这句话不会得到回应' }] };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Decide who answers one unnamed free-room message: whether its addressee is already settled by the text or the thread.
|
|
264
|
+
* Members are [{name, description}] without the host; host is {name, description}. Returns action member (with member),
|
|
265
|
+
* all, sequential (guided only), some, none, or host for anything unsettled or not confident enough.
|
|
266
|
+
*/
|
|
267
|
+
export async function route_free_message(home, scheduler, { title, goal, context, message, host, members }, fetch_impl) {
|
|
268
|
+
const { choices, instructions, extra } = route_question(scheduler.mode || DEFAULT_SCHEDULER_MODE, host, members, message.text);
|
|
269
|
+
const questions = [{ type: 'choice', name: 'handler', choices, instructions }];
|
|
270
|
+
const state = scheduler_state(title, context, message, goal, '', extra, ROUTE_CONTEXT_MESSAGES);
|
|
271
|
+
const answers = await decide(home, scheduler, state, questions, fetch_impl);
|
|
272
|
+
const judged = judged_of(state, questions, answers);
|
|
273
|
+
const choice = answers.handler?.choice ?? null;
|
|
274
|
+
const confidence = certainty(answers.handler);
|
|
275
|
+
// Waking several members, everyone or no one costs more when wrong than moving the message to one member.
|
|
276
|
+
const group = some_members(choice);
|
|
277
|
+
const wide = [ALL_CHOICE, ALL_SEQUENTIAL_CHOICE, NONE_CHOICE].includes(choice) || Boolean(group);
|
|
278
|
+
const limits = scheduler_thresholds(scheduler.mode || DEFAULT_SCHEDULER_MODE);
|
|
279
|
+
const sure = confidence !== null && confidence >= (wide ? limits.wide : limits.member);
|
|
280
|
+
if (sure && choice === ALL_CHOICE) return { action: 'all', choice, confidence, ...judged };
|
|
281
|
+
if (sure && choice === ALL_SEQUENTIAL_CHOICE) return { action: 'sequential', choice, confidence, ...judged };
|
|
282
|
+
if (sure && choice === NONE_CHOICE) return { action: 'none', choice, confidence, ...judged };
|
|
283
|
+
if (sure && group) return { action: 'some', members: group, choice, confidence, ...judged };
|
|
284
|
+
if (sure && members.some(item => item.name === choice)) return { action: 'member', member: choice, choice, confidence, ...judged };
|
|
285
|
+
return { action: 'host', choice, confidence, ...judged };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The user named everyone (@全体成员): decide whether they answer at once (parallel) or one after another (sequential).
|
|
290
|
+
* Both automatic actions need confidence; unclear division of work or a weak answer returns control to the host.
|
|
291
|
+
*/
|
|
292
|
+
export async function decide_group_order(home, scheduler, { title, goal, context, message, members, descriptions = [] }, fetch_impl) {
|
|
293
|
+
const questions = [{ type: 'choice', name: 'order', choices: [
|
|
294
|
+
{ value: 'parallel', description: '同时回答:各自独立回应即可,不需要看到别人的回答,如打招呼、各自介绍、各说一句看法、各自投票' },
|
|
295
|
+
{ value: 'sequential', description: `依次回答:后发言的需要看到前面的回答,如接龙、轮流补充或反驳、逐个评审上一位的结果、按顺序分工接力;按房间发言顺序(${members.join('、')})逐个回答` },
|
|
296
|
+
{ value: HOST_CHOICE, description: '交给主持人:任务或分工还不清楚,需要先澄清、分配范围或协调;仅把大家排成依次回答不能解决这些问题' }],
|
|
297
|
+
instructions: '用户用 @全体成员 请房间里所有成员回应这条消息。根据任务是否已明确以及成员间的依赖,判断应同时各自回答、按既定顺序依次回答(后一位看到前一位的回答再接着说),还是先由主持人安排。' }];
|
|
298
|
+
const facts = descriptions.length ? `成员的软件、项目权限与当前任务:\n${descriptions.map(member => `- ${member.description}`).join('\n')}` : '';
|
|
299
|
+
const state = [scheduler_state(title, context, message, goal, '', {}, ROUTE_CONTEXT_MESSAGES), facts].filter(Boolean).join('\n\n');
|
|
300
|
+
const answers = await decide(home, scheduler, state, questions, fetch_impl);
|
|
301
|
+
const confidence = certainty(answers.order);
|
|
302
|
+
const choice = answers.order?.choice ?? null;
|
|
303
|
+
const action = confidence !== null && confidence >= ROUTE_CONFIDENCE ? { parallel: 'all', sequential: 'sequential' }[choice] || 'host' : 'host';
|
|
304
|
+
return { action, choice, confidence, ...judged_of(state, questions, answers) };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Decide one interjection in a discussion: continue the plan (note), ask one expert (ask) or wake the host (host).
|
|
309
|
+
* Anything but a confident note or ask is returned as host.
|
|
310
|
+
*/
|
|
311
|
+
export async function decide_interjection(home, scheduler, { title, goal, user_request, context, message, host, experts, spoken = [], upcoming = [], ...extra }, fetch_impl) {
|
|
312
|
+
const guided = (scheduler.mode || DEFAULT_SCHEDULER_MODE) !== 'stable';
|
|
313
|
+
// Ordinary asks do not consume a discussion turn; all present members are eligible.
|
|
314
|
+
const choices = guided
|
|
315
|
+
? [...experts.map(name => ({ value: name, description: `现在单独请 ${name} 回应这条留言:不占讨论轮次,多花一次执行` })),
|
|
316
|
+
{ value: HOST_CHOICE, description: `交给主持人 ${host} 处理` },
|
|
317
|
+
{ value: NOTE_CHOICE, description: '按原安排继续:讨论照常推进、不会暂停,留言留在房间里,后面发言的专家都会看到并可在发言中回应' }]
|
|
318
|
+
: [...experts.map(name => ({ value: name, description: `请 ${name} 回应这条留言:留言是向该专家(或其负责的内容)提问、质疑或补充要求` })),
|
|
319
|
+
{ value: HOST_CHOICE, description: `由主持人 ${host} 处理:留言要改变讨论目标、规则、分工、安排或结束讨论,同时涉及多位专家,或针对的是不在选项里的人` },
|
|
320
|
+
{ value: NOTE_CHOICE, description: '仅补充信息:留言补充背景、约束或偏好,或只是旁注、鼓励,没有要求某人立即回应;后续发言的专家都能看到它,按当前安排继续' }];
|
|
321
|
+
const instructions = guided
|
|
322
|
+
? '讨论进行中,用户发来上面这条留言。请你像熟悉这场讨论的人一样读懂它,选出最合适的处理方式。'
|
|
323
|
+
+ '用户向某位专家要回应时,现在单独请他回答能马上得到答复,但要多花一次执行;如果他就在接下来要发言的人里,留到他发言时一并回应更省,也不打乱讨论。'
|
|
324
|
+
+ '补充的约束、偏好和鼓励,后面发言的人都看得到。要暂停或结束讨论、改目标、规则、分工或安排,或需要几位专家协调的,由主持人处理。'
|
|
325
|
+
: '讨论进行中用户发来这条留言。对照当前焦点、主持简报和安排,判断它最适合怎样处理。';
|
|
326
|
+
const questions = [{ type: 'choice', name: 'handling', instructions, choices }];
|
|
327
|
+
const state = scheduler_state(title, context, message, goal, user_request, { ...extra, progress: round_progress(spoken, experts.filter(name => !spoken.includes(name))),
|
|
328
|
+
...(guided ? { guide: DISCUSSION_GUIDED_GUIDE, upcoming } : {}) });
|
|
329
|
+
const answers = await decide(home, scheduler, state, questions, fetch_impl);
|
|
330
|
+
const judged = judged_of(state, questions, answers);
|
|
331
|
+
const choice = answers.handling?.choice;
|
|
332
|
+
const confidence = certainty(answers.handling);
|
|
333
|
+
const sure = confidence !== null && confidence >= scheduler_thresholds(scheduler.mode || DEFAULT_SCHEDULER_MODE).member;
|
|
334
|
+
if (sure && choice === NOTE_CHOICE) return { action: 'note', confidence, ...judged, label: '仅补充信息,按原计划继续' };
|
|
335
|
+
if (sure && experts.includes(choice)) return { action: 'ask', expert: choice, confidence, ...judged, label: `先请 ${choice} 回应这条留言` };
|
|
336
|
+
const lean = choice === NOTE_CHOICE ? '按原计划继续' : experts.includes(choice) ? `请 ${choice} 回应` : choice === HOST_CHOICE ? '由主持人处理' : '';
|
|
337
|
+
return { action: 'host', confidence, ...judged, label: choice === HOST_CHOICE && sure ? '这条留言需要主持人处理' : unsure_label(lean) };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Scene 7 wording version; it is the same in both modes, like the @everyone and review questions. */
|
|
341
|
+
export const GOAL_PROMPT_VERSION = 1;
|
|
342
|
+
const GOAL_WAIT = 'wait';
|
|
343
|
+
const GOAL_CONTINUE = 'continue';
|
|
344
|
+
// What a member of the room would know about a goal: who drives it and what an unneeded wake-up costs.
|
|
345
|
+
const GOAL_GUIDE = ['这是 AI CoDiscuss 的协作房间,用户开启了 Goal:主持人要持续推进下面的目标,直到达成或卡住。其余成员都是 AI,用户常称它们为专家。',
|
|
346
|
+
'主持人把工作派给专家,专家做完后房间会空下来。房间空闲时,程序可以再唤醒主持人一次,让他看结果并决定下一步;唤醒一次要花一次执行,通常几十秒。',
|
|
347
|
+
'如果最近的回答已经在等用户(向用户提了问题、需要用户授权或拍板,或已经向用户汇报完成),唤醒主持人也只能再等,不如安静等用户回复。'].join('\n');
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Scene 7: the goal's room went idle. Decide whether waking the host would move the goal forward, or whether the room is
|
|
351
|
+
* waiting for the user. Only a confident "waiting" saves the wake-up; anything else wakes the host, as without a model.
|
|
352
|
+
*/
|
|
353
|
+
export async function decide_goal_wake(home, scheduler, { title, objective, context, host, members = [] }, fetch_impl) {
|
|
354
|
+
const questions = [{ type: 'choice', name: 'wake', choices: [
|
|
355
|
+
{ value: GOAL_CONTINUE, description: `唤醒主持人 ${host} 接着推进:最近的结果还需要主持人处理,比如交付要核对、阻止或失败要修、下一步要派活,或者目标还没完成` },
|
|
356
|
+
{ value: GOAL_WAIT, description: '不唤醒,等用户回复:最近的回答已经向用户提问、请用户授权或拍板,或已经向用户汇报目标完成;用户这句话回来之前,主持人做不了更多' }],
|
|
357
|
+
instructions: '目标进行中,房间刚空闲下来。读最近的消息,判断现在唤醒主持人能不能让目标往前走,还是房间在等用户。' }];
|
|
358
|
+
const people = members.length ? `成员:\n${members.map(member => `- ${member.role === 'host' ? '主持人' : '专家'} ${member.description}`).join('\n')}` : '';
|
|
359
|
+
const recent = context.slice(-ROUTE_CONTEXT_MESSAGES).map(excerpt);
|
|
360
|
+
const state = [topic_lines(title, objective, '', { guide: GOAL_GUIDE, people }),
|
|
361
|
+
recent.length ? `近期消息(旧→新):\n${recent.join('\n')}` : '近期没有其他消息。'].join('\n\n');
|
|
362
|
+
const answers = await decide(home, scheduler, state, questions, fetch_impl);
|
|
363
|
+
const choice = answers.wake?.choice ?? null;
|
|
364
|
+
const confidence = certainty(answers.wake);
|
|
365
|
+
const wait = choice === GOAL_WAIT && confidence !== null && confidence >= ROUTE_CONFIDENCE;
|
|
366
|
+
return { action: wait ? 'wait' : 'continue', choice, confidence, ...judged_of(state, questions, answers),
|
|
367
|
+
label: wait ? 'Goal:房间在等用户回复,不唤醒主持人' : choice === GOAL_CONTINUE ? 'Goal:唤醒主持人接着推进' : unsure_label('等用户回复').replace('交给主持人', '唤醒主持人') };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Judge only whether full delivery evidence satisfies the already-authorized review handoff. */
|
|
371
|
+
export async function decide_review_completion(home, scheduler, ready, fetch_impl) {
|
|
372
|
+
const fallback = reason => ({ action: 'host', confidence: null, label: reason });
|
|
373
|
+
if (!ready.ready) return fallback(ready.reason || '评审推进条件未满足,交给主持人');
|
|
374
|
+
if (!ready.original?.text?.trim() || !ready.deliveries?.length
|
|
375
|
+
|| ready.deliveries.some(item => item.status !== 'completed' || !item.answer?.trim())) return fallback('缺少完整交付原文,交给主持人');
|
|
376
|
+
const questions = [{ type: 'choice', name: 'completion',
|
|
377
|
+
instructions: '主持人已指定下一步只读评审者和范围。根据原委派、每位实现者的完整交付原文与执行报告,判断本批是否已经完成所要求的交付、可以进入既定评审。只判断交付完成度,不判断代码正确性,不选择评审者,不改变计划。', choices: [
|
|
378
|
+
{ value: 'review', description: '按既定计划交评审:原委派要求的交付和明确要求的验证均已完成,没有仍待实现、阻塞或需要澄清的任务;代码是否正确由评审者判断' },
|
|
379
|
+
{ value: HOST_CHOICE, description: '交给主持人:交付仅部分完成、明确要求的验证尚未完成或失败、有阻塞或待澄清事项,或原文不足以确认已完成;笼统声称完成不能覆盖具体的未完成事项' }] }];
|
|
380
|
+
const state = JSON.stringify({ original: ready.original, deliveries: ready.deliveries,
|
|
381
|
+
authorized_review: { reviewer: ready.plan.reviewer_name, scope: ready.plan.scope } });
|
|
382
|
+
if (JSON.stringify({ state, questions }).length > MAX_REVIEW_INPUT) return fallback('完整评审判断输入超过 60000 字符,交给主持人');
|
|
383
|
+
const answers = await decide(home, scheduler, state, questions, fetch_impl);
|
|
384
|
+
const confidence = certainty(answers.completion), choice = answers.completion?.choice ?? null;
|
|
385
|
+
const advance = choice === 'review' && confidence !== null && confidence >= REVIEW_CONFIDENCE;
|
|
386
|
+
return { action: advance ? 'review' : 'host', choice, confidence, ...judged_of(state, questions, answers),
|
|
387
|
+
label: advance ? '交付已完成,按既定计划交只读评审' : '交付完成度不足或把握不足,交给主持人' };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Mid-round with no queued turn left: pick the next speaker among the experts who can be woken and have not spoken
|
|
392
|
+
* this round, or hand back to the host when unsure or when the discussion needs steering.
|
|
393
|
+
*/
|
|
394
|
+
export async function decide_next_speaker(home, scheduler, { title, goal, user_request, context, host, candidates, spoken = [], ...extra }, fetch_impl) {
|
|
395
|
+
const guided = (scheduler.mode || DEFAULT_SCHEDULER_MODE) !== 'stable';
|
|
396
|
+
const recent = context.slice(-CONTEXT_MESSAGES).map(excerpt);
|
|
397
|
+
const state = [topic_lines(title, goal, user_request, { ...extra, progress: round_progress(spoken, candidates), ...(guided ? { guide: DISCUSSION_GUIDED_GUIDE } : {}) }),
|
|
398
|
+
recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
|
|
399
|
+
// 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.
|
|
400
|
+
const choices = guided
|
|
401
|
+
? [...candidates.map(name => ({ value: name, description: `请 ${name} 接着发言` })), { value: HOST_CHOICE, description: `交给主持人 ${host} 重新安排` }]
|
|
402
|
+
: [...candidates.map(name => ({ value: name, description: `请 ${name} 接着发言:简报的分工或流程规定轮到 ${name},或最近的发言点名追问了 ${name}` })),
|
|
403
|
+
{ value: HOST_CHOICE, description: `交给主持人 ${host}:简报和最近发言都没有明确规定下一位(仅仅是本轮还没发言不算依据),或需要主持人重新安排` }];
|
|
404
|
+
const instructions = guided
|
|
405
|
+
? '主持人预排的发言安排已经用完或被取消。主持人有时有意只安排到这里,有时只是还没排下一位。请判断下一位该请谁发言:'
|
|
406
|
+
+ '简报的分工、顺序,或最近有人被点名追问,都说明下一位已经明确;看不出来就交给主持人重新安排,请错人会白花一次执行,还可能违背主持人的安排。'
|
|
407
|
+
: '主持人预排的安排已用完或被取消,主持人可能有意只安排到这里。对照主持简报、当前焦点和主持人最近的安排,判断下一位应请谁发言。';
|
|
408
|
+
const questions = [{ type: 'choice', name: 'speaker', instructions, choices }];
|
|
409
|
+
const answers = await decide(home, scheduler, state, questions, fetch_impl);
|
|
410
|
+
const judged = judged_of(state, questions, answers);
|
|
411
|
+
const choice = answers.speaker?.choice;
|
|
412
|
+
const confidence = certainty(answers.speaker);
|
|
413
|
+
const sure = confidence !== null && confidence >= scheduler_thresholds(scheduler.mode || DEFAULT_SCHEDULER_MODE).member;
|
|
414
|
+
if (sure && candidates.includes(choice)) return { action: 'next', expert: choice, confidence, ...judged, label: `本轮继续,请 ${choice} 接着发言` };
|
|
415
|
+
return { action: 'host', confidence, ...judged, label: choice === HOST_CHOICE && sure ? '没有明确该谁发言,交给主持人安排' : unsure_label(candidates.includes(choice) ? `请 ${choice} 发言` : choice === HOST_CHOICE ? '交给主持人' : '') };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* At the end of a round without named follow-ups: continue with another peer round, or wake the host
|
|
420
|
+
* (to conclude, or because the discussion drifted, repeats itself or is stuck). The first speaker is never the last one.
|
|
421
|
+
*/
|
|
422
|
+
export async function decide_round(home, scheduler, { title, goal, user_request, context, host, experts, last_speaker, ...extra }, fetch_impl) {
|
|
423
|
+
const candidates = experts.filter(name => name !== last_speaker);
|
|
424
|
+
if (!candidates.length) return { action: 'host', confidence: null, label: '没有可选的下一轮首位,交给主持人' };
|
|
425
|
+
// Telling repetition or drift needs at least the last two rounds side by side.
|
|
426
|
+
const guided = (scheduler.mode || DEFAULT_SCHEDULER_MODE) !== 'stable';
|
|
427
|
+
const recent = context.slice(-ROUTE_CONTEXT_MESSAGES).map(excerpt);
|
|
428
|
+
const state = [topic_lines(title, goal, user_request, guided ? { ...extra, guide: DISCUSSION_GUIDED_GUIDE } : extra),
|
|
429
|
+
recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
|
|
430
|
+
const questions = guided ? [
|
|
431
|
+
{ type: 'choice', name: 'next', instructions: '一轮发言刚结束。请判断讨论值不值得马上再进行一轮:还有没回应的分歧、追问,或简报规定的流程没走完时,再来一轮能推进讨论;'
|
|
432
|
+
+ '已经达成一致、在重复、偏离目标或卡住时,多一轮只是让每位专家白花一次执行,应交给主持人收束或引导。', choices: [
|
|
433
|
+
{ value: 'continue', description: '直接开始下一轮,每位专家再发言一次' },
|
|
434
|
+
{ value: HOST_CHOICE, description: `交给主持人 ${host} 收束或引导` }] },
|
|
435
|
+
{ type: 'choice', name: 'first', instructions: '如果开始下一轮,先请谁发言?简报规定了顺序,或有人被点名追问、需要先回应时,先请他;看不出依据就交给主持人决定。',
|
|
436
|
+
choices: [...candidates.map(name => ({ value: name, description: `先请 ${name}` })), { value: HOST_CHOICE, description: `交给主持人 ${host} 决定首位` }] }] : [
|
|
437
|
+
{ type: 'choice', name: 'next', instructions: '一轮发言刚结束。对照讨论目标、主持简报与当前焦点判断:下一轮是否还有明确的内容可以让大家互相回应、追问或补充?', choices: [
|
|
438
|
+
{ value: 'continue', description: '继续下一轮:最近发言里有未回应的分歧、追问或可展开的点,或简报规定的流程还没走完' },
|
|
439
|
+
{ value: HOST_CHOICE, description: `交给主持人 ${host}:目标已经达成可以收束,或最近发言在重复、偏离目标、卡住,需要主持人引导` }] },
|
|
440
|
+
{ type: 'choice', name: 'first', instructions: '下一轮应先请谁发言?按简报分工或接龙顺序判断;没有规定时选被其他专家点名追问、需要回应的人;没有明确依据时交主持人安排。',
|
|
441
|
+
choices: [...candidates.map(name => ({ value: name, description: `先请 ${name}:简报规定下一轮由其开头,或最近发言点名追问了该成员` })),
|
|
442
|
+
{ value: HOST_CHOICE, description: `交给主持人 ${host}:没有明确规定首位,也没有点名追问;仅仅有未决分歧不足以确定先请谁` }] }];
|
|
443
|
+
const answers = await decide(home, scheduler, state, questions, fetch_impl);
|
|
444
|
+
const judged = judged_of(state, questions, answers);
|
|
445
|
+
const choice = answers.next?.choice;
|
|
446
|
+
const confidence = certainty(answers.next);
|
|
447
|
+
// A wrong "continue" costs a whole round of runs, so it needs the same certainty as waking everyone.
|
|
448
|
+
const limits = scheduler_thresholds(scheduler.mode || DEFAULT_SCHEDULER_MODE);
|
|
449
|
+
if (choice !== 'continue' || confidence === null || confidence < limits.wide) {
|
|
450
|
+
const sure = confidence !== null && confidence >= (choice === 'continue' ? limits.wide : limits.member);
|
|
451
|
+
return { action: 'host', confidence, ...judged, label: choice === HOST_CHOICE && sure ? '建议收束或调整方向,交给主持人' : unsure_label(choice === 'continue' ? '继续下一轮' : choice === HOST_CHOICE ? '收束或调整方向' : '') };
|
|
452
|
+
}
|
|
453
|
+
const first = answers.first?.choice;
|
|
454
|
+
const first_confidence = certainty(answers.first);
|
|
455
|
+
if (!candidates.includes(first) || first_confidence === null || first_confidence < limits.member) {
|
|
456
|
+
return { action: 'host', confidence: first_confidence, ...judged, label: '下一轮首位没有明确依据或把握不足,交给主持人安排' };
|
|
457
|
+
}
|
|
458
|
+
return { action: 'continue', first, confidence, ...judged, label: `继续下一轮,先请 ${first} 发言` };
|
|
459
|
+
}
|