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:
YoVinchen
2026-02-15 13:48:28 +08:00
112 changed files with 11858 additions and 585 deletions
+3 -1
View File
@@ -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)
+14
View File
@@ -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 (
+18
View File
@@ -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>
+1 -1
View File
@@ -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;
+182
View File
@@ -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;
+221
View File
@@ -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;
+2 -2
View File
@@ -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("");
+28 -4
View File
@@ -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")}
+41 -7
View File
@@ -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}
+19 -2
View File
@@ -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>
+51 -6
View File
@@ -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 {
+267 -89
View File
@@ -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>
</>
);
}
+560 -75
View File
@@ -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>
);
+6
View File
@@ -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>
);
}
+1 -1
View File
@@ -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) {
+9
View File
@@ -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 };
+103
View File
@@ -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,
};
+28
View File
@@ -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;