mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 08:44:41 +08:00
Merge branch 'main' into feat/smart-url-path-detection
# Conflicts: # src/App.tsx # src/hooks/useProviderActions.ts # src/i18n/locales/en.json # src/i18n/locales/ja.json # src/i18n/locales/zh.json
This commit is contained in:
+160
-58
@@ -16,6 +16,10 @@ import {
|
||||
Download,
|
||||
FolderArchive,
|
||||
Search,
|
||||
FolderOpen,
|
||||
KeyRound,
|
||||
Shield,
|
||||
Cpu,
|
||||
} from "lucide-react";
|
||||
import type { Provider, VisibleApps } from "@/types";
|
||||
import type { EnvConflict } from "@/types/env";
|
||||
@@ -28,6 +32,7 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
||||
import { useProviderActions } from "@/hooks/useProviderActions";
|
||||
import { openclawKeys } from "@/hooks/useOpenClaw";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
@@ -56,6 +61,10 @@ import { McpIcon } from "@/components/BrandIcons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
|
||||
import { useDisableCurrentOmo } from "@/lib/query/omo";
|
||||
import WorkspaceFilesPanel from "@/components/workspace/WorkspaceFilesPanel";
|
||||
import EnvPanel from "@/components/openclaw/EnvPanel";
|
||||
import ToolsPanel from "@/components/openclaw/ToolsPanel";
|
||||
import AgentsDefaultsPanel from "@/components/openclaw/AgentsDefaultsPanel";
|
||||
|
||||
type View =
|
||||
| "providers"
|
||||
@@ -66,14 +75,24 @@ type View =
|
||||
| "mcp"
|
||||
| "agents"
|
||||
| "universal"
|
||||
| "sessions";
|
||||
| "sessions"
|
||||
| "workspace"
|
||||
| "openclawEnv"
|
||||
| "openclawTools"
|
||||
| "openclawAgents";
|
||||
|
||||
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
|
||||
const HEADER_HEIGHT = 64; // px
|
||||
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
|
||||
|
||||
const STORAGE_KEY = "cc-switch-last-app";
|
||||
const VALID_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"];
|
||||
const VALID_APPS: AppId[] = [
|
||||
"claude",
|
||||
"codex",
|
||||
"gemini",
|
||||
"opencode",
|
||||
"openclaw",
|
||||
];
|
||||
|
||||
const getInitialApp = (): AppId => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY) as AppId | null;
|
||||
@@ -94,6 +113,10 @@ const VALID_VIEWS: View[] = [
|
||||
"agents",
|
||||
"universal",
|
||||
"sessions",
|
||||
"workspace",
|
||||
"openclawEnv",
|
||||
"openclawTools",
|
||||
"openclawAgents",
|
||||
];
|
||||
|
||||
const getInitialView = (): View => {
|
||||
@@ -123,6 +146,7 @@ function App() {
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
};
|
||||
|
||||
const getFirstVisibleApp = (): AppId => {
|
||||
@@ -130,6 +154,7 @@ function App() {
|
||||
if (visibleApps.codex) return "codex";
|
||||
if (visibleApps.gemini) return "gemini";
|
||||
if (visibleApps.opencode) return "opencode";
|
||||
if (visibleApps.openclaw) return "openclaw";
|
||||
return "claude"; // fallback
|
||||
};
|
||||
|
||||
@@ -141,7 +166,11 @@ function App() {
|
||||
|
||||
// Fallback from sessions view when switching to an app without session support
|
||||
useEffect(() => {
|
||||
if (currentView === "sessions" && activeApp !== "claude" && activeApp !== "codex") {
|
||||
if (
|
||||
currentView === "sessions" &&
|
||||
activeApp !== "claude" &&
|
||||
activeApp !== "codex"
|
||||
) {
|
||||
setCurrentView("providers");
|
||||
}
|
||||
}, [activeApp, currentView]);
|
||||
@@ -192,6 +221,7 @@ function App() {
|
||||
switchProvider,
|
||||
deleteProvider,
|
||||
saveUsageScript,
|
||||
setAsDefaultModel,
|
||||
} = useProviderActions(activeApp, {
|
||||
isProxyRunning,
|
||||
isTakeoverActive: isCurrentAppTakeoverActive,
|
||||
@@ -422,10 +452,19 @@ function App() {
|
||||
const { provider, action } = confirmAction;
|
||||
|
||||
if (action === "remove") {
|
||||
// Remove from live config only (for additive mode apps like OpenCode/OpenClaw)
|
||||
// Does NOT delete from database - provider remains in the list
|
||||
await providersApi.removeFromLiveConfig(provider.id, activeApp);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["opencodeLiveProviderIds"],
|
||||
});
|
||||
// Invalidate queries to refresh the isInConfig state
|
||||
if (activeApp === "opencode") {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["opencodeLiveProviderIds"],
|
||||
});
|
||||
} else if (activeApp === "openclaw") {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: openclawKeys.liveProviderIds,
|
||||
});
|
||||
}
|
||||
toast.success(
|
||||
t("notifications.removeFromConfigSuccess", {
|
||||
defaultValue: "已从配置移除",
|
||||
@@ -585,7 +624,11 @@ function App() {
|
||||
return (
|
||||
<SkillsPage
|
||||
ref={skillsPageRef}
|
||||
initialApp={activeApp === "opencode" ? "claude" : activeApp}
|
||||
initialApp={
|
||||
activeApp === "opencode" || activeApp === "openclaw"
|
||||
? "claude"
|
||||
: activeApp
|
||||
}
|
||||
/>
|
||||
);
|
||||
case "mcp":
|
||||
@@ -608,6 +651,14 @@ function App() {
|
||||
|
||||
case "sessions":
|
||||
return <SessionManagerPage />;
|
||||
case "workspace":
|
||||
return <WorkspaceFilesPanel />;
|
||||
case "openclawEnv":
|
||||
return <EnvPanel />;
|
||||
case "openclawTools":
|
||||
return <ToolsPanel />;
|
||||
case "openclawAgents":
|
||||
return <AgentsDefaultsPanel />;
|
||||
default:
|
||||
return (
|
||||
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
@@ -639,7 +690,7 @@ function App() {
|
||||
setConfirmAction({ provider, action: "delete" })
|
||||
}
|
||||
onRemoveFromConfig={
|
||||
activeApp === "opencode"
|
||||
activeApp === "opencode" || activeApp === "openclaw"
|
||||
? (provider) =>
|
||||
setConfirmAction({ provider, action: "remove" })
|
||||
: undefined
|
||||
@@ -654,6 +705,9 @@ function App() {
|
||||
activeApp === "claude" ? handleOpenTerminal : undefined
|
||||
}
|
||||
onCreate={() => setIsAddOpen(true)}
|
||||
onSetAsDefault={
|
||||
activeApp === "openclaw" ? setAsDefaultModel : undefined
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
@@ -763,6 +817,11 @@ function App() {
|
||||
defaultValue: "统一供应商",
|
||||
})}
|
||||
{currentView === "sessions" && t("sessionManager.title")}
|
||||
{currentView === "workspace" && t("workspace.title")}
|
||||
{currentView === "openclawEnv" && t("openclaw.env.title")}
|
||||
{currentView === "openclawTools" && t("openclaw.tools.title")}
|
||||
{currentView === "openclawAgents" &&
|
||||
t("openclaw.agents.title")}
|
||||
</h1>
|
||||
</div>
|
||||
) : (
|
||||
@@ -914,7 +973,7 @@ function App() {
|
||||
)}
|
||||
{currentView === "providers" && (
|
||||
<>
|
||||
{activeApp !== "opencode" && (
|
||||
{activeApp !== "opencode" && activeApp !== "openclaw" && (
|
||||
<>
|
||||
<ProxyToggle
|
||||
activeApp={activeApp}
|
||||
@@ -944,54 +1003,97 @@ function App() {
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("skills")}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
||||
"transition-all duration-200 ease-in-out overflow-hidden",
|
||||
hasSkillsSupport
|
||||
? "opacity-100 w-8 scale-100 px-2"
|
||||
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
||||
)}
|
||||
title={t("skills.manage")}
|
||||
>
|
||||
<Wrench className="flex-shrink-0 w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("prompts")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("prompts.manage")}
|
||||
>
|
||||
<Book className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("sessions")}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
||||
"transition-all duration-200 ease-in-out overflow-hidden",
|
||||
hasSessionSupport
|
||||
? "opacity-100 w-8 scale-100 px-2"
|
||||
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
||||
)}
|
||||
title={t("sessionManager.title")}
|
||||
>
|
||||
<History className="flex-shrink-0 w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("mcp")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("mcp.title")}
|
||||
>
|
||||
<McpIcon size={16} />
|
||||
</Button>
|
||||
{activeApp === "openclaw" ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("workspace")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("workspace.manage")}
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("openclawEnv")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("openclaw.env.title")}
|
||||
>
|
||||
<KeyRound className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("openclawTools")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("openclaw.tools.title")}
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("openclawAgents")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("openclaw.agents.title")}
|
||||
>
|
||||
<Cpu className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("skills")}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
||||
"transition-all duration-200 ease-in-out overflow-hidden",
|
||||
hasSkillsSupport
|
||||
? "opacity-100 w-8 scale-100 px-2"
|
||||
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
||||
)}
|
||||
title={t("skills.manage")}
|
||||
>
|
||||
<Wrench className="flex-shrink-0 w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("prompts")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("prompts.manage")}
|
||||
>
|
||||
<Book className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("sessions")}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
||||
"transition-all duration-200 ease-in-out overflow-hidden",
|
||||
hasSessionSupport
|
||||
? "opacity-100 w-8 scale-100 px-2"
|
||||
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
||||
)}
|
||||
title={t("sessionManager.title")}
|
||||
>
|
||||
<History className="flex-shrink-0 w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("mcp")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("mcp.title")}
|
||||
>
|
||||
<McpIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -1007,7 +1109,7 @@ function App() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 min-h-0 flex flex-col animate-fade-in">
|
||||
<main className="flex-1 min-h-0 flex flex-col overflow-y-auto animate-fade-in">
|
||||
{renderContent()}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ interface AppSwitcherProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const ALL_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"];
|
||||
const ALL_APPS: AppId[] = ["claude", "codex", "gemini", "opencode", "openclaw"];
|
||||
const STORAGE_KEY = "cc-switch-last-app";
|
||||
|
||||
export function AppSwitcher({
|
||||
@@ -29,12 +29,14 @@ export function AppSwitcher({
|
||||
codex: "openai",
|
||||
gemini: "gemini",
|
||||
opencode: "opencode",
|
||||
openclaw: "openclaw",
|
||||
};
|
||||
const appDisplayName: Record<AppId, string> = {
|
||||
claude: "Claude",
|
||||
codex: "Codex",
|
||||
gemini: "Gemini",
|
||||
opencode: "OpenCode",
|
||||
openclaw: "OpenClaw",
|
||||
};
|
||||
|
||||
// Filter apps based on visibility settings (default all visible)
|
||||
|
||||
@@ -7,6 +7,7 @@ interface IconProps {
|
||||
import ClaudeSvg from "@/icons/extracted/claude.svg?url";
|
||||
import OpenAISvg from "@/icons/extracted/openai.svg?url";
|
||||
import GeminiSvg from "@/icons/extracted/gemini.svg?url";
|
||||
import OpenClawSvg from "@/icons/extracted/claw.svg?url";
|
||||
|
||||
export function ClaudeIcon({ size = 16, className = "" }: IconProps) {
|
||||
return (
|
||||
@@ -47,6 +48,19 @@ export function GeminiIcon({ size = 16, className = "" }: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenClawIcon({ size = 16, className = "" }: IconProps) {
|
||||
return (
|
||||
<img
|
||||
src={OpenClawSvg}
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
alt="OpenClaw"
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// MCP icon uses inline SVG to support currentColor for hover effects
|
||||
export function McpIcon({ size = 16, className = "" }: IconProps) {
|
||||
return (
|
||||
|
||||
@@ -66,6 +66,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
opencode: boolean;
|
||||
openclaw: boolean;
|
||||
}>(() => {
|
||||
if (initialData?.apps) {
|
||||
return { ...initialData.apps };
|
||||
@@ -75,6 +76,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
codex: defaultEnabledApps.includes("codex"),
|
||||
gemini: defaultEnabledApps.includes("gemini"),
|
||||
opencode: defaultEnabledApps.includes("opencode"),
|
||||
openclaw: defaultEnabledApps.includes("openclaw"),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -561,6 +563,22 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
{t("mcp.unifiedPanel.apps.gemini")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="enable-opencode"
|
||||
checked={enabledApps.opencode}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setEnabledApps({ ...enabledApps, opencode: checked })
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="enable-opencode"
|
||||
className="text-sm text-foreground cursor-pointer select-none"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.opencode")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
}, [serversMap]);
|
||||
|
||||
const enabledCounts = useMemo(() => {
|
||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
|
||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
|
||||
serverEntries.forEach(([_, server]) => {
|
||||
for (const app of APP_IDS) {
|
||||
if (server.apps[app]) counts[app]++;
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useOpenClawAgentsDefaults,
|
||||
useSaveOpenClawAgentsDefaults,
|
||||
} from "@/hooks/useOpenClaw";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { OpenClawAgentsDefaults } from "@/types";
|
||||
|
||||
const AgentsDefaultsPanel: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: agentsData, isLoading } = useOpenClawAgentsDefaults();
|
||||
const saveAgentsMutation = useSaveOpenClawAgentsDefaults();
|
||||
const [defaults, setDefaults] = useState<OpenClawAgentsDefaults | null>(null);
|
||||
const [primaryModel, setPrimaryModel] = useState("");
|
||||
const [fallbacks, setFallbacks] = useState("");
|
||||
|
||||
// Extra known fields from agents.defaults
|
||||
const [workspace, setWorkspace] = useState("");
|
||||
const [timeout, setTimeout_] = useState("");
|
||||
const [contextTokens, setContextTokens] = useState("");
|
||||
const [maxConcurrent, setMaxConcurrent] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
// agentsData is undefined while loading, null when config section is absent
|
||||
if (agentsData === undefined) return;
|
||||
setDefaults(agentsData);
|
||||
|
||||
if (agentsData) {
|
||||
setPrimaryModel(agentsData.model?.primary ?? "");
|
||||
setFallbacks((agentsData.model?.fallbacks ?? []).join(", "));
|
||||
|
||||
// Extract known extra fields
|
||||
setWorkspace(String(agentsData.workspace ?? ""));
|
||||
setTimeout_(String(agentsData.timeout ?? ""));
|
||||
setContextTokens(String(agentsData.contextTokens ?? ""));
|
||||
setMaxConcurrent(String(agentsData.maxConcurrent ?? ""));
|
||||
}
|
||||
}, [agentsData]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// Preserve all unknown fields from original data
|
||||
const updated: OpenClawAgentsDefaults = { ...defaults };
|
||||
|
||||
// Model configuration
|
||||
const fallbackList = fallbacks
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (primaryModel.trim()) {
|
||||
updated.model = {
|
||||
primary: primaryModel.trim(),
|
||||
...(fallbackList.length > 0 ? { fallbacks: fallbackList } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
if (workspace.trim()) updated.workspace = workspace.trim();
|
||||
else delete updated.workspace;
|
||||
|
||||
// Numeric fields: validate before saving to avoid NaN
|
||||
const parseNum = (v: string) => {
|
||||
const n = Number(v);
|
||||
return !isNaN(n) && isFinite(n) ? n : undefined;
|
||||
};
|
||||
|
||||
const timeoutNum = timeout.trim() ? parseNum(timeout) : undefined;
|
||||
if (timeoutNum !== undefined) updated.timeout = timeoutNum;
|
||||
else delete updated.timeout;
|
||||
|
||||
const ctxNum = contextTokens.trim() ? parseNum(contextTokens) : undefined;
|
||||
if (ctxNum !== undefined) updated.contextTokens = ctxNum;
|
||||
else delete updated.contextTokens;
|
||||
|
||||
const concNum = maxConcurrent.trim()
|
||||
? parseNum(maxConcurrent)
|
||||
: undefined;
|
||||
if (concNum !== undefined) updated.maxConcurrent = concNum;
|
||||
else delete updated.maxConcurrent;
|
||||
|
||||
await saveAgentsMutation.mutateAsync(updated);
|
||||
toast.success(t("openclaw.agents.saveSuccess"));
|
||||
} catch (error) {
|
||||
const detail = extractErrorMessage(error);
|
||||
toast.error(t("openclaw.agents.saveFailed"), {
|
||||
description: detail || undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 pt-4 pb-8">
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
{t("openclaw.agents.description")}
|
||||
</p>
|
||||
|
||||
{/* Model Configuration Card */}
|
||||
<div className="rounded-xl border border-border bg-card p-5 mb-4">
|
||||
<h3 className="text-sm font-medium mb-4">
|
||||
{t("openclaw.agents.modelSection")}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">
|
||||
{t("openclaw.agents.primaryModel")}
|
||||
</Label>
|
||||
<Input
|
||||
value={primaryModel}
|
||||
onChange={(e) => setPrimaryModel(e.target.value)}
|
||||
placeholder="provider/model-id"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("openclaw.agents.primaryModelHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block">
|
||||
{t("openclaw.agents.fallbackModels")}
|
||||
</Label>
|
||||
<Input
|
||||
value={fallbacks}
|
||||
onChange={(e) => setFallbacks(e.target.value)}
|
||||
placeholder="provider/model-a, provider/model-b"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("openclaw.agents.fallbackModelsHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Runtime Parameters Card */}
|
||||
<div className="rounded-xl border border-border bg-card p-5 mb-4">
|
||||
<h3 className="text-sm font-medium mb-4">
|
||||
{t("openclaw.agents.runtimeSection")}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">
|
||||
{t("openclaw.agents.workspace")}
|
||||
</Label>
|
||||
<Input
|
||||
value={workspace}
|
||||
onChange={(e) => setWorkspace(e.target.value)}
|
||||
placeholder="~/projects"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block">
|
||||
{t("openclaw.agents.timeout")}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={timeout}
|
||||
onChange={(e) => setTimeout_(e.target.value)}
|
||||
placeholder="300"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block">
|
||||
{t("openclaw.agents.contextTokens")}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={contextTokens}
|
||||
onChange={(e) => setContextTokens(e.target.value)}
|
||||
placeholder="200000"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block">
|
||||
{t("openclaw.agents.maxConcurrent")}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={maxConcurrent}
|
||||
onChange={(e) => setMaxConcurrent(e.target.value)}
|
||||
placeholder="4"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saveAgentsMutation.isPending}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
{saveAgentsMutation.isPending ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentsDefaultsPanel;
|
||||
@@ -0,0 +1,182 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2, Save, Eye, EyeOff } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useOpenClawEnv, useSaveOpenClawEnv } from "@/hooks/useOpenClaw";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { OpenClawEnvConfig } from "@/types";
|
||||
|
||||
interface EnvEntry {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
const EnvPanel: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: envData, isLoading } = useOpenClawEnv();
|
||||
const saveEnvMutation = useSaveOpenClawEnv();
|
||||
const [entries, setEntries] = useState<EnvEntry[]>([]);
|
||||
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (envData) {
|
||||
const items: EnvEntry[] = Object.entries(envData).map(([key, value]) => ({
|
||||
id: crypto.randomUUID(),
|
||||
key,
|
||||
value: String(value ?? ""),
|
||||
}));
|
||||
setEntries(items.length > 0 ? items : []);
|
||||
}
|
||||
}, [envData]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const env: OpenClawEnvConfig = {};
|
||||
const seen = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
const trimmedKey = entry.key.trim();
|
||||
if (trimmedKey) {
|
||||
if (seen.has(trimmedKey)) {
|
||||
toast.error(t("openclaw.env.duplicateKey", { key: trimmedKey }));
|
||||
return;
|
||||
}
|
||||
seen.add(trimmedKey);
|
||||
env[trimmedKey] = entry.value;
|
||||
}
|
||||
}
|
||||
await saveEnvMutation.mutateAsync(env);
|
||||
toast.success(t("openclaw.env.saveSuccess"));
|
||||
} catch (error) {
|
||||
const detail = extractErrorMessage(error);
|
||||
toast.error(t("openclaw.env.saveFailed"), {
|
||||
description: detail || undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addEntry = () => {
|
||||
setEntries((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), key: "", value: "", isNew: true },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeEntry = (index: number) => {
|
||||
setEntries((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateEntry = (index: number, field: "key" | "value", val: string) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, [field]: val } : entry,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleVisibility = (key: string) => {
|
||||
setVisibleKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isApiKey = (key: string) => /key|token|secret|password/i.test(key);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 pt-4 pb-8">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t("openclaw.env.description")}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{entries.map((entry, index) => {
|
||||
const sensitive = isApiKey(entry.key);
|
||||
const visibilityId = entry.key || `__new_${index}`;
|
||||
const visible = visibleKeys.has(visibilityId);
|
||||
|
||||
return (
|
||||
<div key={entry.id} className="flex items-center gap-2">
|
||||
<div className="w-[200px] flex-shrink-0">
|
||||
<Input
|
||||
value={entry.key}
|
||||
onChange={(e) => updateEntry(index, "key", e.target.value)}
|
||||
placeholder={t("openclaw.env.keyPlaceholder")}
|
||||
className="font-mono text-xs"
|
||||
autoFocus={entry.isNew}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center gap-1">
|
||||
<Input
|
||||
type={sensitive && !visible ? "password" : "text"}
|
||||
value={entry.value}
|
||||
onChange={(e) => updateEntry(index, "value", e.target.value)}
|
||||
placeholder={t("openclaw.env.valuePlaceholder")}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{sensitive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="flex-shrink-0 h-9 w-9 text-muted-foreground"
|
||||
onClick={() => toggleVisibility(visibilityId)}
|
||||
>
|
||||
{visible ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="flex-shrink-0 h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeEntry(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<Button variant="outline" size="sm" onClick={addEntry}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t("openclaw.env.add")}
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saveEnvMutation.isPending}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
{saveEnvMutation.isPending ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvPanel;
|
||||
@@ -0,0 +1,221 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useOpenClawTools, useSaveOpenClawTools } from "@/hooks/useOpenClaw";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { OpenClawToolsConfig } from "@/types";
|
||||
|
||||
interface ListItem {
|
||||
id: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const PROFILE_OPTIONS = ["default", "strict", "permissive", "custom"];
|
||||
|
||||
const ToolsPanel: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: toolsData, isLoading } = useOpenClawTools();
|
||||
const saveToolsMutation = useSaveOpenClawTools();
|
||||
const [config, setConfig] = useState<OpenClawToolsConfig>({});
|
||||
const [allowList, setAllowList] = useState<ListItem[]>([]);
|
||||
const [denyList, setDenyList] = useState<ListItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (toolsData) {
|
||||
setConfig(toolsData);
|
||||
setAllowList(
|
||||
(toolsData.allow ?? []).map((v) => ({
|
||||
id: crypto.randomUUID(),
|
||||
value: v,
|
||||
})),
|
||||
);
|
||||
setDenyList(
|
||||
(toolsData.deny ?? []).map((v) => ({
|
||||
id: crypto.randomUUID(),
|
||||
value: v,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}, [toolsData]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const { profile, allow, deny, ...other } = config;
|
||||
const newConfig: OpenClawToolsConfig = {
|
||||
...other,
|
||||
profile: config.profile,
|
||||
allow: allowList.map((item) => item.value).filter((s) => s.trim()),
|
||||
deny: denyList.map((item) => item.value).filter((s) => s.trim()),
|
||||
};
|
||||
await saveToolsMutation.mutateAsync(newConfig);
|
||||
toast.success(t("openclaw.tools.saveSuccess"));
|
||||
} catch (error) {
|
||||
const detail = extractErrorMessage(error);
|
||||
toast.error(t("openclaw.tools.saveFailed"), {
|
||||
description: detail || undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updateListItem = (
|
||||
setList: React.Dispatch<React.SetStateAction<ListItem[]>>,
|
||||
index: number,
|
||||
value: string,
|
||||
) => {
|
||||
setList((prev) =>
|
||||
prev.map((item, i) => (i === index ? { ...item, value } : item)),
|
||||
);
|
||||
};
|
||||
|
||||
const removeListItem = (
|
||||
setList: React.Dispatch<React.SetStateAction<ListItem[]>>,
|
||||
index: number,
|
||||
) => {
|
||||
setList((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 pt-4 pb-8">
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
{t("openclaw.tools.description")}
|
||||
</p>
|
||||
|
||||
{/* Profile selector */}
|
||||
<div className="mb-6">
|
||||
<Label className="mb-2 block">{t("openclaw.tools.profile")}</Label>
|
||||
<Select
|
||||
value={config.profile ?? "default"}
|
||||
onValueChange={(val) =>
|
||||
setConfig((prev) => ({ ...prev, profile: val }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROFILE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{t(`openclaw.tools.profiles.${opt}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Allow list */}
|
||||
<div className="mb-6">
|
||||
<Label className="mb-2 block">{t("openclaw.tools.allowList")}</Label>
|
||||
<div className="space-y-2">
|
||||
{allowList.map((item, index) => (
|
||||
<div key={item.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={item.value}
|
||||
onChange={(e) =>
|
||||
updateListItem(setAllowList, index, e.target.value)
|
||||
}
|
||||
placeholder={t("openclaw.tools.patternPlaceholder")}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="flex-shrink-0 h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeListItem(setAllowList, index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setAllowList((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), value: "" },
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t("openclaw.tools.addAllow")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deny list */}
|
||||
<div className="mb-6">
|
||||
<Label className="mb-2 block">{t("openclaw.tools.denyList")}</Label>
|
||||
<div className="space-y-2">
|
||||
{denyList.map((item, index) => (
|
||||
<div key={item.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={item.value}
|
||||
onChange={(e) =>
|
||||
updateListItem(setDenyList, index, e.target.value)
|
||||
}
|
||||
placeholder={t("openclaw.tools.patternPlaceholder")}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="flex-shrink-0 h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeListItem(setDenyList, index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setDenyList((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), value: "" },
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t("openclaw.tools.addDeny")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saveToolsMutation.isPending}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
{saveToolsMutation.isPending ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolsPanel;
|
||||
@@ -30,13 +30,13 @@ const PromptFormModal: React.FC<PromptFormModalProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const appName = t(`apps.${appId}`);
|
||||
const filenameMap: Record<AppId, string> = {
|
||||
const filenameMap: Record<Exclude<AppId, "openclaw">, string> = {
|
||||
claude: "CLAUDE.md",
|
||||
codex: "AGENTS.md",
|
||||
gemini: "GEMINI.md",
|
||||
opencode: "AGENTS.md",
|
||||
};
|
||||
const filename = filenameMap[appId];
|
||||
const filename = filenameMap[appId as Exclude<AppId, "openclaw">];
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
@@ -29,6 +29,7 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
||||
codex: "AGENTS.md",
|
||||
gemini: "GEMINI.md",
|
||||
opencode: "AGENTS.md",
|
||||
openclaw: "AGENTS.md",
|
||||
};
|
||||
const filename = filenameMap[appId];
|
||||
const [name, setName] = useState("");
|
||||
|
||||
@@ -17,6 +17,7 @@ import { UniversalProviderPanel } from "@/components/universal";
|
||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
||||
import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets";
|
||||
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
||||
|
||||
interface AddProviderDialogProps {
|
||||
@@ -24,7 +25,10 @@ interface AddProviderDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
appId: AppId;
|
||||
onSubmit: (
|
||||
provider: Omit<Provider, "id"> & { providerKey?: string },
|
||||
provider: Omit<Provider, "id"> & {
|
||||
providerKey?: string;
|
||||
suggestedDefaults?: OpenClawSuggestedDefaults;
|
||||
},
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
@@ -35,7 +39,8 @@ export function AddProviderDialog({
|
||||
onSubmit,
|
||||
}: AddProviderDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const showUniversalTab = appId !== "opencode";
|
||||
// OpenCode and OpenClaw don't support universal providers
|
||||
const showUniversalTab = appId !== "opencode" && appId !== "openclaw";
|
||||
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
|
||||
"app-specific",
|
||||
);
|
||||
@@ -82,7 +87,11 @@ export function AddProviderDialog({
|
||||
unknown
|
||||
>;
|
||||
|
||||
const providerData: Omit<Provider, "id"> & { providerKey?: string } = {
|
||||
// 构造基础提交数据
|
||||
const providerData: Omit<Provider, "id"> & {
|
||||
providerKey?: string;
|
||||
suggestedDefaults?: OpenClawSuggestedDefaults;
|
||||
} = {
|
||||
name: values.name.trim(),
|
||||
notes: values.notes?.trim() || undefined,
|
||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||
@@ -93,7 +102,11 @@ export function AddProviderDialog({
|
||||
...(values.meta ? { meta: values.meta } : {}),
|
||||
};
|
||||
|
||||
if (appId === "opencode" && values.providerKey) {
|
||||
// OpenCode/OpenClaw: pass providerKey for ID generation
|
||||
if (
|
||||
(appId === "opencode" || appId === "openclaw") &&
|
||||
values.providerKey
|
||||
) {
|
||||
providerData.providerKey = values.providerKey;
|
||||
}
|
||||
|
||||
@@ -185,6 +198,11 @@ export function AddProviderDialog({
|
||||
if (options?.baseURL) {
|
||||
addUrl(options.baseURL);
|
||||
}
|
||||
} else if (appId === "openclaw") {
|
||||
// OpenClaw uses baseUrl directly
|
||||
if (parsedConfig.baseUrl) {
|
||||
addUrl(parsedConfig.baseUrl as string);
|
||||
}
|
||||
}
|
||||
|
||||
const urls = Array.from(urlSet);
|
||||
@@ -206,6 +224,11 @@ export function AddProviderDialog({
|
||||
}
|
||||
}
|
||||
|
||||
// OpenClaw: pass suggestedDefaults for model registration
|
||||
if (appId === "openclaw" && values.suggestedDefaults) {
|
||||
providerData.suggestedDefaults = values.suggestedDefaults;
|
||||
}
|
||||
|
||||
await onSubmit(providerData);
|
||||
onOpenChange(false);
|
||||
},
|
||||
@@ -286,6 +309,7 @@ export function AddProviderDialog({
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
// OpenCode/OpenClaw: directly show form without tabs
|
||||
<ProviderForm
|
||||
appId={appId}
|
||||
submitLabel={t("common.add")}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Terminal,
|
||||
TestTube2,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -36,6 +37,9 @@ interface ProviderActionsProps {
|
||||
isAutoFailoverEnabled?: boolean;
|
||||
isInFailoverQueue?: boolean;
|
||||
onToggleFailover?: (enabled: boolean) => void;
|
||||
// OpenClaw: default model
|
||||
isDefaultModel?: boolean;
|
||||
onSetAsDefault?: () => void;
|
||||
}
|
||||
|
||||
export function ProviderActions({
|
||||
@@ -58,14 +62,20 @@ export function ProviderActions({
|
||||
isAutoFailoverEnabled = false,
|
||||
isInFailoverQueue = false,
|
||||
onToggleFailover,
|
||||
// OpenClaw: default model
|
||||
isDefaultModel = false,
|
||||
onSetAsDefault,
|
||||
}: ProviderActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const iconButtonClass = "h-8 w-8 p-1";
|
||||
|
||||
const isOpenCodeMode = appId === "opencode" && !isOmo;
|
||||
// 累加模式应用(OpenCode 非 OMO 和 OpenClaw)
|
||||
const isAdditiveMode =
|
||||
(appId === "opencode" && !isOmo) || appId === "openclaw";
|
||||
|
||||
// 故障转移模式下的按钮逻辑(累加模式和 OMO 应用不支持故障转移)
|
||||
const isFailoverMode =
|
||||
!isOpenCodeMode && !isOmo && isAutoFailoverEnabled && onToggleFailover;
|
||||
!isAdditiveMode && !isOmo && isAutoFailoverEnabled && onToggleFailover;
|
||||
|
||||
const handleMainButtonClick = () => {
|
||||
if (isOmo) {
|
||||
@@ -74,7 +84,8 @@ export function ProviderActions({
|
||||
} else {
|
||||
onSwitch();
|
||||
}
|
||||
} else if (isOpenCodeMode) {
|
||||
} else if (isAdditiveMode) {
|
||||
// 累加模式:切换配置状态(添加/移除)
|
||||
if (isInConfig) {
|
||||
if (onRemoveFromConfig) {
|
||||
onRemoveFromConfig();
|
||||
@@ -112,13 +123,16 @@ export function ProviderActions({
|
||||
};
|
||||
}
|
||||
|
||||
if (isOpenCodeMode) {
|
||||
// 累加模式(OpenCode 非 OMO / OpenClaw)
|
||||
if (isAdditiveMode) {
|
||||
if (isInConfig) {
|
||||
return {
|
||||
disabled: false,
|
||||
disabled: isDefaultModel === true,
|
||||
variant: "secondary" as const,
|
||||
className:
|
||||
className: cn(
|
||||
"bg-orange-100 text-orange-600 hover:bg-orange-200 dark:bg-orange-900/50 dark:text-orange-400 dark:hover:bg-orange-900/70",
|
||||
isDefaultModel && "opacity-40 cursor-not-allowed",
|
||||
),
|
||||
icon: <Minus className="h-4 w-4" />,
|
||||
text: t("provider.removeFromConfig", { defaultValue: "移除" }),
|
||||
};
|
||||
@@ -180,12 +194,32 @@ export function ProviderActions({
|
||||
|
||||
const canDelete = isOmo
|
||||
? !(isLastOmo && isCurrent)
|
||||
: isOpenCodeMode
|
||||
: isAdditiveMode
|
||||
? true
|
||||
: !isCurrent;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{appId === "openclaw" && isInConfig && onSetAsDefault && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isDefaultModel ? "secondary" : "default"}
|
||||
onClick={isDefaultModel ? undefined : onSetAsDefault}
|
||||
disabled={isDefaultModel}
|
||||
className={cn(
|
||||
"w-[4.5rem] px-2.5",
|
||||
isDefaultModel
|
||||
? "bg-gray-200 text-muted-foreground dark:bg-gray-700 opacity-60 cursor-not-allowed"
|
||||
: "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
||||
)}
|
||||
>
|
||||
<Zap className="h-4 w-4" />
|
||||
{isDefaultModel
|
||||
? t("provider.isDefault", { defaultValue: "默认" })
|
||||
: t("provider.setAsDefault", { defaultValue: "启用" })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant={buttonState.variant}
|
||||
|
||||
@@ -48,6 +48,9 @@ interface ProviderCardProps {
|
||||
isInFailoverQueue?: boolean; // 是否在故障转移队列中
|
||||
onToggleFailover?: (enabled: boolean) => void; // 切换故障转移队列
|
||||
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
|
||||
// OpenClaw: default model
|
||||
isDefaultModel?: boolean;
|
||||
onSetAsDefault?: () => void;
|
||||
}
|
||||
|
||||
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
||||
@@ -108,6 +111,9 @@ export function ProviderCard({
|
||||
isInFailoverQueue = false,
|
||||
onToggleFailover,
|
||||
activeProviderId,
|
||||
// OpenClaw: default model
|
||||
isDefaultModel,
|
||||
onSetAsDefault,
|
||||
}: ProviderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -133,7 +139,10 @@ export function ProviderCard({
|
||||
|
||||
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
||||
|
||||
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
|
||||
// 获取用量数据以判断是否有多套餐
|
||||
// 累加模式应用(OpenCode/OpenClaw):使用 isInConfig 代替 isCurrent
|
||||
const shouldAutoQuery =
|
||||
appId === "opencode" || appId === "openclaw" ? isInConfig : isCurrent;
|
||||
const autoQueryInterval = shouldAutoQuery
|
||||
? provider.meta?.usage_script?.autoQueryInterval || 0
|
||||
: 0;
|
||||
@@ -176,9 +185,14 @@ export function ProviderCard({
|
||||
onOpenWebsite(displayUrl);
|
||||
};
|
||||
|
||||
// 判断是否是"当前使用中"的供应商
|
||||
// - OMO 供应商:使用 isCurrent
|
||||
// - 累加模式应用(OpenCode 非 OMO / OpenClaw):不存在"当前"概念,始终返回 false
|
||||
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
||||
// - 普通模式:isCurrent
|
||||
const isActiveProvider = isOmo
|
||||
? isCurrent
|
||||
: appId === "opencode"
|
||||
: appId === "opencode" || appId === "openclaw"
|
||||
? false
|
||||
: isAutoFailoverEnabled
|
||||
? activeProviderId === provider.id
|
||||
@@ -378,6 +392,9 @@ export function ProviderCard({
|
||||
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||
isInFailoverQueue={isInFailoverQueue}
|
||||
onToggleFailover={onToggleFailover}
|
||||
// OpenClaw: default model
|
||||
isDefaultModel={isDefaultModel}
|
||||
onSetAsDefault={onSetAsDefault}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,11 @@ import type { Provider } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { providersApi } from "@/lib/api/providers";
|
||||
import { useDragSort } from "@/hooks/useDragSort";
|
||||
import {
|
||||
useOpenClawLiveProviderIds,
|
||||
useOpenClawDefaultModel,
|
||||
} from "@/hooks/useOpenClaw";
|
||||
// import { useStreamCheck } from "@/hooks/useStreamCheck"; // 测试功能已隐藏
|
||||
import { ProviderCard } from "@/components/providers/ProviderCard";
|
||||
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
||||
import {
|
||||
@@ -51,6 +56,7 @@ interface ProviderListProps {
|
||||
isProxyRunning?: boolean; // 代理服务运行状态
|
||||
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管)
|
||||
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
|
||||
onSetAsDefault?: (provider: Provider) => void; // OpenClaw: set as default model
|
||||
}
|
||||
|
||||
export function ProviderList({
|
||||
@@ -71,6 +77,7 @@ export function ProviderList({
|
||||
isProxyRunning = false,
|
||||
isProxyTakeover = false,
|
||||
activeProviderId,
|
||||
onSetAsDefault,
|
||||
}: ProviderListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
|
||||
@@ -84,14 +91,39 @@ export function ProviderList({
|
||||
enabled: appId === "opencode",
|
||||
});
|
||||
|
||||
const isProviderInConfig = useCallback(
|
||||
(providerId: string): boolean => {
|
||||
if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true
|
||||
return opencodeLiveIds?.includes(providerId) ?? false;
|
||||
},
|
||||
[appId, opencodeLiveIds],
|
||||
// OpenClaw: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig
|
||||
const { data: openclawLiveIds } = useOpenClawLiveProviderIds(
|
||||
appId === "openclaw",
|
||||
);
|
||||
|
||||
// 判断供应商是否已添加到配置(累加模式应用:OpenCode/OpenClaw)
|
||||
const isProviderInConfig = useCallback(
|
||||
(providerId: string): boolean => {
|
||||
if (appId === "opencode") {
|
||||
return opencodeLiveIds?.includes(providerId) ?? false;
|
||||
}
|
||||
if (appId === "openclaw") {
|
||||
return openclawLiveIds?.includes(providerId) ?? false;
|
||||
}
|
||||
return true; // 其他应用始终返回 true
|
||||
},
|
||||
[appId, opencodeLiveIds, openclawLiveIds],
|
||||
);
|
||||
|
||||
// OpenClaw: query default model to determine which provider is default
|
||||
const { data: openclawDefaultModel } = useOpenClawDefaultModel(
|
||||
appId === "openclaw",
|
||||
);
|
||||
|
||||
const isProviderDefaultModel = useCallback(
|
||||
(providerId: string): boolean => {
|
||||
if (appId !== "openclaw" || !openclawDefaultModel?.primary) return false;
|
||||
return openclawDefaultModel.primary.startsWith(providerId + "/");
|
||||
},
|
||||
[appId, openclawDefaultModel],
|
||||
);
|
||||
|
||||
// 故障转移相关
|
||||
const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId);
|
||||
const { data: failoverQueue } = useFailoverQueue(appId);
|
||||
const addToQueue = useAddToFailoverQueue();
|
||||
@@ -240,6 +272,11 @@ export function ProviderList({
|
||||
handleToggleFailover(provider.id, enabled)
|
||||
}
|
||||
activeProviderId={activeProviderId}
|
||||
// OpenClaw: default model
|
||||
isDefaultModel={isProviderDefaultModel(provider.id)}
|
||||
onSetAsDefault={
|
||||
onSetAsDefault ? () => onSetAsDefault(provider) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -352,6 +389,9 @@ interface SortableProviderCardProps {
|
||||
isInFailoverQueue: boolean;
|
||||
onToggleFailover: (enabled: boolean) => void;
|
||||
activeProviderId?: string;
|
||||
// OpenClaw: default model
|
||||
isDefaultModel?: boolean;
|
||||
onSetAsDefault?: () => void;
|
||||
}
|
||||
|
||||
function SortableProviderCard({
|
||||
@@ -379,6 +419,8 @@ function SortableProviderCard({
|
||||
isInFailoverQueue,
|
||||
onToggleFailover,
|
||||
activeProviderId,
|
||||
isDefaultModel,
|
||||
onSetAsDefault,
|
||||
}: SortableProviderCardProps) {
|
||||
const {
|
||||
setNodeRef,
|
||||
@@ -428,6 +470,9 @@ function SortableProviderCard({
|
||||
isInFailoverQueue={isInFailoverQueue}
|
||||
onToggleFailover={onToggleFailover}
|
||||
activeProviderId={activeProviderId}
|
||||
// OpenClaw: default model
|
||||
isDefaultModel={isDefaultModel}
|
||||
onSetAsDefault={onSetAsDefault}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ const ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = {
|
||||
claude: 8,
|
||||
gemini: 8,
|
||||
opencode: 8,
|
||||
openclaw: 8,
|
||||
};
|
||||
|
||||
interface TestResult {
|
||||
|
||||
@@ -12,6 +12,19 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
@@ -21,6 +34,10 @@ import {
|
||||
Settings,
|
||||
FolderInput,
|
||||
Loader2,
|
||||
HelpCircle,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
@@ -43,6 +60,13 @@ const ADVANCED_PLACEHOLDER = `{
|
||||
interface OmoFormFieldsProps {
|
||||
modelOptions: Array<{ value: string; label: string }>;
|
||||
modelVariantsMap?: Record<string, string[]>;
|
||||
presetMetaMap?: Record<
|
||||
string,
|
||||
{
|
||||
options?: Record<string, unknown>;
|
||||
limit?: { context?: number; output?: number };
|
||||
}
|
||||
>;
|
||||
agents: Record<string, Record<string, unknown>>;
|
||||
onAgentsChange: (agents: Record<string, Record<string, unknown>>) => void;
|
||||
categories: Record<string, Record<string, unknown>>;
|
||||
@@ -53,19 +77,149 @@ interface OmoFormFieldsProps {
|
||||
onOtherFieldsStrChange: (value: string) => void;
|
||||
}
|
||||
|
||||
type CustomModelItem = { key: string; model: string };
|
||||
export type CustomModelItem = {
|
||||
key: string;
|
||||
model: string;
|
||||
sourceKey?: string;
|
||||
};
|
||||
type BuiltinModelDef = Pick<
|
||||
OmoAgentDef | OmoCategoryDef,
|
||||
"key" | "display" | "descZh" | "descEn" | "recommended"
|
||||
"key" | "display" | "descKey" | "recommended" | "tooltipKey"
|
||||
>;
|
||||
type ModelOption = { value: string; label: string };
|
||||
|
||||
function DeferredKeyInput({
|
||||
value,
|
||||
onCommit,
|
||||
placeholder,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
onCommit: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (draft !== value) {
|
||||
onCommit(draft);
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const BUILTIN_AGENT_KEYS = new Set(OMO_BUILTIN_AGENTS.map((a) => a.key));
|
||||
const BUILTIN_CATEGORY_KEYS = new Set(OMO_BUILTIN_CATEGORIES.map((c) => c.key));
|
||||
const EMPTY_MODEL_VALUE = "__cc_switch_omo_model_empty__";
|
||||
const UNAVAILABLE_MODEL_VALUE = "__cc_switch_omo_model_unavailable__";
|
||||
const EMPTY_VARIANT_VALUE = "__cc_switch_omo_variant_empty__";
|
||||
const UNAVAILABLE_VARIANT_VALUE = "__cc_switch_omo_variant_unavailable__";
|
||||
|
||||
function ModelCombobox({
|
||||
value,
|
||||
options,
|
||||
recommended,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
options: ModelOption[];
|
||||
recommended?: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedLabel = options.find((o) => o.value === value)?.label;
|
||||
|
||||
const selectModelText = t("omo.selectModel", {
|
||||
defaultValue: "Select configured model",
|
||||
});
|
||||
const placeholderText = recommended
|
||||
? `${selectModelText} (${t("omo.recommendedHint", { model: recommended, defaultValue: "Recommended: {{model}}" })})`
|
||||
: selectModelText;
|
||||
|
||||
return (
|
||||
<Popover modal open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="flex flex-1 h-8 items-center justify-between whitespace-nowrap rounded-md border border-border-default bg-background px-3 py-1 text-sm shadow-sm ring-offset-background focus:outline-none focus-visible:outline-none focus:border-border-default focus-visible:border-border-default focus:ring-0 focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span className={cn("truncate", !value && "text-muted-foreground")}>
|
||||
{selectedLabel || placeholderText}
|
||||
</span>
|
||||
<span className="flex items-center shrink-0 ml-1 gap-0.5">
|
||||
{value && (
|
||||
<X
|
||||
className="h-3.5 w-3.5 opacity-50 hover:opacity-100 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange("");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ChevronsUpDown className="h-3.5 w-3.5 opacity-50" />
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
className="z-[1000] w-[var(--radix-popover-trigger-width)] p-0 border-border-default"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("omo.searchModel", {
|
||||
defaultValue: "Search model...",
|
||||
})}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("omo.noEnabledModels", {
|
||||
defaultValue: "No configured models",
|
||||
})}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
keywords={[option.label]}
|
||||
onSelect={() => {
|
||||
onChange(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === option.value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function getAdvancedStr(config: Record<string, unknown> | undefined): string {
|
||||
if (!config) return "";
|
||||
@@ -86,24 +240,58 @@ function collectCustomModels(
|
||||
customs.push({
|
||||
key: k,
|
||||
model: ((v as Record<string, unknown>).model as string) || "",
|
||||
sourceKey: k,
|
||||
});
|
||||
}
|
||||
}
|
||||
return customs;
|
||||
}
|
||||
|
||||
function mergeCustomModelsIntoStore(
|
||||
export function mergeCustomModelsIntoStore(
|
||||
store: Record<string, Record<string, unknown>>,
|
||||
builtinKeys: Set<string>,
|
||||
customs: CustomModelItem[],
|
||||
modelVariantsMap: Record<string, string[]>,
|
||||
): Record<string, Record<string, unknown>> {
|
||||
const updated = { ...store };
|
||||
for (const key of Object.keys(updated)) {
|
||||
if (!builtinKeys.has(key)) delete updated[key];
|
||||
const updated: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(store)) {
|
||||
if (builtinKeys.has(key)) {
|
||||
updated[key] = { ...value };
|
||||
}
|
||||
}
|
||||
|
||||
for (const custom of customs) {
|
||||
if (custom.key.trim()) {
|
||||
updated[custom.key] = { ...updated[custom.key], model: custom.model };
|
||||
const targetKey = custom.key.trim();
|
||||
if (!targetKey) continue;
|
||||
|
||||
const sourceKey = (custom.sourceKey || targetKey).trim();
|
||||
const sourceEntry = store[sourceKey] ?? store[targetKey];
|
||||
const nextEntry = {
|
||||
...(updated[targetKey] || {}),
|
||||
...(sourceEntry || {}),
|
||||
};
|
||||
|
||||
if (custom.model.trim()) {
|
||||
nextEntry.model = custom.model;
|
||||
const currentVariant =
|
||||
typeof nextEntry.variant === "string" ? nextEntry.variant : "";
|
||||
if (currentVariant) {
|
||||
const validVariants = modelVariantsMap[custom.model] || [];
|
||||
if (!validVariants.includes(currentVariant)) {
|
||||
delete nextEntry.variant;
|
||||
}
|
||||
}
|
||||
updated[targetKey] = nextEntry;
|
||||
continue;
|
||||
}
|
||||
|
||||
delete nextEntry.model;
|
||||
delete nextEntry.variant;
|
||||
if (Object.keys(nextEntry).length > 0) {
|
||||
updated[targetKey] = nextEntry;
|
||||
} else {
|
||||
delete updated[targetKey];
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
@@ -112,6 +300,7 @@ function mergeCustomModelsIntoStore(
|
||||
export function OmoFormFields({
|
||||
modelOptions,
|
||||
modelVariantsMap = {},
|
||||
presetMetaMap: _presetMetaMap = {},
|
||||
agents,
|
||||
onAgentsChange,
|
||||
categories,
|
||||
@@ -119,8 +308,7 @@ export function OmoFormFields({
|
||||
otherFieldsStr,
|
||||
onOtherFieldsStrChange,
|
||||
}: OmoFormFieldsProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isZh = i18n.language?.startsWith("zh");
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [mainAgentsOpen, setMainAgentsOpen] = useState(true);
|
||||
const [subAgentsOpen, setSubAgentsOpen] = useState(true);
|
||||
@@ -159,19 +347,29 @@ export function OmoFormFields({
|
||||
const syncCustomAgents = useCallback(
|
||||
(customs: CustomModelItem[]) => {
|
||||
onAgentsChange(
|
||||
mergeCustomModelsIntoStore(agents, BUILTIN_AGENT_KEYS, customs),
|
||||
mergeCustomModelsIntoStore(
|
||||
agents,
|
||||
BUILTIN_AGENT_KEYS,
|
||||
customs,
|
||||
modelVariantsMap,
|
||||
),
|
||||
);
|
||||
},
|
||||
[agents, onAgentsChange],
|
||||
[agents, onAgentsChange, modelVariantsMap],
|
||||
);
|
||||
|
||||
const syncCustomCategories = useCallback(
|
||||
(customs: CustomModelItem[]) => {
|
||||
onCategoriesChange(
|
||||
mergeCustomModelsIntoStore(categories, BUILTIN_CATEGORY_KEYS, customs),
|
||||
mergeCustomModelsIntoStore(
|
||||
categories,
|
||||
BUILTIN_CATEGORY_KEYS,
|
||||
customs,
|
||||
modelVariantsMap,
|
||||
),
|
||||
);
|
||||
},
|
||||
[categories, onCategoriesChange],
|
||||
[categories, onCategoriesChange, modelVariantsMap],
|
||||
);
|
||||
|
||||
const buildEffectiveModelOptions = useCallback(
|
||||
@@ -212,43 +410,16 @@ export function OmoFormFields({
|
||||
const renderModelSelect = (
|
||||
currentModel: string,
|
||||
onChange: (value: string) => void,
|
||||
placeholder?: string,
|
||||
recommended?: string,
|
||||
) => {
|
||||
const options = buildEffectiveModelOptions(currentModel);
|
||||
return (
|
||||
<Select
|
||||
value={currentModel || EMPTY_MODEL_VALUE}
|
||||
onValueChange={(value) =>
|
||||
onChange(value === EMPTY_MODEL_VALUE ? "" : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="flex-1 h-8 text-sm">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
placeholder ||
|
||||
t("omo.selectEnabledModel", {
|
||||
defaultValue: "Select enabled model",
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
<SelectItem value={EMPTY_MODEL_VALUE}>
|
||||
{t("omo.clearWrapped", { defaultValue: "(Clear)" })}
|
||||
</SelectItem>
|
||||
{options.length === 0 ? (
|
||||
<SelectItem value={UNAVAILABLE_MODEL_VALUE} disabled>
|
||||
{t("omo.noEnabledModels", { defaultValue: "No enabled models" })}
|
||||
</SelectItem>
|
||||
) : (
|
||||
options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ModelCombobox
|
||||
value={currentModel}
|
||||
options={options}
|
||||
recommended={recommended}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -268,11 +439,21 @@ export function OmoFormFields({
|
||||
currentVariant: string,
|
||||
onChange: (value: string) => void,
|
||||
) => {
|
||||
const hasModel = Boolean(currentModel);
|
||||
const modelVariantKeys = hasModel
|
||||
? modelVariantsMap[currentModel] || []
|
||||
: [];
|
||||
const hasVariants = modelVariantKeys.length > 0;
|
||||
const shouldShow = hasModel && (hasVariants || Boolean(currentVariant));
|
||||
|
||||
if (!shouldShow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const variantOptions = buildEffectiveVariantOptions(
|
||||
currentModel,
|
||||
currentVariant,
|
||||
);
|
||||
const hasModel = Boolean(currentModel);
|
||||
const firstIsUnavailable =
|
||||
Boolean(currentVariant) &&
|
||||
!(modelVariantsMap[currentModel] || []).includes(currentVariant);
|
||||
@@ -283,9 +464,8 @@ export function OmoFormFields({
|
||||
onValueChange={(value) =>
|
||||
onChange(value === EMPTY_VARIANT_VALUE ? "" : value)
|
||||
}
|
||||
disabled={!hasModel}
|
||||
>
|
||||
<SelectTrigger className="w-32 h-8 text-xs shrink-0">
|
||||
<SelectTrigger className="w-28 h-8 text-xs shrink-0">
|
||||
<SelectValue
|
||||
placeholder={t("omo.variantPlaceholder", {
|
||||
defaultValue: "variant",
|
||||
@@ -296,30 +476,16 @@ export function OmoFormFields({
|
||||
<SelectItem value={EMPTY_VARIANT_VALUE}>
|
||||
{t("omo.defaultWrapped", { defaultValue: "(Default)" })}
|
||||
</SelectItem>
|
||||
{!hasModel ? (
|
||||
<SelectItem value={UNAVAILABLE_VARIANT_VALUE} disabled>
|
||||
{t("omo.selectModelFirst", {
|
||||
defaultValue: "Select model first",
|
||||
})}
|
||||
{variantOptions.map((variant, index) => (
|
||||
<SelectItem key={`${variant}-${index}`} value={variant}>
|
||||
{firstIsUnavailable && index === 0
|
||||
? t("omo.currentValueUnavailable", {
|
||||
value: variant,
|
||||
defaultValue: "{{value}} (current value, unavailable)",
|
||||
})
|
||||
: variant}
|
||||
</SelectItem>
|
||||
) : variantOptions.length === 0 ? (
|
||||
<SelectItem value={UNAVAILABLE_VARIANT_VALUE} disabled>
|
||||
{t("omo.noVariantsForModel", {
|
||||
defaultValue: "No variants for model",
|
||||
})}
|
||||
</SelectItem>
|
||||
) : (
|
||||
variantOptions.map((variant, index) => (
|
||||
<SelectItem key={`${variant}-${index}`} value={variant}>
|
||||
{firstIsUnavailable && index === 0
|
||||
? t("omo.currentValueUnavailable", {
|
||||
value: variant,
|
||||
defaultValue: "{{value}} (current value, unavailable)",
|
||||
})
|
||||
: variant}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
@@ -536,7 +702,7 @@ export function OmoFormFields({
|
||||
toast.warning(
|
||||
t("omo.noEnabledModelsWarning", {
|
||||
defaultValue:
|
||||
"No enabled models available. Configure and enable OpenCode models first.",
|
||||
"No configured models available. Configure OpenCode models first.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
@@ -641,9 +807,17 @@ export function OmoFormFields({
|
||||
<div key={key} className="border-b border-border/30 last:border-b-0">
|
||||
<div className="flex items-center gap-2 py-1.5">
|
||||
<div className="w-32 shrink-0">
|
||||
<div className="text-sm font-medium">{def.display}</div>
|
||||
<div className="flex items-center gap-1 text-sm font-medium">
|
||||
{def.display}
|
||||
<span className="relative inline-flex group/tip">
|
||||
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground/60 hover:text-muted-foreground cursor-help shrink-0" />
|
||||
<span className="invisible opacity-0 group-hover/tip:visible group-hover/tip:opacity-100 transition-opacity duration-150 absolute left-0 top-full mt-1 z-50 w-[260px] rounded-md bg-popover text-popover-foreground border border-border shadow-md px-3 py-2 text-xs leading-relaxed font-normal pointer-events-none">
|
||||
{t(def.tooltipKey)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{isZh ? def.descZh : def.descEn}
|
||||
{t(def.descKey)}
|
||||
</div>
|
||||
</div>
|
||||
{renderModelSelect(
|
||||
@@ -727,16 +901,14 @@ export function OmoFormFields({
|
||||
className="border-b border-border/30 last:border-b-0"
|
||||
>
|
||||
<div className="flex items-center gap-2 py-1.5">
|
||||
<Input
|
||||
<DeferredKeyInput
|
||||
value={item.key}
|
||||
onChange={(e) => updateCustom({ key: e.target.value })}
|
||||
onCommit={(value) => updateCustom({ key: value })}
|
||||
placeholder={keyPlaceholder}
|
||||
className="w-32 shrink-0 h-8 text-sm text-primary"
|
||||
/>
|
||||
{renderModelSelect(
|
||||
item.model,
|
||||
(value) => updateCustom({ model: value }),
|
||||
t("omo.modelNamePlaceholder", { defaultValue: "model-name" }),
|
||||
{renderModelSelect(item.model, (value) =>
|
||||
updateCustom({ model: value }),
|
||||
)}
|
||||
{renderVariantSelect(item.model, currentVariant, (value) => {
|
||||
if (!item.key) return;
|
||||
@@ -877,11 +1049,17 @@ export function OmoFormFields({
|
||||
|
||||
const addCustomModel = (scope: AdvancedScope) => {
|
||||
if (scope === "agent") {
|
||||
setCustomAgents((prev) => [...prev, { key: "", model: "" }]);
|
||||
setCustomAgents((prev) => [
|
||||
...prev,
|
||||
{ key: "", model: "", sourceKey: "" },
|
||||
]);
|
||||
setSubAgentsOpen(true);
|
||||
return;
|
||||
}
|
||||
setCustomCategories((prev) => [...prev, { key: "", model: "" }]);
|
||||
setCustomCategories((prev) => [
|
||||
...prev,
|
||||
{ key: "", model: "", sourceKey: "" },
|
||||
]);
|
||||
setCategoriesOpen(true);
|
||||
};
|
||||
|
||||
@@ -931,7 +1109,7 @@ export function OmoFormFields({
|
||||
·{" "}
|
||||
{t("omo.enabledModelsCount", {
|
||||
count: modelOptions.length,
|
||||
defaultValue: "{{count}} enabled models available",
|
||||
defaultValue: "{{count}} configured models available",
|
||||
})}
|
||||
</span>
|
||||
{localFilePath && (
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { ApiKeySection } from "./shared";
|
||||
import { openclawApiProtocols } from "@/config/openclawProviderPresets";
|
||||
import type { ProviderCategory, OpenClawModel } from "@/types";
|
||||
|
||||
interface OpenClawFormFieldsProps {
|
||||
// Base URL
|
||||
baseUrl: string;
|
||||
onBaseUrlChange: (value: string) => void;
|
||||
|
||||
// API Key
|
||||
apiKey: string;
|
||||
onApiKeyChange: (value: string) => void;
|
||||
category?: ProviderCategory;
|
||||
shouldShowApiKeyLink: boolean;
|
||||
websiteUrl: string;
|
||||
isPartner?: boolean;
|
||||
partnerPromotionKey?: string;
|
||||
|
||||
// API Protocol
|
||||
api: string;
|
||||
onApiChange: (value: string) => void;
|
||||
|
||||
// Models
|
||||
models: OpenClawModel[];
|
||||
onModelsChange: (models: OpenClawModel[]) => void;
|
||||
}
|
||||
|
||||
export function OpenClawFormFields({
|
||||
baseUrl,
|
||||
onBaseUrlChange,
|
||||
apiKey,
|
||||
onApiKeyChange,
|
||||
category,
|
||||
shouldShowApiKeyLink,
|
||||
websiteUrl,
|
||||
isPartner,
|
||||
partnerPromotionKey,
|
||||
api,
|
||||
onApiChange,
|
||||
models,
|
||||
onModelsChange,
|
||||
}: OpenClawFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedModels, setExpandedModels] = useState<Record<number, boolean>>(
|
||||
{},
|
||||
);
|
||||
|
||||
// Stable key tracking for models list
|
||||
const modelKeysRef = useRef<string[]>([]);
|
||||
const getModelKeys = useCallback(() => {
|
||||
// Grow keys array if models were added externally
|
||||
while (modelKeysRef.current.length < models.length) {
|
||||
modelKeysRef.current.push(crypto.randomUUID());
|
||||
}
|
||||
// Shrink if models were removed externally
|
||||
if (modelKeysRef.current.length > models.length) {
|
||||
modelKeysRef.current.length = models.length;
|
||||
}
|
||||
return modelKeysRef.current;
|
||||
}, [models.length]);
|
||||
const modelKeys = getModelKeys();
|
||||
|
||||
// Toggle advanced section for a model
|
||||
const toggleModelAdvanced = (index: number) => {
|
||||
setExpandedModels((prev) => ({ ...prev, [index]: !prev[index] }));
|
||||
};
|
||||
|
||||
// Add a new model entry
|
||||
const handleAddModel = () => {
|
||||
modelKeysRef.current.push(crypto.randomUUID());
|
||||
onModelsChange([
|
||||
...models,
|
||||
{
|
||||
id: "",
|
||||
name: "",
|
||||
contextWindow: undefined,
|
||||
maxTokens: undefined,
|
||||
cost: undefined,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// Remove a model entry
|
||||
const handleRemoveModel = (index: number) => {
|
||||
modelKeysRef.current.splice(index, 1);
|
||||
const newModels = [...models];
|
||||
newModels.splice(index, 1);
|
||||
onModelsChange(newModels);
|
||||
// Clean up expanded state
|
||||
setExpandedModels((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[index];
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Update model field
|
||||
const handleModelChange = (
|
||||
index: number,
|
||||
field: keyof OpenClawModel,
|
||||
value: unknown,
|
||||
) => {
|
||||
const newModels = [...models];
|
||||
newModels[index] = { ...newModels[index], [field]: value };
|
||||
onModelsChange(newModels);
|
||||
};
|
||||
|
||||
// Update model cost
|
||||
const handleCostChange = (
|
||||
index: number,
|
||||
costField: "input" | "output" | "cacheRead" | "cacheWrite",
|
||||
value: string,
|
||||
) => {
|
||||
const newModels = [...models];
|
||||
const numValue = parseFloat(value);
|
||||
const currentCost = newModels[index].cost || { input: 0, output: 0 };
|
||||
newModels[index] = {
|
||||
...newModels[index],
|
||||
cost: {
|
||||
...currentCost,
|
||||
[costField]: isNaN(numValue) ? undefined : numValue,
|
||||
},
|
||||
};
|
||||
onModelsChange(newModels);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* API Protocol Selector */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="openclaw-api">
|
||||
{t("openclaw.apiProtocol", {
|
||||
defaultValue: "API 协议",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Select value={api} onValueChange={onApiChange}>
|
||||
<SelectTrigger id="openclaw-api">
|
||||
<SelectValue
|
||||
placeholder={t("openclaw.selectProtocol", {
|
||||
defaultValue: "选择 API 协议",
|
||||
})}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{openclawApiProtocols.map((protocol) => (
|
||||
<SelectItem key={protocol.value} value={protocol.value}>
|
||||
{protocol.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("openclaw.apiProtocolHint", {
|
||||
defaultValue:
|
||||
"选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Base URL */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="openclaw-baseurl">
|
||||
{t("openclaw.baseUrl", { defaultValue: "API 端点" })}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="openclaw-baseurl"
|
||||
value={baseUrl}
|
||||
onChange={(e) => onBaseUrlChange(e.target.value)}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("openclaw.baseUrlHint", {
|
||||
defaultValue: "供应商的 API 端点地址。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<ApiKeySection
|
||||
value={apiKey}
|
||||
onChange={onApiKeyChange}
|
||||
category={category}
|
||||
shouldShowLink={shouldShowApiKeyLink}
|
||||
websiteUrl={websiteUrl}
|
||||
isPartner={isPartner}
|
||||
partnerPromotionKey={partnerPromotionKey}
|
||||
/>
|
||||
|
||||
{/* Models Editor */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>
|
||||
{t("openclaw.models", { defaultValue: "模型列表" })}
|
||||
</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddModel}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("openclaw.addModel", { defaultValue: "添加模型" })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{models.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
{t("openclaw.noModels", {
|
||||
defaultValue: "暂无模型配置。点击添加模型来配置可用模型。",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{models.map((model, index) => (
|
||||
<div
|
||||
key={modelKeys[index]}
|
||||
className="p-3 border border-border/50 rounded-lg space-y-3"
|
||||
>
|
||||
{/* Model ID and Name row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.modelId", { defaultValue: "模型 ID" })}
|
||||
</label>
|
||||
<Input
|
||||
value={model.id}
|
||||
onChange={(e) =>
|
||||
handleModelChange(index, "id", e.target.value)
|
||||
}
|
||||
placeholder={t("openclaw.modelIdPlaceholder", {
|
||||
defaultValue: "claude-3-sonnet",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.modelName", { defaultValue: "显示名称" })}
|
||||
</label>
|
||||
<Input
|
||||
value={model.name}
|
||||
onChange={(e) =>
|
||||
handleModelChange(index, "name", e.target.value)
|
||||
}
|
||||
placeholder={t("openclaw.modelNamePlaceholder", {
|
||||
defaultValue: "Claude 3 Sonnet",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveModel(index)}
|
||||
className="h-9 w-9 mt-5 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Advanced Options (Collapsible) */}
|
||||
<Collapsible
|
||||
open={expandedModels[index] ?? false}
|
||||
onOpenChange={() => toggleModelAdvanced(index)}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{expandedModels[index] ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("openclaw.advancedOptions", {
|
||||
defaultValue: "高级选项",
|
||||
})}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 pt-2">
|
||||
{/* Context Window, Max Tokens and Reasoning row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.contextWindow", {
|
||||
defaultValue: "上下文窗口",
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={model.contextWindow ?? ""}
|
||||
onChange={(e) =>
|
||||
handleModelChange(
|
||||
index,
|
||||
"contextWindow",
|
||||
e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
placeholder="200000"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.maxTokens", {
|
||||
defaultValue: "最大输出 Tokens",
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={model.maxTokens ?? ""}
|
||||
onChange={(e) =>
|
||||
handleModelChange(
|
||||
index,
|
||||
"maxTokens",
|
||||
e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
placeholder="32000"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.reasoning", {
|
||||
defaultValue: "推理模式",
|
||||
})}
|
||||
</label>
|
||||
<div className="flex items-center h-9 gap-2">
|
||||
<Switch
|
||||
checked={model.reasoning ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleModelChange(index, "reasoning", checked)
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{model.reasoning
|
||||
? t("openclaw.reasoningOn", {
|
||||
defaultValue: "启用",
|
||||
})
|
||||
: t("openclaw.reasoningOff", {
|
||||
defaultValue: "关闭",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.inputCost", {
|
||||
defaultValue: "输入价格 ($/M tokens)",
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={model.cost?.input ?? ""}
|
||||
onChange={(e) =>
|
||||
handleCostChange(index, "input", e.target.value)
|
||||
}
|
||||
placeholder="3"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.outputCost", {
|
||||
defaultValue: "输出价格 ($/M tokens)",
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={model.cost?.output ?? ""}
|
||||
onChange={(e) =>
|
||||
handleCostChange(index, "output", e.target.value)
|
||||
}
|
||||
placeholder="15"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
{/* Cache Cost row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.cacheReadCost", {
|
||||
defaultValue: "缓存读取价格 ($/M tokens)",
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={model.cost?.cacheRead ?? ""}
|
||||
onChange={(e) =>
|
||||
handleCostChange(index, "cacheRead", e.target.value)
|
||||
}
|
||||
placeholder="0.3"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.cacheWriteCost", {
|
||||
defaultValue: "缓存写入价格 ($/M tokens)",
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={model.cost?.cacheWrite ?? ""}
|
||||
onChange={(e) =>
|
||||
handleCostChange(
|
||||
index,
|
||||
"cacheWrite",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="3.75"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("openclaw.cacheCostHint", {
|
||||
defaultValue:
|
||||
"缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。",
|
||||
})}
|
||||
</p>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("openclaw.modelsHint", {
|
||||
defaultValue:
|
||||
"配置该供应商支持的模型。模型 ID 用于 API 调用,显示名称用于界面展示。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
ClaudeApiFormat,
|
||||
OpenCodeModel,
|
||||
OpenCodeProviderConfig,
|
||||
OpenClawModel,
|
||||
} from "@/types";
|
||||
import {
|
||||
providerPresets,
|
||||
@@ -31,9 +32,16 @@ import {
|
||||
} from "@/config/geminiProviderPresets";
|
||||
import {
|
||||
opencodeProviderPresets,
|
||||
OPENCODE_PRESET_MODEL_VARIANTS,
|
||||
type OpenCodeProviderPreset,
|
||||
} from "@/config/opencodeProviderPresets";
|
||||
import {
|
||||
openclawProviderPresets,
|
||||
type OpenClawProviderPreset,
|
||||
type OpenClawSuggestedDefaults,
|
||||
} from "@/config/openclawProviderPresets";
|
||||
import { OpenCodeFormFields } from "./OpenCodeFormFields";
|
||||
import { OpenClawFormFields } from "./OpenClawFormFields";
|
||||
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
||||
import {
|
||||
applyTemplateValues,
|
||||
@@ -56,7 +64,7 @@ import { type OmoGlobalConfigFieldsRef } from "./OmoGlobalConfigFields";
|
||||
import { OmoCommonConfigEditor } from "./OmoCommonConfigEditor";
|
||||
import * as configApi from "@/lib/api/config";
|
||||
import type { OmoGlobalConfig } from "@/types/omo";
|
||||
import { mergeOmoConfigPreview } from "@/types/omo";
|
||||
import { mergeOmoConfigPreview, parseOmoOtherFieldsObject } from "@/types/omo";
|
||||
import {
|
||||
ProviderAdvancedConfig,
|
||||
type PricingModelSourceOption,
|
||||
@@ -115,21 +123,25 @@ const isKnownOpencodeOptionKey = (key: string) =>
|
||||
function parseOpencodeConfig(
|
||||
settingsConfig?: Record<string, unknown>,
|
||||
): OpenCodeProviderConfig {
|
||||
const normalize = (
|
||||
parsed: Partial<OpenCodeProviderConfig>,
|
||||
): OpenCodeProviderConfig => ({
|
||||
npm: parsed.npm || OPENCODE_DEFAULT_NPM,
|
||||
options:
|
||||
parsed.options && typeof parsed.options === "object"
|
||||
? (parsed.options as OpenCodeProviderConfig["options"])
|
||||
: {},
|
||||
models:
|
||||
parsed.models && typeof parsed.models === "object"
|
||||
? (parsed.models as Record<string, OpenCodeModel>)
|
||||
: {},
|
||||
});
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG,
|
||||
) as Partial<OpenCodeProviderConfig>;
|
||||
return {
|
||||
npm: parsed.npm || OPENCODE_DEFAULT_NPM,
|
||||
options:
|
||||
parsed.options && typeof parsed.options === "object"
|
||||
? (parsed.options as OpenCodeProviderConfig["options"])
|
||||
: {},
|
||||
models:
|
||||
parsed.models && typeof parsed.models === "object"
|
||||
? (parsed.models as Record<string, OpenCodeModel>)
|
||||
: {},
|
||||
};
|
||||
return normalize(parsed);
|
||||
} catch {
|
||||
return {
|
||||
npm: OPENCODE_DEFAULT_NPM,
|
||||
@@ -139,6 +151,25 @@ function parseOpencodeConfig(
|
||||
}
|
||||
}
|
||||
|
||||
function parseOpencodeConfigStrict(
|
||||
settingsConfig?: Record<string, unknown>,
|
||||
): OpenCodeProviderConfig {
|
||||
const parsed = JSON.parse(
|
||||
settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG,
|
||||
) as Partial<OpenCodeProviderConfig>;
|
||||
return {
|
||||
npm: parsed.npm || OPENCODE_DEFAULT_NPM,
|
||||
options:
|
||||
parsed.options && typeof parsed.options === "object"
|
||||
? (parsed.options as OpenCodeProviderConfig["options"])
|
||||
: {},
|
||||
models:
|
||||
parsed.models && typeof parsed.models === "object"
|
||||
? (parsed.models as Record<string, OpenCodeModel>)
|
||||
: {},
|
||||
};
|
||||
}
|
||||
|
||||
function toOpencodeExtraOptions(
|
||||
options: OpenCodeProviderConfig["options"],
|
||||
): Record<string, string> {
|
||||
@@ -151,13 +182,25 @@ function toOpencodeExtraOptions(
|
||||
return extra;
|
||||
}
|
||||
|
||||
const OPENCLAW_DEFAULT_CONFIG = JSON.stringify(
|
||||
{
|
||||
baseUrl: "",
|
||||
apiKey: "",
|
||||
api: "openai-completions",
|
||||
models: [],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
type PresetEntry = {
|
||||
id: string;
|
||||
preset:
|
||||
| ProviderPreset
|
||||
| CodexProviderPreset
|
||||
| GeminiProviderPreset
|
||||
| OpenCodeProviderPreset;
|
||||
| OpenCodeProviderPreset
|
||||
| OpenClawProviderPreset;
|
||||
};
|
||||
|
||||
interface ProviderFormProps {
|
||||
@@ -198,8 +241,10 @@ function buildOmoProfilePreview(
|
||||
}
|
||||
if (otherFieldsStr.trim()) {
|
||||
try {
|
||||
const other = JSON.parse(otherFieldsStr);
|
||||
Object.assign(profileOnly, other);
|
||||
const other = parseOmoOtherFieldsObject(otherFieldsStr);
|
||||
if (other) {
|
||||
Object.assign(profileOnly, other);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return profileOnly;
|
||||
@@ -236,6 +281,7 @@ export function ProviderForm({
|
||||
category?: ProviderCategory;
|
||||
isPartner?: boolean;
|
||||
partnerPromotionKey?: string;
|
||||
suggestedDefaults?: OpenClawSuggestedDefaults;
|
||||
} | null>(null);
|
||||
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
|
||||
const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] =
|
||||
@@ -314,7 +360,9 @@ export function ProviderForm({
|
||||
? GEMINI_DEFAULT_CONFIG
|
||||
: appId === "opencode"
|
||||
? OPENCODE_DEFAULT_CONFIG
|
||||
: CLAUDE_DEFAULT_CONFIG,
|
||||
: appId === "openclaw"
|
||||
? OPENCLAW_DEFAULT_CONFIG
|
||||
: CLAUDE_DEFAULT_CONFIG,
|
||||
icon: initialData?.icon ?? "",
|
||||
iconColor: initialData?.iconColor ?? "",
|
||||
}),
|
||||
@@ -444,6 +492,11 @@ export function ProviderForm({
|
||||
id: `opencode-${index}`,
|
||||
preset,
|
||||
}));
|
||||
} else if (appId === "openclaw") {
|
||||
return openclawProviderPresets.map<PresetEntry>((preset, index) => ({
|
||||
id: `openclaw-${index}`,
|
||||
preset,
|
||||
}));
|
||||
}
|
||||
return providerPresets.map<PresetEntry>((preset, index) => ({
|
||||
id: `claude-${index}`,
|
||||
@@ -586,19 +639,24 @@ export function ProviderForm({
|
||||
);
|
||||
}, [opencodeProvidersData?.providers, providerId]);
|
||||
const [enabledOpencodeProviderIds, setEnabledOpencodeProviderIds] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
string[] | null
|
||||
>(null);
|
||||
const [omoLiveIdsLoadFailed, setOmoLiveIdsLoadFailed] = useState(false);
|
||||
const lastOmoModelSourceWarningRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
if (!isOmoCategory) {
|
||||
setEnabledOpencodeProviderIds([]);
|
||||
setEnabledOpencodeProviderIds(null);
|
||||
setOmoLiveIdsLoadFailed(false);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}
|
||||
|
||||
setEnabledOpencodeProviderIds(null);
|
||||
setOmoLiveIdsLoadFailed(false);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const ids = await providersApi.getOpenCodeLiveProviderIds();
|
||||
@@ -606,9 +664,13 @@ export function ProviderForm({
|
||||
setEnabledOpencodeProviderIds(ids);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load OpenCode live provider ids:", error);
|
||||
console.warn(
|
||||
"[OMO_MODEL_SOURCE_LIVE_IDS_FAILED] failed to load live provider ids",
|
||||
error,
|
||||
);
|
||||
if (active) {
|
||||
setEnabledOpencodeProviderIds([]);
|
||||
setOmoLiveIdsLoadFailed(true);
|
||||
setEnabledOpencodeProviderIds(null);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -618,23 +680,71 @@ export function ProviderForm({
|
||||
};
|
||||
}, [isOmoCategory]);
|
||||
|
||||
const omoModelOptions = useMemo(() => {
|
||||
if (!isOmoCategory) return [];
|
||||
const omoModelBuild = useMemo(() => {
|
||||
const empty = {
|
||||
options: [] as Array<{ value: string; label: string }>,
|
||||
variantsMap: {} as Record<string, string[]>,
|
||||
presetMetaMap: {} as Record<
|
||||
string,
|
||||
{
|
||||
options?: Record<string, unknown>;
|
||||
limit?: { context?: number; output?: number };
|
||||
}
|
||||
>,
|
||||
parseFailedProviders: [] as string[],
|
||||
usedFallbackSource: false,
|
||||
};
|
||||
if (!isOmoCategory) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
const allProviders = opencodeProvidersData?.providers;
|
||||
if (!allProviders) return [];
|
||||
if (!allProviders) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
const enabledSet = new Set(enabledOpencodeProviderIds);
|
||||
if (enabledSet.size === 0) return [];
|
||||
const shouldFilterByLive = !omoLiveIdsLoadFailed;
|
||||
if (shouldFilterByLive && enabledOpencodeProviderIds === null) {
|
||||
return empty;
|
||||
}
|
||||
const liveSet =
|
||||
shouldFilterByLive && enabledOpencodeProviderIds
|
||||
? new Set(enabledOpencodeProviderIds)
|
||||
: null;
|
||||
|
||||
const dedupedOptions = new Map<string, string>();
|
||||
const variantsMap: Record<string, string[]> = {};
|
||||
const presetMetaMap: Record<
|
||||
string,
|
||||
{
|
||||
options?: Record<string, unknown>;
|
||||
limit?: { context?: number; output?: number };
|
||||
}
|
||||
> = {};
|
||||
const parseFailedProviders: string[] = [];
|
||||
|
||||
for (const [providerKey, provider] of Object.entries(allProviders)) {
|
||||
if (provider.category === "omo" || !enabledSet.has(providerKey)) {
|
||||
if (provider.category === "omo") {
|
||||
continue;
|
||||
}
|
||||
if (liveSet && !liveSet.has(providerKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedConfig = parseOpencodeConfig(provider.settingsConfig);
|
||||
let parsedConfig: OpenCodeProviderConfig;
|
||||
try {
|
||||
parsedConfig = parseOpencodeConfigStrict(provider.settingsConfig);
|
||||
} catch (error) {
|
||||
parseFailedProviders.push(providerKey);
|
||||
console.warn(
|
||||
"[OMO_MODEL_SOURCE_PARSE_FAILED] failed to parse provider settings",
|
||||
{
|
||||
providerKey,
|
||||
error,
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (const [modelId, model] of Object.entries(
|
||||
parsedConfig.models || {},
|
||||
)) {
|
||||
@@ -651,63 +761,107 @@ export function ProviderForm({
|
||||
if (!dedupedOptions.has(value)) {
|
||||
dedupedOptions.set(value, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(dedupedOptions.entries())
|
||||
.map(([value, label]) => ({ value, label }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, "zh-CN"));
|
||||
}, [
|
||||
isOmoCategory,
|
||||
opencodeProvidersData?.providers,
|
||||
enabledOpencodeProviderIds,
|
||||
]);
|
||||
const omoModelVariantsMap = useMemo(() => {
|
||||
const variantsMap: Record<string, string[]> = {};
|
||||
if (!isOmoCategory) {
|
||||
return variantsMap;
|
||||
}
|
||||
|
||||
const allProviders = opencodeProvidersData?.providers;
|
||||
if (!allProviders) {
|
||||
return variantsMap;
|
||||
}
|
||||
|
||||
const enabledSet = new Set(enabledOpencodeProviderIds);
|
||||
if (enabledSet.size === 0) {
|
||||
return variantsMap;
|
||||
}
|
||||
|
||||
for (const [providerKey, provider] of Object.entries(allProviders)) {
|
||||
if (provider.category === "omo" || !enabledSet.has(providerKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedConfig = parseOpencodeConfig(provider.settingsConfig);
|
||||
for (const [modelId, model] of Object.entries(
|
||||
parsedConfig.models || {},
|
||||
)) {
|
||||
const rawVariants = model.variants;
|
||||
if (
|
||||
!rawVariants ||
|
||||
typeof rawVariants !== "object" ||
|
||||
Array.isArray(rawVariants)
|
||||
rawVariants &&
|
||||
typeof rawVariants === "object" &&
|
||||
!Array.isArray(rawVariants)
|
||||
) {
|
||||
continue;
|
||||
const variantKeys = Object.keys(rawVariants).filter(Boolean);
|
||||
if (variantKeys.length > 0) {
|
||||
variantsMap[value] = variantKeys;
|
||||
}
|
||||
}
|
||||
const variantKeys = Object.keys(rawVariants).filter(Boolean);
|
||||
if (variantKeys.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Preset fallback: for models without config-defined variants,
|
||||
// check if the npm package has preset variant definitions.
|
||||
// Also collect preset metadata (options, limit) for enrichment.
|
||||
const presetModels = OPENCODE_PRESET_MODEL_VARIANTS[parsedConfig.npm];
|
||||
if (presetModels) {
|
||||
for (const modelId of Object.keys(parsedConfig.models || {})) {
|
||||
const fullKey = `${providerKey}/${modelId}`;
|
||||
const preset = presetModels.find((p) => p.id === modelId);
|
||||
if (!preset) continue;
|
||||
|
||||
// Variant fallback
|
||||
if (!variantsMap[fullKey] && preset.variants) {
|
||||
const presetKeys = Object.keys(preset.variants).filter(Boolean);
|
||||
if (presetKeys.length > 0) {
|
||||
variantsMap[fullKey] = presetKeys;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect preset metadata for model enrichment
|
||||
const meta: (typeof presetMetaMap)[string] = {};
|
||||
if (preset.options) meta.options = preset.options;
|
||||
if (preset.contextLimit || preset.outputLimit) {
|
||||
meta.limit = {};
|
||||
if (preset.contextLimit) meta.limit.context = preset.contextLimit;
|
||||
if (preset.outputLimit) meta.limit.output = preset.outputLimit;
|
||||
}
|
||||
if (Object.keys(meta).length > 0) {
|
||||
presetMetaMap[fullKey] = meta;
|
||||
}
|
||||
}
|
||||
variantsMap[`${providerKey}/${modelId}`] = variantKeys;
|
||||
}
|
||||
}
|
||||
|
||||
return variantsMap;
|
||||
return {
|
||||
options: Array.from(dedupedOptions.entries())
|
||||
.map(([value, label]) => ({ value, label }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, "zh-CN")),
|
||||
variantsMap,
|
||||
presetMetaMap,
|
||||
parseFailedProviders,
|
||||
usedFallbackSource: omoLiveIdsLoadFailed,
|
||||
};
|
||||
}, [
|
||||
isOmoCategory,
|
||||
opencodeProvidersData?.providers,
|
||||
enabledOpencodeProviderIds,
|
||||
omoLiveIdsLoadFailed,
|
||||
]);
|
||||
const omoModelOptions = omoModelBuild.options;
|
||||
const omoModelVariantsMap = omoModelBuild.variantsMap;
|
||||
const omoPresetMetaMap = omoModelBuild.presetMetaMap;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOmoCategory) return;
|
||||
const failed = omoModelBuild.parseFailedProviders;
|
||||
const fallback = omoModelBuild.usedFallbackSource;
|
||||
if (failed.length === 0 && !fallback) return;
|
||||
|
||||
const signature = `${fallback ? "fallback:" : ""}${failed
|
||||
.slice()
|
||||
.sort()
|
||||
.join(",")}`;
|
||||
if (lastOmoModelSourceWarningRef.current === signature) return;
|
||||
lastOmoModelSourceWarningRef.current = signature;
|
||||
|
||||
if (failed.length > 0) {
|
||||
toast.warning(
|
||||
t("omo.modelSourcePartialWarning", {
|
||||
count: failed.length,
|
||||
defaultValue:
|
||||
"Some provider model configs are invalid and were skipped.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (fallback) {
|
||||
toast.warning(
|
||||
t("omo.modelSourceFallbackWarning", {
|
||||
defaultValue:
|
||||
"Failed to load live provider state. Falling back to configured providers.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [
|
||||
isOmoCategory,
|
||||
omoModelBuild.parseFailedProviders,
|
||||
omoModelBuild.usedFallbackSource,
|
||||
t,
|
||||
]);
|
||||
|
||||
const initialOmoSettings =
|
||||
@@ -725,6 +879,24 @@ export function ProviderForm({
|
||||
return providerId || "";
|
||||
});
|
||||
|
||||
// OpenClaw: query existing providers for duplicate key checking
|
||||
const { data: openclawProvidersData } = useProvidersQuery("openclaw");
|
||||
const existingOpenclawKeys = useMemo(() => {
|
||||
if (!openclawProvidersData?.providers) return [];
|
||||
// Exclude current provider ID when in edit mode
|
||||
return Object.keys(openclawProvidersData.providers).filter(
|
||||
(k) => k !== providerId,
|
||||
);
|
||||
}, [openclawProvidersData?.providers, providerId]);
|
||||
|
||||
// OpenClaw Provider Key state
|
||||
const [openclawProviderKey, setOpenclawProviderKey] = useState<string>(() => {
|
||||
if (appId !== "openclaw") return "";
|
||||
// In edit mode, use the existing provider ID as the key
|
||||
return providerId || "";
|
||||
});
|
||||
|
||||
// OpenCode 配置状态
|
||||
const [opencodeNpm, setOpencodeNpm] = useState<string>(() => {
|
||||
if (appId !== "opencode") return OPENCODE_DEFAULT_NPM;
|
||||
return initialOpencodeConfig?.npm || OPENCODE_DEFAULT_NPM;
|
||||
@@ -866,6 +1038,128 @@ export function ProviderForm({
|
||||
setUseOmoCommonConfig(useCommonConfig);
|
||||
}, []);
|
||||
|
||||
// OpenClaw 配置状态
|
||||
const [openclawBaseUrl, setOpenclawBaseUrl] = useState<string>(() => {
|
||||
if (appId !== "openclaw") return "";
|
||||
try {
|
||||
const config = JSON.parse(
|
||||
initialData?.settingsConfig
|
||||
? JSON.stringify(initialData.settingsConfig)
|
||||
: OPENCLAW_DEFAULT_CONFIG,
|
||||
);
|
||||
return config.baseUrl || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
const [openclawApiKey, setOpenclawApiKey] = useState<string>(() => {
|
||||
if (appId !== "openclaw") return "";
|
||||
try {
|
||||
const config = JSON.parse(
|
||||
initialData?.settingsConfig
|
||||
? JSON.stringify(initialData.settingsConfig)
|
||||
: OPENCLAW_DEFAULT_CONFIG,
|
||||
);
|
||||
return config.apiKey || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
const [openclawApi, setOpenclawApi] = useState<string>(() => {
|
||||
if (appId !== "openclaw") return "openai-completions";
|
||||
try {
|
||||
const config = JSON.parse(
|
||||
initialData?.settingsConfig
|
||||
? JSON.stringify(initialData.settingsConfig)
|
||||
: OPENCLAW_DEFAULT_CONFIG,
|
||||
);
|
||||
return config.api || "openai-completions";
|
||||
} catch {
|
||||
return "openai-completions";
|
||||
}
|
||||
});
|
||||
|
||||
const [openclawModels, setOpenclawModels] = useState<OpenClawModel[]>(() => {
|
||||
if (appId !== "openclaw") return [];
|
||||
try {
|
||||
const config = JSON.parse(
|
||||
initialData?.settingsConfig
|
||||
? JSON.stringify(initialData.settingsConfig)
|
||||
: OPENCLAW_DEFAULT_CONFIG,
|
||||
);
|
||||
return config.models || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// OpenClaw handlers - sync state to form
|
||||
const handleOpenclawBaseUrlChange = useCallback(
|
||||
(baseUrl: string) => {
|
||||
setOpenclawBaseUrl(baseUrl);
|
||||
try {
|
||||
const config = JSON.parse(
|
||||
form.getValues("settingsConfig") || OPENCLAW_DEFAULT_CONFIG,
|
||||
);
|
||||
config.baseUrl = baseUrl.trim().replace(/\/+$/, "");
|
||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const handleOpenclawApiKeyChange = useCallback(
|
||||
(apiKey: string) => {
|
||||
setOpenclawApiKey(apiKey);
|
||||
try {
|
||||
const config = JSON.parse(
|
||||
form.getValues("settingsConfig") || OPENCLAW_DEFAULT_CONFIG,
|
||||
);
|
||||
config.apiKey = apiKey;
|
||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const handleOpenclawApiChange = useCallback(
|
||||
(api: string) => {
|
||||
setOpenclawApi(api);
|
||||
try {
|
||||
const config = JSON.parse(
|
||||
form.getValues("settingsConfig") || OPENCLAW_DEFAULT_CONFIG,
|
||||
);
|
||||
config.api = api;
|
||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const handleOpenclawModelsChange = useCallback(
|
||||
(models: OpenClawModel[]) => {
|
||||
setOpenclawModels(models);
|
||||
try {
|
||||
const config = JSON.parse(
|
||||
form.getValues("settingsConfig") || OPENCLAW_DEFAULT_CONFIG,
|
||||
);
|
||||
config.models = models;
|
||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const updateOpencodeSettings = useCallback(
|
||||
(updater: (config: Record<string, any>) => void) => {
|
||||
try {
|
||||
@@ -993,6 +1287,24 @@ export function ProviderForm({
|
||||
}
|
||||
}
|
||||
|
||||
// OpenClaw: validate provider key
|
||||
if (appId === "openclaw") {
|
||||
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
if (!openclawProviderKey.trim()) {
|
||||
toast.error(t("openclaw.providerKeyRequired"));
|
||||
return;
|
||||
}
|
||||
if (!keyPattern.test(openclawProviderKey)) {
|
||||
toast.error(t("openclaw.providerKeyInvalid"));
|
||||
return;
|
||||
}
|
||||
if (!isEditMode && existingOpenclawKeys.includes(openclawProviderKey)) {
|
||||
toast.error(t("openclaw.providerKeyDuplicate"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 非官方供应商必填校验:端点和 API Key
|
||||
if (category !== "official") {
|
||||
if (appId === "claude") {
|
||||
if (!baseUrl.trim()) {
|
||||
@@ -1084,7 +1396,19 @@ export function ProviderForm({
|
||||
}
|
||||
if (omoOtherFieldsStr.trim()) {
|
||||
try {
|
||||
omoConfig.otherFields = JSON.parse(omoOtherFieldsStr);
|
||||
const otherFields = parseOmoOtherFieldsObject(omoOtherFieldsStr);
|
||||
if (!otherFields) {
|
||||
toast.error(
|
||||
t("omo.jsonMustBeObject", {
|
||||
field: t("omo.otherFields", {
|
||||
defaultValue: "Other Config",
|
||||
}),
|
||||
defaultValue: "{{field}} must be a JSON object",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
omoConfig.otherFields = otherFields;
|
||||
} catch {
|
||||
toast.error(
|
||||
t("omo.invalidJson", {
|
||||
@@ -1114,6 +1438,8 @@ export function ProviderForm({
|
||||
} else {
|
||||
payload.providerKey = opencodeProviderKey;
|
||||
}
|
||||
} else if (appId === "openclaw") {
|
||||
payload.providerKey = openclawProviderKey;
|
||||
}
|
||||
|
||||
if (category === "omo" && !payload.presetCategory) {
|
||||
@@ -1128,6 +1454,10 @@ export function ProviderForm({
|
||||
if (activePreset.isPartner) {
|
||||
payload.isPartner = activePreset.isPartner;
|
||||
}
|
||||
// OpenClaw: 传递预设的 suggestedDefaults 到提交数据
|
||||
if (activePreset.suggestedDefaults) {
|
||||
payload.suggestedDefaults = activePreset.suggestedDefaults;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isEditMode && draftCustomEndpoints.length > 0) {
|
||||
@@ -1268,6 +1598,21 @@ export function ProviderForm({
|
||||
formWebsiteUrl: form.watch("websiteUrl") || "",
|
||||
});
|
||||
|
||||
// 使用 API Key 链接 hook (OpenClaw)
|
||||
const {
|
||||
shouldShowApiKeyLink: shouldShowOpenclawApiKeyLink,
|
||||
websiteUrl: openclawWebsiteUrl,
|
||||
isPartner: isOpenclawPartner,
|
||||
partnerPromotionKey: openclawPartnerPromotionKey,
|
||||
} = useApiKeyLink({
|
||||
appId: "openclaw",
|
||||
category,
|
||||
selectedPresetId,
|
||||
presetEntries,
|
||||
formWebsiteUrl: form.watch("websiteUrl") || "",
|
||||
});
|
||||
|
||||
// 使用端点测速候选 hook
|
||||
const speedTestEndpoints = useSpeedTestEndpoints({
|
||||
appId,
|
||||
selectedPresetId,
|
||||
@@ -1299,6 +1644,14 @@ export function ProviderForm({
|
||||
setOpencodeExtraOptions({});
|
||||
resetOmoDraftState();
|
||||
}
|
||||
// OpenClaw 自定义模式:重置为空配置
|
||||
if (appId === "openclaw") {
|
||||
setOpenclawProviderKey("");
|
||||
setOpenclawBaseUrl("");
|
||||
setOpenclawApiKey("");
|
||||
setOpenclawApi("openai-completions");
|
||||
setOpenclawModels([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1382,6 +1735,40 @@ export function ProviderForm({
|
||||
return;
|
||||
}
|
||||
|
||||
// OpenClaw preset handling
|
||||
if (appId === "openclaw") {
|
||||
const preset = entry.preset as OpenClawProviderPreset;
|
||||
const config = preset.settingsConfig;
|
||||
|
||||
// Update activePreset with suggestedDefaults for OpenClaw
|
||||
setActivePreset({
|
||||
id: value,
|
||||
category: preset.category,
|
||||
isPartner: preset.isPartner,
|
||||
partnerPromotionKey: preset.partnerPromotionKey,
|
||||
suggestedDefaults: preset.suggestedDefaults,
|
||||
});
|
||||
|
||||
// Clear provider key (user must enter their own unique key)
|
||||
setOpenclawProviderKey("");
|
||||
|
||||
// Update OpenClaw-specific states
|
||||
setOpenclawBaseUrl(config.baseUrl || "");
|
||||
setOpenclawApiKey(config.apiKey || "");
|
||||
setOpenclawApi(config.api || "openai-completions");
|
||||
setOpenclawModels(config.models || []);
|
||||
|
||||
// Update form fields
|
||||
form.reset({
|
||||
name: preset.name,
|
||||
websiteUrl: preset.websiteUrl ?? "",
|
||||
settingsConfig: JSON.stringify(config, null, 2),
|
||||
icon: preset.icon ?? "",
|
||||
iconColor: preset.iconColor ?? "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = entry.preset as ProviderPreset;
|
||||
const config = applyTemplateValues(
|
||||
preset.settingsConfig,
|
||||
@@ -1486,6 +1873,54 @@ export function ProviderForm({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : appId === "openclaw" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openclaw-key">
|
||||
{t("openclaw.providerKey")}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="openclaw-key"
|
||||
value={openclawProviderKey}
|
||||
onChange={(e) =>
|
||||
setOpenclawProviderKey(
|
||||
e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""),
|
||||
)
|
||||
}
|
||||
placeholder={t("openclaw.providerKeyPlaceholder")}
|
||||
disabled={isEditMode}
|
||||
className={
|
||||
(existingOpenclawKeys.includes(openclawProviderKey) &&
|
||||
!isEditMode) ||
|
||||
(openclawProviderKey.trim() !== "" &&
|
||||
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(openclawProviderKey))
|
||||
? "border-destructive"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{existingOpenclawKeys.includes(openclawProviderKey) &&
|
||||
!isEditMode && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("openclaw.providerKeyDuplicate")}
|
||||
</p>
|
||||
)}
|
||||
{openclawProviderKey.trim() !== "" &&
|
||||
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(openclawProviderKey) && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("openclaw.providerKeyInvalid")}
|
||||
</p>
|
||||
)}
|
||||
{!(
|
||||
existingOpenclawKeys.includes(openclawProviderKey) &&
|
||||
!isEditMode
|
||||
) &&
|
||||
(openclawProviderKey.trim() === "" ||
|
||||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(openclawProviderKey)) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("openclaw.providerKeyHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
@@ -1611,6 +2046,7 @@ export function ProviderForm({
|
||||
<OmoFormFields
|
||||
modelOptions={omoModelOptions}
|
||||
modelVariantsMap={omoModelVariantsMap}
|
||||
presetMetaMap={omoPresetMetaMap}
|
||||
agents={omoAgents}
|
||||
onAgentsChange={setOmoAgents}
|
||||
categories={omoCategories}
|
||||
@@ -1620,6 +2056,26 @@ export function ProviderForm({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* OpenClaw 专属字段 */}
|
||||
{appId === "openclaw" && (
|
||||
<OpenClawFormFields
|
||||
baseUrl={openclawBaseUrl}
|
||||
onBaseUrlChange={handleOpenclawBaseUrlChange}
|
||||
apiKey={openclawApiKey}
|
||||
onApiKeyChange={handleOpenclawApiKeyChange}
|
||||
category={category}
|
||||
shouldShowApiKeyLink={shouldShowOpenclawApiKeyLink}
|
||||
websiteUrl={openclawWebsiteUrl}
|
||||
isPartner={isOpenclawPartner}
|
||||
partnerPromotionKey={openclawPartnerPromotionKey}
|
||||
api={openclawApi}
|
||||
onApiChange={handleOpenclawApiChange}
|
||||
models={openclawModels}
|
||||
onModelsChange={handleOpenclawModelsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */}
|
||||
{appId === "codex" ? (
|
||||
<>
|
||||
<CodexConfigEditor
|
||||
@@ -1696,6 +2152,34 @@ export function ProviderForm({
|
||||
</div>
|
||||
{settingsConfigErrorField}
|
||||
</>
|
||||
) : appId === "openclaw" ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settingsConfig">{t("provider.configJson")}</Label>
|
||||
<JsonEditor
|
||||
value={form.getValues("settingsConfig")}
|
||||
onChange={(config) => form.setValue("settingsConfig", config)}
|
||||
placeholder={`{
|
||||
"baseUrl": "https://api.example.com/v1",
|
||||
"apiKey": "your-api-key-here",
|
||||
"api": "openai-completions",
|
||||
"models": []
|
||||
}`}
|
||||
rows={14}
|
||||
showValidation={true}
|
||||
language="json"
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsConfig"
|
||||
render={() => (
|
||||
<FormItem className="space-y-0">
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CommonConfigEditor
|
||||
@@ -1745,5 +2229,6 @@ export type ProviderFormValues = ProviderFormData & {
|
||||
presetCategory?: ProviderCategory;
|
||||
isPartner?: boolean;
|
||||
meta?: ProviderMeta;
|
||||
providerKey?: string;
|
||||
providerKey?: string; // OpenCode/OpenClaw: user-defined provider key
|
||||
suggestedDefaults?: OpenClawSuggestedDefaults; // OpenClaw: suggested default model configuration
|
||||
};
|
||||
|
||||
@@ -4,9 +4,10 @@ import {
|
||||
setCodexBaseUrl as setCodexBaseUrlInConfig,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
|
||||
interface UseBaseUrlStateProps {
|
||||
appType: "claude" | "codex" | "gemini" | "opencode";
|
||||
appType: AppId;
|
||||
category: ProviderCategory | undefined;
|
||||
settingsConfig: string;
|
||||
codexConfig?: string;
|
||||
|
||||
@@ -20,6 +20,7 @@ const APP_CONFIG: Array<{
|
||||
{ id: "codex", icon: "openai", nameKey: "apps.codex" },
|
||||
{ id: "gemini", icon: "gemini", nameKey: "apps.gemini" },
|
||||
{ id: "opencode", icon: "opencode", nameKey: "apps.opencode" },
|
||||
{ id: "openclaw", icon: "openclaw", nameKey: "apps.openclaw" },
|
||||
];
|
||||
|
||||
export function AppVisibilitySettings({
|
||||
@@ -33,6 +34,7 @@ export function AppVisibilitySettings({
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
};
|
||||
|
||||
// Count how many apps are currently visible
|
||||
|
||||
@@ -10,6 +10,7 @@ export function RectifierConfigPanel() {
|
||||
const [config, setConfig] = useState<RectifierConfig>({
|
||||
enabled: true,
|
||||
requestThinkingSignature: true,
|
||||
requestThinkingBudget: true,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -69,6 +70,21 @@ export function RectifierConfigPanel() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pl-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label>{t("settings.advanced.rectifier.thinkingBudget")}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.advanced.rectifier.thinkingBudgetDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.requestThinkingBudget}
|
||||
disabled={!config.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({ requestThinkingBudget: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,6 +39,7 @@ import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSe
|
||||
import { TerminalSettings } from "@/components/settings/TerminalSettings";
|
||||
import { DirectorySettings } from "@/components/settings/DirectorySettings";
|
||||
import { ImportExportSection } from "@/components/settings/ImportExportSection";
|
||||
import { WebdavSyncSection } from "@/components/settings/WebdavSyncSection";
|
||||
import { AboutSection } from "@/components/settings/AboutSection";
|
||||
import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings";
|
||||
import { ProxyPanel } from "@/components/proxy";
|
||||
@@ -595,6 +596,11 @@ export function SettingsPage({
|
||||
onExport={exportConfig}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
<div className="pt-6">
|
||||
<WebdavSyncSection
|
||||
config={settings?.webdavSync}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
|
||||
@@ -0,0 +1,775 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Link2,
|
||||
UploadCloud,
|
||||
DownloadCloud,
|
||||
Loader2,
|
||||
Save,
|
||||
Check,
|
||||
Info,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import type { RemoteSnapshotInfo, WebDavSyncSettings } from "@/types";
|
||||
|
||||
// ─── WebDAV service presets ─────────────────────────────────
|
||||
|
||||
interface WebDavPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
baseUrl: string;
|
||||
hint: string;
|
||||
matchPattern?: string; // substring match on URL
|
||||
}
|
||||
|
||||
const WEBDAV_PRESETS: WebDavPreset[] = [
|
||||
{
|
||||
id: "jianguoyun",
|
||||
label: "settings.webdavSync.presets.jianguoyun",
|
||||
baseUrl: "https://dav.jianguoyun.com/dav/",
|
||||
hint: "settings.webdavSync.presets.jianguoyunHint",
|
||||
matchPattern: "jianguoyun.com",
|
||||
},
|
||||
{
|
||||
id: "nextcloud",
|
||||
label: "settings.webdavSync.presets.nextcloud",
|
||||
baseUrl: "https://your-server/remote.php/dav/files/USERNAME/",
|
||||
hint: "settings.webdavSync.presets.nextcloudHint",
|
||||
matchPattern: "remote.php/dav",
|
||||
},
|
||||
{
|
||||
id: "synology",
|
||||
label: "settings.webdavSync.presets.synology",
|
||||
baseUrl: "http://your-nas-ip:5005/",
|
||||
hint: "settings.webdavSync.presets.synologyHint",
|
||||
matchPattern: ":5005",
|
||||
},
|
||||
{
|
||||
id: "custom",
|
||||
label: "settings.webdavSync.presets.custom",
|
||||
baseUrl: "",
|
||||
hint: "",
|
||||
},
|
||||
];
|
||||
|
||||
/** Match a URL to one of the preset providers, or "custom". */
|
||||
function detectPreset(url: string): string {
|
||||
if (!url) return "custom";
|
||||
for (const preset of WEBDAV_PRESETS) {
|
||||
if (preset.matchPattern && url.includes(preset.matchPattern)) {
|
||||
return preset.id;
|
||||
}
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
/** Format an RFC 3339 date string for display; falls back to raw string. */
|
||||
function formatDate(rfc3339: string): string {
|
||||
const d = new Date(rfc3339);
|
||||
return Number.isNaN(d.getTime()) ? rfc3339 : d.toLocaleString();
|
||||
}
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────
|
||||
|
||||
type ActionState =
|
||||
| "idle"
|
||||
| "testing"
|
||||
| "saving"
|
||||
| "uploading"
|
||||
| "downloading"
|
||||
| "fetching_remote";
|
||||
|
||||
type DialogType = "upload" | "download" | null;
|
||||
|
||||
interface WebdavSyncSectionProps {
|
||||
config?: WebDavSyncSettings;
|
||||
}
|
||||
|
||||
// ─── ActionButton ───────────────────────────────────────────
|
||||
|
||||
/** Reusable button with loading spinner. */
|
||||
function ActionButton({
|
||||
actionState,
|
||||
targetState,
|
||||
alsoActiveFor,
|
||||
icon: Icon,
|
||||
activeLabel,
|
||||
idleLabel,
|
||||
disabled,
|
||||
...props
|
||||
}: {
|
||||
actionState: ActionState;
|
||||
targetState: ActionState;
|
||||
alsoActiveFor?: ActionState[];
|
||||
icon: LucideIcon;
|
||||
activeLabel: ReactNode;
|
||||
idleLabel: ReactNode;
|
||||
} & Omit<React.ComponentPropsWithoutRef<typeof Button>, "children">) {
|
||||
const isActive =
|
||||
actionState === targetState ||
|
||||
(alsoActiveFor?.includes(actionState) ?? false);
|
||||
return (
|
||||
<Button {...props} disabled={actionState !== "idle" || disabled}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{isActive ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{isActive ? activeLabel : idleLabel}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ─────────────────────────────────────────
|
||||
|
||||
export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [actionState, setActionState] = useState<ActionState>("idle");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [passwordTouched, setPasswordTouched] = useState(false);
|
||||
const [justSaved, setJustSaved] = useState(false);
|
||||
const justSavedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Local form state — credentials are only persisted on explicit "Save".
|
||||
const [form, setForm] = useState(() => ({
|
||||
baseUrl: config?.baseUrl ?? "",
|
||||
username: config?.username ?? "",
|
||||
password: config?.password ?? "",
|
||||
remoteRoot: config?.remoteRoot ?? "cc-switch-sync",
|
||||
profile: config?.profile ?? "default",
|
||||
}));
|
||||
|
||||
// Preset selector — derived from initial URL, updated on user selection
|
||||
const [presetId, setPresetId] = useState(() =>
|
||||
detectPreset(config?.baseUrl ?? ""),
|
||||
);
|
||||
|
||||
const activePreset = WEBDAV_PRESETS.find((p) => p.id === presetId);
|
||||
|
||||
// Confirmation dialog state
|
||||
const [dialogType, setDialogType] = useState<DialogType>(null);
|
||||
const [remoteInfo, setRemoteInfo] = useState<RemoteSnapshotInfo | null>(null);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
setDialogType(null);
|
||||
setRemoteInfo(null);
|
||||
}, []);
|
||||
|
||||
// Cleanup justSaved timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (justSavedTimerRef.current) clearTimeout(justSavedTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Sync form when config is loaded/updated from backend, but not while user is editing
|
||||
useEffect(() => {
|
||||
if (!config || dirty) return;
|
||||
setForm({
|
||||
baseUrl: config.baseUrl ?? "",
|
||||
username: config.username ?? "",
|
||||
password: config.password ?? "",
|
||||
remoteRoot: config.remoteRoot ?? "cc-switch-sync",
|
||||
profile: config.profile ?? "default",
|
||||
});
|
||||
setPasswordTouched(false);
|
||||
setPresetId(detectPreset(config.baseUrl ?? ""));
|
||||
}, [config, dirty]);
|
||||
|
||||
const updateField = useCallback((field: keyof typeof form, value: string) => {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
if (field === "password") {
|
||||
setPasswordTouched(true);
|
||||
}
|
||||
setDirty(true);
|
||||
setJustSaved(false);
|
||||
if (justSavedTimerRef.current) {
|
||||
clearTimeout(justSavedTimerRef.current);
|
||||
justSavedTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePresetChange = useCallback((id: string) => {
|
||||
setPresetId(id);
|
||||
const preset = WEBDAV_PRESETS.find((p) => p.id === id);
|
||||
if (preset?.baseUrl) {
|
||||
setForm((prev) => ({ ...prev, baseUrl: preset.baseUrl }));
|
||||
setDirty(true);
|
||||
setJustSaved(false);
|
||||
if (justSavedTimerRef.current) {
|
||||
clearTimeout(justSavedTimerRef.current);
|
||||
justSavedTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// When user edits the URL, check if it still matches the current preset on blur
|
||||
const handleBaseUrlBlur = useCallback(() => {
|
||||
if (presetId === "custom") return;
|
||||
const detected = detectPreset(form.baseUrl);
|
||||
if (detected !== presetId) {
|
||||
setPresetId("custom");
|
||||
}
|
||||
}, [form.baseUrl, presetId]);
|
||||
|
||||
const buildSettings = useCallback((): WebDavSyncSettings | null => {
|
||||
const baseUrl = form.baseUrl.trim();
|
||||
if (!baseUrl) return null;
|
||||
return {
|
||||
enabled: true,
|
||||
baseUrl,
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
remoteRoot: form.remoteRoot.trim() || "cc-switch-sync",
|
||||
profile: form.profile.trim() || "default",
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
// ─── Handlers ───────────────────────────────────────────
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
const settings = buildSettings();
|
||||
if (!settings) {
|
||||
toast.error(t("settings.webdavSync.missingUrl"));
|
||||
return;
|
||||
}
|
||||
setActionState("testing");
|
||||
try {
|
||||
await settingsApi.webdavTestConnection(settings, !passwordTouched);
|
||||
toast.success(t("settings.webdavSync.testSuccess"));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("settings.webdavSync.testFailed", {
|
||||
error: (error as Error)?.message ?? String(error),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setActionState("idle");
|
||||
}
|
||||
}, [buildSettings, passwordTouched, t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const settings = buildSettings();
|
||||
if (!settings) {
|
||||
toast.error(t("settings.webdavSync.missingUrl"));
|
||||
return;
|
||||
}
|
||||
setActionState("saving");
|
||||
try {
|
||||
await settingsApi.webdavSyncSaveSettings(settings, passwordTouched);
|
||||
setDirty(false);
|
||||
setPasswordTouched(false);
|
||||
// Show "saved" indicator for 2 seconds
|
||||
setJustSaved(true);
|
||||
if (justSavedTimerRef.current) clearTimeout(justSavedTimerRef.current);
|
||||
justSavedTimerRef.current = setTimeout(() => {
|
||||
setJustSaved(false);
|
||||
justSavedTimerRef.current = null;
|
||||
}, 2000);
|
||||
await queryClient.invalidateQueries();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("settings.webdavSync.saveFailed", {
|
||||
error: (error as Error)?.message ?? String(error),
|
||||
}),
|
||||
);
|
||||
setActionState("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-test connection after save
|
||||
setActionState("testing");
|
||||
try {
|
||||
await settingsApi.webdavTestConnection(settings, true);
|
||||
toast.success(t("settings.webdavSync.saveAndTestSuccess"));
|
||||
} catch (error) {
|
||||
toast.warning(
|
||||
t("settings.webdavSync.saveAndTestFailed", {
|
||||
error: (error as Error)?.message ?? String(error),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setActionState("idle");
|
||||
}
|
||||
}, [buildSettings, passwordTouched, queryClient, t]);
|
||||
|
||||
/** Fetch remote info, then open upload confirmation dialog. */
|
||||
const handleUploadClick = useCallback(async () => {
|
||||
if (dirty) {
|
||||
toast.error(t("settings.webdavSync.unsavedChanges"));
|
||||
return;
|
||||
}
|
||||
setActionState("fetching_remote");
|
||||
try {
|
||||
const info = await settingsApi.webdavSyncFetchRemoteInfo();
|
||||
if ("empty" in info) {
|
||||
setRemoteInfo(null);
|
||||
} else {
|
||||
setRemoteInfo(info);
|
||||
}
|
||||
setDialogType("upload");
|
||||
} catch {
|
||||
setRemoteInfo(null);
|
||||
toast.error(t("settings.webdavSync.fetchRemoteFailed"));
|
||||
setActionState("idle");
|
||||
return;
|
||||
}
|
||||
setActionState("idle");
|
||||
}, [dirty, t]);
|
||||
|
||||
/** Actually perform the upload after user confirms. */
|
||||
const handleUploadConfirm = useCallback(async () => {
|
||||
if (dirty) {
|
||||
toast.error(t("settings.webdavSync.unsavedChanges"));
|
||||
return;
|
||||
}
|
||||
closeDialog();
|
||||
setActionState("uploading");
|
||||
try {
|
||||
await settingsApi.webdavSyncUpload();
|
||||
toast.success(t("settings.webdavSync.uploadSuccess"));
|
||||
await queryClient.invalidateQueries();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("settings.webdavSync.uploadFailed", {
|
||||
error: (error as Error)?.message ?? String(error),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setActionState("idle");
|
||||
}
|
||||
}, [closeDialog, dirty, queryClient, t]);
|
||||
|
||||
/** Fetch remote info, then open download confirmation dialog. */
|
||||
const handleDownloadClick = useCallback(async () => {
|
||||
if (dirty) {
|
||||
toast.error(t("settings.webdavSync.unsavedChanges"));
|
||||
return;
|
||||
}
|
||||
setActionState("fetching_remote");
|
||||
try {
|
||||
const info = await settingsApi.webdavSyncFetchRemoteInfo();
|
||||
if ("empty" in info) {
|
||||
toast.info(t("settings.webdavSync.noRemoteData"));
|
||||
return;
|
||||
}
|
||||
if (!info.compatible) {
|
||||
toast.error(
|
||||
t("settings.webdavSync.incompatibleVersion", {
|
||||
version: info.version,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setRemoteInfo(info);
|
||||
setDialogType("download");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("settings.webdavSync.downloadFailed", {
|
||||
error: (error as Error)?.message ?? String(error),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setActionState("idle");
|
||||
}
|
||||
}, [dirty, t]);
|
||||
|
||||
/** Actually perform the download after user confirms. */
|
||||
const handleDownloadConfirm = useCallback(async () => {
|
||||
if (dirty) {
|
||||
toast.error(t("settings.webdavSync.unsavedChanges"));
|
||||
return;
|
||||
}
|
||||
closeDialog();
|
||||
setActionState("downloading");
|
||||
try {
|
||||
await settingsApi.webdavSyncDownload();
|
||||
toast.success(t("settings.webdavSync.downloadSuccess"));
|
||||
await queryClient.invalidateQueries();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("settings.webdavSync.downloadFailed", {
|
||||
error: (error as Error)?.message ?? String(error),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setActionState("idle");
|
||||
}
|
||||
}, [closeDialog, dirty, queryClient, t]);
|
||||
|
||||
// ─── Derived state ──────────────────────────────────────
|
||||
|
||||
const isLoading = actionState !== "idle";
|
||||
const hasSavedConfig = Boolean(
|
||||
config?.baseUrl?.trim() && config?.username?.trim(),
|
||||
);
|
||||
|
||||
const lastSyncAt = config?.status?.lastSyncAt;
|
||||
const lastSyncDisplay = lastSyncAt
|
||||
? new Date(lastSyncAt * 1000).toLocaleString()
|
||||
: null;
|
||||
|
||||
// ─── Render ─────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{t("settings.webdavSync.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.webdavSync.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-border bg-muted/40 p-6">
|
||||
{/* Config fields */}
|
||||
<div className="space-y-3">
|
||||
{/* Service preset selector */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-40 text-xs font-medium text-foreground shrink-0">
|
||||
{t("settings.webdavSync.presets.label")}
|
||||
</label>
|
||||
<Select
|
||||
value={presetId}
|
||||
onValueChange={handlePresetChange}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className="text-xs flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WEBDAV_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.id} value={preset.id}>
|
||||
{t(preset.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Server URL */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-40 text-xs font-medium text-foreground shrink-0">
|
||||
{t("settings.webdavSync.baseUrl")}
|
||||
</label>
|
||||
<Input
|
||||
value={form.baseUrl}
|
||||
onChange={(e) => updateField("baseUrl", e.target.value)}
|
||||
onBlur={handleBaseUrlBlur}
|
||||
placeholder={t("settings.webdavSync.baseUrlPlaceholder")}
|
||||
className="text-xs flex-1"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-40 text-xs font-medium text-foreground shrink-0">
|
||||
{t("settings.webdavSync.username")}
|
||||
</label>
|
||||
<Input
|
||||
value={form.username}
|
||||
onChange={(e) => updateField("username", e.target.value)}
|
||||
placeholder={t("settings.webdavSync.usernamePlaceholder")}
|
||||
className="text-xs flex-1"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-40 text-xs font-medium text-foreground shrink-0">
|
||||
{t("settings.webdavSync.password")}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => updateField("password", e.target.value)}
|
||||
placeholder={t("settings.webdavSync.passwordPlaceholder")}
|
||||
className="text-xs flex-1"
|
||||
autoComplete="off"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Preset hint */}
|
||||
{activePreset?.hint && (
|
||||
<div className="flex items-start gap-2 pl-44 text-xs text-muted-foreground">
|
||||
<Info className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
||||
<span>{t(activePreset.hint)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remote Root */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-40 text-xs font-medium text-foreground shrink-0">
|
||||
{t("settings.webdavSync.remoteRoot")}
|
||||
<span className="block text-[10px] font-normal text-muted-foreground">
|
||||
{t("settings.webdavSync.remoteRootDefault")}
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
value={form.remoteRoot}
|
||||
onChange={(e) => updateField("remoteRoot", e.target.value)}
|
||||
placeholder="cc-switch-sync"
|
||||
className="text-xs flex-1"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Profile */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-40 text-xs font-medium text-foreground shrink-0">
|
||||
{t("settings.webdavSync.profile")}
|
||||
<span className="block text-[10px] font-normal text-muted-foreground">
|
||||
{t("settings.webdavSync.profileDefault")}
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
value={form.profile}
|
||||
onChange={(e) => updateField("profile", e.target.value)}
|
||||
placeholder="default"
|
||||
className="text-xs flex-1"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last sync time */}
|
||||
{lastSyncDisplay && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.webdavSync.lastSync", { time: lastSyncDisplay })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Config buttons + save status */}
|
||||
<div className="flex flex-wrap items-center gap-3 pt-2">
|
||||
<ActionButton
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTest}
|
||||
actionState={actionState}
|
||||
targetState="testing"
|
||||
icon={Link2}
|
||||
activeLabel={t("settings.webdavSync.testing")}
|
||||
idleLabel={t("settings.webdavSync.test")}
|
||||
/>
|
||||
<ActionButton
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
actionState={actionState}
|
||||
targetState="saving"
|
||||
icon={Save}
|
||||
activeLabel={t("settings.webdavSync.saving")}
|
||||
idleLabel={t("settings.webdavSync.save")}
|
||||
/>
|
||||
|
||||
{/* Save status indicator */}
|
||||
{dirty && (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-amber-500 dark:text-amber-400 animate-in fade-in duration-200">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-500 dark:bg-amber-400" />
|
||||
{t("settings.webdavSync.unsaved")}
|
||||
</span>
|
||||
)}
|
||||
{!dirty && justSaved && (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400 animate-in fade-in duration-200">
|
||||
<Check className="h-3 w-3" />
|
||||
{t("settings.webdavSync.saved")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sync buttons */}
|
||||
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-4">
|
||||
<ActionButton
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleUploadClick}
|
||||
disabled={!hasSavedConfig}
|
||||
actionState={actionState}
|
||||
targetState="uploading"
|
||||
alsoActiveFor={["fetching_remote"]}
|
||||
icon={UploadCloud}
|
||||
activeLabel={
|
||||
actionState === "fetching_remote"
|
||||
? t("settings.webdavSync.fetchingRemote")
|
||||
: t("settings.webdavSync.uploading")
|
||||
}
|
||||
idleLabel={t("settings.webdavSync.upload")}
|
||||
/>
|
||||
<ActionButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleDownloadClick}
|
||||
disabled={!hasSavedConfig}
|
||||
actionState={actionState}
|
||||
targetState="downloading"
|
||||
alsoActiveFor={["fetching_remote"]}
|
||||
icon={DownloadCloud}
|
||||
activeLabel={
|
||||
actionState === "fetching_remote"
|
||||
? t("settings.webdavSync.fetchingRemote")
|
||||
: t("settings.webdavSync.downloading")
|
||||
}
|
||||
idleLabel={t("settings.webdavSync.download")}
|
||||
/>
|
||||
</div>
|
||||
{!hasSavedConfig && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.webdavSync.saveBeforeSync")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── Upload confirmation dialog ──────────────────── */}
|
||||
<Dialog
|
||||
open={dialogType === "upload"}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeDialog();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-sm" zIndex="alert">
|
||||
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
{t("settings.webdavSync.confirmUpload.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3 text-sm leading-relaxed">
|
||||
<p>{t("settings.webdavSync.confirmUpload.content")}</p>
|
||||
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
|
||||
<li>{t("settings.webdavSync.confirmUpload.dbItem")}</li>
|
||||
<li>{t("settings.webdavSync.confirmUpload.skillsItem")}</li>
|
||||
</ul>
|
||||
<p className="text-muted-foreground">
|
||||
{t("settings.webdavSync.confirmUpload.targetPath")}
|
||||
{": "}
|
||||
<code className="ml-1 text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
/{form.remoteRoot.trim() || "cc-switch-sync"}/v2/
|
||||
{form.profile.trim() || "default"}
|
||||
</code>
|
||||
</p>
|
||||
{remoteInfo && (
|
||||
<div className="rounded-lg border border-border bg-muted/50 p-3 space-y-2">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{t("settings.webdavSync.confirmUpload.existingData")}
|
||||
</p>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-xs text-muted-foreground">
|
||||
<dt className="font-medium text-foreground">
|
||||
{t("settings.webdavSync.confirmUpload.deviceName")}
|
||||
</dt>
|
||||
<dd>
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded">
|
||||
{remoteInfo.deviceName}
|
||||
</code>
|
||||
</dd>
|
||||
<dt className="font-medium text-foreground">
|
||||
{t("settings.webdavSync.confirmUpload.createdAt")}
|
||||
</dt>
|
||||
<dd>{formatDate(remoteInfo.createdAt)}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
{remoteInfo && (
|
||||
<p className="text-destructive font-medium">
|
||||
{t("settings.webdavSync.confirmUpload.warning")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleUploadConfirm}>
|
||||
{t("settings.webdavSync.confirmUpload.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ─── Download confirmation dialog ────────────────── */}
|
||||
<Dialog
|
||||
open={dialogType === "download"}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeDialog();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-sm" zIndex="alert">
|
||||
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
{t("settings.webdavSync.confirmDownload.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3 text-sm leading-relaxed">
|
||||
{remoteInfo && (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-muted-foreground">
|
||||
<dt className="font-medium text-foreground">
|
||||
{t("settings.webdavSync.confirmDownload.deviceName")}
|
||||
</dt>
|
||||
<dd>
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{remoteInfo.deviceName}
|
||||
</code>
|
||||
</dd>
|
||||
<dt className="font-medium text-foreground">
|
||||
{t("settings.webdavSync.confirmDownload.createdAt")}
|
||||
</dt>
|
||||
<dd>{formatDate(remoteInfo.createdAt)}</dd>
|
||||
<dt className="font-medium text-foreground">
|
||||
{t("settings.webdavSync.confirmDownload.artifacts")}
|
||||
</dt>
|
||||
<dd>{remoteInfo.artifacts.join(", ")}</dd>
|
||||
</dl>
|
||||
)}
|
||||
<p className="text-destructive font-medium">
|
||||
{t("settings.webdavSync.confirmDownload.warning")}
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDownloadConfirm}>
|
||||
{t("settings.webdavSync.confirmDownload.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
const installFromZipMutation = useInstallSkillsFromZip();
|
||||
|
||||
const enabledCounts = useMemo(() => {
|
||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
|
||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
|
||||
if (!skills) return counts;
|
||||
skills.forEach((skill) => {
|
||||
for (const app of APP_IDS) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { Search } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
className="flex items-center border-b px-3 focus-within:outline-none focus-within:ring-0"
|
||||
cmdk-input-wrapper=""
|
||||
>
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md bg-transparent py-3 text-sm outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ComponentRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "start", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { workspaceApi } from "@/lib/api/workspace";
|
||||
|
||||
interface WorkspaceFileEditorProps {
|
||||
filename: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const WorkspaceFileEditor: React.FC<WorkspaceFileEditorProps> = ({
|
||||
filename,
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [content, setContent] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !filename) return;
|
||||
|
||||
setLoading(true);
|
||||
workspaceApi
|
||||
.readFile(filename)
|
||||
.then((data) => {
|
||||
setContent(data ?? "");
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to read workspace file:", err);
|
||||
toast.error(t("workspace.loadFailed"));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [isOpen, filename, t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await workspaceApi.writeFile(filename, content);
|
||||
toast.success(t("workspace.saveSuccess"));
|
||||
} catch (err) {
|
||||
console.error("Failed to save workspace file:", err);
|
||||
toast.error(t("workspace.saveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [filename, content, t]);
|
||||
|
||||
return (
|
||||
<FullScreenPanel
|
||||
isOpen={isOpen}
|
||||
title={t("workspace.editing", { filename })}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<Button onClick={handleSave} disabled={saving || loading}>
|
||||
{saving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
{t("prompts.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
darkMode={isDarkMode}
|
||||
placeholder={`# ${filename}\n\n...`}
|
||||
minHeight="calc(100vh - 240px)"
|
||||
/>
|
||||
)}
|
||||
</FullScreenPanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceFileEditor;
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
FileCode,
|
||||
Heart,
|
||||
User,
|
||||
IdCard,
|
||||
Wrench,
|
||||
Brain,
|
||||
Activity,
|
||||
Rocket,
|
||||
Power,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { workspaceApi } from "@/lib/api/workspace";
|
||||
import WorkspaceFileEditor from "./WorkspaceFileEditor";
|
||||
|
||||
interface WorkspaceFile {
|
||||
filename: string;
|
||||
icon: LucideIcon;
|
||||
descKey: string;
|
||||
}
|
||||
|
||||
const WORKSPACE_FILES: WorkspaceFile[] = [
|
||||
{ filename: "AGENTS.md", icon: FileCode, descKey: "workspace.files.agents" },
|
||||
{ filename: "SOUL.md", icon: Heart, descKey: "workspace.files.soul" },
|
||||
{ filename: "USER.md", icon: User, descKey: "workspace.files.user" },
|
||||
{
|
||||
filename: "IDENTITY.md",
|
||||
icon: IdCard,
|
||||
descKey: "workspace.files.identity",
|
||||
},
|
||||
{ filename: "TOOLS.md", icon: Wrench, descKey: "workspace.files.tools" },
|
||||
{ filename: "MEMORY.md", icon: Brain, descKey: "workspace.files.memory" },
|
||||
{
|
||||
filename: "HEARTBEAT.md",
|
||||
icon: Activity,
|
||||
descKey: "workspace.files.heartbeat",
|
||||
},
|
||||
{
|
||||
filename: "BOOTSTRAP.md",
|
||||
icon: Rocket,
|
||||
descKey: "workspace.files.bootstrap",
|
||||
},
|
||||
{ filename: "BOOT.md", icon: Power, descKey: "workspace.files.boot" },
|
||||
];
|
||||
|
||||
const WorkspaceFilesPanel: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [editingFile, setEditingFile] = useState<string | null>(null);
|
||||
const [fileExists, setFileExists] = useState<Record<string, boolean>>({});
|
||||
|
||||
const checkFileExistence = async () => {
|
||||
const results: Record<string, boolean> = {};
|
||||
await Promise.all(
|
||||
WORKSPACE_FILES.map(async (f) => {
|
||||
try {
|
||||
const content = await workspaceApi.readFile(f.filename);
|
||||
results[f.filename] = content !== null;
|
||||
} catch {
|
||||
results[f.filename] = false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
setFileExists(results);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void checkFileExistence();
|
||||
}, []);
|
||||
|
||||
const handleEditorClose = () => {
|
||||
setEditingFile(null);
|
||||
// Re-check file existence after closing editor (file may have been created)
|
||||
void checkFileExistence();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-6 pt-4 pb-8">
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
~/.openclaw/workspace/
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{WORKSPACE_FILES.map((file) => {
|
||||
const Icon = file.icon;
|
||||
const exists = fileExists[file.filename];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={file.filename}
|
||||
onClick={() => setEditingFile(file.filename)}
|
||||
className="flex items-start gap-3 p-4 rounded-xl border border-border bg-card hover:bg-accent/50 transition-colors text-left group"
|
||||
>
|
||||
<div className="mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-foreground">
|
||||
{file.filename}
|
||||
</span>
|
||||
{exists ? (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-500 flex-shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-3.5 h-3.5 text-muted-foreground/40 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t(file.descKey)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<WorkspaceFileEditor
|
||||
filename={editingFile ?? ""}
|
||||
isOpen={!!editingFile}
|
||||
onClose={handleEditorClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceFilesPanel;
|
||||
@@ -1,6 +1,11 @@
|
||||
import React from "react";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons";
|
||||
import {
|
||||
ClaudeIcon,
|
||||
CodexIcon,
|
||||
GeminiIcon,
|
||||
OpenClawIcon,
|
||||
} from "@/components/BrandIcons";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
|
||||
export interface AppConfig {
|
||||
@@ -10,7 +15,13 @@ export interface AppConfig {
|
||||
badgeClass: string;
|
||||
}
|
||||
|
||||
export const APP_IDS: AppId[] = ["claude", "codex", "gemini", "opencode"];
|
||||
export const APP_IDS: AppId[] = [
|
||||
"claude",
|
||||
"codex",
|
||||
"gemini",
|
||||
"opencode",
|
||||
"openclaw",
|
||||
];
|
||||
|
||||
export const APP_ICON_MAP: Record<AppId, AppConfig> = {
|
||||
claude: {
|
||||
@@ -52,4 +63,12 @@ export const APP_ICON_MAP: Record<AppId, AppConfig> = {
|
||||
badgeClass:
|
||||
"bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-500/20 border-0 gap-1.5",
|
||||
},
|
||||
openclaw: {
|
||||
label: "OpenClaw",
|
||||
icon: <OpenClawIcon size={14} />,
|
||||
activeClass:
|
||||
"bg-rose-500/10 ring-1 ring-rose-500/20 hover:bg-rose-500/20 text-rose-600 dark:text-rose-400",
|
||||
badgeClass:
|
||||
"bg-rose-500/10 text-rose-700 dark:text-rose-300 hover:bg-rose-500/20 border-0 gap-1.5",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -99,8 +99,6 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "zhipu", // 促销信息 i18n key
|
||||
icon: "zhipu",
|
||||
iconColor: "#0F62FE",
|
||||
},
|
||||
@@ -119,8 +117,6 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "zhipu", // 促销信息 i18n key
|
||||
icon: "zhipu",
|
||||
iconColor: "#0F62FE",
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,384 @@ export const opencodeNpmPackages = [
|
||||
{ value: "@ai-sdk/google", label: "Google (Gemini)" },
|
||||
] as const;
|
||||
|
||||
export interface PresetModelVariant {
|
||||
id: string;
|
||||
name?: string;
|
||||
contextLimit?: number;
|
||||
outputLimit?: number;
|
||||
modalities?: { input: string[]; output: string[] };
|
||||
options?: Record<string, unknown>;
|
||||
variants?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export const OPENCODE_PRESET_MODEL_VARIANTS: Record<
|
||||
string,
|
||||
PresetModelVariant[]
|
||||
> = {
|
||||
"@ai-sdk/openai-compatible": [
|
||||
{
|
||||
id: "MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
contextLimit: 204800,
|
||||
outputLimit: 131072,
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
},
|
||||
{
|
||||
id: "glm-4.7",
|
||||
name: "GLM 4.7",
|
||||
contextLimit: 204800,
|
||||
outputLimit: 131072,
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
},
|
||||
{
|
||||
id: "kimi-k2.5",
|
||||
name: "Kimi K2.5",
|
||||
contextLimit: 262144,
|
||||
outputLimit: 262144,
|
||||
modalities: { input: ["text", "image", "video"], output: ["text"] },
|
||||
},
|
||||
],
|
||||
"@ai-sdk/google": [
|
||||
{
|
||||
id: "gemini-2.5-flash-lite",
|
||||
name: "Gemini 2.5 Flash Lite",
|
||||
contextLimit: 1048576,
|
||||
outputLimit: 65536,
|
||||
modalities: {
|
||||
input: ["text", "image", "pdf", "video", "audio"],
|
||||
output: ["text"],
|
||||
},
|
||||
variants: {
|
||||
auto: {
|
||||
thinkingConfig: { includeThoughts: true, thinkingBudget: -1 },
|
||||
},
|
||||
"no-thinking": { thinkingConfig: { thinkingBudget: 0 } },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gemini-3-flash-preview",
|
||||
name: "Gemini 3 Flash Preview",
|
||||
contextLimit: 1048576,
|
||||
outputLimit: 65536,
|
||||
modalities: {
|
||||
input: ["text", "image", "pdf", "video", "audio"],
|
||||
output: ["text"],
|
||||
},
|
||||
variants: {
|
||||
minimal: {
|
||||
thinkingConfig: { includeThoughts: true, thinkingLevel: "minimal" },
|
||||
},
|
||||
low: {
|
||||
thinkingConfig: { includeThoughts: true, thinkingLevel: "low" },
|
||||
},
|
||||
medium: {
|
||||
thinkingConfig: { includeThoughts: true, thinkingLevel: "medium" },
|
||||
},
|
||||
high: {
|
||||
thinkingConfig: { includeThoughts: true, thinkingLevel: "high" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gemini-3-pro-preview",
|
||||
name: "Gemini 3 Pro Preview",
|
||||
contextLimit: 1048576,
|
||||
outputLimit: 65536,
|
||||
modalities: {
|
||||
input: ["text", "image", "pdf", "video", "audio"],
|
||||
output: ["text"],
|
||||
},
|
||||
variants: {
|
||||
low: {
|
||||
thinkingConfig: { includeThoughts: true, thinkingLevel: "low" },
|
||||
},
|
||||
high: {
|
||||
thinkingConfig: { includeThoughts: true, thinkingLevel: "high" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"@ai-sdk/openai": [
|
||||
{
|
||||
id: "gpt-5",
|
||||
name: "GPT-5",
|
||||
contextLimit: 400000,
|
||||
outputLimit: 128000,
|
||||
modalities: { input: ["text", "image"], output: ["text"] },
|
||||
variants: {
|
||||
low: {
|
||||
reasoningEffort: "low",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "low",
|
||||
},
|
||||
medium: {
|
||||
reasoningEffort: "medium",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
},
|
||||
high: {
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "high",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gpt-5.1",
|
||||
name: "GPT-5.1",
|
||||
contextLimit: 400000,
|
||||
outputLimit: 272000,
|
||||
modalities: { input: ["text", "image"], output: ["text"] },
|
||||
variants: {
|
||||
low: {
|
||||
reasoningEffort: "low",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "low",
|
||||
},
|
||||
medium: {
|
||||
reasoningEffort: "medium",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
},
|
||||
high: {
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "high",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gpt-5.1-codex",
|
||||
name: "GPT-5.1 Codex",
|
||||
contextLimit: 400000,
|
||||
outputLimit: 128000,
|
||||
modalities: { input: ["text", "image"], output: ["text"] },
|
||||
options: { include: ["reasoning.encrypted_content"], store: false },
|
||||
variants: {
|
||||
low: {
|
||||
reasoningEffort: "low",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
},
|
||||
medium: {
|
||||
reasoningEffort: "medium",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
},
|
||||
high: {
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gpt-5.1-codex-max",
|
||||
name: "GPT-5.1 Codex Max",
|
||||
contextLimit: 400000,
|
||||
outputLimit: 128000,
|
||||
modalities: { input: ["text", "image"], output: ["text"] },
|
||||
options: { include: ["reasoning.encrypted_content"], store: false },
|
||||
variants: {
|
||||
low: {
|
||||
reasoningEffort: "low",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
},
|
||||
medium: {
|
||||
reasoningEffort: "medium",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
},
|
||||
high: {
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
},
|
||||
xhigh: {
|
||||
reasoningEffort: "xhigh",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gpt-5.2",
|
||||
name: "GPT-5.2",
|
||||
contextLimit: 400000,
|
||||
outputLimit: 128000,
|
||||
modalities: { input: ["text", "image"], output: ["text"] },
|
||||
variants: {
|
||||
low: {
|
||||
reasoningEffort: "low",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
},
|
||||
medium: {
|
||||
reasoningEffort: "medium",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
},
|
||||
high: {
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
},
|
||||
xhigh: {
|
||||
reasoningEffort: "xhigh",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gpt-5.2-codex",
|
||||
name: "GPT-5.2 Codex",
|
||||
contextLimit: 400000,
|
||||
outputLimit: 128000,
|
||||
modalities: { input: ["text", "image"], output: ["text"] },
|
||||
options: { include: ["reasoning.encrypted_content"], store: false },
|
||||
variants: {
|
||||
low: {
|
||||
reasoningEffort: "low",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
},
|
||||
medium: {
|
||||
reasoningEffort: "medium",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
},
|
||||
high: {
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
},
|
||||
xhigh: {
|
||||
reasoningEffort: "xhigh",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gpt-5.3-codex",
|
||||
name: "GPT-5.3 Codex",
|
||||
contextLimit: 400000,
|
||||
outputLimit: 128000,
|
||||
modalities: { input: ["text", "image"], output: ["text"] },
|
||||
options: { include: ["reasoning.encrypted_content"], store: false },
|
||||
variants: {
|
||||
low: {
|
||||
reasoningEffort: "low",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
},
|
||||
medium: {
|
||||
reasoningEffort: "medium",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
},
|
||||
high: {
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
},
|
||||
xhigh: {
|
||||
reasoningEffort: "xhigh",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"@ai-sdk/anthropic": [
|
||||
{
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
name: "Claude Sonnet 4.5",
|
||||
contextLimit: 200000,
|
||||
outputLimit: 64000,
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
variants: {
|
||||
low: { effort: "low" },
|
||||
medium: { effort: "medium" },
|
||||
high: { effort: "high" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-5-20251101",
|
||||
name: "Claude Opus 4.5",
|
||||
contextLimit: 200000,
|
||||
outputLimit: 64000,
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
variants: {
|
||||
low: { thinking: { budgetTokens: 5000, type: "enabled" } },
|
||||
medium: { thinking: { budgetTokens: 13000, type: "enabled" } },
|
||||
high: { thinking: { budgetTokens: 18000, type: "enabled" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
contextLimit: 1000000,
|
||||
outputLimit: 128000,
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
variants: {
|
||||
low: { effort: "low" },
|
||||
medium: { effort: "medium" },
|
||||
high: { effort: "high" },
|
||||
max: { effort: "max" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "claude-haiku-4-5-20251001",
|
||||
name: "Claude Haiku 4.5",
|
||||
contextLimit: 200000,
|
||||
outputLimit: 64000,
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
},
|
||||
{
|
||||
id: "gemini-claude-opus-4-5-thinking",
|
||||
name: "Antigravity - Claude Opus 4.5",
|
||||
contextLimit: 200000,
|
||||
outputLimit: 64000,
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
variants: {
|
||||
low: { effort: "low" },
|
||||
medium: { effort: "medium" },
|
||||
high: { effort: "high" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gemini-claude-sonnet-4-5-thinking",
|
||||
name: "Antigravity - Claude Sonnet 4.5",
|
||||
contextLimit: 200000,
|
||||
outputLimit: 64000,
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
variants: {
|
||||
low: { thinking: { budgetTokens: 5000, type: "enabled" } },
|
||||
medium: { thinking: { budgetTokens: 13000, type: "enabled" } },
|
||||
high: { thinking: { budgetTokens: 18000, type: "enabled" } },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Look up preset metadata for a model by npm package and model ID.
|
||||
* Returns enrichment fields (options, limit, modalities) that can be
|
||||
* merged into a model definition when the user's config doesn't already
|
||||
* provide them.
|
||||
*/
|
||||
export function getPresetModelDefaults(
|
||||
npm: string,
|
||||
modelId: string,
|
||||
): PresetModelVariant | undefined {
|
||||
const models = OPENCODE_PRESET_MODEL_VARIANTS[npm];
|
||||
if (!models) return undefined;
|
||||
return models.find((m) => m.id === modelId);
|
||||
}
|
||||
|
||||
export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
{
|
||||
name: "DeepSeek",
|
||||
@@ -67,8 +445,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "zhipu",
|
||||
icon: "zhipu",
|
||||
iconColor: "#0F62FE",
|
||||
templateValues: {
|
||||
@@ -101,8 +477,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "zhipu",
|
||||
icon: "zhipu",
|
||||
iconColor: "#0F62FE",
|
||||
templateValues: {
|
||||
@@ -470,7 +844,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -497,7 +871,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -524,7 +898,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4.5": { name: "Claude Sonnet 4.5" },
|
||||
"anthropic/claude-opus-4.5": { name: "Claude Opus 4.5" },
|
||||
"anthropic/claude-opus-4.6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -578,7 +952,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
@@ -606,7 +980,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
@@ -635,7 +1009,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
@@ -664,7 +1038,10 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"gpt-5.2": { name: "GPT-5.2" },
|
||||
"gpt-5.2-codex": { name: "GPT-5.2 Codex" },
|
||||
"gpt-5.2-codex": {
|
||||
name: "GPT-5.2 Codex",
|
||||
options: { include: ["reasoning.encrypted_content"], store: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
@@ -693,7 +1070,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4.5": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4.5": { name: "Claude Opus 4.5" },
|
||||
"claude-opus-4.6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { openclawApi } from "@/lib/api/openclaw";
|
||||
import { providersApi } from "@/lib/api/providers";
|
||||
import type {
|
||||
OpenClawEnvConfig,
|
||||
OpenClawToolsConfig,
|
||||
OpenClawAgentsDefaults,
|
||||
} from "@/types";
|
||||
|
||||
/**
|
||||
* Centralized query keys for all OpenClaw-related queries.
|
||||
* Import this from any file that needs to invalidate OpenClaw caches.
|
||||
*/
|
||||
export const openclawKeys = {
|
||||
all: ["openclaw"] as const,
|
||||
liveProviderIds: ["openclaw", "liveProviderIds"] as const,
|
||||
defaultModel: ["openclaw", "defaultModel"] as const,
|
||||
env: ["openclaw", "env"] as const,
|
||||
tools: ["openclaw", "tools"] as const,
|
||||
agentsDefaults: ["openclaw", "agentsDefaults"] as const,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Query hooks
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Query live provider IDs from openclaw.json config.
|
||||
* Used by ProviderList to show "In Config" badge.
|
||||
*/
|
||||
export function useOpenClawLiveProviderIds(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: openclawKeys.liveProviderIds,
|
||||
queryFn: () => providersApi.getOpenClawLiveProviderIds(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the default model from agents.defaults.model.
|
||||
* Used by ProviderList to show which provider is the default.
|
||||
*/
|
||||
export function useOpenClawDefaultModel(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: openclawKeys.defaultModel,
|
||||
queryFn: () => openclawApi.getDefaultModel(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query env section of openclaw.json.
|
||||
*/
|
||||
export function useOpenClawEnv() {
|
||||
return useQuery({
|
||||
queryKey: openclawKeys.env,
|
||||
queryFn: () => openclawApi.getEnv(),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query tools section of openclaw.json.
|
||||
*/
|
||||
export function useOpenClawTools() {
|
||||
return useQuery({
|
||||
queryKey: openclawKeys.tools,
|
||||
queryFn: () => openclawApi.getTools(),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query agents.defaults section of openclaw.json.
|
||||
*/
|
||||
export function useOpenClawAgentsDefaults() {
|
||||
return useQuery({
|
||||
queryKey: openclawKeys.agentsDefaults,
|
||||
queryFn: () => openclawApi.getAgentsDefaults(),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Mutation hooks
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Save env config. Invalidates env query on success.
|
||||
* Toast notifications are handled by the component.
|
||||
*/
|
||||
export function useSaveOpenClawEnv() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (env: OpenClawEnvConfig) => openclawApi.setEnv(env),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: openclawKeys.env });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save tools config. Invalidates tools query on success.
|
||||
* Toast notifications are handled by the component.
|
||||
*/
|
||||
export function useSaveOpenClawTools() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (tools: OpenClawToolsConfig) => openclawApi.setTools(tools),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: openclawKeys.tools });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save agents.defaults config. Invalidates both agentsDefaults and defaultModel
|
||||
* queries on success (since changing agents.defaults may affect the default model).
|
||||
* Toast notifications are handled by the component.
|
||||
*/
|
||||
export function useSaveOpenClawAgentsDefaults() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (defaults: OpenClawAgentsDefaults) =>
|
||||
openclawApi.setAgentsDefaults(defaults),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: openclawKeys.agentsDefaults });
|
||||
queryClient.invalidateQueries({ queryKey: openclawKeys.defaultModel });
|
||||
},
|
||||
});
|
||||
}
|
||||
+113
-13
@@ -2,9 +2,15 @@ import { useCallback } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { providersApi, settingsApi, type AppId } from "@/lib/api";
|
||||
import { providersApi, settingsApi, openclawApi, type AppId } from "@/lib/api";
|
||||
import { proxyApi } from "@/lib/api/proxy";
|
||||
import type { Provider, UsageScript } from "@/types";
|
||||
import type {
|
||||
Provider,
|
||||
UsageScript,
|
||||
OpenClawProviderConfig,
|
||||
OpenClawDefaultModel,
|
||||
} from "@/types";
|
||||
import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets";
|
||||
import {
|
||||
useAddProviderMutation,
|
||||
useUpdateProviderMutation,
|
||||
@@ -13,6 +19,7 @@ import {
|
||||
} from "@/lib/query";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { extractProviderBaseUrl } from "@/utils/providerBaseUrl";
|
||||
import { openclawKeys } from "@/hooks/useOpenClaw";
|
||||
|
||||
interface UseProviderActionsOptions {
|
||||
/** 代理服务是否正在运行 */
|
||||
@@ -67,10 +74,55 @@ export function useProviderActions(
|
||||
|
||||
// 添加供应商
|
||||
const addProvider = useCallback(
|
||||
async (provider: Omit<Provider, "id"> & { providerKey?: string }) => {
|
||||
async (
|
||||
provider: Omit<Provider, "id"> & {
|
||||
providerKey?: string;
|
||||
suggestedDefaults?: OpenClawSuggestedDefaults;
|
||||
},
|
||||
) => {
|
||||
await addProviderMutation.mutateAsync(provider);
|
||||
|
||||
// OpenClaw: register models to allowlist after adding provider
|
||||
if (activeApp === "openclaw" && provider.suggestedDefaults) {
|
||||
const { model, modelCatalog } = provider.suggestedDefaults;
|
||||
let modelsRegistered = false;
|
||||
|
||||
try {
|
||||
// 1. Merge model catalog (allowlist)
|
||||
if (modelCatalog && Object.keys(modelCatalog).length > 0) {
|
||||
const existingCatalog = (await openclawApi.getModelCatalog()) || {};
|
||||
const mergedCatalog = { ...existingCatalog, ...modelCatalog };
|
||||
await openclawApi.setModelCatalog(mergedCatalog);
|
||||
modelsRegistered = true;
|
||||
}
|
||||
|
||||
// 2. Set default model (only if not already set)
|
||||
if (model) {
|
||||
const existingDefault = await openclawApi.getDefaultModel();
|
||||
if (!existingDefault?.primary) {
|
||||
await openclawApi.setDefaultModel(model);
|
||||
}
|
||||
}
|
||||
|
||||
// Show success toast if models were registered
|
||||
if (modelsRegistered) {
|
||||
toast.success(
|
||||
t("notifications.openclawModelsRegistered", {
|
||||
defaultValue: "模型已注册到 /model 列表",
|
||||
}),
|
||||
{ closeButton: true },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Log warning but don't block main flow - provider config is already saved
|
||||
console.warn(
|
||||
"[OpenClaw] Failed to register models to allowlist:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[addProviderMutation],
|
||||
[addProviderMutation, activeApp, t],
|
||||
);
|
||||
|
||||
// 更新供应商
|
||||
@@ -99,8 +151,16 @@ export function useProviderActions(
|
||||
try {
|
||||
await switchProviderMutation.mutateAsync(provider.id);
|
||||
await syncClaudePlugin(provider);
|
||||
const isMultiProviderApp =
|
||||
activeApp === "opencode" || activeApp === "openclaw";
|
||||
const messageKey = isMultiProviderApp
|
||||
? "notifications.addToConfigSuccess"
|
||||
: "notifications.switchSuccess";
|
||||
const defaultMessage = isMultiProviderApp
|
||||
? "已添加到配置"
|
||||
: "切换成功!";
|
||||
toast.success(
|
||||
t("notifications.switchSuccess", { defaultValue: "切换成功!" }),
|
||||
t(messageKey, { defaultValue: defaultMessage }),
|
||||
{
|
||||
closeButton: true,
|
||||
},
|
||||
@@ -170,14 +230,12 @@ export function useProviderActions(
|
||||
await switchProviderMutation.mutateAsync(provider.id);
|
||||
await syncClaudePlugin(provider);
|
||||
|
||||
// 普通供应商:显示切换成功
|
||||
// OpenCode: show "added to config" message instead of "switched"
|
||||
const messageKey =
|
||||
activeApp === "opencode"
|
||||
? "notifications.addToConfigSuccess"
|
||||
: "notifications.switchSuccess";
|
||||
const defaultMessage =
|
||||
activeApp === "opencode" ? "已添加到配置" : "切换成功!";
|
||||
const isMultiProviderApp =
|
||||
activeApp === "opencode" || activeApp === "openclaw";
|
||||
const messageKey = isMultiProviderApp
|
||||
? "notifications.addToConfigSuccess"
|
||||
: "notifications.switchSuccess";
|
||||
const defaultMessage = isMultiProviderApp ? "已添加到配置" : "切换成功!";
|
||||
|
||||
if (proxyRequirementCheckFailed && baseUrl) {
|
||||
toast.success(
|
||||
@@ -259,12 +317,54 @@ export function useProviderActions(
|
||||
[activeApp, queryClient, t],
|
||||
);
|
||||
|
||||
// Set provider as default model (OpenClaw only)
|
||||
const setAsDefaultModel = useCallback(
|
||||
async (provider: Provider) => {
|
||||
const config = provider.settingsConfig as OpenClawProviderConfig;
|
||||
if (!config.models || config.models.length === 0) {
|
||||
toast.error(
|
||||
t("notifications.openclawNoModels", {
|
||||
defaultValue: "该供应商没有配置模型",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const model: OpenClawDefaultModel = {
|
||||
primary: `${provider.id}/${config.models[0].id}`,
|
||||
fallbacks: config.models.slice(1).map((m) => `${provider.id}/${m.id}`),
|
||||
};
|
||||
|
||||
try {
|
||||
await openclawApi.setDefaultModel(model);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: openclawKeys.defaultModel,
|
||||
});
|
||||
toast.success(
|
||||
t("notifications.openclawDefaultModelSet", {
|
||||
defaultValue: "已设为默认模型",
|
||||
}),
|
||||
{ closeButton: true },
|
||||
);
|
||||
} catch (error) {
|
||||
const detail =
|
||||
extractErrorMessage(error) ||
|
||||
t("notifications.openclawDefaultModelSetFailed", {
|
||||
defaultValue: "设置默认模型失败",
|
||||
});
|
||||
toast.error(detail);
|
||||
}
|
||||
},
|
||||
[queryClient, t],
|
||||
);
|
||||
|
||||
return {
|
||||
addProvider,
|
||||
updateProvider,
|
||||
switchProvider,
|
||||
deleteProvider,
|
||||
saveUsageScript,
|
||||
setAsDefaultModel,
|
||||
isLoading:
|
||||
addProviderMutation.isPending ||
|
||||
updateProviderMutation.isPending ||
|
||||
|
||||
@@ -135,9 +135,10 @@ export function useSettings(): UseSettingsResult {
|
||||
const sanitizedOpencodeDir = sanitizeDir(
|
||||
mergedSettings.opencodeConfigDir,
|
||||
);
|
||||
const { webdavSync: _ignoredWebdavSync, ...restSettings } = mergedSettings;
|
||||
|
||||
const payload: Settings = {
|
||||
...mergedSettings,
|
||||
...restSettings,
|
||||
claudeConfigDir: sanitizedClaudeDir,
|
||||
codexConfigDir: sanitizedCodexDir,
|
||||
geminiConfigDir: sanitizedGeminiDir,
|
||||
@@ -251,9 +252,10 @@ export function useSettings(): UseSettingsResult {
|
||||
const previousCodexDir = sanitizeDir(data?.codexConfigDir);
|
||||
const previousGeminiDir = sanitizeDir(data?.geminiConfigDir);
|
||||
const previousOpencodeDir = sanitizeDir(data?.opencodeConfigDir);
|
||||
const { webdavSync: _ignoredWebdavSync, ...restSettings } = mergedSettings;
|
||||
|
||||
const payload: Settings = {
|
||||
...mergedSettings,
|
||||
...restSettings,
|
||||
claudeConfigDir: sanitizedClaudeDir,
|
||||
codexConfigDir: sanitizedCodexDir,
|
||||
geminiConfigDir: sanitizedGeminiDir,
|
||||
|
||||
+273
-12
@@ -88,6 +88,8 @@
|
||||
"addOpenCodeProvider": "Add OpenCode Provider",
|
||||
"addToConfig": "Add",
|
||||
"removeFromConfig": "Remove",
|
||||
"setAsDefault": "Enable",
|
||||
"isDefault": "Default",
|
||||
"inConfig": "Added",
|
||||
"addProviderHint": "Fill in the information to quickly switch providers in the list.",
|
||||
"editClaudeProvider": "Edit Claude Code Provider",
|
||||
@@ -170,7 +172,11 @@
|
||||
"urlMismatchWarningOnDisable": "The current provider's request URL configuration may rely on the proxy. It may not work properly after disabling the proxy. Consider switching to a base URL configuration or keep the proxy enabled.",
|
||||
"switchAppliedUnverified": "Switch applied (direct mode not verified)",
|
||||
"switchAppliedUnverifiedDesc": "Unable to verify whether this endpoint requires the proxy. If it doesn't work after switching, enable the proxy and takeover the current app.",
|
||||
"openLinkFailed": "Failed to open link"
|
||||
"openLinkFailed": "Failed to open link",
|
||||
"openclawModelsRegistered": "Models have been registered to /model list",
|
||||
"openclawDefaultModelSet": "Set as default model",
|
||||
"openclawDefaultModelSetFailed": "Failed to set default model",
|
||||
"openclawNoModels": "No models configured"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "Delete Provider",
|
||||
@@ -222,7 +228,9 @@
|
||||
"requestGroup": "Request Rectification",
|
||||
"responseGroup": "Response Rectification",
|
||||
"thinkingSignature": "Thinking Signature Rectification",
|
||||
"thinkingSignatureDescription": "Automatically fix Claude API errors caused by thinking signature validation failures"
|
||||
"thinkingSignatureDescription": "When an Anthropic-type provider returns thinking signature incompatibility or illegal request errors, automatically removes incompatible thinking-related blocks and retries once with the same provider",
|
||||
"thinkingBudget": "Thinking Budget Rectification",
|
||||
"thinkingBudgetDescription": "When an Anthropic-type provider returns budget_tokens constraint errors (such as at least 1024), automatically normalizes thinking to enabled, sets thinking budget to 32000, and raises max_tokens to 64000 if needed, then retries once"
|
||||
},
|
||||
"logConfig": {
|
||||
"title": "Log Management",
|
||||
@@ -272,6 +280,77 @@
|
||||
"selectFileFailed": "Please choose a valid SQL backup file",
|
||||
"configCorrupted": "SQL file may be corrupted or invalid",
|
||||
"backupId": "Backup ID",
|
||||
"webdavSync": {
|
||||
"title": "WebDAV Cloud Sync",
|
||||
"description": "Sync database and skill configurations across devices via WebDAV.",
|
||||
"baseUrl": "WebDAV Server URL",
|
||||
"baseUrlPlaceholder": "https://dav.example.com/dav/",
|
||||
"username": "WebDAV Account",
|
||||
"usernamePlaceholder": "Email or username",
|
||||
"password": "WebDAV Password",
|
||||
"passwordPlaceholder": "App password",
|
||||
"remoteRoot": "Remote Root Directory",
|
||||
"profile": "Sync Profile Name",
|
||||
"test": "Test Connection",
|
||||
"testing": "Testing...",
|
||||
"testSuccess": "Connection successful",
|
||||
"testFailed": "Connection failed: {{error}}",
|
||||
"save": "Save Config",
|
||||
"saving": "Saving...",
|
||||
"saveFailed": "Failed to save config: {{error}}",
|
||||
"upload": "Upload to Cloud",
|
||||
"uploading": "Uploading...",
|
||||
"uploadSuccess": "Uploaded to WebDAV",
|
||||
"uploadFailed": "Upload failed: {{error}}",
|
||||
"download": "Download from Cloud",
|
||||
"downloading": "Downloading...",
|
||||
"downloadSuccess": "Downloaded and restored from WebDAV",
|
||||
"downloadFailed": "Download failed: {{error}}",
|
||||
"lastSync": "Last sync: {{time}}",
|
||||
"missingUrl": "Please enter the WebDAV server URL",
|
||||
"presets": {
|
||||
"label": "Provider",
|
||||
"jianguoyun": "Jianguoyun",
|
||||
"jianguoyunHint": "Generate an \"App Password\" in Jianguoyun security settings. Do not use your login password.",
|
||||
"nextcloud": "Nextcloud",
|
||||
"nextcloudHint": "Replace your-server with your Nextcloud domain and USERNAME with your username.",
|
||||
"synology": "Synology NAS",
|
||||
"synologyHint": "Install and enable the WebDAV Server package in Synology Package Center first.",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"remoteRootDefault": "Default: cc-switch-sync",
|
||||
"profileDefault": "Default: default",
|
||||
"saveAndTestSuccess": "Config saved, connection OK",
|
||||
"saveAndTestFailed": "Config saved, but connection test failed: {{error}}",
|
||||
"noRemoteData": "No sync data found on the remote server",
|
||||
"incompatibleVersion": "Remote data version incompatible (v{{version}}), current supports v2",
|
||||
"unsaved": "Unsaved",
|
||||
"saved": "Saved",
|
||||
"unsavedChanges": "Please save config first",
|
||||
"saveBeforeSync": "Save configuration first to enable upload/download.",
|
||||
"fetchingRemote": "Fetching remote info...",
|
||||
"fetchRemoteFailed": "Failed to fetch remote info. Please check configuration and network.",
|
||||
"confirmDownload": {
|
||||
"title": "Restore from Cloud",
|
||||
"deviceName": "Uploaded by",
|
||||
"createdAt": "Uploaded at",
|
||||
"artifacts": "Contents",
|
||||
"warning": "This will overwrite all local data and skill configurations",
|
||||
"confirm": "Confirm Restore"
|
||||
},
|
||||
"confirmUpload": {
|
||||
"title": "Upload to Cloud",
|
||||
"content": "The following will be synced to the WebDAV server:",
|
||||
"dbItem": "Database (all provider configs and data)",
|
||||
"skillsItem": "Skills (all custom skills)",
|
||||
"targetPath": "Target path",
|
||||
"existingData": "Existing cloud data",
|
||||
"deviceName": "Uploaded by",
|
||||
"createdAt": "Uploaded at",
|
||||
"warning": "This will overwrite existing sync data on the remote server",
|
||||
"confirm": "Confirm Upload"
|
||||
}
|
||||
},
|
||||
"autoReload": "Data refreshed",
|
||||
"languageOptionChinese": "中文",
|
||||
"languageOptionEnglish": "English",
|
||||
@@ -413,7 +492,8 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw"
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "Session Manager",
|
||||
@@ -496,7 +576,6 @@
|
||||
"officialHint": "💡 Official provider uses browser login, no API Key needed",
|
||||
"getApiKey": "Get API Key",
|
||||
"partnerPromotion": {
|
||||
"zhipu": "Zhipu GLM is an official partner of CC Switch. Use this link to top up and get a 10% discount",
|
||||
"packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off",
|
||||
"minimax_cn": "MiniMax Coding Plan Special Offer, Starter from ¥9.9",
|
||||
"minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)",
|
||||
@@ -673,6 +752,38 @@
|
||||
"modelOptionKeyPlaceholder": "provider",
|
||||
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
|
||||
},
|
||||
"openclaw": {
|
||||
"providerKey": "Provider Key",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"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.",
|
||||
"apiProtocol": "API Protocol",
|
||||
"selectProtocol": "Select API Protocol",
|
||||
"apiProtocolHint": "Select the protocol type compatible with the provider's API. Most providers use OpenAI Completions format.",
|
||||
"baseUrl": "API Endpoint",
|
||||
"baseUrlHint": "The provider's API endpoint address.",
|
||||
"models": "Models",
|
||||
"addModel": "Add Model",
|
||||
"noModels": "No models configured. Click Add Model to configure available models.",
|
||||
"modelId": "Model ID",
|
||||
"modelIdPlaceholder": "claude-3-sonnet",
|
||||
"modelName": "Display Name",
|
||||
"modelNamePlaceholder": "Claude 3 Sonnet",
|
||||
"contextWindow": "Context Window",
|
||||
"maxTokens": "Max Output Tokens",
|
||||
"reasoning": "Reasoning Mode",
|
||||
"reasoningOn": "Enabled",
|
||||
"reasoningOff": "Disabled",
|
||||
"inputCost": "Input Cost ($/M tokens)",
|
||||
"outputCost": "Output Cost ($/M tokens)",
|
||||
"advancedOptions": "Advanced Options",
|
||||
"cacheReadCost": "Cache Read Cost ($/M tokens)",
|
||||
"cacheWriteCost": "Cache Write Cost ($/M tokens)",
|
||||
"cacheCostHint": "Cache costs are used to calculate Prompt Caching costs. Leave empty if not using caching.",
|
||||
"modelsHint": "Configure the models supported by this provider. Model ID is used for API calls, Display Name for the interface."
|
||||
},
|
||||
"providerPreset": {
|
||||
"label": "Provider Preset",
|
||||
"custom": "Custom Configuration",
|
||||
@@ -868,7 +979,8 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw"
|
||||
}
|
||||
},
|
||||
"userLevelPath": "User-level MCP path",
|
||||
@@ -1041,6 +1153,74 @@
|
||||
"deleteMessage": "Are you sure you want to delete prompt \"{{name}}\"?"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"title": "Workspace Files",
|
||||
"manage": "Workspace",
|
||||
"files": {
|
||||
"agents": "Agent instructions and rules",
|
||||
"soul": "Agent personality and communication style",
|
||||
"user": "User profile and preferences",
|
||||
"identity": "Agent name and avatar",
|
||||
"tools": "Local tool documentation",
|
||||
"memory": "Long-term memory and decisions",
|
||||
"heartbeat": "Heartbeat run checklist",
|
||||
"bootstrap": "First-run bootstrap ritual",
|
||||
"boot": "Gateway reboot checklist"
|
||||
},
|
||||
"editing": "Edit {{filename}}",
|
||||
"saveSuccess": "Saved successfully",
|
||||
"saveFailed": "Failed to save",
|
||||
"loadFailed": "Failed to load"
|
||||
},
|
||||
"openclaw": {
|
||||
"env": {
|
||||
"title": "Environment Variables",
|
||||
"description": "Manage environment variables in openclaw.json (API keys, custom variables, etc.)",
|
||||
"keyPlaceholder": "Variable name",
|
||||
"valuePlaceholder": "Value",
|
||||
"add": "Add Variable",
|
||||
"saveSuccess": "Environment variables saved",
|
||||
"saveFailed": "Failed to save environment variables",
|
||||
"loadFailed": "Failed to load environment variables",
|
||||
"duplicateKey": "Duplicate variable name detected: {{key}}"
|
||||
},
|
||||
"tools": {
|
||||
"title": "Tool Permissions",
|
||||
"description": "Manage tool permissions in openclaw.json (allow/deny lists)",
|
||||
"profile": "Permission Profile",
|
||||
"profiles": {
|
||||
"default": "Default",
|
||||
"strict": "Strict",
|
||||
"permissive": "Permissive",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"allowList": "Allow List",
|
||||
"denyList": "Deny List",
|
||||
"patternPlaceholder": "Tool name or pattern",
|
||||
"addAllow": "Add Allow",
|
||||
"addDeny": "Add Deny",
|
||||
"saveSuccess": "Tool permissions saved",
|
||||
"saveFailed": "Failed to save tool permissions",
|
||||
"loadFailed": "Failed to load tool permissions"
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agents Config",
|
||||
"description": "Manage agents.defaults in openclaw.json (default model, runtime parameters, etc.)",
|
||||
"modelSection": "Model Configuration",
|
||||
"primaryModel": "Primary Model",
|
||||
"primaryModelHint": "Format: provider/model-id",
|
||||
"fallbackModels": "Fallback Models",
|
||||
"fallbackModelsHint": "Comma-separated, ordered by priority",
|
||||
"runtimeSection": "Runtime Parameters",
|
||||
"workspace": "Workspace Path",
|
||||
"timeout": "Timeout (seconds)",
|
||||
"contextTokens": "Context Tokens",
|
||||
"maxConcurrent": "Max Concurrent",
|
||||
"saveSuccess": "Agents config saved",
|
||||
"saveFailed": "Failed to save agents config",
|
||||
"loadFailed": "Failed to load agents config"
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"warning": {
|
||||
"title": "Environment Variable Conflicts Detected",
|
||||
@@ -1183,7 +1363,8 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw"
|
||||
},
|
||||
"installFromZip": {
|
||||
"button": "Install from ZIP",
|
||||
@@ -1590,16 +1771,21 @@
|
||||
"clearWrapped": "(Clear)",
|
||||
"defaultWrapped": "(Default)",
|
||||
"variantPlaceholder": "variant",
|
||||
"selectEnabledModel": "Select enabled model",
|
||||
"selectEnabledModel": "Select configured model",
|
||||
"selectModel": "Select configured model",
|
||||
"recommendedHint": "Recommended: {{model}}",
|
||||
"searchModel": "Search model...",
|
||||
"selectModelFirst": "Select model first",
|
||||
"noEnabledModels": "No enabled models",
|
||||
"noEnabledModels": "No configured models",
|
||||
"noVariantsForModel": "No variants for model",
|
||||
"currentValueNotEnabled": "{{value}} (current value, not enabled)",
|
||||
"currentValueNotEnabled": "{{value}} (current value, not configured)",
|
||||
"currentValueUnavailable": "{{value}} (current value, unavailable)",
|
||||
"advancedLabel": "Advanced",
|
||||
"advancedJsonInvalid": "Advanced JSON is invalid",
|
||||
"advancedJsonHint": "temperature, top_p, budgetTokens, prompt_append, permission, etc. Leave empty for defaults",
|
||||
"noEnabledModelsWarning": "No enabled models available. Configure and enable OpenCode models first.",
|
||||
"noEnabledModelsWarning": "No configured models available. Configure OpenCode models first.",
|
||||
"modelSourcePartialWarning": "Some provider model configs are invalid and were skipped.",
|
||||
"modelSourceFallbackWarning": "Failed to load live provider state. Falling back to configured providers.",
|
||||
"importLocalReplaceSuccess": "Imported local file and replaced Agents/Categories/Other Fields",
|
||||
"importLocalFailed": "Failed to read local file: {{error}}",
|
||||
"agentKeyPlaceholder": "agent key",
|
||||
@@ -1610,7 +1796,7 @@
|
||||
"modelConfiguration": "Model Configuration",
|
||||
"fillRecommended": "Fill Recommended",
|
||||
"configSummary": "{{agents}} agents, {{categories}} categories configured · Click ⚙ for advanced params",
|
||||
"enabledModelsCount": "{{count}} enabled models available",
|
||||
"enabledModelsCount": "{{count}} configured models available",
|
||||
"source": "from:",
|
||||
"otherFieldsJson": "Other Fields (JSON)",
|
||||
"searchOrType": "Search or type custom value...",
|
||||
@@ -1636,6 +1822,81 @@
|
||||
"advancedExperimental": "Experimental Features",
|
||||
"advancedBackgroundTask": "Background Tasks",
|
||||
"advancedBrowserAutomation": "Browser Automation",
|
||||
"advancedClaudeCode": "Claude Code"
|
||||
"advancedClaudeCode": "Claude Code",
|
||||
"agentDesc": {
|
||||
"sisyphus": "Main orchestrator",
|
||||
"hephaestus": "Autonomous deep worker",
|
||||
"prometheus": "Strategic planner",
|
||||
"atlas": "Task manager",
|
||||
"oracle": "Strategic advisor",
|
||||
"librarian": "Multi-repo researcher",
|
||||
"explore": "Fast code search",
|
||||
"multimodalLooker": "Media analyzer",
|
||||
"metis": "Pre-plan analysis advisor",
|
||||
"momus": "Plan reviewer",
|
||||
"sisyphusJunior": "Delegated task executor"
|
||||
},
|
||||
"agentTooltip": {
|
||||
"sisyphus": "Main orchestrator responsible for task planning, delegation and parallel execution. Uses extended thinking (32k budget) and drives workflows through TODO lists to ensure task completion.",
|
||||
"atlas": "Main orchestrator (holds TODO list) responsible for task distribution and coordination during execution phase. Delegates to specialized agents rather than completing all work directly.",
|
||||
"prometheus": "Strategic planner that collects requirements through interview mode and creates detailed work plans. Can only read/write Markdown files in .sisyphus/ directory, never writes code directly.",
|
||||
"hephaestus": "Autonomous deep worker (\"legitimate craftsman\") inspired by AmpCode deep mode. Goal-oriented execution that launches 2-5 explore/librarian agents in parallel for research before taking action.",
|
||||
"oracle": "Architecture decision and debugging advisor. Read-only consulting agent providing excellent logical reasoning and deep analysis. Cannot write files or delegate tasks.",
|
||||
"librarian": "Multi-repository analysis and documentation retrieval expert. Deeply understands codebases and provides evidence-based answers. Excels at finding official documentation and open-source implementation examples.",
|
||||
"explore": "Fast codebase exploration and context grep expert. Uses lightweight models for high-speed search. The scout for understanding code structure.",
|
||||
"multimodalLooker": "Visual content expert that analyzes PDFs, images, charts and other non-text media, extracting information and insights from them.",
|
||||
"metis": "Plan consultant that performs pre-analysis before planning. Identifies hidden intents, ambiguities and AI failure points to prevent over-engineering.",
|
||||
"momus": "Plan reviewer that validates plan clarity, verifiability and completeness with high precision. Rejects and requests revisions until plans are perfect.",
|
||||
"sisyphusJunior": "Category-generated executor, automatically created via category parameters. Focuses on executing assigned tasks and cannot re-delegate, preventing infinite delegation loops."
|
||||
},
|
||||
"categoryDesc": {
|
||||
"visualEngineering": "Visual/frontend engineering",
|
||||
"ultrabrain": "Ultra thinking",
|
||||
"deep": "Deep work",
|
||||
"artistry": "Creative/artistic",
|
||||
"quick": "Quick response",
|
||||
"unspecifiedLow": "General low tier",
|
||||
"unspecifiedHigh": "General high tier",
|
||||
"writing": "Writing"
|
||||
},
|
||||
"categoryTooltip": {
|
||||
"visualEngineering": "Frontend and visual engineering category for UI/UX design, styling, animation and interface implementation. Defaults to Gemini 3 Pro model.",
|
||||
"ultrabrain": "Deep logical reasoning category for complex architectural decisions requiring extensive analysis. Defaults to GPT-5.3 Codex ultra-high reasoning variant.",
|
||||
"deep": "Deep autonomous problem-solving category with goal-oriented execution. Conducts thorough research before action, suitable for difficult problems requiring deep understanding.",
|
||||
"artistry": "Highly creative and artistic task category that inspires novel ideas and creative solutions. Defaults to Gemini 3 Pro max variant.",
|
||||
"quick": "Lightweight task category for single-file modifications, typo fixes, and simple adjustments. Defaults to Claude Haiku 4.5 fast model.",
|
||||
"unspecifiedLow": "Uncategorized low-effort task category for tasks that don't fit other categories with small workload. Defaults to Claude Sonnet 4.5.",
|
||||
"unspecifiedHigh": "Uncategorized high-effort task category for tasks that don't fit other categories with large workload. Defaults to Claude Opus 4.6 max variant.",
|
||||
"writing": "Writing category for documentation, prose and technical writing. Defaults to Gemini 3 Flash fast generation model."
|
||||
}
|
||||
},
|
||||
"openclawConfig": {
|
||||
"defaultModel": {
|
||||
"title": "Default Model",
|
||||
"description": "Configure the default primary model and fallback models for OpenClaw",
|
||||
"primary": "Primary Model",
|
||||
"primaryPlaceholder": "e.g., deepseek/deepseek-chat",
|
||||
"fallbacks": "Fallback Models",
|
||||
"fallbacksPlaceholder": "e.g., openrouter/anthropic/claude-sonnet-4.5",
|
||||
"addFallback": "Add Fallback Model",
|
||||
"saved": "Default model configuration saved",
|
||||
"saveFailed": "Failed to save default model"
|
||||
},
|
||||
"modelCatalog": {
|
||||
"title": "Model Catalog",
|
||||
"description": "Configure model aliases and allowlist",
|
||||
"modelId": "Model ID",
|
||||
"modelIdPlaceholder": "e.g., deepseek/deepseek-chat",
|
||||
"alias": "Alias",
|
||||
"aliasPlaceholder": "e.g., DeepSeek",
|
||||
"addEntry": "Add Model",
|
||||
"removeEntry": "Remove",
|
||||
"saved": "Model catalog saved",
|
||||
"saveFailed": "Failed to save model catalog",
|
||||
"empty": "No model catalog entries",
|
||||
"emptyHint": "Add models to the catalog to configure aliases"
|
||||
},
|
||||
"suggestedDefaults": "Apply Suggested Defaults",
|
||||
"suggestedDefaultsHint": "Use this preset's recommended default model configuration"
|
||||
}
|
||||
}
|
||||
|
||||
+273
-12
@@ -88,6 +88,8 @@
|
||||
"addOpenCodeProvider": "OpenCode プロバイダーを追加",
|
||||
"addToConfig": "追加",
|
||||
"removeFromConfig": "削除",
|
||||
"setAsDefault": "有効化",
|
||||
"isDefault": "デフォルト",
|
||||
"inConfig": "追加済み",
|
||||
"addProviderHint": "一覧にすばやく切り替えられるよう、ここに情報を入力してください。",
|
||||
"editClaudeProvider": "Claude Code プロバイダーを編集",
|
||||
@@ -170,7 +172,11 @@
|
||||
"urlMismatchWarningOnDisable": "現在のプロバイダーのリクエスト URL 設定はプロキシに依存している可能性があります。プロキシを無効にすると正常に動作しない可能性があります。ベース URL 設定に変更するか、プロキシを有効のままにすることをお勧めします。",
|
||||
"switchAppliedUnverified": "切り替えを適用しました(直接接続は未検証)",
|
||||
"switchAppliedUnverifiedDesc": "このエンドポイントがプロキシを必要とするか検証できませんでした。切り替え後に動作しない場合は、プロキシを有効にして現在のアプリをテイクオーバーしてください。",
|
||||
"openLinkFailed": "リンクを開けませんでした"
|
||||
"openLinkFailed": "リンクを開けませんでした",
|
||||
"openclawModelsRegistered": "モデルが /model リストに登録されました",
|
||||
"openclawDefaultModelSet": "デフォルトモデルに設定しました",
|
||||
"openclawDefaultModelSetFailed": "デフォルトモデルの設定に失敗しました",
|
||||
"openclawNoModels": "モデルが設定されていません"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "プロバイダーを削除",
|
||||
@@ -222,7 +228,9 @@
|
||||
"requestGroup": "リクエスト整流",
|
||||
"responseGroup": "レスポンス整流",
|
||||
"thinkingSignature": "Thinking 署名整流",
|
||||
"thinkingSignatureDescription": "Claude API の thinking 署名検証エラーを自動修正"
|
||||
"thinkingSignatureDescription": "Anthropic タイプのプロバイダーが thinking 署名の非互換性や不正なリクエストエラーを返した場合、互換性のない thinking 関連ブロックを自動削除し、同じプロバイダーで 1 回リトライします",
|
||||
"thinkingBudget": "Thinking Budget 整流",
|
||||
"thinkingBudgetDescription": "Anthropic タイプのプロバイダーが budget_tokens 制約エラー(例: 1024 以上)を返した場合、thinking を enabled に正規化し、thinking 予算を 32000 に設定し、必要に応じて max_tokens を 64000 に引き上げて 1 回リトライします"
|
||||
},
|
||||
"logConfig": {
|
||||
"title": "ログ管理",
|
||||
@@ -272,6 +280,77 @@
|
||||
"selectFileFailed": "有効な SQL バックアップファイルを選択してください",
|
||||
"configCorrupted": "SQL ファイルが壊れているか形式が無効な可能性があります",
|
||||
"backupId": "バックアップ ID",
|
||||
"webdavSync": {
|
||||
"title": "WebDAV クラウド同期",
|
||||
"description": "WebDAV を使ってデバイス間でデータベースとスキル設定を同期します。",
|
||||
"baseUrl": "WebDAV サーバー URL",
|
||||
"baseUrlPlaceholder": "https://example.com/remote.php/dav/files/user",
|
||||
"username": "ユーザー名",
|
||||
"usernamePlaceholder": "メールアドレスまたはユーザー名",
|
||||
"password": "パスワード",
|
||||
"passwordPlaceholder": "アプリパスワード",
|
||||
"remoteRoot": "リモートルートディレクトリ",
|
||||
"profile": "同期プロファイル名",
|
||||
"test": "接続テスト",
|
||||
"testing": "テスト中...",
|
||||
"testSuccess": "接続成功",
|
||||
"testFailed": "接続失敗:{{error}}",
|
||||
"save": "設定を保存",
|
||||
"saving": "保存中...",
|
||||
"saveFailed": "設定の保存に失敗しました:{{error}}",
|
||||
"upload": "クラウドにアップロード",
|
||||
"uploading": "アップロード中...",
|
||||
"uploadSuccess": "WebDAV にアップロードしました",
|
||||
"uploadFailed": "アップロードに失敗しました:{{error}}",
|
||||
"download": "クラウドからダウンロード",
|
||||
"downloading": "ダウンロード中...",
|
||||
"downloadSuccess": "WebDAV からダウンロード・復元しました",
|
||||
"downloadFailed": "ダウンロードに失敗しました:{{error}}",
|
||||
"lastSync": "前回の同期:{{time}}",
|
||||
"missingUrl": "WebDAV サーバー URL を入力してください",
|
||||
"presets": {
|
||||
"label": "サービス",
|
||||
"jianguoyun": "坚果云",
|
||||
"jianguoyunHint": "坚果云の「セキュリティ設定」で「サードパーティアプリパスワード」を生成してください。ログインパスワードは使用しないでください。",
|
||||
"nextcloud": "Nextcloud",
|
||||
"nextcloudHint": "your-server を Nextcloud サーバーのアドレスに、USERNAME をユーザー名に置き換えてください。",
|
||||
"synology": "Synology NAS",
|
||||
"synologyHint": "Synology の「パッケージセンター」で WebDAV Server パッケージをインストール・有効化してください。",
|
||||
"custom": "カスタム"
|
||||
},
|
||||
"remoteRootDefault": "デフォルト: cc-switch-sync",
|
||||
"profileDefault": "デフォルト: default",
|
||||
"saveAndTestSuccess": "設定を保存しました。接続正常です",
|
||||
"saveAndTestFailed": "設定を保存しましたが、接続テストに失敗しました:{{error}}",
|
||||
"noRemoteData": "クラウドに同期データが見つかりません",
|
||||
"incompatibleVersion": "リモートデータのバージョンに互換性がありません(v{{version}})。現在 v2 をサポートしています",
|
||||
"unsaved": "未保存",
|
||||
"saved": "保存済み",
|
||||
"unsavedChanges": "先に設定を保存してください",
|
||||
"saveBeforeSync": "アップロード/ダウンロードを有効にするには、先に設定を保存してください。",
|
||||
"fetchingRemote": "リモート情報を取得中...",
|
||||
"fetchRemoteFailed": "リモート情報の取得に失敗しました。設定とネットワークを確認してください。",
|
||||
"confirmDownload": {
|
||||
"title": "クラウドから復元",
|
||||
"deviceName": "アップロード元",
|
||||
"createdAt": "アップロード日時",
|
||||
"artifacts": "内容",
|
||||
"warning": "ローカルのすべてのデータとスキル設定が上書きされます",
|
||||
"confirm": "復元を実行"
|
||||
},
|
||||
"confirmUpload": {
|
||||
"title": "クラウドにアップロード",
|
||||
"content": "以下の内容を WebDAV サーバーに同期します:",
|
||||
"dbItem": "データベース(すべてのプロバイダー設定とデータ)",
|
||||
"skillsItem": "スキル(すべてのカスタムスキル)",
|
||||
"targetPath": "保存先パス",
|
||||
"existingData": "クラウドの既存データ",
|
||||
"deviceName": "アップロード元",
|
||||
"createdAt": "アップロード日時",
|
||||
"warning": "リモートの既存同期データが上書きされます",
|
||||
"confirm": "アップロードを実行"
|
||||
}
|
||||
},
|
||||
"autoReload": "データを更新しました",
|
||||
"languageOptionChinese": "中文",
|
||||
"languageOptionEnglish": "English",
|
||||
@@ -413,7 +492,8 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw"
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "セッション管理",
|
||||
@@ -496,7 +576,6 @@
|
||||
"officialHint": "💡 公式プロバイダーはブラウザログインで、API Key は不要です",
|
||||
"getApiKey": "API Key を取得",
|
||||
"partnerPromotion": {
|
||||
"zhipu": "Zhipu GLM は CC Switch の公式パートナーです。リンク経由でチャージすると 10% 割引",
|
||||
"packycode": "PackyCode は CC Switch の公式パートナーです。登録後チャージ時に \"cc-switch\" を入力すると 10% オフ",
|
||||
"minimax_cn": "MiniMax Coding Plan 特別価格、Starter ¥9.9 から",
|
||||
"minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $2(80% OFF)",
|
||||
@@ -673,6 +752,38 @@
|
||||
"modelOptionKeyPlaceholder": "provider",
|
||||
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
|
||||
},
|
||||
"openclaw": {
|
||||
"providerKey": "プロバイダーキー",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。",
|
||||
"providerKeyRequired": "プロバイダーキーを入力してください",
|
||||
"providerKeyDuplicate": "このキーは既に使用されています",
|
||||
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。",
|
||||
"apiProtocol": "API プロトコル",
|
||||
"selectProtocol": "API プロトコルを選択",
|
||||
"apiProtocolHint": "プロバイダーの API と互換性のあるプロトコルタイプを選択してください。ほとんどのプロバイダーは OpenAI Completions 形式を使用します。",
|
||||
"baseUrl": "API エンドポイント",
|
||||
"baseUrlHint": "プロバイダーの API エンドポイントアドレス。",
|
||||
"models": "モデル一覧",
|
||||
"addModel": "モデルを追加",
|
||||
"noModels": "モデルが設定されていません。「モデルを追加」をクリックして利用可能なモデルを設定してください。",
|
||||
"modelId": "モデル ID",
|
||||
"modelIdPlaceholder": "claude-3-sonnet",
|
||||
"modelName": "表示名",
|
||||
"modelNamePlaceholder": "Claude 3 Sonnet",
|
||||
"contextWindow": "コンテキストウィンドウ",
|
||||
"maxTokens": "最大出力トークン数",
|
||||
"reasoning": "推論モード",
|
||||
"reasoningOn": "有効",
|
||||
"reasoningOff": "無効",
|
||||
"inputCost": "入力コスト ($/M トークン)",
|
||||
"outputCost": "出力コスト ($/M トークン)",
|
||||
"advancedOptions": "詳細オプション",
|
||||
"cacheReadCost": "キャッシュ読取コスト ($/M トークン)",
|
||||
"cacheWriteCost": "キャッシュ書込コスト ($/M トークン)",
|
||||
"cacheCostHint": "キャッシュコストは Prompt Caching のコスト計算に使用されます。キャッシュを使用しない場合は空欄のままにしてください。",
|
||||
"modelsHint": "このプロバイダーがサポートするモデルを設定します。モデル ID は API 呼び出しに、表示名はインターフェースに使用されます。"
|
||||
},
|
||||
"providerPreset": {
|
||||
"label": "プロバイダータイプ",
|
||||
"custom": "カスタム設定",
|
||||
@@ -868,7 +979,8 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw"
|
||||
}
|
||||
},
|
||||
"userLevelPath": "ユーザーレベルの MCP パス",
|
||||
@@ -1041,6 +1153,74 @@
|
||||
"deleteMessage": "プロンプト「{{name}}」を削除してもよろしいですか?"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"title": "ワークスペースファイル",
|
||||
"manage": "ワークスペース",
|
||||
"files": {
|
||||
"agents": "エージェントの操作指示とルール",
|
||||
"soul": "エージェントの人格とコミュニケーションスタイル",
|
||||
"user": "ユーザープロファイルと設定",
|
||||
"identity": "エージェントの名前とアバター",
|
||||
"tools": "ローカルツールドキュメント",
|
||||
"memory": "長期記憶と意思決定記録",
|
||||
"heartbeat": "ハートビート実行チェックリスト",
|
||||
"bootstrap": "初回実行ブートストラップ",
|
||||
"boot": "ゲートウェイ再起動チェックリスト"
|
||||
},
|
||||
"editing": "{{filename}} を編集",
|
||||
"saveSuccess": "保存しました",
|
||||
"saveFailed": "保存に失敗しました",
|
||||
"loadFailed": "読み込みに失敗しました"
|
||||
},
|
||||
"openclaw": {
|
||||
"env": {
|
||||
"title": "環境変数",
|
||||
"description": "openclaw.json の環境変数設定を管理(APIキー、カスタム変数など)",
|
||||
"keyPlaceholder": "変数名",
|
||||
"valuePlaceholder": "値",
|
||||
"add": "変数を追加",
|
||||
"saveSuccess": "環境変数を保存しました",
|
||||
"saveFailed": "環境変数の保存に失敗しました",
|
||||
"loadFailed": "環境変数の読み込みに失敗しました",
|
||||
"duplicateKey": "重複する変数名が検出されました: {{key}}"
|
||||
},
|
||||
"tools": {
|
||||
"title": "ツール権限",
|
||||
"description": "openclaw.json のツール権限設定を管理(許可/拒否リスト)",
|
||||
"profile": "権限プロファイル",
|
||||
"profiles": {
|
||||
"default": "デフォルト",
|
||||
"strict": "厳格",
|
||||
"permissive": "寛容",
|
||||
"custom": "カスタム"
|
||||
},
|
||||
"allowList": "許可リスト",
|
||||
"denyList": "拒否リスト",
|
||||
"patternPlaceholder": "ツール名またはパターン",
|
||||
"addAllow": "許可を追加",
|
||||
"addDeny": "拒否を追加",
|
||||
"saveSuccess": "ツール権限を保存しました",
|
||||
"saveFailed": "ツール権限の保存に失敗しました",
|
||||
"loadFailed": "ツール権限の読み込みに失敗しました"
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agents 設定",
|
||||
"description": "openclaw.json の agents.defaults 設定を管理(デフォルトモデル、ランタイムパラメータなど)",
|
||||
"modelSection": "モデル設定",
|
||||
"primaryModel": "プライマリモデル",
|
||||
"primaryModelHint": "形式: provider/model-id",
|
||||
"fallbackModels": "フォールバックモデル",
|
||||
"fallbackModelsHint": "カンマ区切り、優先度順",
|
||||
"runtimeSection": "ランタイムパラメータ",
|
||||
"workspace": "ワークスペースパス",
|
||||
"timeout": "タイムアウト(秒)",
|
||||
"contextTokens": "コンテキストトークン数",
|
||||
"maxConcurrent": "最大同時実行数",
|
||||
"saveSuccess": "Agents 設定を保存しました",
|
||||
"saveFailed": "Agents 設定の保存に失敗しました",
|
||||
"loadFailed": "Agents 設定の読み込みに失敗しました"
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"warning": {
|
||||
"title": "競合する環境変数を検出しました",
|
||||
@@ -1181,7 +1361,8 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw"
|
||||
},
|
||||
"installFromZip": {
|
||||
"button": "ZIP からインストール",
|
||||
@@ -1571,16 +1752,21 @@
|
||||
"clearWrapped": "(クリア)",
|
||||
"defaultWrapped": "(デフォルト)",
|
||||
"variantPlaceholder": "variant",
|
||||
"selectEnabledModel": "有効なモデルを選択",
|
||||
"selectEnabledModel": "設定済みモデルを選択",
|
||||
"selectModel": "設定済みモデルを選択",
|
||||
"recommendedHint": "推奨: {{model}}",
|
||||
"searchModel": "モデルを検索...",
|
||||
"selectModelFirst": "先にモデルを選択",
|
||||
"noEnabledModels": "有効なモデルがありません",
|
||||
"noEnabledModels": "設定済みモデルがありません",
|
||||
"noVariantsForModel": "このモデルには思考レベルがありません",
|
||||
"currentValueNotEnabled": "{{value}} (現在値・未有効)",
|
||||
"currentValueNotEnabled": "{{value}} (現在値・未設定)",
|
||||
"currentValueUnavailable": "{{value}} (現在値・利用不可)",
|
||||
"advancedLabel": "詳細",
|
||||
"advancedJsonInvalid": "詳細 JSON が不正です",
|
||||
"advancedJsonHint": "temperature, top_p, budgetTokens, prompt_append, permission など。空欄でデフォルトを使用します",
|
||||
"noEnabledModelsWarning": "利用可能な有効モデルがありません。先に OpenCode モデルを有効化してください。",
|
||||
"noEnabledModelsWarning": "利用可能な設定済みモデルがありません。先に OpenCode モデルを設定してください。",
|
||||
"modelSourcePartialWarning": "一部プロバイダーのモデル設定が不正なため、候補から除外しました。",
|
||||
"modelSourceFallbackWarning": "live プロバイダー状態の取得に失敗したため、設定済みプロバイダーへフォールバックしました。",
|
||||
"importLocalReplaceSuccess": "ローカルファイルから読み込み、Agents/Categories/Other Fields を置き換えました",
|
||||
"importLocalFailed": "ローカルファイルの読み込みに失敗しました: {{error}}",
|
||||
"agentKeyPlaceholder": "agent キー",
|
||||
@@ -1591,7 +1777,7 @@
|
||||
"modelConfiguration": "モデル設定",
|
||||
"fillRecommended": "推奨を入力",
|
||||
"configSummary": "{{agents}} 個の Agent、{{categories}} 個の Category を設定済み · ⚙ で詳細を展開",
|
||||
"enabledModelsCount": "有効モデル {{count}} 件",
|
||||
"enabledModelsCount": "設定済みモデル {{count}} 件",
|
||||
"source": "出典:",
|
||||
"otherFieldsJson": "その他のフィールド (JSON)",
|
||||
"searchOrType": "検索またはカスタム値を入力...",
|
||||
@@ -1617,6 +1803,81 @@
|
||||
"advancedExperimental": "実験的機能",
|
||||
"advancedBackgroundTask": "バックグラウンドタスク",
|
||||
"advancedBrowserAutomation": "ブラウザ自動化",
|
||||
"advancedClaudeCode": "Claude Code"
|
||||
"advancedClaudeCode": "Claude Code",
|
||||
"agentDesc": {
|
||||
"sisyphus": "メインオーケストレーター",
|
||||
"hephaestus": "自律型ディープワーカー",
|
||||
"prometheus": "戦略プランナー",
|
||||
"atlas": "タスクマネージャー",
|
||||
"oracle": "戦略アドバイザー",
|
||||
"librarian": "マルチリポジトリ研究員",
|
||||
"explore": "高速コード検索",
|
||||
"multimodalLooker": "メディアアナライザー",
|
||||
"metis": "計画前分析アドバイザー",
|
||||
"momus": "プランレビュアー",
|
||||
"sisyphusJunior": "委任タスクエグゼキューター"
|
||||
},
|
||||
"agentTooltip": {
|
||||
"sisyphus": "メインオーケストレーター。タスクの計画、委任、並列実行を担当。拡張思考(32k バジェット)を使用し、TODO リストでワークフローを駆動してタスク完了を確保します。",
|
||||
"atlas": "メインオーケストレーター(TODO リスト保持)。実行フェーズでのタスク配分と調整を担当。すべての作業を直接行うのではなく、専門エージェントに委任します。",
|
||||
"prometheus": "戦略プランナー。インタビューモードで要件を収集し、詳細な作業計画を策定。.sisyphus/ ディレクトリ内の Markdown ファイルの読み書きのみ可能で、直接コードを書くことはありません。",
|
||||
"hephaestus": "自律型ディープワーカー(「正当な職人」)。AmpCode ディープモードに着想を得た目標指向の実行を行い、行動前に 2-5 個の探索/ライブラリアンエージェントを並列起動して調査します。",
|
||||
"oracle": "アーキテクチャ決定とデバッグのアドバイザー。読み取り専用のコンサルティングエージェントで、優れた論理的推論と深い分析を提供。ファイルの書き込みやタスクの委任はできません。",
|
||||
"librarian": "マルチリポジトリ分析とドキュメント検索の専門家。コードベースを深く理解し、エビデンスに基づく回答を提供。公式ドキュメントやオープンソース実装例の検索に長けています。",
|
||||
"explore": "高速コードベース探索とコンテキスト grep の専門家。軽量モデルを使用した高速検索で、コード構造を理解するための先鋒です。",
|
||||
"multimodalLooker": "ビジュアルコンテンツの専門家。PDF、画像、グラフなどの非テキストメディアを分析し、情報とインサイトを抽出します。",
|
||||
"metis": "プランコンサルタント。計画前に事前分析を行い、隠れた意図、曖昧な点、AI の失敗ポイントを特定して、過剰エンジニアリングを防止します。",
|
||||
"momus": "プランレビュアー。計画の明確さ、検証可能性、完全性を高精度で検証。計画が完璧になるまで却下と修正を要求します。",
|
||||
"sisyphusJunior": "カテゴリ生成のエグゼキューター。category パラメータにより自動生成され、割り当てられたタスクの実行に専念し、再委任はできません。無限委任ループを防止します。"
|
||||
},
|
||||
"categoryDesc": {
|
||||
"visualEngineering": "ビジュアル/フロントエンド工学",
|
||||
"ultrabrain": "超深度思考",
|
||||
"deep": "ディープワーク",
|
||||
"artistry": "クリエイティブ/芸術",
|
||||
"quick": "クイックレスポンス",
|
||||
"unspecifiedLow": "汎用ロースペック",
|
||||
"unspecifiedHigh": "汎用ハイスペック",
|
||||
"writing": "ライティング"
|
||||
},
|
||||
"categoryTooltip": {
|
||||
"visualEngineering": "フロントエンドとビジュアルエンジニアリングカテゴリ。UI/UX デザイン、スタイリング、アニメーション、インターフェース実装に特化。デフォルトで Gemini 3 Pro モデルを使用。",
|
||||
"ultrabrain": "ディープロジック推論カテゴリ。広範な分析が必要な複雑なアーキテクチャ決定に使用。デフォルトで GPT-5.3 Codex の超高推論バリアントを使用。",
|
||||
"deep": "ディープ自律問題解決カテゴリ。目標指向の実行で、行動前に徹底的な調査を実施。深い理解が必要な難問に適しています。",
|
||||
"artistry": "高度にクリエイティブで芸術的なタスクカテゴリ。斬新なアイデアとクリエイティブなソリューションを促進。デフォルトで Gemini 3 Pro の max バリアントを使用。",
|
||||
"quick": "軽量タスクカテゴリ。単一ファイルの修正、タイポ修正、簡単な調整などの些細な作業に使用。デフォルトで Claude Haiku 4.5 高速モデルを使用。",
|
||||
"unspecifiedLow": "未分類の低作業量タスクカテゴリ。他のカテゴリに該当せず作業量が小さいタスクに適用。デフォルトで Claude Sonnet 4.5 を使用。",
|
||||
"unspecifiedHigh": "未分類の高作業量タスクカテゴリ。他のカテゴリに該当せず作業量が大きいタスクに適用。デフォルトで Claude Opus 4.6 の max バリアントを使用。",
|
||||
"writing": "ライティングカテゴリ。ドキュメント、散文、技術文書に特化。デフォルトで Gemini 3 Flash 高速生成モデルを使用。"
|
||||
}
|
||||
},
|
||||
"openclawConfig": {
|
||||
"defaultModel": {
|
||||
"title": "デフォルトモデル",
|
||||
"description": "OpenClaw のデフォルトのプライマリモデルとフォールバックモデルを設定します",
|
||||
"primary": "プライマリモデル",
|
||||
"primaryPlaceholder": "例: deepseek/deepseek-chat",
|
||||
"fallbacks": "フォールバックモデル",
|
||||
"fallbacksPlaceholder": "例: openrouter/anthropic/claude-sonnet-4.5",
|
||||
"addFallback": "フォールバックモデルを追加",
|
||||
"saved": "デフォルトモデル設定を保存しました",
|
||||
"saveFailed": "デフォルトモデルの保存に失敗しました"
|
||||
},
|
||||
"modelCatalog": {
|
||||
"title": "モデルカタログ",
|
||||
"description": "モデルのエイリアスと許可リストを設定します",
|
||||
"modelId": "モデル ID",
|
||||
"modelIdPlaceholder": "例: deepseek/deepseek-chat",
|
||||
"alias": "エイリアス",
|
||||
"aliasPlaceholder": "例: DeepSeek",
|
||||
"addEntry": "モデルを追加",
|
||||
"removeEntry": "削除",
|
||||
"saved": "モデルカタログを保存しました",
|
||||
"saveFailed": "モデルカタログの保存に失敗しました",
|
||||
"empty": "モデルカタログエントリがありません",
|
||||
"emptyHint": "エイリアスを設定するにはカタログにモデルを追加してください"
|
||||
},
|
||||
"suggestedDefaults": "推奨デフォルト設定を適用",
|
||||
"suggestedDefaultsHint": "このプリセットの推奨デフォルトモデル設定を使用します"
|
||||
}
|
||||
}
|
||||
|
||||
+274
-13
@@ -88,6 +88,8 @@
|
||||
"addOpenCodeProvider": "添加 OpenCode 供应商",
|
||||
"addToConfig": "添加",
|
||||
"removeFromConfig": "移除",
|
||||
"setAsDefault": "启用",
|
||||
"isDefault": "默认",
|
||||
"inConfig": "已添加",
|
||||
"addProviderHint": "填写信息后即可在列表中快速切换供应商。",
|
||||
"editClaudeProvider": "编辑 Claude Code 供应商",
|
||||
@@ -170,7 +172,11 @@
|
||||
"urlMismatchWarningOnDisable": "当前供应商的请求地址配置可能依赖代理模式,关闭代理后可能无法正常工作。建议更换为基础地址配置或保持代理开启。",
|
||||
"switchAppliedUnverified": "切换已应用(未验证直连兼容性)",
|
||||
"switchAppliedUnverifiedDesc": "未能验证该端点是否需要代理。如果切换后无法正常使用,请开启代理并接管当前应用。",
|
||||
"openLinkFailed": "链接打开失败"
|
||||
"openLinkFailed": "链接打开失败",
|
||||
"openclawModelsRegistered": "模型已注册到 /model 列表",
|
||||
"openclawDefaultModelSet": "已设为默认模型",
|
||||
"openclawDefaultModelSetFailed": "设置默认模型失败",
|
||||
"openclawNoModels": "该供应商没有配置模型"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "删除供应商",
|
||||
@@ -222,7 +228,9 @@
|
||||
"requestGroup": "请求整流",
|
||||
"responseGroup": "响应整流",
|
||||
"thinkingSignature": "Thinking 签名整流",
|
||||
"thinkingSignatureDescription": "自动修复 Claude API 中因 thinking 签名校验失败导致的请求错误"
|
||||
"thinkingSignatureDescription": "当 Anthropic 类型供应商返回 thinking 签名不兼容或非法请求等错误时,自动移除不兼容的 thinking 相关块并对同一供应商重试一次",
|
||||
"thinkingBudget": "Thinking Budget 整流",
|
||||
"thinkingBudgetDescription": "当 Anthropic 类型供应商返回 budget_tokens 约束错误(如至少 1024)时,自动将 thinking 规范为 enabled 并将 budget 设为 32000,同时在需要时将 max_tokens 设为 64000,然后重试一次"
|
||||
},
|
||||
"logConfig": {
|
||||
"title": "日志管理",
|
||||
@@ -272,6 +280,77 @@
|
||||
"selectFileFailed": "请选择有效的 SQL 备份文件",
|
||||
"configCorrupted": "SQL 文件可能已损坏或格式不正确",
|
||||
"backupId": "备份ID",
|
||||
"webdavSync": {
|
||||
"title": "WebDAV 云同步",
|
||||
"description": "通过 WebDAV 在多设备间同步数据库和技能配置。",
|
||||
"baseUrl": "WebDAV 服务器地址",
|
||||
"baseUrlPlaceholder": "https://dav.jianguoyun.com/dav/",
|
||||
"username": "WebDAV 账户",
|
||||
"usernamePlaceholder": "邮箱账号",
|
||||
"password": "WebDAV 密码",
|
||||
"passwordPlaceholder": "应用密码(坚果云请使用「第三方应用密码」)",
|
||||
"remoteRoot": "远程根目录",
|
||||
"profile": "同步配置名",
|
||||
"test": "测试连接",
|
||||
"testing": "测试中...",
|
||||
"testSuccess": "连接成功",
|
||||
"testFailed": "连接失败:{{error}}",
|
||||
"save": "保存配置",
|
||||
"saving": "保存中...",
|
||||
"saveFailed": "保存配置失败:{{error}}",
|
||||
"upload": "上传到云端",
|
||||
"uploading": "上传中...",
|
||||
"uploadSuccess": "已上传到 WebDAV",
|
||||
"uploadFailed": "上传失败:{{error}}",
|
||||
"download": "从云端下载",
|
||||
"downloading": "下载中...",
|
||||
"downloadSuccess": "已从 WebDAV 下载并恢复",
|
||||
"downloadFailed": "下载失败:{{error}}",
|
||||
"lastSync": "上次同步:{{time}}",
|
||||
"missingUrl": "请填写 WebDAV 服务器地址",
|
||||
"presets": {
|
||||
"label": "服务商",
|
||||
"jianguoyun": "坚果云",
|
||||
"jianguoyunHint": "请在坚果云「安全选项」中生成「第三方应用密码」,不要使用登录密码。",
|
||||
"nextcloud": "Nextcloud",
|
||||
"nextcloudHint": "请将 your-server 替换为你的 Nextcloud 服务器地址,USERNAME 替换为你的用户名。",
|
||||
"synology": "群晖 NAS",
|
||||
"synologyHint": "请先在群晖「套件中心」安装并启用 WebDAV Server 套件。",
|
||||
"custom": "自定义"
|
||||
},
|
||||
"remoteRootDefault": "默认: cc-switch-sync",
|
||||
"profileDefault": "默认: default",
|
||||
"saveAndTestSuccess": "配置已保存,连接正常",
|
||||
"saveAndTestFailed": "配置已保存,但连接测试失败:{{error}}",
|
||||
"noRemoteData": "云端没有找到同步数据",
|
||||
"incompatibleVersion": "远端数据版本不兼容(v{{version}}),当前支持 v2",
|
||||
"unsaved": "未保存",
|
||||
"saved": "已保存",
|
||||
"unsavedChanges": "请先保存配置",
|
||||
"saveBeforeSync": "请先保存配置,再使用上传/下载。",
|
||||
"fetchingRemote": "获取远端信息...",
|
||||
"fetchRemoteFailed": "获取远端信息失败,请检查配置和网络后重试。",
|
||||
"confirmDownload": {
|
||||
"title": "从云端恢复",
|
||||
"deviceName": "上传设备",
|
||||
"createdAt": "上传时间",
|
||||
"artifacts": "包含内容",
|
||||
"warning": "恢复将覆盖本地所有数据和技能配置",
|
||||
"confirm": "确认恢复"
|
||||
},
|
||||
"confirmUpload": {
|
||||
"title": "上传到云端",
|
||||
"content": "将同步以下内容到 WebDAV 服务器:",
|
||||
"dbItem": "数据库(所有 Provider 配置和数据)",
|
||||
"skillsItem": "技能包(所有自定义技能)",
|
||||
"targetPath": "目标路径",
|
||||
"existingData": "云端已有数据",
|
||||
"deviceName": "上传设备",
|
||||
"createdAt": "上传时间",
|
||||
"warning": "将覆盖云端已有的同步数据",
|
||||
"confirm": "确认上传"
|
||||
}
|
||||
},
|
||||
"autoReload": "数据已刷新",
|
||||
"languageOptionChinese": "中文",
|
||||
"languageOptionEnglish": "English",
|
||||
@@ -413,7 +492,8 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw"
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "会话管理",
|
||||
@@ -496,7 +576,6 @@
|
||||
"officialHint": "💡 官方供应商使用浏览器登录,无需配置 API Key",
|
||||
"getApiKey": "获取 API Key",
|
||||
"partnerPromotion": {
|
||||
"zhipu": "智谱 GLM 是 CC Switch 的官方合作伙伴,使用此链接充值可以获得9折优惠",
|
||||
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠",
|
||||
"minimax_cn": "MiniMax Coding Plan 特惠,Starter 套餐 9.9 元起",
|
||||
"minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)",
|
||||
@@ -673,6 +752,38 @@
|
||||
"modelOptionKeyPlaceholder": "provider",
|
||||
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
|
||||
},
|
||||
"openclaw": {
|
||||
"providerKey": "供应商标识",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
|
||||
"providerKeyRequired": "请填写供应商标识",
|
||||
"providerKeyDuplicate": "此标识已被使用,请更换",
|
||||
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
|
||||
"apiProtocol": "API 协议",
|
||||
"selectProtocol": "选择 API 协议",
|
||||
"apiProtocolHint": "选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。",
|
||||
"baseUrl": "API 端点",
|
||||
"baseUrlHint": "供应商的 API 端点地址。",
|
||||
"models": "模型列表",
|
||||
"addModel": "添加模型",
|
||||
"noModels": "暂无模型配置。点击添加模型来配置可用模型。",
|
||||
"modelId": "模型 ID",
|
||||
"modelIdPlaceholder": "claude-3-sonnet",
|
||||
"modelName": "显示名称",
|
||||
"modelNamePlaceholder": "Claude 3 Sonnet",
|
||||
"contextWindow": "上下文窗口",
|
||||
"maxTokens": "最大输出 Tokens",
|
||||
"reasoning": "推理模式",
|
||||
"reasoningOn": "启用",
|
||||
"reasoningOff": "关闭",
|
||||
"inputCost": "输入价格 ($/M tokens)",
|
||||
"outputCost": "输出价格 ($/M tokens)",
|
||||
"advancedOptions": "高级选项",
|
||||
"cacheReadCost": "缓存读取价格 ($/M tokens)",
|
||||
"cacheWriteCost": "缓存写入价格 ($/M tokens)",
|
||||
"cacheCostHint": "缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。",
|
||||
"modelsHint": "配置该供应商支持的模型。模型 ID 用于 API 调用,显示名称用于界面展示。"
|
||||
},
|
||||
"providerPreset": {
|
||||
"label": "预设供应商",
|
||||
"custom": "自定义配置",
|
||||
@@ -868,7 +979,8 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw"
|
||||
}
|
||||
},
|
||||
"userLevelPath": "用户级 MCP 配置路径",
|
||||
@@ -1041,6 +1153,74 @@
|
||||
"deleteMessage": "确定要删除提示词 \"{{name}}\" 吗?"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"title": "Workspace 文件管理",
|
||||
"manage": "Workspace",
|
||||
"files": {
|
||||
"agents": "Agent 操作指令和规则",
|
||||
"soul": "Agent 人格和沟通风格",
|
||||
"user": "用户档案和偏好",
|
||||
"identity": "Agent 名称和头像",
|
||||
"tools": "本地工具文档",
|
||||
"memory": "长期记忆和决策记录",
|
||||
"heartbeat": "心跳运行清单",
|
||||
"bootstrap": "首次运行仪式",
|
||||
"boot": "网关重启清单"
|
||||
},
|
||||
"editing": "编辑 {{filename}}",
|
||||
"saveSuccess": "保存成功",
|
||||
"saveFailed": "保存失败",
|
||||
"loadFailed": "读取失败"
|
||||
},
|
||||
"openclaw": {
|
||||
"env": {
|
||||
"title": "环境变量",
|
||||
"description": "管理 openclaw.json 中的环境变量配置(API Key、自定义变量等)",
|
||||
"keyPlaceholder": "变量名",
|
||||
"valuePlaceholder": "值",
|
||||
"add": "添加变量",
|
||||
"saveSuccess": "环境变量已保存",
|
||||
"saveFailed": "保存环境变量失败",
|
||||
"loadFailed": "读取环境变量失败",
|
||||
"duplicateKey": "检测到重复的变量名: {{key}}"
|
||||
},
|
||||
"tools": {
|
||||
"title": "工具权限",
|
||||
"description": "管理 openclaw.json 中的工具权限配置(允许/拒绝列表)",
|
||||
"profile": "权限模式",
|
||||
"profiles": {
|
||||
"default": "默认",
|
||||
"strict": "严格",
|
||||
"permissive": "宽松",
|
||||
"custom": "自定义"
|
||||
},
|
||||
"allowList": "允许列表",
|
||||
"denyList": "拒绝列表",
|
||||
"patternPlaceholder": "工具名称或模式",
|
||||
"addAllow": "添加允许",
|
||||
"addDeny": "添加拒绝",
|
||||
"saveSuccess": "工具权限已保存",
|
||||
"saveFailed": "保存工具权限失败",
|
||||
"loadFailed": "读取工具权限失败"
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agents 配置",
|
||||
"description": "管理 openclaw.json 中的 agents.defaults 配置(默认模型、运行参数等)",
|
||||
"modelSection": "模型配置",
|
||||
"primaryModel": "主模型",
|
||||
"primaryModelHint": "格式:provider/model-id",
|
||||
"fallbackModels": "回退模型",
|
||||
"fallbackModelsHint": "逗号分隔,按优先级排列",
|
||||
"runtimeSection": "运行参数",
|
||||
"workspace": "工作区路径",
|
||||
"timeout": "超时时间(秒)",
|
||||
"contextTokens": "上下文 Token 数",
|
||||
"maxConcurrent": "最大并发数",
|
||||
"saveSuccess": "Agents 配置已保存",
|
||||
"saveFailed": "保存 Agents 配置失败",
|
||||
"loadFailed": "读取 Agents 配置失败"
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"warning": {
|
||||
"title": "检测到系统环境变量冲突",
|
||||
@@ -1183,7 +1363,8 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw"
|
||||
},
|
||||
"installFromZip": {
|
||||
"button": "从 ZIP 安装",
|
||||
@@ -1590,16 +1771,21 @@
|
||||
"clearWrapped": "(清空)",
|
||||
"defaultWrapped": "(默认)",
|
||||
"variantPlaceholder": "思考等级",
|
||||
"selectEnabledModel": "选择已启用模型",
|
||||
"selectEnabledModel": "选择已配置模型",
|
||||
"selectModel": "选择已配置模型",
|
||||
"recommendedHint": "推荐: {{model}}",
|
||||
"searchModel": "搜索模型...",
|
||||
"selectModelFirst": "先选择模型",
|
||||
"noEnabledModels": "暂无已启用模型",
|
||||
"noEnabledModels": "暂无已配置模型",
|
||||
"noVariantsForModel": "该模型无思考等级",
|
||||
"currentValueNotEnabled": "{{value}}(当前值,未启用)",
|
||||
"currentValueUnavailable": "{{value}}(当前值,未启用)",
|
||||
"currentValueNotEnabled": "{{value}}(当前值,未配置)",
|
||||
"currentValueUnavailable": "{{value}}(当前值,不可用)",
|
||||
"advancedLabel": "高级参数",
|
||||
"advancedJsonInvalid": "高级参数 JSON 无效",
|
||||
"advancedJsonHint": "temperature, top_p, budgetTokens, prompt_append, permission 等,留空使用默认值",
|
||||
"noEnabledModelsWarning": "当前没有可用的已启用模型,请先启用并配置 OpenCode 模型",
|
||||
"noEnabledModelsWarning": "当前没有可用的已配置模型,请先配置 OpenCode 模型",
|
||||
"modelSourcePartialWarning": "部分供应商模型配置无效,已自动跳过。",
|
||||
"modelSourceFallbackWarning": "读取 live 供应商状态失败,已回退到已配置供应商列表。",
|
||||
"importLocalReplaceSuccess": "已从本地文件导入并覆盖 Agent/Category/Other Fields",
|
||||
"importLocalFailed": "读取本地文件失败: {{error}}",
|
||||
"agentKeyPlaceholder": "agent 键名",
|
||||
@@ -1610,7 +1796,7 @@
|
||||
"modelConfiguration": "模型配置",
|
||||
"fillRecommended": "填充推荐",
|
||||
"configSummary": "已配置 {{agents}} 个 Agent,{{categories}} 个 Category · 点击 ⚙ 展开高级参数",
|
||||
"enabledModelsCount": "可选已启用模型 {{count}} 个",
|
||||
"enabledModelsCount": "可选已配置模型 {{count}} 个",
|
||||
"source": "来源:",
|
||||
"otherFieldsJson": "其他字段 (JSON)",
|
||||
"searchOrType": "搜索或输入自定义值...",
|
||||
@@ -1636,6 +1822,81 @@
|
||||
"advancedExperimental": "实验性功能",
|
||||
"advancedBackgroundTask": "后台任务",
|
||||
"advancedBrowserAutomation": "浏览器自动化",
|
||||
"advancedClaudeCode": "Claude Code"
|
||||
"advancedClaudeCode": "Claude Code",
|
||||
"agentDesc": {
|
||||
"sisyphus": "主编排者",
|
||||
"hephaestus": "自主深度工作者",
|
||||
"prometheus": "战略规划者",
|
||||
"atlas": "任务管理者",
|
||||
"oracle": "战略顾问",
|
||||
"librarian": "多仓库研究员",
|
||||
"explore": "快速代码搜索",
|
||||
"multimodalLooker": "媒体分析器",
|
||||
"metis": "规划前分析顾问",
|
||||
"momus": "计划审查者",
|
||||
"sisyphusJunior": "委托任务执行器"
|
||||
},
|
||||
"agentTooltip": {
|
||||
"sisyphus": "主编排器,负责任务规划、委派与并行执行,使用扩展思考(32k 预算),通过 TODO 驱动工作流确保任务完成。",
|
||||
"atlas": "主编排器(持有 TODO 列表),负责执行阶段的任务分发与协调,不直接完成所有工作而是委派给专业代理。",
|
||||
"prometheus": "战略规划师,通过访谈模式收集需求并制定详细工作计划,仅能在 .sisyphus/ 目录读写 Markdown 文件,从不直接写代码。",
|
||||
"hephaestus": "自主深度工作者(「合法工匠」),受 AmpCode 深度模式启发,目标导向执行,行动前会并行启动 2-5 个探索/图书管理员代理进行研究。",
|
||||
"oracle": "架构决策与调试顾问,只读咨询代理,提供出色的逻辑推理和深度分析,不能写文件或委派任务。",
|
||||
"librarian": "多仓库分析与文档检索专家,深度理解代码库并提供基于证据的答案,擅长查找官方文档和开源实现示例。",
|
||||
"explore": "快速代码库探索与上下文 grep 专家,使用轻量级模型进行高速搜索,是理解代码结构的先锋。",
|
||||
"multimodalLooker": "视觉内容专家,分析 PDF、图片、图表等非文本媒体,提取其中的信息与洞察。",
|
||||
"metis": "计划顾问,在规划前进行预分析,识别隐藏意图、模糊点和 AI 失败点,防止过度工程化。",
|
||||
"momus": "计划评审员,高精度验证计划的清晰度、可验证性和完整性,拒绝并要求修订直到计划完美。",
|
||||
"sisyphusJunior": "类别生成的执行器,由 category 参数自动生成,专注于执行分配的任务且不能再委派,防止无限委派循环。"
|
||||
},
|
||||
"categoryDesc": {
|
||||
"visualEngineering": "视觉/前端工程",
|
||||
"ultrabrain": "超级思考",
|
||||
"deep": "深度工作",
|
||||
"artistry": "创意/文艺",
|
||||
"quick": "快速响应",
|
||||
"unspecifiedLow": "通用低配",
|
||||
"unspecifiedHigh": "通用高配",
|
||||
"writing": "写作"
|
||||
},
|
||||
"categoryTooltip": {
|
||||
"visualEngineering": "前端与视觉工程类别,专注于 UI/UX 设计、样式、动画和界面实现,默认使用 Gemini 3 Pro 模型。",
|
||||
"ultrabrain": "深度逻辑推理类别,用于需要广泛分析的复杂架构决策,默认使用 GPT-5.3 Codex 的超高推理变体。",
|
||||
"deep": "深度自主问题解决类别,目标导向执行,行动前进行彻底研究,适用于需要深度理解的棘手问题。",
|
||||
"artistry": "高度创意与艺术性任务类别,激发新颖想法和创造性解决方案,默认使用 Gemini 3 Pro 的最大变体。",
|
||||
"quick": "轻量任务类别,用于单文件修改、错别字修复、简单调整等琐碎工作,默认使用 Claude Haiku 4.5 快速模型。",
|
||||
"unspecifiedLow": "未归类低工作量任务类别,适用于不适合其他类别且工作量较小的任务,默认使用 Claude Sonnet 4.5。",
|
||||
"unspecifiedHigh": "未归类高工作量任务类别,适用于不适合其他类别且工作量较大的任务,默认使用 Claude Opus 4.6 的最大变体。",
|
||||
"writing": "写作类别,专注于文档、散文和技术写作,默认使用 Gemini 3 Flash 快速生成模型。"
|
||||
}
|
||||
},
|
||||
"openclawConfig": {
|
||||
"defaultModel": {
|
||||
"title": "默认模型",
|
||||
"description": "配置 OpenClaw 的默认主模型和回退模型",
|
||||
"primary": "主模型",
|
||||
"primaryPlaceholder": "例如: deepseek/deepseek-chat",
|
||||
"fallbacks": "回退模型",
|
||||
"fallbacksPlaceholder": "例如: openrouter/anthropic/claude-sonnet-4.5",
|
||||
"addFallback": "添加回退模型",
|
||||
"saved": "默认模型配置已保存",
|
||||
"saveFailed": "保存默认模型失败"
|
||||
},
|
||||
"modelCatalog": {
|
||||
"title": "模型目录",
|
||||
"description": "配置可用模型的别名和允许列表",
|
||||
"modelId": "模型 ID",
|
||||
"modelIdPlaceholder": "例如: deepseek/deepseek-chat",
|
||||
"alias": "别名",
|
||||
"aliasPlaceholder": "例如: DeepSeek",
|
||||
"addEntry": "添加模型",
|
||||
"removeEntry": "移除",
|
||||
"saved": "模型目录已保存",
|
||||
"saveFailed": "保存模型目录失败",
|
||||
"empty": "暂无模型目录配置",
|
||||
"emptyHint": "添加模型到目录以配置别名"
|
||||
},
|
||||
"suggestedDefaults": "应用建议默认配置",
|
||||
"suggestedDefaultsHint": "使用此预设推荐的默认模型配置"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<svg viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="lobster-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ff4d4d"/>
|
||||
<stop offset="100%" stop-color="#991b1b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Body -->
|
||||
<path d="M60 10 C30 10 15 35 15 55 C15 75 30 95 45 100 L45 110 L55 110 L55 100 C55 100 60 102 65 100 L65 110 L75 110 L75 100 C90 95 105 75 105 55 C105 35 90 10 60 10Z" fill="url(#lobster-gradient)"/>
|
||||
<!-- Left Claw -->
|
||||
<path d="M20 45 C5 40 0 50 5 60 C10 70 20 65 25 55 C28 48 25 45 20 45Z" fill="url(#lobster-gradient)"/>
|
||||
<!-- Right Claw -->
|
||||
<path d="M100 45 C115 40 120 50 115 60 C110 70 100 65 95 55 C92 48 95 45 100 45Z" fill="url(#lobster-gradient)"/>
|
||||
<!-- Antenna -->
|
||||
<path d="M45 15 Q35 5 30 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M75 15 Q85 5 90 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round"/>
|
||||
<!-- Eyes -->
|
||||
<circle cx="45" cy="35" r="6" fill="#050810"/>
|
||||
<circle cx="75" cy="35" r="6" fill="#050810"/>
|
||||
<circle cx="46" cy="34" r="2.5" fill="#00e5cc"/>
|
||||
<circle cx="76" cy="34" r="2.5" fill="#00e5cc"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
File diff suppressed because one or more lines are too long
@@ -247,6 +247,13 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
keywords: ["gpt", "chatgpt"],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
openclaw: {
|
||||
name: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
category: "ai-provider",
|
||||
keywords: ["openclaw", "lobster", "claw"],
|
||||
defaultColor: "#ff4f40",
|
||||
},
|
||||
packycode: {
|
||||
name: "packycode",
|
||||
displayName: "PackyCode",
|
||||
|
||||
@@ -7,7 +7,9 @@ export { skillsApi } from "./skills";
|
||||
export { usageApi } from "./usage";
|
||||
export { vscodeApi } from "./vscode";
|
||||
export { proxyApi } from "./proxy";
|
||||
export { openclawApi } from "./openclaw";
|
||||
export { sessionsApi } from "./sessions";
|
||||
export { workspaceApi } from "./workspace";
|
||||
export * as configApi from "./config";
|
||||
export type { ProviderSwitchEvent } from "./providers";
|
||||
export type { Prompt } from "./prompts";
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
OpenClawDefaultModel,
|
||||
OpenClawModelCatalogEntry,
|
||||
OpenClawAgentsDefaults,
|
||||
OpenClawEnvConfig,
|
||||
OpenClawToolsConfig,
|
||||
} from "@/types";
|
||||
|
||||
/**
|
||||
* OpenClaw configuration API
|
||||
*
|
||||
* Manages ~/.openclaw/openclaw.json sections:
|
||||
* - agents.defaults (model, catalog)
|
||||
* - env (environment variables)
|
||||
* - tools (permissions)
|
||||
*/
|
||||
export const openclawApi = {
|
||||
// ============================================================
|
||||
// Agents Configuration
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get default model configuration (agents.defaults.model)
|
||||
*/
|
||||
async getDefaultModel(): Promise<OpenClawDefaultModel | null> {
|
||||
return await invoke("get_openclaw_default_model");
|
||||
},
|
||||
|
||||
/**
|
||||
* Set default model configuration (agents.defaults.model)
|
||||
*/
|
||||
async setDefaultModel(model: OpenClawDefaultModel): Promise<void> {
|
||||
return await invoke("set_openclaw_default_model", { model });
|
||||
},
|
||||
|
||||
/**
|
||||
* Get model catalog/allowlist (agents.defaults.models)
|
||||
*/
|
||||
async getModelCatalog(): Promise<Record<
|
||||
string,
|
||||
OpenClawModelCatalogEntry
|
||||
> | null> {
|
||||
return await invoke("get_openclaw_model_catalog");
|
||||
},
|
||||
|
||||
/**
|
||||
* Set model catalog/allowlist (agents.defaults.models)
|
||||
*/
|
||||
async setModelCatalog(
|
||||
catalog: Record<string, OpenClawModelCatalogEntry>,
|
||||
): Promise<void> {
|
||||
return await invoke("set_openclaw_model_catalog", { catalog });
|
||||
},
|
||||
|
||||
/**
|
||||
* Get full agents.defaults config (all fields)
|
||||
*/
|
||||
async getAgentsDefaults(): Promise<OpenClawAgentsDefaults | null> {
|
||||
return await invoke("get_openclaw_agents_defaults");
|
||||
},
|
||||
|
||||
/**
|
||||
* Set full agents.defaults config (all fields)
|
||||
*/
|
||||
async setAgentsDefaults(defaults: OpenClawAgentsDefaults): Promise<void> {
|
||||
return await invoke("set_openclaw_agents_defaults", { defaults });
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// Env Configuration
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get env config (env section of openclaw.json)
|
||||
*/
|
||||
async getEnv(): Promise<OpenClawEnvConfig> {
|
||||
return await invoke("get_openclaw_env");
|
||||
},
|
||||
|
||||
/**
|
||||
* Set env config (env section of openclaw.json)
|
||||
*/
|
||||
async setEnv(env: OpenClawEnvConfig): Promise<void> {
|
||||
return await invoke("set_openclaw_env", { env });
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// Tools Configuration
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get tools config (tools section of openclaw.json)
|
||||
*/
|
||||
async getTools(): Promise<OpenClawToolsConfig> {
|
||||
return await invoke("get_openclaw_tools");
|
||||
},
|
||||
|
||||
/**
|
||||
* Set tools config (tools section of openclaw.json)
|
||||
*/
|
||||
async setTools(tools: OpenClawToolsConfig): Promise<void> {
|
||||
return await invoke("set_openclaw_tools", { tools });
|
||||
},
|
||||
};
|
||||
@@ -98,6 +98,14 @@ export const providersApi = {
|
||||
async getOpenCodeLiveProviderIds(): Promise<string[]> {
|
||||
return await invoke("get_opencode_live_provider_ids");
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 OpenClaw live 配置中的供应商 ID 列表
|
||||
* 用于前端判断供应商是否已添加到 openclaw.json
|
||||
*/
|
||||
async getOpenClawLiveProviderIds(): Promise<string[]> {
|
||||
return await invoke("get_openclaw_live_provider_ids");
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
+47
-1
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Settings } from "@/types";
|
||||
import type { Settings, WebDavSyncSettings, RemoteSnapshotInfo } from "@/types";
|
||||
import type { AppId } from "./types";
|
||||
|
||||
export interface ConfigTransferResult {
|
||||
@@ -9,6 +9,15 @@ export interface ConfigTransferResult {
|
||||
backupId?: string;
|
||||
}
|
||||
|
||||
export interface WebDavTestResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface WebDavSyncResult {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
async get(): Promise<Settings> {
|
||||
return await invoke("get_settings");
|
||||
@@ -93,6 +102,42 @@ export const settingsApi = {
|
||||
return await invoke("import_config_from_file", { filePath });
|
||||
},
|
||||
|
||||
// ─── WebDAV v2 sync ───────────────────────────────────────
|
||||
|
||||
async webdavTestConnection(
|
||||
settings: WebDavSyncSettings,
|
||||
preserveEmptyPassword = true,
|
||||
): Promise<WebDavTestResult> {
|
||||
return await invoke("webdav_test_connection", {
|
||||
settings,
|
||||
preserveEmptyPassword,
|
||||
});
|
||||
},
|
||||
|
||||
async webdavSyncUpload(): Promise<WebDavSyncResult> {
|
||||
return await invoke("webdav_sync_upload");
|
||||
},
|
||||
|
||||
async webdavSyncDownload(): Promise<WebDavSyncResult> {
|
||||
return await invoke("webdav_sync_download");
|
||||
},
|
||||
|
||||
async webdavSyncSaveSettings(
|
||||
settings: WebDavSyncSettings,
|
||||
passwordTouched = false,
|
||||
): Promise<{ success: boolean }> {
|
||||
return await invoke("webdav_sync_save_settings", {
|
||||
settings,
|
||||
passwordTouched,
|
||||
});
|
||||
},
|
||||
|
||||
async webdavSyncFetchRemoteInfo(): Promise<
|
||||
RemoteSnapshotInfo | { empty: true }
|
||||
> {
|
||||
return await invoke("webdav_sync_fetch_remote_info");
|
||||
},
|
||||
|
||||
async syncCurrentProvidersLive(): Promise<void> {
|
||||
const result = (await invoke("sync_current_providers_live")) as {
|
||||
success?: boolean;
|
||||
@@ -155,6 +200,7 @@ export const settingsApi = {
|
||||
export interface RectifierConfig {
|
||||
enabled: boolean;
|
||||
requestThinkingSignature: boolean;
|
||||
requestThinkingBudget: boolean;
|
||||
}
|
||||
|
||||
export interface LogConfig {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
|
||||
// ========== 类型定义 ==========
|
||||
export type AppType = "claude" | "codex" | "gemini" | "opencode" | "openclaw";
|
||||
|
||||
/** Skill 应用启用状态 */
|
||||
export interface SkillApps {
|
||||
@@ -10,6 +10,7 @@ export interface SkillApps {
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
opencode: boolean;
|
||||
openclaw: boolean;
|
||||
}
|
||||
|
||||
/** 已安装的 Skill(v3.10.0+ 统一结构) */
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// 前端统一使用 AppId 作为应用标识(与后端命令参数 `app` 一致)
|
||||
export type AppId = "claude" | "codex" | "gemini" | "opencode";
|
||||
export type AppId = "claude" | "codex" | "gemini" | "opencode" | "openclaw";
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export const workspaceApi = {
|
||||
async readFile(filename: string): Promise<string | null> {
|
||||
return invoke<string | null>("read_workspace_file", { filename });
|
||||
},
|
||||
|
||||
async writeFile(filename: string, content: string): Promise<void> {
|
||||
return invoke<void>("write_workspace_file", { filename, content });
|
||||
},
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { providersApi, settingsApi, type AppId } from "@/lib/api";
|
||||
import type { Provider, Settings } from "@/types";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { generateUUID } from "@/utils/uuid";
|
||||
import { openclawKeys } from "@/hooks/useOpenClaw";
|
||||
|
||||
export const useAddProviderMutation = (appId: AppId) => {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -16,12 +17,12 @@ export const useAddProviderMutation = (appId: AppId) => {
|
||||
) => {
|
||||
let id: string;
|
||||
|
||||
if (appId === "opencode") {
|
||||
if (appId === "opencode" || appId === "openclaw") {
|
||||
if (providerInput.category === "omo") {
|
||||
id = `omo-${generateUUID()}`;
|
||||
} else {
|
||||
if (!providerInput.providerKey) {
|
||||
throw new Error("Provider key is required for OpenCode");
|
||||
throw new Error(`Provider key is required for ${appId}`);
|
||||
}
|
||||
id = providerInput.providerKey;
|
||||
}
|
||||
@@ -29,8 +30,10 @@ export const useAddProviderMutation = (appId: AppId) => {
|
||||
id = generateUUID();
|
||||
}
|
||||
|
||||
const { providerKey: _providerKey, ...rest } = providerInput;
|
||||
|
||||
const newProvider: Provider = {
|
||||
...providerInput,
|
||||
...rest,
|
||||
id,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
@@ -174,6 +177,7 @@ export const useSwitchProviderMutation = (appId: AppId) => {
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["providers", appId] });
|
||||
|
||||
// OpenCode/OpenClaw: also invalidate live provider IDs cache to update button state
|
||||
if (appId === "opencode") {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["opencodeLiveProviderIds"],
|
||||
@@ -182,6 +186,14 @@ export const useSwitchProviderMutation = (appId: AppId) => {
|
||||
queryKey: ["omo", "current-provider-id"],
|
||||
});
|
||||
}
|
||||
if (appId === "openclaw") {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: openclawKeys.liveProviderIds,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: openclawKeys.defaultModel,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await providersApi.updateTrayMenu();
|
||||
|
||||
@@ -4,8 +4,8 @@ export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 0,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
|
||||
@@ -28,6 +28,27 @@ export const settingsSchema = z.object({
|
||||
|
||||
// Skill 同步设置
|
||||
skillSyncMethod: z.enum(["auto", "symlink", "copy"]).optional(),
|
||||
|
||||
// WebDAV v2 同步设置(通过专用命令保存,schema 仅用于读取)
|
||||
webdavSync: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
baseUrl: z.string().trim().optional().or(z.literal("")),
|
||||
username: z.string().trim().optional().or(z.literal("")),
|
||||
password: z.string().optional(),
|
||||
remoteRoot: z.string().trim().optional().or(z.literal("")),
|
||||
profile: z.string().trim().optional().or(z.literal("")),
|
||||
status: z
|
||||
.object({
|
||||
lastSyncAt: z.number().nullable().optional(),
|
||||
lastError: z.string().nullable().optional(),
|
||||
lastRemoteEtag: z.string().nullable().optional(),
|
||||
lastLocalManifestHash: z.string().nullable().optional(),
|
||||
lastRemoteManifestHash: z.string().nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type SettingsFormData = z.infer<typeof settingsSchema>;
|
||||
|
||||
@@ -163,6 +163,37 @@ export interface VisibleApps {
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
opencode: boolean;
|
||||
openclaw: boolean;
|
||||
}
|
||||
|
||||
// WebDAV v2 同步状态
|
||||
export interface WebDavSyncStatus {
|
||||
lastSyncAt?: number | null;
|
||||
lastError?: string | null;
|
||||
lastRemoteEtag?: string | null;
|
||||
lastLocalManifestHash?: string | null;
|
||||
lastRemoteManifestHash?: string | null;
|
||||
}
|
||||
|
||||
// WebDAV v2 同步配置
|
||||
export interface WebDavSyncSettings {
|
||||
enabled?: boolean;
|
||||
baseUrl?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
remoteRoot?: string;
|
||||
profile?: string;
|
||||
status?: WebDavSyncStatus;
|
||||
}
|
||||
|
||||
// 远端快照信息(下载前预览)
|
||||
export interface RemoteSnapshotInfo {
|
||||
deviceName: string;
|
||||
createdAt: string;
|
||||
snapshotId: string;
|
||||
version: number;
|
||||
compatible: boolean;
|
||||
artifacts: string[];
|
||||
}
|
||||
|
||||
// 应用设置类型(用于设置对话框与 Tauri API)
|
||||
@@ -196,6 +227,8 @@ export interface Settings {
|
||||
geminiConfigDir?: string;
|
||||
// 覆盖 OpenCode 配置目录(可选)
|
||||
opencodeConfigDir?: string;
|
||||
// 覆盖 OpenClaw 配置目录(可选)
|
||||
openclawConfigDir?: string;
|
||||
|
||||
// ===== 当前供应商 ID(设备级)=====
|
||||
// 当前 Claude 供应商 ID(优先于数据库 is_current)
|
||||
@@ -209,6 +242,9 @@ export interface Settings {
|
||||
// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
|
||||
skillSyncMethod?: SkillSyncMethod;
|
||||
|
||||
// ===== WebDAV v2 同步设置 =====
|
||||
webdavSync?: WebDavSyncSettings;
|
||||
|
||||
// ===== 终端设置 =====
|
||||
// 首选终端应用(可选,默认使用系统默认终端)
|
||||
// macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
|
||||
@@ -257,6 +293,7 @@ export interface McpApps {
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
opencode: boolean;
|
||||
openclaw: boolean;
|
||||
}
|
||||
|
||||
// MCP 服务器条目(v3.7.0 统一结构)
|
||||
@@ -394,3 +431,64 @@ export interface OpenCodeMcpServerSpec {
|
||||
// 通用字段
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OpenClaw 专属配置(v3.11.0+)
|
||||
// ============================================================================
|
||||
|
||||
// OpenClaw 模型配置
|
||||
export interface OpenClawModel {
|
||||
id: string;
|
||||
name: string;
|
||||
alias?: string;
|
||||
reasoning?: boolean; // 是否支持推理模式(如 o1、DeepSeek R1)
|
||||
input?: string[]; // 支持的输入类型(如 ["text"]、["text", "image"])
|
||||
cost?: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead?: number; // 缓存读取价格
|
||||
cacheWrite?: number; // 缓存写入价格
|
||||
};
|
||||
contextWindow?: number;
|
||||
maxTokens?: number; // 最大输出 token 数
|
||||
}
|
||||
|
||||
// OpenClaw 默认模型配置(agents.defaults.model)
|
||||
export interface OpenClawDefaultModel {
|
||||
primary: string;
|
||||
fallbacks?: string[];
|
||||
}
|
||||
|
||||
// OpenClaw 模型目录条目(agents.defaults.models 中的值)
|
||||
export interface OpenClawModelCatalogEntry {
|
||||
alias?: string;
|
||||
}
|
||||
|
||||
// OpenClaw 供应商配置(settings_config 结构)
|
||||
// 对应 OpenClaw 的 models.providers.<provider-id> 配置
|
||||
export interface OpenClawProviderConfig {
|
||||
baseUrl?: string; // API 端点
|
||||
apiKey?: string; // API 密钥
|
||||
api?: string; // API 协议类型(如 "openai-completions"、"anthropic")
|
||||
models?: OpenClawModel[]; // 可用模型列表
|
||||
}
|
||||
|
||||
// OpenClaw agents.defaults 完整配置
|
||||
export interface OpenClawAgentsDefaults {
|
||||
model?: OpenClawDefaultModel;
|
||||
models?: Record<string, OpenClawModelCatalogEntry>;
|
||||
[key: string]: unknown; // preserve unknown fields
|
||||
}
|
||||
|
||||
// OpenClaw env 配置(openclaw.json 的 env 节点)
|
||||
export interface OpenClawEnvConfig {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// OpenClaw tools 配置(openclaw.json 的 tools 节点)
|
||||
export interface OpenClawToolsConfig {
|
||||
profile?: string;
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
[key: string]: unknown; // preserve unknown fields
|
||||
}
|
||||
|
||||
+62
-50
@@ -27,8 +27,8 @@ export interface OmoLocalFileData {
|
||||
export interface OmoAgentDef {
|
||||
key: string;
|
||||
display: string;
|
||||
descZh: string;
|
||||
descEn: string;
|
||||
descKey: string;
|
||||
tooltipKey: string;
|
||||
recommended?: string;
|
||||
group: "main" | "sub";
|
||||
}
|
||||
@@ -36,97 +36,97 @@ export interface OmoAgentDef {
|
||||
export interface OmoCategoryDef {
|
||||
key: string;
|
||||
display: string;
|
||||
descZh: string;
|
||||
descEn: string;
|
||||
descKey: string;
|
||||
tooltipKey: string;
|
||||
recommended?: string;
|
||||
}
|
||||
|
||||
export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [
|
||||
{
|
||||
key: "Sisyphus",
|
||||
key: "sisyphus",
|
||||
display: "Sisyphus",
|
||||
descZh: "主编排者",
|
||||
descEn: "Main orchestrator",
|
||||
descKey: "omo.agentDesc.sisyphus",
|
||||
tooltipKey: "omo.agentTooltip.sisyphus",
|
||||
recommended: "claude-opus-4-6",
|
||||
group: "main",
|
||||
},
|
||||
{
|
||||
key: "Hephaestus",
|
||||
key: "hephaestus",
|
||||
display: "Hephaestus",
|
||||
descZh: "自主深度工作者",
|
||||
descEn: "Autonomous deep worker",
|
||||
descKey: "omo.agentDesc.hephaestus",
|
||||
tooltipKey: "omo.agentTooltip.hephaestus",
|
||||
recommended: "gpt-5.3-codex",
|
||||
group: "main",
|
||||
},
|
||||
{
|
||||
key: "Prometheus",
|
||||
key: "prometheus",
|
||||
display: "Prometheus",
|
||||
descZh: "战略规划者",
|
||||
descEn: "Strategic planner",
|
||||
descKey: "omo.agentDesc.prometheus",
|
||||
tooltipKey: "omo.agentTooltip.prometheus",
|
||||
recommended: "claude-opus-4-6",
|
||||
group: "main",
|
||||
},
|
||||
{
|
||||
key: "Atlas",
|
||||
key: "atlas",
|
||||
display: "Atlas",
|
||||
descZh: "任务管理者",
|
||||
descEn: "Task manager",
|
||||
descKey: "omo.agentDesc.atlas",
|
||||
tooltipKey: "omo.agentTooltip.atlas",
|
||||
recommended: "kimi-k2.5",
|
||||
group: "main",
|
||||
},
|
||||
{
|
||||
key: "oracle",
|
||||
display: "Oracle",
|
||||
descZh: "战略顾问",
|
||||
descEn: "Strategic advisor",
|
||||
descKey: "omo.agentDesc.oracle",
|
||||
tooltipKey: "omo.agentTooltip.oracle",
|
||||
recommended: "gpt-5.3",
|
||||
group: "sub",
|
||||
},
|
||||
{
|
||||
key: "librarian",
|
||||
display: "Librarian",
|
||||
descZh: "多仓库研究员",
|
||||
descEn: "Multi-repo researcher",
|
||||
descKey: "omo.agentDesc.librarian",
|
||||
tooltipKey: "omo.agentTooltip.librarian",
|
||||
recommended: "glm-4.7",
|
||||
group: "sub",
|
||||
},
|
||||
{
|
||||
key: "explore",
|
||||
display: "Explore",
|
||||
descZh: "快速代码搜索",
|
||||
descEn: "Fast code search",
|
||||
descKey: "omo.agentDesc.explore",
|
||||
tooltipKey: "omo.agentTooltip.explore",
|
||||
recommended: "grok-code-fast-1",
|
||||
group: "sub",
|
||||
},
|
||||
{
|
||||
key: "multimodal-looker",
|
||||
display: "Multimodal-Looker",
|
||||
descZh: "媒体分析器",
|
||||
descEn: "Media analyzer",
|
||||
descKey: "omo.agentDesc.multimodalLooker",
|
||||
tooltipKey: "omo.agentTooltip.multimodalLooker",
|
||||
recommended: "gemini-3-flash",
|
||||
group: "sub",
|
||||
},
|
||||
{
|
||||
key: "Metis",
|
||||
key: "metis",
|
||||
display: "Metis",
|
||||
descZh: "规划前分析顾问",
|
||||
descEn: "Pre-plan analysis advisor",
|
||||
descKey: "omo.agentDesc.metis",
|
||||
tooltipKey: "omo.agentTooltip.metis",
|
||||
recommended: "claude-opus-4-6",
|
||||
group: "sub",
|
||||
},
|
||||
{
|
||||
key: "Momus",
|
||||
key: "momus",
|
||||
display: "Momus",
|
||||
descZh: "计划审查者",
|
||||
descEn: "Plan reviewer",
|
||||
descKey: "omo.agentDesc.momus",
|
||||
tooltipKey: "omo.agentTooltip.momus",
|
||||
recommended: "gpt-5.3",
|
||||
group: "sub",
|
||||
},
|
||||
{
|
||||
key: "Sisyphus-Junior",
|
||||
key: "sisyphus-junior",
|
||||
display: "Sisyphus-Junior",
|
||||
descZh: "委托任务执行器",
|
||||
descEn: "Delegated task executor",
|
||||
descKey: "omo.agentDesc.sisyphusJunior",
|
||||
tooltipKey: "omo.agentTooltip.sisyphusJunior",
|
||||
group: "sub",
|
||||
},
|
||||
];
|
||||
@@ -135,57 +135,57 @@ export const OMO_BUILTIN_CATEGORIES: OmoCategoryDef[] = [
|
||||
{
|
||||
key: "visual-engineering",
|
||||
display: "Visual Engineering",
|
||||
descZh: "视觉/前端工程",
|
||||
descEn: "Visual/frontend engineering",
|
||||
descKey: "omo.categoryDesc.visualEngineering",
|
||||
tooltipKey: "omo.categoryTooltip.visualEngineering",
|
||||
recommended: "gemini-3-pro",
|
||||
},
|
||||
{
|
||||
key: "ultrabrain",
|
||||
display: "Ultrabrain",
|
||||
descZh: "超级思考",
|
||||
descEn: "Ultra thinking",
|
||||
descKey: "omo.categoryDesc.ultrabrain",
|
||||
tooltipKey: "omo.categoryTooltip.ultrabrain",
|
||||
recommended: "claude-opus-4-6",
|
||||
},
|
||||
{
|
||||
key: "deep",
|
||||
display: "Deep",
|
||||
descZh: "深度工作",
|
||||
descEn: "Deep work",
|
||||
descKey: "omo.categoryDesc.deep",
|
||||
tooltipKey: "omo.categoryTooltip.deep",
|
||||
recommended: "gpt-5.3-codex",
|
||||
},
|
||||
{
|
||||
key: "artistry",
|
||||
display: "Artistry",
|
||||
descZh: "创意/文艺",
|
||||
descEn: "Creative/artistic",
|
||||
descKey: "omo.categoryDesc.artistry",
|
||||
tooltipKey: "omo.categoryTooltip.artistry",
|
||||
recommended: "claude-opus-4-6",
|
||||
},
|
||||
{
|
||||
key: "quick",
|
||||
display: "Quick",
|
||||
descZh: "快速响应",
|
||||
descEn: "Quick response",
|
||||
descKey: "omo.categoryDesc.quick",
|
||||
tooltipKey: "omo.categoryTooltip.quick",
|
||||
recommended: "gemini-3-flash",
|
||||
},
|
||||
{
|
||||
key: "unspecified-low",
|
||||
display: "Unspecified Low",
|
||||
descZh: "通用低配",
|
||||
descEn: "General low tier",
|
||||
descKey: "omo.categoryDesc.unspecifiedLow",
|
||||
tooltipKey: "omo.categoryTooltip.unspecifiedLow",
|
||||
recommended: "gemini-3-flash",
|
||||
},
|
||||
{
|
||||
key: "unspecified-high",
|
||||
display: "Unspecified High",
|
||||
descZh: "通用高配",
|
||||
descEn: "General high tier",
|
||||
descKey: "omo.categoryDesc.unspecifiedHigh",
|
||||
tooltipKey: "omo.categoryTooltip.unspecifiedHigh",
|
||||
recommended: "gpt-5.3-codex",
|
||||
},
|
||||
{
|
||||
key: "writing",
|
||||
display: "Writing",
|
||||
descZh: "写作",
|
||||
descEn: "Writing",
|
||||
descKey: "omo.categoryDesc.writing",
|
||||
tooltipKey: "omo.categoryTooltip.writing",
|
||||
recommended: "claude-opus-4-6",
|
||||
},
|
||||
];
|
||||
@@ -316,6 +316,17 @@ export const OMO_CLAUDE_CODE_PLACEHOLDER = `{
|
||||
"plugins": true
|
||||
}`;
|
||||
|
||||
export function parseOmoOtherFieldsObject(
|
||||
raw: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!raw.trim()) return undefined;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function mergeOmoConfigPreview(
|
||||
global: OmoGlobalConfig,
|
||||
agents: Record<string, Record<string, unknown>>,
|
||||
@@ -351,7 +362,8 @@ export function mergeOmoConfigPreview(
|
||||
if (Object.keys(agents).length > 0) result["agents"] = agents;
|
||||
if (Object.keys(categories).length > 0) result["categories"] = categories;
|
||||
try {
|
||||
const other = JSON.parse(otherFieldsStr || "{}");
|
||||
const other = parseOmoOtherFieldsObject(otherFieldsStr);
|
||||
if (!other) return result;
|
||||
for (const [k, v] of Object.entries(other)) {
|
||||
result[k] = v;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface ProxyTakeoverStatus {
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
opencode: boolean;
|
||||
openclaw: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderHealth {
|
||||
|
||||
Reference in New Issue
Block a user