Compare commits

..

2 Commits

Author SHA1 Message Date
YoVinchen 60e0263cc0 fix(config): update StepFun API endpoint domain from .ai to .com 2026-04-01 16:45:59 +08:00
Bengbengbalabalabeng 72f3610ac0 docs(readme): use dynamic badges (#1806) 2026-04-01 16:09:19 +08:00
26 changed files with 231 additions and 1440 deletions
+2 -2
View File
@@ -4,10 +4,10 @@
### The All-in-One Manager for Claude Code, Codex, Gemini CLI, OpenCode & OpenClaw
[![Version](https://img.shields.io/badge/version-3.12.3-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
+2 -2
View File
@@ -4,10 +4,10 @@
### Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw のオールインワン管理ツール
[![Version](https://img.shields.io/badge/version-3.12.3-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
+2 -2
View File
@@ -4,10 +4,10 @@
### Claude Code、Codex、Gemini CLI、OpenCode 和 OpenClaw 的全方位管理工具
[![Version](https://img.shields.io/badge/version-3.12.3-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
+2 -6
View File
@@ -36,11 +36,9 @@ pub fn add_provider(
state: State<'_, AppState>,
app: String,
provider: Provider,
#[allow(non_snake_case)] addToLive: Option<bool>,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::add(state.inner(), app_type, provider, addToLive.unwrap_or(true))
.map_err(|e| e.to_string())
ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string())
}
#[tauri::command]
@@ -48,11 +46,9 @@ pub fn update_provider(
state: State<'_, AppState>,
app: String,
provider: Provider,
#[allow(non_snake_case)] originalId: Option<String>,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::update(state.inner(), app_type, originalId.as_deref(), provider)
.map_err(|e| e.to_string())
ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string())
}
#[tauri::command]
+1 -1
View File
@@ -110,7 +110,7 @@ pub fn import_provider_from_deeplink(
let provider_id = provider.id.clone();
// Use ProviderService to add the provider
ProviderService::add(state, app_type.clone(), provider, true)?;
ProviderService::add(state, app_type.clone(), provider)?;
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
for ep in all_endpoints.iter().skip(1) {
+6 -38
View File
@@ -8,6 +8,7 @@ use crate::error::AppError;
use crate::settings::{effective_backup_retain_count, get_openclaw_override_dir};
use chrono::Local;
use indexmap::IndexMap;
use json_five::parser::{FormatConfiguration, TrailingComma};
use json_five::rt::parser::{
from_str as rt_from_str, JSONKeyValuePair as RtJSONKeyValuePair,
JSONObjectContext as RtJSONObjectContext, JSONText as RtJSONText, JSONValue as RtJSONValue,
@@ -489,11 +490,11 @@ fn derive_entry_separator(leading_ws: &str) -> String {
}
fn value_to_rt_value(value: &Value, parent_indent: &str) -> Result<RtJSONValue, AppError> {
// `json-five` 0.3.1 can panic when pretty-printing nested empty maps/arrays.
// Serialize with `serde_json` instead; the resulting JSON is valid JSON5 and
// can still be parsed back into the round-trip AST we use for insertion.
let source = serde_json::to_string_pretty(value)
.map_err(|e| AppError::Config(format!("Failed to serialize JSON section: {e}")))?;
let source = json_five::to_string_formatted(
value,
FormatConfiguration::with_indent(2, TrailingComma::NONE),
)
.map_err(|e| AppError::Config(format!("Failed to serialize JSON5 section: {e}")))?;
let adjusted = reindent_json5_block(&source, parent_indent);
let text = rt_from_str(&adjusted).map_err(|e| {
@@ -1050,37 +1051,4 @@ mod tests {
assert!(err.to_string().contains("OpenClaw config changed on disk"));
});
}
#[test]
fn remove_last_provider_writes_empty_providers_without_panic() {
let source = r#"{
models: {
mode: 'merge',
providers: {
'1-copy': {
api: 'anthropic-messages',
},
},
},
}
"#;
with_test_paths(source, |_| {
let outcome = remove_provider("1-copy").unwrap();
assert!(outcome.backup_path.is_some());
let config = read_openclaw_config().unwrap();
let providers = config
.get("models")
.and_then(|models| models.get("providers"))
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
assert!(providers.is_empty());
let written = fs::read_to_string(get_openclaw_config_path()).unwrap();
assert!(written.contains("\"providers\": {}"));
});
}
}
-4
View File
@@ -283,10 +283,6 @@ pub struct ProviderMeta {
/// If not set, provider ID is used automatically during format conversion.
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
/// 累加模式应用中,该 provider 是否已写入 live config。
/// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
pub live_config_managed: Option<bool>,
/// 供应商类型标识(用于特殊供应商检测)
/// - "github_copilot": GitHub Copilot 供应商
#[serde(rename = "providerType", skip_serializing_if = "Option::is_none")]
+22 -75
View File
@@ -1,7 +1,6 @@
use crate::config::{atomic_write, write_json_file};
use crate::config::write_json_file;
use crate::error::AppError;
use crate::opencode_config::get_opencode_dir;
use crate::provider::Provider;
use crate::store::AppState;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
@@ -134,68 +133,6 @@ impl OmoService {
}
}
fn profile_data_from_provider(provider: &Provider, v: &OmoVariant) -> OmoProfileData {
let agents = provider.settings_config.get("agents").cloned();
let categories = if v.has_categories {
provider.settings_config.get("categories").cloned()
} else {
None
};
let other_fields = provider.settings_config.get("otherFields").cloned();
(agents, categories, other_fields)
}
fn snapshot_config_file(path: &Path) -> Result<Option<Vec<u8>>, AppError> {
if !path.exists() {
return Ok(None);
}
std::fs::read(path)
.map(Some)
.map_err(|e| AppError::io(path, e))
}
fn restore_config_file(path: &Path, snapshot: Option<&[u8]>) -> Result<(), AppError> {
match snapshot {
Some(bytes) => atomic_write(path, bytes),
None => {
if path.exists() {
std::fs::remove_file(path).map_err(|e| AppError::io(path, e))?;
}
Ok(())
}
}
}
fn write_profile_config(
v: &OmoVariant,
profile_data: Option<&OmoProfileData>,
) -> Result<(), AppError> {
let merged = Self::build_config(v, profile_data);
let config_path = Self::config_path(v);
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
let previous_contents = Self::snapshot_config_file(&config_path)?;
write_json_file(&config_path, &merged)?;
if let Err(err) = crate::opencode_config::add_plugin(v.plugin_name) {
if let Err(rollback_err) =
Self::restore_config_file(&config_path, previous_contents.as_deref())
{
log::warn!(
"Failed to roll back {} config after plugin sync error: {}",
v.label,
rollback_err
);
}
return Err(err);
}
log::info!("{} config written to {config_path:?}", v.label);
Ok(())
}
// ── Public API (variant-parameterized) ─────────────────
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {
@@ -216,18 +153,28 @@ impl OmoService {
pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> {
let current_omo = state.db.get_current_omo_provider("opencode", v.category)?;
let profile_data = current_omo
.as_ref()
.map(|provider| Self::profile_data_from_provider(provider, v));
Self::write_profile_config(v, profile_data.as_ref())
}
let profile_data = current_omo.as_ref().map(|p| {
let agents = p.settings_config.get("agents").cloned();
let categories = if v.has_categories {
p.settings_config.get("categories").cloned()
} else {
None
};
let other_fields = p.settings_config.get("otherFields").cloned();
(agents, categories, other_fields)
});
pub fn write_provider_config_to_file(
provider: &Provider,
v: &OmoVariant,
) -> Result<(), AppError> {
let profile_data = Self::profile_data_from_provider(provider, v);
Self::write_profile_config(v, Some(&profile_data))
let merged = Self::build_config(v, profile_data.as_ref());
let config_path = Self::config_path(v);
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
write_json_file(&config_path, &merged)?;
crate::opencode_config::add_plugin(v.plugin_name)?;
log::info!("{} config written to {config_path:?}", v.label);
Ok(())
}
fn build_config(v: &OmoVariant, profile_data: Option<&OmoProfileData>) -> Value {
+14 -42
View File
@@ -33,19 +33,6 @@ pub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value {
v
}
pub(crate) fn provider_exists_in_live_config(
app_type: &AppType,
provider_id: &str,
) -> Result<bool, AppError> {
match app_type {
AppType::OpenCode => crate::opencode_config::get_providers()
.map(|providers| providers.contains_key(provider_id)),
AppType::OpenClaw => crate::openclaw_config::get_providers()
.map(|providers| providers.contains_key(provider_id)),
_ => Ok(false),
}
}
fn json_is_subset(target: &Value, source: &Value) -> bool {
match source {
Value::Object(source_map) => {
@@ -740,10 +727,10 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
provider.id
);
} else {
return Err(AppError::Message(format!(
"OpenCode provider '{}' has invalid config structure for live config (must contain 'npm' or 'options')",
log::error!(
"OpenCode provider '{}' has invalid config structure, skipping write",
provider.id
)));
);
}
}
}
@@ -782,10 +769,10 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
provider.id
);
} else {
return Err(AppError::Message(format!(
"OpenClaw provider '{}' has invalid config structure for live config (must contain 'baseUrl', 'api', or 'models')",
log::error!(
"OpenClaw provider '{}' has invalid config structure, skipping write",
provider.id
)));
);
}
}
}
@@ -800,30 +787,23 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
/// Used for OpenCode and other additive mode applications.
fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<(), AppError> {
let providers = state.db.get_all_providers(app_type.as_str())?;
let mut synced_count = 0usize;
for provider in providers.values() {
if provider
.meta
.as_ref()
.and_then(|meta| meta.live_config_managed)
== Some(false)
{
continue;
}
if let Err(e) = write_live_with_common_config(state.db.as_ref(), app_type, provider) {
log::warn!(
"Failed to sync {:?} provider '{}' to live: {e}",
app_type,
provider.id
);
continue;
// Continue syncing other providers, don't abort
}
synced_count += 1;
}
log::info!("Synced {synced_count} {app_type:?} providers to live config");
log::info!(
"Synced {} {:?} providers to live config",
providers.len(),
app_type
);
Ok(())
}
@@ -1227,16 +1207,12 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
};
// Create provider
let mut provider = Provider::with_id(
let provider = Provider::with_id(
id.clone(),
config.name.clone().unwrap_or_else(|| id.clone()),
settings_config,
None,
);
provider.meta = Some(crate::provider::ProviderMeta {
live_config_managed: Some(true),
..Default::default()
});
// Save to database
if let Err(e) = state.db.save_provider("opencode", &provider) {
@@ -1301,11 +1277,7 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
.unwrap_or_else(|| id.clone());
// Create provider
let mut provider = Provider::with_id(id.clone(), display_name, settings_config, None);
provider.meta = Some(crate::provider::ProviderMeta {
live_config_managed: Some(true),
..Default::default()
});
let provider = Provider::with_id(id.clone(), display_name, settings_config, None);
// Save to database
if let Err(e) = state.db.save_provider("openclaw", &provider) {
File diff suppressed because it is too large Load Diff
+6 -41
View File
@@ -533,14 +533,8 @@ function App() {
}
};
const handleEditProvider = async ({
provider,
originalId,
}: {
provider: Provider;
originalId?: string;
}) => {
await updateProvider(provider, originalId);
const handleEditProvider = async (provider: Provider) => {
await updateProvider(provider);
setEditingProvider(null);
};
@@ -577,7 +571,7 @@ function App() {
setConfirmAction(null);
};
const generateUniqueProviderCopyKey = (
const generateUniqueOpencodeKey = (
originalKey: string,
existingKeys: string[],
): string => {
@@ -600,7 +594,6 @@ function App() {
const duplicatedProvider: Omit<Provider, "id" | "createdAt"> & {
providerKey?: string;
addToLive?: boolean;
} = {
name: `${provider.name} copy`,
settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝
@@ -614,40 +607,12 @@ function App() {
iconColor: provider.iconColor,
};
if (activeApp === "opencode" || activeApp === "openclaw") {
let liveProviderIds: string[] = [];
try {
liveProviderIds =
activeApp === "opencode"
? await queryClient.ensureQueryData({
queryKey: ["opencodeLiveProviderIds"],
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
})
: await queryClient.ensureQueryData({
queryKey: openclawKeys.liveProviderIds,
queryFn: () => providersApi.getOpenClawLiveProviderIds(),
});
} catch (error) {
console.error(
"[App] Failed to load live provider IDs for duplication",
error,
);
const errorMessage = extractErrorMessage(error);
toast.error(
t("provider.duplicateLiveIdsLoadFailed", {
defaultValue: "读取配置中的供应商标识失败,请先修复配置后再试",
}) + (errorMessage ? `: ${errorMessage}` : ""),
);
return;
}
const existingKeys = Array.from(
new Set([...Object.keys(providers), ...liveProviderIds]),
);
duplicatedProvider.providerKey = generateUniqueProviderCopyKey(
if (activeApp === "opencode") {
const existingKeys = Object.keys(providers);
duplicatedProvider.providerKey = generateUniqueOpencodeKey(
provider.id,
existingKeys,
);
duplicatedProvider.addToLive = false;
}
if (provider.sortIndex !== undefined) {
@@ -14,10 +14,7 @@ interface EditProviderDialogProps {
open: boolean;
provider: Provider | null;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: {
provider: Provider;
originalId?: string;
}) => Promise<void> | void;
onSubmit: (provider: Provider) => Promise<void> | void;
appId: AppId;
isProxyTakeover?: boolean; // 代理接管模式下不读取 live(避免显示被接管后的代理配置)
}
@@ -168,15 +165,9 @@ export function EditProviderDialog({
string,
unknown
>;
const nextProviderId =
(appId === "opencode" || appId === "openclaw") &&
values.providerKey?.trim()
? values.providerKey.trim()
: provider.id;
const updatedProvider: Provider = {
...provider,
id: nextProviderId,
name: values.name.trim(),
notes: values.notes?.trim() || undefined,
websiteUrl: values.websiteUrl?.trim() || undefined,
@@ -188,13 +179,10 @@ export function EditProviderDialog({
...(values.meta ? { meta: values.meta } : {}),
};
await onSubmit({
provider: updatedProvider,
originalId: provider.id,
});
await onSubmit(updatedProvider);
onOpenChange(false);
},
[appId, onSubmit, onOpenChange, provider],
[onSubmit, onOpenChange, provider],
);
if (!provider || !initialData) {
+23 -134
View File
@@ -1,14 +1,13 @@
import { useEffect, useMemo, useState, useCallback } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
import { providersApi, type AppId } from "@/lib/api";
import type { AppId } from "@/lib/api";
import type {
ProviderCategory,
ProviderMeta,
@@ -92,7 +91,6 @@ import {
normalizePricingSource,
} from "./helpers/opencodeFormUtils";
import { resolveManagedAccountId } from "@/lib/authBinding";
import { useOpenClawLiveProviderIds } from "@/hooks/useOpenClaw";
type PresetEntry = {
id: string;
@@ -579,15 +577,6 @@ export function ProviderForm({
existingOpencodeKeys,
} = useOmoModelSource({ isOmoCategory: isAnyOmoCategory, providerId });
const {
data: opencodeLiveProviderIds = [],
isLoading: isOpencodeLiveProviderIdsLoading,
} = useQuery({
queryKey: ["opencodeLiveProviderIds"],
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
enabled: appId === "opencode" && !isAnyOmoCategory,
});
const opencodeForm = useOpencodeFormState({
initialData,
appId,
@@ -616,78 +605,6 @@ export function ProviderForm({
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
getSettingsConfig: () => form.getValues("settingsConfig"),
});
const {
data: openclawLiveProviderIds = [],
isLoading: isOpenclawLiveProviderIdsLoading,
} = useOpenClawLiveProviderIds(appId === "openclaw");
const additiveExistingProviderKeys = useMemo(() => {
if (appId === "opencode" && !isAnyOmoCategory) {
return Array.from(
new Set(
[...existingOpencodeKeys, ...opencodeLiveProviderIds].filter(
(key) => key !== providerId,
),
),
);
}
if (appId === "openclaw") {
return Array.from(
new Set(
[
...openclawForm.existingOpenclawKeys,
...openclawLiveProviderIds,
].filter((key) => key !== providerId),
),
);
}
return [];
}, [
appId,
existingOpencodeKeys,
isAnyOmoCategory,
openclawForm.existingOpenclawKeys,
openclawLiveProviderIds,
opencodeLiveProviderIds,
providerId,
]);
const isProviderKeyLockStateLoading = useMemo(() => {
if (!isEditMode) return false;
if (appId === "opencode" && !isAnyOmoCategory) {
return isOpencodeLiveProviderIdsLoading;
}
if (appId === "openclaw") {
return isOpenclawLiveProviderIdsLoading;
}
return false;
}, [
appId,
isAnyOmoCategory,
isEditMode,
isOpenclawLiveProviderIdsLoading,
isOpencodeLiveProviderIdsLoading,
]);
const isProviderKeyLocked = useMemo(() => {
if (!isEditMode || !providerId) return false;
if (appId === "opencode" && !isAnyOmoCategory) {
return opencodeLiveProviderIds.includes(providerId);
}
if (appId === "openclaw") {
return openclawLiveProviderIds.includes(providerId);
}
return false;
}, [
appId,
isAnyOmoCategory,
isEditMode,
openclawLiveProviderIds,
opencodeLiveProviderIds,
providerId,
]);
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
@@ -724,17 +641,9 @@ export function ProviderForm({
toast.error(t("opencode.providerKeyInvalid"));
return;
}
if (isProviderKeyLockStateLoading) {
toast.error(
t("providerForm.providerKeyStatusLoading", {
defaultValue: "正在加载供应商标识状态,请稍后再试",
}),
);
return;
}
if (
!isProviderKeyLocked &&
additiveExistingProviderKeys.includes(opencodeForm.opencodeProviderKey)
!isEditMode &&
existingOpencodeKeys.includes(opencodeForm.opencodeProviderKey)
) {
toast.error(t("opencode.providerKeyDuplicate"));
return;
@@ -756,17 +665,11 @@ export function ProviderForm({
toast.error(t("openclaw.providerKeyInvalid"));
return;
}
if (isProviderKeyLockStateLoading) {
toast.error(
t("providerForm.providerKeyStatusLoading", {
defaultValue: "正在加载供应商标识状态,请稍后再试",
}),
);
return;
}
if (
!isProviderKeyLocked &&
additiveExistingProviderKeys.includes(openclawForm.openclawProviderKey)
!isEditMode &&
openclawForm.existingOpenclawKeys.includes(
openclawForm.openclawProviderKey,
)
) {
toast.error(t("openclaw.providerKeyDuplicate"));
return;
@@ -1350,14 +1253,12 @@ export function ProviderForm({
)
}
placeholder={t("opencode.providerKeyPlaceholder")}
disabled={
isProviderKeyLocked || isProviderKeyLockStateLoading
}
disabled={isEditMode}
className={
(additiveExistingProviderKeys.includes(
(existingOpencodeKeys.includes(
opencodeForm.opencodeProviderKey,
) &&
!isProviderKeyLocked) ||
!isEditMode) ||
(opencodeForm.opencodeProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
opencodeForm.opencodeProviderKey,
@@ -1366,10 +1267,10 @@ export function ProviderForm({
: ""
}
/>
{additiveExistingProviderKeys.includes(
{existingOpencodeKeys.includes(
opencodeForm.opencodeProviderKey,
) &&
!isProviderKeyLocked && (
!isEditMode && (
<p className="text-xs text-destructive">
{t("opencode.providerKeyDuplicate")}
</p>
@@ -1383,21 +1284,16 @@ export function ProviderForm({
</p>
)}
{!(
additiveExistingProviderKeys.includes(
existingOpencodeKeys.includes(
opencodeForm.opencodeProviderKey,
) && !isProviderKeyLocked
) && !isEditMode
) &&
(opencodeForm.opencodeProviderKey.trim() === "" ||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
opencodeForm.opencodeProviderKey,
)) && (
<p className="text-xs text-muted-foreground">
{isProviderKeyLocked
? t("opencode.providerKeyLockedHint", {
defaultValue:
"该供应商已添加到应用配置中,供应商标识不可修改",
})
: t("opencode.providerKeyHint")}
{t("opencode.providerKeyHint")}
</p>
)}
</div>
@@ -1416,14 +1312,12 @@ export function ProviderForm({
)
}
placeholder={t("openclaw.providerKeyPlaceholder")}
disabled={
isProviderKeyLocked || isProviderKeyLockStateLoading
}
disabled={isEditMode}
className={
(additiveExistingProviderKeys.includes(
(openclawForm.existingOpenclawKeys.includes(
openclawForm.openclawProviderKey,
) &&
!isProviderKeyLocked) ||
!isEditMode) ||
(openclawForm.openclawProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
openclawForm.openclawProviderKey,
@@ -1432,10 +1326,10 @@ export function ProviderForm({
: ""
}
/>
{additiveExistingProviderKeys.includes(
{openclawForm.existingOpenclawKeys.includes(
openclawForm.openclawProviderKey,
) &&
!isProviderKeyLocked && (
!isEditMode && (
<p className="text-xs text-destructive">
{t("openclaw.providerKeyDuplicate")}
</p>
@@ -1449,21 +1343,16 @@ export function ProviderForm({
</p>
)}
{!(
additiveExistingProviderKeys.includes(
openclawForm.existingOpenclawKeys.includes(
openclawForm.openclawProviderKey,
) && !isProviderKeyLocked
) && !isEditMode
) &&
(openclawForm.openclawProviderKey.trim() === "" ||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
openclawForm.openclawProviderKey,
)) && (
<p className="text-xs text-muted-foreground">
{isProviderKeyLocked
? t("openclaw.providerKeyLockedHint", {
defaultValue:
"该供应商已添加到应用配置中,供应商标识不可修改",
})
: t("openclaw.providerKeyHint")}
{t("openclaw.providerKeyHint")}
</p>
)}
</div>
+2 -2
View File
@@ -192,7 +192,7 @@ export const providerPresets: ProviderPreset[] = [
apiKeyUrl: "https://platform.stepfun.ai/interface-key",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.stepfun.ai/v1",
ANTHROPIC_BASE_URL: "https://api.stepfun.com/v1",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "step-3.5-flash",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "step-3.5-flash",
@@ -201,7 +201,7 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
endpointCandidates: ["https://api.stepfun.ai/v1"],
endpointCandidates: ["https://api.stepfun.com/v1"],
apiFormat: "openai_chat",
icon: "stepfun",
iconColor: "#005AFF",
+3 -3
View File
@@ -298,7 +298,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
websiteUrl: "https://platform.stepfun.ai",
apiKeyUrl: "https://platform.stepfun.ai/interface-key",
settingsConfig: {
baseUrl: "https://api.stepfun.ai/v1",
baseUrl: "https://api.stepfun.com/v1",
apiKey: "",
api: "openai-completions",
models: [
@@ -315,8 +315,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
templateValues: {
baseUrl: {
label: "Base URL",
placeholder: "https://api.stepfun.ai/v1",
defaultValue: "https://api.stepfun.ai/v1",
placeholder: "https://api.stepfun.com/v1",
defaultValue: "https://api.stepfun.com/v1",
editorValue: "",
},
apiKey: {
+3 -3
View File
@@ -489,7 +489,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
npm: "@ai-sdk/openai-compatible",
name: "StepFun",
options: {
baseURL: "https://api.stepfun.ai/v1",
baseURL: "https://api.stepfun.com/v1",
apiKey: "",
setCacheKey: true,
},
@@ -503,8 +503,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
templateValues: {
baseURL: {
label: "Base URL",
placeholder: "https://api.stepfun.ai/v1",
defaultValue: "https://api.stepfun.ai/v1",
placeholder: "https://api.stepfun.com/v1",
defaultValue: "https://api.stepfun.com/v1",
editorValue: "",
},
apiKey: {
+2 -3
View File
@@ -65,7 +65,6 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
provider: Omit<Provider, "id"> & {
providerKey?: string;
suggestedDefaults?: OpenClawSuggestedDefaults;
addToLive?: boolean;
},
) => {
await addProviderMutation.mutateAsync(provider);
@@ -121,8 +120,8 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
// 更新供应商
const updateProvider = useCallback(
async (provider: Provider, originalId?: string) => {
await updateProviderMutation.mutateAsync({ provider, originalId });
async (provider: Provider) => {
await updateProviderMutation.mutateAsync(provider);
// 更新托盘菜单(失败不影响主操作)
try {
+2 -4
View File
@@ -933,8 +933,7 @@
"modelsRequired": "Please add at least one model",
"providerKey": "Provider Key",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "Unique identifier in config file. Use lowercase letters, numbers, and hyphens only.",
"providerKeyLockedHint": "This provider has already been added to the app config, so its key can no longer be changed.",
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
"providerKeyRequired": "Provider key is required",
"providerKeyDuplicate": "This key is already in use",
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
@@ -1393,8 +1392,7 @@
"backupCreated": "Backup created: {{path}}",
"providerKey": "Provider Key",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "Unique identifier in config file. Use lowercase letters, numbers, and hyphens only.",
"providerKeyLockedHint": "This provider has already been added to the app config, so its key can no longer be changed.",
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
"providerKeyRequired": "Provider key is required",
"providerKeyDuplicate": "This key is already in use",
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
+2 -4
View File
@@ -933,8 +933,7 @@
"modelsRequired": "モデルを少なくとも1つ追加してください",
"providerKey": "プロバイダーキー",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "設定ファイルの一意の識別子です。小文字、数字、ハイフンのみ使用できます。",
"providerKeyLockedHint": "このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。",
"providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。",
"providerKeyRequired": "プロバイダーキーを入力してください",
"providerKeyDuplicate": "このキーは既に使用されています",
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。",
@@ -1393,8 +1392,7 @@
"backupCreated": "バックアップを作成しました: {{path}}",
"providerKey": "プロバイダーキー",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "設定ファイル内のユニーク識別子。小文字、数字、ハイフンのみ使用可能。",
"providerKeyLockedHint": "このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。",
"providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。",
"providerKeyRequired": "プロバイダーキーを入力してください",
"providerKeyDuplicate": "このキーは既に使用されています",
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。",
+2 -4
View File
@@ -933,8 +933,7 @@
"modelsRequired": "请至少添加一个模型配置",
"providerKey": "供应商标识",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "配置文件中的唯一标识符,只能使用小写字母、数字和连字符",
"providerKeyLockedHint": "该供应商已添加到应用配置中,供应商标识不可修改",
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
"providerKeyRequired": "请填写供应商标识",
"providerKeyDuplicate": "此标识已被使用,请更换",
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
@@ -1393,8 +1392,7 @@
"backupCreated": "已创建备份:{{path}}",
"providerKey": "供应商标识",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "配置文件中的唯一标识符,只能使用小写字母、数字和连字符",
"providerKeyLockedHint": "该供应商已添加到应用配置中,供应商标识不可修改",
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
"providerKeyRequired": "请填写供应商标识",
"providerKeyDuplicate": "此标识已被使用,请更换",
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
+4 -16
View File
@@ -34,24 +34,12 @@ export const providersApi = {
return await invoke("get_current_provider", { app: appId });
},
async add(
provider: Provider,
appId: AppId,
addToLive?: boolean,
): Promise<boolean> {
return await invoke("add_provider", { provider, app: appId, addToLive });
async add(provider: Provider, appId: AppId): Promise<boolean> {
return await invoke("add_provider", { provider, app: appId });
},
async update(
provider: Provider,
appId: AppId,
originalId?: string,
): Promise<boolean> {
return await invoke("update_provider", {
provider,
app: appId,
originalId,
});
async update(provider: Provider, appId: AppId): Promise<boolean> {
return await invoke("update_provider", { provider, app: appId });
},
async delete(id: string, appId: AppId): Promise<boolean> {
+5 -14
View File
@@ -15,10 +15,7 @@ export const useAddProviderMutation = (appId: AppId) => {
return useMutation({
mutationFn: async (
providerInput: Omit<Provider, "id"> & {
providerKey?: string;
addToLive?: boolean;
},
providerInput: Omit<Provider, "id"> & { providerKey?: string },
) => {
let id: string;
@@ -39,7 +36,7 @@ export const useAddProviderMutation = (appId: AppId) => {
id = generateUUID();
}
const { providerKey: _providerKey, addToLive, ...rest } = providerInput;
const { providerKey: _providerKey, ...rest } = providerInput;
const newProvider: Provider = {
...rest,
@@ -48,7 +45,7 @@ export const useAddProviderMutation = (appId: AppId) => {
};
delete (newProvider as any).providerKey;
await providersApi.add(newProvider, appId, addToLive);
await providersApi.add(newProvider, appId);
return newProvider;
},
onSuccess: async () => {
@@ -110,14 +107,8 @@ export const useUpdateProviderMutation = (appId: AppId) => {
const { t } = useTranslation();
return useMutation({
mutationFn: async ({
provider,
originalId,
}: {
provider: Provider;
originalId?: string;
}) => {
await providersApi.update(provider, appId, originalId);
mutationFn: async (provider: Provider) => {
await providersApi.update(provider, appId);
return provider;
},
onSuccess: async () => {
+1 -4
View File
@@ -169,10 +169,7 @@ describe("useProviderActions", () => {
await result.current.updateProvider(provider);
});
expect(updateProviderMutateAsync).toHaveBeenCalledWith({
provider,
originalId: undefined,
});
expect(updateProviderMutateAsync).toHaveBeenCalledWith(provider);
expect(providersApiUpdateTrayMenuMock).toHaveBeenCalledTimes(1);
});
+3 -104
View File
@@ -2,13 +2,7 @@ import { Suspense, type ComponentType } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { providersApi } from "@/lib/api/providers";
import {
resetProviderState,
setCurrentProviderId,
setLiveProviderIds,
setProviders,
} from "../msw/state";
import { resetProviderState } from "../msw/state";
import { emitTauriEvent } from "../msw/tauriMocks";
const toastSuccessMock = vi.fn();
@@ -81,11 +75,8 @@ vi.mock("@/components/providers/EditProviderDialog", () => ({
<button
onClick={() =>
onSubmit({
provider: {
...provider,
name: `${provider.name}-edited`,
},
originalId: provider.id,
...provider,
name: `${provider.name}-edited`,
})
}
>
@@ -123,7 +114,6 @@ vi.mock("@/components/AppSwitcher", () => ({
<span>{activeApp}</span>
<button onClick={() => onSwitch("claude")}>switch-claude</button>
<button onClick={() => onSwitch("codex")}>switch-codex</button>
<button onClick={() => onSwitch("openclaw")}>switch-openclaw</button>
</div>
),
}));
@@ -240,95 +230,4 @@ describe("App integration with MSW", () => {
expect(toastErrorMock).toHaveBeenCalled();
});
});
it("duplicates openclaw providers with a generated key that avoids live-only ids", async () => {
setProviders("openclaw", {
deepseek: {
id: "deepseek",
name: "DeepSeek",
settingsConfig: {
baseUrl: "https://api.deepseek.com",
apiKey: "test-key",
api: "openai-completions",
models: [],
},
category: "custom",
sortIndex: 0,
createdAt: Date.now(),
},
});
setCurrentProviderId("openclaw", "deepseek");
setLiveProviderIds("openclaw", ["deepseek-copy"]);
const { default: App } = await import("@/App");
renderApp(App);
fireEvent.click(screen.getByText("switch-openclaw"));
await waitFor(() =>
expect(screen.getByTestId("provider-list").textContent).toContain(
"deepseek",
),
);
fireEvent.click(screen.getByText("duplicate"));
await waitFor(() => {
const providerList = screen.getByTestId("provider-list").textContent;
expect(providerList).toContain("deepseek-copy-2");
expect(providerList).toContain("DeepSeek copy");
});
expect(toastErrorMock).not.toHaveBeenCalledWith(
expect.stringContaining("Provider key is required for openclaw"),
);
});
it("shows toast when duplicate cannot load live provider ids", async () => {
setProviders("openclaw", {
deepseek: {
id: "deepseek",
name: "DeepSeek",
settingsConfig: {
baseUrl: "https://api.deepseek.com",
apiKey: "test-key",
api: "openai-completions",
models: [],
},
category: "custom",
sortIndex: 0,
createdAt: Date.now(),
},
});
setCurrentProviderId("openclaw", "deepseek");
const liveIdsSpy = vi
.spyOn(providersApi, "getOpenClawLiveProviderIds")
.mockRejectedValueOnce(new Error("broken config"));
const { default: App } = await import("@/App");
renderApp(App);
fireEvent.click(screen.getByText("switch-openclaw"));
await waitFor(() =>
expect(screen.getByTestId("provider-list").textContent).toContain(
"deepseek",
),
);
fireEvent.click(screen.getByText("duplicate"));
await waitFor(() => {
expect(toastErrorMock).toHaveBeenCalledWith(
expect.stringContaining("读取配置中的供应商标识失败"),
);
});
expect(screen.getByTestId("provider-list").textContent).not.toContain(
"deepseek-copy",
);
liveIdsSpy.mockRestore();
});
});
-17
View File
@@ -6,7 +6,6 @@ import {
deleteProvider,
deleteSession,
getCurrentProviderId,
getLiveProviderIds,
getSessionMessages,
getProviders,
listProviders,
@@ -68,20 +67,6 @@ export const handlers = [
http.post(`${TAURI_ENDPOINT}/update_tray_menu`, () => success(true)),
http.post(`${TAURI_ENDPOINT}/get_opencode_live_provider_ids`, () =>
success(getLiveProviderIds("opencode")),
),
http.post(`${TAURI_ENDPOINT}/get_openclaw_live_provider_ids`, () =>
success(getLiveProviderIds("openclaw")),
),
http.post(`${TAURI_ENDPOINT}/get_openclaw_default_model`, () =>
success({ primary: null, fallback: [] }),
),
http.post(`${TAURI_ENDPOINT}/scan_openclaw_config_health`, () => success([])),
http.post(`${TAURI_ENDPOINT}/switch_provider`, async ({ request }) => {
const { id, app } = await withJson<{ id: string; app: AppId }>(request);
const providers = listProviders(app);
@@ -212,8 +197,6 @@ export const handlers = [
http.post(`${TAURI_ENDPOINT}/get_settings`, () => success(getSettings())),
http.post(`${TAURI_ENDPOINT}/check_env_conflicts`, () => success([])),
http.post(`${TAURI_ENDPOINT}/save_settings`, async ({ request }) => {
const { settings } = await withJson<{ settings: Settings }>(request);
setSettings(settings);
-20
View File
@@ -10,7 +10,6 @@ import type {
type ProvidersByApp = Record<AppId, Record<string, Provider>>;
type CurrentProviderState = Record<AppId, string>;
type McpConfigState = Record<AppId, Record<string, McpServer>>;
type LiveProviderIdsByApp = Record<"opencode" | "openclaw", string[]>;
const createDefaultProviders = (): ProvidersByApp => ({
claude: {
@@ -78,10 +77,6 @@ const createDefaultCurrent = (): CurrentProviderState => ({
let providers = createDefaultProviders();
let current = createDefaultCurrent();
let liveProviderIds: LiveProviderIdsByApp = {
opencode: [],
openclaw: [],
};
let settingsState: Settings = {
showInTray: true,
minimizeToTrayOnClose: true,
@@ -189,10 +184,6 @@ const cloneProviders = (value: ProvidersByApp) =>
export const resetProviderState = () => {
providers = createDefaultProviders();
current = createDefaultCurrent();
liveProviderIds = {
opencode: [],
openclaw: [],
};
sessionsState = createDefaultSessions();
sessionMessagesState = createDefaultSessionMessages();
settingsState = {
@@ -252,17 +243,6 @@ export const getProviders = (appType: AppId) =>
export const getCurrentProviderId = (appType: AppId) => current[appType] ?? "";
export const getLiveProviderIds = (appType: "opencode" | "openclaw") => [
...liveProviderIds[appType],
];
export const setLiveProviderIds = (
appType: "opencode" | "openclaw",
ids: string[],
) => {
liveProviderIds[appType] = [...ids];
};
export const setCurrentProviderId = (appType: AppId, providerId: string) => {
current[appType] = providerId;
};