diff --git a/CONTEXT.md b/CONTEXT.md index eae84a96..440d1a13 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -119,6 +119,10 @@ _Avoid_: 编排引擎、调度 编排中的两类工位:Explorer 只读,做探索、规划、评审;Worker 可写,做实现。各有简单、困难两档,Worker 另有界面简单、困难两档,用于页面、样式、交互和看图的实现,没设时沿用 Worker 同档;用户在 App 选 CLI、模型和可选档位,存于 `orchestration.json`;当前接入行启用编排时,`list_members` 的 `orchestration` 才返回工位及可用性,派发才套用角色默认值,boss 按任务难度选档,涉及界面的实现改用界面档。关闭保留已保存的选择。与宿主同类的工位标 `native=true`,交给宿主自带的子代理,评审工位同样如此。 +## 生图工位 + +设置页常驻、独立于普通自定义角色的可选图像生成工位,用户为它选定 CLI 及与之对话的模型;实际图像由该 CLI 的模型调用自身可用的生图服务生成。它独立于 Explorer/Worker 的难度档,未配置时没有默认绑定;生图编排规则与工位反馈仅在选定后向不同类 CLI 的宿主提供;同类宿主不展示此工位。 + ## 权限档位(Permission Tier) 每个 CLI 的派发权限:自动(缺省)或完全放开,用户在 App 中改选,存于独立的 `permissions.json`,私聊与会议共用,与模型无关。服务把它映射为该 CLI 的原生权限选项并在回执注明;调用方显式给出权限选项时不套用。「自动」交给 CLI 自带的自动审核,CLI 没有可用的自动审核时等同完全放开。Pi 没有审批步骤,不设档位。 @@ -154,3 +158,7 @@ _Avoid_: 控制台、桌面客户端 ## 语音播报 按模型家族音色朗读右侧消息摘要的可选功能,覆盖已有的 boss 发言与员工回答;可手动选择消息,点读后的连播仅限所选那场会议,包含其全部轮次,不串播其他会议。排队时一次只播一个 boss 的消息,播完再换下一个;手选的消息插到最前。每场会议或每段私聊是一张专辑,封面是其中所有参与者,与从哪条消息开始播放无关。播放不随切换视图、关闭对话栏或隐藏窗口中断。**收听**是用户主动开启、限时跟读一个工作区、一场会议或一段私聊的播放状态:一场会议或一段私聊从头听全,本次运行内再开从上次念完处继续;工作区只读开启之后的新消息;同一时间只收听一个,隐藏办公室或切换视图不影响收听,重启即结束。到期后当前正在念的消息念完,后续收听内容撤下,手动点读与连播不受影响。独立语音频道用头像区分主持人和发言人,不改变办公室的小人动画与任务状态。模型 prompt 定义消息缩写规则,小人人设定义性格、措辞与口吻;语音以发言者本人第一人称表达。boss 发言的头像和音色跟实际说话的 boss 走,接手工作区不改变已说过的话,新 boss 在会议里发言后才换。 + +## CLI 可用模型池 + +用户在员工页为每个 CLI 维护的共用模型边界,普通任务、编排工位与会议都从其中选择模型。工位绑定和场景默认值不再各自拥有允许名单;宿主启用编排时,普通任务候选与场景默认值退出 boss 的编排输入,关闭后恢复。 diff --git a/README.en.md b/README.en.md index aab44ed3..a2d2a63b 100644 --- a/README.en.md +++ b/README.en.md @@ -44,7 +44,7 @@ On macOS and Linux, Claude runs interactively by default, which saves subscripti ### 🧭 Seats and review -Pick models for the Explorer and Worker seats in the App, with tiers for easy, hard and interface work, and the boss dispatches by difficulty. You can also add a [review before merging](skills/agentnave-review/SKILL.md): how much review a change gets depends on its risk, reviewers read but never edit, and their conclusions are kept as a record. +Pick models for the Explorer and Worker seats in the App, with tiers for easy, hard and interface work, and the boss dispatches by difficulty. An optional image generation seat selects a CLI and its conversation model, which calls its own image service. You can also add a [review before merging](skills/agentnave-review/SKILL.md): how much review a change gets depends on its risk, reviewers read but never edit, and their conclusions are kept as a record. ### 🤝 Continue in another chat diff --git a/README.md b/README.md index db36e1aa..0dfae27f 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ ### 🧭 工位和评审 -在 App 里给 Explorer、Worker 工位选好模型,简单、困难和界面任务各有档位,boss 按任务难度派活。还可以加一道[合并前评审](skills/agentnave-review/SKILL.md):按风险大小决定派谁来看,评审的员工只读不改,结论留作记录。 +在 App 里给 Explorer、Worker 工位选好模型,简单、困难和界面任务各有档位,boss 按任务难度派活。可选的生图工位只需选 CLI 和对话模型,由它调用自身的生图服务。还可以加一道[合并前评审](skills/agentnave-review/SKILL.md):按风险大小决定派谁来看,评审的员工只读不改,结论留作记录。 ### 🤝 换个聊天接着干 diff --git a/apps/tray/src/badge.rs b/apps/tray/src/badge.rs index de4b3be6..2b7d731b 100644 --- a/apps/tray/src/badge.rs +++ b/apps/tray/src/badge.rs @@ -35,8 +35,8 @@ const DRIP: [&[u8]; 18] = [ ]; /// Font choices and the macOS fonts they draw with; the first is the fallback. const FONTS: [(&str, &str); 4] = [ - ("futura", "Futura-Medium"), ("baskerville", "Baskerville-SemiBoldItalic"), + ("futura", "Futura-Medium"), ("typewriter", "AmericanTypewriter-Semibold"), ("marker", "MarkerFelt-Wide"), ]; diff --git a/apps/tray/src/data/request.rs b/apps/tray/src/data/request.rs index aebcdbd9..e8c0f807 100644 --- a/apps/tray/src/data/request.rs +++ b/apps/tray/src/data/request.rs @@ -198,7 +198,7 @@ impl Reader { }; let rules = self.rule_files(); Response::json(&json!({"document": document, "error": error, - "presets": crate::rules::presets(), "files": rules["files"], "bosses": rules["bosses"]})) + "presets": crate::rules::presets(&document), "files": rules["files"], "bosses": rules["bosses"]})) } pub(super) fn save_orchestration(&self, payload: &Value) -> Response { // The modes each row runs on are worked out here, never taken from the page. diff --git a/apps/tray/src/members.rs b/apps/tray/src/members.rs index f976aac7..cd794e15 100644 --- a/apps/tray/src/members.rs +++ b/apps/tray/src/members.rs @@ -164,7 +164,13 @@ pub fn validate(document: &Value) -> Result<(), String> { let default = default .as_str() .ok_or_else(|| format!("{what}.default 必须为字符串"))?; - if !known.contains_key(default) || !(listed.is_empty() || listed.contains(&default)) + // A CLI's own list replaces the scene's. A default outside it stays readable: the + // App reports it and the server refuses to run it. + let pooled = clis + .get(provider) + .is_some_and(|cli| cli.get("allowed_models").is_some()); + if !known.contains_key(default) + || !(pooled || listed.is_empty() || listed.contains(&default)) { return Err(format!("{what} 的默认模型 {default} 不在允许的模型中")); } @@ -290,8 +296,9 @@ fn validate_seat(seat: &Value, what: &str) -> Result<(), String> { /// orchestration.json: `{"version": 1, "preset": "off" | "split" | "parallel" | "custom", /// "custom": text, "apps": {row: mode}, "effective_apps": {row: mode}, "explorer": {"easy" | "hard": -/// seat}, "worker": {...}, "worker_frontend": {...}}`. Interface Workers have a key of their own so -/// servers that predate it ignore them rather than reject the file. The App writes the chosen +/// seat}, "worker": {...}, "worker_frontend": {...}, "image_generation": seat}`. Interface Workers +/// and the image seat have keys of their own so servers that predate them ignore them rather than +/// reject the file. The App writes the chosen /// rules into the boss apps' global rules and /// `effective_apps`, every connected row's mode as its group applies it, for the server. pub fn validate_orchestration(document: &Value) -> Result<(), String> { @@ -307,6 +314,7 @@ pub fn validate_orchestration(document: &Value) -> Result<(), String> { "explorer", "worker", "worker_frontend", + "image_generation", ], ORCHESTRATION_FILE, )?; @@ -346,6 +354,9 @@ pub fn validate_orchestration(document: &Value) -> Result<(), String> { validate_seat(seat, &format!("{role}.{difficulty}"))?; } } + if let Some(seat) = top.get("image_generation").filter(|seat| !seat.is_null()) { + validate_seat(seat, "image_generation")?; + } Ok(()) } @@ -612,7 +623,11 @@ fn validate_clis(clis: &Map) -> Result<(), String> { return Err(format!("不支持的 CLI:{provider}")); } let fields = object(cli, &format!("clis.{provider}"))?; - only(fields, &["models"], &format!("clis.{provider}"))?; + only( + fields, + &["models", "allowed_models"], + &format!("clis.{provider}"), + )?; let models = fields .get("models") .ok_or_else(|| format!("clis.{provider} 缺少 models"))?; @@ -624,6 +639,30 @@ fn validate_clis(clis: &Map) -> Result<(), String> { settings(shared, &what)?; effort_allowed(shared, None, &what)?; } + if let Some(pool) = fields.get("allowed_models").filter(|pool| !pool.is_null()) { + validate_pool(pool, object(models, "models")?, provider)?; + } + } + Ok(()) +} + +/// The models a CLI may run, for every scene and seat; absent or null means any. +fn validate_pool(pool: &Value, models: &Map, provider: &str) -> Result<(), String> { + let what = format!("clis.{provider}.allowed_models"); + let pool = pool + .as_array() + .filter(|pool| !pool.is_empty()) + .ok_or_else(|| format!("{what} 必须为非空数组"))?; + for (index, model) in pool.iter().enumerate() { + let name = model + .as_str() + .ok_or_else(|| format!("{what} 只能含字符串"))?; + if !models.contains_key(name) { + return Err(format!("{what} 引用了未配置的模型 {name}")); + } + if pool[..index].contains(model) { + return Err(format!("{what} 重复列出模型 {name}")); + } } Ok(()) } diff --git a/apps/tray/src/members/tests.rs b/apps/tray/src/members/tests.rs index 1ce52880..9c128c7f 100644 --- a/apps/tray/src/members/tests.rs +++ b/apps/tray/src/members/tests.rs @@ -228,6 +228,29 @@ fn accepts_the_contract_example_and_rejects_structural_or_reference_errors() { } } +#[test] +fn a_cli_pool_is_checked_and_replaces_the_scene_list_for_defaults() { + let mut pooled = document(); + pooled["clis"]["codex"]["allowed_models"] = json!(["gpt-6.1-sol"]); + validate(&pooled).unwrap(); + pooled["clis"]["codex"]["allowed_models"] = Value::Null; + validate(&pooled).unwrap(); + for pool in [ + json!([]), + json!("gpt-6.1-sol"), + json!([1]), + json!(["missing"]), + json!(["gpt-6.1-sol", "gpt-6.1-sol"]), + ] { + pooled["clis"]["codex"]["allowed_models"] = pool.clone(); + assert!(validate(&pooled).is_err(), "{pool}"); + } + // A default outside the pool or an older scene list stays readable for the App to report. + pooled["clis"]["codex"]["allowed_models"] = json!(["gpt-6.1-sol"]); + pooled["modes"]["task"]["codex"]["default"] = json!("gpt-6-luna"); + validate(&pooled).unwrap(); +} + #[test] fn saves_atomically_through_the_post_route_only() { let directory = tempfile::tempdir().unwrap(); diff --git a/apps/tray/src/preferences/general.rs b/apps/tray/src/preferences/general.rs index 8ec3b696..d32ee87e 100644 --- a/apps/tray/src/preferences/general.rs +++ b/apps/tray/src/preferences/general.rs @@ -27,7 +27,7 @@ pub fn effective(document: &Value, autostart: bool) -> Value { let mut result = json!({ "language": "system", "theme": "system", "motion": "system", "scale": 100, "autostart": autostart, "open_on_start": true, "tray_click": "panel", - "tray_count": true, "tray_font": "futura", "keep_awake": false, + "tray_count": true, "tray_font": "baskerville", "keep_awake": false, }); if let Some(saved) = document["general"].as_object() { for (field, value) in saved { diff --git a/apps/tray/src/preferences/tests.rs b/apps/tray/src/preferences/tests.rs index 3ae60ba8..95568cfe 100644 --- a/apps/tray/src/preferences/tests.rs +++ b/apps/tray/src/preferences/tests.rs @@ -110,13 +110,14 @@ fn effective_fills_defaults_and_uses_the_os_autostart_value() { json!({ "language": "system", "theme": "system", "motion": "system", "scale": 100, "autostart": true, "open_on_start": true, "tray_click": "panel", - "tray_count": true, "tray_font": "futura", "keep_awake": false, + "tray_count": true, "tray_font": "baskerville", "keep_awake": false, }) ); let effective = general::effective( &json!({"general": { "autostart": true, "scale": 115, "theme": "light", "motion": "full", - "open_on_start": false, "tray_click": "office", "tray_count": false, "keep_awake": true, + "open_on_start": false, "tray_click": "office", "tray_count": false, + "tray_font": "futura", "keep_awake": true, "future": 1 }}), false, @@ -126,6 +127,7 @@ fn effective_fills_defaults_and_uses_the_os_autostart_value() { assert_eq!(effective["motion"], "full"); assert_eq!(effective["tray_click"], "office"); assert_eq!(effective["tray_count"], false); + assert_eq!(effective["tray_font"], "futura"); assert!(effective.get("future").is_none()); assert_eq!( general::effective( diff --git a/apps/tray/src/rules.rs b/apps/tray/src/rules.rs index 99065e4b..8e5ac9f9 100644 --- a/apps/tray/src/rules.rs +++ b/apps/tray/src/rules.rs @@ -22,6 +22,8 @@ const SHARED: &str = "## AgentNave 编排偏好 - 本偏好不决定何时评审,用户要求或其规则要求时才评审。评审时有 agentnave-review Skill 就按它定级、选工位,不另派 Explorer;没有时派 Explorer,不交给写这段代码的 Worker,尽量换模型。 - `succeeded` 不等于完成,boss 核对产物后验收;失败或取消时先核对现状,再决定续接、重派还是收尾。"; +const IMAGE_GENERATION: &str = "\n- 生成图片派生图工位,`seat` 用 `image-generator`,prompt 写清图像需求和产物路径,由它的对话模型自行调用生图服务。"; + const PARALLEL: &str = " - 计划确认后,互不依赖的写入步骤并行派给多个 Worker,在 Git 仓库里各用独立 worktree;无法隔离时串行。boss 合入当前分支,最后统一验收。 - 有实质取舍或分歧时进会议室出决定,会上不改代码。 @@ -29,8 +31,8 @@ const PARALLEL: &str = " - 没有新证据或待解问题时,不再追加讨论、重试和验证。"; /// Each preset's rules text, for the settings page and the custom editor's starting text. -pub fn presets() -> Value { - json!({"split": SHARED, "parallel": format!("{SHARED}{PARALLEL}")}) +pub fn presets(document: &Value) -> Value { + json!({"split": text_of(document, "split"), "parallel": text_of(document, "parallel"), "image_generation": IMAGE_GENERATION}) } /// The mode of apps sharing a file: the first set among them, or the default that rows without @@ -45,14 +47,21 @@ fn mode_of<'a>(document: &'a Value, group: &[&App]) -> &'a str { /// The text a mode puts in the rules files, if any. fn text_of(document: &Value, mode: &str) -> Option { - match mode { - "split" => Some(SHARED.into()), - "parallel" => Some(format!("{SHARED}{PARALLEL}")), - "custom" => Some(document["custom"].as_str()?.trim()) - .filter(|text| !text.is_empty()) - .map(Into::into), - _ => None, + let mut text = match mode { + "split" => SHARED.to_owned(), + "parallel" => format!("{SHARED}{PARALLEL}"), + "custom" => document["custom"].as_str()?.trim().to_owned(), + _ => return None, + }; + if text.is_empty() { + return None; } + // Strip our generated line if it was copied into a custom preset before being cleared. + text = text.replace(IMAGE_GENERATION, ""); + if document["image_generation"].is_object() { + text.push_str(IMAGE_GENERATION); + } + Some(text) } /// Custom text goes into every boss app's rules each turn, so it stays short and cannot end the @@ -266,12 +275,24 @@ pub fn effective(connected: &[App], home: &Path, document: &Value) -> Value { fn wanted(connected: &[App], home: &Path, document: &Value) -> Vec<(PathBuf, String)> { let mut wanted = Vec::new(); for group in groups(connected, home) { - if let Some(text) = text_of(document, mode_of(document, &group)) { - wanted.extend( - targets(&group, home) - .into_iter() - .map(|path| (path, text.clone())), - ); + for path in targets(&group, home) { + let mut scoped = document.clone(); + let provider = document["image_generation"]["provider"].as_str(); + // A shared rules file cannot give one of its readers a rule it must not see. + let own_reader = group.iter().any(|app| { + let cli = if app.id == "workbuddy" { + "codebuddy" + } else { + app.id + }; + provider == Some(cli) && would_read(app, home).contains(&path) + }); + if own_reader { + scoped["image_generation"] = Value::Null; + } + if let Some(text) = text_of(&scoped, mode_of(document, &group)) { + wanted.push((path, text)); + } } } wanted diff --git a/apps/tray/src/rules/tests.rs b/apps/tray/src/rules/tests.rs index 7b9c8180..23e8b1aa 100644 --- a/apps/tray/src/rules/tests.rs +++ b/apps/tray/src/rules/tests.rs @@ -278,6 +278,21 @@ fn each_boss_gets_its_own_mode_and_apps_sharing_a_file_get_one() { assert!(valid(&document).is_ok()); assert!(valid(&json!({"version": 1, "preset": "off", "apps": {"codex": "fast"}})).is_err()); assert!(valid(&json!({"version": 1, "preset": "off", "apps": {"codex": "custom"}})).is_err()); + let image = |seat| json!({"version": 1, "preset": "off", "image_generation": seat}); + assert!( + valid(&image( + json!({"provider": "codex", "model": "m", "effort": "high"}) + )) + .is_ok() + ); + assert!(valid(&image(json!({"easy": {"provider": "codex", "model": "m"}}))).is_err()); + assert!(valid(&image(json!({"provider": "codex", "model": " "}))).is_err()); + assert!( + valid(&image( + json!({"provider": "codex", "model": "m", "effort": 1}) + )) + .is_err() + ); // Turning one boss off leaves the other's block alone. let document = json!({"preset": "off", "custom": "我的规则", "apps": {"codex": "custom"}}); @@ -328,3 +343,87 @@ fn a_held_sync_keeps_the_old_block_until_the_service_catches_up() { assert!(sync(&apps, &apps, h, &split, &[], &backup, false).is_empty()); assert!(sync(&apps, &apps, h, &split, &[], &backup, true).is_empty()); } + +#[test] +fn image_rules_follow_the_optional_seat_and_clear_copied_custom_text() { + let mut document = json!({"preset": "split"}); + for mode in ["split", "parallel"] { + assert!( + !text_of(&document, mode) + .unwrap() + .contains("image-generator") + ); + } + document["image_generation"] = json!({"provider": "codex", "model": "chat"}); + let generated = text_of(&document, "split").unwrap(); + assert_eq!(generated.matches("image-generator").count(), 1); + document["custom"] = json!(generated); + assert_eq!( + text_of(&document, "custom") + .unwrap() + .matches("image-generator") + .count(), + 1 + ); + document["image_generation"] = Value::Null; + assert!( + !text_of(&document, "custom") + .unwrap() + .contains("image-generator") + ); + assert!( + !presets(&document)["split"] + .as_str() + .unwrap() + .contains("image-generator") + ); + assert!(text_of(&document, "off").is_none()); +} + +#[test] +fn image_rule_is_hidden_from_its_own_cli_and_shared_readers() { + let home = tempfile::tempdir().unwrap(); + let h = home.path(); + let app = |id, root: &str| App { + key: id, + id, + label: id, + root: h.join(root), + }; + let apps = [ + app("codex", ".codex"), + app("claude", ".claude"), + app("grok", ".grok"), + app("workbuddy", ".workbuddy"), + ]; + let mut document = + json!({"preset": "split", "image_generation": {"provider": "codex", "model": "chat"}}); + let rows = wanted(&apps, h, &document); + for (path, text) in &rows { + assert_eq!( + text.contains("image-generator"), + !path.starts_with(h.join(".codex")) + ); + } + document["image_generation"]["provider"] = json!("claude"); + let rows = wanted(&apps, h, &document); + assert!( + rows.iter() + .find(|(p, _)| p.starts_with(h.join(".claude"))) + .is_some_and(|(_, text)| !text.contains("image-generator")) + ); + assert!( + rows.iter() + .find(|(p, _)| p.starts_with(h.join(".codex"))) + .unwrap() + .1 + .contains("image-generator") + ); + document["image_generation"]["provider"] = json!("codebuddy"); + assert!( + wanted(&apps, h, &document) + .iter() + .find(|(p, _)| p.starts_with(h.join(".workbuddy"))) + .is_some_and(|(_, text)| !text.contains("image-generator")) + ); +} diff --git a/apps/tray/ui/advanced.js b/apps/tray/ui/advanced.js index 1f695ca9..bc8e8603 100644 --- a/apps/tray/ui/advanced.js +++ b/apps/tray/ui/advanced.js @@ -4,9 +4,10 @@ // one that fails stays unsaved for another try while the others land. // Orchestration lives in orchestration.json: the rules text each boss gets, by mode (`apps`, with -// `preset` for rows without one, and bosses connected later), the user's own text, and the Explorer -// and Worker seats boss reads from list_members. The App writes the modes into the connected boss -// apps' global rules. The rules files are listed one row per file with every app that reads it. +// `preset` for rows without one, and bosses connected later), the user's own text, and the Explorer, +// Worker and image generation seats boss reads from list_members. The App writes the modes into the +// connected boss apps' global rules. The rules files are listed one row per file with every app that +// reads it. const ORCHESTRATION_EFFECTS = { off: AN.L("不写入全局规则,boss 按自己的习惯派发。", "Does not write global rules; the boss dispatches in its own way."), split: AN.L("写入所选 boss 的全局规则:boss 未被点名也会把探索、规划交给 Explorer,实现交给 Worker,会用到它们的额度。", "Writes to selected bosses' global rules: even when not named, the boss delegates exploration and planning to Explorer, and implementation to Worker, using their quota."), @@ -20,7 +21,10 @@ const ORCHESTRATION_ROLES = [ ["worker", "hard", AN.L("Worker · 困难", "Worker · Hard"), AN.L("可写:界面以外的实现", "Writable: implementation other than interfaces")], ["worker_frontend", "easy", AN.L("Worker · 界面 · 简单", "Worker · Interface · Easy"), AN.L("可写:页面、样式、交互、看图", "Writable: pages, styles, interactions, images")], ["worker_frontend", "hard", AN.L("Worker · 界面 · 困难", "Worker · Interface · Hard"), AN.L("可写:页面、样式、交互、看图", "Writable: pages, styles, interactions, images")], + // One seat with no difficulty, its model the one the CLI talks to, which calls the image service. + ["image_generation", null, AN.L("生图(可选)", "Image generation (optional)"), null, AN.L("未配置", "Not configured"), AN.L("生图的对话模型", "Chat model for image generation")], ]; +const orchestrationSeat = (doc, role, difficulty) => difficulty ? doc[role]?.[difficulty] : doc[role]; const ORCHESTRATION_NAMES = { off: AN.L("关闭", "Off"), split: AN.L("分工协作", "Split"), parallel: AN.L("并行分工", "Parallel"), custom: AN.L("自定义", "Custom") }; let orchestration = null; // { document, error, presets, files, bosses } from GET /app/orchestration: what is saved. let orchestrationDraft = null; // The document as picked, without the custom text, which the editor holds. @@ -65,13 +69,15 @@ async function loadOrchestration() { // Review seats list the connected bosses' CLIs too. renderReviewSeats(); renderAdvancedBar(); + // Cards name the seats their pool left out. + renderCards(); } async function saveOrchestration() { const wanted = orchestrationWanted(); for (const [role, difficulty] of ORCHESTRATION_ROLES) { - const seat = wanted[role]?.[difficulty]; - if (seat && !sameJson(seat, orchestration.document[role]?.[difficulty])) await listSeatModel(seat); + const seat = orchestrationSeat(wanted, role, difficulty); + if (seat && !sameJson(seat, orchestrationSeat(orchestration.document, role, difficulty))) await listSeatModel(seat); } const response = await fetch("/app/orchestration", { method: "POST", @@ -90,11 +96,19 @@ async function saveOrchestration() { if (failed.length) throw new Error(AN.L(`已保存,但 ${failed.map((f) => `${f.path}(${f.error})`).join("、")} 未能写入`, `Saved, but failed to write ${failed.map((f) => `${f.path} (${f.error})`).join(", ")}`)); } +function orchestrationPresetText(mode) { + const text = orchestration?.presets[mode]; + const image = orchestration?.presets.image_generation; + if (!text || !image) return text; + const base = text.replace(image, ""); + return orchestrationShown()?.image_generation ? base + image : base; +} + function viewMode(mode) { const editor = $("custom-text"); // The editor starts from the mode the user leaves; what it shows is what gets written. if (mode === "custom" && !editor.value.trim()) { - editor.value = orchestration.presets[orchestrationView === "off" ? "split" : orchestrationView]; + editor.value = orchestrationPresetText(orchestrationView === "off" ? "split" : orchestrationView); } orchestrationView = mode; renderOrchestration(); @@ -156,16 +170,29 @@ function renderBossChips() { function setOrchestrationSeat(role, difficulty, seat) { const shown = orchestrationShown(); - const seats = { ...(shown[role] || {}) }; - if (seat) seats[difficulty] = seat; - else delete seats[difficulty]; - orchestrationDraft = { ...shown, [role]: seats }; - if (!Object.keys(seats).length) delete orchestrationDraft[role]; + let value = seat; + if (difficulty) { + value = { ...(shown[role] || {}) }; + if (seat) value[difficulty] = seat; + else delete value[difficulty]; + } + orchestrationDraft = { ...shown, [role]: value }; + if (!Object.keys(value || {}).length) delete orchestrationDraft[role]; renderOrchestration(); advancedEdited(); } +// Bosses on a saved mode dispatch through the seats, each boss by its own row. +function renderTaskScope() { + const on = orchestration && !orchestration.error + ? orchestration.bosses.filter((boss) => bossMode(orchestration.document, boss) !== "off") : []; + const names = on.flatMap((boss) => boss.labels).join(AN.L("、", ", ")); + $("task-scope").hidden = !on.length; + $("task-scope").textContent = on.length ? AN.L(`${names} 已启用编排,用“高级”里的工位派活;单独干活只提供给未启用编排的 boss。`, `${names} use orchestration and dispatch through the seats in Advanced; solo tasks are offered only to bosses without orchestration.`) : ""; +} + function renderOrchestration() { + renderTaskScope(); const shown = orchestrationShown(); const preset = orchestrationView; for (const button of $("orchestration-presets").querySelectorAll("button")) { @@ -176,24 +203,25 @@ function renderOrchestration() { } $("orchestration-effect").textContent = orchestration ? ORCHESTRATION_EFFECTS[preset] : ""; renderBossChips(); - const text = orchestration?.presets[preset]; + const text = orchestrationPresetText(preset); $("orchestration-text").hidden = !text; $("orchestration-text").textContent = text || ""; // An unread or broken file cannot take edits: nothing here could be saved or discarded. const editable = orchestration && !orchestration.error; $("orchestration-custom").hidden = preset !== "custom" || !editable; const roles = $("orchestration-roles"); - roles.hidden = preset === "off" || !editable; + roles.hidden = !editable; if (!roles.hidden) { const focusKey = document.activeElement?.dataset?.focusKey; - roles.replaceChildren(...ORCHESTRATION_ROLES.map(([role, difficulty, name, hint]) => { - const seat = shown[role]?.[difficulty]; + const visibleRoles = preset === "off" ? ORCHESTRATION_ROLES.filter(([role]) => role === "image_generation") : ORCHESTRATION_ROLES; + roles.replaceChildren(...visibleRoles.map(([role, difficulty, name, hint, unsetName, modelLabel]) => { + const seat = orchestrationSeat(shown, role, difficulty); // An unset interface seat runs the Worker seat of its difficulty, as the server fills it. const general = role === "worker_frontend" && shown.worker?.[difficulty] && ORCHESTRATION_ROLES.find(([r, d]) => r === "worker" && d === difficulty); - const unset = general ? AN.L(`沿用 ${general[2]}`, `Same as ${general[2]}`) : undefined; + const unset = general ? AN.L(`沿用 ${general[2]}`, `Same as ${general[2]}`) : unsetName; return el("div", { className: "review-row" }, - el("div", { className: "review-role" }, el("strong", {}, name), el("small", {}, hint)), - ...seatSelects(`${role}/${difficulty}`, name, seat, (next) => setOrchestrationSeat(role, difficulty, next), unset)); + el("div", { className: "review-role" }, el("strong", {}, name), hint && el("small", {}, hint)), + ...seatSelects(`${role}/${difficulty}`, name, seat, (next) => setOrchestrationSeat(role, difficulty, next), unset, modelLabel)); })); if (focusKey) roles.querySelector(`[data-focus-key="${CSS.escape(focusKey)}"]`)?.focus({ preventScroll: true }); } @@ -250,8 +278,22 @@ let seatsDraft = null; // Review seats as picked; `reviewSeats` holds what is sa const seatsShown = () => seatsDraft || reviewSeats; const seatsChanged = () => !sameJson(seatsShown(), reviewSeats); +// Every seat in use with the model its CLI's pool left out. Orchestration seats wait unused +// while no boss runs orchestration. +function seatConflicts() { + const review = REVIEW_ROLES.map(([role, name]) => ({ section: "review", name: AN.L(`${name}评审`, `${name} review`), seat: seatsShown()[role] })); + const doc = orchestrationShown(); + const live = Boolean(doc) && (doc.preset !== "off" || Object.values(doc.apps || {}).some((mode) => mode !== "off")); + const roles = !live ? [] : ORCHESTRATION_ROLES + .map(([role, difficulty, name]) => ({ section: "orchestration", name, seat: orchestrationSeat(doc, role, difficulty) })); + return [...review, ...roles].filter(({ seat }) => seat && outOfPool(seat.provider, seat.model)); +} +const seatConflictText = ({ name, seat }) => AN.L(`${name} 用的 ${seat.model} 不在 ${PROVIDER_NAMES[seat.provider]} 的可用模型里`, `${name} uses ${seat.model}, which is not in the available models of ${PROVIDER_NAMES[seat.provider]}`); +const memberSaveErrors = () => configurationErrors(); + // A picked model missing from that CLI's configured models is added to the last saved -// members.json, where the server looks for it; unsaved 员工 edits stay unsaved. +// members.json, where the server looks for it; unsaved 员工 edits stay unsaved. A CLI with a pool +// already configures all it may run, so a model it left out is refused instead. async function listSeatModel(seat) { const add = (doc) => { doc.clis ||= {}; @@ -263,6 +305,8 @@ async function listSeatModel(seat) { // Until 员工 is read there is nothing to add to: an empty document would replace the file. if (!savedSnapshot && !documentError) throw new Error(AN.L("员工配置还在读取,请稍后再保存", "Employee settings are still loading; please save in a moment")); const saved = JSON.parse(savedSnapshot || JSON.stringify({ version: 1, clis: {}, modes: {} })); + const pool = saved.clis?.[seat.provider]?.allowed_models; + if (pool && !pool.includes(seat.model)) throw new Error(AN.L(`${seat.model} 不在 ${PROVIDER_NAMES[seat.provider]} 的可用模型里`, `${seat.model} is not in the available models of ${PROVIDER_NAMES[seat.provider]}`)); if (Object.hasOwn(saved.clis?.[seat.provider]?.models || {}, seat.model)) return; if (documentError) throw new Error(AN.L("配置文件有错误,请先在“员工”里修复", "Configuration file has errors; fix them in “Employees” first")); await apiPostMembers(add(saved)); @@ -303,19 +347,29 @@ function reviewSelect(key, label, value, options, onPick, disabled = false) { }, options.map(([v, text, off]) => el("option", { value: v, selected: v === value, disabled: Boolean(off) }, text))); } +const seatModels = (provider) => listedPool(provider) ? [...listedPool(provider)] : getAllModelIds(provider); + // A seat's CLI, then one of that CLI's models, then an effort or the member's default. -function seatSelects(key, name, seat, onSet, unset = AN.L("自动", "Auto")) { +function seatSelects(key, name, seat, onSet, unset = AN.L("自动", "Auto"), modelLabel = AN.L(`${name}的模型`, `Model for ${name}`)) { const provider = seat?.provider || ""; - // Only CLIs 员工 lets work alone, or a connected boss's own CLI, which its built-in subagent - // runs; a disabled one stays shown while picked, so it can be changed. + // Orchestrated bosses use confirmed CLI configurations, not the hidden solo-task switches. const bossClis = (orchestration?.bosses || []).flatMap((boss) => (boss.ids || []).map((id) => id === "workbuddy" ? "codebuddy" : id)); - const usable = (p) => (known(p) && slot("task", p).enabled) || bossClis.includes(p); + const doc = orchestrationShown(); + const orchestrated = doc && [doc.preset, ...Object.values(doc.apps || {})].some((mode) => mode && mode !== "off"); + const inScene = (p, scene) => Object.hasOwn(currentDoc.modes?.[scene] || {}, p); + const confirmed = (p) => { + if (SCENE_IDS.every(isRestricted)) return SCENE_IDS.some((scene) => inScene(p, scene)); + if (isRestricted("task")) return inScene(p, "task"); + return Object.hasOwn(currentDoc.clis || {}, p) || SCENE_IDS.some((scene) => inScene(p, scene)); + }; + const usable = (p) => (known(p) && (orchestrated ? confirmed(p) : slot("task", p).enabled)) || bossClis.includes(p); const note = (p) => !usable(p) ? AN.L("(员工里已停用)", " (disabled in Employees)") - : getAllModelIds(p).length ? "" : AN.L("(没有可选模型)", " (no models available)"); + : seatModels(p).length ? "" : AN.L("(没有可选模型)", " (no models available)"); const clis = PROVIDERS.filter((p) => usable(p) || p === provider) .map((p) => [p, PROVIDER_NAMES[p] + note(p), Boolean(note(p)) && p !== provider]); - const models = provider ? getAllModelIds(provider) : []; - if (seat?.model && !models.includes(seat.model)) models.unshift(seat.model); + const models = provider ? seatModels(provider) : []; + const excluded = seat?.model && outOfPool(provider, seat.model); + if (seat?.model && !excluded && !models.includes(seat.model)) models.unshift(seat.model); const levels = seat?.model ? effortLevels(provider, seat.model) : []; if (seat?.effort && !levels.includes(seat.effort)) levels.push(seat.effort); // A seat saves the level it runs at, as a member does. @@ -325,7 +379,7 @@ function seatSelects(key, name, seat, onSet, unset = AN.L("自动", "Auto")) { }; const pickCli = (next) => { if (!next) return onSet(null); - const ids = getAllModelIds(next); + const ids = seatModels(next); const preferred = slot("task", next).model || nativeDefault(next).model; onSet(seatModel(next, ids.includes(preferred) ? preferred : ids[0])); }; @@ -335,8 +389,8 @@ function seatSelects(key, name, seat, onSet, unset = AN.L("自动", "Auto")) { : [...(seat.effort ? [] : [["", AN.L("选一个档位", "Choose effort"), true]]), ...levels.map((l) => [l, l])]; return [ reviewSelect(`${key}/cli`, AN.L(`${name}的 CLI`, `CLI for ${name}`), provider, [["", unset], ...clis], pickCli), - reviewSelect(`${key}/model`, AN.L(`${name}的模型`, `Model for ${name}`), seat?.model || "", - seat ? models.map((m) => [m, m]) : [["", "—"]], pickModel, !seat), + reviewSelect(`${key}/model`, modelLabel, seat?.model || "", + seat ? [...(excluded ? [[seat.model, AN.L(`${seat.model}(不在可用模型里)`, `${seat.model} (not available)`), true]] : []), ...models.map((m) => [m, m])] : [["", "—"]], pickModel, !seat), reviewSelect(`${key}/effort`, AN.L(`${name}的思考档位`, `Effort for ${name}`), seat?.effort || "", efforts, pickEffort, !seat || !levels.length), ]; } @@ -367,17 +421,20 @@ function advancedChanges() { } const customEmpty = () => usesCustom(orchestrationShown()) && !$("custom-text").value.trim(); +// A changed section cannot save while one of its seats is outside its CLI's pool. +const blockedSeat = () => seatConflicts().find(({ section }) => (section === "review" ? seatsChanged() : orchestrationChanged())); function renderAdvancedBar() { const changes = advancedChanges(); - const invalid = orchestrationChanged() && customEmpty(); + const blocked = blockedSeat(); + const invalid = (orchestrationChanged() && customEmpty()) || Boolean(blocked); $("advanced-dirty").textContent = changes.length ? AN.L(`未保存:${changes.join("、")}`, `Unsaved: ${changes.join(", ")}`) : ""; $("advanced-badge").hidden = !changes.length; $("advanced-save").disabled = busy || !changes.length || invalid; $("advanced-discard").disabled = busy || !changes.length; const feedback = $("advanced-feedback"); if (invalid) { - feedback.textContent = AN.L("自定义规则不能为空;不想写入时选「关闭」", "Custom rules cannot be empty; choose “Off” if you don’t want to write rules"); + feedback.textContent = blocked ? seatConflictText(blocked) : AN.L("自定义规则不能为空;不想写入时选「关闭」", "Custom rules cannot be empty; choose “Off” if you don’t want to write rules"); feedback.className = "save-feedback error"; feedback.dataset.validation = "true"; } else if (feedback.dataset.validation === "true") { @@ -409,7 +466,7 @@ function discardAdvanced() { } async function saveAdvanced() { - if (busy || !advancedChanges().length || (orchestrationChanged() && customEmpty())) return; + if (busy || !advancedChanges().length || (orchestrationChanged() && customEmpty()) || blockedSeat()) return; busy = true; $("pane-advanced").inert = true; updateControls(); diff --git a/apps/tray/ui/general-settings.js b/apps/tray/ui/general-settings.js index 663061ad..97dc89d9 100644 --- a/apps/tray/ui/general-settings.js +++ b/apps/tray/ui/general-settings.js @@ -14,7 +14,7 @@ open_on_start: true, tray_click: "panel", tray_count: true, - tray_font: "futura", + tray_font: "baskerville", keep_awake: false, }; diff --git a/apps/tray/ui/member-cards.js b/apps/tray/ui/member-cards.js index aaab2481..3e94e13b 100644 --- a/apps/tray/ui/member-cards.js +++ b/apps/tray/ui/member-cards.js @@ -5,6 +5,7 @@ function slotRows(provider, scene, split) { const native = nativeDefault(provider); const all = getAllModelIds(provider); if (native.model && !all.includes(native.model)) all.unshift(native.model); + const offered = current.allowed ? all.filter((m) => current.allowed.includes(m)) : all; const label = el("span", { className: "member-scene" }, split ? SCENES[scene] : L(`${SCENES.task}、${SCENES.discussion}`, `${SCENES.task}, ${SCENES.discussion}`)); const pick = (change) => changeSlot(provider, scene, change); // Turning a CLI off for both scenes asks first when it drops settings. @@ -18,16 +19,16 @@ function slotRows(provider, scene, split) { "aria-label": L("默认模型", "Default model"), onChange: (e) => pick({ enabled: true, model: e.target.value, effort: savedEffort(provider, e.target.value || native.model, "") }), }, el("option", { value: OFF, selected: true }, L("不用", "Off")), - ...(native.model ? [] : [el("option", { value: "" }, L("CLI 自己决定", "Follow CLI"))]), - ...all.map((m) => el("option", { value: m }, m === native.model ? L(`${m}(CLI 当前设置)`, `${m} (CLI current)`) : m))), el("span", { className: "member-reset-slot" })))]; + ...(native.model || current.allowed ? [] : [el("option", { value: "" }, L("CLI 自己决定", "Follow CLI"))]), + ...offered.map((m) => el("option", { value: m }, m === native.model ? L(`${m}(CLI 当前设置)`, `${m} (CLI current)`) : m))), el("span", { className: "member-reset-slot" })))]; } - const offered = current.allowed ? all.filter((m) => current.allowed.includes(m)) : all; const model = current.model || (current.allowed ? "" : native.model) || ""; const options = [ el("option", { value: OFF }, L("不用", "Off")), ...(current.allowed && !model ? [el("option", { value: "", selected: true, disabled: true }, L("选一个默认模型", "Choose default model"))] : native.model ? [] : [el("option", { value: "", selected: !model }, L("CLI 自己决定", "Follow CLI"))]), + ...(model && !offered.includes(model) ? [el("option", { value: model, selected: true, disabled: true }, L(`${model}(不在可用模型里)`, `${model} (not available)`))] : []), ...offered.map((m) => el("option", { value: m, selected: m === model }, m === native.model ? L(`${m}(CLI 当前设置)`, `${m} (CLI current)`) : m)), ]; const modelLine = el("div", { className: "member-line" }, @@ -42,7 +43,7 @@ function slotRows(provider, scene, split) { pick({ model: value, effort: savedEffort(provider, id, sceneEffort(scene || "task", provider, id)) }); }, }, ...options), - current.model && native.model && current.model !== native.model + current.model && offered.includes(native.model) && current.model !== native.model ? el("button", { type: "button", className: "member-reset", @@ -53,58 +54,80 @@ function slotRows(provider, scene, split) { }, "↺") : el("span", { className: "member-reset-slot" })); - const effortLine = slotEffortLine({ provider, scene, current, model, key, pick }); + return [el("div", { className: "member-row" }, label, modelLine, slotEffortLine({ provider, scene, current, model, key, pick }))]; +} - return slotAllowedRows({ provider, current, native, all, offered, model, key, pick, label, modelLine, effortLine }); +// What the pool row reads: scenes of an older file can still hold lists of their own. +function poolSummary(provider, all, pool) { + const count = (list) => (list ? L(`${list.length} 个`, `${list.length}`) : L(`全部 ${all.length} 个`, `All ${all.length}`)); + const scenes = SCENE_IDS.filter((s) => slot(s, provider).enabled); + const lists = scenes.map((s) => slot(s, provider).allowed); + if (!hasPool(provider) && lists.length > 1 && !sameJson(lists[0], lists[1])) { + return scenes.map((s, i) => `${SCENES[s]} ${count(lists[i])}`).join(L("、", ", ")); + } + if (!pool) return count(null); + const names = pool.slice(0, 3).join(L("、", ", ")); + return L(`${pool.length} 个:${names}${pool.length > 3 ? " 等" : ""}`, `${pool.length}: ${names}${pool.length > 3 ? ", etc." : ""}`); } -function slotAllowedRows({ provider, current, native, all, offered, model, key, pick, label, modelLine, effortLine }) { +// One list per CLI; every scene and seat of the CLI picks its model from it. +function poolRows(provider) { + const key = `${provider}/pool`; + const native = nativeDefault(provider); + const all = getAllModelIds(provider); + if (native.model && !all.includes(native.model)) all.unshift(native.model); + const pool = shownPool(provider); const open = pickerOpen === key; - const summary = current.allowed - ? L(`${offered.length} 个:${offered.slice(0, 3).join("、")}${offered.length > 3 ? " 等" : ""}`, `${offered.length}: ${offered.slice(0, 3).join(", ")}${offered.length > 3 ? ", etc." : ""}`) - : L(`全部 ${all.length} 个`, `All ${all.length}`); - const listButton = el("button", { - type: "button", - className: "member-list", - dataset: { focusKey: `${key}/list` }, - title: L("boss 可以选用的模型", "Models the boss may choose from"), - "aria-expanded": String(open), - onClick: () => { pickerOpen = open ? null : key; renderCards(); }, - }, el("span", { className: "member-muted" }, L("可选 ", "Allowed: ")), textNode(summary), el("span", { className: "member-muted" }, open ? " ▴" : " ▾")); - - const rows = [el("div", { className: "member-row" }, label, modelLine, listButton, effortLine)]; + const rows = [el("div", { className: "member-row" }, + el("span", { className: "member-scene" }, L("可用模型", "Available models")), + el("button", { + type: "button", + className: "member-list", + dataset: { focusKey: `${key}/list` }, + title: L("单独干活、开会和工位都从这里选", "Solo tasks, meetings and seats pick from these"), + "aria-expanded": String(open), + onClick: () => { pickerOpen = open ? null : key; renderCards(); }, + }, el("span", {}, poolSummary(provider, all, pool)), el("span", { className: "member-muted" }, open ? "▴" : "▾")))]; if (open) { const toggle = (m, on) => { - const list = current.allowed || []; + const list = pool || []; const allowed = on ? [...list, m] : list.filter((x) => x !== m); - if (allowed.length) pick({ allowed }); + if (allowed.length) writePool(provider, allowed); else renderCards(); }; + const defaults = SCENE_IDS.map((s) => slot(s, provider).model); rows.push(el("div", { className: "member-picker" }, el("label", { className: "member-option member-option-all" }, el("input", { type: "checkbox", dataset: { focusKey: `${key}/all` }, - checked: !current.allowed, + checked: !pool, disabled: !all.length, - onChange: (e) => pick(e.target.checked - ? { allowed: null, model: current.model === native.model ? "" : current.model } - : { allowed: all.length ? [...all] : model ? [model] : [] }), + onChange: (e) => writePool(provider, e.target.checked ? null : [...all]), }), textNode(L("全部模型(以后新出的也算)", "All models (including future releases)"))), ...all.map((m) => el("label", { className: "member-option" }, el("input", { type: "checkbox", dataset: { focusKey: `${key}/allow/${m}` }, - checked: !current.allowed || current.allowed.includes(m), - disabled: !current.allowed || m === model, + checked: !pool || pool.includes(m), + disabled: !pool, onChange: (e) => toggle(m, e.target.checked), }), el("span", { className: "member-option-id" }, m), - m === model ? el("span", { className: "badge badge-muted" }, L("默认", "Default")) : null)))); + defaults.includes(m) ? el("span", { className: "badge badge-muted" }, L("默认", "Default")) : null)))); } return rows; } +// Bindings the pool no longer has stay as saved, each named on its CLI's card until changed. +function poolConflicts(provider) { + const scenes = SCENE_IDS.map((s) => [SCENES[s], slot(s, provider)]) + .filter(([, current]) => current.enabled && current.model && outOfPool(provider, current.model)) + .map(([name, current]) => L(`${name}的默认模型 ${current.model} 不在可用模型里`, `${name} default model ${current.model} is not in the available models`)); + const seats = seatConflicts().filter(({ seat }) => seat.provider === provider).map(seatConflictText); + return [...scenes, ...seats].map((text) => el("p", { className: "member-error" }, text)); +} + function renderMembership(provider, enabled) { return el("button", { type: "button", @@ -176,13 +199,13 @@ function renderCards() { const card = el("div", { className: `cli-card member-card${enabled ? "" : " off"}`, dataset: { provider } }, head); if (pendingDisable === provider) { card.appendChild(el("div", { className: "disable-confirm", role: "group", "aria-label": L(`确认移除 ${name}`, `Confirm removing ${name}`) }, - el("p", {}, L(`移除会清除 ${name} 的默认模型和可选模型。重新启用不会恢复;点击“保存设置”后才会生效。`, `Removing clears default and allowed models for ${name}. Re-enabling will not restore them; takes effect after clicking “Save settings”.`)), + el("p", {}, L(`移除会清除 ${name} 的默认模型。重新启用不会恢复;点击“保存设置”后才会生效。`, `Removing clears default models for ${name}. Re-enabling will not restore them; takes effect after clicking “Save settings”.`)), el("div", { className: "btn-group" }, el("button", { type: "button", dataset: { focusKey: `${provider}/cancel-disable` }, onClick: () => { pendingDisable = null; renderCards(); } }, L("保留", "Keep")), el("button", { type: "button", className: "danger", dataset: { focusKey: `${provider}/confirm-disable` }, onClick: () => setProviderEnabled(provider, false, true) }, L("确认移除", "Remove"))))); } const rows = split ? SCENE_IDS.flatMap((s) => slotRows(provider, s, true)) : slotRows(provider, null, false); - for (const row of rows) card.appendChild(row); + for (const row of [...poolRows(provider), ...rows, ...poolConflicts(provider)]) card.appendChild(row); const runtime = renderClaudeInteractive(provider); if (runtime) card.appendChild(runtime); if (info.error) card.appendChild(el("p", { className: "member-error" }, L(`读取模型列表失败:${info.error}`, `Failed to load model list: ${info.error}`))); @@ -213,7 +236,7 @@ function renderCards() { // Save members.json with race version checking async function saveMembers() { if (busy || !dirty || documentError || emptyScene()) return; - const errors = configurationErrors(); + const errors = memberSaveErrors(); if (errors.length) { showNotice(errors[0].message, true); updateControls(); diff --git a/apps/tray/ui/member-models.js b/apps/tray/ui/member-models.js index 5f6e5b99..c99ad506 100644 --- a/apps/tray/ui/member-models.js +++ b/apps/tray/ui/member-models.js @@ -1,6 +1,12 @@ "use strict"; const isRestricted = (scene) => Object.hasOwn(currentDoc.modes || {}, scene); +// The CLI's one model list, shared by its scenes and seats. Once present it replaces the scene +// lists of older files; null means any model, now or later. +const hasPool = (provider) => Object.hasOwn(currentDoc.clis?.[provider] || {}, "allowed_models"); +const listedPool = (provider) => currentDoc.clis?.[provider]?.allowed_models || null; +const outOfPool = (provider, model) => Boolean(listedPool(provider)) && !listedPool(provider).includes(model); + // A model the App starts configuring keeps the CLI's own default effort until one is chosen (ADR-0028). function ensureCliModel(provider, modelId) { if (!currentDoc.clis) currentDoc.clis = {}; @@ -86,11 +92,19 @@ function slot(scene, provider) { const enabled = isRestricted(scene) ? Boolean(selection) : Boolean(detected[provider]?.installed || currentDoc.defaults?.[scene]?.[provider]); const model = (selection ? selection.default : currentDoc.defaults?.[scene]?.[provider]) || ""; - const allowed = selection?.models?.length ? [...selection.models] : null; + const pool = hasPool(provider) ? listedPool(provider) : selection?.models; + const allowed = pool?.length ? [...pool] : null; const effort = enabled ? sceneEffort(scene, provider, model || nativeDefault(provider).model) : ""; return { enabled, model: enabled ? model : "", allowed, effort }; } +// The pool as the card shows it: an older file's scene lists together, unless a scene is open. +function shownPool(provider) { + if (hasPool(provider)) return listedPool(provider); + const lists = SCENE_IDS.map((s) => slot(s, provider)).filter((s) => s.enabled).map((s) => s.allowed); + return lists.length && lists.every(Boolean) ? [...new Set(lists.flat())] : null; +} + // Scenes that show the same model and effort, followed or chosen, need only one row. function shownSlot(scene, provider) { const current = slot(scene, provider); @@ -143,12 +157,38 @@ function writeSlot(scene, provider, next) { const model = next.model || (next.allowed ? (next.allowed.includes(native) ? native : next.allowed[0]) : ""); const selection = restrict(scene); const conf = (selection[provider] ||= { models: [], overrides: {} }); - conf.models = next.allowed ? [...new Set([...next.allowed, model])] : []; + conf.models = next.allowed && !hasPool(provider) ? [...new Set([...next.allowed, model])] : []; if (model) conf.default = model; else delete conf.default; for (const m of [model, ...(next.allowed || [])]) if (m) ensureCliModel(provider, m); } +// A scene that follows the CLI names the CLI's model once a pool has it, as a model list needs a +// default. Any other default stays as it is, reported until the user changes it. +function pinFollowed(provider, conf) { + const native = nativeDefault(provider).model; + if (conf.default || !listedPool(provider)?.includes(native)) return; + conf.default = native; + const shared = currentDoc.clis[provider].models[native]; + const effort = savedEffort(provider, native, ""); + if (effort && !shared.effort) shared.effort = effort; +} + +// Editing the pool takes over from the scene lists, so they cannot narrow it a second time. +function writePool(provider, allowed) { + for (const m of allowed || []) ensureCliModel(provider, m); + const cli = ((currentDoc.clis ||= {})[provider] ||= { models: {} }); + if (allowed) cli.allowed_models = [...allowed]; + else delete cli.allowed_models; + for (const scene of SCENE_IDS) { + const conf = restrict(scene)[provider]; + if (!conf) continue; + conf.models = []; + pinFollowed(provider, conf); + } + edited(); +} + // One model's effort in both scenes: shared when they agree, else a scene override. An override // cannot unset the shared level, so a scene that follows the CLI holds the shared one. function writeEfforts(provider, modelId, wants) { @@ -221,7 +261,7 @@ function setProviderEnabled(provider, enabled, confirmed = false) { pendingDisable = null; for (const scene of SCENE_IDS) { if (!enabled) delete restrict(scene)[provider]; - else restrict(scene)[provider] ||= { models: [], overrides: {} }; + else pinFollowed(provider, restrict(scene)[provider] ||= { models: [], overrides: {} }); } edited(); } @@ -247,4 +287,4 @@ function setSplit(provider, split) { edited(); } -let pickerOpen = null; // "provider/scene" whose model list is open +let pickerOpen = null; // "provider/pool" whose model list is open diff --git a/apps/tray/ui/mock.js b/apps/tray/ui/mock.js index 0bbdcede..1a3dcede 100644 --- a/apps/tray/ui/mock.js +++ b/apps/tray/ui/mock.js @@ -863,7 +863,7 @@ All checks passed! open_on_start: true, tray_click: "panel", tray_count: true, - tray_font: "futura", + tray_font: "baskerville", keep_awake: false, }, alerts: { diff --git a/apps/tray/ui/settings-state.js b/apps/tray/ui/settings-state.js index 9738382c..cc8d7db9 100644 --- a/apps/tray/ui/settings-state.js +++ b/apps/tray/ui/settings-state.js @@ -435,7 +435,7 @@ function configurationErrors() { if (!Object.hasOwn(models, model)) add(provider, model, scope, L("模型尚未配置", "Model is not configured")); } if (Object.hasOwn(selection, "default") && - (!Object.hasOwn(models, selection.default) || (selection.models.length && !selection.models.includes(selection.default)))) { + (!Object.hasOwn(models, selection.default) || (!hasPool(provider) && selection.models.length && !selection.models.includes(selection.default)))) { add(provider, selection.default, scope, L("默认模型不在允许范围内", "Default model is not in the allowed list")); } for (const [model, override] of Object.entries(selection.overrides || {})) { @@ -458,6 +458,12 @@ function configurationErrors() { if (current.model && !current.effort && effortLevels(provider, current.model).length > 1) { add(provider, current.model, SCENES[scene], L("要选一个思考档位", "Choose an effort level")); } + if (current.enabled && listedPool(provider) && !current.model) { + add(provider, L("默认模型", "default model"), SCENES[scene], L("要选一个默认模型", "Choose a default model")); + } + if (current.enabled && current.model && outOfPool(provider, current.model)) { + add(provider, current.model, SCENES[scene], L("不在可用模型里", "Not in the available models")); + } } } return errors; @@ -471,7 +477,7 @@ function updateControls() { renderAdvancedBar(); const emptyRestricted = emptyScene(); - const errors = configurationErrors(); + const errors = memberSaveErrors(); const canSave = !busy && dirty && !documentError && !emptyRestricted && errors.length === 0; $("save-btn").disabled = !canSave; @@ -611,6 +617,7 @@ function setTab(tab) { if (tab === "voice") window.AgentNaveVoiceSettings?.load(); if (tab === "about") loadStatus().then(checkIfStale); if (tab === "members") { + loadOrchestration(); loadLimit(); window.AgentNaveAlertsSettings?.load(); } diff --git a/apps/tray/ui/settings.css b/apps/tray/ui/settings.css index 27becb6c..295a0f3d 100644 --- a/apps/tray/ui/settings.css +++ b/apps/tray/ui/settings.css @@ -106,7 +106,8 @@ .char-preview .fx { display: none; } /* Fixed control columns, with explanations kept beside the setting they describe. */ -.settings-page { overflow-anchor: none; } +.settings-page { --control-height: 32px; overflow-anchor: none; } +.settings-page select { appearance: none; -webkit-appearance: none; box-sizing: border-box; height: var(--control-height); max-width: 100%; min-width: 0; padding: 0 30px 0 10px; font-size: 12.5px; background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%); background-position: right 13px center, right 9px center; background-size: 4px 4px; background-repeat: no-repeat; } .disable-confirm { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; padding: 14px 18px; background: color-mix(in srgb, var(--bad) 7%, var(--surface)); border-bottom: 1px solid var(--line); } .disable-confirm p { flex: 1 1 300px; font-size: 12px; line-height: 1.6; color: var(--soft); } .leave-confirm { pointer-events: auto; border: 1px solid var(--line); border-radius: 12px; margin-bottom: 8px; box-shadow: 0 3px 14px #00000014; } @@ -176,23 +177,28 @@ .member-end > .segment-control, .member-row .member-efforts { display: flex; min-width: 0; } .member-end > .segment-control > .segment-btn, .member-efforts > .segment-btn { flex: 1 1 0; min-width: 0; padding-left: 2px; padding-right: 2px; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .member-hidden { visibility: hidden; } +.member-row > :nth-child(3) { grid-column: 3 / -1; } +.member-row > .member-list { grid-column: 2; width: calc(100% - 34px); } +.member-scope { margin: 0 0 10px; } .member-scene { color: var(--muted); font-size: 12.5px; } .member-tail { display: flex; align-items: center; gap: 8px; min-width: 0; } .member-tail > :first-child { flex: 1; } .member-toggle { flex: none; font-size: 12px; padding: 4px 10px; } -.member-tier { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: 12.5px; color: var(--muted); } -.member-tier select { flex: 1; min-width: 0; font-size: 12.5px; padding: 4px 8px; } +.member-tier { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; font-size: 12.5px; color: var(--muted); } +.member-tier select { flex: none; width: 112px; } .member-interactive { grid-column: 2 / -1; display: inline-flex; align-items: center; justify-self: start; gap: 8px; font-size: 12.5px; cursor: pointer; } .member-switch { display: inline-flex; align-items: center; gap: 8px; padding-left: 30px; font-size: 12.5px; cursor: pointer; } .member-error { margin: 0; padding-left: 30px; font-size: 12px; color: var(--bad); overflow-wrap: anywhere; } .member-line { display: flex; align-items: center; gap: 8px; min-width: 0; } .member-line > select, .member-line > .member-efforts { flex: 1; min-width: 0; } .member-reset-slot { flex: none; width: 26px; } -.member-model { font-size: 12.5px; padding: 5px 8px; } +.member-model { font-size: 12.5px; } .member-reset { flex: none; width: 26px; padding: 0; min-height: 24px; font-size: 13px; color: var(--muted); } .member-line > .member-muted { flex: none; } .member-muted { color: var(--muted); font-size: 12px; } -.member-list { width: 100%; min-width: 0; font-size: 12.5px; padding: 5px 10px; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.member-list { display: flex; align-items: center; justify-content: space-between; gap: 8px; box-sizing: border-box; height: var(--control-height); min-width: 0; font-size: 12.5px; padding: 0 10px; text-align: left; white-space: nowrap; } +.member-list > :first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.member-list > .member-muted { flex: none; } .member-picker { margin: 0 0 4px 30px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px; display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 6px 14px; } .member-option { display: flex; align-items: center; gap: 8px; font-size: 12.5px; cursor: pointer; } .member-option:has(input:disabled) { cursor: default; } @@ -203,13 +209,14 @@ .member-card { --member-cols: 96px minmax(0, 1fr) minmax(0, 1fr); } .member-title { grid-column: 1 / -1; } .member-end > .segment-control { grid-column: 2; } - .member-row > :nth-child(4) { grid-column: 2 / -1; } + .member-row .member-efforts { flex-wrap: wrap; } + .member-efforts > .segment-btn { flex: 1 0 auto; min-width: max-content; padding-left: 8px; padding-right: 8px; } } @media (max-width: 700px) { .member-card { --member-cols: minmax(0, 1fr); padding: 12px 14px; } .member-head, .member-row { padding-left: 0; gap: 8px; } .member-title { margin-left: 0; } - .member-end > .segment-control, .member-row > :nth-child(4) { grid-column: auto; } + .member-end > .segment-control, .member-row > :nth-child(3), .member-row > .member-list { grid-column: auto; } .member-row > .member-list { width: calc(100% - 34px); } .member-switch, .member-error { padding-left: 0; } .member-interactive { grid-column: auto; } @@ -226,7 +233,7 @@ .updates-card h2 { font-size: 14px; margin-bottom: 14px; } .setting-row > h2 { margin: 0; font-size: 14px; } /* Typed fields look like the selects beside them. */ -.settings-page :is(input[type="text"], input[type="password"], input[type="url"], input:not([type])) { box-sizing: border-box; min-height: 34px; padding: 7px 11px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface); color: var(--ink); font: inherit; font-size: 13px; } +.settings-page :is(input[type="text"], input[type="password"], input[type="url"], input:not([type])) { box-sizing: border-box; height: var(--control-height); padding: 0 10px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface); color: var(--ink); font: inherit; font-size: 12.5px; } .settings-page :is(input, textarea):focus-visible { outline: 2px solid color-mix(in srgb, var(--accent) 40%, transparent); outline-offset: 1px; } /* A host's notes and details start under its name, not under its icon. */ .hc-main > :is(.hc-note, .hc-orphan, .hc-details) { margin-left: 46px; } @@ -234,10 +241,14 @@ .hc-main > :is(.hc-note, .hc-orphan, .hc-details) { margin-left: 0; } } .review-grid { display: grid; gap: 8px; } -.review-row { display: grid; grid-template-columns: minmax(150px, 1.2fr) 1fr 1.4fr 1fr; gap: 10px; align-items: center; } +.review-row { display: grid; grid-template-columns: minmax(150px, 1fr) minmax(0, 180px) minmax(0, 260px) minmax(0, 112px); gap: 10px; align-items: center; } .review-role { display: flex; flex-direction: column; gap: 2px; } .review-role small { color: var(--muted); font-size: 11px; } .review-row select { min-width: 0; } +@media (max-width: 700px) { + .review-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) minmax(80px, 112px); } + .review-role { grid-column: 1 / -1; } +} .skill-rows { display: grid; gap: 10px; max-width: 1100px; } .skill-row { border: 1px solid var(--line); border-radius: 12px; background: var(--panel, var(--bg)); } .skill-head { display: grid; grid-template-columns: 1fr auto 16px; align-items: center; gap: 16px; width: 100%; padding: 14px 18px; border: 0; background: none; text-align: left; cursor: pointer; color: inherit; } @@ -295,8 +306,8 @@ .voice-preview { width: 64px; } #voice-settings .setting-row > .voice-select { width: min(100%, 320px); } .voice-select { position: relative; min-width: 0; margin: 0; } -.voice-select summary { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; list-style: none; cursor: pointer; border: 1px solid var(--line); border-radius: 7px; background: var(--surface); padding: 7px 26px 7px 10px; font-size: 13px; } -.voice-select summary::after { content: "⌄"; position: absolute; right: 9px; } +.voice-select summary { box-sizing: border-box; height: var(--control-height); line-height: calc(var(--control-height) - 2px); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; list-style: none; cursor: pointer; border: 1px solid var(--line); border-radius: 7px; background: var(--surface); padding: 0 28px 0 10px; font-size: 12.5px; } +.voice-select summary::after { content: "⌄"; position: absolute; right: 9px; top: 0; } .voice-select summary[aria-disabled="true"] { opacity: .5; cursor: default; } .voice-options { position: absolute; top: calc(100% + 4px); left: 0; right: 0; max-height: 220px; overflow-y: auto; z-index: 20; padding: 4px; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 8px 24px #0003; } .voice-options button { display: block; width: 100%; text-align: left; border: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } @@ -317,9 +328,10 @@ /* Voice: one control column, groups as headings instead of boxes inside boxes. */ #voice-settings { --control: 320px; } #voice-settings .setting-row > :is(.voice-select, .voice-key, .voice-summary-input, .voice-summary-model, .voice-chatgpt, .voice-summary-effort) { width: min(100%, var(--control)); } +#voice-settings .setting-row > .voice-summary-effort { width: 160px; } .voice-key { grid-template-columns: minmax(0, 1fr) auto auto; } .voice-summary-input { grid-template-columns: minmax(0, 1fr) auto; } -.voice-key button, .voice-summary-input button, .voice-refresh { min-width: 56px; } +.voice-key button, .voice-summary-input button, .voice-refresh { min-width: 56px; height: var(--control-height); padding: 0 10px; font-size: 12.5px; } .voice-wide { flex-direction: column; align-items: stretch; gap: 10px; } .voice-wide > .voice-prompt-editor { width: 100%; } .voice-section { margin: 24px 0 0; padding: 0; border: 0; border-radius: 0; } diff --git a/apps/tray/ui/settings.html b/apps/tray/ui/settings.html index d9073841..5e6777b6 100644 --- a/apps/tray/ui/settings.html +++ b/apps/tray/ui/settings.html @@ -74,6 +74,8 @@