ai_co_discuss 0.3.12 → 0.3.14
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/backend/free_room.py +28 -2
- package/docs/runner.md +1 -1
- package/package.json +1 -1
- package/runner/discussion_launch.mjs +3 -3
- package/runner/free_runner.mjs +15 -9
- package/runner/scheduler.mjs +171 -39
- package/runner/session_driver.mjs +7 -4
- package/web/app.js +10 -5
- package/web/modern.css +1 -1
- package/web/modern.js +1573 -1430
package/backend/free_room.py
CHANGED
|
@@ -1727,6 +1727,8 @@ def route_wide(connection, room_id, message_id, payload):
|
|
|
1727
1727
|
return route_group(connection, room_id, group, payload)
|
|
1728
1728
|
if action in ("sequential", "host"):
|
|
1729
1729
|
raise ValueError("只有请全体成员回应的消息可以依次回答或交回主持人")
|
|
1730
|
+
if action not in ("all", "none", "some"):
|
|
1731
|
+
raise ValueError("未点名消息只能选 action=all、some 或 none")
|
|
1730
1732
|
route = host_route_request(connection, message_id)
|
|
1731
1733
|
if not route or route["room_id"] != room_id:
|
|
1732
1734
|
raise LookupError("只能处理交给主持人的未点名消息")
|
|
@@ -1735,6 +1737,8 @@ def route_wide(connection, room_id, message_id, payload):
|
|
|
1735
1737
|
if action == "none":
|
|
1736
1738
|
record_cancel_reasons(connection, "id=?", (route["id"],), "no_reply")
|
|
1737
1739
|
connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?", (route["id"],))
|
|
1740
|
+
elif action == "some":
|
|
1741
|
+
route_some(connection, room_id, route, payload.get("members"))
|
|
1738
1742
|
else:
|
|
1739
1743
|
# The host keeps its row (one request per message and member) and now answers its own share, without dispatching.
|
|
1740
1744
|
connection.execute("UPDATE member_requests SET purpose='all' WHERE id=?", (route["id"],))
|
|
@@ -1745,6 +1749,28 @@ def route_wide(connection, room_id, message_id, payload):
|
|
|
1745
1749
|
return {"action": action, "requests": [dict(row) for row in requests]}
|
|
1746
1750
|
|
|
1747
1751
|
|
|
1752
|
+
def route_some(connection, room_id, route, names):
|
|
1753
|
+
"""Smart scheduling answers an unnamed message with the few members it names, each once, as if the user named them."""
|
|
1754
|
+
if not isinstance(names, list) or not all(isinstance(name, str) for name in names) or len(set(names)) < 2:
|
|
1755
|
+
raise ValueError("action=some 须带 members:至少两位不同的成员名")
|
|
1756
|
+
found = {row["name"]: row["id"] for row in connection.execute(
|
|
1757
|
+
f"SELECT id,name FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER}", (room_id,))}
|
|
1758
|
+
missing = [name for name in dict.fromkeys(names) if name not in found]
|
|
1759
|
+
if missing:
|
|
1760
|
+
raise FileExistsError(f"这些成员不在场或未注册:{'、'.join(missing)}")
|
|
1761
|
+
ids = [found[name] for name in dict.fromkeys(names)]
|
|
1762
|
+
if route["member_id"] in ids:
|
|
1763
|
+
# The host is one of them: it keeps its row and answers its own share, without dispatching.
|
|
1764
|
+
connection.execute("UPDATE member_requests SET purpose='all' WHERE id=?", (route["id"],))
|
|
1765
|
+
else:
|
|
1766
|
+
record_cancel_reasons(connection, "id=?", (route["id"],), "rerouted")
|
|
1767
|
+
connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?", (route["id"],))
|
|
1768
|
+
for member_id in ids:
|
|
1769
|
+
if member_id != route["member_id"]:
|
|
1770
|
+
connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
|
|
1771
|
+
VALUES (?,?,?,?,'all','user',?)""", (room_id, member_id, route["message_id"], route["message_id"], int(time.time())))
|
|
1772
|
+
|
|
1773
|
+
|
|
1748
1774
|
def adopt_recommendation(connection, room_id, token, message_id, payload):
|
|
1749
1775
|
"""The user adopts a recommendation before it expires: interrupt the host now, or ask the member after the host.
|
|
1750
1776
|
|
|
@@ -1752,10 +1778,10 @@ def adopt_recommendation(connection, room_id, token, message_id, payload):
|
|
|
1752
1778
|
"""
|
|
1753
1779
|
require_owner(connection, room_id, token)
|
|
1754
1780
|
begin_write(connection)
|
|
1755
|
-
if payload.get("action") in ("all", "none", "sequential", "host"):
|
|
1781
|
+
if payload.get("action") in ("all", "none", "sequential", "host", "some"):
|
|
1756
1782
|
return route_wide(connection, room_id, message_id, payload)
|
|
1757
1783
|
if payload.get("action") != "adopt" or payload.get("mode") not in ("interrupt", "after"):
|
|
1758
|
-
raise ValueError("采纳须为 action=adopt,mode 为 interrupt 或 after
|
|
1784
|
+
raise ValueError("采纳须为 action=adopt,mode 为 interrupt 或 after;全体、依次、指定几位或无需回应为 action=all、sequential、some 或 none")
|
|
1759
1785
|
row = connection.execute("SELECT * FROM member_recommendations WHERE room_id=? AND message_id=?", (room_id, message_id)).fetchone()
|
|
1760
1786
|
if not row:
|
|
1761
1787
|
raise LookupError("这条消息没有智能调度推荐")
|
package/docs/runner.md
CHANGED
|
@@ -65,7 +65,7 @@ API 成员可以担任专家或主持人,只有房间读写工具,不访问
|
|
|
65
65
|
|
|
66
66
|
## 智能调度(可选)
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
建房时在“智能调度”下选择供应商并填写密钥,需要连接本机服务。卡片下方的“判断方式”可选“引导(试验)”(默认,正在打磨)或“稳定”(已通过回归评测),影响未点名消息的分派和讨论中的插话、下一位发言、下一轮判断。作用见 [使用指南](workflow.md#智能调度)。
|
|
69
69
|
|
|
70
70
|
翻开成员卡片,“提示”一行可以给这位专家单独写几句要求,例如“回答先给结论”。它只发给这位专家,不改变权限;部分软件带有预设,可随时恢复。卡片背面的书签图标把这张卡片的描述、提示词、项目权限、模型与强度存为专家预设,之后在“添加”行一键加入,见 [使用指南](workflow.md#成员管理)。
|
|
71
71
|
|
package/package.json
CHANGED
|
@@ -14,7 +14,7 @@ import { setup_sidecar } from './adapters/antigravity_app.mjs';
|
|
|
14
14
|
import { adapter_of, adoption_adapter, BRANCHABLE_ADAPTERS, branch_session, session_settings, software_defaults, software_effort, software_models } from './adapters/index.mjs';
|
|
15
15
|
import { ADAPTER_IDS, ADAPTER_PROFILES, adapter_profile, platform_adapters, same_session_family } from './adapters/profiles.mjs';
|
|
16
16
|
import { API_PROVIDERS, api_check, api_models, save_api_key, saved_api_keys, validate_api_profile } from './discussion_api.mjs';
|
|
17
|
-
import { SCHEDULER_PROVIDERS, scheduler_check } from './scheduler.mjs';
|
|
17
|
+
import { DEFAULT_SCHEDULER_MODE, SCHEDULER_MODES, SCHEDULER_PROVIDERS, scheduler_check } from './scheduler.mjs';
|
|
18
18
|
import { access_levels, BUILTIN_EXPERT_PRESETS, MAX_EXPERT_PROMPT, PROJECT_ACCESS } from './expert_prompts.mjs';
|
|
19
19
|
import { acquire_lock, api, DiscussionStore, service_drafts, validate_config } from './discussion_runner.mjs';
|
|
20
20
|
import { FreeRoomRunner, free_task_files } from './free_runner.mjs';
|
|
@@ -285,7 +285,7 @@ export async function room_models(home, raw, { read_defaults = software_defaults
|
|
|
285
285
|
own_project: Boolean(facts?.own_project_when_bound && profile.session_id) }];
|
|
286
286
|
})));
|
|
287
287
|
// Smart scheduling is a local choice; only which model decides is shown, never its key.
|
|
288
|
-
const scheduler = config.scheduler ? { provider: config.scheduler.provider, model: config.scheduler.model } : null;
|
|
288
|
+
const scheduler = config.scheduler ? { provider: config.scheduler.provider, model: config.scheduler.model, mode: config.scheduler.mode || DEFAULT_SCHEDULER_MODE } : null;
|
|
289
289
|
return { room_id: input.room_id, registered: paths.length > 0, participants, scheduler };
|
|
290
290
|
}
|
|
291
291
|
|
|
@@ -1232,7 +1232,7 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
|
|
|
1232
1232
|
assert_shared_home(home);
|
|
1233
1233
|
let result;
|
|
1234
1234
|
if (positionals[0] === 'settings') result = { ...(input ? save_launch_settings(home, input) : launch_settings(home)), api_providers: API_PROVIDERS,
|
|
1235
|
-
scheduler_providers: SCHEDULER_PROVIDERS, api_keys: saved_api_keys(home), projects: launch_projects(home).projects, ...expert_presets(home) };
|
|
1235
|
+
scheduler_providers: SCHEDULER_PROVIDERS, scheduler_modes: SCHEDULER_MODES, scheduler_default_mode: DEFAULT_SCHEDULER_MODE, api_keys: saved_api_keys(home), projects: launch_projects(home).projects, ...expert_presets(home) };
|
|
1236
1236
|
else if (positionals[0] === 'projects') result = input ? save_launch_projects(home, input) : launch_projects(home);
|
|
1237
1237
|
else if (positionals[0] === 'presets') result = input ? save_expert_presets(home, input) : expert_presets(home);
|
|
1238
1238
|
else if (positionals[0] === 'api-key' && input) result = save_api_key(home, input);
|
package/runner/free_runner.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { SegmentLog } from './execution_progress.mjs';
|
|
|
9
9
|
import { free_task_text, TASK_SUBCOMMANDS, with_expert_prompt } from './expert_prompts.mjs';
|
|
10
10
|
import { atomic_json } from './files.mjs';
|
|
11
11
|
import { SessionDriver } from './session_driver.mjs';
|
|
12
|
-
import { REVIEW_CONFIDENCE, REVIEW_PROMPT_VERSION, ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, decide_group_order, decide_review_completion, route_free_message } from './scheduler.mjs';
|
|
12
|
+
import { DEFAULT_SCHEDULER_MODE, REVIEW_CONFIDENCE, REVIEW_PROMPT_VERSION, ROUTE_CONFIDENCE, ROUTE_CONTEXT_MESSAGES, ROUTE_PROMPT_VERSION, ROUTE_WIDE_CONFIDENCE, decide_group_order, decide_review_completion, route_free_message, route_prompt_version, scheduler_thresholds, some_members } from './scheduler.mjs';
|
|
13
13
|
|
|
14
14
|
const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
15
15
|
const RENEW_MS = 30_000;
|
|
@@ -142,7 +142,8 @@ function member_work(requests, name, message_id) {
|
|
|
142
142
|
/** Why a message stayed with the host: the scheduler chose the host, or leaned elsewhere without enough certainty. */
|
|
143
143
|
function route_host_label(decision, host, members) {
|
|
144
144
|
if (decision.choice === '__host__' || decision.confidence === null) return `交给主持人 ${host}`;
|
|
145
|
-
const
|
|
145
|
+
const group = some_members(decision.choice);
|
|
146
|
+
const lean = decision.choice === '__all__' ? '全体各自回应' : decision.choice === '__none__' ? '无需回应' : group ? `请 ${group.join('、')} 各自回应`
|
|
146
147
|
: members.some(member => member.name === decision.choice) ? `请 ${decision.choice} 回答` : '';
|
|
147
148
|
return lean ? `倾向${lean},把握不足,交给主持人 ${host}` : `交给主持人 ${host}`;
|
|
148
149
|
}
|
|
@@ -451,7 +452,8 @@ export class FreeRoomRunner {
|
|
|
451
452
|
let record;
|
|
452
453
|
if (decision.detail) {
|
|
453
454
|
if (result.status === 'failed') label += '(未生效,网站写入失败)';
|
|
454
|
-
|
|
455
|
+
// The @全体成员 question has one wording in every mode.
|
|
456
|
+
record = await this.record_decision(request.message_id, decision, label, result.applied && decision.action !== 'host', { prompt: ROUTE_PROMPT_VERSION, threshold: ROUTE_CONFIDENCE, wide_threshold: ROUTE_WIDE_CONFIDENCE });
|
|
455
457
|
}
|
|
456
458
|
this.save_suggestion(request, { action: decision.action, actual_action,
|
|
457
459
|
applied: result.applied && decision.action !== 'host', model_judgment: Boolean(decision.detail),
|
|
@@ -481,7 +483,10 @@ export class FreeRoomRunner {
|
|
|
481
483
|
const work = member_work(requests, member.name, message_id);
|
|
482
484
|
const profile = this.config.participants[member.name];
|
|
483
485
|
const described = room.descriptions?.[member.name] ? `;用户描述:${room.descriptions[member.name]}` : '';
|
|
484
|
-
|
|
486
|
+
// The model and the last finished message are only read by the guided wording.
|
|
487
|
+
const finished = (room.free?.requests || []).filter(item => item.member === member.name && item.status === 'completed' && item.message_id !== message_id).at(-1);
|
|
488
|
+
return { id: member.id, name: member.name, role: member.role, model: profile?.model || '', finished: finished ? `#${finished.message_id}` : '',
|
|
489
|
+
description: profile ? member_description(member.name, profile, `${work}${described}`)
|
|
485
490
|
: `${member.name}(${member.access === 'online' ? '网页 AI' : '手动接入'},由用户代为触发${work}${described})` };
|
|
486
491
|
});
|
|
487
492
|
}
|
|
@@ -517,11 +522,11 @@ export class FreeRoomRunner {
|
|
|
517
522
|
this.log(`智能调度 #${message.id}:${decision.action}${decision.member ? ` ${decision.member}` : ''}(选择 ${decision.choice ?? '-'},置信度 ${decision.confidence ?? '-'})`);
|
|
518
523
|
if (decision.action === 'host') return this.record_route(request, decision, route_host_label(decision, host.name, members), false);
|
|
519
524
|
if (decision.action !== 'member') {
|
|
520
|
-
// Everyone or no one replaces the host's request in one step; a host that already started keeps the message.
|
|
521
|
-
const applied = await api(this.config, `/recommendations/${message.id}`, { action: decision.action })
|
|
525
|
+
// Everyone, the named few or no one replaces the host's request in one step; a host that already started keeps the message.
|
|
526
|
+
const applied = await api(this.config, `/recommendations/${message.id}`, { action: decision.action, ...(decision.members ? { members: decision.members } : {}) })
|
|
522
527
|
.then(() => true, error => { this.log(`智能调度 #${message.id} 未能改派:${error.message}`); return false; });
|
|
523
528
|
const names = described.map(member => member.name).join('、');
|
|
524
|
-
const label = decision.action === 'all' ? `请全体各自回应(${names})` : '无需回应,未唤醒任何成员';
|
|
529
|
+
const label = decision.action === 'all' ? `请全体各自回应(${names})` : decision.action === 'some' ? `请 ${decision.members.join('、')} 各自回应` : '无需回应,未唤醒任何成员';
|
|
525
530
|
return this.record_route(request, decision, applied ? label : `${label}(未生效,仍由主持人处理)`, applied);
|
|
526
531
|
}
|
|
527
532
|
try {
|
|
@@ -538,11 +543,12 @@ export class FreeRoomRunner {
|
|
|
538
543
|
}
|
|
539
544
|
/** Show each judgment under its message so the user can rate it; a failed record never affects delivery. */
|
|
540
545
|
async record_decision(message_id, decision, label, applied, context = {}) {
|
|
546
|
+
const limits = scheduler_thresholds(this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE);
|
|
541
547
|
try {
|
|
542
548
|
return await api(this.config, '/scheduler-decisions', { kind: 'interjection', label, confidence: decision.confidence ?? null,
|
|
543
549
|
applied, provider: this.config.scheduler.provider, after_message_id: message_id, detail: decision.detail, input: decision.input,
|
|
544
|
-
context: { model: this.config.scheduler.model, threshold:
|
|
545
|
-
prompt:
|
|
550
|
+
context: { model: this.config.scheduler.model, threshold: limits.member, wide_threshold: limits.wide,
|
|
551
|
+
mode: this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE, prompt: route_prompt_version(this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE), package: PACKAGE_VERSION, ...context } });
|
|
546
552
|
} catch (error) { this.log(`智能调度 #${message_id} 决策记录未保存:${error.message}`); }
|
|
547
553
|
}
|
|
548
554
|
/** Reserve with a persisted client id before invoking any software. */
|
package/runner/scheduler.mjs
CHANGED
|
@@ -11,10 +11,38 @@ const REQUEST_TIMEOUT_MS = 30_000;
|
|
|
11
11
|
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
|
+
/**
|
|
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
|
+
}
|
|
14
24
|
/** Bump when the routing question or its option descriptions change, so ratings can be compared across wordings. */
|
|
15
|
-
export const ROUTE_PROMPT_VERSION =
|
|
25
|
+
export const ROUTE_PROMPT_VERSION = 13;
|
|
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 = 7;
|
|
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
|
+
}
|
|
16
38
|
/** The same for the three discussion questions (interjection, next speaker, next round). */
|
|
17
|
-
export const DISCUSSION_PROMPT_VERSION =
|
|
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
|
+
}
|
|
18
46
|
/** Completion of a preauthorized implementation batch is separate from routing and discussion. */
|
|
19
47
|
export const REVIEW_PROMPT_VERSION = 1;
|
|
20
48
|
export const REVIEW_CONFIDENCE = 0.85;
|
|
@@ -23,19 +51,34 @@ const HOST_CHOICE = '__host__';
|
|
|
23
51
|
const NOTE_CHOICE = '__note__';
|
|
24
52
|
const ALL_CHOICE = '__all__';
|
|
25
53
|
const NONE_CHOICE = '__none__';
|
|
54
|
+
/** Prefix of the "only these few" options; the value lists the names after it, joined by SOME_JOINER. */
|
|
55
|
+
export const SOME_PREFIX = '__some__:';
|
|
56
|
+
const SOME_JOINER = '、';
|
|
57
|
+
/** At most this many named members are combined, so the options stay few (4 names give 11 groups). */
|
|
58
|
+
const MAX_SOME_NAMES = 4;
|
|
26
59
|
const CONTEXT_MESSAGES = 10;
|
|
27
60
|
/** A free-room route sees a longer stretch, enough to tell who has not spoken for a while. */
|
|
28
61
|
export const ROUTE_CONTEXT_MESSAGES = 20;
|
|
29
62
|
const MAX_MESSAGE_CHARS = 600;
|
|
30
63
|
const MAX_BRIEF_CHARS = 1200;
|
|
31
64
|
const MAX_PROFILE_CHARS = 500;
|
|
65
|
+
/**
|
|
66
|
+
* What the room is and how its people talk, so a decision reads the user's words the way a member would.
|
|
67
|
+
* Shared by every scene built from topic_lines; bump both prompt versions when it changes.
|
|
68
|
+
*/
|
|
69
|
+
const ROOM_GUIDE = ['这是 AI CoDiscuss 的协作房间。用户是房主和需求方;其余成员都是 AI,各自运行在 Claude Code、Codex 等软件或模型 API 上,用户也称它们为专家或 AI。',
|
|
70
|
+
'其中一位是主持人,负责接收没有点名的消息并分派、协调,也可以自己回答;其他成员是专家,在各自的会话里工作并回复。',
|
|
71
|
+
'用户用 @名字 点名成员,用 @全体成员 请所有人回应。每唤醒一位成员就要花一次执行(数十秒);几位成员同时回答时彼此看不到对方的回答。',
|
|
72
|
+
'房间可以临时开启讨论:主持人按目标安排专家轮流发言,到达轮次或时长上限后回到自由协作。'].join('\n');
|
|
32
73
|
|
|
33
74
|
/** Normalize a launch's scheduler choice and prove its key exists for that provider's root. */
|
|
34
75
|
export function validate_scheduler(home, scheduler) {
|
|
35
76
|
const provider = SCHEDULER_PROVIDERS[scheduler?.provider];
|
|
36
77
|
if (!provider) throw new Error(`智能调度供应商只能为 ${Object.keys(SCHEDULER_PROVIDERS).join(' / ')}`);
|
|
37
78
|
if (!scheduler.model || typeof scheduler.model !== 'string') throw new Error('请填写智能调度模型');
|
|
38
|
-
|
|
79
|
+
if (scheduler.mode !== undefined && !SCHEDULER_MODES[scheduler.mode]) throw new Error(`智能调度模式只能为 ${Object.keys(SCHEDULER_MODES).join(' / ')}`);
|
|
80
|
+
const normalized = { provider: scheduler.provider, model: scheduler.model.trim(), base_url: provider.base_url, api_key_ref: scheduler.api_key_ref,
|
|
81
|
+
mode: scheduler.mode || DEFAULT_SCHEDULER_MODE };
|
|
39
82
|
read_saved_api_key(home, normalized.base_url, normalized.api_key_ref);
|
|
40
83
|
return normalized;
|
|
41
84
|
}
|
|
@@ -125,10 +168,23 @@ function excerpt(message) {
|
|
|
125
168
|
}
|
|
126
169
|
|
|
127
170
|
/** 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)}`,
|
|
171
|
+
function topic_lines(title, goal, user_request, { guide = ROOM_GUIDE, roster, people, profiles, brief, focus, arrangement, progress, upcoming } = {}) {
|
|
172
|
+
return [guide, `房间:${title || ''}`, roster && `房间成员(名字不区分大小写):${roster}`, people, profile_lines(profiles), goal && `目标:${goal}`, user_request && `用户原文:${String(user_request).slice(0, MAX_MESSAGE_CHARS)}`,
|
|
130
173
|
focus && `当前焦点:${focus}`, brief && `主持简报(含分工与规则):${String(brief).slice(0, MAX_BRIEF_CHARS)}`,
|
|
131
|
-
arrangement && `主持人最近的安排:${arrangement}`, progress].filter(Boolean).join('\n');
|
|
174
|
+
arrangement && `主持人最近的安排:${arrangement}`, progress, upcoming && `接下来按安排发言:${upcoming.length ? upcoming.join(' → ') : '暂无,没有已安排的发言'}`].filter(Boolean).join('\n');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Guided discussion background: how a discussion runs and what each step costs, so the model weighs the situation
|
|
179
|
+
* instead of matching rules. Used by the three discussion questions in the guided mode.
|
|
180
|
+
*/
|
|
181
|
+
const DISCUSSION_GUIDED_GUIDE = ['这是 AI CoDiscuss 协作房间里临时开启的一场讨论。用户是房主,给出讨论目标;其余成员都是 AI,用户常称它们为专家。',
|
|
182
|
+
'主持人按目标安排专家轮流发言:每轮每位专家发言一次,后发言的人能看到前面所有发言;主持简报里记着分工、顺序和规则。到达轮次或时长上限、目标达成或用户结束时,回到自由协作。',
|
|
183
|
+
'每请一位专家发言或回答,都要花一次执行(通常几十秒)。用户在讨论中随时可能留言:补充约束、追问某人、调整方向,或者只是鼓励。'].join('\n');
|
|
184
|
+
/** The member line of the guided routing state: the shared facts plus the model and the last finished message. */
|
|
185
|
+
function guided_member_line(role, member) {
|
|
186
|
+
const extra = [member.model && `模型 ${member.model}`, member.finished && `最近完成 ${member.finished}`].filter(Boolean).join(';');
|
|
187
|
+
return `- ${role} ${extra ? String(member.description).replace(/)$/, `;${extra})`) : member.description}`;
|
|
132
188
|
}
|
|
133
189
|
|
|
134
190
|
/** The owner's descriptions of members, so a decision can weigh who is good at what. */
|
|
@@ -152,28 +208,76 @@ export function certainty(answer) {
|
|
|
152
208
|
return typeof probability === 'number' ? probability : typeof answer?.confidence === 'number' ? answer.confidence : null;
|
|
153
209
|
}
|
|
154
210
|
|
|
211
|
+
/** Stable routing wording: an ordered checklist of evidence, each item settling the addressee before the next is read. */
|
|
212
|
+
const ROUTE_STABLE_STEM = '这条用户消息没有用 @ 点名成员。判断它的回应对象,按下面的顺序:1)正文叫了某位成员的名字或用其他说法指代他(对照房间成员名单和前文只可能是他),或在向其提问(包括“X在吗”“X还在吗”“X你怎么看”),选该成员,即使上一条是别人的回答;同时提到几位成员时,选被直接称呼、要求其回应或做事的那位(如“A 你让 B 去改”是在对 A 说);2)正文没有指向任何人时,看它是否在延续某位成员的回答(追问、要求修改、引用,或“回复”指向其消息),或在补充、纠正一条正由某位成员处理的消息,是则选该成员,是主持人就选主持人;3)查看各成员的用户描述:已经明确由唯一一位成员负责这类任务时,直接选该成员,不需要再次点名;仅有擅长、多人分工重叠或任务还需要拆分时交主持人;4)符合“全体”选项的条件时选全体;要求成员之间讨论或互相回应的不算,按第 7 条处理;5)单独的问候或闲聊(如“hi”)既没有指向某位成员,也没有要求大家一起回应,选主持人;6)只是确认、致谢或旁注选无需回应;7)对象不明确,或要讨论、互相回应、分工、排先后时选主持人,由主持人安排。';
|
|
213
|
+
/**
|
|
214
|
+
* Guided routing: instead of rules, the model gets what a member of the room would know (who is who, what each is busy
|
|
215
|
+
* with, what each choice leads to) and the purpose of this step, and weighs the message itself.
|
|
216
|
+
*/
|
|
217
|
+
const ROUTE_GUIDED_GUIDE = ['这是 AI CoDiscuss 的协作房间。用户是房主,也是提出需求的人;其余成员都是 AI,各自运行在 Claude Code、Codex 等软件或模型 API 上,用户常称它们为专家。',
|
|
218
|
+
'主持人是其中一位:平时接收没人点名的消息,自己回答或分派给别人,也负责协调需要几个人配合的事。专家们在各自的会话里工作,回复都发在房间里,所有人都看得到。',
|
|
219
|
+
'用户找某人时通常会 @ 他,但聊顺了也常直接说话:叫名字、换个说法指代,或者接着刚才的话往下说。用户也可能事先在成员描述里交代过谁负责什么。',
|
|
220
|
+
'唤醒一位成员要花一次执行,通常几十秒;交给了不对的人,就得打断后重来。'].join('\n');
|
|
221
|
+
const ROUTE_GUIDED_STEM = '用户发来上面这条消息,没有 @ 任何人。请你像熟悉这个房间的成员一样读懂它:用户想让谁来回应?'
|
|
222
|
+
+ '用户心里已经有明确的对象时,直接交给对方,省掉主持人转一手的时间;需要有人先理解、拆分或协调时交给主持人。'
|
|
223
|
+
+ '用户想听每个人各自说一句时,交给全体能让大家同时开始,比主持人逐个点名快得多;但大家看不到彼此的回答,需要互相接话、讨论的事仍要主持人安排。用户只是表示收到或感谢时,不必唤醒任何人。'
|
|
224
|
+
+ '两种容易看错的情况:向在场的各位问好,是在等每个人回一声,和只对房间说一句“hi”不同;用户问“谁是……”“谁能……”时是在找符合条件的人,只需要符合的人出来回应(哪怕说“自己说一下”),成员信息里(含主持人)只有一位符合就交给他。';
|
|
225
|
+
|
|
226
|
+
/** The routing question in one mode: option texts, the stem, and what the state adds about the members. */
|
|
227
|
+
/** Every group of two or more among the members the text names, so "these few each answer" is one choice. */
|
|
228
|
+
function named_groups(text, host, members) {
|
|
229
|
+
const lower = String(text || '').toLowerCase();
|
|
230
|
+
const named = [host, ...members].map(member => member.name).filter(name => lower.includes(name.toLowerCase()));
|
|
231
|
+
if (named.length < 2 || named.length > MAX_SOME_NAMES) return [];
|
|
232
|
+
const groups = [];
|
|
233
|
+
for (let mask = 1; mask < 1 << named.length; mask += 1) {
|
|
234
|
+
const group = named.filter((_, index) => mask & (1 << index));
|
|
235
|
+
if (group.length >= 2) groups.push(group);
|
|
236
|
+
}
|
|
237
|
+
return groups;
|
|
238
|
+
}
|
|
239
|
+
/** The members of a "these few" choice, or null for any other value. */
|
|
240
|
+
export function some_members(choice) {
|
|
241
|
+
return typeof choice === 'string' && choice.startsWith(SOME_PREFIX) ? choice.slice(SOME_PREFIX.length).split(SOME_JOINER) : null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function route_question(mode, host, members, text) {
|
|
245
|
+
if (mode === 'stable') return { extra: { roster: [`${host.name}(主持人)`, ...members.map(member => `${member.name}(专家)`)].join('、') },
|
|
246
|
+
instructions: ROUTE_STABLE_STEM, choices: [...members.map(member => ({ value: member.name, description: member.description })),
|
|
247
|
+
{ value: HOST_CHOICE, description: `主持人 ${host.description}:没有指向任何人且用户没有确定负责人(如单独一句“hi”“你好”这类问候、闲聊,由主持人代表房间回应),或诉求模糊、复杂,需要主持人判断怎么安排` },
|
|
248
|
+
{ value: ALL_CHOICE, description: '全体成员(含主持人)各自回应一次,彼此不需要看到别人的回答;只在要求简单明确、每人独立回应一次就算完成时选,如“大家打个招呼”“各自报一下在用的模型”' },
|
|
249
|
+
{ value: NONE_CHOICE, description: '无需回应:只是确认、致谢或旁注,不需要任何人回答;选它则用户这句话不会得到回应' }] };
|
|
250
|
+
const people = ['成员(名字不区分大小写):', guided_member_line('主持人', host), ...members.map(member => guided_member_line('专家', member))].join('\n');
|
|
251
|
+
return { extra: { guide: ROUTE_GUIDED_GUIDE, people }, instructions: ROUTE_GUIDED_STEM,
|
|
252
|
+
choices: [...members.map(member => ({ value: member.name, description: `交给 ${member.name} 回应` })),
|
|
253
|
+
{ value: HOST_CHOICE, description: `交给主持人 ${host.name}:由他回应,或由他安排别人` },
|
|
254
|
+
...named_groups(text, host, members).map(group => ({ value: `${SOME_PREFIX}${group.join(SOME_JOINER)}`,
|
|
255
|
+
description: `只请 ${group.join('、')} 各自回应一次:同时开始,每人都收到这句原话,彼此看不到对方的回答,其他成员不被唤醒` })),
|
|
256
|
+
{ value: ALL_CHOICE, description: '全体成员(含主持人)同时收到这句原话,各自回应一次,彼此看不到对方的回答,也不会被分到不同的任务' },
|
|
257
|
+
{ value: NONE_CHOICE, description: '不唤醒任何人,用户这句话不会得到回应' }] };
|
|
258
|
+
}
|
|
259
|
+
|
|
155
260
|
/**
|
|
156
261
|
* Decide who answers one unnamed free-room message: whether its addressee is already settled by the text or the thread.
|
|
157
262
|
* Members are [{name, description}] without the host; host is {name, description}. Returns action member (with member),
|
|
158
263
|
* all, none, or host for anything unsettled or not confident enough.
|
|
159
264
|
*/
|
|
160
265
|
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);
|
|
266
|
+
const { choices, instructions, extra } = route_question(scheduler.mode || DEFAULT_SCHEDULER_MODE, host, members, message.text);
|
|
267
|
+
const questions = [{ type: 'choice', name: 'handler', choices, instructions }];
|
|
268
|
+
const state = scheduler_state(title, context, message, goal, '', extra, ROUTE_CONTEXT_MESSAGES);
|
|
169
269
|
const answers = await decide(home, scheduler, state, questions, fetch_impl);
|
|
170
270
|
const judged = judged_of(state, questions, answers);
|
|
171
271
|
const choice = answers.handler?.choice ?? null;
|
|
172
272
|
const confidence = certainty(answers.handler);
|
|
173
|
-
|
|
174
|
-
const
|
|
273
|
+
// Waking several members, everyone or no one costs more when wrong than moving the message to one member.
|
|
274
|
+
const group = some_members(choice);
|
|
275
|
+
const wide = choice === ALL_CHOICE || choice === NONE_CHOICE || Boolean(group);
|
|
276
|
+
const limits = scheduler_thresholds(scheduler.mode || DEFAULT_SCHEDULER_MODE);
|
|
277
|
+
const sure = confidence !== null && confidence >= (wide ? limits.wide : limits.member);
|
|
175
278
|
if (sure && choice === ALL_CHOICE) return { action: 'all', choice, confidence, ...judged };
|
|
176
279
|
if (sure && choice === NONE_CHOICE) return { action: 'none', choice, confidence, ...judged };
|
|
280
|
+
if (sure && group) return { action: 'some', members: group, choice, confidence, ...judged };
|
|
177
281
|
if (sure && members.some(item => item.name === choice)) return { action: 'member', member: choice, choice, confidence, ...judged };
|
|
178
282
|
return { action: 'host', choice, confidence, ...judged };
|
|
179
283
|
}
|
|
@@ -201,18 +305,29 @@ export async function decide_group_order(home, scheduler, { title, goal, context
|
|
|
201
305
|
* Decide one interjection in a discussion: continue the plan (note), ask one expert (ask) or wake the host (host).
|
|
202
306
|
* Anything but a confident note or ask is returned as host.
|
|
203
307
|
*/
|
|
204
|
-
export async function decide_interjection(home, scheduler, { title, goal, user_request, context, message, host, experts, spoken = [], ...extra }, fetch_impl) {
|
|
308
|
+
export async function decide_interjection(home, scheduler, { title, goal, user_request, context, message, host, experts, spoken = [], upcoming = [], ...extra }, fetch_impl) {
|
|
309
|
+
const guided = (scheduler.mode || DEFAULT_SCHEDULER_MODE) !== 'stable';
|
|
205
310
|
// Ordinary asks do not consume a discussion turn; all present members are eligible.
|
|
206
|
-
const choices =
|
|
207
|
-
{ value:
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
311
|
+
const choices = guided
|
|
312
|
+
? [...experts.map(name => ({ value: name, description: `现在单独请 ${name} 回应这条留言:不占讨论轮次,多花一次执行` })),
|
|
313
|
+
{ value: HOST_CHOICE, description: `交给主持人 ${host} 处理` },
|
|
314
|
+
{ value: NOTE_CHOICE, description: '按原安排继续:讨论照常推进、不会暂停,留言留在房间里,后面发言的专家都会看到并可在发言中回应' }]
|
|
315
|
+
: [...experts.map(name => ({ value: name, description: `请 ${name} 回应这条留言:留言是向该专家(或其负责的内容)提问、质疑或补充要求` })),
|
|
316
|
+
{ value: HOST_CHOICE, description: `由主持人 ${host} 处理:留言要改变讨论目标、规则、分工、安排或结束讨论,同时涉及多位专家,或针对的是不在选项里的人` },
|
|
317
|
+
{ value: NOTE_CHOICE, description: '仅补充信息:留言补充背景、约束或偏好,或只是旁注、鼓励,没有要求某人立即回应;后续发言的专家都能看到它,按当前安排继续' }];
|
|
318
|
+
const instructions = guided
|
|
319
|
+
? '讨论进行中,用户发来上面这条留言。请你像熟悉这场讨论的人一样读懂它,选出最合适的处理方式。'
|
|
320
|
+
+ '用户向某位专家要回应时,现在单独请他回答能马上得到答复,但要多花一次执行;如果他就在接下来要发言的人里,留到他发言时一并回应更省,也不打乱讨论。'
|
|
321
|
+
+ '补充的约束、偏好和鼓励,后面发言的人都看得到。要暂停或结束讨论、改目标、规则、分工或安排,或需要几位专家协调的,由主持人处理。'
|
|
322
|
+
: '讨论进行中用户发来这条留言。对照当前焦点、主持简报和安排,判断它最适合怎样处理。';
|
|
323
|
+
const questions = [{ type: 'choice', name: 'handling', instructions, choices }];
|
|
324
|
+
const state = scheduler_state(title, context, message, goal, user_request, { ...extra, progress: round_progress(spoken, experts.filter(name => !spoken.includes(name))),
|
|
325
|
+
...(guided ? { guide: DISCUSSION_GUIDED_GUIDE, upcoming } : {}) });
|
|
211
326
|
const answers = await decide(home, scheduler, state, questions, fetch_impl);
|
|
212
327
|
const judged = judged_of(state, questions, answers);
|
|
213
328
|
const choice = answers.handling?.choice;
|
|
214
329
|
const confidence = certainty(answers.handling);
|
|
215
|
-
const sure = confidence !== null && confidence >=
|
|
330
|
+
const sure = confidence !== null && confidence >= scheduler_thresholds(scheduler.mode || DEFAULT_SCHEDULER_MODE).member;
|
|
216
331
|
if (sure && choice === NOTE_CHOICE) return { action: 'note', confidence, ...judged, label: '仅补充信息,按原计划继续' };
|
|
217
332
|
if (sure && experts.includes(choice)) return { action: 'ask', expert: choice, confidence, ...judged, label: `先请 ${choice} 回应这条留言` };
|
|
218
333
|
const lean = choice === NOTE_CHOICE ? '按原计划继续' : experts.includes(choice) ? `请 ${choice} 回应` : choice === HOST_CHOICE ? '由主持人处理' : '';
|
|
@@ -244,18 +359,25 @@ export async function decide_review_completion(home, scheduler, ready, fetch_imp
|
|
|
244
359
|
* this round, or hand back to the host when unsure or when the discussion needs steering.
|
|
245
360
|
*/
|
|
246
361
|
export async function decide_next_speaker(home, scheduler, { title, goal, user_request, context, host, candidates, spoken = [], ...extra }, fetch_impl) {
|
|
362
|
+
const guided = (scheduler.mode || DEFAULT_SCHEDULER_MODE) !== 'stable';
|
|
247
363
|
const recent = context.slice(-CONTEXT_MESSAGES).map(excerpt);
|
|
248
|
-
const state = [topic_lines(title, goal, user_request, { ...extra, progress: round_progress(spoken, candidates)
|
|
364
|
+
const state = [topic_lines(title, goal, user_request, { ...extra, progress: round_progress(spoken, candidates), ...(guided ? { guide: DISCUSSION_GUIDED_GUIDE } : {}) }),
|
|
365
|
+
recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
|
|
249
366
|
// 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
|
-
|
|
367
|
+
const choices = guided
|
|
368
|
+
? [...candidates.map(name => ({ value: name, description: `请 ${name} 接着发言` })), { value: HOST_CHOICE, description: `交给主持人 ${host} 重新安排` }]
|
|
369
|
+
: [...candidates.map(name => ({ value: name, description: `请 ${name} 接着发言:简报的分工或流程规定轮到 ${name},或最近的发言点名追问了 ${name}` })),
|
|
370
|
+
{ value: HOST_CHOICE, description: `交给主持人 ${host}:简报和最近发言都没有明确规定下一位(仅仅是本轮还没发言不算依据),或需要主持人重新安排` }];
|
|
371
|
+
const instructions = guided
|
|
372
|
+
? '主持人预排的发言安排已经用完或被取消。主持人有时有意只安排到这里,有时只是还没排下一位。请判断下一位该请谁发言:'
|
|
373
|
+
+ '简报的分工、顺序,或最近有人被点名追问,都说明下一位已经明确;看不出来就交给主持人重新安排,请错人会白花一次执行,还可能违背主持人的安排。'
|
|
374
|
+
: '主持人预排的安排已用完或被取消,主持人可能有意只安排到这里。对照主持简报、当前焦点和主持人最近的安排,判断下一位应请谁发言。';
|
|
375
|
+
const questions = [{ type: 'choice', name: 'speaker', instructions, choices }];
|
|
254
376
|
const answers = await decide(home, scheduler, state, questions, fetch_impl);
|
|
255
377
|
const judged = judged_of(state, questions, answers);
|
|
256
378
|
const choice = answers.speaker?.choice;
|
|
257
379
|
const confidence = certainty(answers.speaker);
|
|
258
|
-
const sure = confidence !== null && confidence >=
|
|
380
|
+
const sure = confidence !== null && confidence >= scheduler_thresholds(scheduler.mode || DEFAULT_SCHEDULER_MODE).member;
|
|
259
381
|
if (sure && candidates.includes(choice)) return { action: 'next', expert: choice, confidence, ...judged, label: `本轮继续,请 ${choice} 接着发言` };
|
|
260
382
|
return { action: 'host', confidence, ...judged, label: choice === HOST_CHOICE && sure ? '没有明确该谁发言,交给主持人安排' : unsure_label(candidates.includes(choice) ? `请 ${choice} 发言` : choice === HOST_CHOICE ? '交给主持人' : '') };
|
|
261
383
|
}
|
|
@@ -268,26 +390,36 @@ export async function decide_round(home, scheduler, { title, goal, user_request,
|
|
|
268
390
|
const candidates = experts.filter(name => name !== last_speaker);
|
|
269
391
|
if (!candidates.length) return { action: 'host', confidence: null, label: '没有可选的下一轮首位,交给主持人' };
|
|
270
392
|
// Telling repetition or drift needs at least the last two rounds side by side.
|
|
393
|
+
const guided = (scheduler.mode || DEFAULT_SCHEDULER_MODE) !== 'stable';
|
|
271
394
|
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}
|
|
395
|
+
const state = [topic_lines(title, goal, user_request, guided ? { ...extra, guide: DISCUSSION_GUIDED_GUIDE } : extra),
|
|
396
|
+
recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
|
|
397
|
+
const questions = guided ? [
|
|
398
|
+
{ type: 'choice', name: 'next', instructions: '一轮发言刚结束。请判断讨论值不值得马上再进行一轮:还有没回应的分歧、追问,或简报规定的流程没走完时,再来一轮能推进讨论;'
|
|
399
|
+
+ '已经达成一致、在重复、偏离目标或卡住时,多一轮只是让每位专家白花一次执行,应交给主持人收束或引导。', choices: [
|
|
400
|
+
{ value: 'continue', description: '直接开始下一轮,每位专家再发言一次' },
|
|
401
|
+
{ value: HOST_CHOICE, description: `交给主持人 ${host} 收束或引导` }] },
|
|
402
|
+
{ type: 'choice', name: 'first', instructions: '如果开始下一轮,先请谁发言?简报规定了顺序,或有人被点名追问、需要先回应时,先请他;看不出依据就交给主持人决定。',
|
|
403
|
+
choices: [...candidates.map(name => ({ value: name, description: `先请 ${name}` })), { value: HOST_CHOICE, description: `交给主持人 ${host} 决定首位` }] }] : [
|
|
404
|
+
{ type: 'choice', name: 'next', instructions: '一轮发言刚结束。对照讨论目标、主持简报与当前焦点判断:下一轮是否还有明确的内容可以让大家互相回应、追问或补充?', choices: [
|
|
405
|
+
{ value: 'continue', description: '继续下一轮:最近发言里有未回应的分歧、追问或可展开的点,或简报规定的流程还没走完' },
|
|
406
|
+
{ value: HOST_CHOICE, description: `交给主持人 ${host}:目标已经达成可以收束,或最近发言在重复、偏离目标、卡住,需要主持人引导` }] },
|
|
407
|
+
{ type: 'choice', name: 'first', instructions: '下一轮应先请谁发言?按简报分工或接龙顺序判断;没有规定时选被其他专家点名追问、需要回应的人;没有明确依据时交主持人安排。',
|
|
408
|
+
choices: [...candidates.map(name => ({ value: name, description: `先请 ${name}:简报规定下一轮由其开头,或最近发言点名追问了该成员` })),
|
|
409
|
+
{ value: HOST_CHOICE, description: `交给主持人 ${host}:没有明确规定首位,也没有点名追问;仅仅有未决分歧不足以确定先请谁` }] }];
|
|
279
410
|
const answers = await decide(home, scheduler, state, questions, fetch_impl);
|
|
280
411
|
const judged = judged_of(state, questions, answers);
|
|
281
412
|
const choice = answers.next?.choice;
|
|
282
413
|
const confidence = certainty(answers.next);
|
|
283
414
|
// A wrong "continue" costs a whole round of runs, so it needs the same certainty as waking everyone.
|
|
284
|
-
|
|
285
|
-
|
|
415
|
+
const limits = scheduler_thresholds(scheduler.mode || DEFAULT_SCHEDULER_MODE);
|
|
416
|
+
if (choice !== 'continue' || confidence === null || confidence < limits.wide) {
|
|
417
|
+
const sure = confidence !== null && confidence >= (choice === 'continue' ? limits.wide : limits.member);
|
|
286
418
|
return { action: 'host', confidence, ...judged, label: choice === HOST_CHOICE && sure ? '建议收束或调整方向,交给主持人' : unsure_label(choice === 'continue' ? '继续下一轮' : choice === HOST_CHOICE ? '收束或调整方向' : '') };
|
|
287
419
|
}
|
|
288
420
|
const first = answers.first?.choice;
|
|
289
421
|
const first_confidence = certainty(answers.first);
|
|
290
|
-
if (!candidates.includes(first) || first_confidence === null || first_confidence <
|
|
422
|
+
if (!candidates.includes(first) || first_confidence === null || first_confidence < limits.member) {
|
|
291
423
|
return { action: 'host', confidence: first_confidence, ...judged, label: '下一轮首位没有明确依据或把握不足,交给主持人安排' };
|
|
292
424
|
}
|
|
293
425
|
return { action: 'continue', first, confidence, ...judged, label: `继续下一轮,先请 ${first} 发言` };
|
|
@@ -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_CONTEXT_MESSAGES, scheduler_thresholds, 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 };
|
|
@@ -198,8 +200,9 @@ export class SessionDriver {
|
|
|
198
200
|
return await api(this.config, '/scheduler-decisions', { kind: plan.kind, label: plan.label,
|
|
199
201
|
confidence: plan.confidence ?? null, applied, provider: this.config.scheduler.provider,
|
|
200
202
|
after_message_id, detail: plan.detail, input: plan.input,
|
|
201
|
-
context: { model: this.config.scheduler.model, threshold:
|
|
202
|
-
|
|
203
|
+
context: { model: this.config.scheduler.model, threshold: scheduler_thresholds(this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE).member,
|
|
204
|
+
wide_threshold: scheduler_thresholds(this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE).wide,
|
|
205
|
+
mode: this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE, prompt: discussion_prompt_version(this.config.scheduler.mode || DEFAULT_SCHEDULER_MODE), package: PACKAGE_VERSION } });
|
|
203
206
|
} catch (error) { this.log(`智能调度决策记录未保存:${error.message}`); }
|
|
204
207
|
}
|
|
205
208
|
}
|
package/web/app.js
CHANGED
|
@@ -2115,7 +2115,7 @@ async function load_launch_settings() {
|
|
|
2115
2115
|
state.launch.saved_profiles = JSON.parse(JSON.stringify(settings.participants || {}));
|
|
2116
2116
|
Object.assign(state.launch, { available: true, profiles: settings.participants, projects: settings.projects || [], api_providers: settings.api_providers || {}, api_keys: settings.api_keys || {},
|
|
2117
2117
|
presets: settings.presets || [], builtin_presets: settings.builtin_presets || [], description_templates: settings.description_templates || {},
|
|
2118
|
-
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: "",
|
|
2119
2119
|
notice: relay_machine ? `经电脑「${relay_machine.name}」执行本机操作` : local_connection ? "已连接本机服务,讨论保存在当前网站" : "" });
|
|
2120
2120
|
Object.values(state.launch.profiles).forEach(fill_api_key);
|
|
2121
2121
|
if (local_connection) pair_local_machine();
|
|
@@ -2420,8 +2420,8 @@ function launch_options() {
|
|
|
2420
2420
|
|
|
2421
2421
|
/** A new room also carries the optional smart scheduling choice; only a complete, enabled choice is sent. */
|
|
2422
2422
|
function creation_launch_options() {
|
|
2423
|
-
const { enabled, provider, model, api_key_ref } = state.launch.scheduler;
|
|
2424
|
-
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 } : {}) } } : {}) };
|
|
2425
2425
|
}
|
|
2426
2426
|
|
|
2427
2427
|
/** Choose whether the new discussion's host writes named follow-ups for each expert between rounds. */
|
|
@@ -2452,7 +2452,7 @@ function default_scheduler() {
|
|
|
2452
2452
|
const provider = Object.keys(providers).find((name) => state.launch.api_keys?.[providers[name].base_url]);
|
|
2453
2453
|
if (!provider) return {};
|
|
2454
2454
|
const { base_url, models } = providers[provider];
|
|
2455
|
-
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 || "" };
|
|
2456
2456
|
}
|
|
2457
2457
|
|
|
2458
2458
|
/** Change the smart scheduling draft; a new provider starts from its preset model and any key saved for its root. */
|
|
@@ -3264,6 +3264,11 @@ async function rate_decision({ decision_id, rating, should_be = "", note = "" })
|
|
|
3264
3264
|
if (state.room?.id === room_id) await refresh_room();
|
|
3265
3265
|
}
|
|
3266
3266
|
|
|
3267
|
+
/** Read what the decision model was given for one decision (state and questions), for the card's input view. */
|
|
3268
|
+
async function load_decision_input(decision_id) {
|
|
3269
|
+
return request_json(`/api/rooms/${state.room.id}/scheduler-decisions/${decision_id}`);
|
|
3270
|
+
}
|
|
3271
|
+
|
|
3267
3272
|
/** A completed creation starts the next form with fresh mode defaults. */
|
|
3268
3273
|
function reset_creation_mode() {
|
|
3269
3274
|
state.creation_mode = "free";
|
|
@@ -4107,7 +4112,7 @@ async function execute_view_action(name, value) {
|
|
|
4107
4112
|
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,
|
|
4108
4113
|
save_launch_defaults, launch_current_room, resume_launch, discard_launch, retry_launch_connection, check_launch_connection: load_launch_settings,
|
|
4109
4114
|
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,
|
|
4110
|
-
prepare_brainstorm, return_conclusion, adopt_recommendation, rate_decision,
|
|
4115
|
+
prepare_brainstorm, return_conclusion, adopt_recommendation, rate_decision, load_decision_input,
|
|
4111
4116
|
busy: () => { throw new Error("上一个操作仍在处理中,请稍候;长时间无响应请刷新页面"); },
|
|
4112
4117
|
copy_reading: () => copy_text(`${location.origin}/r/${state.room.id}`), copy: copy_text };
|
|
4113
4118
|
try {
|