chore: finalize v1.21.0 release updates

This commit is contained in:
coso
2026-04-28 12:33:00 +08:00
parent fba6b22493
commit 32825054e8
35 changed files with 992 additions and 154 deletions
+2 -3
View File
@@ -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
+1
View File
@@ -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`。
@@ -219,5 +219,5 @@ describe("Harness eval history window", () => {
expect(report.signals).not.toContain(
"样本数不足 2,当前仅形成 trend seed,还不能判断长期退化。",
);
}, 30_000);
}, 90_000);
});
+30 -1
View File
@@ -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,
);
@@ -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]
@@ -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",
];
@@ -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",
];
@@ -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"]
+13 -1
View File
@@ -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"]
+1
View File
@@ -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,
@@ -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<String>,
}
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<bool, String> {
pub async fn open_browser_remote_debugging_page_cmd() -> Result<bool, String> {
open_chrome_url("chrome://inspect/#remote-debugging")
}
#[tauri::command]
pub fn open_browser_connector_guide_window(
app: AppHandle,
request: Option<OpenBrowserConnectorGuideWindowRequest>,
) -> 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}"))
}
+1
View File
@@ -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 服务
@@ -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"
);
}
}
+1
View File
@@ -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;
@@ -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 (
+3
View File
@@ -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");
@@ -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",
@@ -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<HTMLButtonElement>('[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<HTMLButtonElement>("add-model-button").click();
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
findByTestId<HTMLButtonElement>("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<HTMLButtonElement>('[data-template-id="kimi-code-subscription"]')
?.click();
await Promise.resolve();
});
expect(container.textContent ?? "").toContain("https://api.kimi.com/coding/");
});
it("自定义供应商可在添加流程内完成创建、加 Key、写入模型并激活", async () => {
@@ -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<string, string> = {
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<string, string> = {
"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 (
<span className="flex flex-wrap items-center gap-1.5">
{badges.map((badge) => (
<Badge
key={badge}
className="border border-slate-200 bg-slate-50 px-2 py-0 text-[11px] text-slate-600 hover:bg-slate-50"
>
{badge}
</Badge>
))}
</span>
);
}
export const ModelAddPanel: React.FC<ModelAddPanelProps> = ({
providers,
onAddProvider,
@@ -635,14 +800,14 @@ export const ModelAddPanel: React.FC<ModelAddPanelProps> = ({
)}
data-testid="model-add-catalog"
>
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-full bg-slate-100 p-1">
<div className="mb-4 grid grid-cols-2 gap-1 rounded-[18px] bg-slate-100 p-1 sm:flex sm:flex-wrap sm:items-center sm:gap-2 sm:rounded-full">
{CATEGORY_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setCategory(option.value)}
className={cn(
"min-w-[118px] rounded-full px-4 py-2 text-sm font-semibold transition",
"min-w-0 rounded-[14px] px-3 py-2 text-sm font-semibold transition sm:min-w-[118px] sm:rounded-full sm:px-4",
category === option.value
? "bg-white text-slate-900 shadow-sm shadow-slate-950/5"
: "text-slate-500 hover:text-slate-800",
@@ -675,15 +840,11 @@ export const ModelAddPanel: React.FC<ModelAddPanelProps> = ({
{renderTemplateIcon(template)}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-slate-900">
<span className="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
<span className="min-w-0 truncate text-sm font-semibold text-slate-900">
{template.name}
</span>
{template.recommended ? (
<Badge className="border border-amber-200 bg-amber-50 px-2 py-0 text-[11px] text-amber-700 hover:bg-amber-50">
推荐
</Badge>
) : null}
{renderTemplateBadges(template)}
</span>
<span className="mt-1 block text-sm leading-5 text-slate-500">
{template.description}
@@ -748,9 +909,12 @@ export const ModelAddPanel: React.FC<ModelAddPanelProps> = ({
{renderTemplateIcon(template)}
</span>
<div className="min-w-0">
<h3 className="truncate text-lg font-semibold text-slate-900">
{template.isCustom ? "自定义供应商" : `配置 ${template.name}`}
</h3>
<div className="flex min-w-0 flex-col gap-1 sm:flex-row sm:flex-wrap sm:items-center sm:gap-2">
<h3 className="min-w-0 truncate text-lg font-semibold text-slate-900">
{template.isCustom ? "自定义供应商" : `配置 ${template.name}`}
</h3>
{renderTemplateBadges(template)}
</div>
<p className="mt-1 text-sm text-slate-500">{template.description}</p>
</div>
</div>
@@ -57,7 +57,7 @@ const SPECIAL_PROVIDER_PROTOCOL_HINTS: Partial<Record<ProviderType, string>> = {
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 处理。",
};
@@ -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",
@@ -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<boolean> {
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<void>((resolve) => {
void guideWindow.once("tauri://created", () => resolve());
}),
new Promise<void>((resolve) => window.setTimeout(resolve, 160)),
]);
return true;
} catch (error) {
console.warn("[连接器引导] 打开独立窗口失败,回退到浏览器窗口:", error);
return false;
}
}
export async function openBrowserConnectorGuideWindow({
mode,
}: {
mode: BrowserConnectorGuideMode;
}): Promise<void> {
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",
);
}
}
@@ -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();
});
});
@@ -334,8 +334,8 @@ function BrowserConnectorGuideContent() {
);
return (
<main className="lime-settings-theme-scope min-h-screen overflow-y-auto bg-[color:var(--lime-stage-surface,hsl(var(--background)))] text-[color:var(--lime-text,hsl(var(--foreground)))]">
<div className="mx-auto max-w-[900px] px-7 py-12 sm:px-10">
<main className="lime-settings-theme-scope h-screen overflow-y-auto bg-[color:var(--lime-stage-surface,hsl(var(--background)))] text-[color:var(--lime-text,hsl(var(--foreground)))]">
<div className="mx-auto max-w-[900px] px-7 py-12 pb-20 sm:px-10">
<header className="flex flex-col gap-5 sm:flex-row sm:items-start">
<div className="flex h-[60px] w-[60px] shrink-0 items-center justify-center rounded-[18px] border border-emerald-200 bg-emerald-50 text-emerald-700">
<Sparkles className="h-7 w-7" />
+30
View File
@@ -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",
+63
View File
@@ -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";
+46 -6
View File
@@ -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<OemCloudPublicOAuthProvider[]> {
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<OemCloudPublicAuthCatalog> {
return parsePublicAuthCatalog(
await requestControlPlane<unknown>(
`/v1/public/tenants/${encodeURIComponent(tenantId)}/oauth/providers`,
),
);
}
export async function verifyClientAuthEmailCode(
@@ -69,6 +69,7 @@ const mockPriorityCommands = new Set<string>([
"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",
@@ -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",
@@ -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"
@@ -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",
+83 -24
View File
@@ -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();
});
});
+31 -6
View File
@@ -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<ReturnType<typeof getPublicAuthCatalog>>,
): 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);
+1
View File
@@ -4845,6 +4845,7 @@ const defaultMocks: Record<string, any> = {
},
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()}`,
+10
View File
@@ -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<boolean> {
return safeInvoke<boolean>("open_browser_remote_debugging_page_cmd");
}
export async function openBrowserConnectorGuideWindow(request: {
mode: BrowserConnectorGuideMode;
}): Promise<void> {
return safeInvoke<void>("open_browser_connector_guide_window", {
request,
});
}
/**
* 通过 ChromeBridge 发送测试命令
*/