diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c5f450e0c..74f260c4f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -315,9 +315,9 @@ jobs: key_path.chmod(0o600) with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as env_file: - env_file.write(f"TAURI_SIGNING_PRIVATE_KEY_PATH={key_path}\n") + env_file.write(f"TAURI_SIGNING_PRIVATE_KEY={normalized_key}\n") - print(f"Normalized updater key written to {key_path}") + print(f"Normalized updater key verified and staged for Tauri signing from {key_path}") PY - name: Validate updater signing key @@ -327,7 +327,6 @@ jobs: PROBE_FILE="$RUNNER_TEMP/tauri-signing-probe.txt" printf 'lime-release-signing-probe\n' > "$PROBE_FILE" npx tauri signer sign \ - -f "$TAURI_SIGNING_PRIVATE_KEY_PATH" \ -p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" \ "$PROBE_FILE" >/dev/null diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index 75bbbd37d..b0adadc86 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -39,6 +39,7 @@ - `install_browser_connector_extension_cmd` - `open_browser_extensions_page_cmd` - `open_browser_remote_debugging_page_cmd` +- `open_browser_connector_guide_window` - `disconnect_browser_connector_session` 这些命令属于当前设置主路径,不应再在页面组件里散落裸 `invoke`。 diff --git a/scripts/lib/harness-eval-history-window.test.ts b/scripts/lib/harness-eval-history-window.test.ts index 4cfb64fd4..0f060de40 100644 --- a/scripts/lib/harness-eval-history-window.test.ts +++ b/scripts/lib/harness-eval-history-window.test.ts @@ -219,5 +219,5 @@ describe("Harness eval history window", () => { expect(report.signals).not.toContain( "样本数不足 2,当前仅形成 trend seed,还不能判断长期退化。", ); - }, 30_000); + }, 90_000); }); diff --git a/scripts/verify-gui-smoke.mjs b/scripts/verify-gui-smoke.mjs index 2733ea19a..99954fbf0 100644 --- a/scripts/verify-gui-smoke.mjs +++ b/scripts/verify-gui-smoke.mjs @@ -990,6 +990,7 @@ async function waitForBridgeHealth(options, startedByScript) { let lastExitedChildLogAt = 0; let lastError = null; let lastHeartbeatAt = startedAt; + let lastHealthDuringCompileAt = 0; console.log(`[bridge:health] 开始检查: ${options.healthUrl}`); @@ -1056,6 +1057,20 @@ async function waitForBridgeHealth(options, startedByScript) { const elapsed = Date.now() - startedAt; const status = payload && typeof payload === "object" ? payload.status : undefined; + const smokeRustcProcesses = listActiveGuiSmokeGroupProcesses( + startedByScript, + ).filter((item) => item.command.includes("/bin/rustc")); + if (startedByScript && smokeRustcProcesses.length > 0) { + if (elapsed - lastHealthDuringCompileAt >= GUI_SMOKE_BRIDGE_HEARTBEAT_MS) { + lastHealthDuringCompileAt = elapsed; + const heartbeat = describeGuiSmokeHeartbeat(startedByScript); + console.log( + `[bridge:health] 检测到健康响应但本轮 headless Tauri 仍在编译,继续等待本轮 DevBridge 稳定就绪${heartbeat ? `;进程组: ${heartbeat}` : ""}`, + ); + } + await sleep(options.intervalMs); + continue; + } console.log( `[bridge:health] 就绪: ${options.healthUrl} (${elapsed}ms)${status ? ` status=${status}` : ""}`, ); @@ -1369,7 +1384,21 @@ async function main() { runCommand( npmCommand, - ["run", "smoke:agent-runtime-tool-surface-page"], + [ + "run", + "smoke:agent-runtime-tool-surface-page", + "--", + "--app-url", + options.appUrl, + "--health-url", + options.healthUrl, + "--invoke-url", + options.invokeUrl, + "--timeout-ms", + String(options.timeoutMs), + "--interval-ms", + String(options.intervalMs), + ], "smoke:agent-runtime-tool-surface-page", options.timeoutMs + 30_000, ); diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/anthropic.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/anthropic.rs index ac29641a1..64fee99df 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/anthropic.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/anthropic.rs @@ -49,10 +49,13 @@ fn should_use_bearer_auth_for_anthropic_host(host: &str) -> bool { [ "bigmodel.cn/api/anthropic", + "api.z.ai/api/anthropic", "moonshot.cn/anthropic", "moonshot.ai/anthropic", "minimaxi.com/anthropic", "minimax.io/anthropic", + "coding.dashscope.aliyuncs.com/apps/anthropic", + "coding-intl.dashscope.aliyuncs.com/apps/anthropic", "token-plan-cn.xiaomimimo.com/anthropic", ] .iter() @@ -409,6 +412,12 @@ mod tests { assert!(should_use_bearer_auth_for_anthropic_host( "https://open.bigmodel.cn/api/anthropic" )); + assert!(should_use_bearer_auth_for_anthropic_host( + "https://api.z.ai/api/anthropic" + )); + assert!(should_use_bearer_auth_for_anthropic_host( + "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" + )); } #[test] @@ -416,6 +425,9 @@ mod tests { assert!(!should_use_bearer_auth_for_anthropic_host( "https://api.anthropic.com" )); + assert!(!should_use_bearer_auth_for_anthropic_host( + "https://api.kimi.com/coding/" + )); } #[test] @@ -480,6 +492,12 @@ mod tests { assert!(supports_automatic_prompt_cache_for_host( "https://api.minimaxi.com/anthropic" )); + assert!(supports_automatic_prompt_cache_for_host( + "https://coding.dashscope.aliyuncs.com/apps/anthropic" + )); + assert!(supports_automatic_prompt_cache_for_host( + "https://api.kimi.com/coding/" + )); } #[test] diff --git a/src-tauri/crates/core/src/database/dao/api_key_provider.rs b/src-tauri/crates/core/src/database/dao/api_key_provider.rs index f104bc66e..7e3ad8b19 100644 --- a/src-tauri/crates/core/src/database/dao/api_key_provider.rs +++ b/src-tauri/crates/core/src/database/dao/api_key_provider.rs @@ -296,8 +296,14 @@ mod tests { fn test_known_official_anthropic_compatible_hosts_default_to_automatic() { let hosts = [ "https://open.bigmodel.cn/api/anthropic", + "https://api.z.ai/api/anthropic", "https://api.moonshot.cn/anthropic", + "https://api.moonshot.ai/anthropic", + "https://api.kimi.com/coding/", "https://api.minimaxi.com/anthropic", + "https://api.minimax.io/anthropic", + "https://coding.dashscope.aliyuncs.com/apps/anthropic", + "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", "https://token-plan-cn.xiaomimimo.com/anthropic", ]; diff --git a/src-tauri/crates/core/src/provider_prompt_cache_support.rs b/src-tauri/crates/core/src/provider_prompt_cache_support.rs index 0f00559dd..c935e129f 100644 --- a/src-tauri/crates/core/src/provider_prompt_cache_support.rs +++ b/src-tauri/crates/core/src/provider_prompt_cache_support.rs @@ -53,8 +53,14 @@ mod tests { fn known_official_anthropic_compatible_hosts_should_match() { let hosts = [ "https://open.bigmodel.cn/api/anthropic", + "https://api.z.ai/api/anthropic", "https://api.moonshot.cn/anthropic", + "https://api.moonshot.ai/anthropic", + "https://api.kimi.com/coding/", "https://api.minimaxi.com/anthropic", + "https://api.minimax.io/anthropic", + "https://coding.dashscope.aliyuncs.com/apps/anthropic", + "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", "https://token-plan-cn.xiaomimimo.com/anthropic", ]; diff --git a/src-tauri/crates/services/src/model_registry_service.rs b/src-tauri/crates/services/src/model_registry_service.rs index f8d3a803e..d68eae24c 100644 --- a/src-tauri/crates/services/src/model_registry_service.rs +++ b/src-tauri/crates/services/src/model_registry_service.rs @@ -2373,6 +2373,38 @@ impl ModelRegistryService { return &["zai"]; } + if host.contains("moonshot.cn") { + return &["moonshotai-cn", "moonshotai", "kimi-for-coding"]; + } + + if host.contains("moonshot.ai") { + return &["moonshotai", "kimi-for-coding"]; + } + + if host.contains("api.kimi.com") { + return &["kimi-for-coding"]; + } + + if host.contains("minimaxi.com") { + return &["minimax-cn", "minimax"]; + } + + if host.contains("minimax.io") { + return &["minimax"]; + } + + if host.contains("coding-intl.dashscope.aliyuncs.com") { + return &["alibaba", "alibaba-cn"]; + } + + if host.contains("dashscope-intl.aliyuncs.com") { + return &["alibaba", "alibaba-cn"]; + } + + if host.contains("dashscope.aliyuncs.com") { + return &["alibaba-cn", "alibaba"]; + } + if host.contains("anthropic.com") { return &["anthropic"]; } @@ -3708,6 +3740,22 @@ mod tests { ), ["zhipuai"] ); + assert_eq!( + ModelRegistryService::infer_provider_ids_from_api_host( + "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" + ), + ["alibaba", "alibaba-cn"] + ); + assert_eq!( + ModelRegistryService::infer_provider_ids_from_api_host( + "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/" + ), + ["alibaba", "alibaba-cn"] + ); + assert_eq!( + ModelRegistryService::infer_provider_ids_from_api_host("https://api.kimi.com/coding/"), + ["kimi-for-coding"] + ); assert_eq!( ModelRegistryService::infer_provider_ids_from_api_host("https://api.openai.com/v1"), ["openai"] diff --git a/src-tauri/resources/models/host_aliases.json b/src-tauri/resources/models/host_aliases.json index 7403395dc..9eb4bb53a 100644 --- a/src-tauri/resources/models/host_aliases.json +++ b/src-tauri/resources/models/host_aliases.json @@ -44,6 +44,10 @@ "contains": "moonshot.ai", "providers": ["moonshotai", "kimi-for-coding"] }, + { + "contains": "api.kimi.com", + "providers": ["kimi-for-coding"] + }, { "contains": "siliconflow.cn", "providers": ["siliconflow-cn", "siliconflow"] @@ -78,7 +82,7 @@ }, { "contains": "minimaxi.com", - "providers": ["minimax"] + "providers": ["minimax-cn", "minimax"] }, { "contains": "minimax.io", @@ -88,6 +92,14 @@ "contains": "xiaomimimo.com", "providers": ["xiaomi"] }, + { + "contains": "coding-intl.dashscope.aliyuncs.com", + "providers": ["alibaba", "alibaba-cn"] + }, + { + "contains": "dashscope-intl.aliyuncs.com", + "providers": ["alibaba", "alibaba-cn"] + }, { "contains": "dashscope.aliyuncs.com", "providers": ["alibaba-cn", "alibaba"] diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 1fb54c2d3..086ff4c93 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -1561,6 +1561,7 @@ pub fn run() { commands::browser_connector_cmd::install_browser_connector_extension_cmd, commands::browser_connector_cmd::open_browser_extensions_page_cmd, commands::browser_connector_cmd::open_browser_remote_debugging_page_cmd, + commands::browser_connector_cmd::open_browser_connector_guide_window, // Browser profile commands commands::browser_profile_cmd::list_browser_profiles_cmd, commands::browser_profile_cmd::save_browser_profile_cmd, diff --git a/src-tauri/src/commands/browser_connector_cmd.rs b/src-tauri/src/commands/browser_connector_cmd.rs index bcd5ccfd7..9c805caf6 100644 --- a/src-tauri/src/commands/browser_connector_cmd.rs +++ b/src-tauri/src/commands/browser_connector_cmd.rs @@ -1,6 +1,10 @@ //! 浏览器连接器命令 use crate::app::AppState; +use crate::services::browser_connector_guide_window::{ + normalize_browser_connector_guide_mode, + open_browser_connector_guide_window as open_browser_connector_guide_window_service, +}; use crate::services::browser_connector_service::{ get_browser_connector_install_status, get_browser_connector_settings, install_browser_connector_extension, sync_browser_connector_auto_config_if_installed, @@ -37,6 +41,12 @@ pub struct SetBrowserActionCapabilityEnabledRequest { pub enabled: bool, } +#[derive(Debug, Deserialize)] +pub struct OpenBrowserConnectorGuideWindowRequest { + #[serde(default)] + pub mode: Option, +} + fn normalize_bridge_host(host: &str) -> String { match host.trim() { "" | "0.0.0.0" | "::" | "[::]" => "127.0.0.1".to_string(), @@ -201,3 +211,14 @@ pub async fn open_browser_extensions_page_cmd() -> Result { pub async fn open_browser_remote_debugging_page_cmd() -> Result { open_chrome_url("chrome://inspect/#remote-debugging") } + +#[tauri::command] +pub fn open_browser_connector_guide_window( + app: AppHandle, + request: Option, +) -> Result<(), String> { + let mode = request.and_then(|request| request.mode); + let mode = normalize_browser_connector_guide_mode(mode.as_deref()); + open_browser_connector_guide_window_service(&app, mode) + .map_err(|error| format!("打开浏览器连接器引导窗口失败: {error}")) +} diff --git a/src-tauri/src/services/README.md b/src-tauri/src/services/README.md index 2c45cdfa2..e71d6931a 100644 --- a/src-tauri/src/services/README.md +++ b/src-tauri/src/services/README.md @@ -26,6 +26,7 @@ - `file_browser_service.rs` - Tauri 命令桥接(纯逻辑已迁移到 `crates/services/src/file_browser_service.rs`) - `update_check_service.rs` - 兼容导出层(纯逻辑已迁移到 `crates/services/src/update_check_service.rs`) - `update_window.rs` - 更新提醒独立窗口管理(底部居中 Toast 窗口定位) +- `browser_connector_guide_window.rs` - 浏览器连接器引导独立窗口管理 - `browser_runtime_window.rs` - 浏览器运行时调试独立窗口管理 - `general_chat/` - 通用对话服务模块(会话管理、消息存储) - `api_key_provider_service.rs` - API Key Provider 服务 diff --git a/src-tauri/src/services/browser_connector_guide_window.rs b/src-tauri/src/services/browser_connector_guide_window.rs new file mode 100644 index 000000000..80de1f17d --- /dev/null +++ b/src-tauri/src/services/browser_connector_guide_window.rs @@ -0,0 +1,128 @@ +//! 浏览器连接器引导独立窗口管理 + +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; + +#[derive(Debug, thiserror::Error)] +pub enum BrowserConnectorGuideWindowError { + #[error("窗口创建失败: {0}")] + CreateFailed(String), + #[error("窗口操作失败: {0}")] + OperationFailed(String), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BrowserConnectorGuideMode { + Extension, + Cdp, +} + +const GUIDE_WINDOW_LABEL_PREFIX: &str = "browser-connector-guide"; +const GUIDE_WINDOW_ROUTE: &str = "/browser-connector-guide"; +const WINDOW_WIDTH: f64 = 900.0; +const WINDOW_HEIGHT: f64 = 700.0; +const MIN_WINDOW_WIDTH: f64 = 760.0; +const MIN_WINDOW_HEIGHT: f64 = 560.0; + +impl BrowserConnectorGuideMode { + fn as_query_value(self) -> &'static str { + match self { + Self::Extension => "extension", + Self::Cdp => "cdp", + } + } + + fn window_title(self) -> &'static str { + match self { + Self::Extension => "浏览器扩展连接引导", + Self::Cdp => "浏览器直连配置引导", + } + } +} + +pub fn normalize_browser_connector_guide_mode(mode: Option<&str>) -> BrowserConnectorGuideMode { + match mode.map(str::trim) { + Some("cdp") => BrowserConnectorGuideMode::Cdp, + _ => BrowserConnectorGuideMode::Extension, + } +} + +fn build_browser_connector_guide_label(mode: BrowserConnectorGuideMode) -> String { + format!("{GUIDE_WINDOW_LABEL_PREFIX}-{}", mode.as_query_value()) +} + +fn build_browser_connector_guide_route(mode: BrowserConnectorGuideMode) -> String { + format!( + "{GUIDE_WINDOW_ROUTE}?mode={}", + urlencoding::encode(mode.as_query_value()) + ) +} + +pub fn open_browser_connector_guide_window( + app: &AppHandle, + mode: BrowserConnectorGuideMode, +) -> Result<(), BrowserConnectorGuideWindowError> { + let label = build_browser_connector_guide_label(mode); + let route = build_browser_connector_guide_route(mode); + + if let Some(window) = app.get_webview_window(&label) { + let route_literal = serde_json::to_string(&route).map_err(|error| { + BrowserConnectorGuideWindowError::OperationFailed(format!("窗口路由编码失败: {error}")) + })?; + let js = format!("window.location.replace({route_literal});"); + window.eval(&js).map_err(|error| { + BrowserConnectorGuideWindowError::OperationFailed(format!("导航失败: {error}")) + })?; + let _ = window.unminimize(); + window.show().map_err(|error| { + BrowserConnectorGuideWindowError::OperationFailed(format!("显示窗口失败: {error}")) + })?; + window.set_focus().map_err(|error| { + BrowserConnectorGuideWindowError::OperationFailed(format!("聚焦窗口失败: {error}")) + })?; + return Ok(()); + } + + WebviewWindowBuilder::new(app, &label, WebviewUrl::App(route.into())) + .title(mode.window_title()) + .inner_size(WINDOW_WIDTH, WINDOW_HEIGHT) + .min_inner_size(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT) + .resizable(true) + .visible(true) + .focused(true) + .center() + .build() + .map_err(|error| BrowserConnectorGuideWindowError::CreateFailed(format!("{error}")))?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn guide_mode_should_fallback_to_extension() { + assert_eq!( + normalize_browser_connector_guide_mode(None), + BrowserConnectorGuideMode::Extension + ); + assert_eq!( + normalize_browser_connector_guide_mode(Some("unknown")), + BrowserConnectorGuideMode::Extension + ); + } + + #[test] + fn cdp_mode_should_build_stable_route_and_label() { + let mode = normalize_browser_connector_guide_mode(Some("cdp")); + + assert_eq!( + build_browser_connector_guide_label(mode), + "browser-connector-guide-cdp" + ); + assert_eq!( + build_browser_connector_guide_route(mode), + "/browser-connector-guide?mode=cdp" + ); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 48d429cfa..138b2950a 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -14,6 +14,7 @@ pub mod artifact_prompt_service; pub mod artifact_request_metadata_service; pub mod auto_memory_service; pub mod automation_service; +pub mod browser_connector_guide_window; pub mod browser_connector_service; pub mod browser_environment_service; pub mod browser_profile_service; diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 4cf74464e..edee45498 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -4260,10 +4260,17 @@ export function AgentChatWorkspace({ }), [sessionId, taskCenterTransitionTopicId], ); + const hasHomeConversationActivity = + hasDisplayMessages || + hasPendingA2UIForm || + isPreparingSend || + isSending || + queuedTurns.length > 0; const shouldRenderTaskCenterEmbeddedHome = Boolean( agentEntry === "claw" && sessionId && !taskCenterSessionSwitchPending && + !hasHomeConversationActivity && taskCenterEmbeddedHomeSessionIds.has(sessionId), ); useEffect(() => { @@ -4271,27 +4278,18 @@ export function AgentChatWorkspace({ return; } - if ( - hasDisplayMessages || - hasPendingA2UIForm || - isPreparingSend || - isSending || - queuedTurns.length > 0 - ) { + if (hasHomeConversationActivity) { clearTaskCenterEmbeddedHomeSession(sessionId); } }, [ clearTaskCenterEmbeddedHomeSession, - hasDisplayMessages, - hasPendingA2UIForm, - isPreparingSend, - isSending, - queuedTurns.length, + hasHomeConversationActivity, sessionId, taskCenterEmbeddedHomeSessionIds, ]); const suppressHomeNavbarUtilityActions = - shouldUseBrowserWorkspaceHomeChrome || shouldRenderTaskCenterEmbeddedHome; + (shouldUseBrowserWorkspaceHomeChrome && !hasHomeConversationActivity) || + shouldRenderTaskCenterEmbeddedHome; useEffect(() => { if ( diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index f1f8e8443..7f701c139 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -2032,6 +2032,9 @@ describe("AgentChatPage 侧栏显示控制", () => { }); await flushEffects(); + expect( + container.querySelector('[data-testid="toggle-harness"]'), + ).not.toBeNull(); expect(container.querySelector('[data-testid="chat-sidebar"]')).toBeNull(); clickButton(container, "toggle-history"); diff --git a/src/components/input-kit/ModelSelector.test.tsx b/src/components/input-kit/ModelSelector.test.tsx index 88f6f1678..1430c5fc1 100644 --- a/src/components/input-kit/ModelSelector.test.tsx +++ b/src/components/input-kit/ModelSelector.test.tsx @@ -373,16 +373,46 @@ describe("ModelSelector", () => { apiHost: "https://open.bigmodel.cn/api/anthropic", model: "glm-5.1", }, + { + label: "Z.AI Anthropic", + apiHost: "https://api.z.ai/api/anthropic", + model: "glm-4.7", + }, { label: "Kimi Anthropic", apiHost: "https://api.moonshot.cn/anthropic", model: "kimi-k2.5", }, + { + label: "Kimi Global Anthropic", + apiHost: "https://api.moonshot.ai/anthropic", + model: "kimi-k2.5", + }, + { + label: "Kimi Code Subscription", + apiHost: "https://api.kimi.com/coding/", + model: "k2p5", + }, { label: "MiniMax Anthropic", apiHost: "https://api.minimaxi.com/anthropic", model: "minimax-m1", }, + { + label: "MiniMax Global Anthropic", + apiHost: "https://api.minimax.io/anthropic", + model: "minimax-m1", + }, + { + label: "Alibaba Coding Anthropic", + apiHost: "https://coding.dashscope.aliyuncs.com/apps/anthropic", + model: "qwen3-coder-plus", + }, + { + label: "Alibaba Global Coding Anthropic", + apiHost: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", + model: "qwen3-coder-plus", + }, { label: "MiMo Anthropic", apiHost: "https://token-plan-cn.xiaomimimo.com/anthropic", diff --git a/src/components/provider-pool/api-key/ApiKeyProviderSection.ui.test.tsx b/src/components/provider-pool/api-key/ApiKeyProviderSection.ui.test.tsx index 71584549c..ee87bf2cf 100644 --- a/src/components/provider-pool/api-key/ApiKeyProviderSection.ui.test.tsx +++ b/src/components/provider-pool/api-key/ApiKeyProviderSection.ui.test.tsx @@ -288,10 +288,67 @@ describe("ApiKeyProviderSection 模型管理布局", () => { }); expect(container.textContent ?? "").toContain("DeepSeek"); - expect(container.textContent ?? "").toContain("Kimi Coding Plan"); + expect(container.textContent ?? "").toContain("Kimi API(国内按量)"); + expect(container.textContent ?? "").toContain("GLM Coding Plan(国内)"); + expect(container.textContent ?? "").not.toContain("Kimi Code 会员(订阅)"); + expect(container.textContent ?? "").not.toContain("Z.AI Coding Plan(海外)"); expect(container.querySelector('[data-testid="model-add-catalog"]')?.className).toContain( "overflow-y-auto", ); + + await act(async () => { + container + .querySelector('[data-template-id="glm-cn-coding-plan"]') + ?.click(); + await Promise.resolve(); + }); + + expect(container.textContent ?? "").toContain("https://open.bigmodel.cn/api/anthropic"); + }); + + it("海外分类应展示国内厂商的国际订阅入口", async () => { + createHookState(); + const container = renderSection(); + + await act(async () => { + findByTestId("add-model-button").click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + findByTestId("model-catalog-category-overseas").click(); + await Promise.resolve(); + }); + + const text = container.textContent ?? ""; + expect(text).toContain("Kimi Code 会员(订阅)"); + expect(text).toContain("Kimi API(海外按量)"); + expect(text).toContain("Z.AI Coding Plan(海外)"); + expect(text).toContain("MiniMax Coding Plan(海外)"); + expect(text).toContain("Alibaba Coding Plan(海外)"); + expect(text).not.toContain("GLM Coding Plan(国内)"); + expect( + container.querySelector('[data-template-id="kimi-code-subscription"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-template-id="zai-coding-plan"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-template-id="minimax-coding-plan-global"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-template-id="alibaba-coding-plan-global"]'), + ).not.toBeNull(); + + await act(async () => { + container + .querySelector('[data-template-id="kimi-code-subscription"]') + ?.click(); + await Promise.resolve(); + }); + + expect(container.textContent ?? "").toContain("https://api.kimi.com/coding/"); }); it("自定义供应商可在添加流程内完成创建、加 Key、写入模型并激活", async () => { diff --git a/src/components/provider-pool/api-key/ModelAddPanel.tsx b/src/components/provider-pool/api-key/ModelAddPanel.tsx index fcb567b74..a006a1897 100644 --- a/src/components/provider-pool/api-key/ModelAddPanel.tsx +++ b/src/components/provider-pool/api-key/ModelAddPanel.tsx @@ -49,6 +49,8 @@ export type ModelCatalogCategory = | "aggregator" | "overseas" | "local"; +type ProviderRegion = "cn" | "global"; +type ProviderBillingMode = "payg" | "coding_plan" | "subscription"; interface ProviderTemplate { id: string; @@ -62,7 +64,10 @@ interface ProviderTemplate { defaultModels: string[]; iconProviderId?: string; systemProviderId?: string; + providerResourceId?: string; isCustom?: boolean; + region?: ProviderRegion; + billingMode?: ProviderBillingMode; } interface FormState { @@ -94,58 +99,160 @@ const CATEGORY_OPTIONS: Array<{ value: ModelCatalogCategory; label: string }> = const FEATURED_TEMPLATES: ProviderTemplate[] = [ { - id: "kimi-coding-plan", - name: "Kimi Coding Plan", - description: "Anthropic 协议编码套餐,适合 Claude Code / OpenClaw 工作流", - category: "recommended", + id: "kimi-code-subscription", + name: "Kimi Code 会员(订阅)", + description: "Kimi Code 官方订阅入口,Anthropic 协议,适合 Claude Code / OpenClaw", + category: "overseas", + type: "anthropic-compatible", + apiHost: "https://api.kimi.com/coding/", + recommended: true, + apiKeyUrl: "https://www.kimi.com/code", + defaultModels: ["k2p5"], + iconProviderId: "moonshotai", + providerResourceId: "kimi-for-coding", + region: "global", + billingMode: "subscription", + }, + { + id: "kimi-api-cn", + name: "Kimi API(国内按量)", + description: "Moonshot 中国区 Anthropic 协议 API,适合按量接入 Kimi 模型", + category: "cn", type: "anthropic-compatible", apiHost: "https://api.moonshot.cn/anthropic", recommended: true, apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", - defaultModels: ["k2p5"], + defaultModels: ["kimi-k2.5"], iconProviderId: "moonshotai", + providerResourceId: "moonshotai-cn", + region: "cn", + billingMode: "payg", + }, + { + id: "kimi-api-global", + name: "Kimi API(海外按量)", + description: "Moonshot 国际区 Anthropic 协议 API,适合海外账号按量接入", + category: "overseas", + type: "anthropic-compatible", + apiHost: "https://api.moonshot.ai/anthropic", + recommended: true, + apiKeyUrl: "https://platform.moonshot.ai/console/api-keys", + defaultModels: ["kimi-k2.5"], + iconProviderId: "moonshotai", + providerResourceId: "moonshotai", + region: "global", + billingMode: "payg", }, { id: "minimax-coding-plan", - name: "MiniMax Coding Plan", - description: "Anthropic 协议编码套餐,默认使用 MiniMax-M2.7", - category: "recommended", + name: "MiniMax Coding Plan(国内)", + description: "MiniMax 中国区 Anthropic 协议编码套餐,默认使用 MiniMax-M2.7", + category: "cn", type: "anthropic-compatible", apiHost: "https://api.minimaxi.com/anthropic", recommended: true, apiKeyUrl: "https://platform.minimaxi.com/user-center/basic-information/interface-key", defaultModels: ["MiniMax-M2.7"], - iconProviderId: "minimax", + iconProviderId: "minimax-cn", + providerResourceId: "minimax-cn", + region: "cn", + billingMode: "coding_plan", }, { - id: "glm-coding-plan", - name: "GLM Coding Plan", - description: "智谱 Coding Plan,Anthropic/Claude API 兼容编码入口", - category: "recommended", + id: "minimax-coding-plan-global", + name: "MiniMax Coding Plan(海外)", + description: "MiniMax 国际区 Anthropic 协议编码套餐,使用海外订阅入口", + category: "overseas", + type: "anthropic-compatible", + apiHost: "https://api.minimax.io/anthropic", + recommended: true, + apiKeyUrl: "https://platform.minimax.io/user-center/basic-information/interface-key", + defaultModels: ["MiniMax-M2.7"], + iconProviderId: "minimax", + providerResourceId: "minimax", + region: "global", + billingMode: "coding_plan", + }, + { + id: "glm-cn-coding-plan", + name: "GLM Coding Plan(国内)", + description: "智谱 BigModel 中国区 Anthropic/Claude API 兼容编码入口", + category: "cn", + type: "anthropic-compatible", + apiHost: "https://open.bigmodel.cn/api/anthropic", + recommended: true, + apiKeyUrl: "https://open.bigmodel.cn/usercenter/apikeys", + defaultModels: ["glm-4.7"], + iconProviderId: "zhipuai", + providerResourceId: "zhipuai-coding-plan", + region: "cn", + billingMode: "coding_plan", + }, + { + id: "zai-coding-plan", + name: "Z.AI Coding Plan(海外)", + description: "Z.AI 国际区 Anthropic/Claude API 兼容编码入口", + category: "overseas", type: "anthropic-compatible", apiHost: "https://api.z.ai/api/anthropic", recommended: true, apiKeyUrl: "https://z.ai/manage-apikey/apikey-list", defaultModels: ["glm-4.7"], iconProviderId: "zai", + providerResourceId: "zai-coding-plan", + region: "global", + billingMode: "coding_plan", }, { id: "mimo-coding-plan", name: "MiMo Coding Plan", description: "小米 MiMo Token Plan,兼容 Claude Code 的 Anthropic 协议", - category: "recommended", + category: "cn", type: "anthropic-compatible", apiHost: "https://token-plan-cn.xiaomimimo.com/anthropic", recommended: true, apiKeyUrl: "https://mimo.mi.com/", defaultModels: ["mimo-v2.5-pro"], iconProviderId: "xiaomi", + providerResourceId: "xiaomi", + region: "cn", + billingMode: "subscription", + }, + { + id: "alibaba-coding-plan-cn", + name: "Alibaba Coding Plan(国内)", + description: "阿里云百炼中国区 Claude Code Coding Plan 专用 Anthropic 入口", + category: "cn", + type: "anthropic-compatible", + apiHost: "https://coding.dashscope.aliyuncs.com/apps/anthropic", + recommended: true, + apiKeyUrl: "https://bailian.console.aliyun.com/", + defaultModels: ["qwen3-coder-plus"], + iconProviderId: "alibaba-cn", + providerResourceId: "alibaba-cn", + region: "cn", + billingMode: "coding_plan", + }, + { + id: "alibaba-coding-plan-global", + name: "Alibaba Coding Plan(海外)", + description: "阿里云 Model Studio 国际区 Claude Code Coding Plan 专用 Anthropic 入口", + category: "overseas", + type: "anthropic-compatible", + apiHost: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", + recommended: true, + apiKeyUrl: "https://modelstudio.console.alibabacloud.com/", + defaultModels: ["qwen3-coder-plus"], + iconProviderId: "alibaba", + providerResourceId: "alibaba", + region: "global", + billingMode: "coding_plan", }, { id: "aihubmix-recommended", name: "AiHubMix", description: "聚合 Claude、OpenAI、Gemini 的常用中转服务", - category: "recommended", + category: "aggregator", type: "openai", apiHost: "https://aihubmix.com", recommended: true, @@ -157,7 +264,7 @@ const FEATURED_TEMPLATES: ProviderTemplate[] = [ id: "openrouter-recommended", name: "OpenRouter", description: "海外模型聚合平台,可按模型 ID 灵活接入", - category: "recommended", + category: "overseas", type: "openai", apiHost: "https://openrouter.ai/api/v1/", recommended: true, @@ -180,7 +287,6 @@ const CUSTOM_TEMPLATE: ProviderTemplate = { }; const CN_PROVIDER_IDS = new Set([ - "alibaba", "alibaba-cn", "bailing", "baidu-cloud", @@ -192,10 +298,8 @@ const CN_PROVIDER_IDS = new Set([ "infini", "internlm", "kimi-for-coding", - "minimax", "minimax-cn", "modelscope", - "moonshotai", "moonshotai-cn", "sensenova", "spark", @@ -206,8 +310,6 @@ const CN_PROVIDER_IDS = new Set([ "xiaomi", "xirang", "yi", - "zai", - "zai-coding-plan", "zhipuai", "zhipuai-coding-plan", "zhinao", @@ -259,15 +361,15 @@ const LOCAL_PROVIDER_IDS = new Set([ const RESOURCE_PROVIDER_API_HOSTS: Record = { aihubmix: "https://aihubmix.com", - alibaba: "https://dashscope.aliyuncs.com/compatible-mode/v1/", + alibaba: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/", "alibaba-cn": "https://dashscope.aliyuncs.com/compatible-mode/v1/", anthropic: "https://api.anthropic.com", deepseek: "https://api.deepseek.com", - "kimi-for-coding": "https://api.moonshot.cn/anthropic", + "kimi-for-coding": "https://api.kimi.com/coding/", lmstudio: "http://localhost:1234", - minimax: "https://api.minimaxi.com/anthropic", + minimax: "https://api.minimax.io/anthropic", "minimax-cn": "https://api.minimaxi.com/anthropic", - moonshotai: "https://api.moonshot.cn", + moonshotai: "https://api.moonshot.ai", "moonshotai-cn": "https://api.moonshot.cn", openai: "https://api.openai.com", openrouter: "https://openrouter.ai/api/v1/", @@ -275,9 +377,18 @@ const RESOURCE_PROVIDER_API_HOSTS: Record = { "siliconflow-cn": "https://api.siliconflow.cn", xiaomi: "https://token-plan-cn.xiaomimimo.com/anthropic", "zai-coding-plan": "https://api.z.ai/api/anthropic", - "zhipuai-coding-plan": "https://api.z.ai/api/anthropic", + "zhipuai-coding-plan": "https://open.bigmodel.cn/api/anthropic", }; +const ANTHROPIC_COMPATIBLE_REGISTRY_PROVIDER_IDS = new Set([ + "kimi-for-coding", + "minimax", + "minimax-cn", + "xiaomi", + "zai-coding-plan", + "zhipuai-coding-plan", +]); + function normalizeCatalogProviderType(providerType: string): ProviderType { return isSupportedProviderType(providerType) ? providerType : "openai"; } @@ -351,10 +462,13 @@ function buildRegistryTemplates( ? `模型目录 · ${apiHost}` : "模型目录供应商,按服务文档补充 API Base URL", category: resolveProviderCategory(providerId), - type: providerId === "kimi-for-coding" ? "anthropic-compatible" : "openai", + type: ANTHROPIC_COMPATIBLE_REGISTRY_PROVIDER_IDS.has(providerId) + ? "anthropic-compatible" + : "openai", apiHost, defaultModels: [], iconProviderId: providerId, + providerResourceId: providerId, }); }); @@ -366,7 +480,9 @@ function dedupeTemplates(templates: ProviderTemplate[]): ProviderTemplate[] { const result: ProviderTemplate[] = []; for (const template of templates) { - const key = template.systemProviderId ?? template.id; + const key = template.providerResourceId + ? `provider:${template.providerResourceId}:${template.apiHost}` + : template.systemProviderId ?? template.id; if (seen.has(key)) { continue; } @@ -427,6 +543,55 @@ function renderTemplateIcon(template: ProviderTemplate) { ); } +function getRegionLabel(region?: ProviderRegion): string | null { + switch (region) { + case "cn": + return "国内"; + case "global": + return "海外"; + default: + return null; + } +} + +function getBillingModeLabel(mode?: ProviderBillingMode): string | null { + switch (mode) { + case "payg": + return "按量 API"; + case "coding_plan": + return "Coding Plan"; + case "subscription": + return "订阅套餐"; + default: + return null; + } +} + +function renderTemplateBadges(template: ProviderTemplate) { + const badges = [ + template.recommended ? "推荐" : null, + getRegionLabel(template.region), + getBillingModeLabel(template.billingMode), + ].filter((item): item is string => Boolean(item)); + + if (badges.length === 0) { + return null; + } + + return ( + + {badges.map((badge) => ( + + {badge} + + ))} + + ); +} + export const ModelAddPanel: React.FC = ({ providers, onAddProvider, @@ -635,14 +800,14 @@ export const ModelAddPanel: React.FC = ({ )} data-testid="model-add-catalog" > -
+
{CATEGORY_OPTIONS.map((option) => (
diff --git a/src/components/provider-pool/api-key/providerConfigUtils.ts b/src/components/provider-pool/api-key/providerConfigUtils.ts index 17c83e08f..e7cac0153 100644 --- a/src/components/provider-pool/api-key/providerConfigUtils.ts +++ b/src/components/provider-pool/api-key/providerConfigUtils.ts @@ -57,7 +57,7 @@ const SPECIAL_PROVIDER_PROTOCOL_HINTS: Partial> = { anthropic: "Anthropic 继续使用原生协议,不会被收敛到普通 OpenAI 兼容请求格式。", "anthropic-compatible": - "Anthropic 兼容用于接入实现 Anthropic wire format 的第三方服务,会沿用 Anthropic 请求结构与模型映射。Lime 会自动识别已知官方 Anthropic 兼容端点(如 GLM / Kimi / MiniMax / MiMo);未知端点默认回退为仅显式缓存。", + "Anthropic 兼容用于接入实现 Anthropic wire format 的第三方服务,会沿用 Anthropic 请求结构与模型映射。Lime 会自动识别已知官方 Anthropic 兼容端点(如 GLM / Z.AI / Kimi / MiniMax / Alibaba / MiMo);未知端点默认回退为仅显式缓存。", gemini: "Gemini 保留原生协议能力与专属模型映射,不按普通 OpenAI 兼容 Provider 处理。", }; diff --git a/src/components/provider-pool/api-key/providerTypeMapping.test.ts b/src/components/provider-pool/api-key/providerTypeMapping.test.ts index 8f790dc62..07b92a04a 100644 --- a/src/components/provider-pool/api-key/providerTypeMapping.test.ts +++ b/src/components/provider-pool/api-key/providerTypeMapping.test.ts @@ -124,6 +124,48 @@ describe("providerTypeMapping", () => { expect(resolved).toBe("minimax"); }); + it("Z.AI 海外 Anthropic 兼容 Host 应映射到 Z.AI 目录", () => { + const resolved = resolveRegistryProviderId("custom-zai-provider", { + providerType: "anthropic-compatible", + apiHost: "https://api.z.ai/api/anthropic", + catalogAliasMap: null, + validRegistryProviders: ["anthropic", "zai", "zhipuai"], + }); + + expect(resolved).toBe("zai"); + }); + + it("Kimi Code 订阅 Host 应映射到 Kimi For Coding 目录", () => { + const resolved = resolveRegistryProviderId("custom-kimi-code-provider", { + providerType: "anthropic-compatible", + apiHost: "https://api.kimi.com/coding/", + catalogAliasMap: null, + validRegistryProviders: ["anthropic", "kimi-for-coding", "moonshotai"], + }); + + expect(resolved).toBe("kimi-for-coding"); + }); + + it("阿里 Coding Plan 国内和海外 Host 应分别映射到对应目录", () => { + expect( + resolveRegistryProviderId("custom-alibaba-cn-provider", { + providerType: "anthropic-compatible", + apiHost: "https://coding.dashscope.aliyuncs.com/apps/anthropic", + catalogAliasMap: null, + validRegistryProviders: ["anthropic", "alibaba-cn", "alibaba"], + }), + ).toBe("alibaba-cn"); + + expect( + resolveRegistryProviderId("custom-alibaba-global-provider", { + providerType: "anthropic-compatible", + apiHost: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", + catalogAliasMap: null, + validRegistryProviders: ["anthropic", "alibaba-cn", "alibaba"], + }), + ).toBe("alibaba"); + }); + it("MiMo 的 Anthropic 兼容 Host 应映射到 Xiaomi 模型目录", () => { const resolved = resolveRegistryProviderId("custom-mimo-provider", { providerType: "anthropic-compatible", diff --git a/src/components/settings-v2/system/chrome-relay/guide-window-launcher.ts b/src/components/settings-v2/system/chrome-relay/guide-window-launcher.ts index 2ac9d951a..53eb10491 100644 --- a/src/components/settings-v2/system/chrome-relay/guide-window-launcher.ts +++ b/src/components/settings-v2/system/chrome-relay/guide-window-launcher.ts @@ -1,9 +1,12 @@ +import { + openBrowserConnectorGuideWindow as openBrowserConnectorGuideWindowCommand, + type BrowserConnectorGuideMode, +} from "@/lib/webview-api"; import { hasTauriInvokeCapability } from "@/lib/tauri-runtime"; -export type BrowserConnectorGuideMode = "extension" | "cdp"; +export type { BrowserConnectorGuideMode }; const GUIDE_ROUTE = "/browser-connector-guide"; -const GUIDE_WINDOW_LABEL = "browser-connector-guide"; export function buildBrowserConnectorGuideUrl( mode: BrowserConnectorGuideMode, @@ -11,63 +14,21 @@ export function buildBrowserConnectorGuideUrl( return `${GUIDE_ROUTE}?mode=${encodeURIComponent(mode)}`; } -async function openTauriGuideWindow( - mode: BrowserConnectorGuideMode, - url: string, -): Promise { - if (!hasTauriInvokeCapability()) { - return false; - } - - try { - const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow"); - const label = `${GUIDE_WINDOW_LABEL}-${mode}`; - const existingWindow = await WebviewWindow.getByLabel(label).catch( - () => null, - ); - - if (existingWindow) { - await existingWindow.show().catch(() => undefined); - await existingWindow.setFocus().catch(() => undefined); - return true; - } - - const guideWindow = new WebviewWindow(label, { - url, - title: mode === "extension" ? "浏览器扩展连接引导" : "浏览器直连配置引导", - width: 900, - height: 700, - minWidth: 760, - minHeight: 560, - center: true, - visible: true, - focus: true, - resizable: true, - decorations: true, - }); - - await Promise.race([ - new Promise((resolve) => { - void guideWindow.once("tauri://created", () => resolve()); - }), - new Promise((resolve) => window.setTimeout(resolve, 160)), - ]); - return true; - } catch (error) { - console.warn("[连接器引导] 打开独立窗口失败,回退到浏览器窗口:", error); - return false; - } -} - export async function openBrowserConnectorGuideWindow({ mode, }: { mode: BrowserConnectorGuideMode; }): Promise { - const url = buildBrowserConnectorGuideUrl(mode); - const openedInTauri = await openTauriGuideWindow(mode, url); + if (hasTauriInvokeCapability()) { + await openBrowserConnectorGuideWindowCommand({ mode }); + return; + } - if (!openedInTauri && typeof window !== "undefined") { - window.open(url, "_blank", "noopener,noreferrer"); + if (typeof window !== "undefined") { + window.open( + buildBrowserConnectorGuideUrl(mode), + "_blank", + "noopener,noreferrer", + ); } } diff --git a/src/components/settings-v2/system/chrome-relay/guide-window.test.tsx b/src/components/settings-v2/system/chrome-relay/guide-window.test.tsx index 5d8ef1cb1..a010a476b 100644 --- a/src/components/settings-v2/system/chrome-relay/guide-window.test.tsx +++ b/src/components/settings-v2/system/chrome-relay/guide-window.test.tsx @@ -13,6 +13,7 @@ const { mockSetBrowserConnectorInstallRoot, mockOpenBrowserExtensionsPage, mockOpenBrowserRemoteDebuggingPage, + mockOpenBrowserConnectorGuideWindowCommand, mockHasTauriInvokeCapability, } = vi.hoisted(() => ({ mockOpenDialog: vi.fn(), @@ -25,6 +26,7 @@ const { mockSetBrowserConnectorInstallRoot: vi.fn(), mockOpenBrowserExtensionsPage: vi.fn(), mockOpenBrowserRemoteDebuggingPage: vi.fn(), + mockOpenBrowserConnectorGuideWindowCommand: vi.fn(), mockHasTauriInvokeCapability: vi.fn(), })); @@ -53,6 +55,7 @@ vi.mock("@/lib/webview-api", async () => { setBrowserConnectorInstallRoot: mockSetBrowserConnectorInstallRoot, openBrowserExtensionsPage: mockOpenBrowserExtensionsPage, openBrowserRemoteDebuggingPage: mockOpenBrowserRemoteDebuggingPage, + openBrowserConnectorGuideWindow: mockOpenBrowserConnectorGuideWindowCommand, }; }); @@ -168,6 +171,7 @@ beforeEach(() => { }); mockOpenBrowserExtensionsPage.mockResolvedValue(true); mockOpenBrowserRemoteDebuggingPage.mockResolvedValue(true); + mockOpenBrowserConnectorGuideWindowCommand.mockResolvedValue(undefined); }); afterEach(() => { @@ -230,6 +234,15 @@ describe("BrowserConnectorGuideWindow", () => { ); }); + it("独立窗口内容区应可滚动", async () => { + const container = renderGuide("/browser-connector-guide?mode=extension"); + await flushEffects(); + + const main = container.querySelector("main"); + expect(main?.className).toContain("h-screen"); + expect(main?.className).toContain("overflow-y-auto"); + }); + it("cdp 模式应展示直连步骤并打开远程调试页", async () => { const container = renderGuide("/browser-connector-guide?mode=cdp"); await flushEffects(); @@ -271,6 +284,22 @@ describe("BrowserConnectorGuideWindow", () => { "_blank", "noopener,noreferrer", ); + expect(mockOpenBrowserConnectorGuideWindowCommand).not.toHaveBeenCalled(); + mockWindowOpen.mockRestore(); + }); + + it("Tauri 环境打开引导时应走 Rust 开窗命令", async () => { + const mockWindowOpen = vi + .spyOn(window, "open") + .mockImplementation(() => null); + mockHasTauriInvokeCapability.mockReturnValue(true); + + await openBrowserConnectorGuideWindow({ mode: "cdp" }); + + expect(mockOpenBrowserConnectorGuideWindowCommand).toHaveBeenCalledWith({ + mode: "cdp", + }); + expect(mockWindowOpen).not.toHaveBeenCalled(); mockWindowOpen.mockRestore(); }); }); diff --git a/src/components/settings-v2/system/chrome-relay/guide-window.tsx b/src/components/settings-v2/system/chrome-relay/guide-window.tsx index fb017e5c3..52cf61998 100644 --- a/src/components/settings-v2/system/chrome-relay/guide-window.tsx +++ b/src/components/settings-v2/system/chrome-relay/guide-window.tsx @@ -334,8 +334,8 @@ function BrowserConnectorGuideContent() { ); return ( -
-
+
+
diff --git a/src/hooks/useConfiguredProviders.test.ts b/src/hooks/useConfiguredProviders.test.ts index 64d13dc58..7e7c24a0e 100644 --- a/src/hooks/useConfiguredProviders.test.ts +++ b/src/hooks/useConfiguredProviders.test.ts @@ -246,16 +246,46 @@ describe("buildConfiguredProviders", () => { name: "GLM Anthropic", apiHost: "https://open.bigmodel.cn/api/anthropic", }, + { + id: "zai-anthropic", + name: "Z.AI Anthropic", + apiHost: "https://api.z.ai/api/anthropic", + }, { id: "kimi-anthropic", name: "Kimi Anthropic", apiHost: "https://api.moonshot.cn/anthropic", }, + { + id: "kimi-global-anthropic", + name: "Kimi Global Anthropic", + apiHost: "https://api.moonshot.ai/anthropic", + }, + { + id: "kimi-code-subscription", + name: "Kimi Code Subscription", + apiHost: "https://api.kimi.com/coding/", + }, { id: "minimax-anthropic", name: "MiniMax Anthropic", apiHost: "https://api.minimaxi.com/anthropic", }, + { + id: "minimax-global-anthropic", + name: "MiniMax Global Anthropic", + apiHost: "https://api.minimax.io/anthropic", + }, + { + id: "alibaba-coding-anthropic", + name: "Alibaba Coding Anthropic", + apiHost: "https://coding.dashscope.aliyuncs.com/apps/anthropic", + }, + { + id: "alibaba-global-coding-anthropic", + name: "Alibaba Global Coding Anthropic", + apiHost: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", + }, { id: "mimo-anthropic", name: "MiMo Anthropic", diff --git a/src/lib/api/oemCloudControlPlane.test.ts b/src/lib/api/oemCloudControlPlane.test.ts index cbe06c6ef..23926b042 100644 --- a/src/lib/api/oemCloudControlPlane.test.ts +++ b/src/lib/api/oemCloudControlPlane.test.ts @@ -13,6 +13,7 @@ import { getClientOrder, getClientBootstrap, getClientCreditsDashboard, + getPublicAuthCatalog, getClientProviderOffer, getClientReferralDashboard, listPublicOAuthProviders, @@ -174,6 +175,11 @@ describe("oemCloudControlPlane desktop auth", () => { loginHint: "使用 Google 登录", }, ], + authPolicy: { + required: true, + startupTrigger: "oauth", + primaryProvider: "google", + }, }, }), })); @@ -203,6 +209,63 @@ describe("oemCloudControlPlane desktop auth", () => { ]); }); + it("应读取公开登录目录与启动策略", async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + code: 200, + message: "success", + data: { + items: [ + { + provider: "google", + displayName: "Google", + scopes: ["openid", "email"], + enabled: true, + }, + ], + authPolicy: { + required: true, + startupTrigger: "oauth", + primaryProvider: "google", + }, + }, + }), + })); + vi.stubGlobal("fetch", fetchMock); + + const catalog = await getPublicAuthCatalog("tenant-0001"); + + expect(fetchMock).toHaveBeenCalledWith( + "https://user.limeai.run/api/v1/public/tenants/tenant-0001/oauth/providers", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Accept: "application/json", + }), + }), + ); + expect(catalog).toEqual({ + providers: [ + { + provider: "google", + displayName: "Google", + authorizeUrl: undefined, + redirectUri: undefined, + scopes: ["openid", "email"], + enabled: true, + loginHint: undefined, + }, + ], + authPolicy: { + required: true, + startupTrigger: "oauth", + primaryProvider: "google", + }, + }); + }); + it("应解析 bootstrap 中缓存的邀请开关与分享事实源", async () => { window.__LIME_SESSION_TOKEN__ = "session-token-001"; diff --git a/src/lib/api/oemCloudControlPlane.ts b/src/lib/api/oemCloudControlPlane.ts index a9549df92..ba7db0c8c 100644 --- a/src/lib/api/oemCloudControlPlane.ts +++ b/src/lib/api/oemCloudControlPlane.ts @@ -57,6 +57,19 @@ export interface OemCloudPublicOAuthProvider { loginHint?: string; } +export type OemCloudAuthStartupTrigger = "none" | "oauth"; + +export interface OemCloudAuthPolicy { + required: boolean; + startupTrigger: OemCloudAuthStartupTrigger; + primaryProvider?: string; +} + +export interface OemCloudPublicAuthCatalog { + providers: OemCloudPublicOAuthProvider[]; + authPolicy: OemCloudAuthPolicy; +} + export interface OemCloudCurrentSession extends Omit< OemCloudCurrentSessionLike, "tenant" | "user" | "session" @@ -181,6 +194,7 @@ export interface OemCloudBootstrapResponse { status: string; distributionChannels: string[]; }; + authPolicy: OemCloudAuthPolicy; providerOffersSummary: OemCloudProviderOfferSummary[]; providerPreference: OemCloudProviderPreference; skillCatalog?: unknown; @@ -1128,6 +1142,27 @@ function parsePublicOAuthProvider(value: unknown): OemCloudPublicOAuthProvider { }; } +function parseAuthPolicy(value: unknown): OemCloudAuthPolicy { + const record = isRecord(value) ? value : {}; + const startupTrigger = normalizeText(record.startupTrigger); + + return { + required: normalizeBoolean(record.required), + startupTrigger: startupTrigger === "oauth" ? "oauth" : "none", + primaryProvider: normalizeText(record.primaryProvider) ?? undefined, + }; +} + +function parsePublicAuthCatalog(value: unknown): OemCloudPublicAuthCatalog { + const record = isRecord(value) ? value : {}; + return { + providers: Array.isArray(record.items) + ? record.items.map(parsePublicOAuthProvider) + : [], + authPolicy: parseAuthPolicy(record.authPolicy), + }; +} + function parseProviderOfferSummary( value: unknown, ): OemCloudProviderOfferSummary { @@ -1486,6 +1521,7 @@ function parseBootstrap(value: unknown): OemCloudBootstrapResponse { value.app && isRecord(value.app) ? value.app.distributionChannels : [], ), }, + authPolicy: parseAuthPolicy(value.authPolicy), providerOffersSummary: Array.isArray(value.providerOffersSummary) ? value.providerOffersSummary.map(parseProviderOfferSummary) : [], @@ -2437,13 +2473,17 @@ export async function pollClientDesktopAuthSession( export async function listPublicOAuthProviders( tenantId: string, ): Promise { - const response = await requestControlPlane<{ items?: unknown[] }>( - `/v1/public/tenants/${encodeURIComponent(tenantId)}/oauth/providers`, - ); + return (await getPublicAuthCatalog(tenantId)).providers; +} - return Array.isArray(response.items) - ? response.items.map(parsePublicOAuthProvider) - : []; +export async function getPublicAuthCatalog( + tenantId: string, +): Promise { + return parsePublicAuthCatalog( + await requestControlPlane( + `/v1/public/tenants/${encodeURIComponent(tenantId)}/oauth/providers`, + ), + ); } export async function verifyClientAuthEmailCode( diff --git a/src/lib/dev-bridge/mockPriorityCommands.ts b/src/lib/dev-bridge/mockPriorityCommands.ts index d7386ab57..451358874 100644 --- a/src/lib/dev-bridge/mockPriorityCommands.ts +++ b/src/lib/dev-bridge/mockPriorityCommands.ts @@ -69,6 +69,7 @@ const mockPriorityCommands = new Set([ "install_browser_connector_extension_cmd", "open_browser_extensions_page_cmd", "open_browser_remote_debugging_page_cmd", + "open_browser_connector_guide_window", "disconnect_browser_connector_session", "launch_browser_session", "launch_browser_profile_runtime_assist_cmd", diff --git a/src/lib/governance/agentCommandCatalog.json b/src/lib/governance/agentCommandCatalog.json index 4d61746fa..d99ffc962 100644 --- a/src/lib/governance/agentCommandCatalog.json +++ b/src/lib/governance/agentCommandCatalog.json @@ -71,6 +71,19 @@ "companion_launch_pet", "companion_send_pet_command" ], + "browserConnectorCommands": [ + "disconnect_browser_connector_session", + "get_browser_connector_settings_cmd", + "set_browser_connector_install_root_cmd", + "set_browser_connector_enabled_cmd", + "set_system_connector_enabled_cmd", + "set_browser_action_capability_enabled_cmd", + "get_browser_connector_install_status_cmd", + "install_browser_connector_extension_cmd", + "open_browser_extensions_page_cmd", + "open_browser_remote_debugging_page_cmd", + "open_browser_connector_guide_window" + ], "memoryRuntimeCommands": [ "memory_runtime_get_stats", "memory_runtime_get_overview", diff --git a/src/lib/model/anthropicCompatiblePromptCacheCatalog.json b/src/lib/model/anthropicCompatiblePromptCacheCatalog.json index 68e379892..9e4f9d4f3 100644 --- a/src/lib/model/anthropicCompatiblePromptCacheCatalog.json +++ b/src/lib/model/anthropicCompatiblePromptCacheCatalog.json @@ -9,7 +9,7 @@ "contains": "bigmodel.cn/api/coding/paas/v4" }, { - "provider": "zhipuai", + "provider": "zai", "contains": "api.z.ai/api/anthropic" }, { @@ -20,6 +20,10 @@ "provider": "moonshotai", "contains": "moonshot.ai/anthropic" }, + { + "provider": "kimi-for-coding", + "contains": "api.kimi.com/coding" + }, { "provider": "minimax", "contains": "minimaxi.com/anthropic" @@ -28,6 +32,14 @@ "provider": "minimax", "contains": "minimax.io/anthropic" }, + { + "provider": "alibaba-cn", + "contains": "coding.dashscope.aliyuncs.com/apps/anthropic" + }, + { + "provider": "alibaba", + "contains": "coding-intl.dashscope.aliyuncs.com/apps/anthropic" + }, { "provider": "xiaomi", "contains": "token-plan-cn.xiaomimimo.com/anthropic" diff --git a/src/lib/model/providerPromptCacheSupport.test.ts b/src/lib/model/providerPromptCacheSupport.test.ts index 87ca961ce..3deb78e10 100644 --- a/src/lib/model/providerPromptCacheSupport.test.ts +++ b/src/lib/model/providerPromptCacheSupport.test.ts @@ -43,8 +43,14 @@ describe("providerPromptCacheSupport", () => { it.each([ "https://open.bigmodel.cn/api/anthropic", + "https://api.z.ai/api/anthropic", "https://api.moonshot.cn/anthropic", + "https://api.moonshot.ai/anthropic", + "https://api.kimi.com/coding/", "https://api.minimaxi.com/anthropic", + "https://api.minimax.io/anthropic", + "https://coding.dashscope.aliyuncs.com/apps/anthropic", + "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", "https://token-plan-cn.xiaomimimo.com/anthropic", ])("已知官方 Anthropic 兼容 Host %s 不应误报显式缓存提示", (apiHost) => { expect(isKnownAutomaticAnthropicCompatibleHost(apiHost)).toBe(true); @@ -121,6 +127,29 @@ describe("providerPromptCacheSupport", () => { "https://open.bigmodel.cn/api/anthropic", ), ).toBe("zhipuai"); + expect( + resolveKnownAnthropicCompatibleProvider( + "https://api.z.ai/api/anthropic", + ), + ).toBe("zai"); + expect( + getRegistryIdFromType( + "anthropic-compatible", + "https://api.kimi.com/coding/", + ), + ).toBe("kimi-for-coding"); + expect( + getRegistryIdFromType( + "anthropic-compatible", + "https://coding.dashscope.aliyuncs.com/apps/anthropic", + ), + ).toBe("alibaba-cn"); + expect( + getRegistryIdFromType( + "anthropic-compatible", + "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", + ), + ).toBe("alibaba"); expect( resolveKnownAnthropicCompatibleProvider( "https://token-plan-cn.xiaomimimo.com/anthropic", diff --git a/src/lib/oemCloudStartupLogin.test.ts b/src/lib/oemCloudStartupLogin.test.ts index 3fbc26220..0077d792d 100644 --- a/src/lib/oemCloudStartupLogin.test.ts +++ b/src/lib/oemCloudStartupLogin.test.ts @@ -2,12 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { setStoredOemCloudSessionState } from "@/lib/oemCloudSession"; import { startOemCloudStartupLoginIfRequired } from "@/lib/oemCloudStartupLogin"; -const { mockListPublicOAuthProviders, mockStartOemCloudLogin } = vi.hoisted( - () => ({ - mockListPublicOAuthProviders: vi.fn(), - mockStartOemCloudLogin: vi.fn(), - }), -); +const { mockGetPublicAuthCatalog, mockStartOemCloudLogin } = vi.hoisted(() => ({ + mockGetPublicAuthCatalog: vi.fn(), + mockStartOemCloudLogin: vi.fn(), +})); vi.mock("@/lib/api/oemCloudControlPlane", async (importOriginal) => { const actual = @@ -15,7 +13,7 @@ vi.mock("@/lib/api/oemCloudControlPlane", async (importOriginal) => { return { ...actual, - listPublicOAuthProviders: mockListPublicOAuthProviders, + getPublicAuthCatalog: mockGetPublicAuthCatalog, }; }); @@ -41,14 +39,21 @@ describe("oemCloudStartupLogin", () => { delete window.__LIME_OEM_CLOUD__; delete window.__LIME_SESSION_TOKEN__; vi.spyOn(console, "warn").mockImplementation(() => undefined); - mockListPublicOAuthProviders.mockResolvedValue([ - { - provider: "google", - displayName: "Google", - enabled: true, - scopes: ["openid", "email"], + mockGetPublicAuthCatalog.mockResolvedValue({ + providers: [ + { + provider: "google", + displayName: "Google", + enabled: true, + scopes: ["openid", "email"], + }, + ], + authPolicy: { + required: true, + startupTrigger: "oauth", + primaryProvider: "google", }, - ]); + }); mockStartOemCloudLogin.mockResolvedValue({ mode: "desktop_auth", openedUrl: "https://user.limeai.run/oauth/desktop/authorize", @@ -70,7 +75,7 @@ describe("oemCloudStartupLogin", () => { const result = await startOemCloudStartupLoginIfRequired(); expect(result.status).toBe("started"); - expect(mockListPublicOAuthProviders).toHaveBeenCalledWith("tenant-0001"); + expect(mockGetPublicAuthCatalog).toHaveBeenCalledWith("tenant-0001"); expect(mockStartOemCloudLogin).toHaveBeenCalledWith( expect.objectContaining({ baseUrl: "https://user.limeai.run", @@ -101,20 +106,27 @@ describe("oemCloudStartupLogin", () => { const result = await startOemCloudStartupLoginIfRequired(); expect(result.status).toBe("has_session"); - expect(mockListPublicOAuthProviders).not.toHaveBeenCalled(); + expect(mockGetPublicAuthCatalog).not.toHaveBeenCalled(); expect(mockStartOemCloudLogin).not.toHaveBeenCalled(); }); it("后端未下发 Google Provider 时保持开源默认使用", async () => { configureRuntime(); - mockListPublicOAuthProviders.mockResolvedValue([ - { - provider: "github", - displayName: "GitHub", - enabled: true, - scopes: [], + mockGetPublicAuthCatalog.mockResolvedValue({ + providers: [ + { + provider: "github", + displayName: "GitHub", + enabled: true, + scopes: [], + }, + ], + authPolicy: { + required: true, + startupTrigger: "oauth", + primaryProvider: "google", }, - ]); + }); const result = await startOemCloudStartupLoginIfRequired(); @@ -124,7 +136,7 @@ describe("oemCloudStartupLogin", () => { it("读取后端登录策略失败时不应阻塞主应用启动", async () => { configureRuntime(); - mockListPublicOAuthProviders.mockRejectedValue(new Error("network down")); + mockGetPublicAuthCatalog.mockRejectedValue(new Error("network down")); const result = await startOemCloudStartupLoginIfRequired(); @@ -134,4 +146,51 @@ describe("oemCloudStartupLogin", () => { }); expect(mockStartOemCloudLogin).not.toHaveBeenCalled(); }); + + it("后端未要求启动登录时应保持开源默认使用", async () => { + configureRuntime(); + mockGetPublicAuthCatalog.mockResolvedValue({ + providers: [ + { + provider: "google", + displayName: "Google", + enabled: true, + scopes: ["openid", "email"], + }, + ], + authPolicy: { + required: false, + startupTrigger: "none", + }, + }); + + const result = await startOemCloudStartupLoginIfRequired(); + + expect(result.status).toBe("not_required"); + expect(mockStartOemCloudLogin).not.toHaveBeenCalled(); + }); + + it("后端要求的启动策略不是 Google OAuth 时不应猜测兜底", async () => { + configureRuntime(); + mockGetPublicAuthCatalog.mockResolvedValue({ + providers: [ + { + provider: "github", + displayName: "GitHub", + enabled: true, + scopes: [], + }, + ], + authPolicy: { + required: true, + startupTrigger: "oauth", + primaryProvider: "github", + }, + }); + + const result = await startOemCloudStartupLoginIfRequired(); + + expect(result.status).toBe("unsupported_policy"); + expect(mockStartOemCloudLogin).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/oemCloudStartupLogin.ts b/src/lib/oemCloudStartupLogin.ts index 8b943458c..c2382f500 100644 --- a/src/lib/oemCloudStartupLogin.ts +++ b/src/lib/oemCloudStartupLogin.ts @@ -1,5 +1,5 @@ import { - listPublicOAuthProviders, + getPublicAuthCatalog, type OemCloudPublicOAuthProvider, } from "@/lib/api/oemCloudControlPlane"; import { @@ -15,7 +15,9 @@ export type OemCloudStartupLoginStatus = | "not_configured" | "has_session" | "already_attempted" + | "not_required" | "no_google_provider" + | "unsupported_policy" | "started" | "failed"; @@ -38,11 +40,32 @@ function hasGoogleOAuthProvider( ); } +function shouldStartGoogleOauth( + catalog: Awaited>, +): OemCloudStartupLoginResult | null { + if (!catalog.authPolicy.required) { + return { status: "not_required" }; + } + + if ( + catalog.authPolicy.startupTrigger !== "oauth" || + normalizeProvider(catalog.authPolicy.primaryProvider) !== "google" + ) { + return { status: "unsupported_policy" }; + } + + if (!hasGoogleOAuthProvider(catalog.providers)) { + return { status: "no_google_provider" }; + } + + return null; +} + function hasCurrentTenantSession(runtime: OemCloudRuntimeContext): boolean { const storedSession = getStoredOemCloudSessionState(); return Boolean( storedSession?.token && - storedSession.session.tenant.id === runtime.tenantId, + storedSession.session.tenant.id === runtime.tenantId, ); } @@ -83,9 +106,11 @@ export async function startOemCloudStartupLoginIfRequired( return { status: "already_attempted" }; } - let providers: OemCloudPublicOAuthProvider[]; + let startupDecision: OemCloudStartupLoginResult | null; try { - providers = await listPublicOAuthProviders(runtime.tenantId); + startupDecision = shouldStartGoogleOauth( + await getPublicAuthCatalog(runtime.tenantId), + ); } catch (error) { const reason = error instanceof Error && error.message.trim() @@ -95,8 +120,8 @@ export async function startOemCloudStartupLoginIfRequired( return { status: "failed", reason }; } - if (!hasGoogleOAuthProvider(providers)) { - return { status: "no_google_provider" }; + if (startupDecision) { + return startupDecision; } markStartupAttempt(runtime); diff --git a/src/lib/tauri-mock/core.ts b/src/lib/tauri-mock/core.ts index d3393fc46..ed637aceb 100644 --- a/src/lib/tauri-mock/core.ts +++ b/src/lib/tauri-mock/core.ts @@ -4845,6 +4845,7 @@ const defaultMocks: Record = { }, open_browser_extensions_page_cmd: () => true, open_browser_remote_debugging_page_cmd: () => true, + open_browser_connector_guide_window: () => ({ success: true }), chrome_bridge_execute_command: (args: any) => ({ success: true, request_id: `mock-${Date.now()}`, diff --git a/src/lib/webview-api.ts b/src/lib/webview-api.ts index 940182d15..866de1d25 100644 --- a/src/lib/webview-api.ts +++ b/src/lib/webview-api.ts @@ -349,6 +349,8 @@ export interface BrowserConnectorInstallResult { auto_config_path: string; } +export type BrowserConnectorGuideMode = "extension" | "cdp"; + export type BrowserBackendType = | "aster_compat" | "lime_extension_bridge" @@ -999,6 +1001,14 @@ export async function openBrowserRemoteDebuggingPage(): Promise { return safeInvoke("open_browser_remote_debugging_page_cmd"); } +export async function openBrowserConnectorGuideWindow(request: { + mode: BrowserConnectorGuideMode; +}): Promise { + return safeInvoke("open_browser_connector_guide_window", { + request, + }); +} + /** * 通过 ChromeBridge 发送测试命令 */