Merge branch 'main' into fix/openclaw-serialize-panic

# Conflicts:
#	src-tauri/src/proxy/providers/claude.rs
#	src-tauri/src/services/stream_check.rs
This commit is contained in:
YoVinchen
2026-03-29 21:07:06 +08:00
53 changed files with 2816 additions and 689 deletions
+1 -1
View File
@@ -255,7 +255,7 @@ function App() {
deleteProvider,
saveUsageScript,
setAsDefaultModel,
} = useProviderActions(activeApp);
} = useProviderActions(activeApp, isProxyRunning);
const disableOmoMutation = useDisableCurrentOmo();
const handleDisableOmo = () => {
@@ -42,9 +42,9 @@ export function AddProviderDialog({
const { t } = useTranslation();
// OpenCode and OpenClaw don't support universal providers
const showUniversalTab = appId !== "opencode" && appId !== "openclaw";
const [activeTab, setActiveTab] = useState<
"app-specific" | "universal"
>("app-specific");
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
"app-specific",
);
const [universalFormOpen, setUniversalFormOpen] = useState(false);
const [selectedUniversalPreset, setSelectedUniversalPreset] =
useState<UniversalProviderPreset | null>(null);
@@ -284,9 +284,7 @@ export function AddProviderDialog({
{showUniversalTab ? (
<Tabs
value={activeTab}
onValueChange={(v) =>
setActiveTab(v as "app-specific" | "universal")
}
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
>
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="app-specific">
@@ -108,6 +108,10 @@ interface ClaudeFormFieldsProps {
// Auth Field (ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY)
apiKeyField: ClaudeApiKeyField;
onApiKeyFieldChange: (field: ClaudeApiKeyField) => void;
// Full URL mode
isFullUrl: boolean;
onFullUrlChange: (value: boolean) => void;
}
export function ClaudeFormFields({
@@ -149,6 +153,8 @@ export function ClaudeFormFields({
onApiFormatChange,
apiKeyField,
onApiKeyFieldChange,
isFullUrl,
onFullUrlChange,
}: ClaudeFormFieldsProps) {
const { t } = useTranslation();
const hasAnyAdvancedValue = !!(
@@ -160,8 +166,7 @@ export function ClaudeFormFields({
apiFormat !== "anthropic" ||
apiKeyField !== "ANTHROPIC_AUTH_TOKEN"
);
const [advancedExpanded, setAdvancedExpanded] =
useState(hasAnyAdvancedValue);
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
// 预设填充高级值后自动展开(仅从折叠→展开,不会自动折叠)
useEffect(() => {
@@ -379,6 +384,9 @@ export function ClaudeFormFields({
: t("providerForm.apiHint")
}
onManageClick={() => onEndpointModalToggle(true)}
showFullUrlToggle={true}
isFullUrl={isFullUrl}
onFullUrlChange={onFullUrlChange}
/>
)}
@@ -400,10 +408,7 @@ export function ClaudeFormFields({
{/* 高级选项(API 格式 + 认证字段 + 模型映射) */}
{shouldShowModelSelector && (
<Collapsible
open={advancedExpanded}
onOpenChange={setAdvancedExpanded}
>
<Collapsible open={advancedExpanded} onOpenChange={setAdvancedExpanded}>
<CollapsibleTrigger asChild>
<Button
type="button"
@@ -1,4 +1,10 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import JsonEditor from "@/components/JsonEditor";
import {
@@ -175,10 +181,7 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
}
} else {
toml = removeCodexTopLevelField(toml, "model_context_window");
toml = removeCodexTopLevelField(
toml,
"model_auto_compact_token_limit",
);
toml = removeCodexTopLevelField(toml, "model_auto_compact_token_limit");
}
handleLocalChange(toml);
},
@@ -22,6 +22,8 @@ interface CodexFormFieldsProps {
shouldShowSpeedTest: boolean;
codexBaseUrl: string;
onBaseUrlChange: (url: string) => void;
isFullUrl: boolean;
onFullUrlChange: (value: boolean) => void;
isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange?: (endpoints: string[]) => void;
@@ -49,6 +51,8 @@ export function CodexFormFields({
shouldShowSpeedTest,
codexBaseUrl,
onBaseUrlChange,
isFullUrl,
onFullUrlChange,
isEndpointModalOpen,
onEndpointModalToggle,
onCustomEndpointsChange,
@@ -93,6 +97,9 @@ export function CodexFormFields({
onChange={onBaseUrlChange}
placeholder={t("providerForm.codexApiEndpointPlaceholder")}
hint={t("providerForm.codexApiHint")}
showFullUrlToggle
isFullUrl={isFullUrl}
onFullUrlChange={onFullUrlChange}
onManageClick={() => onEndpointModalToggle(true)}
/>
)}
@@ -164,6 +164,11 @@ export function ProviderForm({
const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>(
() => initialData?.meta?.endpointAutoSelect ?? true,
);
const supportsFullUrl = appId === "claude" || appId === "codex";
const [localIsFullUrl, setLocalIsFullUrl] = useState<boolean>(() => {
if (!supportsFullUrl) return false;
return initialData?.meta?.isFullUrl ?? false;
});
const [testConfig, setTestConfig] = useState<ProviderTestConfig>(
() => initialData?.meta?.testConfig ?? { enabled: false },
@@ -203,6 +208,9 @@ export function ProviderForm({
setDraftCustomEndpoints([]);
}
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
setLocalIsFullUrl(
supportsFullUrl ? (initialData?.meta?.isFullUrl ?? false) : false,
);
setTestConfig(initialData?.meta?.testConfig ?? { enabled: false });
setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false });
setPricingConfig({
@@ -214,7 +222,7 @@ export function ProviderForm({
initialData?.meta?.pricingModelSource,
),
});
}, [appId, initialData]);
}, [appId, initialData, supportsFullUrl]);
const defaultValues: ProviderFormData = useMemo(
() => ({
@@ -1040,6 +1048,10 @@ export function ProviderForm({
localApiKeyField !== "ANTHROPIC_AUTH_TOKEN"
? localApiKeyField
: undefined,
isFullUrl:
supportsFullUrl && category !== "official" && localIsFullUrl
? true
: undefined,
};
await onSubmit(payload);
@@ -1279,6 +1291,7 @@ export function ProviderForm({
}
setLocalApiKeyField(preset.apiKeyField ?? "ANTHROPIC_AUTH_TOKEN");
setLocalIsFullUrl(false);
form.reset({
name: preset.nameKey ? t(preset.nameKey) : preset.name,
@@ -1511,6 +1524,8 @@ export function ProviderForm({
onApiFormatChange={handleApiFormatChange}
apiKeyField={localApiKeyField}
onApiKeyFieldChange={handleApiKeyFieldChange}
isFullUrl={localIsFullUrl}
onFullUrlChange={setLocalIsFullUrl}
/>
)}
@@ -1527,6 +1542,8 @@ export function ProviderForm({
shouldShowSpeedTest={shouldShowSpeedTest}
codexBaseUrl={codexBaseUrl}
onBaseUrlChange={handleCodexBaseUrlChange}
isFullUrl={localIsFullUrl}
onFullUrlChange={setLocalIsFullUrl}
isEndpointModalOpen={isCodexEndpointModalOpen}
onEndpointModalToggle={setIsCodexEndpointModalOpen}
onCustomEndpointsChange={
@@ -1,7 +1,8 @@
import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Zap } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Link2, Zap } from "lucide-react";
interface EndpointFieldProps {
id: string;
@@ -13,6 +14,9 @@ interface EndpointFieldProps {
showManageButton?: boolean;
onManageClick?: () => void;
manageButtonLabel?: string;
showFullUrlToggle?: boolean;
isFullUrl?: boolean;
onFullUrlChange?: (value: boolean) => void;
}
export function EndpointField({
@@ -25,18 +29,56 @@ export function EndpointField({
showManageButton = true,
onManageClick,
manageButtonLabel,
showFullUrlToggle = false,
isFullUrl = false,
onFullUrlChange,
}: EndpointFieldProps) {
const { t } = useTranslation();
const defaultManageLabel = t("providerForm.manageAndTest", {
defaultValue: "管理和测速",
});
const effectiveHint =
showFullUrlToggle && isFullUrl
? t("providerForm.fullUrlHint", {
defaultValue:
"💡 请填写完整请求 URL,并且必须开启代理后使用;代理将直接使用此 URL,不拼接路径",
})
: hint;
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel htmlFor={id}>{label}</FormLabel>
{showManageButton && onManageClick && (
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-3">
<FormLabel htmlFor={id}>{label}</FormLabel>
{showFullUrlToggle && onFullUrlChange ? (
<div className="flex items-center gap-2 rounded-full border border-border/70 bg-muted/30 px-2.5 py-1">
<Link2
className={`h-3.5 w-3.5 ${
isFullUrl ? "text-primary" : "text-muted-foreground"
}`}
/>
<span
className={`text-xs font-medium ${
isFullUrl ? "text-foreground" : "text-muted-foreground"
}`}
>
{t("providerForm.fullUrlLabel", {
defaultValue: "完整 URL",
})}
</span>
<Switch
checked={isFullUrl}
onCheckedChange={onFullUrlChange}
aria-label={t("providerForm.fullUrlLabel", {
defaultValue: "完整 URL",
})}
className="h-5 w-9"
/>
</div>
) : null}
</div>
{showManageButton && onManageClick ? (
<button
type="button"
onClick={onManageClick}
@@ -45,7 +87,7 @@ export function EndpointField({
<Zap className="h-3.5 w-3.5" />
{manageButtonLabel || defaultManageLabel}
</button>
)}
) : null}
</div>
<Input
id={id}
@@ -55,9 +97,11 @@ export function EndpointField({
placeholder={placeholder}
autoComplete="off"
/>
{hint ? (
{effectiveHint ? (
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
<p className="text-xs text-amber-600 dark:text-amber-400">{hint}</p>
<p className="text-xs text-amber-600 dark:text-amber-400">
{effectiveHint}
</p>
</div>
) : null}
</div>
+1 -2
View File
@@ -11,5 +11,4 @@ export const TEMPLATE_TYPES = {
GITHUB_COPILOT: "github_copilot",
} as const;
export type TemplateType =
(typeof TEMPLATE_TYPES)[keyof typeof TEMPLATE_TYPES];
export type TemplateType = (typeof TEMPLATE_TYPES)[keyof typeof TEMPLATE_TYPES];
+4 -4
View File
@@ -453,10 +453,10 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
websiteUrl: "https://www.kimi.com/coding/docs/",
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
npm: "@ai-sdk/anthropic",
name: "Kimi For Coding",
options: {
baseURL: "https://api.kimi.com/v1",
baseURL: "https://api.kimi.com/coding/v1",
apiKey: "",
setCacheKey: true,
},
@@ -470,8 +470,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
templateValues: {
baseURL: {
label: "Base URL",
placeholder: "https://api.kimi.com/v1",
defaultValue: "https://api.kimi.com/v1",
placeholder: "https://api.kimi.com/coding/v1",
defaultValue: "https://api.kimi.com/coding/v1",
editorValue: "",
},
apiKey: {
+53 -7
View File
@@ -23,7 +23,7 @@ import { openclawKeys } from "@/hooks/useOpenClaw";
* Hook for managing provider actions (add, update, delete, switch)
* Extracts business logic from App.tsx
*/
export function useProviderActions(activeApp: AppId) {
export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -140,6 +140,52 @@ export function useProviderActions(activeApp: AppId) {
// 切换供应商
const switchProvider = useCallback(
async (provider: Provider) => {
const isCopilotProvider =
activeApp === "claude" &&
provider.meta?.providerType === "github_copilot";
// Determine why this provider requires the proxy
let proxyRequiredReason: string | null = null;
if (!isProxyRunning && provider.category !== "official") {
if (isCopilotProvider) {
proxyRequiredReason = t("notifications.proxyReasonCopilot", {
defaultValue: "使用 GitHub Copilot 作为 Claude 供应商",
});
} else if (
provider.meta?.apiFormat === "openai_chat" &&
activeApp === "claude"
) {
proxyRequiredReason = t("notifications.proxyReasonOpenAIChat", {
defaultValue: "使用 OpenAI Chat 接口格式",
});
} else if (
provider.meta?.apiFormat === "openai_responses" &&
activeApp === "claude"
) {
proxyRequiredReason = t("notifications.proxyReasonOpenAIResponses", {
defaultValue: "使用 OpenAI Responses 接口格式",
});
} else if (
provider.meta?.isFullUrl &&
(activeApp === "claude" || activeApp === "codex")
) {
proxyRequiredReason = t("notifications.proxyReasonFullUrl", {
defaultValue: "开启了完整 URL 连接模式",
});
}
}
if (proxyRequiredReason) {
toast.warning(
t("notifications.proxyRequiredForSwitch", {
reason: proxyRequiredReason,
defaultValue:
"此供应商{{reason}},需要代理服务才能正常使用,请先启动代理",
}),
);
return;
}
try {
const result = await switchProviderMutation.mutateAsync(provider.id);
await syncClaudePlugin(provider);
@@ -159,15 +205,15 @@ export function useProviderActions(activeApp: AppId) {
if (
activeApp === "claude" &&
provider.category !== "official" &&
(provider.meta?.apiFormat === "openai_chat" ||
(isCopilotProvider ||
provider.meta?.apiFormat === "openai_chat" ||
provider.meta?.apiFormat === "openai_responses")
) {
// OpenAI format provider: show proxy hint
toast.info(
t("notifications.openAIFormatHint", {
defaultValue:
"此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用",
}),
isCopilotProvider
? t("notifications.copilotProxyHint")
: t("notifications.openAIFormatHint"),
{
duration: 5000,
closeButton: true,
@@ -193,7 +239,7 @@ export function useProviderActions(activeApp: AppId) {
// 错误提示由 mutation 处理
}
},
[switchProviderMutation, syncClaudePlugin, activeApp, t],
[switchProviderMutation, syncClaudePlugin, activeApp, isProxyRunning, t],
);
// 删除供应商
+10 -8
View File
@@ -1,4 +1,9 @@
import { useMutation, useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import {
useMutation,
useQuery,
useQueryClient,
keepPreviousData,
} from "@tanstack/react-query";
import {
skillsApi,
type SkillBackupEntry,
@@ -108,13 +113,10 @@ export function useInstallSkill() {
export function useUninstallSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
skillKey,
}: {
id: string;
skillKey: string;
}) => skillsApi.uninstallUnified(id).then((result) => ({ ...result, skillKey })),
mutationFn: ({ id, skillKey }: { id: string; skillKey: string }) =>
skillsApi
.uninstallUnified(id)
.then((result) => ({ ...result, skillKey })),
onSuccess: ({ skillKey }, _vars) => {
// 直接更新 installed 缓存,移除该 skill
queryClient.setQueryData<InstalledSkill[]>(
+10 -1
View File
@@ -174,8 +174,13 @@
"deleteFailed": "Failed to delete provider: {{error}}",
"settingsSaved": "Settings saved",
"settingsSaveFailed": "Failed to save settings: {{error}}",
"openAIChatFormatHint": "This provider uses OpenAI Chat format and requires the proxy service to be enabled",
"proxyRequiredForSwitch": "This provider {{reason}}, requires the proxy service to work properly. Start the proxy first.",
"proxyReasonCopilot": "uses GitHub Copilot as a Claude provider",
"proxyReasonOpenAIChat": "uses OpenAI Chat API format",
"proxyReasonOpenAIResponses": "uses OpenAI Responses API format",
"proxyReasonFullUrl": "has full URL connection mode enabled",
"openAIFormatHint": "This provider uses OpenAI-compatible format and requires the proxy service to be enabled",
"copilotProxyHint": "GitHub Copilot as a Claude provider always requires the local proxy; the proxy automatically selects Chat Completions or Responses based on the current model.",
"openLinkFailed": "Failed to open link",
"openclawModelsRegistered": "Models have been registered to /model list",
"openclawDefaultModelSet": "Set as default model",
@@ -743,6 +748,10 @@
"anthropicReasoningModel": "Reasoning Model (Thinking)",
"apiFormat": "API Format",
"apiFormatHint": "Select the input format for the provider's API",
"fullUrlLabel": "Full URL",
"fullUrlEnabled": "Full URL Mode",
"fullUrlDisabled": "Mark as Full URL",
"fullUrlHint": "💡 Enter the full request URL. This mode requires the proxy to be enabled, and the proxy will use the URL as-is without appending a path",
"apiFormatAnthropic": "Anthropic Messages (Native)",
"apiFormatOpenAIChat": "OpenAI Chat Completions (Requires proxy)",
"apiFormatOpenAIResponses": "OpenAI Responses API (Requires proxy)",
+10 -1
View File
@@ -174,8 +174,13 @@
"deleteFailed": "プロバイダーの削除に失敗しました: {{error}}",
"settingsSaved": "設定を保存しました",
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}",
"openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です",
"proxyRequiredForSwitch": "このプロバイダーは{{reason}}、プロキシサービスが必要です。先にプロキシを起動してください",
"proxyReasonCopilot": "GitHub Copilot を Claude プロバイダーとして使用しており",
"proxyReasonOpenAIChat": "OpenAI Chat API フォーマットを使用しており",
"proxyReasonOpenAIResponses": "OpenAI Responses API フォーマットを使用しており",
"proxyReasonFullUrl": "完全 URL 接続モードが有効になっており",
"openAIFormatHint": "このプロバイダーは OpenAI 互換フォーマットを使用しており、プロキシサービスの有効化が必要です",
"copilotProxyHint": "GitHub Copilot を Claude プロバイダーとして使用する場合、ローカルプロキシが常に必要です。プロキシは現在のモデルに応じて Chat Completions または Responses を自動的に選択します。",
"openLinkFailed": "リンクを開けませんでした",
"openclawModelsRegistered": "モデルが /model リストに登録されました",
"openclawDefaultModelSet": "デフォルトモデルに設定しました",
@@ -743,6 +748,10 @@
"anthropicReasoningModel": "推論モデル(Thinking",
"apiFormat": "API フォーマット",
"apiFormatHint": "プロバイダー API の入力フォーマットを選択",
"fullUrlLabel": "フル URL",
"fullUrlEnabled": "フル URL モード",
"fullUrlDisabled": "フル URL として設定",
"fullUrlHint": "💡 完全なリクエスト URL を入力してください。このモードはプロキシを有効にして使用する必要があり、プロキシはこの URL をそのまま使用し、パスを追加しません",
"apiFormatAnthropic": "Anthropic Messages(ネイティブ)",
"apiFormatOpenAIChat": "OpenAI Chat Completions(プロキシが必要)",
"apiFormatOpenAIResponses": "OpenAI Responses API(プロキシが必要)",
+10 -1
View File
@@ -174,8 +174,13 @@
"deleteFailed": "删除供应商失败:{{error}}",
"settingsSaved": "设置已保存",
"settingsSaveFailed": "保存设置失败:{{error}}",
"openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用",
"proxyRequiredForSwitch": "此供应商{{reason}},需要代理服务才能正常使用,请先启动代理",
"proxyReasonCopilot": "使用 GitHub Copilot 作为 Claude 供应商",
"proxyReasonOpenAIChat": "使用 OpenAI Chat 接口格式",
"proxyReasonOpenAIResponses": "使用 OpenAI Responses 接口格式",
"proxyReasonFullUrl": "开启了完整 URL 连接模式",
"openAIFormatHint": "此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用",
"copilotProxyHint": "GitHub Copilot 作为 Claude 供应商时始终需要本地代理;代理会根据当前模型自动选择 Chat Completions 或 Responses。",
"openLinkFailed": "链接打开失败",
"openclawModelsRegistered": "模型已注册到 /model 列表",
"openclawDefaultModelSet": "已设为默认模型",
@@ -743,6 +748,10 @@
"anthropicReasoningModel": "推理模型 (Thinking)",
"apiFormat": "API 格式",
"apiFormatHint": "选择供应商 API 的输入格式",
"fullUrlLabel": "完整 URL",
"fullUrlEnabled": "完整 URL 模式",
"fullUrlDisabled": "标记为完整 URL",
"fullUrlHint": "💡 请填写完整请求 URL,并且必须开启代理后使用;代理将直接使用此 URL,不拼接路径",
"apiFormatAnthropic": "Anthropic Messages (原生)",
"apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)",
"apiFormatOpenAIResponses": "OpenAI Responses API (需开启代理)",
+2
View File
@@ -163,6 +163,8 @@ export interface ProviderMeta {
authBinding?: AuthBinding;
// Claude 认证字段名
apiKeyField?: ClaudeApiKeyField;
// 是否将 base_url 视为完整 API 端点(代理直接使用此 URL,不拼接路径)
isFullUrl?: boolean;
// Prompt cache key for OpenAI-compatible endpoints (improves cache hit rate)
promptCacheKey?: string;
// 供应商类型(用于识别 Copilot 等特殊供应商)