mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-16 09:39:29 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52f0ff3961 | |||
| 544e9bf937 | |||
| 48fb00d81b | |||
| fb70f6d4b7 | |||
| fda88b2e42 | |||
| 98c8255dbb | |||
| dbb943852d | |||
| cc070327ff | |||
| d61c24255b | |||
| b3bb020d3c | |||
| 31278e8916 | |||
| e9ead2841d |
@@ -36,9 +36,11 @@ 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).map_err(|e| e.to_string())
|
||||
ProviderService::add(state.inner(), app_type, provider, addToLive.unwrap_or(true))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -46,9 +48,11 @@ 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, provider).map_err(|e| e.to_string())
|
||||
ProviderService::update(state.inner(), app_type, originalId.as_deref(), provider)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -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)?;
|
||||
ProviderService::add(state, app_type.clone(), provider, true)?;
|
||||
|
||||
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
|
||||
for ep in all_endpoints.iter().skip(1) {
|
||||
|
||||
@@ -8,7 +8,6 @@ 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,
|
||||
@@ -490,11 +489,11 @@ fn derive_entry_separator(leading_ws: &str) -> String {
|
||||
}
|
||||
|
||||
fn value_to_rt_value(value: &Value, parent_indent: &str) -> Result<RtJSONValue, AppError> {
|
||||
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}")))?;
|
||||
// `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 adjusted = reindent_json5_block(&source, parent_indent);
|
||||
let text = rt_from_str(&adjusted).map_err(|e| {
|
||||
@@ -1051,4 +1050,37 @@ 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\": {}"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +283,10 @@ 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")]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::config::write_json_file;
|
||||
use crate::config::{atomic_write, 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};
|
||||
@@ -133,6 +134,68 @@ 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> {
|
||||
@@ -153,28 +216,18 @@ 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(|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)
|
||||
});
|
||||
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 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(())
|
||||
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))
|
||||
}
|
||||
|
||||
fn build_config(v: &OmoVariant, profile_data: Option<&OmoProfileData>) -> Value {
|
||||
|
||||
@@ -33,6 +33,19 @@ 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) => {
|
||||
@@ -727,10 +740,10 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
provider.id
|
||||
);
|
||||
} else {
|
||||
log::error!(
|
||||
"OpenCode provider '{}' has invalid config structure, skipping write",
|
||||
return Err(AppError::Message(format!(
|
||||
"OpenCode provider '{}' has invalid config structure for live config (must contain 'npm' or 'options')",
|
||||
provider.id
|
||||
);
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -769,10 +782,10 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
provider.id
|
||||
);
|
||||
} else {
|
||||
log::error!(
|
||||
"OpenClaw provider '{}' has invalid config structure, skipping write",
|
||||
return Err(AppError::Message(format!(
|
||||
"OpenClaw provider '{}' has invalid config structure for live config (must contain 'baseUrl', 'api', or 'models')",
|
||||
provider.id
|
||||
);
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -787,23 +800,30 @@ 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 syncing other providers, don't abort
|
||||
continue;
|
||||
}
|
||||
synced_count += 1;
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Synced {} {:?} providers to live config",
|
||||
providers.len(),
|
||||
app_type
|
||||
);
|
||||
log::info!("Synced {synced_count} {app_type:?} providers to live config");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1207,12 +1227,16 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
};
|
||||
|
||||
// Create provider
|
||||
let provider = Provider::with_id(
|
||||
let mut 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) {
|
||||
@@ -1277,7 +1301,11 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
.unwrap_or_else(|| id.clone());
|
||||
|
||||
// Create provider
|
||||
let provider = Provider::with_id(id.clone(), display_name, settings_config, None);
|
||||
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()
|
||||
});
|
||||
|
||||
// Save to database
|
||||
if let Err(e) = state.db.save_provider("openclaw", &provider) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+41
-6
@@ -533,8 +533,14 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditProvider = async (provider: Provider) => {
|
||||
await updateProvider(provider);
|
||||
const handleEditProvider = async ({
|
||||
provider,
|
||||
originalId,
|
||||
}: {
|
||||
provider: Provider;
|
||||
originalId?: string;
|
||||
}) => {
|
||||
await updateProvider(provider, originalId);
|
||||
setEditingProvider(null);
|
||||
};
|
||||
|
||||
@@ -571,7 +577,7 @@ function App() {
|
||||
setConfirmAction(null);
|
||||
};
|
||||
|
||||
const generateUniqueOpencodeKey = (
|
||||
const generateUniqueProviderCopyKey = (
|
||||
originalKey: string,
|
||||
existingKeys: string[],
|
||||
): string => {
|
||||
@@ -594,6 +600,7 @@ function App() {
|
||||
|
||||
const duplicatedProvider: Omit<Provider, "id" | "createdAt"> & {
|
||||
providerKey?: string;
|
||||
addToLive?: boolean;
|
||||
} = {
|
||||
name: `${provider.name} copy`,
|
||||
settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝
|
||||
@@ -607,12 +614,40 @@ function App() {
|
||||
iconColor: provider.iconColor,
|
||||
};
|
||||
|
||||
if (activeApp === "opencode") {
|
||||
const existingKeys = Object.keys(providers);
|
||||
duplicatedProvider.providerKey = generateUniqueOpencodeKey(
|
||||
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(
|
||||
provider.id,
|
||||
existingKeys,
|
||||
);
|
||||
duplicatedProvider.addToLive = false;
|
||||
}
|
||||
|
||||
if (provider.sortIndex !== undefined) {
|
||||
|
||||
@@ -14,7 +14,10 @@ interface EditProviderDialogProps {
|
||||
open: boolean;
|
||||
provider: Provider | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (provider: Provider) => Promise<void> | void;
|
||||
onSubmit: (payload: {
|
||||
provider: Provider;
|
||||
originalId?: string;
|
||||
}) => Promise<void> | void;
|
||||
appId: AppId;
|
||||
isProxyTakeover?: boolean; // 代理接管模式下不读取 live(避免显示被接管后的代理配置)
|
||||
}
|
||||
@@ -165,9 +168,15 @@ 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,
|
||||
@@ -179,10 +188,13 @@ export function EditProviderDialog({
|
||||
...(values.meta ? { meta: values.meta } : {}),
|
||||
};
|
||||
|
||||
await onSubmit(updatedProvider);
|
||||
await onSubmit({
|
||||
provider: updatedProvider,
|
||||
originalId: provider.id,
|
||||
});
|
||||
onOpenChange(false);
|
||||
},
|
||||
[onSubmit, onOpenChange, provider],
|
||||
[appId, onSubmit, onOpenChange, provider],
|
||||
);
|
||||
|
||||
if (!provider || !initialData) {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
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 type { AppId } from "@/lib/api";
|
||||
import { providersApi, type AppId } from "@/lib/api";
|
||||
import type {
|
||||
ProviderCategory,
|
||||
ProviderMeta,
|
||||
@@ -91,6 +92,7 @@ import {
|
||||
normalizePricingSource,
|
||||
} from "./helpers/opencodeFormUtils";
|
||||
import { resolveManagedAccountId } from "@/lib/authBinding";
|
||||
import { useOpenClawLiveProviderIds } from "@/hooks/useOpenClaw";
|
||||
|
||||
type PresetEntry = {
|
||||
id: string;
|
||||
@@ -577,6 +579,15 @@ 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,
|
||||
@@ -605,6 +616,78 @@ 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);
|
||||
|
||||
@@ -641,9 +724,17 @@ export function ProviderForm({
|
||||
toast.error(t("opencode.providerKeyInvalid"));
|
||||
return;
|
||||
}
|
||||
if (isProviderKeyLockStateLoading) {
|
||||
toast.error(
|
||||
t("providerForm.providerKeyStatusLoading", {
|
||||
defaultValue: "正在加载供应商标识状态,请稍后再试",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isEditMode &&
|
||||
existingOpencodeKeys.includes(opencodeForm.opencodeProviderKey)
|
||||
!isProviderKeyLocked &&
|
||||
additiveExistingProviderKeys.includes(opencodeForm.opencodeProviderKey)
|
||||
) {
|
||||
toast.error(t("opencode.providerKeyDuplicate"));
|
||||
return;
|
||||
@@ -665,11 +756,17 @@ export function ProviderForm({
|
||||
toast.error(t("openclaw.providerKeyInvalid"));
|
||||
return;
|
||||
}
|
||||
if (isProviderKeyLockStateLoading) {
|
||||
toast.error(
|
||||
t("providerForm.providerKeyStatusLoading", {
|
||||
defaultValue: "正在加载供应商标识状态,请稍后再试",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isEditMode &&
|
||||
openclawForm.existingOpenclawKeys.includes(
|
||||
openclawForm.openclawProviderKey,
|
||||
)
|
||||
!isProviderKeyLocked &&
|
||||
additiveExistingProviderKeys.includes(openclawForm.openclawProviderKey)
|
||||
) {
|
||||
toast.error(t("openclaw.providerKeyDuplicate"));
|
||||
return;
|
||||
@@ -1253,12 +1350,14 @@ export function ProviderForm({
|
||||
)
|
||||
}
|
||||
placeholder={t("opencode.providerKeyPlaceholder")}
|
||||
disabled={isEditMode}
|
||||
disabled={
|
||||
isProviderKeyLocked || isProviderKeyLockStateLoading
|
||||
}
|
||||
className={
|
||||
(existingOpencodeKeys.includes(
|
||||
(additiveExistingProviderKeys.includes(
|
||||
opencodeForm.opencodeProviderKey,
|
||||
) &&
|
||||
!isEditMode) ||
|
||||
!isProviderKeyLocked) ||
|
||||
(opencodeForm.opencodeProviderKey.trim() !== "" &&
|
||||
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
|
||||
opencodeForm.opencodeProviderKey,
|
||||
@@ -1267,10 +1366,10 @@ export function ProviderForm({
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{existingOpencodeKeys.includes(
|
||||
{additiveExistingProviderKeys.includes(
|
||||
opencodeForm.opencodeProviderKey,
|
||||
) &&
|
||||
!isEditMode && (
|
||||
!isProviderKeyLocked && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("opencode.providerKeyDuplicate")}
|
||||
</p>
|
||||
@@ -1284,16 +1383,21 @@ export function ProviderForm({
|
||||
</p>
|
||||
)}
|
||||
{!(
|
||||
existingOpencodeKeys.includes(
|
||||
additiveExistingProviderKeys.includes(
|
||||
opencodeForm.opencodeProviderKey,
|
||||
) && !isEditMode
|
||||
) && !isProviderKeyLocked
|
||||
) &&
|
||||
(opencodeForm.opencodeProviderKey.trim() === "" ||
|
||||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
|
||||
opencodeForm.opencodeProviderKey,
|
||||
)) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("opencode.providerKeyHint")}
|
||||
{isProviderKeyLocked
|
||||
? t("opencode.providerKeyLockedHint", {
|
||||
defaultValue:
|
||||
"该供应商已添加到应用配置中,供应商标识不可修改",
|
||||
})
|
||||
: t("opencode.providerKeyHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -1312,12 +1416,14 @@ export function ProviderForm({
|
||||
)
|
||||
}
|
||||
placeholder={t("openclaw.providerKeyPlaceholder")}
|
||||
disabled={isEditMode}
|
||||
disabled={
|
||||
isProviderKeyLocked || isProviderKeyLockStateLoading
|
||||
}
|
||||
className={
|
||||
(openclawForm.existingOpenclawKeys.includes(
|
||||
(additiveExistingProviderKeys.includes(
|
||||
openclawForm.openclawProviderKey,
|
||||
) &&
|
||||
!isEditMode) ||
|
||||
!isProviderKeyLocked) ||
|
||||
(openclawForm.openclawProviderKey.trim() !== "" &&
|
||||
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
|
||||
openclawForm.openclawProviderKey,
|
||||
@@ -1326,10 +1432,10 @@ export function ProviderForm({
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{openclawForm.existingOpenclawKeys.includes(
|
||||
{additiveExistingProviderKeys.includes(
|
||||
openclawForm.openclawProviderKey,
|
||||
) &&
|
||||
!isEditMode && (
|
||||
!isProviderKeyLocked && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("openclaw.providerKeyDuplicate")}
|
||||
</p>
|
||||
@@ -1343,16 +1449,21 @@ export function ProviderForm({
|
||||
</p>
|
||||
)}
|
||||
{!(
|
||||
openclawForm.existingOpenclawKeys.includes(
|
||||
additiveExistingProviderKeys.includes(
|
||||
openclawForm.openclawProviderKey,
|
||||
) && !isEditMode
|
||||
) && !isProviderKeyLocked
|
||||
) &&
|
||||
(openclawForm.openclawProviderKey.trim() === "" ||
|
||||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
|
||||
openclawForm.openclawProviderKey,
|
||||
)) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("openclaw.providerKeyHint")}
|
||||
{isProviderKeyLocked
|
||||
? t("openclaw.providerKeyLockedHint", {
|
||||
defaultValue:
|
||||
"该供应商已添加到应用配置中,供应商标识不可修改",
|
||||
})
|
||||
: t("openclaw.providerKeyHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -65,6 +65,7 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
|
||||
provider: Omit<Provider, "id"> & {
|
||||
providerKey?: string;
|
||||
suggestedDefaults?: OpenClawSuggestedDefaults;
|
||||
addToLive?: boolean;
|
||||
},
|
||||
) => {
|
||||
await addProviderMutation.mutateAsync(provider);
|
||||
@@ -120,8 +121,8 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
|
||||
|
||||
// 更新供应商
|
||||
const updateProvider = useCallback(
|
||||
async (provider: Provider) => {
|
||||
await updateProviderMutation.mutateAsync(provider);
|
||||
async (provider: Provider, originalId?: string) => {
|
||||
await updateProviderMutation.mutateAsync({ provider, originalId });
|
||||
|
||||
// 更新托盘菜单(失败不影响主操作)
|
||||
try {
|
||||
|
||||
@@ -933,7 +933,8 @@
|
||||
"modelsRequired": "Please add at least one model",
|
||||
"providerKey": "Provider Key",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
|
||||
"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.",
|
||||
"providerKeyRequired": "Provider key is required",
|
||||
"providerKeyDuplicate": "This key is already in use",
|
||||
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
|
||||
@@ -1392,7 +1393,8 @@
|
||||
"backupCreated": "Backup created: {{path}}",
|
||||
"providerKey": "Provider Key",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
|
||||
"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.",
|
||||
"providerKeyRequired": "Provider key is required",
|
||||
"providerKeyDuplicate": "This key is already in use",
|
||||
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
|
||||
|
||||
@@ -933,7 +933,8 @@
|
||||
"modelsRequired": "モデルを少なくとも1つ追加してください",
|
||||
"providerKey": "プロバイダーキー",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。",
|
||||
"providerKeyHint": "設定ファイルの一意の識別子です。小文字、数字、ハイフンのみ使用できます。",
|
||||
"providerKeyLockedHint": "このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。",
|
||||
"providerKeyRequired": "プロバイダーキーを入力してください",
|
||||
"providerKeyDuplicate": "このキーは既に使用されています",
|
||||
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。",
|
||||
@@ -1392,7 +1393,8 @@
|
||||
"backupCreated": "バックアップを作成しました: {{path}}",
|
||||
"providerKey": "プロバイダーキー",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。",
|
||||
"providerKeyHint": "設定ファイル内のユニーク識別子。小文字、数字、ハイフンのみ使用可能。",
|
||||
"providerKeyLockedHint": "このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。",
|
||||
"providerKeyRequired": "プロバイダーキーを入力してください",
|
||||
"providerKeyDuplicate": "このキーは既に使用されています",
|
||||
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。",
|
||||
|
||||
@@ -933,7 +933,8 @@
|
||||
"modelsRequired": "请至少添加一个模型配置",
|
||||
"providerKey": "供应商标识",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
|
||||
"providerKeyHint": "配置文件中的唯一标识符,只能使用小写字母、数字和连字符",
|
||||
"providerKeyLockedHint": "该供应商已添加到应用配置中,供应商标识不可修改",
|
||||
"providerKeyRequired": "请填写供应商标识",
|
||||
"providerKeyDuplicate": "此标识已被使用,请更换",
|
||||
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
|
||||
@@ -1392,7 +1393,8 @@
|
||||
"backupCreated": "已创建备份:{{path}}",
|
||||
"providerKey": "供应商标识",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
|
||||
"providerKeyHint": "配置文件中的唯一标识符,只能使用小写字母、数字和连字符",
|
||||
"providerKeyLockedHint": "该供应商已添加到应用配置中,供应商标识不可修改",
|
||||
"providerKeyRequired": "请填写供应商标识",
|
||||
"providerKeyDuplicate": "此标识已被使用,请更换",
|
||||
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
|
||||
|
||||
@@ -34,12 +34,24 @@ export const providersApi = {
|
||||
return await invoke("get_current_provider", { app: appId });
|
||||
},
|
||||
|
||||
async add(provider: Provider, appId: AppId): Promise<boolean> {
|
||||
return await invoke("add_provider", { provider, app: appId });
|
||||
async add(
|
||||
provider: Provider,
|
||||
appId: AppId,
|
||||
addToLive?: boolean,
|
||||
): Promise<boolean> {
|
||||
return await invoke("add_provider", { provider, app: appId, addToLive });
|
||||
},
|
||||
|
||||
async update(provider: Provider, appId: AppId): Promise<boolean> {
|
||||
return await invoke("update_provider", { provider, app: appId });
|
||||
async update(
|
||||
provider: Provider,
|
||||
appId: AppId,
|
||||
originalId?: string,
|
||||
): Promise<boolean> {
|
||||
return await invoke("update_provider", {
|
||||
provider,
|
||||
app: appId,
|
||||
originalId,
|
||||
});
|
||||
},
|
||||
|
||||
async delete(id: string, appId: AppId): Promise<boolean> {
|
||||
|
||||
@@ -15,7 +15,10 @@ export const useAddProviderMutation = (appId: AppId) => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
providerInput: Omit<Provider, "id"> & { providerKey?: string },
|
||||
providerInput: Omit<Provider, "id"> & {
|
||||
providerKey?: string;
|
||||
addToLive?: boolean;
|
||||
},
|
||||
) => {
|
||||
let id: string;
|
||||
|
||||
@@ -36,7 +39,7 @@ export const useAddProviderMutation = (appId: AppId) => {
|
||||
id = generateUUID();
|
||||
}
|
||||
|
||||
const { providerKey: _providerKey, ...rest } = providerInput;
|
||||
const { providerKey: _providerKey, addToLive, ...rest } = providerInput;
|
||||
|
||||
const newProvider: Provider = {
|
||||
...rest,
|
||||
@@ -45,7 +48,7 @@ export const useAddProviderMutation = (appId: AppId) => {
|
||||
};
|
||||
delete (newProvider as any).providerKey;
|
||||
|
||||
await providersApi.add(newProvider, appId);
|
||||
await providersApi.add(newProvider, appId, addToLive);
|
||||
return newProvider;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
@@ -107,8 +110,14 @@ export const useUpdateProviderMutation = (appId: AppId) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (provider: Provider) => {
|
||||
await providersApi.update(provider, appId);
|
||||
mutationFn: async ({
|
||||
provider,
|
||||
originalId,
|
||||
}: {
|
||||
provider: Provider;
|
||||
originalId?: string;
|
||||
}) => {
|
||||
await providersApi.update(provider, appId, originalId);
|
||||
return provider;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
|
||||
@@ -169,7 +169,10 @@ describe("useProviderActions", () => {
|
||||
await result.current.updateProvider(provider);
|
||||
});
|
||||
|
||||
expect(updateProviderMutateAsync).toHaveBeenCalledWith(provider);
|
||||
expect(updateProviderMutateAsync).toHaveBeenCalledWith({
|
||||
provider,
|
||||
originalId: undefined,
|
||||
});
|
||||
expect(providersApiUpdateTrayMenuMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,13 @@ 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 { resetProviderState } from "../msw/state";
|
||||
import { providersApi } from "@/lib/api/providers";
|
||||
import {
|
||||
resetProviderState,
|
||||
setCurrentProviderId,
|
||||
setLiveProviderIds,
|
||||
setProviders,
|
||||
} from "../msw/state";
|
||||
import { emitTauriEvent } from "../msw/tauriMocks";
|
||||
|
||||
const toastSuccessMock = vi.fn();
|
||||
@@ -75,8 +81,11 @@ vi.mock("@/components/providers/EditProviderDialog", () => ({
|
||||
<button
|
||||
onClick={() =>
|
||||
onSubmit({
|
||||
...provider,
|
||||
name: `${provider.name}-edited`,
|
||||
provider: {
|
||||
...provider,
|
||||
name: `${provider.name}-edited`,
|
||||
},
|
||||
originalId: provider.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -114,6 +123,7 @@ 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>
|
||||
),
|
||||
}));
|
||||
@@ -230,4 +240,95 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
deleteProvider,
|
||||
deleteSession,
|
||||
getCurrentProviderId,
|
||||
getLiveProviderIds,
|
||||
getSessionMessages,
|
||||
getProviders,
|
||||
listProviders,
|
||||
@@ -67,6 +68,20 @@ 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);
|
||||
@@ -197,6 +212,8 @@ 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);
|
||||
|
||||
@@ -10,6 +10,7 @@ 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: {
|
||||
@@ -77,6 +78,10 @@ const createDefaultCurrent = (): CurrentProviderState => ({
|
||||
|
||||
let providers = createDefaultProviders();
|
||||
let current = createDefaultCurrent();
|
||||
let liveProviderIds: LiveProviderIdsByApp = {
|
||||
opencode: [],
|
||||
openclaw: [],
|
||||
};
|
||||
let settingsState: Settings = {
|
||||
showInTray: true,
|
||||
minimizeToTrayOnClose: true,
|
||||
@@ -184,6 +189,10 @@ const cloneProviders = (value: ProvidersByApp) =>
|
||||
export const resetProviderState = () => {
|
||||
providers = createDefaultProviders();
|
||||
current = createDefaultCurrent();
|
||||
liveProviderIds = {
|
||||
opencode: [],
|
||||
openclaw: [],
|
||||
};
|
||||
sessionsState = createDefaultSessions();
|
||||
sessionMessagesState = createDefaultSessionMessages();
|
||||
settingsState = {
|
||||
@@ -243,6 +252,17 @@ 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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user