2026-05-08 15:31:52 +00:00
|
|
|
import {
|
2026-05-19 22:42:38 +08:00
|
|
|
useCallback,
|
|
|
|
|
useEffect,
|
2026-05-25 20:07:02 +08:00
|
|
|
forwardRef,
|
2026-05-19 22:42:38 +08:00
|
|
|
useMemo,
|
|
|
|
|
useState,
|
|
|
|
|
type Dispatch,
|
2026-06-14 18:24:59 +08:00
|
|
|
type FormEvent,
|
2026-05-19 22:42:38 +08:00
|
|
|
type ReactNode,
|
|
|
|
|
type SetStateAction,
|
|
|
|
|
} from "react";
|
|
|
|
|
import {
|
|
|
|
|
Activity,
|
2026-06-09 22:31:14 +08:00
|
|
|
ArrowUpCircle,
|
2026-06-14 18:24:59 +08:00
|
|
|
ArrowUpDown,
|
2026-05-08 15:31:52 +00:00
|
|
|
Bot,
|
|
|
|
|
Brain,
|
|
|
|
|
Check,
|
2026-06-06 19:49:33 +08:00
|
|
|
CircleAlert,
|
2026-05-19 22:42:38 +08:00
|
|
|
ChevronDown,
|
|
|
|
|
ChevronLeft,
|
|
|
|
|
ChevronRight,
|
2026-05-08 15:31:52 +00:00
|
|
|
Cloud,
|
|
|
|
|
Cpu,
|
|
|
|
|
Database,
|
|
|
|
|
Eye,
|
|
|
|
|
EyeOff,
|
2026-06-09 22:31:14 +08:00
|
|
|
ExternalLink,
|
2026-05-08 15:31:52 +00:00
|
|
|
Gem,
|
2026-05-19 22:42:38 +08:00
|
|
|
Globe2,
|
2026-05-08 15:31:52 +00:00
|
|
|
Grid3X3,
|
2026-05-19 22:42:38 +08:00
|
|
|
HardDrive,
|
2026-05-08 15:31:52 +00:00
|
|
|
Hexagon,
|
2026-05-19 22:42:38 +08:00
|
|
|
ImageIcon,
|
2026-05-08 15:31:52 +00:00
|
|
|
Layers,
|
2026-05-19 22:42:38 +08:00
|
|
|
Loader2,
|
|
|
|
|
LogOut,
|
2026-06-09 01:08:49 +08:00
|
|
|
Mic,
|
2026-05-08 15:31:52 +00:00
|
|
|
Moon,
|
2026-06-13 20:08:18 +08:00
|
|
|
PauseCircle,
|
2026-05-22 22:25:12 +08:00
|
|
|
PlayCircle,
|
2026-05-24 13:38:37 +08:00
|
|
|
Plus,
|
2026-05-08 15:31:52 +00:00
|
|
|
Orbit,
|
2026-05-19 22:42:38 +08:00
|
|
|
Palette,
|
|
|
|
|
Pencil,
|
2026-05-08 15:31:52 +00:00
|
|
|
RotateCcw,
|
2026-05-19 22:42:38 +08:00
|
|
|
Search,
|
|
|
|
|
Server,
|
|
|
|
|
ShieldCheck,
|
|
|
|
|
SlidersHorizontal,
|
2026-05-08 15:31:52 +00:00
|
|
|
Sparkles,
|
2026-05-22 22:25:12 +08:00
|
|
|
Trash2,
|
2026-05-08 15:31:52 +00:00
|
|
|
Triangle,
|
|
|
|
|
Waves,
|
2026-05-25 20:07:02 +08:00
|
|
|
X,
|
2026-05-08 15:31:52 +00:00
|
|
|
Zap,
|
|
|
|
|
type LucideIcon,
|
|
|
|
|
} from "lucide-react";
|
2026-05-06 23:15:18 +08:00
|
|
|
import { useTranslation } from "react-i18next";
|
2026-04-25 18:05:06 +00:00
|
|
|
|
|
|
|
|
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
2026-06-06 19:49:33 +08:00
|
|
|
import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings";
|
|
|
|
|
import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap";
|
2026-04-25 18:05:06 +00:00
|
|
|
import { Button } from "@/components/ui/button";
|
2026-05-08 15:31:52 +00:00
|
|
|
import {
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
DropdownMenuItem,
|
2026-05-30 23:45:26 +08:00
|
|
|
DropdownMenuSeparator,
|
2026-05-08 15:31:52 +00:00
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
} from "@/components/ui/dropdown-menu";
|
2026-05-09 06:48:26 +00:00
|
|
|
import {
|
2026-05-24 13:38:37 +08:00
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from "@/components/ui/dialog";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
|
|
|
import {
|
2026-06-09 22:31:14 +08:00
|
|
|
checkVersion,
|
2026-05-24 13:38:37 +08:00
|
|
|
createModelConfiguration,
|
2026-06-13 20:08:18 +08:00
|
|
|
fetchAutomations,
|
2026-05-09 06:48:26 +00:00
|
|
|
fetchSettings,
|
2026-06-06 19:49:33 +08:00
|
|
|
fetchSettingsUsage,
|
2026-05-22 22:25:12 +08:00
|
|
|
fetchCliApps,
|
2026-05-24 13:38:37 +08:00
|
|
|
fetchMcpPresets,
|
2026-05-30 23:45:26 +08:00
|
|
|
fetchProviderModels,
|
2026-05-24 13:38:37 +08:00
|
|
|
importMcpConfig,
|
2026-05-29 03:42:53 +08:00
|
|
|
loginProviderOAuth,
|
|
|
|
|
logoutProviderOAuth,
|
2026-06-13 20:08:18 +08:00
|
|
|
runAutomationAction,
|
2026-05-22 22:25:12 +08:00
|
|
|
runCliAppAction,
|
2026-05-24 13:38:37 +08:00
|
|
|
runMcpPresetAction,
|
|
|
|
|
saveCustomMcpServer,
|
2026-06-14 18:24:59 +08:00
|
|
|
updateAutomation,
|
2026-05-19 22:42:38 +08:00
|
|
|
updateImageGenerationSettings,
|
2026-05-24 13:38:37 +08:00
|
|
|
updateMcpServerTools,
|
2026-05-29 03:42:53 +08:00
|
|
|
updateModelConfiguration,
|
|
|
|
|
updateNetworkSafetySettings,
|
2026-05-09 06:48:26 +00:00
|
|
|
updateProviderSettings,
|
|
|
|
|
updateSettings,
|
2026-06-09 01:08:49 +08:00
|
|
|
updateTranscriptionSettings,
|
2026-05-09 06:48:26 +00:00
|
|
|
updateWebSearchSettings,
|
|
|
|
|
} from "@/lib/api";
|
2026-05-23 00:59:54 +08:00
|
|
|
import { notifyCliAppsChanged } from "@/lib/cli-app-events";
|
2026-05-29 03:42:53 +08:00
|
|
|
import { getHostApi } from "@/lib/runtime";
|
2026-05-24 13:38:37 +08:00
|
|
|
import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events";
|
2026-06-13 20:08:18 +08:00
|
|
|
import { fmtDateTime, relativeTime } from "@/lib/format";
|
2026-05-24 13:38:37 +08:00
|
|
|
import {
|
|
|
|
|
logoFallbackUrls,
|
|
|
|
|
providerBrand,
|
|
|
|
|
providerDisplayLabel,
|
|
|
|
|
} from "@/lib/provider-brand";
|
2026-04-25 18:05:06 +00:00
|
|
|
import { cn } from "@/lib/utils";
|
2026-06-06 19:49:33 +08:00
|
|
|
import { shortWorkspacePath } from "@/lib/workspace";
|
2026-04-25 18:05:06 +00:00
|
|
|
import { useClient } from "@/providers/ClientProvider";
|
2026-05-19 22:42:38 +08:00
|
|
|
import type {
|
2026-06-13 20:08:18 +08:00
|
|
|
AutomationsPayload,
|
2026-06-14 18:24:59 +08:00
|
|
|
AutomationUpdatePayload,
|
2026-05-22 22:25:12 +08:00
|
|
|
CliAppInfo,
|
|
|
|
|
CliAppsPayload,
|
2026-05-19 22:42:38 +08:00
|
|
|
ImageGenerationSettingsUpdate,
|
2026-05-24 13:38:37 +08:00
|
|
|
McpPresetInfo,
|
|
|
|
|
McpPresetsPayload,
|
2026-05-29 03:42:53 +08:00
|
|
|
NetworkSafetySettingsUpdate,
|
2026-05-30 23:45:26 +08:00
|
|
|
ProviderModelsPayload,
|
2026-06-13 20:08:18 +08:00
|
|
|
SessionAutomationJob,
|
2026-05-19 22:42:38 +08:00
|
|
|
SettingsPayload,
|
2026-06-06 19:49:33 +08:00
|
|
|
SkillSummary,
|
2026-06-09 01:08:49 +08:00
|
|
|
TranscriptionSettingsUpdate,
|
2026-05-19 22:42:38 +08:00
|
|
|
WebSearchSettingsUpdate,
|
2026-05-29 03:42:53 +08:00
|
|
|
WebuiDefaultAccessMode,
|
2026-05-19 22:42:38 +08:00
|
|
|
} from "@/lib/types";
|
2026-04-25 18:05:06 +00:00
|
|
|
|
2026-05-25 20:07:02 +08:00
|
|
|
export type SettingsSectionKey =
|
2026-05-19 22:42:38 +08:00
|
|
|
| "overview"
|
|
|
|
|
| "appearance"
|
|
|
|
|
| "models"
|
|
|
|
|
| "image"
|
2026-06-09 01:08:49 +08:00
|
|
|
| "voice"
|
2026-05-29 03:42:53 +08:00
|
|
|
| "browser"
|
2026-05-25 20:07:02 +08:00
|
|
|
| "apps"
|
2026-06-13 20:08:18 +08:00
|
|
|
| "automations"
|
2026-06-06 19:49:33 +08:00
|
|
|
| "skills"
|
2026-05-19 22:42:38 +08:00
|
|
|
| "runtime"
|
|
|
|
|
| "advanced";
|
|
|
|
|
|
|
|
|
|
type LocalDensity = "comfortable" | "compact";
|
|
|
|
|
type LocalActivityMode = "auto" | "expanded";
|
2026-05-25 20:07:02 +08:00
|
|
|
type AppsKindFilter = "all" | "cli" | "mcp";
|
2026-06-13 20:08:18 +08:00
|
|
|
type AutomationFilter = "all" | "active" | "paused" | "failed" | "system";
|
2026-06-14 18:24:59 +08:00
|
|
|
type AutomationSort = "next" | "last" | "updated" | "name";
|
2026-06-13 20:08:18 +08:00
|
|
|
type AutomationAction = "enable" | "disable" | "delete" | "run";
|
2026-05-25 20:07:02 +08:00
|
|
|
type AppsCatalogItem =
|
|
|
|
|
| { id: string; kind: "cli"; app: CliAppInfo }
|
|
|
|
|
| { id: string; kind: "mcp"; preset: McpPresetInfo };
|
2026-05-19 22:42:38 +08:00
|
|
|
|
|
|
|
|
interface LocalPreferences {
|
|
|
|
|
density: LocalDensity;
|
|
|
|
|
activityMode: LocalActivityMode;
|
|
|
|
|
codeWrap: boolean;
|
2026-05-22 22:25:12 +08:00
|
|
|
brandLogos: boolean;
|
2026-05-19 22:42:38 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface AgentSettingsDraft {
|
|
|
|
|
model: string;
|
|
|
|
|
provider: string;
|
|
|
|
|
modelPreset: string;
|
2026-05-29 03:42:53 +08:00
|
|
|
presetLabel: string;
|
2026-05-29 04:23:30 +08:00
|
|
|
contextWindowTokens: number;
|
2026-05-19 22:42:38 +08:00
|
|
|
timezone: string;
|
|
|
|
|
botName: string;
|
|
|
|
|
botIcon: string;
|
|
|
|
|
toolHintMaxLength: number;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 13:38:37 +08:00
|
|
|
interface ModelConfigurationDraft {
|
|
|
|
|
label: string;
|
|
|
|
|
provider: string;
|
|
|
|
|
model: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 03:42:53 +08:00
|
|
|
type PendingRestartSection = "runtime" | "browser" | "image";
|
2026-05-19 22:42:38 +08:00
|
|
|
type PendingRestartSections = Record<PendingRestartSection, boolean>;
|
2026-05-29 03:42:53 +08:00
|
|
|
type RestartAwarePayload = {
|
|
|
|
|
requires_restart?: boolean;
|
|
|
|
|
surface?: SettingsPayload["surface"];
|
|
|
|
|
runtime_surface?: SettingsPayload["runtime_surface"];
|
|
|
|
|
runtime_capabilities?: SettingsPayload["runtime_capabilities"];
|
|
|
|
|
};
|
2026-05-23 18:04:40 +08:00
|
|
|
type ProviderApiType = "auto" | "chat_completions" | "responses";
|
|
|
|
|
type ProviderForm = { apiKey: string; apiBase: string; apiType: ProviderApiType };
|
2026-05-24 13:38:37 +08:00
|
|
|
type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
|
|
|
|
|
|
2026-05-29 04:23:30 +08:00
|
|
|
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 262_144] as const;
|
2026-05-30 23:45:26 +08:00
|
|
|
const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
|
|
|
|
|
"aihubmix",
|
|
|
|
|
"atomic_chat",
|
|
|
|
|
"byteplus",
|
|
|
|
|
"byteplus_coding_plan",
|
|
|
|
|
"huggingface",
|
|
|
|
|
"lm_studio",
|
|
|
|
|
"novita",
|
|
|
|
|
"ollama",
|
|
|
|
|
"openrouter",
|
|
|
|
|
"ovms",
|
|
|
|
|
"siliconflow",
|
|
|
|
|
"vllm",
|
|
|
|
|
"volcengine",
|
|
|
|
|
"volcengine_coding_plan",
|
|
|
|
|
]);
|
|
|
|
|
const DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH = 2;
|
2026-05-24 13:38:37 +08:00
|
|
|
|
|
|
|
|
const FALLBACK_TIMEZONES = [
|
|
|
|
|
"UTC",
|
|
|
|
|
"Asia/Shanghai",
|
|
|
|
|
"Asia/Hong_Kong",
|
|
|
|
|
"Asia/Tokyo",
|
|
|
|
|
"Asia/Seoul",
|
|
|
|
|
"Asia/Singapore",
|
|
|
|
|
"Asia/Taipei",
|
|
|
|
|
"Asia/Dubai",
|
|
|
|
|
"Asia/Kolkata",
|
|
|
|
|
"Europe/London",
|
|
|
|
|
"Europe/Paris",
|
|
|
|
|
"Europe/Berlin",
|
|
|
|
|
"Europe/Amsterdam",
|
|
|
|
|
"America/New_York",
|
|
|
|
|
"America/Chicago",
|
|
|
|
|
"America/Denver",
|
|
|
|
|
"America/Los_Angeles",
|
|
|
|
|
"America/Toronto",
|
|
|
|
|
"America/Sao_Paulo",
|
|
|
|
|
"Australia/Sydney",
|
|
|
|
|
"Pacific/Auckland",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
interface CustomMcpForm {
|
|
|
|
|
name: string;
|
|
|
|
|
transport: CustomMcpTransport;
|
|
|
|
|
command: string;
|
|
|
|
|
args: string;
|
|
|
|
|
url: string;
|
|
|
|
|
env: string;
|
|
|
|
|
headers: string;
|
|
|
|
|
toolTimeout: string;
|
|
|
|
|
}
|
2026-05-19 22:42:38 +08:00
|
|
|
|
|
|
|
|
const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences";
|
|
|
|
|
|
|
|
|
|
const DEFAULT_LOCAL_PREFS: LocalPreferences = {
|
|
|
|
|
density: "comfortable",
|
|
|
|
|
activityMode: "auto",
|
|
|
|
|
codeWrap: true,
|
2026-05-22 22:25:12 +08:00
|
|
|
brandLogos: true,
|
2026-05-19 22:42:38 +08:00
|
|
|
};
|
2026-05-23 18:04:40 +08:00
|
|
|
const OPENAI_API_TYPE_OPTIONS: Array<{ value: ProviderApiType; label: string }> = [
|
|
|
|
|
{ value: "auto", label: "Auto" },
|
|
|
|
|
{ value: "chat_completions", label: "Chat Completions" },
|
|
|
|
|
{ value: "responses", label: "Responses" },
|
|
|
|
|
];
|
2026-05-08 15:31:52 +00:00
|
|
|
|
2026-05-17 17:41:33 +08:00
|
|
|
const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map(
|
|
|
|
|
["vllm", "ollama", "lm_studio", "atomic_chat", "ovms"].map((name, index) => [
|
|
|
|
|
name,
|
|
|
|
|
index,
|
|
|
|
|
]),
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
const IMAGE_ASPECT_RATIO_OPTIONS = ["1:1", "3:4", "9:16", "4:3", "16:9", "3:2", "2:3", "21:9"];
|
|
|
|
|
const IMAGE_SIZE_OPTIONS = ["1K", "2K", "4K", "1024x1024", "1536x1024", "1024x1536"];
|
|
|
|
|
const EMPTY_PENDING_RESTART_SECTIONS: PendingRestartSections = {
|
|
|
|
|
runtime: false,
|
2026-05-29 03:42:53 +08:00
|
|
|
browser: false,
|
2026-05-19 22:42:38 +08:00
|
|
|
image: false,
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-24 13:38:37 +08:00
|
|
|
const DEFAULT_CUSTOM_MCP_FORM: CustomMcpForm = {
|
|
|
|
|
name: "",
|
|
|
|
|
transport: "stdio",
|
|
|
|
|
command: "",
|
|
|
|
|
args: "",
|
|
|
|
|
url: "",
|
|
|
|
|
env: "",
|
|
|
|
|
headers: "",
|
|
|
|
|
toolTimeout: "30",
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-25 18:05:06 +00:00
|
|
|
interface SettingsViewProps {
|
|
|
|
|
theme: "light" | "dark";
|
2026-05-25 20:07:02 +08:00
|
|
|
initialSection?: SettingsSectionKey;
|
2026-06-06 19:49:33 +08:00
|
|
|
initialSettings?: SettingsPayload | null;
|
2026-05-25 20:07:02 +08:00
|
|
|
showSidebar?: boolean;
|
2026-04-25 18:05:06 +00:00
|
|
|
onToggleTheme: () => void;
|
|
|
|
|
onBackToChat: () => void;
|
|
|
|
|
onModelNameChange: (modelName: string | null) => void;
|
2026-05-29 03:42:53 +08:00
|
|
|
onSettingsChange?: (payload: SettingsPayload) => void;
|
2026-06-06 19:49:33 +08:00
|
|
|
skills?: SkillSummary[];
|
2026-05-29 03:42:53 +08:00
|
|
|
onWorkspaceSettingsChange?: () => void | Promise<void>;
|
2026-06-02 11:29:18 +08:00
|
|
|
onSectionChange?: (section: SettingsSectionKey) => void;
|
2026-05-06 23:15:18 +08:00
|
|
|
onLogout?: () => void;
|
2026-05-08 09:40:15 +00:00
|
|
|
onRestart?: () => void;
|
2026-06-06 19:49:33 +08:00
|
|
|
onNativeEngineRestart?: () => Promise<string>;
|
2026-05-08 15:31:52 +00:00
|
|
|
isRestarting?: boolean;
|
2026-05-29 03:42:53 +08:00
|
|
|
hostChromeInset?: boolean;
|
2026-04-25 18:05:06 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
function readLocalPreferences(): LocalPreferences {
|
|
|
|
|
try {
|
|
|
|
|
const raw = window.localStorage.getItem(LOCAL_PREFS_STORAGE_KEY);
|
|
|
|
|
if (!raw) return DEFAULT_LOCAL_PREFS;
|
|
|
|
|
const parsed = JSON.parse(raw) as Partial<LocalPreferences>;
|
|
|
|
|
return {
|
|
|
|
|
density: parsed.density === "compact" ? "compact" : "comfortable",
|
|
|
|
|
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
|
|
|
|
|
codeWrap: parsed.codeWrap !== false,
|
2026-05-22 22:25:12 +08:00
|
|
|
brandLogos: parsed.brandLogos !== false,
|
2026-05-19 22:42:38 +08:00
|
|
|
};
|
|
|
|
|
} catch {
|
|
|
|
|
return DEFAULT_LOCAL_PREFS;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function modelPresetValue(payload: SettingsPayload): string {
|
|
|
|
|
return payload.agent.model_preset || "default";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function defaultPreset(payload: SettingsPayload): SettingsPayload["model_presets"][number] | null {
|
|
|
|
|
return payload.model_presets.find((preset) => preset.is_default) ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 04:23:30 +08:00
|
|
|
function normalizeContextWindowTokens(value: number | null | undefined): number {
|
|
|
|
|
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 65_536;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
function editableDefaultProvider(payload: SettingsPayload): string {
|
|
|
|
|
const base = defaultPreset(payload);
|
|
|
|
|
return base?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? "";
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-06 19:49:33 +08:00
|
|
|
function settingsProviderRow(
|
|
|
|
|
payload: SettingsPayload,
|
|
|
|
|
provider: string | null | undefined,
|
|
|
|
|
): SettingsPayload["providers"][number] | null {
|
|
|
|
|
if (!provider) return null;
|
|
|
|
|
return payload.providers.find((row) => row.name === provider) ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function settingsProviderConfigured(
|
|
|
|
|
payload: SettingsPayload,
|
|
|
|
|
provider: string | null | undefined,
|
|
|
|
|
): boolean {
|
|
|
|
|
const row = settingsProviderRow(payload, provider);
|
|
|
|
|
if (row) return row.configured;
|
2026-06-12 10:16:54 +08:00
|
|
|
if (provider === "auto") {
|
|
|
|
|
const resolvedRow = settingsProviderRow(
|
|
|
|
|
payload,
|
|
|
|
|
payload.agent.resolved_provider ?? payload.agent.provider,
|
|
|
|
|
);
|
|
|
|
|
if (resolvedRow) return resolvedRow.configured;
|
|
|
|
|
}
|
2026-06-06 19:49:33 +08:00
|
|
|
return payload.agent.has_api_key;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = {
|
|
|
|
|
model: "",
|
|
|
|
|
provider: "",
|
|
|
|
|
modelPreset: "default",
|
|
|
|
|
presetLabel: "Default",
|
|
|
|
|
contextWindowTokens: 65_536,
|
|
|
|
|
timezone: "UTC",
|
|
|
|
|
botName: "nanobot",
|
|
|
|
|
botIcon: "",
|
|
|
|
|
toolHintMaxLength: 40,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const DEFAULT_WEB_SEARCH_FORM: WebSearchSettingsUpdate = {
|
|
|
|
|
provider: "duckduckgo",
|
|
|
|
|
apiKey: "",
|
|
|
|
|
baseUrl: "",
|
|
|
|
|
maxResults: 5,
|
|
|
|
|
timeout: 30,
|
|
|
|
|
useJinaReader: true,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const DEFAULT_IMAGE_GENERATION_FORM: ImageGenerationSettingsUpdate = {
|
|
|
|
|
enabled: false,
|
|
|
|
|
provider: "openrouter",
|
|
|
|
|
model: "openai/gpt-5.4-image-2",
|
|
|
|
|
defaultAspectRatio: "1:1",
|
|
|
|
|
defaultImageSize: "1K",
|
|
|
|
|
maxImagesPerTurn: 4,
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-09 01:08:49 +08:00
|
|
|
const DEFAULT_TRANSCRIPTION_FORM: TranscriptionSettingsUpdate = {
|
|
|
|
|
enabled: true,
|
|
|
|
|
provider: "groq",
|
|
|
|
|
model: "",
|
|
|
|
|
language: "",
|
|
|
|
|
maxDurationSec: 120,
|
|
|
|
|
maxUploadMb: 25,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const DEFAULT_TRANSCRIPTION_SETTINGS: NonNullable<SettingsPayload["transcription"]> = {
|
|
|
|
|
enabled: true,
|
|
|
|
|
provider: "groq",
|
|
|
|
|
provider_configured: false,
|
|
|
|
|
model: "whisper-large-v3",
|
|
|
|
|
language: null,
|
|
|
|
|
max_duration_sec: 120,
|
|
|
|
|
max_upload_mb: 25,
|
|
|
|
|
providers: [],
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-06 19:49:33 +08:00
|
|
|
const DEFAULT_NETWORK_SAFETY_FORM: NetworkSafetySettingsUpdate = {
|
|
|
|
|
webuiAllowLocalServiceAccess: true,
|
|
|
|
|
webuiDefaultAccessMode: "default",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function agentDraftFromPayload(payload: SettingsPayload): AgentSettingsDraft {
|
|
|
|
|
const fallbackDefault = defaultPreset(payload);
|
|
|
|
|
const activePresetName = modelPresetValue(payload);
|
|
|
|
|
const activePreset =
|
|
|
|
|
payload.model_presets.find((preset) => preset.name === activePresetName) ?? fallbackDefault;
|
|
|
|
|
return {
|
|
|
|
|
model: activePreset?.model ?? payload.agent.model,
|
|
|
|
|
provider: activePreset?.is_default
|
|
|
|
|
? editableDefaultProvider(payload)
|
|
|
|
|
: activePreset?.provider ?? editableDefaultProvider(payload),
|
|
|
|
|
modelPreset: activePresetName,
|
|
|
|
|
presetLabel: activePreset?.label ?? activePresetName,
|
|
|
|
|
contextWindowTokens: normalizeContextWindowTokens(
|
|
|
|
|
activePreset?.context_window_tokens ?? payload.agent.context_window_tokens,
|
|
|
|
|
),
|
|
|
|
|
timezone: payload.agent.timezone,
|
|
|
|
|
botName: payload.agent.bot_name,
|
|
|
|
|
botIcon: payload.agent.bot_icon,
|
|
|
|
|
toolHintMaxLength: payload.agent.tool_hint_max_length,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function webSearchFormFromPayload(
|
|
|
|
|
payload: SettingsPayload,
|
|
|
|
|
previous?: WebSearchSettingsUpdate,
|
|
|
|
|
): WebSearchSettingsUpdate {
|
|
|
|
|
return {
|
|
|
|
|
provider: payload.web_search.provider,
|
|
|
|
|
apiKey: previous?.provider === payload.web_search.provider ? previous.apiKey ?? "" : "",
|
|
|
|
|
baseUrl: payload.web_search.base_url ?? "",
|
|
|
|
|
maxResults: payload.web_search.max_results,
|
|
|
|
|
timeout: payload.web_search.timeout,
|
|
|
|
|
useJinaReader: payload.web.fetch.use_jina_reader,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function imageGenerationFormFromPayload(payload: SettingsPayload): ImageGenerationSettingsUpdate {
|
|
|
|
|
return {
|
|
|
|
|
enabled: payload.image_generation.enabled,
|
|
|
|
|
provider: payload.image_generation.provider,
|
|
|
|
|
model: payload.image_generation.model,
|
|
|
|
|
defaultAspectRatio: payload.image_generation.default_aspect_ratio,
|
|
|
|
|
defaultImageSize: payload.image_generation.default_image_size,
|
|
|
|
|
maxImagesPerTurn: payload.image_generation.max_images_per_turn,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 01:08:49 +08:00
|
|
|
function transcriptionFormFromPayload(payload: SettingsPayload): TranscriptionSettingsUpdate {
|
|
|
|
|
const transcription = payload.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
|
|
|
|
|
return {
|
|
|
|
|
enabled: transcription.enabled,
|
|
|
|
|
provider: transcription.provider,
|
|
|
|
|
model: transcription.model,
|
|
|
|
|
language: transcription.language ?? "",
|
|
|
|
|
maxDurationSec: transcription.max_duration_sec,
|
|
|
|
|
maxUploadMb: transcription.max_upload_mb,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-06 19:49:33 +08:00
|
|
|
function networkSafetyFormFromPayload(payload: SettingsPayload): NetworkSafetySettingsUpdate {
|
|
|
|
|
return {
|
|
|
|
|
webuiAllowLocalServiceAccess:
|
|
|
|
|
payload.advanced.webui_allow_local_service_access ??
|
|
|
|
|
payload.advanced.allow_local_preview_access ??
|
|
|
|
|
true,
|
|
|
|
|
webuiDefaultAccessMode: visibleWebuiDefaultAccessMode(
|
|
|
|
|
payload.advanced.webui_default_access_mode,
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pendingRestartSectionsFromPayload(payload: SettingsPayload): PendingRestartSections {
|
|
|
|
|
const sections = payload.restart_required_sections ?? [];
|
|
|
|
|
return {
|
|
|
|
|
runtime: sections.includes("runtime"),
|
|
|
|
|
browser: sections.includes("browser"),
|
|
|
|
|
image: sections.includes("image"),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 18:05:06 +00:00
|
|
|
export function SettingsView({
|
2026-05-08 15:31:52 +00:00
|
|
|
theme,
|
2026-05-25 20:07:02 +08:00
|
|
|
initialSection = "overview",
|
2026-06-06 19:49:33 +08:00
|
|
|
initialSettings = null,
|
2026-05-25 20:07:02 +08:00
|
|
|
showSidebar = true,
|
2026-05-08 15:31:52 +00:00
|
|
|
onToggleTheme,
|
2026-04-25 18:05:06 +00:00
|
|
|
onBackToChat,
|
|
|
|
|
onModelNameChange,
|
2026-05-29 03:42:53 +08:00
|
|
|
onSettingsChange,
|
2026-06-06 19:49:33 +08:00
|
|
|
skills = [],
|
2026-05-29 03:42:53 +08:00
|
|
|
onWorkspaceSettingsChange,
|
2026-06-02 11:29:18 +08:00
|
|
|
onSectionChange,
|
2026-05-06 23:15:18 +08:00
|
|
|
onLogout,
|
2026-05-08 09:40:15 +00:00
|
|
|
onRestart,
|
2026-06-06 19:49:33 +08:00
|
|
|
onNativeEngineRestart,
|
2026-05-08 15:31:52 +00:00
|
|
|
isRestarting = false,
|
2026-05-29 03:42:53 +08:00
|
|
|
hostChromeInset = false,
|
2026-04-25 18:05:06 +00:00
|
|
|
}: SettingsViewProps) {
|
2026-05-08 15:31:52 +00:00
|
|
|
const { t } = useTranslation();
|
2026-04-25 18:05:06 +00:00
|
|
|
const { token } = useClient();
|
2026-06-06 19:49:33 +08:00
|
|
|
const [settings, setSettings] = useState<SettingsPayload | null>(() => initialSettings);
|
2026-05-22 22:25:12 +08:00
|
|
|
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
|
2026-05-24 13:38:37 +08:00
|
|
|
const [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null);
|
2026-06-13 20:08:18 +08:00
|
|
|
const [automations, setAutomations] = useState<AutomationsPayload | null>(null);
|
2026-06-06 19:49:33 +08:00
|
|
|
const [loading, setLoading] = useState(() => initialSettings === null);
|
2026-05-22 22:25:12 +08:00
|
|
|
const [cliAppsLoading, setCliAppsLoading] = useState(true);
|
2026-05-24 13:38:37 +08:00
|
|
|
const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true);
|
2026-06-13 20:08:18 +08:00
|
|
|
const [automationsLoading, setAutomationsLoading] = useState(false);
|
2026-04-25 18:05:06 +00:00
|
|
|
const [saving, setSaving] = useState(false);
|
2026-05-24 13:38:37 +08:00
|
|
|
const [modelConfigurationOpen, setModelConfigurationOpen] = useState(false);
|
|
|
|
|
const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false);
|
|
|
|
|
const [modelConfigurationForm, setModelConfigurationForm] = useState<ModelConfigurationDraft>({
|
|
|
|
|
label: "",
|
|
|
|
|
provider: "",
|
|
|
|
|
model: "",
|
|
|
|
|
});
|
2026-05-22 22:25:12 +08:00
|
|
|
const [cliAppsAction, setCliAppsAction] = useState<string | null>(null);
|
2026-05-24 13:38:37 +08:00
|
|
|
const [mcpPresetAction, setMcpPresetAction] = useState<string | null>(null);
|
2026-05-08 15:31:52 +00:00
|
|
|
const [providerSaving, setProviderSaving] = useState<string | null>(null);
|
2026-05-09 06:48:26 +00:00
|
|
|
const [webSearchSaving, setWebSearchSaving] = useState(false);
|
2026-05-19 22:42:38 +08:00
|
|
|
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
|
2026-06-09 01:08:49 +08:00
|
|
|
const [transcriptionSaving, setTranscriptionSaving] = useState(false);
|
2026-05-29 03:42:53 +08:00
|
|
|
const [networkSafetySaving, setNetworkSafetySaving] = useState(false);
|
|
|
|
|
const [hostEngineApplying, setHostEngineApplying] = useState(false);
|
2026-04-25 18:05:06 +00:00
|
|
|
const [error, setError] = useState<string | null>(null);
|
2026-05-25 20:07:02 +08:00
|
|
|
const [activeSection, setActiveSection] = useState<SettingsSectionKey>(initialSection);
|
2026-05-08 15:31:52 +00:00
|
|
|
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
|
2026-05-19 22:42:38 +08:00
|
|
|
const [providerQuery, setProviderQuery] = useState("");
|
2026-05-25 20:07:02 +08:00
|
|
|
const [appsQuery, setAppsQuery] = useState("");
|
2026-06-13 20:08:18 +08:00
|
|
|
const [automationsQuery, setAutomationsQuery] = useState("");
|
|
|
|
|
const [automationsFilter, setAutomationsFilter] = useState<AutomationFilter>("all");
|
2026-06-14 18:24:59 +08:00
|
|
|
const [automationsSort, setAutomationsSort] = useState<AutomationSort>("next");
|
2026-05-22 22:25:12 +08:00
|
|
|
const [cliAppsMessage, setCliAppsMessage] = useState<string | null>(null);
|
|
|
|
|
const [cliAppsError, setCliAppsError] = useState<string | null>(null);
|
|
|
|
|
const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null);
|
2026-05-25 20:07:02 +08:00
|
|
|
const [appsKindFilter, setAppsKindFilter] = useState<AppsKindFilter>("all");
|
2026-05-24 13:38:37 +08:00
|
|
|
const [mcpMessage, setMcpMessage] = useState<string | null>(null);
|
|
|
|
|
const [mcpError, setMcpError] = useState<string | null>(null);
|
2026-06-13 20:08:18 +08:00
|
|
|
const [automationsError, setAutomationsError] = useState<string | null>(null);
|
|
|
|
|
const [automationAction, setAutomationAction] = useState<string | null>(null);
|
|
|
|
|
const [automationPendingDelete, setAutomationPendingDelete] =
|
|
|
|
|
useState<SessionAutomationJob | null>(null);
|
2026-06-14 18:24:59 +08:00
|
|
|
const [automationPendingEdit, setAutomationPendingEdit] =
|
|
|
|
|
useState<SessionAutomationJob | null>(null);
|
2026-05-24 13:38:37 +08:00
|
|
|
const [mcpFieldValues, setMcpFieldValues] = useState<Record<string, Record<string, string>>>({});
|
|
|
|
|
const [customMcpForm, setCustomMcpForm] = useState<CustomMcpForm>(DEFAULT_CUSTOM_MCP_FORM);
|
|
|
|
|
const [mcpConfigImport, setMcpConfigImport] = useState("");
|
2026-05-23 18:04:40 +08:00
|
|
|
const [providerForms, setProviderForms] = useState<Record<string, ProviderForm>>({});
|
2026-05-08 15:31:52 +00:00
|
|
|
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
|
|
|
|
|
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
|
2026-05-19 22:42:38 +08:00
|
|
|
const [pendingRestartSections, setPendingRestartSections] = useState<PendingRestartSections>(
|
|
|
|
|
EMPTY_PENDING_RESTART_SECTIONS,
|
|
|
|
|
);
|
|
|
|
|
const [localPrefs, setLocalPrefs] = useState<LocalPreferences>(() => readLocalPreferences());
|
2026-06-06 19:49:33 +08:00
|
|
|
const [webSearchForm, setWebSearchForm] = useState<WebSearchSettingsUpdate>(() =>
|
|
|
|
|
initialSettings ? webSearchFormFromPayload(initialSettings) : DEFAULT_WEB_SEARCH_FORM,
|
|
|
|
|
);
|
|
|
|
|
const [imageGenerationForm, setImageGenerationForm] = useState<ImageGenerationSettingsUpdate>(
|
|
|
|
|
() =>
|
|
|
|
|
initialSettings
|
|
|
|
|
? imageGenerationFormFromPayload(initialSettings)
|
|
|
|
|
: DEFAULT_IMAGE_GENERATION_FORM,
|
|
|
|
|
);
|
2026-06-09 01:08:49 +08:00
|
|
|
const [transcriptionForm, setTranscriptionForm] = useState<TranscriptionSettingsUpdate>(
|
|
|
|
|
() => initialSettings ? transcriptionFormFromPayload(initialSettings) : DEFAULT_TRANSCRIPTION_FORM,
|
|
|
|
|
);
|
2026-06-06 19:49:33 +08:00
|
|
|
const [networkSafetyForm, setNetworkSafetyForm] = useState<NetworkSafetySettingsUpdate>(() =>
|
|
|
|
|
initialSettings ? networkSafetyFormFromPayload(initialSettings) : DEFAULT_NETWORK_SAFETY_FORM,
|
|
|
|
|
);
|
2026-05-25 20:07:02 +08:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setActiveSection(initialSection);
|
|
|
|
|
}, [initialSection]);
|
2026-06-02 11:29:18 +08:00
|
|
|
|
|
|
|
|
const selectSection = useCallback(
|
|
|
|
|
(section: SettingsSectionKey) => {
|
|
|
|
|
setActiveSection(section);
|
|
|
|
|
onSectionChange?.(section);
|
|
|
|
|
},
|
|
|
|
|
[onSectionChange],
|
|
|
|
|
);
|
2026-05-09 06:48:26 +00:00
|
|
|
const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false);
|
|
|
|
|
const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false);
|
2026-06-06 19:49:33 +08:00
|
|
|
const [form, setForm] = useState<AgentSettingsDraft>(() =>
|
|
|
|
|
initialSettings ? agentDraftFromPayload(initialSettings) : DEFAULT_AGENT_SETTINGS_DRAFT,
|
|
|
|
|
);
|
2026-04-25 18:05:06 +00:00
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
const text = useCallback(
|
|
|
|
|
(key: string, fallback: string, options?: Record<string, unknown>) =>
|
|
|
|
|
t(key, { defaultValue: fallback, ...(options ?? {}) }),
|
|
|
|
|
[t],
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-25 18:05:06 +00:00
|
|
|
const applyPayload = useCallback((payload: SettingsPayload) => {
|
|
|
|
|
setSettings(payload);
|
2026-06-06 19:49:33 +08:00
|
|
|
setForm(agentDraftFromPayload(payload));
|
|
|
|
|
setWebSearchForm((prev) => webSearchFormFromPayload(payload, prev));
|
|
|
|
|
setImageGenerationForm(imageGenerationFormFromPayload(payload));
|
2026-06-09 01:08:49 +08:00
|
|
|
setTranscriptionForm(transcriptionFormFromPayload(payload));
|
2026-06-06 19:49:33 +08:00
|
|
|
setNetworkSafetyForm(networkSafetyFormFromPayload(payload));
|
2026-05-19 22:42:38 +08:00
|
|
|
if (payload.restart_required_sections) {
|
2026-06-06 19:49:33 +08:00
|
|
|
setPendingRestartSections(pendingRestartSectionsFromPayload(payload));
|
2026-05-19 22:42:38 +08:00
|
|
|
}
|
2026-05-29 03:42:53 +08:00
|
|
|
onSettingsChange?.(payload);
|
|
|
|
|
}, [onSettingsChange]);
|
2026-04-25 18:05:06 +00:00
|
|
|
|
2026-06-06 19:49:33 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!initialSettings || settings !== null) return;
|
|
|
|
|
applyPayload(initialSettings);
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}, [applyPayload, initialSettings, settings]);
|
|
|
|
|
|
2026-04-25 18:05:06 +00:00
|
|
|
useEffect(() => {
|
|
|
|
|
let cancelled = false;
|
2026-06-06 19:49:33 +08:00
|
|
|
const showLoading = settings === null;
|
|
|
|
|
if (showLoading) setLoading(true);
|
2026-04-25 18:05:06 +00:00
|
|
|
fetchSettings(token)
|
|
|
|
|
.then((payload) => {
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
applyPayload(payload);
|
|
|
|
|
setError(null);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch((err) => {
|
2026-06-06 19:49:33 +08:00
|
|
|
if (!cancelled && showLoading) setError((err as Error).message);
|
2026-04-25 18:05:06 +00:00
|
|
|
})
|
|
|
|
|
.finally(() => {
|
|
|
|
|
if (!cancelled) setLoading(false);
|
|
|
|
|
});
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
|
|
|
|
}, [applyPayload, token]);
|
|
|
|
|
|
2026-06-06 19:49:33 +08:00
|
|
|
const hasSettings = settings !== null;
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (activeSection !== "overview" || !hasSettings) return;
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
const refresh = () => {
|
|
|
|
|
fetchSettingsUsage(token)
|
|
|
|
|
.then((usage) => {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
setSettings((current) => (current ? { ...current, usage } : current));
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
};
|
|
|
|
|
void refresh();
|
|
|
|
|
const interval = window.setInterval(refresh, 5000);
|
|
|
|
|
const onFocus = () => refresh();
|
|
|
|
|
const onVisibilityChange = () => {
|
|
|
|
|
if (document.visibilityState === "visible") refresh();
|
|
|
|
|
};
|
|
|
|
|
window.addEventListener("focus", onFocus);
|
|
|
|
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
window.clearInterval(interval);
|
|
|
|
|
window.removeEventListener("focus", onFocus);
|
|
|
|
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
|
|
|
};
|
|
|
|
|
}, [activeSection, hasSettings, token]);
|
|
|
|
|
|
2026-05-22 22:25:12 +08:00
|
|
|
useEffect(() => {
|
2026-05-29 03:42:53 +08:00
|
|
|
if (activeSection !== "apps") return;
|
2026-05-22 22:25:12 +08:00
|
|
|
let cancelled = false;
|
|
|
|
|
setCliAppsLoading(true);
|
|
|
|
|
fetchCliApps(token)
|
|
|
|
|
.then((payload) => {
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
setCliApps(payload);
|
|
|
|
|
setCliAppsError(null);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
if (!cancelled) setCliAppsError((err as Error).message);
|
|
|
|
|
})
|
|
|
|
|
.finally(() => {
|
|
|
|
|
if (!cancelled) setCliAppsLoading(false);
|
|
|
|
|
});
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
2026-05-29 03:42:53 +08:00
|
|
|
}, [activeSection, token]);
|
2026-05-22 22:25:12 +08:00
|
|
|
|
2026-05-24 13:38:37 +08:00
|
|
|
useEffect(() => {
|
2026-05-29 03:42:53 +08:00
|
|
|
if (activeSection !== "apps") return;
|
2026-05-24 13:38:37 +08:00
|
|
|
let cancelled = false;
|
|
|
|
|
setMcpPresetsLoading(true);
|
|
|
|
|
fetchMcpPresets(token)
|
|
|
|
|
.then((payload) => {
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
setMcpPresets(payload);
|
|
|
|
|
setMcpError(null);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
if (!cancelled) setMcpError((err as Error).message);
|
|
|
|
|
})
|
|
|
|
|
.finally(() => {
|
|
|
|
|
if (!cancelled) setMcpPresetsLoading(false);
|
|
|
|
|
});
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
2026-05-29 03:42:53 +08:00
|
|
|
}, [activeSection, token]);
|
2026-05-24 13:38:37 +08:00
|
|
|
|
2026-06-14 18:24:59 +08:00
|
|
|
const refreshAutomations = useCallback(
|
|
|
|
|
async (showLoading = false) => {
|
|
|
|
|
if (showLoading) setAutomationsLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const payload = await fetchAutomations(token);
|
|
|
|
|
setAutomations(payload);
|
|
|
|
|
setAutomationsError(null);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setAutomationsError((err as Error).message);
|
|
|
|
|
} finally {
|
|
|
|
|
if (showLoading) setAutomationsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[token],
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-13 20:08:18 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (activeSection !== "automations") return;
|
|
|
|
|
let cancelled = false;
|
2026-06-14 18:24:59 +08:00
|
|
|
const refresh = async (showLoading = false) => {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
if (showLoading) setAutomationsLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const payload = await fetchAutomations(token);
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
setAutomations(payload);
|
|
|
|
|
setAutomationsError(null);
|
|
|
|
|
} catch (err) {
|
2026-06-13 20:08:18 +08:00
|
|
|
if (!cancelled) setAutomationsError((err as Error).message);
|
2026-06-14 18:24:59 +08:00
|
|
|
} finally {
|
|
|
|
|
if (!cancelled && showLoading) setAutomationsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
void refresh(true);
|
|
|
|
|
const interval = window.setInterval(() => void refresh(false), 5000);
|
|
|
|
|
const refreshOnFocus = () => {
|
|
|
|
|
if (document.visibilityState !== "hidden") void refresh(false);
|
|
|
|
|
};
|
|
|
|
|
window.addEventListener("focus", refreshOnFocus);
|
|
|
|
|
document.addEventListener("visibilitychange", refreshOnFocus);
|
2026-06-13 20:08:18 +08:00
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
2026-06-14 18:24:59 +08:00
|
|
|
window.clearInterval(interval);
|
|
|
|
|
window.removeEventListener("focus", refreshOnFocus);
|
|
|
|
|
document.removeEventListener("visibilitychange", refreshOnFocus);
|
2026-06-13 20:08:18 +08:00
|
|
|
};
|
|
|
|
|
}, [activeSection, token]);
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
try {
|
|
|
|
|
window.localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify(localPrefs));
|
|
|
|
|
} catch {
|
|
|
|
|
// Browser-only preferences should never block settings.
|
|
|
|
|
}
|
|
|
|
|
}, [localPrefs]);
|
|
|
|
|
|
2026-05-08 15:31:52 +00:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!settings) return;
|
|
|
|
|
setProviderForms((prev) => {
|
|
|
|
|
const next = { ...prev };
|
|
|
|
|
for (const provider of settings.providers) {
|
|
|
|
|
next[provider.name] = {
|
|
|
|
|
apiKey: next[provider.name]?.apiKey ?? "",
|
|
|
|
|
apiBase: next[provider.name]?.apiBase ?? provider.api_base ?? provider.default_api_base ?? "",
|
2026-05-23 18:04:40 +08:00
|
|
|
apiType: next[provider.name]?.apiType ?? provider.api_type ?? "auto",
|
2026-05-08 15:31:52 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}, [settings]);
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
const modelDirty = useMemo(() => {
|
2026-04-25 18:05:06 +00:00
|
|
|
if (!settings) return false;
|
2026-05-29 03:42:53 +08:00
|
|
|
const activePresetName = modelPresetValue(settings);
|
|
|
|
|
const selectedPreset = settings.model_presets.find((preset) => preset.name === form.modelPreset);
|
|
|
|
|
if (!selectedPreset) return form.modelPreset !== activePresetName;
|
|
|
|
|
const selectedProvider = selectedPreset.is_default
|
|
|
|
|
? editableDefaultProvider(settings)
|
|
|
|
|
: selectedPreset.provider;
|
2026-04-25 18:05:06 +00:00
|
|
|
return (
|
2026-05-29 03:42:53 +08:00
|
|
|
form.modelPreset !== activePresetName ||
|
|
|
|
|
form.model !== selectedPreset.model ||
|
|
|
|
|
form.provider !== selectedProvider ||
|
2026-05-29 04:23:30 +08:00
|
|
|
form.contextWindowTokens !== normalizeContextWindowTokens(selectedPreset.context_window_tokens) ||
|
2026-05-29 03:42:53 +08:00
|
|
|
(!selectedPreset.is_default && form.presetLabel.trim() !== selectedPreset.label)
|
2026-04-25 18:05:06 +00:00
|
|
|
);
|
|
|
|
|
}, [form, settings]);
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
const runtimeDirty = useMemo(() => {
|
|
|
|
|
if (!settings) return false;
|
|
|
|
|
return (
|
|
|
|
|
form.timezone !== settings.agent.timezone ||
|
|
|
|
|
form.botName !== settings.agent.bot_name ||
|
2026-05-29 03:42:53 +08:00
|
|
|
form.botIcon !== settings.agent.bot_icon
|
2026-05-19 22:42:38 +08:00
|
|
|
);
|
|
|
|
|
}, [form, settings]);
|
|
|
|
|
|
|
|
|
|
const imageGenerationDirty = useMemo(() => {
|
|
|
|
|
if (!settings) return false;
|
|
|
|
|
return (
|
|
|
|
|
imageGenerationForm.enabled !== settings.image_generation.enabled ||
|
|
|
|
|
imageGenerationForm.provider !== settings.image_generation.provider ||
|
|
|
|
|
imageGenerationForm.model !== settings.image_generation.model ||
|
|
|
|
|
imageGenerationForm.defaultAspectRatio !== settings.image_generation.default_aspect_ratio ||
|
|
|
|
|
imageGenerationForm.defaultImageSize !== settings.image_generation.default_image_size ||
|
|
|
|
|
imageGenerationForm.maxImagesPerTurn !== settings.image_generation.max_images_per_turn
|
|
|
|
|
);
|
|
|
|
|
}, [imageGenerationForm, settings]);
|
|
|
|
|
|
2026-06-09 01:08:49 +08:00
|
|
|
const transcriptionDirty = useMemo(() => {
|
|
|
|
|
if (!settings) return false;
|
|
|
|
|
const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
|
|
|
|
|
return (
|
|
|
|
|
transcriptionForm.enabled !== transcription.enabled ||
|
|
|
|
|
transcriptionForm.provider !== transcription.provider ||
|
|
|
|
|
transcriptionForm.model !== transcription.model ||
|
|
|
|
|
transcriptionForm.language !== (transcription.language ?? "") ||
|
|
|
|
|
transcriptionForm.maxDurationSec !== transcription.max_duration_sec ||
|
|
|
|
|
transcriptionForm.maxUploadMb !== transcription.max_upload_mb
|
|
|
|
|
);
|
|
|
|
|
}, [settings, transcriptionForm]);
|
|
|
|
|
|
2026-05-29 03:42:53 +08:00
|
|
|
const networkSafetyDirty = useMemo(() => {
|
|
|
|
|
if (!settings) return false;
|
|
|
|
|
const currentLocalServiceAccess =
|
|
|
|
|
settings.advanced.webui_allow_local_service_access ?? settings.advanced.allow_local_preview_access ?? true;
|
|
|
|
|
const currentDefaultAccess = visibleWebuiDefaultAccessMode(settings.advanced.webui_default_access_mode);
|
|
|
|
|
return (
|
|
|
|
|
networkSafetyForm.webuiAllowLocalServiceAccess !== currentLocalServiceAccess ||
|
|
|
|
|
networkSafetyForm.webuiDefaultAccessMode !== currentDefaultAccess
|
|
|
|
|
);
|
|
|
|
|
}, [networkSafetyForm, settings]);
|
|
|
|
|
|
2026-05-24 13:38:37 +08:00
|
|
|
const configuredModelProviderOptions = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
settings?.providers
|
2026-06-06 12:25:03 -05:00
|
|
|
.filter((provider) => provider.configured && provider.model_selectable !== false)
|
2026-05-24 13:38:37 +08:00
|
|
|
.map((provider) => ({ name: provider.name, label: provider.label })) ?? [],
|
|
|
|
|
[settings],
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
const hasPendingRestart = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
!!settings?.requires_restart ||
|
|
|
|
|
pendingRestartSections.runtime ||
|
2026-05-29 03:42:53 +08:00
|
|
|
pendingRestartSections.browser ||
|
2026-05-19 22:42:38 +08:00
|
|
|
pendingRestartSections.image,
|
|
|
|
|
[pendingRestartSections, settings?.requires_restart],
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-29 03:42:53 +08:00
|
|
|
const restartViaSettingsSurface = useCallback(async () => {
|
|
|
|
|
const isNativeHost = (settings?.surface ?? settings?.runtime_surface) === "native";
|
2026-06-06 19:49:33 +08:00
|
|
|
if (
|
|
|
|
|
isNativeHost &&
|
|
|
|
|
settings?.runtime_capabilities?.can_restart_engine &&
|
|
|
|
|
onNativeEngineRestart
|
|
|
|
|
) {
|
2026-05-29 03:42:53 +08:00
|
|
|
setHostEngineApplying(true);
|
|
|
|
|
try {
|
2026-06-06 19:49:33 +08:00
|
|
|
const nextToken = await onNativeEngineRestart();
|
|
|
|
|
const payload = await fetchSettings(nextToken);
|
2026-05-29 03:42:53 +08:00
|
|
|
applyPayload(payload);
|
|
|
|
|
setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS);
|
|
|
|
|
setError(null);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError((err as Error).message);
|
|
|
|
|
} finally {
|
|
|
|
|
setHostEngineApplying(false);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
onRestart?.();
|
2026-06-06 19:49:33 +08:00
|
|
|
}, [applyPayload, onNativeEngineRestart, onRestart, settings]);
|
2026-05-29 03:42:53 +08:00
|
|
|
|
|
|
|
|
const maybeRestartHostEngine = useCallback(
|
|
|
|
|
async (payload: RestartAwarePayload) => {
|
|
|
|
|
const surface = payload.surface ?? payload.runtime_surface ?? settings?.surface ?? settings?.runtime_surface;
|
|
|
|
|
const capabilities = payload.runtime_capabilities ?? settings?.runtime_capabilities;
|
|
|
|
|
const isNativeHost = surface === "native";
|
2026-06-06 19:49:33 +08:00
|
|
|
if (
|
|
|
|
|
!payload.requires_restart ||
|
|
|
|
|
!isNativeHost ||
|
|
|
|
|
!capabilities?.can_restart_engine ||
|
|
|
|
|
!onNativeEngineRestart
|
|
|
|
|
) {
|
2026-05-29 03:42:53 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setHostEngineApplying(true);
|
|
|
|
|
try {
|
2026-06-06 19:49:33 +08:00
|
|
|
const nextToken = await onNativeEngineRestart();
|
|
|
|
|
const refreshed = await fetchSettings(nextToken);
|
2026-05-29 03:42:53 +08:00
|
|
|
applyPayload(refreshed);
|
|
|
|
|
setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS);
|
|
|
|
|
setError(null);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError((err as Error).message);
|
|
|
|
|
} finally {
|
|
|
|
|
setHostEngineApplying(false);
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-06-06 19:49:33 +08:00
|
|
|
[applyPayload, onNativeEngineRestart, settings],
|
2026-05-29 03:42:53 +08:00
|
|
|
);
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
const saveModelSettings = async () => {
|
|
|
|
|
if (!settings || !modelDirty || saving) return;
|
2026-04-25 18:05:06 +00:00
|
|
|
setSaving(true);
|
|
|
|
|
try {
|
2026-05-29 03:42:53 +08:00
|
|
|
const selectedPreset = settings.model_presets.find((preset) => preset.name === form.modelPreset);
|
|
|
|
|
let payload: SettingsPayload;
|
|
|
|
|
if (selectedPreset && !selectedPreset.is_default) {
|
|
|
|
|
payload = await updateModelConfiguration(token, {
|
|
|
|
|
name: selectedPreset.name,
|
|
|
|
|
label: form.presetLabel.trim(),
|
|
|
|
|
model: form.model,
|
|
|
|
|
provider: form.provider,
|
2026-05-29 04:23:30 +08:00
|
|
|
...(form.contextWindowTokens !== selectedPreset.context_window_tokens
|
|
|
|
|
? { contextWindowTokens: form.contextWindowTokens }
|
|
|
|
|
: {}),
|
2026-05-29 03:42:53 +08:00
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
const defaultModel = defaultPreset(settings)?.model ?? settings.agent.model;
|
|
|
|
|
const defaultProvider = editableDefaultProvider(settings);
|
2026-05-29 04:23:30 +08:00
|
|
|
const defaultContextWindowTokens = normalizeContextWindowTokens(
|
|
|
|
|
defaultPreset(settings)?.context_window_tokens ?? settings.agent.context_window_tokens,
|
|
|
|
|
);
|
2026-05-29 03:42:53 +08:00
|
|
|
payload = await updateSettings(token, {
|
|
|
|
|
modelPreset: form.modelPreset,
|
|
|
|
|
...(form.model !== defaultModel ? { model: form.model } : {}),
|
|
|
|
|
...(form.provider !== defaultProvider ? { provider: form.provider } : {}),
|
2026-05-29 04:23:30 +08:00
|
|
|
...(form.contextWindowTokens !== defaultContextWindowTokens
|
|
|
|
|
? { contextWindowTokens: form.contextWindowTokens }
|
|
|
|
|
: {}),
|
2026-05-29 03:42:53 +08:00
|
|
|
});
|
|
|
|
|
}
|
2026-04-25 18:05:06 +00:00
|
|
|
applyPayload(payload);
|
|
|
|
|
onModelNameChange(payload.agent.model || null);
|
|
|
|
|
setError(null);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError((err as Error).message);
|
|
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-24 13:38:37 +08:00
|
|
|
const openModelConfigurationDialog = () => {
|
|
|
|
|
if (!settings) return;
|
|
|
|
|
const currentProvider = settings.agent.provider;
|
|
|
|
|
const provider =
|
|
|
|
|
configuredModelProviderOptions.find((option) => option.name === currentProvider)?.name ??
|
|
|
|
|
configuredModelProviderOptions[0]?.name ??
|
|
|
|
|
"";
|
|
|
|
|
setModelConfigurationForm({
|
|
|
|
|
label: "",
|
|
|
|
|
provider,
|
|
|
|
|
model: "",
|
|
|
|
|
});
|
|
|
|
|
setModelConfigurationOpen(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleCreateModelConfiguration = async () => {
|
|
|
|
|
if (modelConfigurationSaving) return;
|
|
|
|
|
const label = modelConfigurationForm.label.trim();
|
|
|
|
|
const provider = modelConfigurationForm.provider.trim();
|
|
|
|
|
const model = modelConfigurationForm.model.trim();
|
|
|
|
|
if (!label || !provider || !model) return;
|
|
|
|
|
setModelConfigurationSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
const payload = await createModelConfiguration(token, {
|
|
|
|
|
label,
|
|
|
|
|
provider,
|
|
|
|
|
model,
|
|
|
|
|
});
|
|
|
|
|
applyPayload(payload);
|
|
|
|
|
onModelNameChange(payload.agent.model || null);
|
|
|
|
|
setModelConfigurationOpen(false);
|
|
|
|
|
setError(null);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError((err as Error).message);
|
|
|
|
|
} finally {
|
|
|
|
|
setModelConfigurationSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
const saveRuntimeSettings = async () => {
|
|
|
|
|
if (!settings || !runtimeDirty || saving) return;
|
|
|
|
|
setSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
const payload = await updateSettings(token, {
|
|
|
|
|
timezone: form.timezone,
|
|
|
|
|
botName: form.botName,
|
|
|
|
|
botIcon: form.botIcon,
|
|
|
|
|
});
|
|
|
|
|
applyPayload(payload);
|
|
|
|
|
if (payload.requires_restart) {
|
|
|
|
|
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
|
|
|
|
}
|
2026-05-29 03:42:53 +08:00
|
|
|
await onWorkspaceSettingsChange?.();
|
|
|
|
|
await maybeRestartHostEngine(payload);
|
2026-05-19 22:42:38 +08:00
|
|
|
setError(null);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError((err as Error).message);
|
|
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const saveImageGenerationSettings = async () => {
|
|
|
|
|
if (!settings || !imageGenerationDirty || imageGenerationSaving) return;
|
|
|
|
|
setImageGenerationSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
const payload = await updateImageGenerationSettings(token, imageGenerationForm);
|
|
|
|
|
applyPayload(payload);
|
|
|
|
|
if (payload.requires_restart) {
|
|
|
|
|
setPendingRestartSections((prev) => ({ ...prev, image: true }));
|
|
|
|
|
}
|
2026-05-29 03:42:53 +08:00
|
|
|
await maybeRestartHostEngine(payload);
|
2026-05-19 22:42:38 +08:00
|
|
|
setError(null);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError((err as Error).message);
|
|
|
|
|
} finally {
|
|
|
|
|
setImageGenerationSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-09 01:08:49 +08:00
|
|
|
const saveTranscriptionSettings = async () => {
|
|
|
|
|
if (!settings || !transcriptionDirty || transcriptionSaving) return;
|
|
|
|
|
setTranscriptionSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
const payload = await updateTranscriptionSettings(token, transcriptionForm);
|
|
|
|
|
applyPayload(payload);
|
|
|
|
|
if (payload.requires_restart) {
|
|
|
|
|
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
|
|
|
|
|
}
|
|
|
|
|
await maybeRestartHostEngine(payload);
|
|
|
|
|
setError(null);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError((err as Error).message);
|
|
|
|
|
} finally {
|
|
|
|
|
setTranscriptionSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-29 03:42:53 +08:00
|
|
|
const saveNetworkSafetySettings = async () => {
|
|
|
|
|
if (!settings || !networkSafetyDirty || networkSafetySaving) return;
|
|
|
|
|
setNetworkSafetySaving(true);
|
|
|
|
|
try {
|
|
|
|
|
const payload = await updateNetworkSafetySettings(token, networkSafetyForm);
|
|
|
|
|
applyPayload(payload);
|
|
|
|
|
if (payload.requires_restart) {
|
|
|
|
|
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
|
|
|
|
}
|
|
|
|
|
await maybeRestartHostEngine(payload);
|
|
|
|
|
setError(null);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError((err as Error).message);
|
|
|
|
|
} finally {
|
|
|
|
|
setNetworkSafetySaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-08 15:31:52 +00:00
|
|
|
const saveProvider = async (providerName: string) => {
|
|
|
|
|
if (providerSaving) return;
|
|
|
|
|
const provider = settings?.providers.find((item) => item.name === providerName);
|
|
|
|
|
if (!provider) return;
|
2026-05-29 03:42:53 +08:00
|
|
|
if (provider.auth_type === "oauth") return;
|
2026-05-23 18:04:40 +08:00
|
|
|
const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "", apiType: "auto" };
|
2026-05-08 15:31:52 +00:00
|
|
|
const apiKey = providerForm.apiKey.trim();
|
2026-05-17 17:41:33 +08:00
|
|
|
const apiKeyRequired = provider.api_key_required ?? true;
|
|
|
|
|
if (!provider.configured && apiKeyRequired && !apiKey) {
|
2026-05-08 15:31:52 +00:00
|
|
|
setError(t("settings.byok.apiKeyRequired"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setProviderSaving(providerName);
|
|
|
|
|
try {
|
|
|
|
|
const payload = await updateProviderSettings(token, {
|
|
|
|
|
provider: providerName,
|
|
|
|
|
apiKey: apiKey || undefined,
|
|
|
|
|
apiBase: providerForm.apiBase.trim(),
|
2026-05-23 18:04:40 +08:00
|
|
|
apiType: providerForm.apiType,
|
2026-05-08 15:31:52 +00:00
|
|
|
});
|
|
|
|
|
applyPayload(payload);
|
2026-05-19 22:42:38 +08:00
|
|
|
if (payload.requires_restart) {
|
|
|
|
|
setPendingRestartSections((prev) => ({ ...prev, image: true }));
|
|
|
|
|
}
|
2026-05-29 03:42:53 +08:00
|
|
|
await maybeRestartHostEngine(payload);
|
2026-05-08 15:31:52 +00:00
|
|
|
setProviderForms((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[providerName]: {
|
|
|
|
|
apiKey: "",
|
|
|
|
|
apiBase: providerForm.apiBase.trim(),
|
2026-05-23 18:04:40 +08:00
|
|
|
apiType: providerForm.apiType,
|
2026-05-08 15:31:52 +00:00
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false }));
|
|
|
|
|
setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false }));
|
|
|
|
|
setError(null);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError((err as Error).message);
|
|
|
|
|
} finally {
|
|
|
|
|
setProviderSaving(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-29 03:42:53 +08:00
|
|
|
const runProviderOAuth = async (providerName: string, action: "login" | "logout") => {
|
|
|
|
|
if (providerSaving) return;
|
|
|
|
|
setProviderSaving(providerName);
|
|
|
|
|
try {
|
|
|
|
|
const payload =
|
|
|
|
|
action === "login"
|
|
|
|
|
? await loginProviderOAuth(token, providerName)
|
|
|
|
|
: await logoutProviderOAuth(token, providerName);
|
|
|
|
|
applyPayload(payload);
|
|
|
|
|
setExpandedProvider(providerName);
|
|
|
|
|
setError(null);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError((err as Error).message);
|
|
|
|
|
} finally {
|
|
|
|
|
setProviderSaving(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-09 06:48:26 +00:00
|
|
|
const saveWebSearch = async () => {
|
|
|
|
|
if (!settings || webSearchSaving) return;
|
|
|
|
|
const provider = settings.web_search.providers.find((item) => item.name === webSearchForm.provider);
|
|
|
|
|
if (!provider) return;
|
|
|
|
|
const apiKey = webSearchForm.apiKey?.trim() ?? "";
|
|
|
|
|
const baseUrl = webSearchForm.baseUrl?.trim() ?? "";
|
|
|
|
|
const hasExistingSecret =
|
|
|
|
|
provider.credential === "api_key" &&
|
|
|
|
|
webSearchForm.provider === settings.web_search.provider &&
|
|
|
|
|
!!settings.web_search.api_key_hint;
|
|
|
|
|
|
|
|
|
|
if (provider.credential === "api_key" && !apiKey && !hasExistingSecret) {
|
|
|
|
|
setError(t("settings.byok.webSearch.apiKeyRequired"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (provider.credential === "base_url" && !baseUrl) {
|
|
|
|
|
setError(t("settings.byok.webSearch.baseUrlRequired"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setWebSearchSaving(true);
|
|
|
|
|
try {
|
2026-05-19 22:42:38 +08:00
|
|
|
const webFetchRestartRequired =
|
|
|
|
|
(webSearchForm.useJinaReader ?? settings.web.fetch.use_jina_reader) !==
|
|
|
|
|
settings.web.fetch.use_jina_reader;
|
|
|
|
|
const update: WebSearchSettingsUpdate = {
|
|
|
|
|
provider: webSearchForm.provider,
|
|
|
|
|
maxResults: webSearchForm.maxResults,
|
|
|
|
|
timeout: webSearchForm.timeout,
|
|
|
|
|
useJinaReader: webSearchForm.useJinaReader,
|
|
|
|
|
};
|
2026-05-09 06:48:26 +00:00
|
|
|
if (provider.credential === "api_key" && apiKey) update.apiKey = apiKey;
|
|
|
|
|
if (provider.credential === "base_url") update.baseUrl = baseUrl;
|
|
|
|
|
const payload = await updateWebSearchSettings(token, update);
|
|
|
|
|
applyPayload(payload);
|
2026-05-19 22:42:38 +08:00
|
|
|
if (payload.requires_restart || webFetchRestartRequired) {
|
2026-05-29 03:42:53 +08:00
|
|
|
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
|
2026-05-19 22:42:38 +08:00
|
|
|
}
|
2026-05-29 03:42:53 +08:00
|
|
|
await maybeRestartHostEngine(payload);
|
2026-05-09 06:48:26 +00:00
|
|
|
setWebSearchForm((prev) => ({
|
|
|
|
|
provider: payload.web_search.provider,
|
|
|
|
|
apiKey: "",
|
|
|
|
|
baseUrl: payload.web_search.base_url ?? prev.baseUrl ?? "",
|
2026-05-19 22:42:38 +08:00
|
|
|
maxResults: payload.web_search.max_results,
|
|
|
|
|
timeout: payload.web_search.timeout,
|
|
|
|
|
useJinaReader: payload.web.fetch.use_jina_reader,
|
2026-05-09 06:48:26 +00:00
|
|
|
}));
|
|
|
|
|
setWebSearchKeyVisible(false);
|
|
|
|
|
setWebSearchKeyEditing(false);
|
|
|
|
|
setError(null);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError((err as Error).message);
|
|
|
|
|
} finally {
|
|
|
|
|
setWebSearchSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const resetProviderDraft = useCallback((providerName: string) => {
|
|
|
|
|
const provider = settings?.providers.find((item) => item.name === providerName);
|
|
|
|
|
if (!provider) return;
|
|
|
|
|
setProviderForms((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[providerName]: {
|
|
|
|
|
apiKey: "",
|
|
|
|
|
apiBase: provider.api_base ?? provider.default_api_base ?? "",
|
2026-05-23 18:04:40 +08:00
|
|
|
apiType: provider.api_type ?? "auto",
|
2026-05-09 06:48:26 +00:00
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false }));
|
|
|
|
|
setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false }));
|
|
|
|
|
}, [settings]);
|
|
|
|
|
|
|
|
|
|
const handleToggleProvider = useCallback((providerName: string) => {
|
|
|
|
|
if (expandedProvider) resetProviderDraft(expandedProvider);
|
|
|
|
|
setExpandedProvider(expandedProvider === providerName ? null : providerName);
|
|
|
|
|
}, [expandedProvider, resetProviderDraft]);
|
|
|
|
|
|
|
|
|
|
const resetWebSearchDraft = useCallback(() => {
|
|
|
|
|
if (!settings) return;
|
|
|
|
|
setWebSearchForm({
|
|
|
|
|
provider: settings.web_search.provider,
|
|
|
|
|
apiKey: "",
|
|
|
|
|
baseUrl: settings.web_search.base_url ?? "",
|
2026-05-19 22:42:38 +08:00
|
|
|
maxResults: settings.web_search.max_results,
|
|
|
|
|
timeout: settings.web_search.timeout,
|
|
|
|
|
useJinaReader: settings.web.fetch.use_jina_reader,
|
2026-05-09 06:48:26 +00:00
|
|
|
});
|
|
|
|
|
setWebSearchKeyVisible(false);
|
|
|
|
|
setWebSearchKeyEditing(false);
|
|
|
|
|
}, [settings]);
|
|
|
|
|
|
|
|
|
|
const handleWebSearchProviderChange = useCallback((provider: string) => {
|
|
|
|
|
if (!settings) return;
|
2026-05-19 22:42:38 +08:00
|
|
|
setWebSearchForm((prev) => ({
|
2026-05-09 06:48:26 +00:00
|
|
|
provider,
|
|
|
|
|
apiKey: "",
|
|
|
|
|
baseUrl: provider === settings.web_search.provider ? settings.web_search.base_url ?? "" : "",
|
2026-05-19 22:42:38 +08:00
|
|
|
maxResults: prev.maxResults ?? settings.web_search.max_results,
|
|
|
|
|
timeout: prev.timeout ?? settings.web_search.timeout,
|
|
|
|
|
useJinaReader: prev.useJinaReader ?? settings.web.fetch.use_jina_reader,
|
|
|
|
|
}));
|
2026-05-09 06:48:26 +00:00
|
|
|
setWebSearchKeyVisible(false);
|
|
|
|
|
setWebSearchKeyEditing(false);
|
|
|
|
|
}, [settings]);
|
|
|
|
|
|
2026-05-08 15:31:52 +00:00
|
|
|
const toggleProviderKeyVisibility = (providerName: string) => {
|
|
|
|
|
const isVisible = visibleProviderKeys[providerName];
|
|
|
|
|
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: !isVisible }));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const toggleProviderKeyEditing = (providerName: string) => {
|
|
|
|
|
setEditingProviderKeys((prev) => {
|
|
|
|
|
const nextEditing = !prev[providerName];
|
|
|
|
|
if (!nextEditing) {
|
|
|
|
|
setProviderForms((forms) => ({
|
|
|
|
|
...forms,
|
|
|
|
|
[providerName]: {
|
|
|
|
|
apiKey: "",
|
|
|
|
|
apiBase: forms[providerName]?.apiBase ?? "",
|
2026-05-23 18:04:40 +08:00
|
|
|
apiType: forms[providerName]?.apiType ?? "auto",
|
2026-05-08 15:31:52 +00:00
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
setVisibleProviderKeys((visible) => ({ ...visible, [providerName]: false }));
|
|
|
|
|
}
|
|
|
|
|
return { ...prev, [providerName]: nextEditing };
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-22 22:25:12 +08:00
|
|
|
const handleCliAppAction = async (
|
|
|
|
|
action: "install" | "update" | "uninstall" | "test",
|
|
|
|
|
name: string,
|
|
|
|
|
) => {
|
|
|
|
|
const key = `${action}:${name}`;
|
|
|
|
|
setCliAppsAction(key);
|
|
|
|
|
setCliAppsMessage(null);
|
|
|
|
|
setCliAppsError(null);
|
|
|
|
|
try {
|
|
|
|
|
const payload = await runCliAppAction(token, action, name);
|
|
|
|
|
setCliApps(payload);
|
2026-05-23 00:59:54 +08:00
|
|
|
if (action !== "test") {
|
|
|
|
|
notifyCliAppsChanged(payload);
|
|
|
|
|
}
|
2026-05-22 22:25:12 +08:00
|
|
|
setCliAppsMessage(payload.last_action?.message ?? null);
|
|
|
|
|
setCliAppsFocusName(action === "uninstall" ? null : name);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setCliAppsError((err as Error).message);
|
|
|
|
|
} finally {
|
|
|
|
|
setCliAppsAction(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-13 20:08:18 +08:00
|
|
|
const handleAutomationAction = async (
|
|
|
|
|
action: AutomationAction,
|
|
|
|
|
job: SessionAutomationJob,
|
|
|
|
|
) => {
|
|
|
|
|
const key = `${action}:${job.id}`;
|
|
|
|
|
setAutomationAction(key);
|
|
|
|
|
setAutomationsError(null);
|
|
|
|
|
try {
|
|
|
|
|
const payload = await runAutomationAction(token, action, job.id);
|
|
|
|
|
setAutomations(payload);
|
|
|
|
|
if (action === "delete") setAutomationPendingDelete(null);
|
2026-06-14 18:24:59 +08:00
|
|
|
if (action === "run") {
|
|
|
|
|
window.setTimeout(() => void refreshAutomations(false), 1200);
|
|
|
|
|
window.setTimeout(() => void refreshAutomations(false), 4000);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setAutomationsError((err as Error).message);
|
|
|
|
|
} finally {
|
|
|
|
|
setAutomationAction(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleAutomationEdit = async (
|
|
|
|
|
job: SessionAutomationJob,
|
|
|
|
|
values: AutomationUpdatePayload,
|
|
|
|
|
) => {
|
|
|
|
|
const key = `update:${job.id}`;
|
|
|
|
|
setAutomationAction(key);
|
|
|
|
|
setAutomationsError(null);
|
|
|
|
|
try {
|
|
|
|
|
const payload = await updateAutomation(token, job.id, values);
|
|
|
|
|
setAutomations(payload);
|
|
|
|
|
setAutomationPendingEdit(null);
|
2026-06-13 20:08:18 +08:00
|
|
|
} catch (err) {
|
|
|
|
|
setAutomationsError((err as Error).message);
|
|
|
|
|
} finally {
|
|
|
|
|
setAutomationAction(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-24 13:38:37 +08:00
|
|
|
const handleMcpPresetAction = async (
|
|
|
|
|
action: "enable" | "remove" | "test",
|
|
|
|
|
name: string,
|
|
|
|
|
values: Record<string, string> = {},
|
|
|
|
|
) => {
|
|
|
|
|
const key = `${action}:${name}`;
|
|
|
|
|
setMcpPresetAction(key);
|
|
|
|
|
setMcpMessage(null);
|
|
|
|
|
setMcpError(null);
|
|
|
|
|
try {
|
|
|
|
|
const payload = await runMcpPresetAction(token, action, name, values);
|
|
|
|
|
setMcpPresets(payload);
|
|
|
|
|
setMcpMessage(payload.last_action?.message ?? null);
|
|
|
|
|
if (action !== "test") {
|
|
|
|
|
notifyMcpPresetsChanged(payload);
|
|
|
|
|
}
|
|
|
|
|
if (payload.requires_restart) {
|
|
|
|
|
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
|
|
|
|
}
|
2026-05-29 03:42:53 +08:00
|
|
|
await maybeRestartHostEngine(payload);
|
2026-05-24 13:38:37 +08:00
|
|
|
if (action === "enable") {
|
|
|
|
|
setMcpFieldValues((prev) => ({ ...prev, [name]: {} }));
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setMcpError((err as Error).message);
|
|
|
|
|
} finally {
|
|
|
|
|
setMcpPresetAction(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSaveCustomMcp = async () => {
|
|
|
|
|
const name = customMcpForm.name.trim();
|
|
|
|
|
const key = `custom:${name || "new"}`;
|
|
|
|
|
setMcpPresetAction(key);
|
|
|
|
|
setMcpMessage(null);
|
|
|
|
|
setMcpError(null);
|
|
|
|
|
try {
|
|
|
|
|
const payload = await saveCustomMcpServer(token, {
|
|
|
|
|
name,
|
|
|
|
|
transport: customMcpForm.transport,
|
|
|
|
|
command: customMcpForm.command,
|
|
|
|
|
args: customMcpForm.args,
|
|
|
|
|
url: customMcpForm.url,
|
|
|
|
|
env: customMcpForm.env,
|
|
|
|
|
headers: customMcpForm.headers,
|
|
|
|
|
tool_timeout: customMcpForm.toolTimeout,
|
|
|
|
|
});
|
|
|
|
|
setMcpPresets(payload);
|
|
|
|
|
setMcpMessage(payload.last_action?.message ?? null);
|
|
|
|
|
notifyMcpPresetsChanged(payload);
|
|
|
|
|
if (payload.requires_restart) {
|
|
|
|
|
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
|
|
|
|
}
|
2026-05-29 03:42:53 +08:00
|
|
|
await maybeRestartHostEngine(payload);
|
2026-05-24 13:38:37 +08:00
|
|
|
setCustomMcpForm((prev) => ({ ...DEFAULT_CUSTOM_MCP_FORM, transport: prev.transport }));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setMcpError((err as Error).message);
|
|
|
|
|
} finally {
|
|
|
|
|
setMcpPresetAction(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleImportMcpConfig = async () => {
|
|
|
|
|
setMcpPresetAction("import");
|
|
|
|
|
setMcpMessage(null);
|
|
|
|
|
setMcpError(null);
|
|
|
|
|
try {
|
|
|
|
|
const payload = await importMcpConfig(token, mcpConfigImport);
|
|
|
|
|
setMcpPresets(payload);
|
|
|
|
|
setMcpMessage(payload.last_action?.message ?? null);
|
|
|
|
|
notifyMcpPresetsChanged(payload);
|
|
|
|
|
if (payload.requires_restart) {
|
|
|
|
|
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
|
|
|
|
}
|
2026-05-29 03:42:53 +08:00
|
|
|
await maybeRestartHostEngine(payload);
|
2026-05-24 13:38:37 +08:00
|
|
|
setMcpConfigImport("");
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setMcpError((err as Error).message);
|
|
|
|
|
} finally {
|
|
|
|
|
setMcpPresetAction(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleMcpToolsChange = async (name: string, enabledTools: string[]) => {
|
|
|
|
|
setMcpPresetAction(`tools:${name}`);
|
|
|
|
|
setMcpMessage(null);
|
|
|
|
|
setMcpError(null);
|
|
|
|
|
try {
|
|
|
|
|
const payload = await updateMcpServerTools(token, name, enabledTools);
|
|
|
|
|
setMcpPresets(payload);
|
|
|
|
|
setMcpMessage(payload.last_action?.message ?? null);
|
|
|
|
|
notifyMcpPresetsChanged(payload);
|
|
|
|
|
if (payload.requires_restart) {
|
|
|
|
|
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
|
|
|
|
}
|
2026-05-29 03:42:53 +08:00
|
|
|
await maybeRestartHostEngine(payload);
|
2026-05-24 13:38:37 +08:00
|
|
|
} catch (err) {
|
|
|
|
|
setMcpError((err as Error).message);
|
|
|
|
|
} finally {
|
|
|
|
|
setMcpPresetAction(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
const renderSection = () => {
|
|
|
|
|
if (!settings) return null;
|
|
|
|
|
switch (activeSection) {
|
|
|
|
|
case "overview":
|
|
|
|
|
return (
|
|
|
|
|
<OverviewSettings
|
|
|
|
|
settings={settings}
|
|
|
|
|
requiresRestart={hasPendingRestart}
|
2026-05-24 13:38:37 +08:00
|
|
|
showBrandLogos={localPrefs.brandLogos}
|
2026-06-02 11:29:18 +08:00
|
|
|
onSelectSection={selectSection}
|
2026-05-19 22:42:38 +08:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
case "appearance":
|
|
|
|
|
return (
|
|
|
|
|
<AppearanceSettings
|
|
|
|
|
theme={theme}
|
|
|
|
|
onToggleTheme={onToggleTheme}
|
|
|
|
|
localPrefs={localPrefs}
|
|
|
|
|
onChangeLocalPrefs={setLocalPrefs}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
case "models":
|
|
|
|
|
return (
|
2026-05-24 13:38:37 +08:00
|
|
|
<div className="space-y-8">
|
|
|
|
|
<ModelsSettings
|
2026-05-30 23:45:26 +08:00
|
|
|
token={token}
|
2026-05-24 13:38:37 +08:00
|
|
|
form={form}
|
|
|
|
|
setForm={setForm}
|
|
|
|
|
settings={settings}
|
|
|
|
|
dirty={modelDirty}
|
|
|
|
|
saving={saving}
|
|
|
|
|
showBrandLogos={localPrefs.brandLogos}
|
2026-05-29 03:42:53 +08:00
|
|
|
providerSaving={providerSaving}
|
|
|
|
|
onProviderOAuthLogin={(provider) => runProviderOAuth(provider, "login")}
|
2026-05-24 13:38:37 +08:00
|
|
|
onSave={saveModelSettings}
|
|
|
|
|
onCreateConfiguration={openModelConfigurationDialog}
|
|
|
|
|
/>
|
|
|
|
|
<ProvidersSettings
|
|
|
|
|
settings={settings}
|
|
|
|
|
expandedProvider={expandedProvider}
|
|
|
|
|
providerForms={providerForms}
|
|
|
|
|
visibleProviderKeys={visibleProviderKeys}
|
|
|
|
|
editingProviderKeys={editingProviderKeys}
|
|
|
|
|
providerSaving={providerSaving}
|
|
|
|
|
query={providerQuery}
|
|
|
|
|
showBrandLogos={localPrefs.brandLogos}
|
|
|
|
|
onQueryChange={setProviderQuery}
|
|
|
|
|
onToggleProvider={handleToggleProvider}
|
|
|
|
|
onToggleProviderKey={toggleProviderKeyVisibility}
|
|
|
|
|
onToggleProviderKeyEditing={toggleProviderKeyEditing}
|
|
|
|
|
onChangeProviderForm={(provider, value) =>
|
|
|
|
|
setProviderForms((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[provider]: {
|
|
|
|
|
apiKey: prev[provider]?.apiKey ?? "",
|
|
|
|
|
apiBase: prev[provider]?.apiBase ?? "",
|
2026-05-23 18:04:40 +08:00
|
|
|
apiType: prev[provider]?.apiType ?? "auto",
|
2026-05-24 13:38:37 +08:00
|
|
|
...value,
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
onSaveProvider={saveProvider}
|
2026-05-29 03:42:53 +08:00
|
|
|
onProviderOAuthLogin={(provider) => runProviderOAuth(provider, "login")}
|
|
|
|
|
onProviderOAuthLogout={(provider) => runProviderOAuth(provider, "logout")}
|
2026-05-24 13:38:37 +08:00
|
|
|
onResetProviderDraft={resetProviderDraft}
|
|
|
|
|
imageProviderRestartPending={pendingRestartSections.image}
|
2026-05-29 03:42:53 +08:00
|
|
|
onRestart={restartViaSettingsSurface}
|
|
|
|
|
isRestarting={isRestarting || hostEngineApplying}
|
2026-05-24 13:38:37 +08:00
|
|
|
/>
|
|
|
|
|
</div>
|
2026-05-19 22:42:38 +08:00
|
|
|
);
|
|
|
|
|
case "image":
|
|
|
|
|
return (
|
|
|
|
|
<ImageGenerationSettings
|
|
|
|
|
settings={settings}
|
|
|
|
|
form={imageGenerationForm}
|
|
|
|
|
dirty={imageGenerationDirty}
|
|
|
|
|
saving={imageGenerationSaving}
|
|
|
|
|
onChangeForm={setImageGenerationForm}
|
|
|
|
|
onSave={saveImageGenerationSettings}
|
2026-06-02 11:29:18 +08:00
|
|
|
onOpenProviders={() => selectSection("models")}
|
2026-05-24 13:38:37 +08:00
|
|
|
showBrandLogos={localPrefs.brandLogos}
|
2026-05-29 03:42:53 +08:00
|
|
|
onRestart={restartViaSettingsSurface}
|
|
|
|
|
isRestarting={isRestarting || hostEngineApplying}
|
2026-05-19 22:42:38 +08:00
|
|
|
requiresRestartPending={pendingRestartSections.image}
|
|
|
|
|
/>
|
|
|
|
|
);
|
2026-06-09 01:08:49 +08:00
|
|
|
case "voice":
|
|
|
|
|
return (
|
|
|
|
|
<TranscriptionSettings
|
|
|
|
|
settings={settings}
|
|
|
|
|
form={transcriptionForm}
|
|
|
|
|
dirty={transcriptionDirty}
|
|
|
|
|
saving={transcriptionSaving}
|
|
|
|
|
onChangeForm={setTranscriptionForm}
|
|
|
|
|
onSave={saveTranscriptionSettings}
|
|
|
|
|
onOpenProviders={() => selectSection("models")}
|
|
|
|
|
showBrandLogos={localPrefs.brandLogos}
|
|
|
|
|
onRestart={restartViaSettingsSurface}
|
|
|
|
|
isRestarting={isRestarting || hostEngineApplying}
|
|
|
|
|
requiresRestartPending={pendingRestartSections.browser}
|
|
|
|
|
/>
|
|
|
|
|
);
|
2026-05-29 03:42:53 +08:00
|
|
|
case "browser":
|
2026-05-19 22:42:38 +08:00
|
|
|
return (
|
|
|
|
|
<WebSettings
|
|
|
|
|
settings={settings}
|
|
|
|
|
form={webSearchForm}
|
|
|
|
|
keyVisible={webSearchKeyVisible}
|
|
|
|
|
keyEditing={webSearchKeyEditing}
|
|
|
|
|
saving={webSearchSaving}
|
|
|
|
|
onChangeForm={setWebSearchForm}
|
|
|
|
|
onChangeProvider={handleWebSearchProviderChange}
|
|
|
|
|
onToggleKey={() => setWebSearchKeyVisible((visible) => !visible)}
|
|
|
|
|
onToggleKeyEditing={() => {
|
|
|
|
|
setWebSearchKeyEditing((editing) => !editing);
|
|
|
|
|
setWebSearchKeyVisible(false);
|
|
|
|
|
setWebSearchForm((prev) => ({ ...prev, apiKey: "" }));
|
|
|
|
|
}}
|
|
|
|
|
onReset={resetWebSearchDraft}
|
|
|
|
|
onSave={saveWebSearch}
|
2026-05-24 13:38:37 +08:00
|
|
|
showBrandLogos={localPrefs.brandLogos}
|
2026-05-29 03:42:53 +08:00
|
|
|
onRestart={restartViaSettingsSurface}
|
|
|
|
|
isRestarting={isRestarting || hostEngineApplying}
|
|
|
|
|
requiresRestartPending={pendingRestartSections.browser}
|
2026-05-19 22:42:38 +08:00
|
|
|
/>
|
|
|
|
|
);
|
2026-05-25 20:07:02 +08:00
|
|
|
case "apps":
|
2026-05-22 22:25:12 +08:00
|
|
|
return (
|
2026-05-25 20:07:02 +08:00
|
|
|
<AppsCatalogSettings
|
|
|
|
|
cliApps={cliApps}
|
|
|
|
|
mcpPresets={mcpPresets}
|
|
|
|
|
cliAppsLoading={cliAppsLoading}
|
|
|
|
|
mcpPresetsLoading={mcpPresetsLoading}
|
|
|
|
|
query={appsQuery}
|
|
|
|
|
filter={appsKindFilter}
|
|
|
|
|
cliActionKey={cliAppsAction}
|
|
|
|
|
mcpActionKey={mcpPresetAction}
|
|
|
|
|
cliMessage={cliAppsMessage}
|
|
|
|
|
cliError={cliAppsError}
|
|
|
|
|
cliFocusName={cliAppsFocusName}
|
|
|
|
|
mcpMessage={mcpMessage}
|
|
|
|
|
mcpError={mcpError}
|
|
|
|
|
mcpFieldValues={mcpFieldValues}
|
|
|
|
|
customMcpForm={customMcpForm}
|
|
|
|
|
mcpConfigImport={mcpConfigImport}
|
2026-05-24 13:38:37 +08:00
|
|
|
showBrandLogos={localPrefs.brandLogos}
|
|
|
|
|
requiresRestartPending={pendingRestartSections.runtime}
|
2026-05-25 20:07:02 +08:00
|
|
|
onQueryChange={setAppsQuery}
|
|
|
|
|
onFilterChange={setAppsKindFilter}
|
|
|
|
|
onCliAction={handleCliAppAction}
|
|
|
|
|
onMcpAction={handleMcpPresetAction}
|
|
|
|
|
onDismissStatus={() => {
|
|
|
|
|
setCliAppsMessage(null);
|
|
|
|
|
setCliAppsError(null);
|
|
|
|
|
setMcpMessage(null);
|
|
|
|
|
setMcpError(null);
|
|
|
|
|
}}
|
|
|
|
|
onBackToChat={onBackToChat}
|
|
|
|
|
onMcpFieldChange={(presetName, fieldName, value) => {
|
2026-05-24 13:38:37 +08:00
|
|
|
setMcpFieldValues((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[presetName]: {
|
|
|
|
|
...(prev[presetName] ?? {}),
|
|
|
|
|
[fieldName]: value,
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
}}
|
2026-05-25 20:07:02 +08:00
|
|
|
onCustomMcpFormChange={setCustomMcpForm}
|
|
|
|
|
onMcpConfigImportChange={setMcpConfigImport}
|
|
|
|
|
onSaveCustomMcp={handleSaveCustomMcp}
|
|
|
|
|
onImportMcpConfig={handleImportMcpConfig}
|
|
|
|
|
onMcpToolsChange={handleMcpToolsChange}
|
2026-05-29 03:42:53 +08:00
|
|
|
onRestart={restartViaSettingsSurface}
|
|
|
|
|
isRestarting={isRestarting || hostEngineApplying}
|
2026-05-24 13:38:37 +08:00
|
|
|
/>
|
|
|
|
|
);
|
2026-06-13 20:08:18 +08:00
|
|
|
case "automations":
|
|
|
|
|
return (
|
|
|
|
|
<AutomationsSettings
|
|
|
|
|
payload={automations}
|
|
|
|
|
loading={automationsLoading}
|
|
|
|
|
query={automationsQuery}
|
|
|
|
|
filter={automationsFilter}
|
2026-06-14 18:24:59 +08:00
|
|
|
sort={automationsSort}
|
2026-06-13 20:08:18 +08:00
|
|
|
actionKey={automationAction}
|
|
|
|
|
error={automationsError}
|
|
|
|
|
onQueryChange={setAutomationsQuery}
|
|
|
|
|
onFilterChange={setAutomationsFilter}
|
2026-06-14 18:24:59 +08:00
|
|
|
onSortChange={setAutomationsSort}
|
2026-06-13 20:08:18 +08:00
|
|
|
onAction={handleAutomationAction}
|
2026-06-14 18:24:59 +08:00
|
|
|
onRequestEdit={setAutomationPendingEdit}
|
2026-06-13 20:08:18 +08:00
|
|
|
onRequestDelete={setAutomationPendingDelete}
|
|
|
|
|
/>
|
|
|
|
|
);
|
2026-06-06 19:49:33 +08:00
|
|
|
case "skills":
|
|
|
|
|
return <SkillsCatalogSettings skills={skills} />;
|
2026-05-19 22:42:38 +08:00
|
|
|
case "runtime":
|
|
|
|
|
return (
|
|
|
|
|
<RuntimeSettings
|
|
|
|
|
form={form}
|
|
|
|
|
setForm={setForm}
|
|
|
|
|
settings={settings}
|
|
|
|
|
dirty={runtimeDirty}
|
|
|
|
|
saving={saving}
|
|
|
|
|
onSave={saveRuntimeSettings}
|
2026-05-29 03:42:53 +08:00
|
|
|
onRestart={restartViaSettingsSurface}
|
|
|
|
|
isRestarting={isRestarting || hostEngineApplying}
|
2026-05-19 22:42:38 +08:00
|
|
|
requiresRestartPending={pendingRestartSections.runtime}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
case "advanced":
|
2026-05-29 03:42:53 +08:00
|
|
|
return (
|
|
|
|
|
<AdvancedSettings
|
|
|
|
|
form={networkSafetyForm}
|
|
|
|
|
dirty={networkSafetyDirty}
|
|
|
|
|
saving={networkSafetySaving}
|
|
|
|
|
isNativeHostSurface={(settings.surface ?? settings.runtime_surface) === "native"}
|
|
|
|
|
onChangeForm={setNetworkSafetyForm}
|
|
|
|
|
onSave={saveNetworkSafetySettings}
|
|
|
|
|
onRestart={restartViaSettingsSurface}
|
|
|
|
|
isRestarting={isRestarting || hostEngineApplying}
|
|
|
|
|
requiresRestartPending={pendingRestartSections.runtime}
|
|
|
|
|
/>
|
|
|
|
|
);
|
2026-05-19 22:42:38 +08:00
|
|
|
default:
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-25 18:05:06 +00:00
|
|
|
return (
|
2026-05-19 22:42:38 +08:00
|
|
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[radial-gradient(circle_at_50%_0%,hsl(var(--muted))_0%,hsl(var(--background))_42%)] md:flex-row">
|
2026-05-25 20:07:02 +08:00
|
|
|
{showSidebar ? (
|
|
|
|
|
<SettingsSidebar
|
|
|
|
|
activeSection={activeSection}
|
2026-06-02 11:29:18 +08:00
|
|
|
onSelectSection={selectSection}
|
2026-05-25 20:07:02 +08:00
|
|
|
onBackToChat={onBackToChat}
|
|
|
|
|
onLogout={onLogout}
|
2026-05-29 03:42:53 +08:00
|
|
|
hostChromeInset={hostChromeInset}
|
2026-05-25 20:07:02 +08:00
|
|
|
/>
|
|
|
|
|
) : null}
|
2026-04-25 18:05:06 +00:00
|
|
|
|
2026-05-24 13:38:37 +08:00
|
|
|
<NewModelConfigurationDialog
|
|
|
|
|
open={modelConfigurationOpen}
|
|
|
|
|
draft={modelConfigurationForm}
|
|
|
|
|
providers={configuredModelProviderOptions}
|
|
|
|
|
saving={modelConfigurationSaving}
|
|
|
|
|
showProviderLogos={localPrefs.brandLogos}
|
|
|
|
|
onOpenChange={setModelConfigurationOpen}
|
|
|
|
|
onChangeDraft={setModelConfigurationForm}
|
|
|
|
|
onSave={handleCreateModelConfiguration}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-06-13 20:08:18 +08:00
|
|
|
<AutomationDeleteDialog
|
|
|
|
|
job={automationPendingDelete}
|
|
|
|
|
deleting={automationAction === `delete:${automationPendingDelete?.id ?? ""}`}
|
|
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
if (!open) setAutomationPendingDelete(null);
|
|
|
|
|
}}
|
|
|
|
|
onConfirm={(job) => handleAutomationAction("delete", job)}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-06-14 18:24:59 +08:00
|
|
|
<AutomationEditDialog
|
|
|
|
|
job={automationPendingEdit}
|
|
|
|
|
saving={automationAction === `update:${automationPendingEdit?.id ?? ""}`}
|
|
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
if (!open) setAutomationPendingEdit(null);
|
|
|
|
|
}}
|
|
|
|
|
onSave={handleAutomationEdit}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-05-09 06:48:26 +00:00
|
|
|
<main className="min-w-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
2026-05-29 03:42:53 +08:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
2026-06-15 18:11:22 +08:00
|
|
|
"mx-auto w-full px-4 py-6 sm:px-8 sm:py-8 lg:py-12",
|
2026-06-15 22:52:26 +08:00
|
|
|
"max-w-[920px]",
|
2026-05-29 03:42:53 +08:00
|
|
|
hostChromeInset && "pt-[4.25rem] sm:pt-[4.25rem] lg:pt-[4.75rem]",
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-05-19 22:42:38 +08:00
|
|
|
<div className="mb-7">
|
2026-06-06 19:49:33 +08:00
|
|
|
{!showSidebar ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onBackToChat}
|
|
|
|
|
className="mb-4 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:hidden"
|
|
|
|
|
>
|
|
|
|
|
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
{t("settings.backToChat")}
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
2026-06-15 18:08:44 +08:00
|
|
|
{showSidebar ? (
|
2026-06-15 17:44:26 +08:00
|
|
|
<p className="mb-2 text-[12px] font-normal text-muted-foreground">
|
|
|
|
|
{t("settings.sidebar.title")}
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
2026-06-06 19:49:33 +08:00
|
|
|
<h1 className="text-[24px] font-normal leading-tight tracking-normal text-foreground sm:text-[28px]">
|
2026-05-19 22:42:38 +08:00
|
|
|
{text(`settings.nav.${activeSection}`, titleForSection(activeSection))}
|
2026-05-08 15:31:52 +00:00
|
|
|
</h1>
|
2026-04-25 18:05:06 +00:00
|
|
|
</div>
|
2026-05-08 15:31:52 +00:00
|
|
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
<div className="flex h-48 items-center justify-center rounded-[24px] border border-border/50 bg-card/75 text-sm text-muted-foreground shadow-[0_20px_70px_rgba(15,23,42,0.07)]">
|
|
|
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
{t("settings.status.loading")}
|
|
|
|
|
</div>
|
|
|
|
|
) : error && !settings ? (
|
|
|
|
|
<SettingsGroup>
|
|
|
|
|
<SettingsRow title={t("settings.status.loadError")}>
|
|
|
|
|
<span className="max-w-[520px] text-sm text-muted-foreground">{error}</span>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
</SettingsGroup>
|
|
|
|
|
) : settings ? (
|
|
|
|
|
<div className="space-y-5">
|
|
|
|
|
{error ? (
|
|
|
|
|
<div className="rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
|
|
|
|
|
{error}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-05-19 22:42:38 +08:00
|
|
|
{renderSection()}
|
2026-05-08 15:31:52 +00:00
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
2026-04-25 18:05:06 +00:00
|
|
|
</main>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
const SETTINGS_NAV_ITEMS: Array<{ key: SettingsSectionKey; icon: LucideIcon; fallback: string }> = [
|
|
|
|
|
{ key: "overview", icon: Activity, fallback: "Overview" },
|
|
|
|
|
{ key: "appearance", icon: Palette, fallback: "Appearance" },
|
|
|
|
|
{ key: "models", icon: SlidersHorizontal, fallback: "Models" },
|
|
|
|
|
{ key: "image", icon: ImageIcon, fallback: "Image" },
|
2026-06-09 01:08:49 +08:00
|
|
|
{ key: "voice", icon: Mic, fallback: "Voice" },
|
2026-05-29 03:42:53 +08:00
|
|
|
{ key: "browser", icon: Globe2, fallback: "Web" },
|
|
|
|
|
{ key: "runtime", icon: Server, fallback: "System" },
|
|
|
|
|
{ key: "advanced", icon: ShieldCheck, fallback: "Security" },
|
2026-05-19 22:42:38 +08:00
|
|
|
];
|
|
|
|
|
|
2026-05-29 03:42:53 +08:00
|
|
|
function visibleWebuiDefaultAccessMode(mode: string | null | undefined): WebuiDefaultAccessMode {
|
|
|
|
|
return mode === "full" ? "full" : "default";
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
function titleForSection(section: SettingsSectionKey): string {
|
|
|
|
|
return SETTINGS_NAV_ITEMS.find((item) => item.key === section)?.fallback ?? "Settings";
|
|
|
|
|
}
|
2026-05-08 15:31:52 +00:00
|
|
|
|
|
|
|
|
function SettingsSidebar({
|
|
|
|
|
activeSection,
|
|
|
|
|
onSelectSection,
|
|
|
|
|
onBackToChat,
|
|
|
|
|
onLogout,
|
2026-05-29 03:42:53 +08:00
|
|
|
hostChromeInset,
|
2026-05-08 15:31:52 +00:00
|
|
|
}: {
|
|
|
|
|
activeSection: SettingsSectionKey;
|
|
|
|
|
onSelectSection: (section: SettingsSectionKey) => void;
|
|
|
|
|
onBackToChat: () => void;
|
|
|
|
|
onLogout?: () => void;
|
2026-05-29 03:42:53 +08:00
|
|
|
hostChromeInset?: boolean;
|
2026-05-08 15:31:52 +00:00
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
return (
|
2026-05-29 03:42:53 +08:00
|
|
|
<aside
|
|
|
|
|
className={cn(
|
2026-06-14 16:46:34 +08:00
|
|
|
"flex w-full shrink-0 flex-col border-b border-border/55 bg-card/62 px-3 pb-2 shadow-[inset_0_-1px_0_rgba(255,255,255,0.55)] backdrop-blur-xl dark:bg-card/45 dark:shadow-none md:w-[17rem] md:border-b-0 md:border-r md:px-3 md:pb-4 md:shadow-[inset_-1px_0_0_rgba(255,255,255,0.55)]",
|
2026-05-29 03:42:53 +08:00
|
|
|
hostChromeInset ? "pt-[4.25rem] md:pt-[4.25rem]" : "pt-4 md:pt-4",
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-05-08 15:31:52 +00:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onBackToChat}
|
2026-05-19 22:42:38 +08:00
|
|
|
className="mb-2 inline-flex w-fit items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground md:mb-3"
|
2026-05-08 15:31:52 +00:00
|
|
|
>
|
|
|
|
|
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
{t("settings.backToChat")}
|
|
|
|
|
</button>
|
2026-05-19 22:42:38 +08:00
|
|
|
<div className="mb-3 px-1 md:mb-4 md:px-2">
|
2026-06-06 19:49:33 +08:00
|
|
|
<h2 className="text-[18px] font-normal tracking-normal text-foreground">
|
2026-05-08 15:31:52 +00:00
|
|
|
{t("settings.sidebar.title")}
|
|
|
|
|
</h2>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
<nav
|
|
|
|
|
aria-label={t("settings.sidebar.ariaLabel")}
|
|
|
|
|
className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden md:mx-0 md:block md:space-y-1 md:overflow-visible md:px-0 md:pb-0"
|
|
|
|
|
>
|
|
|
|
|
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
|
2026-05-08 15:31:52 +00:00
|
|
|
const active = key === activeSection;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={key}
|
|
|
|
|
type="button"
|
|
|
|
|
aria-current={active ? "page" : undefined}
|
|
|
|
|
onClick={() => onSelectSection(key)}
|
|
|
|
|
className={cn(
|
2026-05-19 22:42:38 +08:00
|
|
|
"flex h-9 w-auto shrink-0 items-center gap-2 rounded-full px-3 text-left text-[13px] font-medium transition-colors md:w-full md:rounded-[10px] md:px-2.5",
|
2026-05-08 15:31:52 +00:00
|
|
|
active
|
|
|
|
|
? "bg-muted/90 text-foreground shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)]"
|
|
|
|
|
: "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<Icon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
|
2026-05-19 22:42:38 +08:00
|
|
|
<span className="truncate">{t(`settings.nav.${key}`, { defaultValue: fallback })}</span>
|
2026-05-08 15:31:52 +00:00
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</nav>
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
<div className="hidden md:mt-auto md:block md:pt-4">
|
2026-05-29 03:42:53 +08:00
|
|
|
{onLogout && !hostChromeInset ? (
|
2026-05-08 15:31:52 +00:00
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={onLogout}
|
|
|
|
|
className="h-9 w-full justify-start gap-2 rounded-[10px] px-2.5 text-[13px] font-medium text-muted-foreground hover:bg-destructive/8 hover:text-destructive"
|
|
|
|
|
>
|
|
|
|
|
<LogOut className="h-4 w-4" aria-hidden />
|
|
|
|
|
{t("app.account.logout")}
|
|
|
|
|
</Button>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</aside>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
function OverviewSettings({
|
2026-04-25 18:05:06 +00:00
|
|
|
settings,
|
2026-05-19 22:42:38 +08:00
|
|
|
requiresRestart,
|
|
|
|
|
onSelectSection,
|
2026-05-24 13:38:37 +08:00
|
|
|
showBrandLogos,
|
2026-05-19 22:42:38 +08:00
|
|
|
}: {
|
|
|
|
|
settings: SettingsPayload;
|
|
|
|
|
requiresRestart: boolean;
|
|
|
|
|
onSelectSection: (section: SettingsSectionKey) => void;
|
2026-05-24 13:38:37 +08:00
|
|
|
showBrandLogos: boolean;
|
2026-05-19 22:42:38 +08:00
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
|
|
|
const activePreset = settings.agent.model_preset || "default";
|
|
|
|
|
const activeProvider = settings.agent.resolved_provider ?? settings.agent.provider;
|
2026-06-06 19:49:33 +08:00
|
|
|
const activeProviderConfigured = settingsProviderConfigured(settings, activeProvider);
|
|
|
|
|
const activeProviderLabel = providerDisplayLabel(settings.providers, activeProvider);
|
|
|
|
|
const activeModelValue = activeProviderConfigured
|
|
|
|
|
? settings.agent.model
|
|
|
|
|
: tx("settings.values.notConfigured", "Not configured");
|
|
|
|
|
const activeModelCaption = activeProviderConfigured
|
|
|
|
|
? `${activeProvider} · ${activePreset}`
|
|
|
|
|
: activeProviderLabel || settings.agent.model
|
|
|
|
|
? [activeProviderLabel, settings.agent.model].filter(Boolean).join(" · ")
|
|
|
|
|
: tx("settings.byok.noConfiguredProviders", "No configured providers");
|
2026-05-19 22:42:38 +08:00
|
|
|
const webStatus = settings.web.enable
|
|
|
|
|
? tx("settings.values.enabled", "Enabled")
|
|
|
|
|
: tx("settings.values.disabled", "Disabled");
|
2026-06-09 01:08:49 +08:00
|
|
|
const webSearchProvider =
|
|
|
|
|
settings.web_search.providers.find((provider) => provider.name === settings.web_search.provider) ??
|
|
|
|
|
settings.web_search.providers[0];
|
|
|
|
|
const webSearchProviderLabel = providerDisplayLabel(
|
|
|
|
|
settings.web_search.providers,
|
|
|
|
|
settings.web_search.provider,
|
|
|
|
|
);
|
|
|
|
|
const webSearchCredentialStatus =
|
|
|
|
|
webSearchProvider?.credential === "none"
|
|
|
|
|
? tx("settings.byok.webSearch.noCredentialRequired", "No key required")
|
|
|
|
|
: webSearchProvider?.credential === "base_url"
|
|
|
|
|
? settings.web_search.base_url
|
|
|
|
|
? tx("settings.values.configured", "Configured")
|
|
|
|
|
: tx("settings.values.notConfigured", "Not configured")
|
|
|
|
|
: settings.web_search.api_key_hint
|
|
|
|
|
? tx("settings.values.configured", "Configured")
|
|
|
|
|
: tx("settings.values.notConfigured", "Not configured");
|
|
|
|
|
const webCaption = `${webSearchProviderLabel} · ${webSearchCredentialStatus}`;
|
2026-05-19 22:42:38 +08:00
|
|
|
const imageStatus = settings.image_generation.enabled
|
|
|
|
|
? tx("settings.values.enabled", "Enabled")
|
|
|
|
|
: tx("settings.values.disabled", "Disabled");
|
2026-05-24 13:38:37 +08:00
|
|
|
const imageCaption = `${providerDisplayLabel(settings.image_generation.providers, settings.image_generation.provider)} · ${
|
2026-05-19 22:42:38 +08:00
|
|
|
settings.image_generation.provider_configured
|
|
|
|
|
? tx("settings.values.configured", "Configured")
|
|
|
|
|
: tx("settings.values.notConfigured", "Not configured")
|
|
|
|
|
}`;
|
2026-06-09 01:08:49 +08:00
|
|
|
const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
|
|
|
|
|
const voiceStatus = transcription.enabled
|
|
|
|
|
? tx("settings.values.enabled", "Enabled")
|
|
|
|
|
: tx("settings.values.disabled", "Disabled");
|
|
|
|
|
const voiceCaption = `${providerDisplayLabel(transcription.providers, transcription.provider)} · ${
|
|
|
|
|
transcription.provider_configured
|
|
|
|
|
? tx("settings.values.configured", "Configured")
|
|
|
|
|
: tx("settings.values.notConfigured", "Not configured")
|
|
|
|
|
}`;
|
2026-06-06 19:49:33 +08:00
|
|
|
const isNativeHost = (settings.surface ?? settings.runtime_surface) === "native";
|
|
|
|
|
const workspaceCaption = shortWorkspacePath(settings.runtime.workspace_path);
|
|
|
|
|
const runtimeTitle = isNativeHost
|
|
|
|
|
? tx("settings.rows.engine", "Engine")
|
|
|
|
|
: tx("settings.rows.gateway", "Gateway");
|
|
|
|
|
const runtimeValue = isNativeHost
|
|
|
|
|
? tx("settings.values.privateEngine", "Private engine")
|
|
|
|
|
: `${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`;
|
|
|
|
|
const runtimeCaption = isNativeHost
|
|
|
|
|
? tx("settings.values.unixSocket", "Unix socket")
|
|
|
|
|
: requiresRestart
|
|
|
|
|
? tx("settings.values.restartPending", "Restart pending")
|
|
|
|
|
: tx("settings.values.ready", "Ready");
|
2026-05-19 22:42:38 +08:00
|
|
|
return (
|
|
|
|
|
<div className="space-y-7">
|
|
|
|
|
<section>
|
2026-06-08 14:29:31 +08:00
|
|
|
<TokenUsageHeatmap usage={settings.usage} timeZone={settings.agent.timezone} />
|
2026-05-19 22:42:38 +08:00
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section>
|
|
|
|
|
<SettingsSectionTitle>{tx("settings.sections.ai", "AI")}</SettingsSectionTitle>
|
|
|
|
|
<SettingsGroup>
|
|
|
|
|
<OverviewListRow
|
|
|
|
|
icon={Bot}
|
2026-05-24 13:38:37 +08:00
|
|
|
valueLogoProvider={activeProvider}
|
2026-05-19 22:42:38 +08:00
|
|
|
title={tx("settings.overview.model", "Current model")}
|
2026-06-06 19:49:33 +08:00
|
|
|
value={activeModelValue}
|
|
|
|
|
caption={activeModelCaption}
|
2026-05-24 13:38:37 +08:00
|
|
|
showBrandLogos={showBrandLogos}
|
2026-05-19 22:42:38 +08:00
|
|
|
onClick={() => onSelectSection("models")}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsGroup>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section>
|
|
|
|
|
<SettingsSectionTitle>{tx("settings.sections.capabilities", "Capabilities")}</SettingsSectionTitle>
|
|
|
|
|
<SettingsGroup>
|
|
|
|
|
<OverviewListRow
|
|
|
|
|
icon={Globe2}
|
2026-05-24 13:38:37 +08:00
|
|
|
valueLogoProvider={settings.web_search.provider}
|
2026-05-19 22:42:38 +08:00
|
|
|
title={tx("settings.overview.webSearch", "Web search")}
|
2026-06-09 01:08:49 +08:00
|
|
|
value={webStatus}
|
|
|
|
|
caption={webCaption}
|
2026-05-24 13:38:37 +08:00
|
|
|
showBrandLogos={showBrandLogos}
|
2026-05-29 03:42:53 +08:00
|
|
|
onClick={() => onSelectSection("browser")}
|
2026-05-19 22:42:38 +08:00
|
|
|
/>
|
|
|
|
|
<OverviewListRow
|
|
|
|
|
icon={ImageIcon}
|
2026-05-24 13:38:37 +08:00
|
|
|
valueLogoProvider={settings.image_generation.provider}
|
2026-05-19 22:42:38 +08:00
|
|
|
title={tx("settings.overview.imageGeneration", "Image generation")}
|
|
|
|
|
value={imageStatus}
|
|
|
|
|
caption={imageCaption}
|
2026-05-24 13:38:37 +08:00
|
|
|
showBrandLogos={showBrandLogos}
|
2026-05-19 22:42:38 +08:00
|
|
|
onClick={() => onSelectSection("image")}
|
|
|
|
|
/>
|
2026-06-09 01:08:49 +08:00
|
|
|
<OverviewListRow
|
|
|
|
|
icon={Mic}
|
|
|
|
|
valueLogoProvider={transcription.provider}
|
|
|
|
|
title={tx("settings.overview.voiceInput", "Voice input")}
|
|
|
|
|
value={voiceStatus}
|
|
|
|
|
caption={voiceCaption}
|
|
|
|
|
showBrandLogos={showBrandLogos}
|
|
|
|
|
onClick={() => onSelectSection("voice")}
|
|
|
|
|
/>
|
2026-05-19 22:42:38 +08:00
|
|
|
</SettingsGroup>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section>
|
|
|
|
|
<SettingsSectionTitle>{tx("settings.sections.system", "System")}</SettingsSectionTitle>
|
|
|
|
|
<SettingsGroup>
|
|
|
|
|
<OverviewListRow
|
|
|
|
|
icon={Server}
|
2026-06-06 19:49:33 +08:00
|
|
|
title={runtimeTitle}
|
|
|
|
|
value={runtimeValue}
|
|
|
|
|
caption={runtimeCaption}
|
2026-05-19 22:42:38 +08:00
|
|
|
onClick={() => onSelectSection("runtime")}
|
|
|
|
|
/>
|
|
|
|
|
<OverviewListRow
|
|
|
|
|
icon={HardDrive}
|
|
|
|
|
title={tx("settings.overview.workspace", "Workspace")}
|
2026-06-06 19:49:33 +08:00
|
|
|
value={tx("settings.values.defaultWorkspace", "Default workspace")}
|
|
|
|
|
caption={workspaceCaption}
|
2026-05-19 22:42:38 +08:00
|
|
|
onClick={() => onSelectSection("runtime")}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsGroup>
|
|
|
|
|
</section>
|
2026-06-09 22:31:14 +08:00
|
|
|
|
|
|
|
|
<section>
|
|
|
|
|
<SettingsSectionTitle>{tx("settings.sections.about", "About")}</SettingsSectionTitle>
|
|
|
|
|
<SettingsGroup>
|
|
|
|
|
<VersionCheckRow currentVersion={settings.version?.current} />
|
|
|
|
|
</SettingsGroup>
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function VersionCheckRow({ currentVersion }: { currentVersion?: string }) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
|
|
|
const { token } = useClient();
|
|
|
|
|
const [checking, setChecking] = useState(false);
|
|
|
|
|
const [result, setResult] = useState<
|
|
|
|
|
| { type: "up-to-date" }
|
|
|
|
|
| { type: "update"; latestVersion: string; pypiUrl?: string }
|
|
|
|
|
| { type: "error"; message: string }
|
|
|
|
|
| null
|
|
|
|
|
>(null);
|
|
|
|
|
|
|
|
|
|
const handleCheck = async () => {
|
|
|
|
|
setChecking(true);
|
|
|
|
|
setResult(null);
|
|
|
|
|
try {
|
|
|
|
|
const res = await checkVersion(token);
|
|
|
|
|
if (res.updateAvailable) {
|
|
|
|
|
setResult({
|
|
|
|
|
type: "update",
|
|
|
|
|
latestVersion: res.updateAvailable.latestVersion,
|
|
|
|
|
pypiUrl: res.updateAvailable.pypiUrl,
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
setResult({ type: "up-to-date" });
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setResult({ type: "error", message: (err as Error).message });
|
|
|
|
|
} finally {
|
|
|
|
|
setChecking(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex min-h-[62px] flex-col gap-3 px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<div className="text-[14px] font-medium leading-5 text-foreground">
|
|
|
|
|
{tx("settings.about.version", "Version")}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
|
|
|
|
|
{currentVersion ? `v${currentVersion}` : "nanobot"}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex shrink-0 flex-col items-end gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => void handleCheck()}
|
|
|
|
|
disabled={checking}
|
|
|
|
|
className="rounded-full"
|
|
|
|
|
>
|
|
|
|
|
{checking ? (
|
|
|
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
|
|
|
|
) : (
|
|
|
|
|
<ArrowUpCircle className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
)}
|
|
|
|
|
{checking
|
|
|
|
|
? tx("settings.about.checking", "Checking...")
|
|
|
|
|
: tx("settings.about.checkForUpdates", "Check for updates")}
|
|
|
|
|
</Button>
|
|
|
|
|
{result?.type === "up-to-date" ? (
|
|
|
|
|
<span className="inline-flex items-center gap-1.5 text-[12px] text-emerald-600 dark:text-emerald-300">
|
|
|
|
|
<Check className="h-3 w-3" aria-hidden />
|
|
|
|
|
{tx("settings.about.upToDate", "You're up to date")}
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
{result?.type === "update" ? (
|
|
|
|
|
<span className="inline-flex items-center gap-1.5 text-[12px] text-blue-600 dark:text-blue-300">
|
|
|
|
|
<ArrowUpCircle className="h-3 w-3" aria-hidden />
|
2026-06-14 00:19:57 +08:00
|
|
|
{t("settings.about.updateAvailable", {
|
|
|
|
|
defaultValue: "Update available v{{version}}",
|
|
|
|
|
version: result.latestVersion,
|
|
|
|
|
})}
|
2026-06-09 22:31:14 +08:00
|
|
|
{result.pypiUrl ? (
|
|
|
|
|
<a
|
|
|
|
|
href={result.pypiUrl}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
className="inline-flex items-center gap-0.5 underline-offset-2 hover:underline"
|
|
|
|
|
>
|
|
|
|
|
PyPI
|
|
|
|
|
<ExternalLink className="h-2.5 w-2.5" aria-hidden />
|
|
|
|
|
</a>
|
|
|
|
|
) : null}
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
{result?.type === "error" ? (
|
|
|
|
|
<span className="text-[12px] text-destructive">{result.message}</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
2026-05-19 22:42:38 +08:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function AppearanceSettings({
|
|
|
|
|
theme,
|
|
|
|
|
onToggleTheme,
|
|
|
|
|
localPrefs,
|
|
|
|
|
onChangeLocalPrefs,
|
2026-04-25 18:05:06 +00:00
|
|
|
}: {
|
2026-05-08 15:31:52 +00:00
|
|
|
theme: "light" | "dark";
|
|
|
|
|
onToggleTheme: () => void;
|
2026-05-19 22:42:38 +08:00
|
|
|
localPrefs: LocalPreferences;
|
|
|
|
|
onChangeLocalPrefs: Dispatch<SetStateAction<LocalPreferences>>;
|
2026-04-25 18:05:06 +00:00
|
|
|
}) {
|
2026-05-06 23:15:18 +08:00
|
|
|
const { t } = useTranslation();
|
2026-05-19 22:42:38 +08:00
|
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
2026-04-25 18:05:06 +00:00
|
|
|
return (
|
2026-05-19 22:42:38 +08:00
|
|
|
<div className="space-y-7">
|
2026-04-25 18:05:06 +00:00
|
|
|
<section>
|
2026-05-08 15:31:52 +00:00
|
|
|
<SettingsSectionTitle>{t("settings.sections.interface")}</SettingsSectionTitle>
|
2026-04-25 18:05:06 +00:00
|
|
|
<SettingsGroup>
|
2026-05-08 15:31:52 +00:00
|
|
|
<SettingsRow
|
|
|
|
|
title={t("settings.rows.theme")}
|
|
|
|
|
description={t("settings.help.theme")}
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onToggleTheme}
|
|
|
|
|
className="inline-flex h-8 items-center rounded-full bg-muted p-0.5 text-[12px] font-medium text-muted-foreground"
|
2026-04-25 18:05:06 +00:00
|
|
|
>
|
2026-05-08 15:31:52 +00:00
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
"rounded-full px-3 py-1 transition-colors",
|
|
|
|
|
theme === "light" && "bg-background text-foreground shadow-sm",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{t("settings.values.light")}
|
|
|
|
|
</span>
|
|
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
"rounded-full px-3 py-1 transition-colors",
|
|
|
|
|
theme === "dark" && "bg-background text-foreground shadow-sm",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{t("settings.values.dark")}
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
2026-04-25 18:05:06 +00:00
|
|
|
</SettingsRow>
|
|
|
|
|
|
2026-05-08 15:31:52 +00:00
|
|
|
<SettingsRow
|
|
|
|
|
title={t("settings.rows.language")}
|
|
|
|
|
description={t("settings.help.language")}
|
|
|
|
|
>
|
|
|
|
|
<LanguageSwitcher />
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
</SettingsGroup>
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
<section>
|
|
|
|
|
<SettingsSectionTitle>{tx("settings.sections.localPreferences", "Local preferences")}</SettingsSectionTitle>
|
|
|
|
|
<SettingsGroup>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.density", "Density")}
|
|
|
|
|
description={tx("settings.help.density", "Stored only in this browser.")}
|
|
|
|
|
>
|
|
|
|
|
<SegmentedControl
|
|
|
|
|
value={localPrefs.density}
|
|
|
|
|
options={[
|
|
|
|
|
{ value: "comfortable", label: tx("settings.values.comfortable", "Comfortable") },
|
|
|
|
|
{ value: "compact", label: tx("settings.values.compact", "Compact") },
|
|
|
|
|
]}
|
|
|
|
|
onChange={(density) =>
|
|
|
|
|
onChangeLocalPrefs((prev) => ({ ...prev, density: density as LocalDensity }))
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.activityMode", "Activity detail")}
|
|
|
|
|
description={tx("settings.help.activityMode", "Choose how much agent activity chrome to show by default.")}
|
|
|
|
|
>
|
|
|
|
|
<SegmentedControl
|
|
|
|
|
value={localPrefs.activityMode}
|
|
|
|
|
options={[
|
|
|
|
|
{ value: "auto", label: tx("settings.values.auto", "Auto") },
|
|
|
|
|
{ value: "expanded", label: tx("settings.values.expanded", "Expanded") },
|
|
|
|
|
]}
|
|
|
|
|
onChange={(activityMode) =>
|
|
|
|
|
onChangeLocalPrefs((prev) => ({ ...prev, activityMode: activityMode as LocalActivityMode }))
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.codeWrap", "Code wrapping")}
|
|
|
|
|
description={tx("settings.help.codeWrap", "Keep long code lines readable on smaller screens.")}
|
|
|
|
|
>
|
|
|
|
|
<ToggleButton
|
|
|
|
|
checked={localPrefs.codeWrap}
|
|
|
|
|
onChange={(codeWrap) => onChangeLocalPrefs((prev) => ({ ...prev, codeWrap }))}
|
2026-05-24 13:38:37 +08:00
|
|
|
ariaLabel={tx("settings.rows.codeWrap", "Code wrapping")}
|
2026-05-19 22:42:38 +08:00
|
|
|
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
2026-05-22 22:25:12 +08:00
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.brandLogos", "Brand logos")}
|
|
|
|
|
description={tx("settings.help.brandLogos", "Show third-party provider and CLI logos in Settings.")}
|
|
|
|
|
>
|
|
|
|
|
<ToggleButton
|
|
|
|
|
checked={localPrefs.brandLogos}
|
|
|
|
|
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
|
2026-05-24 13:38:37 +08:00
|
|
|
ariaLabel={tx("settings.rows.brandLogos", "Brand logos")}
|
2026-05-22 22:25:12 +08:00
|
|
|
label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
2026-05-19 22:42:38 +08:00
|
|
|
</SettingsGroup>
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 13:38:37 +08:00
|
|
|
function NewModelConfigurationDialog({
|
|
|
|
|
open,
|
|
|
|
|
draft,
|
|
|
|
|
providers,
|
|
|
|
|
saving,
|
|
|
|
|
showProviderLogos,
|
|
|
|
|
onOpenChange,
|
|
|
|
|
onChangeDraft,
|
|
|
|
|
onSave,
|
|
|
|
|
}: {
|
|
|
|
|
open: boolean;
|
|
|
|
|
draft: ModelConfigurationDraft;
|
|
|
|
|
providers: Array<{ name: string; label: string }>;
|
|
|
|
|
saving: boolean;
|
|
|
|
|
showProviderLogos: boolean;
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
onChangeDraft: Dispatch<SetStateAction<ModelConfigurationDraft>>;
|
|
|
|
|
onSave: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
|
|
|
const canSave = Boolean(draft.label.trim() && draft.provider.trim() && draft.model.trim());
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
<DialogContent className="max-w-[520px] rounded-[28px] border-border/55 bg-card/95 p-0 shadow-[0_28px_90px_rgba(15,23,42,0.20)] backdrop-blur-xl dark:border-white/10">
|
|
|
|
|
<form
|
|
|
|
|
onSubmit={(event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
onSave();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<DialogHeader className="border-b border-border/45 px-5 py-4 text-left">
|
|
|
|
|
<DialogTitle className="text-[18px] font-semibold tracking-[-0.01em]">
|
|
|
|
|
{tx("settings.models.newConfiguration", "New model configuration")}
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
<DialogDescription className="text-[12.5px] leading-5">
|
|
|
|
|
{tx("settings.models.newConfigurationHelp", "Save a provider and model as a one-click option.")}
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4 px-5 py-5">
|
|
|
|
|
<label className="block">
|
|
|
|
|
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
2026-05-30 23:45:26 +08:00
|
|
|
{tx("settings.models.configurationName", "Configuration name")}
|
2026-05-24 13:38:37 +08:00
|
|
|
</span>
|
|
|
|
|
<Input
|
|
|
|
|
autoFocus
|
|
|
|
|
value={draft.label}
|
|
|
|
|
placeholder={tx("settings.models.configurationNamePlaceholder", "Fast writing")}
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
onChangeDraft((prev) => ({ ...prev, label: event.target.value }))
|
|
|
|
|
}
|
|
|
|
|
className="h-10 rounded-full px-4 text-[14px]"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
|
|
|
|
|
<label className="block">
|
|
|
|
|
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.rows.model", "Model")}
|
|
|
|
|
</span>
|
|
|
|
|
<Input
|
|
|
|
|
value={draft.model}
|
|
|
|
|
placeholder="openai/gpt-4.1"
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
onChangeDraft((prev) => ({ ...prev, model: event.target.value }))
|
|
|
|
|
}
|
|
|
|
|
className="h-10 rounded-full px-4 text-[14px]"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<div className="block">
|
|
|
|
|
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.rows.provider", "Provider")}
|
|
|
|
|
</span>
|
|
|
|
|
<ProviderPicker
|
|
|
|
|
providers={providers}
|
|
|
|
|
value={draft.provider}
|
|
|
|
|
emptyLabel={tx("settings.byok.noConfiguredProviders", "No configured providers")}
|
|
|
|
|
showProviderLogos={showProviderLogos}
|
|
|
|
|
onChange={(provider) =>
|
|
|
|
|
onChangeDraft((prev) => ({ ...prev, provider }))
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<DialogFooter className="border-t border-border/45 px-5 py-4 sm:space-x-2">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
className="rounded-full"
|
|
|
|
|
disabled={saving}
|
|
|
|
|
onClick={() => onOpenChange(false)}
|
|
|
|
|
>
|
|
|
|
|
{tx("settings.actions.cancel", "Cancel")}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="rounded-full"
|
|
|
|
|
disabled={!canSave || saving || providers.length === 0}
|
|
|
|
|
>
|
|
|
|
|
{saving ? (
|
|
|
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
|
|
|
|
) : null}
|
|
|
|
|
{saving ? tx("settings.actions.saving", "Saving...") : tx("settings.actions.save", "Save")}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</form>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
function ModelsSettings({
|
2026-05-30 23:45:26 +08:00
|
|
|
token,
|
2026-05-19 22:42:38 +08:00
|
|
|
form,
|
|
|
|
|
setForm,
|
|
|
|
|
settings,
|
|
|
|
|
dirty,
|
|
|
|
|
saving,
|
2026-05-24 13:38:37 +08:00
|
|
|
showBrandLogos,
|
2026-05-29 03:42:53 +08:00
|
|
|
providerSaving,
|
|
|
|
|
onProviderOAuthLogin,
|
2026-05-19 22:42:38 +08:00
|
|
|
onSave,
|
2026-05-24 13:38:37 +08:00
|
|
|
onCreateConfiguration,
|
2026-05-19 22:42:38 +08:00
|
|
|
}: {
|
2026-05-30 23:45:26 +08:00
|
|
|
token: string;
|
2026-05-19 22:42:38 +08:00
|
|
|
form: AgentSettingsDraft;
|
|
|
|
|
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
|
|
|
|
|
settings: SettingsPayload;
|
|
|
|
|
dirty: boolean;
|
|
|
|
|
saving: boolean;
|
2026-05-24 13:38:37 +08:00
|
|
|
showBrandLogos: boolean;
|
2026-05-29 03:42:53 +08:00
|
|
|
providerSaving: string | null;
|
|
|
|
|
onProviderOAuthLogin: (provider: string) => void;
|
2026-05-19 22:42:38 +08:00
|
|
|
onSave: () => void;
|
2026-05-24 13:38:37 +08:00
|
|
|
onCreateConfiguration: () => void;
|
2026-05-19 22:42:38 +08:00
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
|
|
|
const configuredProviders = settings.providers.filter((provider) => provider.configured);
|
|
|
|
|
const showAutoProvider = defaultPreset(settings)?.provider === "auto" || form.provider === "auto";
|
2026-06-06 19:49:33 +08:00
|
|
|
const selectableProviders = uniqueProviders(configuredProviders);
|
2026-05-19 22:42:38 +08:00
|
|
|
const providerOptions = showAutoProvider
|
2026-05-29 03:42:53 +08:00
|
|
|
? [{ name: "auto", label: tx("settings.values.auto", "Auto") }, ...selectableProviders]
|
|
|
|
|
: selectableProviders;
|
2026-05-19 22:42:38 +08:00
|
|
|
const providerValue = providerOptions.some((provider) => provider.name === form.provider)
|
|
|
|
|
? form.provider
|
|
|
|
|
: "";
|
2026-05-29 03:42:53 +08:00
|
|
|
const selectedPreset =
|
|
|
|
|
settings.model_presets.find((preset) => preset.name === form.modelPreset) ?? null;
|
|
|
|
|
const selectedProvider = settings.providers.find((provider) => provider.name === form.provider);
|
|
|
|
|
const selectedProviderNeedsSignIn =
|
|
|
|
|
selectedProvider?.auth_type === "oauth" && !selectedProvider.configured;
|
|
|
|
|
const selectedProviderSigningIn = providerSaving === selectedProvider?.name;
|
2026-06-06 19:49:33 +08:00
|
|
|
const selectedProviderConfigured = settingsProviderConfigured(settings, form.provider);
|
2026-05-29 03:42:53 +08:00
|
|
|
const modelFieldsMissing =
|
|
|
|
|
!form.model.trim() ||
|
|
|
|
|
!form.provider.trim() ||
|
|
|
|
|
Boolean(selectedPreset && !selectedPreset.is_default && !form.presetLabel.trim());
|
2026-05-19 22:42:38 +08:00
|
|
|
return (
|
|
|
|
|
<div className="space-y-7">
|
|
|
|
|
<section>
|
2026-05-08 15:31:52 +00:00
|
|
|
<SettingsGroup>
|
|
|
|
|
<SettingsRow
|
2026-05-30 23:45:26 +08:00
|
|
|
title={tx("settings.rows.currentModel", "Current configuration")}
|
|
|
|
|
description={tx("settings.help.currentModel", "Used for new replies.")}
|
2026-05-08 15:31:52 +00:00
|
|
|
>
|
2026-05-24 13:38:37 +08:00
|
|
|
<ModelPresetPicker
|
|
|
|
|
presets={settings.model_presets}
|
|
|
|
|
value={form.modelPreset}
|
|
|
|
|
settings={settings}
|
|
|
|
|
draftModel={form.model}
|
|
|
|
|
draftProvider={form.provider}
|
2026-06-06 19:49:33 +08:00
|
|
|
providerConfigured={selectedProviderConfigured}
|
2026-05-24 13:38:37 +08:00
|
|
|
showProviderLogos={showBrandLogos}
|
2026-05-29 03:42:53 +08:00
|
|
|
onChange={(modelPreset) => {
|
|
|
|
|
const nextPreset = settings.model_presets.find((preset) => preset.name === modelPreset);
|
|
|
|
|
setForm((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
modelPreset,
|
|
|
|
|
model: nextPreset?.model ?? prev.model,
|
|
|
|
|
provider: nextPreset?.is_default
|
|
|
|
|
? editableDefaultProvider(settings)
|
|
|
|
|
: nextPreset?.provider ?? prev.provider,
|
|
|
|
|
presetLabel: nextPreset?.label ?? modelPreset,
|
2026-05-29 04:23:30 +08:00
|
|
|
contextWindowTokens: normalizeContextWindowTokens(
|
|
|
|
|
nextPreset?.context_window_tokens ?? prev.contextWindowTokens,
|
|
|
|
|
),
|
2026-05-29 03:42:53 +08:00
|
|
|
}));
|
|
|
|
|
}}
|
2026-05-24 13:38:37 +08:00
|
|
|
onCreateConfiguration={onCreateConfiguration}
|
|
|
|
|
/>
|
2026-05-08 15:31:52 +00:00
|
|
|
</SettingsRow>
|
2026-05-29 03:42:53 +08:00
|
|
|
{selectedPreset && !selectedPreset.is_default ? (
|
|
|
|
|
<SettingsRow
|
2026-05-30 23:45:26 +08:00
|
|
|
title={tx("settings.models.configurationName", "Configuration name")}
|
2026-05-29 03:42:53 +08:00
|
|
|
description={tx("settings.models.configurationNameHelp", "Rename this saved model configuration.")}
|
|
|
|
|
>
|
|
|
|
|
<Input
|
|
|
|
|
value={form.presetLabel}
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
setForm((prev) => ({ ...prev, presetLabel: event.target.value }))
|
|
|
|
|
}
|
|
|
|
|
className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
) : null}
|
2026-05-29 04:23:30 +08:00
|
|
|
<SettingsRow
|
|
|
|
|
title={t("settings.rows.provider")}
|
|
|
|
|
description={t("settings.help.provider")}
|
|
|
|
|
>
|
|
|
|
|
<ProviderPicker
|
|
|
|
|
providers={providerOptions}
|
|
|
|
|
value={providerValue}
|
|
|
|
|
emptyLabel={t("settings.byok.noConfiguredProviders")}
|
|
|
|
|
showProviderLogos={showBrandLogos}
|
2026-05-30 23:45:26 +08:00
|
|
|
onChange={(provider) =>
|
|
|
|
|
setForm((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
provider,
|
|
|
|
|
model: provider === prev.provider ? prev.model : "",
|
|
|
|
|
}))
|
|
|
|
|
}
|
2026-05-29 04:23:30 +08:00
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
{selectedProviderNeedsSignIn ? (
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.oauth.signInRequired", "Sign in required")}
|
|
|
|
|
description={tx(
|
|
|
|
|
"settings.oauth.signInBeforeSaving",
|
|
|
|
|
"Sign in before saving this OAuth provider as the active model provider.",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => selectedProvider && onProviderOAuthLogin(selectedProvider.name)}
|
|
|
|
|
disabled={!selectedProvider?.oauth_login_supported || selectedProviderSigningIn}
|
|
|
|
|
className="rounded-full"
|
2026-05-19 22:42:38 +08:00
|
|
|
>
|
2026-05-29 04:23:30 +08:00
|
|
|
{selectedProviderSigningIn ? (
|
|
|
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
|
|
|
|
) : null}
|
|
|
|
|
{selectedProviderSigningIn
|
|
|
|
|
? tx("settings.oauth.signingIn", "Signing in...")
|
|
|
|
|
: tx("settings.oauth.signIn", "Sign in")}
|
|
|
|
|
</Button>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
) : null}
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={t("settings.rows.model")}
|
|
|
|
|
description={t("settings.help.model")}
|
|
|
|
|
>
|
2026-05-30 23:45:26 +08:00
|
|
|
<ModelIdPicker
|
|
|
|
|
token={token}
|
|
|
|
|
settings={settings}
|
|
|
|
|
provider={form.provider}
|
2026-05-29 04:23:30 +08:00
|
|
|
value={form.model}
|
2026-05-30 23:45:26 +08:00
|
|
|
showProviderLogos={showBrandLogos}
|
|
|
|
|
onChange={(model) => setForm((prev) => ({ ...prev, model }))}
|
2026-05-29 04:23:30 +08:00
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.contextWindow", "Context window")}
|
|
|
|
|
description={tx(
|
|
|
|
|
"settings.help.contextWindow",
|
|
|
|
|
"Choose the default context budget for this model configuration.",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<SegmentedControl
|
|
|
|
|
value={String(form.contextWindowTokens)}
|
|
|
|
|
options={CONTEXT_WINDOW_TOKEN_OPTIONS.map((tokens) => ({
|
|
|
|
|
value: String(tokens),
|
|
|
|
|
label: tokens === 262_144 ? "256K" : "64K",
|
|
|
|
|
}))}
|
|
|
|
|
onChange={(value) =>
|
|
|
|
|
setForm((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
contextWindowTokens: normalizeContextWindowTokens(Number(value)),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
2026-05-19 22:42:38 +08:00
|
|
|
<SettingsFooter
|
|
|
|
|
dirty={dirty}
|
|
|
|
|
saving={saving}
|
|
|
|
|
saved={false}
|
2026-05-29 03:42:53 +08:00
|
|
|
disabled={selectedProviderNeedsSignIn || modelFieldsMissing}
|
|
|
|
|
message={
|
|
|
|
|
selectedProviderNeedsSignIn
|
|
|
|
|
? tx("settings.oauth.signInBeforeSaving", "Sign in before saving this OAuth provider as the active model provider.")
|
|
|
|
|
: undefined
|
|
|
|
|
}
|
2026-05-19 22:42:38 +08:00
|
|
|
onSave={onSave}
|
|
|
|
|
/>
|
2026-04-25 18:05:06 +00:00
|
|
|
</SettingsGroup>
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
function ProvidersSettings({
|
2026-05-08 15:31:52 +00:00
|
|
|
settings,
|
|
|
|
|
expandedProvider,
|
|
|
|
|
providerForms,
|
|
|
|
|
visibleProviderKeys,
|
|
|
|
|
editingProviderKeys,
|
|
|
|
|
providerSaving,
|
2026-05-19 22:42:38 +08:00
|
|
|
query,
|
2026-05-22 22:25:12 +08:00
|
|
|
showBrandLogos,
|
2026-05-19 22:42:38 +08:00
|
|
|
onQueryChange,
|
2026-05-08 15:31:52 +00:00
|
|
|
onToggleProvider,
|
|
|
|
|
onToggleProviderKey,
|
|
|
|
|
onToggleProviderKeyEditing,
|
|
|
|
|
onChangeProviderForm,
|
|
|
|
|
onSaveProvider,
|
2026-05-29 03:42:53 +08:00
|
|
|
onProviderOAuthLogin,
|
|
|
|
|
onProviderOAuthLogout,
|
2026-05-09 06:48:26 +00:00
|
|
|
onResetProviderDraft,
|
2026-05-19 22:42:38 +08:00
|
|
|
imageProviderRestartPending,
|
|
|
|
|
onRestart,
|
|
|
|
|
isRestarting,
|
2026-05-08 15:31:52 +00:00
|
|
|
}: {
|
|
|
|
|
settings: SettingsPayload;
|
|
|
|
|
expandedProvider: string | null;
|
2026-05-23 18:04:40 +08:00
|
|
|
providerForms: Record<string, ProviderForm>;
|
2026-05-08 15:31:52 +00:00
|
|
|
visibleProviderKeys: Record<string, boolean>;
|
|
|
|
|
editingProviderKeys: Record<string, boolean>;
|
|
|
|
|
providerSaving: string | null;
|
2026-05-19 22:42:38 +08:00
|
|
|
query: string;
|
2026-05-22 22:25:12 +08:00
|
|
|
showBrandLogos: boolean;
|
2026-05-19 22:42:38 +08:00
|
|
|
onQueryChange: (query: string) => void;
|
2026-05-08 15:31:52 +00:00
|
|
|
onToggleProvider: (provider: string) => void;
|
|
|
|
|
onToggleProviderKey: (provider: string) => void;
|
|
|
|
|
onToggleProviderKeyEditing: (provider: string) => void;
|
2026-05-23 18:04:40 +08:00
|
|
|
onChangeProviderForm: (provider: string, value: Partial<ProviderForm>) => void;
|
2026-05-08 15:31:52 +00:00
|
|
|
onSaveProvider: (provider: string) => void;
|
2026-05-29 03:42:53 +08:00
|
|
|
onProviderOAuthLogin: (provider: string) => void;
|
|
|
|
|
onProviderOAuthLogout: (provider: string) => void;
|
2026-05-09 06:48:26 +00:00
|
|
|
onResetProviderDraft: (provider: string) => void;
|
2026-05-19 22:42:38 +08:00
|
|
|
imageProviderRestartPending: boolean;
|
|
|
|
|
onRestart?: () => void;
|
|
|
|
|
isRestarting?: boolean;
|
2026-05-08 15:31:52 +00:00
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
2026-05-19 22:42:38 +08:00
|
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
2026-05-08 15:31:52 +00:00
|
|
|
const configuredProviders = settings.providers.filter((provider) => provider.configured);
|
2026-05-17 17:41:33 +08:00
|
|
|
const unconfiguredProviders = useMemo(
|
|
|
|
|
() => orderUnconfiguredProviders(settings.providers.filter((provider) => !provider.configured)),
|
|
|
|
|
[settings.providers],
|
|
|
|
|
);
|
2026-05-19 22:42:38 +08:00
|
|
|
const filteredConfigured = filterProviders(configuredProviders, query);
|
|
|
|
|
const filteredUnconfigured = filterProviders(unconfiguredProviders, query);
|
2026-05-08 15:31:52 +00:00
|
|
|
const renderProviderRow = (provider: SettingsPayload["providers"][number]) => {
|
|
|
|
|
const expanded = expandedProvider === provider.name;
|
|
|
|
|
const form = providerForms[provider.name] ?? {
|
|
|
|
|
apiKey: "",
|
|
|
|
|
apiBase: provider.api_base ?? provider.default_api_base ?? "",
|
2026-05-23 18:04:40 +08:00
|
|
|
apiType: provider.api_type ?? "auto",
|
2026-05-08 15:31:52 +00:00
|
|
|
};
|
|
|
|
|
const saving = providerSaving === provider.name;
|
2026-05-29 03:42:53 +08:00
|
|
|
const isOauthProvider = provider.auth_type === "oauth";
|
2026-05-08 15:31:52 +00:00
|
|
|
const keyVisible = !!visibleProviderKeys[provider.name];
|
|
|
|
|
const editingKey = !provider.configured || !!editingProviderKeys[provider.name];
|
2026-05-17 17:41:33 +08:00
|
|
|
const apiKeyRequired = provider.api_key_required ?? true;
|
|
|
|
|
const apiKey = form.apiKey.trim();
|
|
|
|
|
const apiBase = form.apiBase.trim();
|
2026-05-29 03:42:53 +08:00
|
|
|
const missingRequiredApiKey = !isOauthProvider && apiKeyRequired && !provider.configured && !apiKey;
|
2026-05-17 17:41:33 +08:00
|
|
|
const missingOptionalCredential =
|
2026-05-29 03:42:53 +08:00
|
|
|
!isOauthProvider && !apiKeyRequired && !provider.configured && !apiKey && !apiBase;
|
2026-05-08 15:31:52 +00:00
|
|
|
return (
|
2026-05-19 22:42:38 +08:00
|
|
|
<div key={provider.name} className="divide-y divide-border/45">
|
2026-05-08 15:31:52 +00:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => onToggleProvider(provider.name)}
|
|
|
|
|
className="flex min-h-[70px] w-full items-center justify-between gap-4 px-4 py-3 text-left transition-colors hover:bg-muted/35 sm:px-5"
|
|
|
|
|
>
|
|
|
|
|
<span className="flex min-w-0 items-center gap-3">
|
2026-05-22 22:25:12 +08:00
|
|
|
<ProviderIcon
|
|
|
|
|
provider={provider.name}
|
|
|
|
|
showBrandLogos={showBrandLogos}
|
|
|
|
|
/>
|
2026-05-08 15:31:52 +00:00
|
|
|
<span className="min-w-0">
|
|
|
|
|
<span className="block truncate text-[15px] font-semibold leading-5 text-foreground">
|
|
|
|
|
{provider.label}
|
|
|
|
|
</span>
|
2026-05-19 22:42:38 +08:00
|
|
|
<span className="block truncate text-[12px] text-muted-foreground">
|
|
|
|
|
{provider.api_base || provider.default_api_base || provider.name}
|
|
|
|
|
</span>
|
2026-05-08 15:31:52 +00:00
|
|
|
</span>
|
|
|
|
|
</span>
|
2026-05-19 22:42:38 +08:00
|
|
|
<StatusPill tone={provider.configured ? "success" : "neutral"}>
|
2026-05-29 03:42:53 +08:00
|
|
|
{isOauthProvider
|
|
|
|
|
? provider.configured
|
|
|
|
|
? tx("settings.oauth.signedIn", "Signed in")
|
|
|
|
|
: tx("settings.oauth.notSignedIn", "Not signed in")
|
|
|
|
|
: provider.configured
|
|
|
|
|
? t("settings.byok.configured")
|
|
|
|
|
: t("settings.byok.notConfigured")}
|
2026-05-19 22:42:38 +08:00
|
|
|
</StatusPill>
|
2026-05-08 15:31:52 +00:00
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{expanded ? (
|
|
|
|
|
<div className="space-y-3 bg-muted/18 px-4 py-4 sm:px-5">
|
2026-05-29 03:42:53 +08:00
|
|
|
{isOauthProvider ? (
|
|
|
|
|
<div className="flex flex-col gap-3 rounded-[18px] border border-border/45 bg-background/75 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<p className="text-[13px] font-semibold text-foreground">
|
|
|
|
|
{tx("settings.oauth.authentication", "OAuth authentication")}
|
|
|
|
|
</p>
|
|
|
|
|
<p className="mt-1 truncate text-[12px] text-muted-foreground">
|
|
|
|
|
{provider.configured
|
|
|
|
|
? t("settings.oauth.signedInAs", {
|
|
|
|
|
account: provider.oauth_account || provider.label,
|
|
|
|
|
defaultValue: "Signed in as {{account}}",
|
|
|
|
|
})
|
|
|
|
|
: tx("settings.oauth.signInHelp", "Sign in from this device; no API key is stored in config.")}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex shrink-0 justify-end gap-2">
|
|
|
|
|
{provider.configured ? (
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={() => onProviderOAuthLogout(provider.name)}
|
|
|
|
|
disabled={saving}
|
|
|
|
|
className="rounded-full"
|
|
|
|
|
>
|
|
|
|
|
{tx("settings.oauth.signOut", "Sign out")}
|
|
|
|
|
</Button>
|
|
|
|
|
) : null}
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => onProviderOAuthLogin(provider.name)}
|
|
|
|
|
disabled={saving || !provider.oauth_login_supported}
|
|
|
|
|
className="rounded-full"
|
|
|
|
|
>
|
|
|
|
|
{saving ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : null}
|
|
|
|
|
{saving
|
|
|
|
|
? tx("settings.oauth.signingIn", "Signing in...")
|
|
|
|
|
: provider.configured
|
|
|
|
|
? tx("settings.oauth.signInAgain", "Sign in again")
|
|
|
|
|
: tx("settings.oauth.signIn", "Sign in")}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
2026-05-08 15:31:52 +00:00
|
|
|
<label className="block space-y-1.5">
|
|
|
|
|
<span className="text-[12px] font-medium text-muted-foreground">
|
|
|
|
|
{t("settings.byok.apiKey")}
|
|
|
|
|
</span>
|
|
|
|
|
<div className="relative">
|
|
|
|
|
{editingKey ? (
|
|
|
|
|
<>
|
|
|
|
|
<Input
|
|
|
|
|
type={keyVisible ? "text" : "password"}
|
|
|
|
|
value={form.apiKey}
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
onChangeProviderForm(provider.name, { apiKey: event.target.value })
|
|
|
|
|
}
|
|
|
|
|
placeholder={
|
|
|
|
|
provider.configured
|
|
|
|
|
? t("settings.byok.apiKeyConfiguredPlaceholder")
|
|
|
|
|
: t("settings.byok.apiKeyPlaceholder")
|
|
|
|
|
}
|
|
|
|
|
className="h-9 rounded-full pr-11 text-[13px]"
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={() => onToggleProviderKey(provider.name)}
|
|
|
|
|
aria-label={
|
|
|
|
|
keyVisible
|
|
|
|
|
? t("settings.byok.hideApiKey")
|
|
|
|
|
: t("settings.byok.showApiKey")
|
|
|
|
|
}
|
|
|
|
|
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
|
|
|
>
|
|
|
|
|
{keyVisible ? (
|
|
|
|
|
<EyeOff className="h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
) : (
|
|
|
|
|
<Eye className="h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div className="flex h-9 items-center rounded-full border border-input bg-background px-3 pr-11 text-[13px] text-muted-foreground">
|
|
|
|
|
{provider.api_key_hint ?? t("settings.byok.configuredKeyHint")}
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={() => onToggleProviderKeyEditing(provider.name)}
|
|
|
|
|
aria-label={t("settings.actions.edit")}
|
|
|
|
|
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
|
|
|
>
|
|
|
|
|
<Pencil className="h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="block space-y-1.5">
|
|
|
|
|
<span className="text-[12px] font-medium text-muted-foreground">
|
|
|
|
|
{t("settings.byok.apiBase")}
|
|
|
|
|
</span>
|
|
|
|
|
<Input
|
|
|
|
|
value={form.apiBase}
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
onChangeProviderForm(provider.name, { apiBase: event.target.value })
|
|
|
|
|
}
|
|
|
|
|
placeholder={provider.default_api_base ?? t("settings.byok.apiBasePlaceholder")}
|
|
|
|
|
className="h-9 rounded-full text-[13px]"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
2026-05-23 18:04:40 +08:00
|
|
|
{provider.name === "openai" ? (
|
|
|
|
|
<label className="block space-y-1.5">
|
|
|
|
|
<span className="text-[12px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.byok.apiType", "API type")}
|
|
|
|
|
</span>
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="h-9 w-full justify-between rounded-full px-3 text-[13px]"
|
|
|
|
|
>
|
|
|
|
|
<span>
|
|
|
|
|
{OPENAI_API_TYPE_OPTIONS.find((option) => option.value === form.apiType)?.label ??
|
|
|
|
|
form.apiType}
|
|
|
|
|
</span>
|
|
|
|
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
|
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="start" className="min-w-[220px]">
|
|
|
|
|
{OPENAI_API_TYPE_OPTIONS.map((option) => (
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
key={option.value}
|
|
|
|
|
onSelect={() => onChangeProviderForm(provider.name, { apiType: option.value })}
|
|
|
|
|
>
|
|
|
|
|
{option.label}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
))}
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
</label>
|
|
|
|
|
) : null}
|
2026-05-19 22:42:38 +08:00
|
|
|
<div className="flex items-center justify-end gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={() => onResetProviderDraft(provider.name)}
|
|
|
|
|
className="rounded-full"
|
|
|
|
|
>
|
|
|
|
|
{t("settings.actions.cancel")}
|
|
|
|
|
</Button>
|
2026-05-08 15:31:52 +00:00
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => onSaveProvider(provider.name)}
|
2026-05-17 17:41:33 +08:00
|
|
|
disabled={saving || missingRequiredApiKey || missingOptionalCredential}
|
2026-05-08 15:31:52 +00:00
|
|
|
className="rounded-full"
|
|
|
|
|
>
|
2026-05-24 13:38:37 +08:00
|
|
|
{saving ? t("settings.actions.saving") : tx("settings.providers.saveProvider", "Save provider")}
|
2026-05-08 15:31:52 +00:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-05-29 03:42:53 +08:00
|
|
|
</>
|
|
|
|
|
)}
|
2026-05-08 15:31:52 +00:00
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
<p className="max-w-[42rem] text-[13px] leading-6 text-muted-foreground">
|
|
|
|
|
{t("settings.byok.description")}
|
|
|
|
|
</p>
|
2026-05-19 22:42:38 +08:00
|
|
|
{imageProviderRestartPending && onRestart ? (
|
|
|
|
|
<div className="flex min-h-[48px] items-center justify-between gap-3 border-y border-border/55 py-3">
|
|
|
|
|
<p className="text-[13px] leading-5 text-muted-foreground">
|
|
|
|
|
{tx("settings.status.imageProviderRestart", "Image provider changes saved. Restart when ready.")}
|
|
|
|
|
</p>
|
|
|
|
|
<div className="shrink-0">
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={onRestart}
|
|
|
|
|
disabled={isRestarting}
|
|
|
|
|
className="rounded-full"
|
|
|
|
|
>
|
|
|
|
|
{isRestarting ? (
|
|
|
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
|
|
|
|
) : (
|
|
|
|
|
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
)}
|
|
|
|
|
{isRestarting ? t("app.system.restarting") : t("app.system.restart")}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
|
|
|
|
|
<Input
|
|
|
|
|
value={query}
|
|
|
|
|
onChange={(event) => onQueryChange(event.target.value)}
|
|
|
|
|
placeholder={tx("settings.providers.searchPlaceholder", "Search providers")}
|
|
|
|
|
className="h-10 rounded-full pl-9 text-[13px]"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<ProviderSection
|
|
|
|
|
title={t("settings.byok.configuredSection")}
|
|
|
|
|
count={filteredConfigured.length}
|
|
|
|
|
empty={t("settings.byok.noConfiguredProviders")}
|
2026-05-09 06:48:26 +00:00
|
|
|
>
|
2026-05-19 22:42:38 +08:00
|
|
|
{filteredConfigured.map(renderProviderRow)}
|
|
|
|
|
</ProviderSection>
|
|
|
|
|
<ProviderSection
|
|
|
|
|
title={t("settings.byok.notConfiguredSection")}
|
|
|
|
|
count={filteredUnconfigured.length}
|
|
|
|
|
empty={tx("settings.providers.noMatches", "No providers match this search.")}
|
|
|
|
|
>
|
|
|
|
|
{filteredUnconfigured.map(renderProviderRow)}
|
|
|
|
|
</ProviderSection>
|
2026-05-22 22:25:12 +08:00
|
|
|
<ThirdPartyBrandNotice />
|
2026-05-19 22:42:38 +08:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ImageGenerationSettings({
|
|
|
|
|
settings,
|
|
|
|
|
form,
|
|
|
|
|
dirty,
|
|
|
|
|
saving,
|
|
|
|
|
onChangeForm,
|
|
|
|
|
onSave,
|
|
|
|
|
onOpenProviders,
|
2026-05-24 13:38:37 +08:00
|
|
|
showBrandLogos,
|
2026-05-19 22:42:38 +08:00
|
|
|
onRestart,
|
|
|
|
|
isRestarting,
|
|
|
|
|
requiresRestartPending,
|
|
|
|
|
}: {
|
|
|
|
|
settings: SettingsPayload;
|
|
|
|
|
form: ImageGenerationSettingsUpdate;
|
|
|
|
|
dirty: boolean;
|
|
|
|
|
saving: boolean;
|
|
|
|
|
onChangeForm: Dispatch<SetStateAction<ImageGenerationSettingsUpdate>>;
|
|
|
|
|
onSave: () => void;
|
|
|
|
|
onOpenProviders: () => void;
|
2026-05-24 13:38:37 +08:00
|
|
|
showBrandLogos: boolean;
|
2026-05-19 22:42:38 +08:00
|
|
|
onRestart?: () => void;
|
|
|
|
|
isRestarting?: boolean;
|
|
|
|
|
requiresRestartPending: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
|
|
|
const selectedProvider =
|
|
|
|
|
settings.image_generation.providers.find((provider) => provider.name === form.provider) ??
|
|
|
|
|
settings.image_generation.providers[0];
|
|
|
|
|
const providerConfigured = !!selectedProvider?.configured;
|
|
|
|
|
const missingCredential = form.enabled && !providerConfigured;
|
|
|
|
|
const aspectOptions = optionRowsWithCurrent(
|
|
|
|
|
IMAGE_ASPECT_RATIO_OPTIONS.map((value) => ({ name: value, label: value })),
|
|
|
|
|
form.defaultAspectRatio,
|
|
|
|
|
);
|
|
|
|
|
const sizeOptions = optionRowsWithCurrent(
|
|
|
|
|
IMAGE_SIZE_OPTIONS.map((value) => ({ name: value, label: value })),
|
|
|
|
|
form.defaultImageSize,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-7">
|
|
|
|
|
<section>
|
|
|
|
|
<SettingsSectionTitle>{tx("settings.sections.imageGeneration", "Image generation")}</SettingsSectionTitle>
|
|
|
|
|
<SettingsGroup>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.imageGeneration", "Image generation")}
|
|
|
|
|
description={tx("settings.help.imageGeneration", "Expose generate_image in chats when a configured image provider is available.")}
|
|
|
|
|
>
|
|
|
|
|
<ToggleButton
|
|
|
|
|
checked={form.enabled}
|
|
|
|
|
onChange={(enabled) => onChangeForm((prev) => ({ ...prev, enabled }))}
|
2026-05-24 13:38:37 +08:00
|
|
|
ariaLabel={tx("settings.rows.imageGeneration", "Image generation")}
|
2026-05-19 22:42:38 +08:00
|
|
|
label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.imageProvider", "Image provider")}
|
|
|
|
|
description={tx("settings.help.imageProvider", "Choose the registry provider used by generate_image.")}
|
|
|
|
|
>
|
|
|
|
|
<ProviderPicker
|
|
|
|
|
providers={settings.image_generation.providers}
|
|
|
|
|
value={form.provider}
|
|
|
|
|
emptyLabel={tx("settings.image.selectProvider", "Select provider")}
|
2026-05-24 13:38:37 +08:00
|
|
|
showProviderLogos={showBrandLogos}
|
2026-05-19 22:42:38 +08:00
|
|
|
onChange={(provider) => onChangeForm((prev) => ({ ...prev, provider }))}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.imageProviderStatus", "Provider status")}
|
|
|
|
|
description={tx("settings.help.imageProviderStatus", "Image generation reuses provider credentials from Providers.")}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
|
|
|
<StatusPill tone={providerConfigured ? "success" : "neutral"}>
|
|
|
|
|
{providerConfigured
|
|
|
|
|
? tx("settings.values.configured", "Configured")
|
|
|
|
|
: tx("settings.values.notConfigured", "Not configured")}
|
|
|
|
|
</StatusPill>
|
|
|
|
|
{!providerConfigured ? (
|
|
|
|
|
<Button size="sm" variant="outline" onClick={onOpenProviders} className="rounded-full">
|
|
|
|
|
{tx("settings.image.configureProvider", "Configure provider")}
|
|
|
|
|
</Button>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<SettingsRow title={tx("settings.rows.imageProviderBase", "Provider base")}>
|
|
|
|
|
<span className="max-w-[320px] truncate text-right text-[13px] text-muted-foreground">
|
|
|
|
|
{selectedProvider?.api_base || selectedProvider?.default_api_base || selectedProvider?.name || tx("settings.values.notAvailable", "Not available")}
|
|
|
|
|
</span>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
</SettingsGroup>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section>
|
|
|
|
|
<SettingsSectionTitle>{tx("settings.sections.imageDefaults", "Defaults")}</SettingsSectionTitle>
|
|
|
|
|
<SettingsGroup>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.imageModel", "Image model")}
|
|
|
|
|
description={tx("settings.help.imageModel", "Model name sent to the selected image provider.")}
|
|
|
|
|
>
|
|
|
|
|
<Input
|
|
|
|
|
value={form.model}
|
|
|
|
|
onChange={(event) => onChangeForm((prev) => ({ ...prev, model: event.target.value }))}
|
|
|
|
|
className="h-8 w-[min(300px,70vw)] rounded-full text-[13px]"
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.defaultAspectRatio", "Default aspect")}
|
|
|
|
|
description={tx("settings.help.defaultAspectRatio", "Used when the prompt does not choose an aspect ratio.")}
|
|
|
|
|
>
|
|
|
|
|
<ProviderPicker
|
|
|
|
|
providers={aspectOptions}
|
|
|
|
|
value={form.defaultAspectRatio}
|
|
|
|
|
emptyLabel={tx("settings.image.selectAspect", "Select aspect")}
|
|
|
|
|
onChange={(defaultAspectRatio) =>
|
|
|
|
|
onChangeForm((prev) => ({ ...prev, defaultAspectRatio }))
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.defaultImageSize", "Default size")}
|
|
|
|
|
description={tx("settings.help.defaultImageSize", "Size hint sent to providers that support it.")}
|
|
|
|
|
>
|
|
|
|
|
<ProviderPicker
|
|
|
|
|
providers={sizeOptions}
|
|
|
|
|
value={form.defaultImageSize}
|
|
|
|
|
emptyLabel={tx("settings.image.selectSize", "Select size")}
|
|
|
|
|
onChange={(defaultImageSize) =>
|
|
|
|
|
onChangeForm((prev) => ({ ...prev, defaultImageSize }))
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.maxImagesPerTurn", "Max images per turn")}
|
|
|
|
|
description={tx("settings.help.maxImagesPerTurn", "Upper bound for one generate_image request.")}
|
|
|
|
|
>
|
|
|
|
|
<NumberInput
|
|
|
|
|
value={form.maxImagesPerTurn}
|
|
|
|
|
min={1}
|
|
|
|
|
max={8}
|
|
|
|
|
onChange={(maxImagesPerTurn) =>
|
|
|
|
|
onChangeForm((prev) => ({ ...prev, maxImagesPerTurn }))
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<ReadOnlyRow title={tx("settings.rows.imageSaveDir", "Save directory")} value={settings.image_generation.save_dir} />
|
|
|
|
|
<RestartSettingsFooter
|
|
|
|
|
dirty={dirty}
|
|
|
|
|
saving={saving}
|
|
|
|
|
pendingRestart={requiresRestartPending}
|
|
|
|
|
disabled={missingCredential}
|
|
|
|
|
message={
|
|
|
|
|
missingCredential
|
|
|
|
|
? tx("settings.image.missingCredential", "Configure this provider before enabling image generation.")
|
|
|
|
|
: undefined
|
|
|
|
|
}
|
|
|
|
|
dirtyMessage={tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")}
|
|
|
|
|
pendingMessage={tx("settings.status.savedRestartApply", "Saved. Restart when ready.")}
|
|
|
|
|
onSave={onSave}
|
|
|
|
|
onRestart={onRestart}
|
|
|
|
|
isRestarting={isRestarting}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsGroup>
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 01:08:49 +08:00
|
|
|
function TranscriptionSettings({
|
|
|
|
|
settings,
|
|
|
|
|
form,
|
|
|
|
|
dirty,
|
|
|
|
|
saving,
|
|
|
|
|
onChangeForm,
|
|
|
|
|
onSave,
|
|
|
|
|
onOpenProviders,
|
|
|
|
|
showBrandLogos,
|
|
|
|
|
onRestart,
|
|
|
|
|
isRestarting,
|
|
|
|
|
requiresRestartPending,
|
|
|
|
|
}: {
|
|
|
|
|
settings: SettingsPayload;
|
|
|
|
|
form: TranscriptionSettingsUpdate;
|
|
|
|
|
dirty: boolean;
|
|
|
|
|
saving: boolean;
|
|
|
|
|
onChangeForm: Dispatch<SetStateAction<TranscriptionSettingsUpdate>>;
|
|
|
|
|
onSave: () => void;
|
|
|
|
|
onOpenProviders: () => void;
|
|
|
|
|
showBrandLogos: boolean;
|
|
|
|
|
onRestart?: () => void;
|
|
|
|
|
isRestarting?: boolean;
|
|
|
|
|
requiresRestartPending: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
|
|
|
const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
|
|
|
|
|
const selectedProvider =
|
|
|
|
|
transcription.providers.find((provider) => provider.name === form.provider) ??
|
|
|
|
|
transcription.providers[0];
|
|
|
|
|
const providerConfigured = !!selectedProvider?.configured;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<section>
|
|
|
|
|
<SettingsSectionTitle>{tx("settings.sections.voiceInput", "Voice input")}</SettingsSectionTitle>
|
|
|
|
|
<SettingsGroup>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.transcription", "Transcription")}
|
|
|
|
|
description={tx("settings.help.transcription", "Transcribe microphone input before sending it. Chat channel voice messages use the same settings.")}
|
|
|
|
|
>
|
|
|
|
|
<ToggleButton
|
|
|
|
|
checked={form.enabled}
|
|
|
|
|
onChange={(enabled) => onChangeForm((prev) => ({ ...prev, enabled }))}
|
|
|
|
|
ariaLabel={tx("settings.rows.transcription", "Transcription")}
|
|
|
|
|
label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.transcriptionProvider", "Provider")}
|
|
|
|
|
description={tx("settings.help.transcriptionProvider", "Uses the matching provider credentials from Providers.")}
|
|
|
|
|
>
|
|
|
|
|
<ProviderPicker
|
|
|
|
|
providers={transcription.providers}
|
|
|
|
|
value={form.provider}
|
|
|
|
|
emptyLabel={tx("settings.voice.selectProvider", "Select provider")}
|
|
|
|
|
showProviderLogos={showBrandLogos}
|
|
|
|
|
onChange={(provider) => onChangeForm((prev) => ({ ...prev, provider }))}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.transcriptionProviderStatus", "Provider status")}
|
|
|
|
|
description={tx("settings.help.transcriptionProviderStatus", "API keys stay under providers, not in transcription settings.")}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
|
|
|
<StatusPill tone={providerConfigured ? "success" : "neutral"}>
|
|
|
|
|
{providerConfigured
|
|
|
|
|
? tx("settings.values.configured", "Configured")
|
|
|
|
|
: tx("settings.values.notConfigured", "Not configured")}
|
|
|
|
|
</StatusPill>
|
|
|
|
|
{!providerConfigured ? (
|
|
|
|
|
<Button size="sm" variant="outline" onClick={onOpenProviders} className="rounded-full">
|
|
|
|
|
{tx("settings.voice.configureProvider", "Configure provider")}
|
|
|
|
|
</Button>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.transcriptionModel", "Model")}
|
|
|
|
|
description={tx("settings.help.transcriptionModel", "Leave as the resolved default unless your provider needs a custom model id.")}
|
|
|
|
|
>
|
|
|
|
|
<Input
|
|
|
|
|
value={form.model}
|
|
|
|
|
onChange={(event) => onChangeForm((prev) => ({ ...prev, model: event.target.value }))}
|
|
|
|
|
className="h-8 w-[min(300px,70vw)] rounded-full text-[13px]"
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.transcriptionLanguage", "Language")}
|
|
|
|
|
description={tx("settings.help.transcriptionLanguage", "Optional ISO-639 hint such as en, zh, ja, or ko.")}
|
|
|
|
|
>
|
|
|
|
|
<Input
|
|
|
|
|
value={form.language}
|
|
|
|
|
onChange={(event) => onChangeForm((prev) => ({ ...prev, language: event.target.value }))}
|
|
|
|
|
placeholder={tx("settings.voice.languageAuto", "Auto")}
|
|
|
|
|
className="h-8 w-[min(180px,60vw)] rounded-full text-[13px]"
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<SettingsRow title={tx("settings.rows.voiceLimits", "Limits")}>
|
|
|
|
|
<div className="flex flex-wrap justify-end gap-2">
|
|
|
|
|
<NumberInput
|
|
|
|
|
value={form.maxDurationSec}
|
|
|
|
|
min={1}
|
|
|
|
|
max={600}
|
|
|
|
|
suffix="s"
|
|
|
|
|
onChange={(maxDurationSec) => onChangeForm((prev) => ({ ...prev, maxDurationSec }))}
|
|
|
|
|
/>
|
|
|
|
|
<NumberInput
|
|
|
|
|
value={form.maxUploadMb}
|
|
|
|
|
min={1}
|
|
|
|
|
max={100}
|
|
|
|
|
suffix="MB"
|
|
|
|
|
onChange={(maxUploadMb) => onChangeForm((prev) => ({ ...prev, maxUploadMb }))}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<RestartSettingsFooter
|
|
|
|
|
dirty={dirty}
|
|
|
|
|
saving={saving}
|
|
|
|
|
pendingRestart={requiresRestartPending}
|
|
|
|
|
dirtyMessage={tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")}
|
|
|
|
|
pendingMessage={tx("settings.status.savedRestartApply", "Saved. Restart when ready.")}
|
|
|
|
|
onSave={onSave}
|
|
|
|
|
onRestart={onRestart}
|
|
|
|
|
isRestarting={isRestarting}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsGroup>
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
function WebSettings({
|
|
|
|
|
settings,
|
|
|
|
|
form,
|
|
|
|
|
keyVisible,
|
|
|
|
|
keyEditing,
|
|
|
|
|
saving,
|
|
|
|
|
onChangeForm,
|
|
|
|
|
onChangeProvider,
|
|
|
|
|
onToggleKey,
|
|
|
|
|
onToggleKeyEditing,
|
|
|
|
|
onReset,
|
|
|
|
|
onSave,
|
2026-05-24 13:38:37 +08:00
|
|
|
showBrandLogos,
|
2026-05-19 22:42:38 +08:00
|
|
|
onRestart,
|
|
|
|
|
isRestarting,
|
|
|
|
|
requiresRestartPending,
|
|
|
|
|
}: {
|
|
|
|
|
settings: SettingsPayload;
|
|
|
|
|
form: WebSearchSettingsUpdate;
|
|
|
|
|
keyVisible: boolean;
|
|
|
|
|
keyEditing: boolean;
|
|
|
|
|
saving: boolean;
|
|
|
|
|
onChangeForm: Dispatch<SetStateAction<WebSearchSettingsUpdate>>;
|
|
|
|
|
onChangeProvider: (provider: string) => void;
|
|
|
|
|
onToggleKey: () => void;
|
|
|
|
|
onToggleKeyEditing: () => void;
|
|
|
|
|
onReset: () => void;
|
|
|
|
|
onSave: () => void;
|
2026-05-24 13:38:37 +08:00
|
|
|
showBrandLogos: boolean;
|
2026-05-19 22:42:38 +08:00
|
|
|
onRestart?: () => void;
|
|
|
|
|
isRestarting?: boolean;
|
|
|
|
|
requiresRestartPending: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
|
|
|
const selectedProvider =
|
|
|
|
|
settings.web_search.providers.find((provider) => provider.name === form.provider) ??
|
|
|
|
|
settings.web_search.providers[0];
|
|
|
|
|
const hasExistingSecret =
|
|
|
|
|
selectedProvider?.credential === "api_key" &&
|
|
|
|
|
form.provider === settings.web_search.provider &&
|
|
|
|
|
!!settings.web_search.api_key_hint;
|
|
|
|
|
const showKeyInput = selectedProvider?.credential === "api_key" && (!hasExistingSecret || keyEditing);
|
|
|
|
|
const apiKey = form.apiKey?.trim() ?? "";
|
|
|
|
|
const baseUrl = form.baseUrl?.trim() ?? "";
|
|
|
|
|
const effectiveJinaReader = form.useJinaReader ?? settings.web.fetch.use_jina_reader;
|
|
|
|
|
const dirty =
|
|
|
|
|
form.provider !== settings.web_search.provider ||
|
|
|
|
|
apiKey.length > 0 ||
|
|
|
|
|
baseUrl !== (settings.web_search.base_url ?? "") ||
|
|
|
|
|
form.maxResults !== settings.web_search.max_results ||
|
|
|
|
|
form.timeout !== settings.web_search.timeout ||
|
|
|
|
|
effectiveJinaReader !== settings.web.fetch.use_jina_reader;
|
|
|
|
|
const jinaReaderDirty = effectiveJinaReader !== settings.web.fetch.use_jina_reader;
|
|
|
|
|
const missingCredential =
|
|
|
|
|
selectedProvider?.credential === "api_key"
|
|
|
|
|
? !apiKey && !hasExistingSecret
|
|
|
|
|
: selectedProvider?.credential === "base_url"
|
|
|
|
|
? !baseUrl
|
|
|
|
|
: false;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-7">
|
|
|
|
|
<section>
|
|
|
|
|
<SettingsSectionTitle>{tx("settings.sections.webSearch", "Web search")}</SettingsSectionTitle>
|
|
|
|
|
<SettingsGroup>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={t("settings.byok.webSearch.provider")}
|
|
|
|
|
description={t("settings.byok.webSearch.providerHelp")}
|
|
|
|
|
>
|
|
|
|
|
<ProviderPicker
|
|
|
|
|
providers={settings.web_search.providers}
|
|
|
|
|
value={form.provider}
|
|
|
|
|
emptyLabel={t("settings.byok.webSearch.selectProvider")}
|
2026-05-24 13:38:37 +08:00
|
|
|
showProviderLogos={showBrandLogos}
|
2026-05-19 22:42:38 +08:00
|
|
|
onChange={onChangeProvider}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
|
|
|
|
|
{selectedProvider?.credential === "none" ? (
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={t("settings.byok.webSearch.credentials")}
|
|
|
|
|
description={t("settings.byok.webSearch.noCredentialHelp")}
|
|
|
|
|
>
|
|
|
|
|
<StatusPill tone="success">{t("settings.byok.webSearch.noCredentialRequired")}</StatusPill>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{selectedProvider?.credential === "api_key" ? (
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={t("settings.byok.apiKey")}
|
|
|
|
|
description={t("settings.byok.webSearch.apiKeyHelp")}
|
|
|
|
|
>
|
|
|
|
|
<div className="relative w-[280px] max-w-full">
|
|
|
|
|
{showKeyInput ? (
|
|
|
|
|
<>
|
|
|
|
|
<Input
|
|
|
|
|
type={keyVisible ? "text" : "password"}
|
|
|
|
|
value={form.apiKey ?? ""}
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
onChangeForm((prev) => ({ ...prev, apiKey: event.target.value }))
|
|
|
|
|
}
|
|
|
|
|
placeholder={
|
|
|
|
|
hasExistingSecret
|
|
|
|
|
? t("settings.byok.apiKeyConfiguredPlaceholder")
|
|
|
|
|
: t("settings.byok.apiKeyPlaceholder")
|
|
|
|
|
}
|
|
|
|
|
className="h-9 rounded-full pr-11 text-[13px]"
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={onToggleKey}
|
|
|
|
|
aria-label={
|
|
|
|
|
keyVisible ? t("settings.byok.hideApiKey") : t("settings.byok.showApiKey")
|
|
|
|
|
}
|
|
|
|
|
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
|
|
|
>
|
|
|
|
|
{keyVisible ? (
|
|
|
|
|
<EyeOff className="h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
) : (
|
|
|
|
|
<Eye className="h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div className="flex h-9 items-center rounded-full border border-input bg-background px-3 pr-11 text-[13px] text-muted-foreground">
|
|
|
|
|
{settings.web_search.api_key_hint ?? t("settings.byok.configuredKeyHint")}
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={onToggleKeyEditing}
|
|
|
|
|
aria-label={t("settings.actions.edit")}
|
|
|
|
|
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
|
|
|
>
|
|
|
|
|
<Pencil className="h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{selectedProvider?.credential === "base_url" ? (
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={t("settings.byok.webSearch.baseUrl")}
|
|
|
|
|
description={t("settings.byok.webSearch.baseUrlHelp")}
|
|
|
|
|
>
|
|
|
|
|
<Input
|
|
|
|
|
value={form.baseUrl ?? ""}
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
onChangeForm((prev) => ({ ...prev, baseUrl: event.target.value }))
|
|
|
|
|
}
|
|
|
|
|
placeholder={t("settings.byok.webSearch.baseUrlPlaceholder")}
|
|
|
|
|
className="h-9 w-[280px] rounded-full text-[13px]"
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
) : null}
|
|
|
|
|
</SettingsGroup>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section>
|
|
|
|
|
<SettingsSectionTitle>{tx("settings.sections.webBehavior", "Behavior")}</SettingsSectionTitle>
|
|
|
|
|
<SettingsGroup>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.maxResults", "Max results")}
|
|
|
|
|
description={tx("settings.help.maxResults", "Results returned by each web_search call.")}
|
|
|
|
|
>
|
|
|
|
|
<NumberInput
|
|
|
|
|
value={form.maxResults ?? settings.web_search.max_results}
|
|
|
|
|
min={1}
|
|
|
|
|
max={10}
|
|
|
|
|
onChange={(maxResults) => onChangeForm((prev) => ({ ...prev, maxResults }))}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.timeout", "Timeout")}
|
|
|
|
|
description={tx("settings.help.timeout", "Seconds before a search provider request times out.")}
|
|
|
|
|
>
|
|
|
|
|
<NumberInput
|
|
|
|
|
value={form.timeout ?? settings.web_search.timeout}
|
|
|
|
|
min={1}
|
|
|
|
|
max={120}
|
|
|
|
|
onChange={(timeout) => onChangeForm((prev) => ({ ...prev, timeout }))}
|
|
|
|
|
suffix="s"
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.jinaReader", "Jina reader")}
|
|
|
|
|
description={tx("settings.help.jinaReader", "Use Jina Reader for web_fetch when available.")}
|
|
|
|
|
>
|
|
|
|
|
<ToggleButton
|
|
|
|
|
checked={effectiveJinaReader}
|
|
|
|
|
onChange={(useJinaReader) => onChangeForm((prev) => ({ ...prev, useJinaReader }))}
|
2026-05-24 13:38:37 +08:00
|
|
|
ariaLabel={tx("settings.rows.jinaReader", "Jina reader")}
|
2026-05-19 22:42:38 +08:00
|
|
|
label={effectiveJinaReader ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<RestartSettingsFooter
|
|
|
|
|
dirty={dirty}
|
|
|
|
|
saving={saving}
|
|
|
|
|
pendingRestart={requiresRestartPending}
|
|
|
|
|
disabled={missingCredential}
|
|
|
|
|
message={
|
|
|
|
|
missingCredential
|
|
|
|
|
? t("settings.byok.webSearch.missingCredential")
|
|
|
|
|
: requiresRestartPending && !dirty
|
|
|
|
|
? tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
|
|
|
|
|
: jinaReaderDirty
|
|
|
|
|
? tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")
|
|
|
|
|
: dirty
|
|
|
|
|
? t("settings.byok.webSearch.saveHint")
|
|
|
|
|
: undefined
|
|
|
|
|
}
|
|
|
|
|
onSave={onSave}
|
|
|
|
|
onRestart={onRestart}
|
|
|
|
|
onReset={onReset}
|
|
|
|
|
isRestarting={isRestarting}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsGroup>
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 20:08:18 +08:00
|
|
|
function AutomationsSettings({
|
|
|
|
|
payload,
|
|
|
|
|
loading,
|
|
|
|
|
query,
|
|
|
|
|
filter,
|
2026-06-14 18:24:59 +08:00
|
|
|
sort,
|
2026-06-13 20:08:18 +08:00
|
|
|
actionKey,
|
|
|
|
|
error,
|
|
|
|
|
onQueryChange,
|
|
|
|
|
onFilterChange,
|
2026-06-14 18:24:59 +08:00
|
|
|
onSortChange,
|
2026-06-13 20:08:18 +08:00
|
|
|
onAction,
|
2026-06-14 18:24:59 +08:00
|
|
|
onRequestEdit,
|
2026-06-13 20:08:18 +08:00
|
|
|
onRequestDelete,
|
|
|
|
|
}: {
|
|
|
|
|
payload: AutomationsPayload | null;
|
|
|
|
|
loading: boolean;
|
|
|
|
|
query: string;
|
|
|
|
|
filter: AutomationFilter;
|
2026-06-14 18:24:59 +08:00
|
|
|
sort: AutomationSort;
|
2026-06-13 20:08:18 +08:00
|
|
|
actionKey: string | null;
|
|
|
|
|
error: string | null;
|
|
|
|
|
onQueryChange: (value: string) => void;
|
|
|
|
|
onFilterChange: (value: AutomationFilter) => void;
|
2026-06-14 18:24:59 +08:00
|
|
|
onSortChange: (value: AutomationSort) => void;
|
2026-06-13 20:08:18 +08:00
|
|
|
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>;
|
2026-06-14 18:24:59 +08:00
|
|
|
onRequestEdit: (job: SessionAutomationJob) => void;
|
2026-06-13 20:08:18 +08:00
|
|
|
onRequestDelete: (job: SessionAutomationJob) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const { t, i18n } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
|
|
|
|
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
|
|
|
|
const jobs = payload?.jobs ?? [];
|
2026-06-15 16:35:15 +08:00
|
|
|
const locale = i18n.resolvedLanguage || i18n.language;
|
|
|
|
|
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
|
|
|
|
|
const filtered = useMemo(() => {
|
2026-06-16 00:59:42 +08:00
|
|
|
const searchTokens = parseAutomationSearchQuery(query);
|
2026-06-15 16:35:15 +08:00
|
|
|
return sortAutomationJobs(jobs, sort)
|
|
|
|
|
.filter((job) => automationMatchesFilter(job, filter))
|
2026-06-16 00:59:42 +08:00
|
|
|
.filter((job) => !searchTokens.length || automationMatchesSearch(job, searchTokens));
|
2026-06-15 16:35:15 +08:00
|
|
|
}, [filter, jobs, query, sort]);
|
2026-06-14 18:24:59 +08:00
|
|
|
const activeCount = jobs.filter((job) => {
|
|
|
|
|
const key = automationStatusKey(job);
|
|
|
|
|
return key === "active" || key === "running";
|
|
|
|
|
}).length;
|
|
|
|
|
const pausedCount = jobs.filter((job) => automationStatusKey(job) === "paused").length;
|
|
|
|
|
const failedCount = jobs.filter(automationNeedsAttention).length;
|
2026-06-13 20:08:18 +08:00
|
|
|
const systemCount = jobs.filter((job) => job.protected).length;
|
2026-06-15 16:35:15 +08:00
|
|
|
const summaryOptions: Array<{ value: AutomationFilter; label: string; count: number }> = [
|
|
|
|
|
{ value: "all", label: tx("settings.automations.filters.all", "All"), count: jobs.length },
|
|
|
|
|
{ value: "active", label: tx("settings.automations.filters.active", "Active"), count: activeCount },
|
|
|
|
|
{ value: "paused", label: tx("settings.automations.filters.paused", "Paused"), count: pausedCount },
|
|
|
|
|
{ value: "failed", label: tx("settings.automations.filters.failed", "Needs attention"), count: failedCount },
|
|
|
|
|
{ value: "system", label: tx("settings.automations.filters.system", "System"), count: systemCount },
|
2026-06-13 20:08:18 +08:00
|
|
|
];
|
2026-06-14 18:24:59 +08:00
|
|
|
const sortLabel = {
|
|
|
|
|
next: tx("settings.automations.sort.next", "Next run"),
|
|
|
|
|
last: tx("settings.automations.sort.last", "Last run"),
|
|
|
|
|
updated: tx("settings.automations.sort.updated", "Updated"),
|
|
|
|
|
name: tx("settings.automations.sort.name", "Name"),
|
|
|
|
|
} satisfies Record<AutomationSort, string>;
|
2026-06-15 16:35:15 +08:00
|
|
|
const selectedJob = filtered.find((job) => job.id === selectedJobId) ?? filtered[0] ?? null;
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!filtered.length) {
|
|
|
|
|
if (selectedJobId !== null) setSelectedJobId(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!selectedJobId || !filtered.some((job) => job.id === selectedJobId)) {
|
|
|
|
|
setSelectedJobId(filtered[0].id);
|
|
|
|
|
}
|
|
|
|
|
}, [filtered, selectedJobId]);
|
2026-06-13 20:08:18 +08:00
|
|
|
|
|
|
|
|
return (
|
2026-06-15 17:30:06 +08:00
|
|
|
<div className="space-y-4">
|
2026-06-16 00:42:25 +08:00
|
|
|
<section className="rounded-[24px] border border-border/45 bg-card/80 p-3 shadow-[0_22px_70px_rgba(15,23,42,0.055)] backdrop-blur-xl sm:p-4">
|
2026-06-16 00:59:42 +08:00
|
|
|
<div className="mx-auto flex w-full max-w-[56rem] flex-col gap-3">
|
2026-06-16 00:42:25 +08:00
|
|
|
<div className="-mx-1 overflow-x-auto px-1 pb-0.5">
|
2026-06-16 00:59:42 +08:00
|
|
|
<div className="grid w-full min-w-[36rem] grid-cols-5 gap-1 rounded-[16px] bg-muted/50 p-1">
|
2026-06-16 00:42:25 +08:00
|
|
|
{summaryOptions.map((option) => (
|
|
|
|
|
<button
|
|
|
|
|
key={option.value}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => onFilterChange(option.value)}
|
|
|
|
|
className={cn(
|
2026-06-16 00:59:42 +08:00
|
|
|
"inline-flex h-8 min-w-0 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-[12px] px-3 text-[12px] font-medium text-muted-foreground transition-colors",
|
2026-06-16 00:42:25 +08:00
|
|
|
filter === option.value && "bg-background text-foreground shadow-sm",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span>{option.label}</span>
|
|
|
|
|
<span className="min-w-5 shrink-0 rounded-full bg-background/75 px-1.5 py-0.5 text-center text-[11px] tabular-nums text-muted-foreground">
|
|
|
|
|
{option.count}
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-06-13 20:08:18 +08:00
|
|
|
</div>
|
2026-06-15 16:35:15 +08:00
|
|
|
|
2026-06-16 00:42:25 +08:00
|
|
|
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
|
|
|
|
<div className="relative min-w-0">
|
2026-06-15 16:35:15 +08:00
|
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/70" />
|
|
|
|
|
<Input
|
|
|
|
|
value={query}
|
|
|
|
|
onChange={(event) => onQueryChange(event.target.value)}
|
2026-06-16 00:59:42 +08:00
|
|
|
placeholder={tx("settings.automations.search", "name:quiz chat:WeChat cron:09-23")}
|
2026-06-16 00:42:25 +08:00
|
|
|
className="h-9 w-full rounded-[13px] border-border/45 bg-background/85 pl-9 text-[13px] shadow-sm"
|
2026-06-15 16:35:15 +08:00
|
|
|
/>
|
|
|
|
|
</div>
|
2026-06-14 18:24:59 +08:00
|
|
|
<DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-06-16 00:42:25 +08:00
|
|
|
className="inline-flex h-9 min-w-[8.5rem] items-center justify-center gap-1.5 whitespace-nowrap rounded-[13px] border border-border/45 bg-background/85 px-3 text-[12px] font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted/70 hover:text-foreground sm:w-auto"
|
2026-06-14 18:24:59 +08:00
|
|
|
>
|
|
|
|
|
<ArrowUpDown className="h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
<span>{sortLabel[sort]}</span>
|
|
|
|
|
<ChevronDown className="h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
</button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="end" className="min-w-40">
|
|
|
|
|
{(Object.keys(sortLabel) as AutomationSort[]).map((value) => (
|
|
|
|
|
<DropdownMenuItem key={value} onClick={() => onSortChange(value)}>
|
|
|
|
|
<span>{sortLabel[value]}</span>
|
|
|
|
|
{sort === value ? <Check className="ml-auto h-3.5 w-3.5" aria-hidden /> : null}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
))}
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
</div>
|
2026-06-13 20:08:18 +08:00
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{error ? (
|
|
|
|
|
<div className="flex items-center gap-2 rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
|
|
|
|
|
<CircleAlert className="h-4 w-4 shrink-0" aria-hidden />
|
|
|
|
|
<span>{error}</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-06-15 17:30:06 +08:00
|
|
|
{loading && !payload ? (
|
|
|
|
|
<div className="flex h-44 items-center justify-center rounded-[24px] border border-border/45 bg-card/80 text-[13px] text-muted-foreground shadow-[0_22px_70px_rgba(15,23,42,0.055)]">
|
|
|
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
|
|
|
|
|
{tx("settings.automations.loading", "Loading automations...")}
|
|
|
|
|
</div>
|
|
|
|
|
) : filtered.length && selectedJob ? (
|
2026-06-15 22:52:26 +08:00
|
|
|
<section className="grid items-start gap-4 xl:grid-cols-[minmax(16rem,18rem)_minmax(0,1fr)]">
|
2026-06-15 17:30:06 +08:00
|
|
|
<aside className="overflow-hidden rounded-[24px] border border-border/45 bg-card/80 shadow-[0_22px_70px_rgba(15,23,42,0.055)] backdrop-blur-xl">
|
|
|
|
|
<div className="flex items-center justify-between gap-3 border-b border-border/35 px-4 py-3">
|
|
|
|
|
<h2 className="text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
|
|
|
|
|
{tx("settings.automations.queue", "Queue")}
|
|
|
|
|
</h2>
|
|
|
|
|
<span className="rounded-full bg-muted/70 px-2 py-0.5 text-[11px] text-muted-foreground tabular-nums">
|
|
|
|
|
{filtered.length}
|
|
|
|
|
</span>
|
2026-06-15 16:35:15 +08:00
|
|
|
</div>
|
2026-06-15 17:30:06 +08:00
|
|
|
<div
|
|
|
|
|
className="max-h-[26rem] space-y-1 overflow-y-auto p-2 lg:max-h-[calc(100dvh-17rem)]"
|
|
|
|
|
role="list"
|
|
|
|
|
aria-label={tx("settings.automations.queue", "Queue")}
|
|
|
|
|
>
|
|
|
|
|
{filtered.map((job) => (
|
|
|
|
|
<AutomationListItem
|
|
|
|
|
key={job.id}
|
|
|
|
|
job={job}
|
|
|
|
|
locale={locale}
|
|
|
|
|
selected={job.id === selectedJob.id}
|
|
|
|
|
onSelect={() => setSelectedJobId(job.id)}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
2026-06-14 18:24:59 +08:00
|
|
|
</div>
|
2026-06-15 17:30:06 +08:00
|
|
|
</aside>
|
|
|
|
|
<AutomationDetailPanel
|
|
|
|
|
job={selectedJob}
|
|
|
|
|
locale={locale}
|
|
|
|
|
actionKey={actionKey}
|
|
|
|
|
onAction={onAction}
|
|
|
|
|
onRequestEdit={onRequestEdit}
|
|
|
|
|
onRequestDelete={onRequestDelete}
|
|
|
|
|
/>
|
|
|
|
|
</section>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="rounded-[24px] border border-border/45 bg-card/80 px-5 py-12 text-center text-[13px] text-muted-foreground shadow-[0_22px_70px_rgba(15,23,42,0.055)]">
|
|
|
|
|
<div>
|
|
|
|
|
{jobs.length
|
|
|
|
|
? tx("settings.automations.noMatches", "No automations match this view.")
|
|
|
|
|
: tx("settings.automations.empty", "No automations yet.")}
|
2026-06-13 20:08:18 +08:00
|
|
|
</div>
|
2026-06-15 17:30:06 +08:00
|
|
|
{!jobs.length ? (
|
|
|
|
|
<div className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5">
|
|
|
|
|
{tx(
|
|
|
|
|
"settings.automations.emptyHint",
|
|
|
|
|
"Create one from the chat or channel where it should run so nanobot keeps the right context.",
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-06-13 20:08:18 +08:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 16:35:15 +08:00
|
|
|
function AutomationListItem({
|
|
|
|
|
job,
|
|
|
|
|
locale,
|
|
|
|
|
selected,
|
|
|
|
|
onSelect,
|
|
|
|
|
}: {
|
|
|
|
|
job: SessionAutomationJob;
|
|
|
|
|
locale: string;
|
|
|
|
|
selected: boolean;
|
|
|
|
|
onSelect: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
|
|
|
|
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
|
|
|
|
const status = automationStatus(job, tx);
|
|
|
|
|
const origin = automationOriginLabel(job, tx);
|
|
|
|
|
const nextRun = formatAutomationNext(job, tx);
|
|
|
|
|
|
2026-06-13 20:08:18 +08:00
|
|
|
return (
|
2026-06-15 16:35:15 +08:00
|
|
|
<div role="listitem">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-pressed={selected}
|
|
|
|
|
onClick={onSelect}
|
|
|
|
|
className={cn(
|
2026-06-15 17:30:06 +08:00
|
|
|
"group grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-[18px] px-3 py-3.5 text-left transition-colors",
|
2026-06-15 16:35:15 +08:00
|
|
|
selected
|
|
|
|
|
? "bg-background text-foreground shadow-sm ring-1 ring-border/45"
|
2026-06-15 17:30:06 +08:00
|
|
|
: "text-muted-foreground hover:bg-background/55 hover:text-foreground",
|
2026-06-15 16:35:15 +08:00
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span className="min-w-0">
|
2026-06-15 17:30:06 +08:00
|
|
|
<span className="flex min-w-0 items-center gap-2.5">
|
2026-06-15 16:35:15 +08:00
|
|
|
<span
|
|
|
|
|
className={cn("h-2 w-2 shrink-0 rounded-full", automationStatusDotClass(job))}
|
|
|
|
|
aria-hidden
|
|
|
|
|
/>
|
|
|
|
|
<span className="truncate text-[13.5px] font-medium text-foreground">
|
|
|
|
|
{job.name || job.id}
|
|
|
|
|
</span>
|
|
|
|
|
</span>
|
2026-06-15 17:30:06 +08:00
|
|
|
<span className="mt-1.5 line-clamp-2 text-[12px] leading-5 text-muted-foreground">
|
2026-06-15 16:35:15 +08:00
|
|
|
{job.payload.message || tx("settings.automations.systemTask", "System-managed automation")}
|
|
|
|
|
</span>
|
2026-06-15 17:30:06 +08:00
|
|
|
<span className="mt-2.5 flex min-w-0 items-center gap-2 text-[11.5px] leading-none text-muted-foreground">
|
2026-06-15 16:35:15 +08:00
|
|
|
<span className="truncate" title={formatAutomationNextTitle(job, locale, tx)}>
|
|
|
|
|
{nextRun}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="h-1 w-1 shrink-0 rounded-full bg-muted-foreground/35" aria-hidden />
|
|
|
|
|
<span className="truncate">{origin}</span>
|
|
|
|
|
</span>
|
|
|
|
|
</span>
|
|
|
|
|
<span className="flex shrink-0 flex-col items-end gap-2 pt-0.5">
|
|
|
|
|
<span className="rounded-full bg-muted/70 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
|
|
|
|
{status.label}
|
|
|
|
|
</span>
|
|
|
|
|
{job.delete_after_run ? (
|
|
|
|
|
<span className="rounded-full bg-background/80 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.automations.oneShot", "One-time")}
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
<ChevronRight
|
|
|
|
|
className={cn(
|
|
|
|
|
"h-3.5 w-3.5 text-muted-foreground/55 transition-opacity",
|
|
|
|
|
selected ? "opacity-100" : "opacity-0 group-hover:opacity-70",
|
|
|
|
|
)}
|
|
|
|
|
aria-hidden
|
|
|
|
|
/>
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
2026-06-13 20:08:18 +08:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 16:35:15 +08:00
|
|
|
function AutomationDetailPanel({
|
2026-06-13 20:08:18 +08:00
|
|
|
job,
|
|
|
|
|
locale,
|
|
|
|
|
actionKey,
|
|
|
|
|
onAction,
|
2026-06-14 18:24:59 +08:00
|
|
|
onRequestEdit,
|
2026-06-13 20:08:18 +08:00
|
|
|
onRequestDelete,
|
|
|
|
|
}: {
|
|
|
|
|
job: SessionAutomationJob;
|
|
|
|
|
locale: string;
|
|
|
|
|
actionKey: string | null;
|
|
|
|
|
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>;
|
2026-06-14 18:24:59 +08:00
|
|
|
onRequestEdit: (job: SessionAutomationJob) => void;
|
2026-06-13 20:08:18 +08:00
|
|
|
onRequestDelete: (job: SessionAutomationJob) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
|
|
|
|
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
|
|
|
|
const status = automationStatus(job, tx);
|
|
|
|
|
const origin = automationOriginLabel(job, tx);
|
2026-06-14 22:19:28 +08:00
|
|
|
const originHref = job.origin?.channel === "websocket" && job.origin.session_key
|
2026-06-14 00:04:15 +08:00
|
|
|
? `#/chat/${encodeURIComponent(job.origin.session_key)}`
|
|
|
|
|
: null;
|
2026-06-15 16:35:15 +08:00
|
|
|
const created = job.created_at_ms ? fmtDateTime(job.created_at_ms, locale) : null;
|
|
|
|
|
const updated = job.updated_at_ms ? fmtDateTime(job.updated_at_ms, locale) : null;
|
2026-06-15 17:30:06 +08:00
|
|
|
const message = job.payload.message || tx("settings.automations.systemTask", "System-managed automation");
|
|
|
|
|
const schedule = formatAutomationSchedule(job, locale, tx);
|
2026-06-15 20:29:18 +08:00
|
|
|
const [messageExpanded, setMessageExpanded] = useState(false);
|
|
|
|
|
const messageNeedsExpansion = automationMessageNeedsExpansion(message);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setMessageExpanded(false);
|
|
|
|
|
}, [job.id]);
|
2026-06-13 20:08:18 +08:00
|
|
|
|
|
|
|
|
return (
|
2026-06-15 17:30:06 +08:00
|
|
|
<article className="min-w-0 overflow-hidden rounded-[24px] border border-border/45 bg-card/90 shadow-[0_24px_80px_rgba(15,23,42,0.065)] backdrop-blur-xl">
|
2026-06-15 16:35:15 +08:00
|
|
|
<div className="border-b border-border/35 px-4 py-4 sm:px-5">
|
2026-06-15 17:30:06 +08:00
|
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
2026-06-15 16:35:15 +08:00
|
|
|
<div className="min-w-0">
|
|
|
|
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
2026-06-15 17:30:06 +08:00
|
|
|
<h3 className="min-w-0 truncate text-[18px] font-medium leading-7 text-foreground">
|
2026-06-15 16:35:15 +08:00
|
|
|
{job.name || job.id}
|
|
|
|
|
</h3>
|
|
|
|
|
<StatusPill tone={status.tone}>{status.label}</StatusPill>
|
|
|
|
|
{job.delete_after_run ? (
|
|
|
|
|
<StatusPill>{tx("settings.automations.oneShot", "One-time")}</StatusPill>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
2026-06-15 17:30:06 +08:00
|
|
|
<p className="mt-1 truncate text-[12.5px] leading-5 text-muted-foreground">
|
|
|
|
|
{schedule} · {origin}
|
2026-06-15 16:35:15 +08:00
|
|
|
</p>
|
2026-06-13 20:08:18 +08:00
|
|
|
</div>
|
2026-06-15 16:35:15 +08:00
|
|
|
<AutomationActionGroup
|
|
|
|
|
job={job}
|
|
|
|
|
actionKey={actionKey}
|
|
|
|
|
onAction={onAction}
|
|
|
|
|
onRequestEdit={onRequestEdit}
|
|
|
|
|
onRequestDelete={onRequestDelete}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-06-13 20:08:18 +08:00
|
|
|
|
2026-06-15 22:52:26 +08:00
|
|
|
<div className="grid min-w-0">
|
2026-06-15 17:30:06 +08:00
|
|
|
<div className="min-w-0 space-y-4 p-4 sm:p-5">
|
|
|
|
|
<section className="rounded-[20px] border border-border/35 bg-background/60 px-4 py-3.5">
|
|
|
|
|
<div className="text-[11px] font-medium leading-none text-muted-foreground/75">
|
|
|
|
|
{tx("settings.automations.fields.message", "Message")}
|
|
|
|
|
</div>
|
2026-06-15 20:29:18 +08:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"mt-3 whitespace-pre-wrap break-words text-[13px] leading-6 text-foreground/85",
|
|
|
|
|
!messageExpanded && messageNeedsExpansion && "line-clamp-6",
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-06-15 17:30:06 +08:00
|
|
|
{message}
|
|
|
|
|
</div>
|
2026-06-15 20:29:18 +08:00
|
|
|
{messageNeedsExpansion ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="mt-3 inline-flex text-[12px] font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
|
|
|
|
onClick={() => setMessageExpanded((value) => !value)}
|
|
|
|
|
>
|
|
|
|
|
{messageExpanded
|
|
|
|
|
? tx("settings.automations.message.showLess", "Show less")
|
|
|
|
|
: tx("settings.automations.message.showMore", "Show full message")}
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
2026-06-15 17:30:06 +08:00
|
|
|
</section>
|
|
|
|
|
|
2026-06-15 22:52:26 +08:00
|
|
|
<div className="grid gap-3 md:grid-cols-2">
|
2026-06-14 18:24:59 +08:00
|
|
|
<AutomationDetail
|
|
|
|
|
label={tx("settings.automations.labels.next", "Next")}
|
|
|
|
|
title={formatAutomationNextTitle(job, locale, tx)}
|
|
|
|
|
>
|
2026-06-13 20:08:18 +08:00
|
|
|
{formatAutomationNext(job, tx)}
|
|
|
|
|
</AutomationDetail>
|
2026-06-14 18:24:59 +08:00
|
|
|
<AutomationDetail label={tx("settings.automations.labels.origin", "Linked chat")} title={origin}>
|
2026-06-14 00:04:15 +08:00
|
|
|
{originHref ? (
|
2026-06-13 20:08:18 +08:00
|
|
|
<a
|
|
|
|
|
className="inline-flex max-w-full items-center gap-1 text-foreground/80 underline-offset-2 hover:underline"
|
2026-06-14 00:04:15 +08:00
|
|
|
href={originHref}
|
2026-06-13 20:08:18 +08:00
|
|
|
>
|
|
|
|
|
<span className="truncate">{origin}</span>
|
|
|
|
|
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden />
|
|
|
|
|
</a>
|
|
|
|
|
) : (
|
|
|
|
|
origin
|
|
|
|
|
)}
|
|
|
|
|
</AutomationDetail>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{job.state.last_error ? (
|
2026-06-15 17:30:06 +08:00
|
|
|
<div className="rounded-[16px] border border-destructive/20 bg-destructive/8 px-3 py-2 text-[12px] leading-5 text-destructive">
|
2026-06-13 20:08:18 +08:00
|
|
|
{job.state.last_error}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-15 22:52:26 +08:00
|
|
|
<aside className="border-t border-border/35 bg-muted/20 p-4 text-[12px] text-muted-foreground sm:p-5">
|
2026-06-15 17:30:06 +08:00
|
|
|
<div className="space-y-4">
|
|
|
|
|
<AutomationDetail
|
|
|
|
|
label={tx("settings.automations.labels.schedule", "Schedule")}
|
|
|
|
|
title={schedule}
|
|
|
|
|
>
|
|
|
|
|
{schedule}
|
|
|
|
|
</AutomationDetail>
|
|
|
|
|
<div className="rounded-[18px] bg-background/55 p-3">
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{created ? (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="text-[11px] leading-none text-muted-foreground/75">
|
|
|
|
|
{tx("settings.automations.labels.created", "Created")}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-1.5 text-[12.5px] leading-5 text-foreground/80">{created}</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
{updated ? (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="text-[11px] leading-none text-muted-foreground/75">
|
|
|
|
|
{tx("settings.automations.labels.updated", "Updated")}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-1.5 text-[12.5px] leading-5 text-foreground/80">{updated}</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
<div>
|
|
|
|
|
<div className="text-[11px] leading-none text-muted-foreground/75">ID</div>
|
|
|
|
|
<div className="mt-1.5 break-all font-mono text-[11.5px] leading-5 text-foreground/70">
|
|
|
|
|
{job.id}
|
|
|
|
|
</div>
|
2026-06-15 16:35:15 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</aside>
|
2026-06-13 20:08:18 +08:00
|
|
|
</div>
|
|
|
|
|
</article>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 16:35:15 +08:00
|
|
|
function AutomationActionGroup({
|
|
|
|
|
job,
|
|
|
|
|
actionKey,
|
|
|
|
|
onAction,
|
|
|
|
|
onRequestEdit,
|
|
|
|
|
onRequestDelete,
|
|
|
|
|
}: {
|
|
|
|
|
job: SessionAutomationJob;
|
|
|
|
|
actionKey: string | null;
|
|
|
|
|
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>;
|
|
|
|
|
onRequestEdit: (job: SessionAutomationJob) => void;
|
|
|
|
|
onRequestDelete: (job: SessionAutomationJob) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
|
|
|
|
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
|
|
|
|
const canManage = !job.protected;
|
|
|
|
|
const canRun = canManage && job.enabled && !job.state.pending;
|
|
|
|
|
const toggleAction: AutomationAction = job.enabled ? "disable" : "enable";
|
|
|
|
|
const toggleBusy = actionKey === `${toggleAction}:${job.id}`;
|
|
|
|
|
|
|
|
|
|
if (!canManage) {
|
|
|
|
|
return (
|
|
|
|
|
<span className="inline-flex h-9 items-center rounded-full bg-muted px-3 text-[12px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.automations.protected", "Protected")}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex shrink-0 items-center gap-1 rounded-full border border-border/35 bg-background/70 p-1 shadow-sm">
|
|
|
|
|
<AppsActionButton
|
|
|
|
|
ariaLabel={tx("settings.automations.edit", "Edit")}
|
|
|
|
|
disabled={Boolean(actionKey)}
|
|
|
|
|
onClick={() => onRequestEdit(job)}
|
|
|
|
|
>
|
|
|
|
|
<Pencil className="h-4 w-4" aria-hidden />
|
|
|
|
|
</AppsActionButton>
|
|
|
|
|
<AppsActionButton
|
|
|
|
|
ariaLabel={tx("settings.automations.runNow", "Run now")}
|
|
|
|
|
busy={actionKey === `run:${job.id}`}
|
|
|
|
|
disabled={!canRun}
|
|
|
|
|
onClick={() => void onAction("run", job)}
|
|
|
|
|
>
|
|
|
|
|
<PlayCircle className="h-4 w-4" aria-hidden />
|
|
|
|
|
</AppsActionButton>
|
|
|
|
|
<AppsActionButton
|
|
|
|
|
ariaLabel={
|
|
|
|
|
job.enabled
|
|
|
|
|
? tx("settings.automations.pause", "Pause")
|
|
|
|
|
: tx("settings.automations.resume", "Resume")
|
|
|
|
|
}
|
|
|
|
|
busy={toggleBusy}
|
|
|
|
|
onClick={() => void onAction(toggleAction, job)}
|
|
|
|
|
>
|
|
|
|
|
{job.enabled ? (
|
|
|
|
|
<PauseCircle className="h-4 w-4" aria-hidden />
|
|
|
|
|
) : (
|
|
|
|
|
<PlayCircle className="h-4 w-4" aria-hidden />
|
|
|
|
|
)}
|
|
|
|
|
</AppsActionButton>
|
|
|
|
|
<AppsActionButton
|
|
|
|
|
ariaLabel={tx("settings.automations.delete", "Delete")}
|
|
|
|
|
tone="danger"
|
|
|
|
|
disabled={Boolean(actionKey)}
|
|
|
|
|
onClick={() => onRequestDelete(job)}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-4 w-4" aria-hidden />
|
|
|
|
|
</AppsActionButton>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 20:29:18 +08:00
|
|
|
function automationMessageNeedsExpansion(message: string): boolean {
|
|
|
|
|
return message.length > 360 || message.split(/\r?\n/).length > 6;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 18:24:59 +08:00
|
|
|
function AutomationDetail({
|
|
|
|
|
label,
|
|
|
|
|
title,
|
2026-06-15 15:41:35 +08:00
|
|
|
secondary,
|
2026-06-14 18:24:59 +08:00
|
|
|
children,
|
|
|
|
|
}: {
|
|
|
|
|
label: string;
|
|
|
|
|
title?: string;
|
2026-06-15 15:41:35 +08:00
|
|
|
secondary?: ReactNode;
|
2026-06-14 18:24:59 +08:00
|
|
|
children: ReactNode;
|
|
|
|
|
}) {
|
2026-06-13 20:08:18 +08:00
|
|
|
return (
|
2026-06-15 16:35:15 +08:00
|
|
|
<div className="min-w-0 rounded-[18px] bg-muted/32 px-3 py-3">
|
2026-06-15 15:41:35 +08:00
|
|
|
<div className="text-[11px] font-medium leading-none text-muted-foreground/75">
|
2026-06-13 20:08:18 +08:00
|
|
|
{label}
|
|
|
|
|
</div>
|
2026-06-15 15:41:35 +08:00
|
|
|
<div className="mt-1.5 min-w-0">
|
2026-06-15 16:35:15 +08:00
|
|
|
<div className="line-clamp-2 text-[13px] leading-5 text-foreground/85" title={title}>
|
2026-06-15 15:41:35 +08:00
|
|
|
{children}
|
|
|
|
|
</div>
|
|
|
|
|
{secondary ? (
|
|
|
|
|
<div className="mt-0.5 truncate text-[11.5px] leading-4 text-muted-foreground" title={title}>
|
|
|
|
|
{secondary}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-06-14 18:24:59 +08:00
|
|
|
</div>
|
2026-06-13 20:08:18 +08:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 18:24:59 +08:00
|
|
|
type AutomationEveryUnit = "second" | "minute" | "hour" | "day";
|
|
|
|
|
|
|
|
|
|
type AutomationEditDraft = {
|
|
|
|
|
name: string;
|
|
|
|
|
message: string;
|
|
|
|
|
scheduleKind: "at" | "every" | "cron";
|
|
|
|
|
everyValue: string;
|
|
|
|
|
everyUnit: AutomationEveryUnit;
|
|
|
|
|
cronExpr: string;
|
|
|
|
|
tz: string;
|
|
|
|
|
atLocal: string;
|
|
|
|
|
};
|
2026-06-15 00:59:23 +08:00
|
|
|
type AutomationScheduleUpdate = NonNullable<AutomationUpdatePayload["schedule"]>;
|
2026-06-14 18:24:59 +08:00
|
|
|
|
|
|
|
|
const AUTOMATION_EVERY_UNITS: Array<{ value: AutomationEveryUnit; ms: number }> = [
|
|
|
|
|
{ value: "second", ms: 1000 },
|
|
|
|
|
{ value: "minute", ms: 60_000 },
|
|
|
|
|
{ value: "hour", ms: 3_600_000 },
|
|
|
|
|
{ value: "day", ms: 86_400_000 },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function AutomationEditDialog({
|
|
|
|
|
job,
|
|
|
|
|
saving,
|
|
|
|
|
onOpenChange,
|
|
|
|
|
onSave,
|
|
|
|
|
}: {
|
|
|
|
|
job: SessionAutomationJob | null;
|
|
|
|
|
saving: boolean;
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
onSave: (job: SessionAutomationJob, values: AutomationUpdatePayload) => void | Promise<void>;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
|
|
|
|
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
|
|
|
|
const [draft, setDraft] = useState<AutomationEditDraft>(() => automationDraftFromJob(null));
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setDraft(automationDraftFromJob(job));
|
|
|
|
|
}, [job]);
|
|
|
|
|
|
2026-06-15 00:59:23 +08:00
|
|
|
const validation = automationEditDraftError(draft, job, tx);
|
2026-06-14 18:24:59 +08:00
|
|
|
const scheduleOptions = [
|
|
|
|
|
{ value: "every", label: tx("settings.automations.scheduleTypes.every", "Interval") },
|
|
|
|
|
{ value: "cron", label: tx("settings.automations.scheduleTypes.cron", "Cron") },
|
|
|
|
|
{ value: "at", label: tx("settings.automations.scheduleTypes.at", "Once") },
|
|
|
|
|
];
|
|
|
|
|
const unitLabels: Record<AutomationEveryUnit, string> = {
|
|
|
|
|
second: tx("settings.automations.everyUnits.second", "Seconds"),
|
|
|
|
|
minute: tx("settings.automations.everyUnits.minute", "Minutes"),
|
|
|
|
|
hour: tx("settings.automations.everyUnits.hour", "Hours"),
|
|
|
|
|
day: tx("settings.automations.everyUnits.day", "Days"),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const submit = (event: FormEvent<HTMLFormElement>) => {
|
|
|
|
|
event.preventDefault();
|
2026-06-15 00:59:23 +08:00
|
|
|
const payload = automationUpdatePayloadFromDraft(draft, job);
|
2026-06-14 18:24:59 +08:00
|
|
|
if (!job || typeof payload === "string") return;
|
|
|
|
|
void onSave(job, payload);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={Boolean(job)} onOpenChange={onOpenChange}>
|
|
|
|
|
{job ? (
|
2026-06-15 15:22:01 +08:00
|
|
|
<DialogContent
|
|
|
|
|
aria-describedby={undefined}
|
|
|
|
|
className="w-[min(calc(100vw-2rem),34rem)] rounded-[26px]"
|
|
|
|
|
>
|
2026-06-14 18:24:59 +08:00
|
|
|
<form className="space-y-5" onSubmit={submit}>
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>{tx("settings.automations.editTitle", "Edit automation")}</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<label className="block space-y-1.5">
|
|
|
|
|
<span className="text-[12px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.automations.fields.name", "Name")}
|
|
|
|
|
</span>
|
|
|
|
|
<Input
|
|
|
|
|
value={draft.name}
|
|
|
|
|
onChange={(event) => setDraft((prev) => ({ ...prev, name: event.target.value }))}
|
|
|
|
|
className="h-10 rounded-[12px]"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
<label className="block space-y-1.5">
|
|
|
|
|
<span className="text-[12px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.automations.fields.message", "Message")}
|
|
|
|
|
</span>
|
|
|
|
|
<Textarea
|
|
|
|
|
value={draft.message}
|
|
|
|
|
onChange={(event) => setDraft((prev) => ({ ...prev, message: event.target.value }))}
|
2026-06-15 15:22:01 +08:00
|
|
|
className="min-h-[160px] resize-none rounded-[12px] text-[13px] leading-5"
|
2026-06-14 18:24:59 +08:00
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<span className="text-[12px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.automations.fields.scheduleType", "Schedule type")}
|
|
|
|
|
</span>
|
|
|
|
|
<SegmentedControl
|
|
|
|
|
value={draft.scheduleKind}
|
|
|
|
|
options={scheduleOptions}
|
|
|
|
|
onChange={(value) =>
|
|
|
|
|
setDraft((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
scheduleKind: value as AutomationEditDraft["scheduleKind"],
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{draft.scheduleKind === "every" ? (
|
|
|
|
|
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_10rem]">
|
|
|
|
|
<label className="block space-y-1.5">
|
|
|
|
|
<span className="text-[12px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.automations.fields.every", "Every")}
|
|
|
|
|
</span>
|
|
|
|
|
<Input
|
|
|
|
|
type="number"
|
|
|
|
|
min={1}
|
|
|
|
|
step={1}
|
|
|
|
|
value={draft.everyValue}
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
setDraft((prev) => ({ ...prev, everyValue: event.target.value }))
|
|
|
|
|
}
|
|
|
|
|
className="h-10 rounded-[12px]"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="block space-y-1.5">
|
|
|
|
|
<span className="text-[12px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.automations.fields.unit", "Unit")}
|
|
|
|
|
</span>
|
|
|
|
|
<select
|
|
|
|
|
value={draft.everyUnit}
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
setDraft((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
everyUnit: event.target.value as AutomationEveryUnit,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
className="h-10 w-full rounded-[12px] border border-input bg-background px-3 text-[13px] text-foreground shadow-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
|
|
|
|
|
>
|
|
|
|
|
{AUTOMATION_EVERY_UNITS.map((unit) => (
|
|
|
|
|
<option key={unit.value} value={unit.value}>
|
|
|
|
|
{unitLabels[unit.value]}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{draft.scheduleKind === "cron" ? (
|
|
|
|
|
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_12rem]">
|
|
|
|
|
<label className="block space-y-1.5">
|
|
|
|
|
<span className="text-[12px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.automations.fields.cronExpression", "Cron expression")}
|
|
|
|
|
</span>
|
|
|
|
|
<Input
|
|
|
|
|
value={draft.cronExpr}
|
|
|
|
|
onChange={(event) => setDraft((prev) => ({ ...prev, cronExpr: event.target.value }))}
|
|
|
|
|
placeholder="0 9 * * *"
|
|
|
|
|
className="h-10 rounded-[12px] font-mono text-[13px]"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="block space-y-1.5">
|
|
|
|
|
<span className="text-[12px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.automations.fields.timezone", "Timezone")}
|
|
|
|
|
</span>
|
|
|
|
|
<Input
|
|
|
|
|
value={draft.tz}
|
|
|
|
|
onChange={(event) => setDraft((prev) => ({ ...prev, tz: event.target.value }))}
|
|
|
|
|
placeholder="Asia/Shanghai"
|
|
|
|
|
className="h-10 rounded-[12px] text-[13px]"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{draft.scheduleKind === "at" ? (
|
|
|
|
|
<label className="block space-y-1.5">
|
|
|
|
|
<span className="text-[12px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.automations.fields.runAt", "Run at")}
|
|
|
|
|
</span>
|
|
|
|
|
<Input
|
|
|
|
|
type="datetime-local"
|
|
|
|
|
value={draft.atLocal}
|
|
|
|
|
onChange={(event) => setDraft((prev) => ({ ...prev, atLocal: event.target.value }))}
|
|
|
|
|
className="h-10 rounded-[12px]"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{validation ? (
|
|
|
|
|
<div className="rounded-[12px] bg-destructive/8 px-3 py-2 text-[12px] text-destructive">
|
|
|
|
|
{validation}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={() => onOpenChange(false)}
|
|
|
|
|
disabled={saving}
|
|
|
|
|
className="rounded-full"
|
|
|
|
|
>
|
|
|
|
|
{tx("settings.automations.cancel", "Cancel")}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button type="submit" disabled={Boolean(validation) || saving} className="rounded-full">
|
|
|
|
|
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
|
|
|
|
|
{tx("settings.automations.save", "Save")}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</form>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
) : null}
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 20:08:18 +08:00
|
|
|
function AutomationDeleteDialog({
|
|
|
|
|
job,
|
|
|
|
|
deleting,
|
|
|
|
|
onOpenChange,
|
|
|
|
|
onConfirm,
|
|
|
|
|
}: {
|
|
|
|
|
job: SessionAutomationJob | null;
|
|
|
|
|
deleting: boolean;
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
onConfirm: (job: SessionAutomationJob) => void | Promise<void>;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
|
|
|
|
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={Boolean(job)} onOpenChange={onOpenChange}>
|
|
|
|
|
<DialogContent className="w-[min(calc(100vw-2rem),26rem)] rounded-[26px]">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>{tx("settings.automations.deleteTitle", "Delete automation")}</DialogTitle>
|
|
|
|
|
<DialogDescription>
|
|
|
|
|
{tx(
|
|
|
|
|
"settings.automations.deleteDescription",
|
|
|
|
|
"This removes {{name}} from the cron store. Past chat messages stay in the session.",
|
|
|
|
|
{ name: job?.name || job?.id || "" },
|
|
|
|
|
)}
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={() => onOpenChange(false)}
|
|
|
|
|
disabled={deleting}
|
|
|
|
|
className="rounded-full"
|
|
|
|
|
>
|
|
|
|
|
{tx("settings.automations.cancel", "Cancel")}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="destructive"
|
|
|
|
|
onClick={() => job && void onConfirm(job)}
|
|
|
|
|
disabled={!job || deleting}
|
|
|
|
|
className="rounded-full"
|
|
|
|
|
>
|
|
|
|
|
{deleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
|
|
|
|
|
{tx("settings.automations.delete", "Delete")}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 18:24:59 +08:00
|
|
|
function automationNeedsAttention(job: SessionAutomationJob): boolean {
|
2026-06-16 00:26:06 +08:00
|
|
|
return job.state.last_status === "error";
|
2026-06-14 18:24:59 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function automationStatusKey(
|
|
|
|
|
job: SessionAutomationJob,
|
2026-06-16 00:26:06 +08:00
|
|
|
): "active" | "running" | "paused" | "failed" | "system" | "completed" | "idle" {
|
2026-06-14 18:24:59 +08:00
|
|
|
if (job.protected) return "system";
|
|
|
|
|
if (job.state.pending) return "running";
|
|
|
|
|
if (!job.enabled) return "paused";
|
|
|
|
|
if (job.state.last_status === "error") return "failed";
|
|
|
|
|
if (job.delete_after_run && !job.state.next_run_at_ms && job.state.last_status === "ok") {
|
|
|
|
|
return "completed";
|
|
|
|
|
}
|
|
|
|
|
if (!job.state.next_run_at_ms) return "idle";
|
|
|
|
|
return "active";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sortAutomationJobs(jobs: SessionAutomationJob[], sort: AutomationSort): SessionAutomationJob[] {
|
|
|
|
|
const byName = (left: SessionAutomationJob, right: SessionAutomationJob) =>
|
|
|
|
|
(left.name || left.id).localeCompare(right.name || right.id);
|
|
|
|
|
return [...jobs].sort((left, right) => {
|
|
|
|
|
if (sort === "name") return byName(left, right);
|
|
|
|
|
if (sort === "last") {
|
|
|
|
|
return (right.state.last_run_at_ms ?? 0) - (left.state.last_run_at_ms ?? 0) || byName(left, right);
|
|
|
|
|
}
|
|
|
|
|
if (sort === "updated") {
|
|
|
|
|
return (right.updated_at_ms ?? 0) - (left.updated_at_ms ?? 0) || byName(left, right);
|
|
|
|
|
}
|
|
|
|
|
const leftNext = left.state.next_run_at_ms ?? Number.MAX_SAFE_INTEGER;
|
|
|
|
|
const rightNext = right.state.next_run_at_ms ?? Number.MAX_SAFE_INTEGER;
|
|
|
|
|
return leftNext - rightNext || byName(left, right);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function automationDraftFromJob(job: SessionAutomationJob | null): AutomationEditDraft {
|
|
|
|
|
const every = automationIntervalDraft(job?.schedule.every_ms ?? 3_600_000);
|
|
|
|
|
const scheduleKind = job?.schedule.kind === "at" || job?.schedule.kind === "cron"
|
|
|
|
|
? job.schedule.kind
|
|
|
|
|
: "every";
|
|
|
|
|
return {
|
|
|
|
|
name: job?.name ?? "",
|
|
|
|
|
message: job?.payload.message ?? "",
|
|
|
|
|
scheduleKind,
|
|
|
|
|
everyValue: every.value,
|
|
|
|
|
everyUnit: every.unit,
|
|
|
|
|
cronExpr: job?.schedule.expr ?? "0 9 * * *",
|
|
|
|
|
tz: job?.schedule.tz ?? "",
|
|
|
|
|
atLocal: formatLocalDateTimeInput(job?.schedule.at_ms ?? Date.now() + 3_600_000),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function automationIntervalDraft(ms: number): { value: string; unit: AutomationEveryUnit } {
|
|
|
|
|
for (const unit of [...AUTOMATION_EVERY_UNITS].reverse()) {
|
|
|
|
|
if (ms >= unit.ms && ms % unit.ms === 0) {
|
|
|
|
|
return { value: String(ms / unit.ms), unit: unit.value };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { value: String(Math.max(1, Math.round(ms / 60_000))), unit: "minute" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatLocalDateTimeInput(ms: number): string {
|
|
|
|
|
const date = new Date(ms);
|
|
|
|
|
if (!Number.isFinite(date.getTime())) return "";
|
|
|
|
|
const local = new Date(ms - date.getTimezoneOffset() * 60_000);
|
|
|
|
|
return local.toISOString().slice(0, 16);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function automationEditDraftError(
|
|
|
|
|
draft: AutomationEditDraft,
|
2026-06-15 00:59:23 +08:00
|
|
|
job: SessionAutomationJob | null,
|
2026-06-14 18:24:59 +08:00
|
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
|
|
|
): string | null {
|
|
|
|
|
if (!draft.name.trim()) return tx("settings.automations.validation.nameRequired", "Name is required.");
|
|
|
|
|
if (!draft.message.trim()) {
|
|
|
|
|
return tx("settings.automations.validation.messageRequired", "Message is required.");
|
|
|
|
|
}
|
|
|
|
|
if (draft.scheduleKind === "every") {
|
|
|
|
|
const value = Number(draft.everyValue);
|
|
|
|
|
if (!Number.isInteger(value) || value <= 0) {
|
|
|
|
|
return tx("settings.automations.validation.intervalRequired", "Interval must be a positive number.");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (draft.scheduleKind === "cron" && !draft.cronExpr.trim()) {
|
|
|
|
|
return tx("settings.automations.validation.cronRequired", "Cron expression is required.");
|
|
|
|
|
}
|
|
|
|
|
if (draft.scheduleKind === "at") {
|
|
|
|
|
const atMs = new Date(draft.atLocal).getTime();
|
|
|
|
|
if (!Number.isFinite(atMs)) {
|
|
|
|
|
return tx("settings.automations.validation.timeRequired", "Run time is required.");
|
|
|
|
|
}
|
2026-06-15 00:59:23 +08:00
|
|
|
if (atMs <= Date.now() && automationScheduleChanged(draft, job)) {
|
2026-06-14 18:24:59 +08:00
|
|
|
return tx("settings.automations.validation.futureRequired", "Run time must be in the future.");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 00:59:23 +08:00
|
|
|
function automationUpdatePayloadFromDraft(
|
|
|
|
|
draft: AutomationEditDraft,
|
|
|
|
|
job: SessionAutomationJob | null,
|
|
|
|
|
): AutomationUpdatePayload | string {
|
2026-06-14 18:24:59 +08:00
|
|
|
const name = draft.name.trim();
|
|
|
|
|
const message = draft.message.trim();
|
|
|
|
|
if (!name || !message) return "invalid";
|
|
|
|
|
const payload: AutomationUpdatePayload = { name, message };
|
2026-06-15 00:59:23 +08:00
|
|
|
const schedule = automationSchedulePayloadFromDraft(draft);
|
|
|
|
|
if (typeof schedule === "string") return schedule;
|
|
|
|
|
if (automationScheduleChanged(draft, job, schedule)) {
|
|
|
|
|
payload.schedule = schedule;
|
|
|
|
|
}
|
|
|
|
|
return payload;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function automationSchedulePayloadFromDraft(draft: AutomationEditDraft): AutomationScheduleUpdate | string {
|
2026-06-14 18:24:59 +08:00
|
|
|
if (draft.scheduleKind === "every") {
|
|
|
|
|
const unit = AUTOMATION_EVERY_UNITS.find((candidate) => candidate.value === draft.everyUnit);
|
|
|
|
|
const value = Number(draft.everyValue);
|
|
|
|
|
if (!unit || !Number.isInteger(value) || value <= 0) return "invalid";
|
2026-06-15 00:59:23 +08:00
|
|
|
return { kind: "every", every_ms: value * unit.ms };
|
2026-06-14 18:24:59 +08:00
|
|
|
} else if (draft.scheduleKind === "cron") {
|
|
|
|
|
const expr = draft.cronExpr.trim();
|
|
|
|
|
if (!expr) return "invalid";
|
2026-06-15 00:59:23 +08:00
|
|
|
return { kind: "cron", expr, ...(draft.tz.trim() ? { tz: draft.tz.trim() } : {}) };
|
2026-06-14 18:24:59 +08:00
|
|
|
} else {
|
|
|
|
|
const atMs = new Date(draft.atLocal).getTime();
|
|
|
|
|
if (!Number.isFinite(atMs)) return "invalid";
|
2026-06-15 00:59:23 +08:00
|
|
|
return { kind: "at", at_ms: atMs };
|
2026-06-14 18:24:59 +08:00
|
|
|
}
|
2026-06-15 00:59:23 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function automationScheduleChanged(
|
|
|
|
|
draft: AutomationEditDraft,
|
|
|
|
|
job: SessionAutomationJob | null,
|
|
|
|
|
schedule: AutomationScheduleUpdate | string = automationSchedulePayloadFromDraft(draft),
|
|
|
|
|
): boolean {
|
|
|
|
|
if (!job || typeof schedule === "string") return true;
|
|
|
|
|
if (schedule.kind !== job.schedule.kind) return true;
|
|
|
|
|
if (schedule.kind === "every") return schedule.every_ms !== job.schedule.every_ms;
|
|
|
|
|
if (schedule.kind === "cron") {
|
|
|
|
|
return schedule.expr !== (job.schedule.expr ?? "") || (schedule.tz ?? null) !== (job.schedule.tz ?? null);
|
|
|
|
|
}
|
|
|
|
|
return draft.atLocal !== formatLocalDateTimeInput(job.schedule.at_ms ?? NaN);
|
2026-06-14 18:24:59 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-16 00:59:42 +08:00
|
|
|
type AutomationSearchField = "id" | "name" | "message" | "chat" | "cron" | "schedule" | "status";
|
|
|
|
|
|
|
|
|
|
interface AutomationSearchToken {
|
|
|
|
|
field: AutomationSearchField | null;
|
|
|
|
|
value: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const AUTOMATION_SEARCH_FIELDS = new Set<AutomationSearchField>([
|
|
|
|
|
"id",
|
|
|
|
|
"name",
|
|
|
|
|
"message",
|
|
|
|
|
"chat",
|
|
|
|
|
"cron",
|
|
|
|
|
"schedule",
|
|
|
|
|
"status",
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const AUTOMATION_CHANNEL_LABELS: Record<string, string> = {
|
|
|
|
|
api: "API",
|
|
|
|
|
cli: "CLI",
|
|
|
|
|
dingtalk: "DingTalk",
|
|
|
|
|
discord: "Discord",
|
|
|
|
|
email: "Email",
|
|
|
|
|
feishu: "Feishu",
|
|
|
|
|
matrix: "Matrix",
|
|
|
|
|
msteams: "Microsoft Teams",
|
|
|
|
|
qq: "QQ",
|
|
|
|
|
slack: "Slack",
|
|
|
|
|
telegram: "Telegram",
|
|
|
|
|
wechat: "WeChat",
|
|
|
|
|
wecom: "WeCom",
|
|
|
|
|
weixin: "WeChat",
|
|
|
|
|
whatsapp: "WhatsApp",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function parseAutomationSearchQuery(query: string): AutomationSearchToken[] {
|
|
|
|
|
return (query.match(/[^\s:]+:"[^"]+"|"[^"]+"|\S+/g) ?? [])
|
|
|
|
|
.map((rawPart): AutomationSearchToken | null => {
|
|
|
|
|
const part = trimAutomationSearchValue(rawPart);
|
|
|
|
|
if (!part) return null;
|
|
|
|
|
const fieldMatch = part.match(/^([A-Za-z]+):(.*)$/);
|
|
|
|
|
if (!fieldMatch) return { field: null, value: part.toLowerCase() };
|
|
|
|
|
const field = fieldMatch[1].toLowerCase() as AutomationSearchField;
|
|
|
|
|
const value = trimAutomationSearchValue(fieldMatch[2]).toLowerCase();
|
|
|
|
|
if (!value) return null;
|
|
|
|
|
return AUTOMATION_SEARCH_FIELDS.has(field)
|
|
|
|
|
? { field, value }
|
|
|
|
|
: { field: null, value: part.toLowerCase() };
|
|
|
|
|
})
|
|
|
|
|
.filter((token): token is AutomationSearchToken => Boolean(token));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function trimAutomationSearchValue(value: string): string {
|
|
|
|
|
return value.trim().replace(/^"|"$/g, "").trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function automationMatchesSearch(job: SessionAutomationJob, tokens: AutomationSearchToken[]): boolean {
|
|
|
|
|
return tokens.every((token) => automationSearchText(job, token.field).includes(token.value));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function automationSearchText(job: SessionAutomationJob, field: AutomationSearchField | null = null): string {
|
|
|
|
|
return automationSearchParts(job, field)
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join(" ")
|
|
|
|
|
.toLowerCase();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function automationSearchParts(
|
|
|
|
|
job: SessionAutomationJob,
|
|
|
|
|
field: AutomationSearchField | null,
|
|
|
|
|
): Array<string | number | null | undefined> {
|
|
|
|
|
const originParts = automationOriginSearchParts(job);
|
|
|
|
|
const scheduleParts = automationScheduleSearchParts(job);
|
|
|
|
|
if (field === "id") return [job.id];
|
|
|
|
|
if (field === "name") return [job.name, job.id];
|
|
|
|
|
if (field === "message") return [job.payload.message];
|
|
|
|
|
if (field === "chat") return originParts;
|
|
|
|
|
if (field === "cron" || field === "schedule") return scheduleParts;
|
|
|
|
|
if (field === "status") return [automationStatusKey(job), job.enabled ? "enabled" : "disabled"];
|
2026-06-13 20:08:18 +08:00
|
|
|
return [
|
|
|
|
|
job.id,
|
|
|
|
|
job.name,
|
|
|
|
|
job.payload.message,
|
2026-06-16 00:59:42 +08:00
|
|
|
...scheduleParts,
|
|
|
|
|
automationStatusKey(job),
|
|
|
|
|
...originParts,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function automationOriginSearchParts(job: SessionAutomationJob): Array<string | null | undefined> {
|
|
|
|
|
const origin = job.origin;
|
|
|
|
|
if (!origin) return [];
|
|
|
|
|
const channel = origin.channel.trim().toLowerCase();
|
|
|
|
|
return [
|
|
|
|
|
origin.session_key,
|
|
|
|
|
origin.title,
|
|
|
|
|
origin.preview,
|
|
|
|
|
origin.channel,
|
|
|
|
|
AUTOMATION_CHANNEL_LABELS[channel],
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function automationScheduleSearchParts(job: SessionAutomationJob): Array<string | number | null | undefined> {
|
|
|
|
|
const schedule = job.schedule;
|
|
|
|
|
const parts: Array<string | number | null | undefined> = [
|
|
|
|
|
schedule.kind,
|
|
|
|
|
schedule.expr,
|
|
|
|
|
schedule.tz,
|
|
|
|
|
schedule.every_ms,
|
|
|
|
|
schedule.at_ms,
|
|
|
|
|
];
|
|
|
|
|
if (schedule.kind === "cron" && schedule.expr) {
|
|
|
|
|
parts.push(...automationCronSearchParts(schedule.expr));
|
|
|
|
|
}
|
|
|
|
|
return parts;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function automationCronSearchParts(expr: string): string[] {
|
|
|
|
|
const parts = expr.trim().split(/\s+/);
|
|
|
|
|
if (parts.length !== 5) return [];
|
|
|
|
|
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
|
|
|
|
const everyDay = dayOfMonth === "*" && month === "*" && dayOfWeek === "*";
|
|
|
|
|
const numericMinute = cronNumericToken(minute, 59);
|
|
|
|
|
const numericHour = cronNumericToken(hour, 23);
|
|
|
|
|
if (numericMinute === null) return [];
|
|
|
|
|
const paddedMinute = String(numericMinute).padStart(2, "0");
|
|
|
|
|
|
|
|
|
|
if (numericHour !== null) {
|
|
|
|
|
const time = `${String(numericHour).padStart(2, "0")}:${paddedMinute}`;
|
|
|
|
|
return [time, `:${paddedMinute}`];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (everyDay && hour === "*") {
|
|
|
|
|
return [`:${paddedMinute}`, `hourly at :${paddedMinute}`];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const range = /^(\d{1,2})-(\d{1,2})$/.exec(hour);
|
|
|
|
|
if (!everyDay || !range) return [];
|
|
|
|
|
const start = Number(range[1]);
|
|
|
|
|
const end = Number(range[2]);
|
|
|
|
|
if (start > 23 || end > 23) return [];
|
|
|
|
|
const paddedRange = `${String(start).padStart(2, "0")}-${String(end).padStart(2, "0")}`;
|
|
|
|
|
const rawRange = `${start}-${end}`;
|
|
|
|
|
return [
|
|
|
|
|
paddedRange,
|
|
|
|
|
rawRange,
|
|
|
|
|
`:${paddedMinute}`,
|
|
|
|
|
`${paddedRange} at :${paddedMinute}`,
|
|
|
|
|
`hourly ${paddedRange} at :${paddedMinute}`,
|
|
|
|
|
];
|
2026-06-13 20:08:18 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function automationMatchesFilter(job: SessionAutomationJob, filter: AutomationFilter): boolean {
|
2026-06-14 18:24:59 +08:00
|
|
|
const status = automationStatusKey(job);
|
|
|
|
|
if (filter === "active") return status === "active" || status === "running";
|
|
|
|
|
if (filter === "paused") return status === "paused";
|
|
|
|
|
if (filter === "failed") return automationNeedsAttention(job);
|
2026-06-13 20:08:18 +08:00
|
|
|
if (filter === "system") return Boolean(job.protected);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function automationStatus(
|
|
|
|
|
job: SessionAutomationJob,
|
|
|
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
|
|
|
): { label: string; tone: "neutral" | "success" | "warning" } {
|
2026-06-14 18:24:59 +08:00
|
|
|
const status = automationStatusKey(job);
|
|
|
|
|
if (status === "system") return { label: tx("settings.automations.status.system", "System"), tone: "neutral" };
|
|
|
|
|
if (status === "running") {
|
|
|
|
|
return { label: tx("settings.automations.status.running", "Running now"), tone: "warning" };
|
|
|
|
|
}
|
|
|
|
|
if (status === "paused") return { label: tx("settings.automations.status.paused", "Paused"), tone: "neutral" };
|
|
|
|
|
if (status === "failed") {
|
2026-06-13 20:08:18 +08:00
|
|
|
return { label: tx("settings.automations.status.failed", "Failed"), tone: "warning" };
|
|
|
|
|
}
|
2026-06-14 18:24:59 +08:00
|
|
|
if (status === "completed") {
|
|
|
|
|
return { label: tx("settings.automations.status.completed", "Completed"), tone: "neutral" };
|
|
|
|
|
}
|
|
|
|
|
if (status === "idle") {
|
|
|
|
|
return { label: tx("settings.automations.status.noSchedule", "No schedule"), tone: "neutral" };
|
|
|
|
|
}
|
2026-06-13 20:08:18 +08:00
|
|
|
return { label: tx("settings.automations.status.active", "Active"), tone: "success" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function automationOriginLabel(
|
|
|
|
|
job: SessionAutomationJob,
|
|
|
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
|
|
|
): string {
|
|
|
|
|
if (job.protected) return tx("settings.automations.origin.system", "System");
|
|
|
|
|
const origin = job.origin;
|
2026-06-14 00:04:15 +08:00
|
|
|
if (!origin) return tx("settings.automations.origin.unknown", "No linked chat");
|
2026-06-14 00:32:53 +08:00
|
|
|
if (origin.channel !== "websocket") return automationChannelLabel(origin.channel, tx);
|
2026-06-14 22:19:28 +08:00
|
|
|
return origin.title || origin.preview || origin.session_key || automationChannelLabel(origin.channel, tx);
|
2026-06-13 20:08:18 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-14 00:32:53 +08:00
|
|
|
function automationChannelLabel(
|
|
|
|
|
channel: string,
|
|
|
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
|
|
|
): string {
|
|
|
|
|
const key = channel.trim().toLowerCase();
|
2026-06-16 00:59:42 +08:00
|
|
|
return AUTOMATION_CHANNEL_LABELS[key]
|
|
|
|
|
? tx(`settings.automations.channels.${key}`, AUTOMATION_CHANNEL_LABELS[key])
|
2026-06-14 00:32:53 +08:00
|
|
|
: channel;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 20:08:18 +08:00
|
|
|
function formatAutomationSchedule(
|
|
|
|
|
job: SessionAutomationJob,
|
|
|
|
|
locale: string,
|
|
|
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
|
|
|
): string {
|
|
|
|
|
if (job.schedule.kind === "at" && job.schedule.at_ms) {
|
|
|
|
|
return tx("settings.automations.schedule.at", "At {{time}}", {
|
|
|
|
|
time: fmtDateTime(job.schedule.at_ms, locale),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (job.schedule.kind === "every" && job.schedule.every_ms) {
|
|
|
|
|
return tx("settings.automations.schedule.every", "Every {{duration}}", {
|
2026-06-13 21:30:31 +08:00
|
|
|
duration: formatAutomationInterval(job.schedule.every_ms, locale),
|
2026-06-13 20:08:18 +08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (job.schedule.kind === "cron" && job.schedule.expr) {
|
2026-06-14 18:24:59 +08:00
|
|
|
const summary = formatCronScheduleSummary(job.schedule.expr, tx);
|
|
|
|
|
if (summary) {
|
|
|
|
|
return job.schedule.tz
|
|
|
|
|
? tx("settings.automations.schedule.withTz", "{{summary}} · {{tz}}", {
|
|
|
|
|
summary,
|
|
|
|
|
tz: job.schedule.tz,
|
|
|
|
|
})
|
|
|
|
|
: summary;
|
|
|
|
|
}
|
2026-06-13 20:08:18 +08:00
|
|
|
return job.schedule.tz
|
|
|
|
|
? tx("settings.automations.schedule.cronWithTz", "Cron {{expr}} · {{tz}}", {
|
|
|
|
|
expr: job.schedule.expr,
|
|
|
|
|
tz: job.schedule.tz,
|
|
|
|
|
})
|
|
|
|
|
: tx("settings.automations.schedule.cron", "Cron {{expr}}", { expr: job.schedule.expr });
|
|
|
|
|
}
|
|
|
|
|
return tx("settings.automations.schedule.custom", "Custom schedule");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 18:24:59 +08:00
|
|
|
function formatCronScheduleSummary(
|
|
|
|
|
expr: string,
|
|
|
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
|
|
|
): string | null {
|
|
|
|
|
const parts = expr.trim().split(/\s+/);
|
|
|
|
|
if (parts.length !== 5) return null;
|
|
|
|
|
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
|
|
|
|
const numericMinute = cronNumericToken(minute, 59);
|
|
|
|
|
const numericHour = cronNumericToken(hour, 23);
|
|
|
|
|
const everyDay = dayOfMonth === "*" && month === "*" && dayOfWeek === "*";
|
|
|
|
|
const workdays = dayOfMonth === "*" && month === "*" && ["1-5", "MON-FRI", "mon-fri"].includes(dayOfWeek);
|
|
|
|
|
|
|
|
|
|
if (numericMinute !== null && numericHour !== null) {
|
|
|
|
|
const time = `${String(numericHour).padStart(2, "0")}:${String(numericMinute).padStart(2, "0")}`;
|
|
|
|
|
if (everyDay) return tx("settings.automations.schedule.dailyAt", "Daily at {{time}}", { time });
|
|
|
|
|
if (workdays) return tx("settings.automations.schedule.weekdaysAt", "Weekdays at {{time}}", { time });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (everyDay && numericMinute !== null && hour === "*") {
|
|
|
|
|
return tx("settings.automations.schedule.hourlyAt", "Hourly at :{{minute}}", {
|
|
|
|
|
minute: String(numericMinute).padStart(2, "0"),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const range = /^(\d{1,2})-(\d{1,2})$/.exec(hour);
|
|
|
|
|
if (everyDay && numericMinute !== null && range) {
|
|
|
|
|
const start = Number(range[1]);
|
|
|
|
|
const end = Number(range[2]);
|
|
|
|
|
if (start > 23 || end > 23) return null;
|
|
|
|
|
return tx("settings.automations.schedule.hourlyWindow", "Hourly {{start}}-{{end}} at :{{minute}}", {
|
|
|
|
|
start: String(start).padStart(2, "0"),
|
|
|
|
|
end: String(end).padStart(2, "0"),
|
|
|
|
|
minute: String(numericMinute).padStart(2, "0"),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cronNumericToken(value: string, max: number): number | null {
|
|
|
|
|
if (!/^\d{1,2}$/.test(value)) return null;
|
|
|
|
|
const parsed = Number(value);
|
|
|
|
|
return parsed <= max ? parsed : null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 20:08:18 +08:00
|
|
|
function formatAutomationNext(
|
|
|
|
|
job: SessionAutomationJob,
|
|
|
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
|
|
|
): string {
|
|
|
|
|
if (!job.enabled) return tx("settings.automations.next.paused", "Paused");
|
2026-06-14 18:24:59 +08:00
|
|
|
if (job.state.pending) return tx("settings.automations.next.pending", "Running now");
|
2026-06-13 20:08:18 +08:00
|
|
|
if (!job.state.next_run_at_ms) return tx("settings.automations.next.none", "No next run");
|
|
|
|
|
return relativeTime(job.state.next_run_at_ms);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 18:24:59 +08:00
|
|
|
function formatAutomationNextTitle(
|
|
|
|
|
job: SessionAutomationJob,
|
|
|
|
|
locale: string,
|
|
|
|
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
|
|
|
|
): string {
|
|
|
|
|
if (!job.state.next_run_at_ms) return formatAutomationNext(job, tx);
|
|
|
|
|
return fmtDateTime(job.state.next_run_at_ms, locale);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 16:35:15 +08:00
|
|
|
function automationStatusDotClass(job: SessionAutomationJob): string {
|
|
|
|
|
const status = automationStatusKey(job);
|
|
|
|
|
if (status === "active" || status === "running") return "bg-emerald-500";
|
2026-06-16 00:26:06 +08:00
|
|
|
if (status === "failed") return "bg-amber-500";
|
2026-06-15 16:35:15 +08:00
|
|
|
if (status === "system") return "bg-blue-500";
|
|
|
|
|
return "bg-muted-foreground/45";
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 21:30:31 +08:00
|
|
|
function formatAutomationUnit(
|
|
|
|
|
value: number,
|
|
|
|
|
unit: Intl.NumberFormatOptions["unit"],
|
|
|
|
|
locale: string,
|
|
|
|
|
maximumFractionDigits = 0,
|
|
|
|
|
): string {
|
|
|
|
|
return new Intl.NumberFormat(locale, {
|
|
|
|
|
style: "unit",
|
|
|
|
|
unit,
|
|
|
|
|
unitDisplay: "long",
|
|
|
|
|
maximumFractionDigits,
|
|
|
|
|
}).format(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatAutomationInterval(ms: number, locale: string): string {
|
|
|
|
|
const units: Array<[Intl.NumberFormatOptions["unit"], number]> = [
|
|
|
|
|
["day", 86_400_000],
|
|
|
|
|
["hour", 3_600_000],
|
|
|
|
|
["minute", 60_000],
|
|
|
|
|
["second", 1000],
|
|
|
|
|
];
|
|
|
|
|
for (const [unit, size] of units) {
|
|
|
|
|
if (ms >= size && ms % size === 0) return formatAutomationUnit(ms / size, unit, locale);
|
|
|
|
|
}
|
|
|
|
|
const fallbackUnit = ms < 60_000 ? "second" : "minute";
|
|
|
|
|
const fallbackSize = fallbackUnit === "second" ? 1000 : 60_000;
|
|
|
|
|
return formatAutomationUnit(ms / fallbackSize, fallbackUnit, locale, 1);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 20:07:02 +08:00
|
|
|
function AppsCatalogSettings({
|
|
|
|
|
cliApps,
|
|
|
|
|
mcpPresets,
|
|
|
|
|
cliAppsLoading,
|
|
|
|
|
mcpPresetsLoading,
|
2026-05-22 22:25:12 +08:00
|
|
|
query,
|
2026-05-25 20:07:02 +08:00
|
|
|
filter,
|
|
|
|
|
cliActionKey,
|
|
|
|
|
mcpActionKey,
|
|
|
|
|
cliMessage,
|
|
|
|
|
cliError,
|
|
|
|
|
cliFocusName,
|
|
|
|
|
mcpMessage,
|
|
|
|
|
mcpError,
|
|
|
|
|
mcpFieldValues,
|
|
|
|
|
customMcpForm,
|
|
|
|
|
mcpConfigImport,
|
2026-05-24 13:38:37 +08:00
|
|
|
showBrandLogos,
|
|
|
|
|
requiresRestartPending,
|
|
|
|
|
onQueryChange,
|
2026-05-25 20:07:02 +08:00
|
|
|
onFilterChange,
|
|
|
|
|
onCliAction,
|
|
|
|
|
onMcpAction,
|
|
|
|
|
onDismissStatus,
|
|
|
|
|
onBackToChat,
|
|
|
|
|
onMcpFieldChange,
|
|
|
|
|
onCustomMcpFormChange,
|
|
|
|
|
onMcpConfigImportChange,
|
|
|
|
|
onSaveCustomMcp,
|
|
|
|
|
onImportMcpConfig,
|
|
|
|
|
onMcpToolsChange,
|
2026-05-24 13:38:37 +08:00
|
|
|
onRestart,
|
|
|
|
|
isRestarting,
|
|
|
|
|
}: {
|
2026-05-25 20:07:02 +08:00
|
|
|
cliApps: CliAppsPayload | null;
|
|
|
|
|
mcpPresets: McpPresetsPayload | null;
|
|
|
|
|
cliAppsLoading: boolean;
|
|
|
|
|
mcpPresetsLoading: boolean;
|
2026-05-24 13:38:37 +08:00
|
|
|
query: string;
|
2026-05-25 20:07:02 +08:00
|
|
|
filter: AppsKindFilter;
|
|
|
|
|
cliActionKey: string | null;
|
|
|
|
|
mcpActionKey: string | null;
|
|
|
|
|
cliMessage: string | null;
|
|
|
|
|
cliError: string | null;
|
|
|
|
|
cliFocusName: string | null;
|
|
|
|
|
mcpMessage: string | null;
|
|
|
|
|
mcpError: string | null;
|
|
|
|
|
mcpFieldValues: Record<string, Record<string, string>>;
|
|
|
|
|
customMcpForm: CustomMcpForm;
|
|
|
|
|
mcpConfigImport: string;
|
2026-05-24 13:38:37 +08:00
|
|
|
showBrandLogos: boolean;
|
|
|
|
|
requiresRestartPending: boolean;
|
|
|
|
|
onQueryChange: (value: string) => void;
|
2026-05-25 20:07:02 +08:00
|
|
|
onFilterChange: (value: AppsKindFilter) => void;
|
|
|
|
|
onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
|
|
|
|
|
onMcpAction: (action: "enable" | "remove" | "test", name: string, values?: Record<string, string>) => void;
|
|
|
|
|
onDismissStatus: () => void;
|
|
|
|
|
onBackToChat: () => void;
|
|
|
|
|
onMcpFieldChange: (presetName: string, fieldName: string, value: string) => void;
|
|
|
|
|
onCustomMcpFormChange: Dispatch<SetStateAction<CustomMcpForm>>;
|
|
|
|
|
onMcpConfigImportChange: (value: string) => void;
|
|
|
|
|
onSaveCustomMcp: () => void;
|
|
|
|
|
onImportMcpConfig: () => void;
|
|
|
|
|
onMcpToolsChange: (name: string, enabledTools: string[]) => void;
|
2026-05-24 13:38:37 +08:00
|
|
|
onRestart?: () => void;
|
|
|
|
|
isRestarting?: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
2026-05-25 20:07:02 +08:00
|
|
|
const filterOptions = [
|
|
|
|
|
{ value: "all", label: tx("settings.apps.filterAll", "All") },
|
|
|
|
|
{ value: "cli", label: tx("settings.apps.filterCli", "App CLIs") },
|
|
|
|
|
{ value: "mcp", label: tx("settings.apps.filterMcp", "MCP services") },
|
2026-05-24 13:38:37 +08:00
|
|
|
];
|
2026-05-25 20:07:02 +08:00
|
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
|
|
|
const items: AppsCatalogItem[] = [
|
|
|
|
|
...(cliApps?.apps ?? []).map((app) => ({ id: `cli:${app.name}`, kind: "cli" as const, app })),
|
|
|
|
|
...(mcpPresets?.presets ?? []).map((preset) => ({
|
|
|
|
|
id: `mcp:${preset.name}`,
|
|
|
|
|
kind: "mcp" as const,
|
|
|
|
|
preset,
|
|
|
|
|
})),
|
|
|
|
|
]
|
|
|
|
|
.filter((item) => filter === "all" || item.kind === filter)
|
|
|
|
|
.filter((item) => !normalizedQuery || appsSearchText(item).includes(normalizedQuery))
|
|
|
|
|
.sort((left, right) => {
|
|
|
|
|
const rank = Number(!appsReady(left)) - Number(!appsReady(right));
|
|
|
|
|
return rank || appsTitle(left).localeCompare(appsTitle(right));
|
|
|
|
|
});
|
|
|
|
|
const focusedApp = cliFocusName
|
|
|
|
|
? (cliApps?.apps ?? []).find((app) => app.name === cliFocusName && app.installed)
|
|
|
|
|
: null;
|
|
|
|
|
const loading = (cliAppsLoading || mcpPresetsLoading) && !cliApps && !mcpPresets;
|
|
|
|
|
const statusMessage = cliError || mcpError || (!focusedApp ? cliMessage || mcpMessage : null);
|
|
|
|
|
const statusIsError = Boolean(cliError || mcpError);
|
2026-06-06 19:49:33 +08:00
|
|
|
const caption = t("settings.apps.caption", {
|
|
|
|
|
cli: cliApps?.installed_count ?? 0,
|
|
|
|
|
mcp: mcpPresets?.installed_count ?? 0,
|
|
|
|
|
defaultValue: "{{cli}} CLI · {{mcp}} MCP",
|
|
|
|
|
});
|
2026-05-24 13:38:37 +08:00
|
|
|
|
|
|
|
|
return (
|
2026-05-25 20:07:02 +08:00
|
|
|
<div className="space-y-7">
|
2026-05-24 13:38:37 +08:00
|
|
|
<section className="space-y-4">
|
2026-05-25 20:07:02 +08:00
|
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
|
|
|
|
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
|
|
|
|
|
{tx(
|
|
|
|
|
"settings.apps.description",
|
|
|
|
|
"Add local app adapters and connected tool servers that nanobot can use from chat.",
|
|
|
|
|
)}
|
|
|
|
|
</p>
|
|
|
|
|
<span className="text-[12px] font-medium text-muted-foreground">{caption}</span>
|
2026-05-24 13:38:37 +08:00
|
|
|
</div>
|
2026-05-25 20:07:02 +08:00
|
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
2026-05-24 13:38:37 +08:00
|
|
|
<div className="relative flex-1">
|
2026-05-25 20:07:02 +08:00
|
|
|
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
|
2026-05-24 13:38:37 +08:00
|
|
|
<Input
|
|
|
|
|
value={query}
|
|
|
|
|
onChange={(event) => onQueryChange(event.target.value)}
|
2026-05-25 20:07:02 +08:00
|
|
|
placeholder={tx("settings.apps.searchPlaceholder", "Search Apps")}
|
|
|
|
|
className="h-12 rounded-[14px] border-border/70 bg-card/90 pl-11 text-[15px] shadow-sm"
|
2026-05-24 13:38:37 +08:00
|
|
|
/>
|
|
|
|
|
</div>
|
2026-05-25 20:07:02 +08:00
|
|
|
<SegmentedControl
|
|
|
|
|
value={filter}
|
|
|
|
|
options={filterOptions}
|
|
|
|
|
onChange={(value) => onFilterChange(value as AppsKindFilter)}
|
|
|
|
|
/>
|
2026-05-24 13:38:37 +08:00
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-05-25 20:07:02 +08:00
|
|
|
{statusMessage ? (
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex items-center justify-between gap-3 rounded-[12px] border py-2.5 pl-4 pr-2 text-[13px]",
|
|
|
|
|
statusIsError
|
|
|
|
|
? "border-destructive/20 bg-destructive/5 text-destructive"
|
|
|
|
|
: "border-border/55 bg-muted/35 text-muted-foreground",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span className="min-w-0">{statusMessage}</span>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-label={tx("settings.actions.dismiss", "Dismiss")}
|
|
|
|
|
title={tx("settings.actions.dismiss", "Dismiss")}
|
|
|
|
|
onClick={onDismissStatus}
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors",
|
|
|
|
|
statusIsError
|
|
|
|
|
? "text-destructive/70 hover:bg-destructive/10 hover:text-destructive"
|
|
|
|
|
: "text-muted-foreground/70 hover:bg-muted hover:text-foreground",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<X className="h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{focusedApp ? (
|
|
|
|
|
<CliAppReadyPanel app={focusedApp} showBrandLogos={showBrandLogos} onBackToChat={onBackToChat} />
|
|
|
|
|
) : null}
|
2026-05-24 13:38:37 +08:00
|
|
|
|
|
|
|
|
{requiresRestartPending ? (
|
2026-05-25 20:07:02 +08:00
|
|
|
<div className="flex flex-col gap-3 rounded-[12px] border border-amber-500/20 bg-amber-500/8 px-4 py-3 text-[12.5px] text-amber-800 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between">
|
2026-05-24 13:38:37 +08:00
|
|
|
<span>{tx("settings.mcp.restartRequired", "Restart nanobot to connect updated MCP tools.")}</span>
|
|
|
|
|
{onRestart ? (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={onRestart}
|
|
|
|
|
disabled={isRestarting}
|
|
|
|
|
className="h-8 rounded-full bg-background/80 px-3 text-[12px] font-semibold"
|
|
|
|
|
>
|
2026-05-25 20:07:02 +08:00
|
|
|
{isRestarting ? (
|
|
|
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
|
|
|
|
) : (
|
|
|
|
|
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
)}
|
2026-05-24 13:38:37 +08:00
|
|
|
{isRestarting ? t("app.system.restarting") : t("app.system.restart")}
|
|
|
|
|
</Button>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-05-25 20:07:02 +08:00
|
|
|
<section>
|
|
|
|
|
<div className="flex items-center justify-between border-b border-border/45 pb-3">
|
|
|
|
|
<SettingsSectionTitle>{tx("settings.apps.featured", "Featured")}</SettingsSectionTitle>
|
|
|
|
|
<span className="rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
|
|
|
|
|
{items.length}
|
|
|
|
|
</span>
|
2026-05-24 13:38:37 +08:00
|
|
|
</div>
|
2026-05-25 20:07:02 +08:00
|
|
|
{loading ? (
|
|
|
|
|
<div className="flex h-36 items-center justify-center text-sm text-muted-foreground">
|
|
|
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
|
|
|
|
|
{tx("settings.apps.loading", "Loading Apps...")}
|
|
|
|
|
</div>
|
|
|
|
|
) : items.length ? (
|
|
|
|
|
<div className="grid gap-x-10 gap-y-1 py-3 md:grid-cols-2">
|
|
|
|
|
{items.map((item) =>
|
|
|
|
|
item.kind === "cli" ? (
|
|
|
|
|
<CliAppsCatalogRow
|
|
|
|
|
key={item.id}
|
|
|
|
|
app={item.app}
|
|
|
|
|
actionKey={cliActionKey}
|
|
|
|
|
showBrandLogos={showBrandLogos}
|
|
|
|
|
onAction={onCliAction}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<McpAppsCatalogRow
|
|
|
|
|
key={item.id}
|
|
|
|
|
preset={item.preset}
|
|
|
|
|
values={mcpFieldValues[item.preset.name] ?? {}}
|
|
|
|
|
actionKey={mcpActionKey}
|
|
|
|
|
showBrandLogos={showBrandLogos}
|
|
|
|
|
onFieldChange={onMcpFieldChange}
|
|
|
|
|
onAction={onMcpAction}
|
|
|
|
|
onToolsChange={onMcpToolsChange}
|
|
|
|
|
/>
|
|
|
|
|
),
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
|
|
|
|
|
{tx("settings.apps.empty", "No apps match this filter.")}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{filter !== "cli" ? (
|
|
|
|
|
<McpCustomServerPanel
|
|
|
|
|
form={customMcpForm}
|
|
|
|
|
configImport={mcpConfigImport}
|
|
|
|
|
actionKey={mcpActionKey}
|
|
|
|
|
onFormChange={onCustomMcpFormChange}
|
|
|
|
|
onConfigImportChange={onMcpConfigImportChange}
|
|
|
|
|
onSave={onSaveCustomMcp}
|
|
|
|
|
onImportConfig={onImportMcpConfig}
|
|
|
|
|
/>
|
2026-05-24 13:38:37 +08:00
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<ThirdPartyBrandNotice />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 20:07:02 +08:00
|
|
|
function CliAppsCatalogRow({
|
|
|
|
|
app,
|
|
|
|
|
actionKey,
|
|
|
|
|
showBrandLogos,
|
|
|
|
|
onAction,
|
|
|
|
|
}: {
|
|
|
|
|
app: CliAppInfo;
|
|
|
|
|
actionKey: string | null;
|
|
|
|
|
showBrandLogos: boolean;
|
|
|
|
|
onAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
|
|
|
const installBusy = actionKey === `install:${app.name}`;
|
|
|
|
|
const updateBusy = actionKey === `update:${app.name}`;
|
|
|
|
|
const uninstallBusy = actionKey === `uninstall:${app.name}`;
|
|
|
|
|
const testBusy = actionKey === `test:${app.name}`;
|
|
|
|
|
const busy = installBusy || updateBusy || uninstallBusy || testBusy;
|
|
|
|
|
const description = app.description || app.requires || app.entry_point || app.name;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<article className="group flex min-w-0 items-center gap-3 rounded-[14px] px-3 py-3 transition-colors hover:bg-muted/45">
|
|
|
|
|
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<div className="flex min-w-0 items-baseline gap-2">
|
|
|
|
|
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{app.display_name}</h3>
|
|
|
|
|
<AppsTypeBadge>{tx("settings.apps.cliLabel", "CLI")}</AppsTypeBadge>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">{description}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
|
|
|
{app.installed ? (
|
|
|
|
|
<>
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<AppsActionButton
|
|
|
|
|
ariaLabel={tx("settings.cliApps.statusInstalled", "CLI installed")}
|
|
|
|
|
busy={testBusy || updateBusy}
|
|
|
|
|
disabled={busy}
|
|
|
|
|
tone="installed"
|
|
|
|
|
>
|
|
|
|
|
<Check className="h-4 w-4" aria-hidden />
|
|
|
|
|
</AppsActionButton>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="end">
|
|
|
|
|
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", app.name)}>
|
|
|
|
|
<PlayCircle className="mr-2 h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
{tx("settings.cliApps.test", "Test CLI")}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuItem disabled={busy} onClick={() => onAction("update", app.name)}>
|
|
|
|
|
<RotateCcw className="mr-2 h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
{tx("settings.cliApps.update", "Update CLI")}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuItem disabled={busy} onClick={() => onAction("uninstall", app.name)}>
|
|
|
|
|
<Trash2 className="mr-2 h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
{tx("settings.cliApps.uninstall", "Uninstall CLI")}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
<AppsActionButton
|
|
|
|
|
ariaLabel={tx("settings.cliApps.uninstall", "Uninstall CLI")}
|
|
|
|
|
busy={uninstallBusy}
|
|
|
|
|
disabled={busy && !uninstallBusy}
|
|
|
|
|
tone="danger"
|
|
|
|
|
onClick={() => onAction("uninstall", app.name)}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-4 w-4" aria-hidden />
|
|
|
|
|
</AppsActionButton>
|
|
|
|
|
</>
|
|
|
|
|
) : app.install_supported ? (
|
|
|
|
|
<AppsActionButton
|
|
|
|
|
ariaLabel={tx("settings.cliApps.install", "Install CLI")}
|
|
|
|
|
busy={installBusy}
|
|
|
|
|
onClick={() => onAction("install", app.name)}
|
|
|
|
|
>
|
|
|
|
|
<Plus className="h-4 w-4" aria-hidden />
|
|
|
|
|
</AppsActionButton>
|
|
|
|
|
) : (
|
|
|
|
|
<AppsActionButton ariaLabel={tx("settings.cliApps.unavailable", "Unavailable")} disabled>
|
|
|
|
|
<Plus className="h-4 w-4" aria-hidden />
|
|
|
|
|
</AppsActionButton>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</article>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function McpAppsCatalogRow({
|
|
|
|
|
preset,
|
|
|
|
|
values,
|
|
|
|
|
actionKey,
|
|
|
|
|
showBrandLogos,
|
|
|
|
|
onFieldChange,
|
|
|
|
|
onAction,
|
|
|
|
|
onToolsChange,
|
|
|
|
|
}: {
|
|
|
|
|
preset: McpPresetInfo;
|
|
|
|
|
values: Record<string, string>;
|
|
|
|
|
actionKey: string | null;
|
|
|
|
|
showBrandLogos: boolean;
|
|
|
|
|
onFieldChange: (presetName: string, fieldName: string, value: string) => void;
|
|
|
|
|
onAction: (action: "enable" | "remove" | "test", name: string, values?: Record<string, string>) => void;
|
|
|
|
|
onToolsChange: (name: string, enabledTools: string[]) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
|
|
|
const [setupOpen, setSetupOpen] = useState(false);
|
|
|
|
|
const [toolsOpen, setToolsOpen] = useState(false);
|
|
|
|
|
const enableBusy = actionKey === `enable:${preset.name}`;
|
|
|
|
|
const removeBusy = actionKey === `remove:${preset.name}`;
|
|
|
|
|
const testBusy = actionKey === `test:${preset.name}`;
|
|
|
|
|
const toolsBusy = actionKey === `tools:${preset.name}`;
|
|
|
|
|
const busy = enableBusy || removeBusy || testBusy || toolsBusy;
|
|
|
|
|
const missingFields = preset.required_fields.filter((field) => field.required && !field.configured);
|
|
|
|
|
const hasFields = preset.required_fields.length > 0;
|
|
|
|
|
const needsSetupInput = missingFields.length > 0;
|
|
|
|
|
const readyInstalled = preset.installed && preset.configured;
|
|
|
|
|
const canEnable =
|
|
|
|
|
preset.install_supported &&
|
|
|
|
|
(missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim())));
|
|
|
|
|
const toolNames = preset.tool_names ?? [];
|
|
|
|
|
const enabledTools = preset.enabled_tools ?? ["*"];
|
|
|
|
|
const allowAllTools = enabledTools.includes("*");
|
|
|
|
|
const enabledSet = new Set(allowAllTools ? toolNames : enabledTools);
|
|
|
|
|
const description = preset.description || preset.note || preset.requires || preset.name;
|
|
|
|
|
const statusLabel = mcpPresetStatusLabel(preset.status, tx);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (preset.configured || !preset.install_supported) setSetupOpen(false);
|
|
|
|
|
}, [preset.configured, preset.install_supported]);
|
|
|
|
|
|
|
|
|
|
const enableOrOpenSetup = () => {
|
|
|
|
|
if (needsSetupInput || (preset.installed && !preset.configured && hasFields)) {
|
|
|
|
|
setSetupOpen(true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
onAction("enable", preset.name, values);
|
|
|
|
|
};
|
|
|
|
|
const submitSetup = () => {
|
|
|
|
|
if (!canEnable) return;
|
|
|
|
|
onAction("enable", preset.name, values);
|
|
|
|
|
};
|
|
|
|
|
const setTools = (next: string[]) => onToolsChange(preset.name, next);
|
|
|
|
|
const toggleTool = (toolName: string) => {
|
|
|
|
|
const next = new Set(allowAllTools ? toolNames : enabledTools);
|
|
|
|
|
if (next.has(toolName)) next.delete(toolName);
|
|
|
|
|
else next.add(toolName);
|
|
|
|
|
const nextValues = Array.from(next);
|
|
|
|
|
setTools(nextValues.length === toolNames.length ? ["*"] : nextValues);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<article className="rounded-[14px] transition-colors hover:bg-muted/45">
|
|
|
|
|
<div className="group flex min-w-0 items-center gap-3 px-3 py-3">
|
|
|
|
|
<McpPresetLogo preset={preset} showBrandLogos={showBrandLogos} />
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<div className="flex min-w-0 items-baseline gap-2">
|
|
|
|
|
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{preset.display_name}</h3>
|
|
|
|
|
<AppsTypeBadge>{tx("settings.apps.mcpLabel", "MCP")}</AppsTypeBadge>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">{description}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
|
|
|
{readyInstalled ? (
|
|
|
|
|
<>
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<AppsActionButton
|
|
|
|
|
ariaLabel={statusLabel}
|
|
|
|
|
busy={testBusy || toolsBusy}
|
|
|
|
|
disabled={busy}
|
|
|
|
|
tone="installed"
|
|
|
|
|
>
|
|
|
|
|
<Check className="h-4 w-4" aria-hidden />
|
|
|
|
|
</AppsActionButton>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="end">
|
|
|
|
|
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
|
|
|
|
|
<PlayCircle className="mr-2 h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
{tx("settings.mcp.test", "Test")}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
{toolNames.length ? (
|
|
|
|
|
<DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}>
|
|
|
|
|
<SlidersHorizontal className="mr-2 h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
{tx("settings.mcp.toolScope", "Tools")}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
) : null}
|
|
|
|
|
<DropdownMenuItem disabled={busy} onClick={() => onAction("remove", preset.name)}>
|
|
|
|
|
<Trash2 className="mr-2 h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
{tx("settings.mcp.remove", "Remove")}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
<AppsActionButton
|
|
|
|
|
ariaLabel={tx("settings.mcp.remove", "Remove")}
|
|
|
|
|
busy={removeBusy}
|
|
|
|
|
disabled={busy && !removeBusy}
|
|
|
|
|
tone="danger"
|
|
|
|
|
onClick={() => onAction("remove", preset.name)}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-4 w-4" aria-hidden />
|
|
|
|
|
</AppsActionButton>
|
|
|
|
|
</>
|
|
|
|
|
) : preset.installed && !preset.configured ? (
|
|
|
|
|
<AppsActionButton
|
|
|
|
|
ariaLabel={hasFields ? tx("settings.mcp.configure", "Configure") : tx("settings.mcp.enable", "Enable")}
|
|
|
|
|
busy={enableBusy}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (hasFields) setSetupOpen(true);
|
|
|
|
|
else onAction("enable", preset.name, values);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Plus className="h-4 w-4" aria-hidden />
|
|
|
|
|
</AppsActionButton>
|
|
|
|
|
) : preset.install_supported ? (
|
|
|
|
|
<AppsActionButton
|
|
|
|
|
ariaLabel={needsSetupInput ? tx("settings.mcp.setup", "Set up") : tx("settings.mcp.enable", "Enable")}
|
|
|
|
|
busy={enableBusy}
|
|
|
|
|
onClick={enableOrOpenSetup}
|
|
|
|
|
>
|
|
|
|
|
<Plus className="h-4 w-4" aria-hidden />
|
|
|
|
|
</AppsActionButton>
|
|
|
|
|
) : (
|
|
|
|
|
<AppsActionButton ariaLabel={tx("settings.mcp.comingSoon", "Coming soon")} disabled>
|
|
|
|
|
<Plus className="h-4 w-4" aria-hidden />
|
|
|
|
|
</AppsActionButton>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{setupOpen && preset.install_supported && hasFields ? (
|
|
|
|
|
<div className="mx-3 mb-3 rounded-[14px] border border-border/45 bg-card/85 p-3 shadow-sm">
|
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<div className="truncate text-[12.5px] font-semibold text-foreground">
|
2026-06-06 19:49:33 +08:00
|
|
|
{t("settings.mcp.connectTitle", {
|
|
|
|
|
name: preset.display_name,
|
|
|
|
|
defaultValue: "Connect {{name}}",
|
|
|
|
|
})}
|
2026-05-25 20:07:02 +08:00
|
|
|
</div>
|
|
|
|
|
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
|
|
|
|
|
{tx("settings.mcp.connectHint", "Add the key from your account settings.")}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
disabled={busy}
|
|
|
|
|
onClick={() => setSetupOpen(false)}
|
|
|
|
|
className="h-7 rounded-full px-2.5 text-[11.5px] font-semibold text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
{tx("actions.cancel", "Cancel")}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-3 grid gap-2">
|
|
|
|
|
{preset.required_fields.map((field) => (
|
|
|
|
|
<label key={field.name} className="min-w-0">
|
|
|
|
|
<span className="mb-1 block text-[11.5px] font-medium text-muted-foreground">
|
|
|
|
|
{field.label}
|
|
|
|
|
{field.configured ? (
|
|
|
|
|
<span className="ml-1 font-normal text-emerald-600 dark:text-emerald-300">
|
|
|
|
|
{tx("settings.mcp.configured", "configured")}
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</span>
|
|
|
|
|
<Input
|
|
|
|
|
type={field.secret ? "password" : "text"}
|
|
|
|
|
value={values[field.name] ?? ""}
|
|
|
|
|
onChange={(event) => onFieldChange(preset.name, field.name, event.target.value)}
|
|
|
|
|
placeholder={
|
|
|
|
|
field.configured
|
|
|
|
|
? tx("settings.mcp.keepExisting", "Leave blank to keep existing")
|
|
|
|
|
: field.placeholder
|
|
|
|
|
}
|
|
|
|
|
className="h-9 rounded-full bg-background/80 text-[12.5px]"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-3 flex justify-end">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
disabled={busy || !canEnable}
|
|
|
|
|
onClick={submitSetup}
|
|
|
|
|
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
|
|
|
|
>
|
|
|
|
|
{enableBusy ? (
|
|
|
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
|
|
|
|
) : (
|
|
|
|
|
<Check className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
)}
|
|
|
|
|
{preset.installed
|
|
|
|
|
? tx("settings.mcp.updateSetup", "Update setup")
|
|
|
|
|
: tx("settings.mcp.saveAndEnable", "Save and enable")}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{toolsOpen && readyInstalled && toolNames.length ? (
|
|
|
|
|
<div className="mx-3 mb-3 rounded-[14px] border border-border/45 bg-card/85 p-3 shadow-sm">
|
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
|
|
|
<div className="text-[11.5px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.mcp.toolScope", "Tools")}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
variant={allowAllTools ? "default" : "outline"}
|
|
|
|
|
disabled={toolsBusy}
|
|
|
|
|
onClick={() => setTools(["*"])}
|
|
|
|
|
className="h-7 rounded-full px-2.5 text-[11.5px] font-semibold"
|
|
|
|
|
>
|
|
|
|
|
{tx("settings.mcp.allTools", "All")}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
variant={!allowAllTools && enabledSet.size === 0 ? "default" : "outline"}
|
|
|
|
|
disabled={toolsBusy}
|
|
|
|
|
onClick={() => setTools([])}
|
|
|
|
|
className="h-7 rounded-full px-2.5 text-[11.5px] font-semibold"
|
|
|
|
|
>
|
|
|
|
|
{tx("settings.mcp.noTools", "None")}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
|
|
|
{toolNames.map((toolName) => {
|
|
|
|
|
const selected = enabledSet.has(toolName);
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={toolName}
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={toolsBusy}
|
|
|
|
|
onClick={() => toggleTool(toolName)}
|
|
|
|
|
className={cn(
|
|
|
|
|
"max-w-full rounded-full border px-2.5 py-1 font-mono text-[11px] transition-colors",
|
|
|
|
|
selected
|
|
|
|
|
? "border-blue-500/25 bg-blue-500/10 text-blue-700 dark:text-blue-300"
|
|
|
|
|
: "border-border/55 bg-muted/30 text-muted-foreground hover:bg-muted/60",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span className="block max-w-[220px] truncate">{toolName}</span>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</article>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function AppsTypeBadge({ children }: { children: ReactNode }) {
|
|
|
|
|
return (
|
|
|
|
|
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase leading-none tracking-[0.06em] text-muted-foreground">
|
|
|
|
|
{children}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const AppsActionButton = forwardRef<HTMLButtonElement, {
|
|
|
|
|
ariaLabel: string;
|
|
|
|
|
busy?: boolean;
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
tone?: "default" | "installed" | "danger";
|
|
|
|
|
onClick?: () => void;
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
}>(function AppsActionButton({
|
|
|
|
|
ariaLabel,
|
|
|
|
|
busy,
|
|
|
|
|
disabled,
|
|
|
|
|
tone = "default",
|
|
|
|
|
onClick,
|
|
|
|
|
children,
|
|
|
|
|
}, ref) {
|
|
|
|
|
return (
|
|
|
|
|
<Button
|
|
|
|
|
ref={ref}
|
|
|
|
|
type="button"
|
|
|
|
|
size="icon"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
aria-label={ariaLabel}
|
|
|
|
|
title={ariaLabel}
|
|
|
|
|
disabled={disabled || busy}
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
className={cn(
|
|
|
|
|
"h-9 w-9 rounded-full text-muted-foreground transition-colors",
|
|
|
|
|
tone === "installed" && "bg-transparent hover:bg-muted/70 hover:text-foreground",
|
|
|
|
|
tone === "danger" && "bg-transparent hover:bg-destructive/10 hover:text-destructive",
|
|
|
|
|
tone === "default" && "bg-muted/70 hover:bg-muted hover:text-foreground",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : children}
|
|
|
|
|
</Button>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function appsTitle(item: AppsCatalogItem): string {
|
|
|
|
|
return item.kind === "cli" ? item.app.display_name : item.preset.display_name;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function appsReady(item: AppsCatalogItem): boolean {
|
|
|
|
|
return item.kind === "cli" ? item.app.installed : item.preset.installed && item.preset.configured;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function appsSearchText(item: AppsCatalogItem): string {
|
|
|
|
|
if (item.kind === "cli") {
|
|
|
|
|
const app = item.app;
|
|
|
|
|
return [
|
|
|
|
|
app.display_name,
|
|
|
|
|
app.name,
|
|
|
|
|
app.category,
|
|
|
|
|
app.description,
|
|
|
|
|
app.requires,
|
|
|
|
|
app.entry_point,
|
|
|
|
|
app.source,
|
|
|
|
|
]
|
|
|
|
|
.join(" ")
|
|
|
|
|
.toLowerCase();
|
|
|
|
|
}
|
|
|
|
|
const preset = item.preset;
|
|
|
|
|
return [
|
|
|
|
|
preset.display_name,
|
|
|
|
|
preset.name,
|
|
|
|
|
preset.category,
|
|
|
|
|
preset.description,
|
|
|
|
|
preset.requires,
|
|
|
|
|
preset.note,
|
|
|
|
|
preset.transport,
|
|
|
|
|
preset.source ?? "",
|
|
|
|
|
]
|
|
|
|
|
.join(" ")
|
|
|
|
|
.toLowerCase();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 13:38:37 +08:00
|
|
|
function McpCustomServerPanel({
|
|
|
|
|
form,
|
|
|
|
|
configImport,
|
|
|
|
|
actionKey,
|
|
|
|
|
onFormChange,
|
|
|
|
|
onConfigImportChange,
|
|
|
|
|
onSave,
|
|
|
|
|
onImportConfig,
|
|
|
|
|
}: {
|
|
|
|
|
form: CustomMcpForm;
|
|
|
|
|
configImport: string;
|
|
|
|
|
actionKey: string | null;
|
|
|
|
|
onFormChange: Dispatch<SetStateAction<CustomMcpForm>>;
|
|
|
|
|
onConfigImportChange: (value: string) => void;
|
|
|
|
|
onSave: () => void;
|
|
|
|
|
onImportConfig: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
|
|
|
const [activeMode, setActiveMode] = useState<"custom" | "import" | null>(null);
|
|
|
|
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
|
|
|
|
const customBusy = actionKey?.startsWith("custom:") ?? false;
|
|
|
|
|
const importBusy = actionKey === "import" || actionKey === "import-cursor";
|
|
|
|
|
const remote = form.transport !== "stdio";
|
|
|
|
|
const canSave = Boolean(form.name.trim()) && (remote ? Boolean(form.url.trim()) : Boolean(form.command.trim()));
|
|
|
|
|
const update = <K extends keyof CustomMcpForm>(key: K, value: CustomMcpForm[K]) => {
|
|
|
|
|
onFormChange((prev) => ({ ...prev, [key]: value }));
|
|
|
|
|
};
|
|
|
|
|
const transports: Array<{ value: CustomMcpTransport; label: string }> = [
|
|
|
|
|
{ value: "stdio", label: "stdio" },
|
|
|
|
|
{ value: "streamableHttp", label: "HTTP" },
|
|
|
|
|
{ value: "sse", label: "SSE" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<section className="overflow-hidden rounded-[16px] border border-border/45 bg-card/72 shadow-[0_10px_30px_rgba(15,23,42,0.045)]">
|
|
|
|
|
<div className="flex flex-col gap-3 px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
|
|
|
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
|
|
|
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[11px] bg-muted text-muted-foreground">
|
|
|
|
|
<Server className="h-4 w-4" aria-hidden />
|
|
|
|
|
</span>
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<h3 className="text-[13px] font-semibold leading-5 text-foreground">
|
|
|
|
|
{tx("settings.mcp.moreOptions", "More MCP options")}
|
|
|
|
|
</h3>
|
|
|
|
|
<p className="truncate text-[12px] text-muted-foreground">
|
|
|
|
|
{tx("settings.mcp.moreOptionsSubtitle", "Add a custom server or import mcp.json.")}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid grid-cols-2 gap-2 sm:flex sm:shrink-0">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
variant={activeMode === "custom" ? "default" : "outline"}
|
|
|
|
|
onClick={() => setActiveMode((mode) => (mode === "custom" ? null : "custom"))}
|
|
|
|
|
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
|
|
|
|
>
|
|
|
|
|
<Server className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
{tx("settings.mcp.customAction", "Custom")}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
variant={activeMode === "import" ? "default" : "outline"}
|
|
|
|
|
onClick={() => setActiveMode((mode) => (mode === "import" ? null : "import"))}
|
|
|
|
|
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
|
|
|
|
>
|
|
|
|
|
<Database className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
{tx("settings.mcp.importAction", "Import")}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{activeMode === "custom" ? (
|
|
|
|
|
<div className="border-t border-border/35 bg-muted/18 px-3 py-3">
|
|
|
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
|
|
|
|
|
<label className="min-w-0 flex-1">
|
|
|
|
|
<span className="mb-1.5 block text-[11.5px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.mcp.serverName", "Server name")}
|
|
|
|
|
</span>
|
|
|
|
|
<Input
|
|
|
|
|
value={form.name}
|
|
|
|
|
onChange={(event) => update("name", event.target.value)}
|
|
|
|
|
placeholder="docs"
|
|
|
|
|
className="h-9 rounded-full bg-background/80 text-[12.5px]"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<div className="min-w-[228px]">
|
|
|
|
|
<span className="mb-1.5 block text-[11.5px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.mcp.transport", "Transport")}
|
|
|
|
|
</span>
|
|
|
|
|
<SegmentedControl
|
|
|
|
|
value={form.transport}
|
|
|
|
|
options={transports}
|
|
|
|
|
onChange={(value) => update("transport", value as CustomMcpTransport)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
{remote ? (
|
|
|
|
|
<label className="min-w-0 flex-[1.4]">
|
|
|
|
|
<span className="mb-1.5 block text-[11.5px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.mcp.serverUrl", "URL")}
|
|
|
|
|
</span>
|
|
|
|
|
<Input
|
|
|
|
|
value={form.url}
|
|
|
|
|
onChange={(event) => update("url", event.target.value)}
|
|
|
|
|
placeholder={form.transport === "sse" ? "https://example.com/sse" : "https://example.com/mcp"}
|
|
|
|
|
className="h-9 rounded-full bg-background/80 text-[12.5px]"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
) : (
|
|
|
|
|
<label className="min-w-0 flex-[1.4]">
|
|
|
|
|
<span className="mb-1.5 block text-[11.5px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.mcp.command", "Command")}
|
|
|
|
|
</span>
|
|
|
|
|
<Input
|
|
|
|
|
value={form.command}
|
|
|
|
|
onChange={(event) => update("command", event.target.value)}
|
|
|
|
|
placeholder="npx"
|
|
|
|
|
className="h-9 rounded-full bg-background/80 text-[12.5px]"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
)}
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={onSave}
|
|
|
|
|
disabled={!canSave || customBusy}
|
|
|
|
|
className="h-9 shrink-0 rounded-full px-4 text-[12.5px] font-semibold"
|
|
|
|
|
>
|
|
|
|
|
{customBusy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : <Check className="mr-1.5 h-3.5 w-3.5" aria-hidden />}
|
|
|
|
|
{tx("settings.mcp.saveCustom", "Save MCP")}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={() => setAdvancedOpen((open) => !open)}
|
|
|
|
|
className="mt-2 h-8 rounded-full px-2 text-[12px] font-medium text-muted-foreground hover:text-foreground"
|
|
|
|
|
>
|
|
|
|
|
<ChevronDown
|
|
|
|
|
className={cn("mr-1.5 h-3.5 w-3.5 transition-transform", advancedOpen ? "rotate-180" : "")}
|
|
|
|
|
aria-hidden
|
|
|
|
|
/>
|
|
|
|
|
{advancedOpen
|
|
|
|
|
? tx("settings.mcp.hideAdvanced", "Hide advanced")
|
|
|
|
|
: tx("settings.mcp.advancedOptions", "Advanced options")}
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
{advancedOpen ? (
|
|
|
|
|
<div className="mt-2 grid gap-2 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_180px]">
|
|
|
|
|
{!remote ? (
|
|
|
|
|
<label className="min-w-0">
|
|
|
|
|
<span className="mb-1 block text-[11.5px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.mcp.args", "Args JSON")}
|
|
|
|
|
</span>
|
|
|
|
|
<Textarea
|
|
|
|
|
value={form.args}
|
|
|
|
|
onChange={(event) => update("args", event.target.value)}
|
|
|
|
|
placeholder={'["-y", "docs-mcp"]'}
|
|
|
|
|
className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
) : (
|
|
|
|
|
<label className="min-w-0">
|
|
|
|
|
<span className="mb-1 block text-[11.5px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.mcp.headers", "Headers JSON")}
|
|
|
|
|
</span>
|
|
|
|
|
<Textarea
|
|
|
|
|
value={form.headers}
|
|
|
|
|
onChange={(event) => update("headers", event.target.value)}
|
|
|
|
|
placeholder={'{"Authorization":"Bearer ..."}'}
|
|
|
|
|
className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
)}
|
|
|
|
|
<label className="min-w-0">
|
|
|
|
|
<span className="mb-1 block text-[11.5px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.mcp.env", "Env JSON")}
|
|
|
|
|
</span>
|
|
|
|
|
<Textarea
|
|
|
|
|
value={form.env}
|
|
|
|
|
onChange={(event) => update("env", event.target.value)}
|
|
|
|
|
placeholder={'{"API_KEY":"..."}'}
|
|
|
|
|
className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="min-w-0">
|
|
|
|
|
<span className="mb-1 block text-[11.5px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.mcp.timeout", "Tool timeout")}
|
|
|
|
|
</span>
|
|
|
|
|
<Input
|
|
|
|
|
value={form.toolTimeout}
|
|
|
|
|
onChange={(event) => update("toolTimeout", event.target.value)}
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
className="h-9 rounded-full bg-background/80 text-[12.5px]"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{activeMode === "import" ? (
|
|
|
|
|
<div className="border-t border-border/35 bg-muted/18 px-3 py-3">
|
|
|
|
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-end">
|
|
|
|
|
<label className="min-w-0 flex-1">
|
|
|
|
|
<span className="mb-1.5 block text-[11.5px] font-medium text-muted-foreground">
|
|
|
|
|
{tx("settings.mcp.configImport", "Import mcp.json")}
|
|
|
|
|
</span>
|
|
|
|
|
<Textarea
|
|
|
|
|
value={configImport}
|
|
|
|
|
onChange={(event) => onConfigImportChange(event.target.value)}
|
|
|
|
|
placeholder={'{"mcpServers":{"docs":{"command":"npx","args":["-y","docs-mcp"]}}}'}
|
|
|
|
|
className="min-h-[84px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={onImportConfig}
|
|
|
|
|
disabled={!configImport.trim() || importBusy}
|
|
|
|
|
className="h-9 shrink-0 rounded-full px-4 text-[12.5px] font-semibold"
|
|
|
|
|
>
|
|
|
|
|
{importBusy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : <Database className="mr-1.5 h-3.5 w-3.5" aria-hidden />}
|
|
|
|
|
{tx("settings.mcp.importConfig", "Import")}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mcpPresetStatusLabel(status: string, tx: (key: string, fallback: string) => string): string {
|
|
|
|
|
switch (status) {
|
|
|
|
|
case "configured":
|
|
|
|
|
return tx("settings.mcp.statusConfigured", "Configured");
|
|
|
|
|
case "missing_credentials":
|
|
|
|
|
return tx("settings.mcp.statusMissingCredentials", "Needs key");
|
|
|
|
|
case "missing_dependency":
|
|
|
|
|
return tx("settings.mcp.statusMissingDependency", "Needs dependency");
|
|
|
|
|
case "coming_soon":
|
|
|
|
|
return tx("settings.mcp.statusComingSoon", "Coming soon");
|
|
|
|
|
default:
|
|
|
|
|
return tx("settings.mcp.statusNotInstalled", "Not enabled");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function McpPresetLogo({ preset, showBrandLogos }: { preset: McpPresetInfo; showBrandLogos: boolean }) {
|
|
|
|
|
const [logoIndex, setLogoIndex] = useState(0);
|
|
|
|
|
const bg = preset.brand_color || "hsl(var(--muted))";
|
|
|
|
|
const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
|
|
|
|
|
const logoUrl = logoUrls[logoIndex];
|
|
|
|
|
const initials = preset.display_name
|
|
|
|
|
.split(/\s+/)
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.slice(0, 2)
|
|
|
|
|
.map((part) => part[0]?.toUpperCase())
|
|
|
|
|
.join("") || preset.name.slice(0, 2).toUpperCase();
|
|
|
|
|
|
|
|
|
|
useEffect(() => setLogoIndex(0), [preset.logo_url]);
|
|
|
|
|
|
|
|
|
|
if (showBrandLogos && logoUrl) {
|
|
|
|
|
return (
|
|
|
|
|
<span
|
|
|
|
|
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] border border-border/45 bg-background"
|
|
|
|
|
style={{ boxShadow: `inset 0 0 0 1px ${preset.brand_color ?? "transparent"}22` }}
|
|
|
|
|
>
|
|
|
|
|
<img
|
|
|
|
|
src={logoUrl}
|
|
|
|
|
alt=""
|
|
|
|
|
className="h-6 w-6 object-contain"
|
|
|
|
|
onError={() => setLogoIndex((index) => index + 1)}
|
|
|
|
|
/>
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<span
|
|
|
|
|
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] text-[13px] font-semibold text-white"
|
|
|
|
|
style={{ backgroundColor: bg }}
|
|
|
|
|
>
|
|
|
|
|
{initials}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 22:25:12 +08:00
|
|
|
function CliAppReadyPanel({
|
|
|
|
|
app,
|
|
|
|
|
showBrandLogos,
|
|
|
|
|
onBackToChat,
|
|
|
|
|
}: {
|
|
|
|
|
app: CliAppInfo;
|
|
|
|
|
showBrandLogos: boolean;
|
|
|
|
|
onBackToChat: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
|
const prompt = t("settings.cliApps.readyPrompt", {
|
|
|
|
|
name: app.name,
|
|
|
|
|
defaultValue: "Use @{{name}} to inspect what this CLI can do.",
|
|
|
|
|
});
|
|
|
|
|
const copyPrompt = () => {
|
|
|
|
|
if (!navigator.clipboard) return;
|
|
|
|
|
void navigator.clipboard.writeText(prompt).then(() => {
|
|
|
|
|
setCopied(true);
|
|
|
|
|
window.setTimeout(() => setCopied(false), 1400);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<section
|
|
|
|
|
className={cn(
|
|
|
|
|
"rounded-[12px] border border-border/55 bg-card/88 px-4 py-3",
|
|
|
|
|
"shadow-[0_8px_26px_rgba(15,23,42,0.055)]",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
|
|
|
|
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
|
|
|
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
|
|
|
|
|
{app.display_name}
|
|
|
|
|
</h3>
|
|
|
|
|
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10.5px] font-medium text-muted-foreground">
|
|
|
|
|
<Check className="h-3 w-3 text-emerald-600 dark:text-emerald-300" aria-hidden />
|
|
|
|
|
{t("settings.cliApps.readyStatus", { defaultValue: "Ready" })}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
|
|
|
|
|
<span className="font-mono">@{app.name}</span>
|
|
|
|
|
<span aria-hidden>·</span>
|
|
|
|
|
<span className="truncate font-mono">{app.entry_point || app.name}</span>
|
|
|
|
|
<span aria-hidden>·</span>
|
|
|
|
|
<span>{app.category}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex shrink-0 flex-wrap gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={copyPrompt}
|
|
|
|
|
className="h-8 rounded-full px-3 text-[12px] font-medium text-muted-foreground hover:bg-muted/65 hover:text-foreground"
|
|
|
|
|
>
|
|
|
|
|
{copied ? <Check className="mr-1.5 h-3.5 w-3.5" aria-hidden /> : null}
|
|
|
|
|
{copied
|
|
|
|
|
? t("settings.cliApps.readyCopied", { defaultValue: "Copied" })
|
|
|
|
|
: t("settings.cliApps.readyTry", { name: app.name, defaultValue: "Try @{{name}}" })}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={onBackToChat}
|
|
|
|
|
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
|
|
|
|
>
|
|
|
|
|
{t("settings.cliApps.openChat", { defaultValue: "Open chat" })}
|
|
|
|
|
<ChevronRight className="ml-1.5 h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function CliAppLogo({ app, showBrandLogos }: { app: CliAppInfo; showBrandLogos: boolean }) {
|
2026-05-24 13:38:37 +08:00
|
|
|
const [logoIndex, setLogoIndex] = useState(0);
|
2026-05-22 22:25:12 +08:00
|
|
|
const bg = app.brand_color || "hsl(var(--muted))";
|
2026-05-24 13:38:37 +08:00
|
|
|
const logoUrls = useMemo(() => logoFallbackUrls(app.logo_url), [app.logo_url]);
|
|
|
|
|
const logoUrl = logoUrls[logoIndex];
|
2026-05-22 22:25:12 +08:00
|
|
|
const initials = app.display_name
|
|
|
|
|
.split(/\s+/)
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.slice(0, 2)
|
|
|
|
|
.map((part) => part[0]?.toUpperCase())
|
|
|
|
|
.join("") || app.name.slice(0, 2).toUpperCase();
|
2026-05-24 13:38:37 +08:00
|
|
|
|
|
|
|
|
useEffect(() => setLogoIndex(0), [app.logo_url]);
|
|
|
|
|
|
|
|
|
|
if (showBrandLogos && logoUrl) {
|
2026-05-22 22:25:12 +08:00
|
|
|
return (
|
|
|
|
|
<span
|
|
|
|
|
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] border border-border/45 bg-background"
|
|
|
|
|
style={{ boxShadow: `inset 0 0 0 1px ${app.brand_color ?? "transparent"}22` }}
|
|
|
|
|
>
|
|
|
|
|
<img
|
2026-05-24 13:38:37 +08:00
|
|
|
src={logoUrl}
|
2026-05-22 22:25:12 +08:00
|
|
|
alt=""
|
|
|
|
|
className="h-6 w-6 object-contain"
|
2026-05-24 13:38:37 +08:00
|
|
|
onError={() => setLogoIndex((index) => index + 1)}
|
2026-05-22 22:25:12 +08:00
|
|
|
/>
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<span
|
|
|
|
|
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] text-[13px] font-semibold text-white"
|
|
|
|
|
style={{ backgroundColor: bg }}
|
|
|
|
|
>
|
|
|
|
|
{initials}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
function RuntimeSettings({
|
|
|
|
|
form,
|
|
|
|
|
setForm,
|
|
|
|
|
settings,
|
|
|
|
|
dirty,
|
|
|
|
|
saving,
|
|
|
|
|
onSave,
|
|
|
|
|
onRestart,
|
|
|
|
|
isRestarting,
|
|
|
|
|
requiresRestartPending,
|
|
|
|
|
}: {
|
|
|
|
|
form: AgentSettingsDraft;
|
|
|
|
|
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
|
|
|
|
|
settings: SettingsPayload;
|
|
|
|
|
dirty: boolean;
|
|
|
|
|
saving: boolean;
|
|
|
|
|
onSave: () => void;
|
|
|
|
|
onRestart?: () => void;
|
|
|
|
|
isRestarting?: boolean;
|
|
|
|
|
requiresRestartPending: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
2026-05-29 03:42:53 +08:00
|
|
|
const isNativeHost = getHostApi() !== null || (settings.surface ?? settings.runtime_surface) === "native";
|
|
|
|
|
const restartActionLabel = isNativeHost
|
|
|
|
|
? tx("app.system.restartEngine", "Restart engine")
|
|
|
|
|
: t("app.system.restart");
|
|
|
|
|
const restartingActionLabel = isNativeHost
|
|
|
|
|
? tx("app.system.restartingEngine", "Restarting engine...")
|
|
|
|
|
: t("app.system.restarting");
|
|
|
|
|
const [diagnosticsPath, setDiagnosticsPath] = useState<string | null>(null);
|
|
|
|
|
const [hostActionMessage, setHostActionMessage] = useState<{
|
|
|
|
|
target: "logs" | "diagnostics";
|
|
|
|
|
message: string;
|
|
|
|
|
} | null>(null);
|
|
|
|
|
const [hostActionBusy, setHostActionBusy] =
|
|
|
|
|
useState<"logs" | "diagnostics" | null>(null);
|
|
|
|
|
const hostApi = getHostApi();
|
|
|
|
|
const engineState = isRestarting
|
|
|
|
|
? tx("settings.values.restartingEngine", "Restarting")
|
|
|
|
|
: settings.apply_state?.status === "pending"
|
|
|
|
|
? tx("settings.values.pending", "Pending")
|
|
|
|
|
: tx("settings.values.ready", "Ready");
|
|
|
|
|
const runHostAction = async (
|
|
|
|
|
target: "logs" | "diagnostics",
|
|
|
|
|
action: () => Promise<string | void>,
|
|
|
|
|
successMessage: (result: string | void) => string,
|
|
|
|
|
failureMessage: string,
|
|
|
|
|
) => {
|
|
|
|
|
if (!hostApi) {
|
|
|
|
|
setHostActionMessage({
|
|
|
|
|
target,
|
|
|
|
|
message: tx(
|
|
|
|
|
"settings.status.hostApiUnavailable",
|
|
|
|
|
"Host actions are only available inside the native app.",
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setHostActionBusy(target);
|
|
|
|
|
setHostActionMessage(null);
|
|
|
|
|
try {
|
|
|
|
|
const result = await action();
|
|
|
|
|
setHostActionMessage({ target, message: successMessage(result) });
|
|
|
|
|
} catch {
|
|
|
|
|
setHostActionMessage({ target, message: failureMessage });
|
|
|
|
|
} finally {
|
|
|
|
|
setHostActionBusy(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-05-19 22:42:38 +08:00
|
|
|
return (
|
|
|
|
|
<div className="space-y-7">
|
|
|
|
|
<section>
|
|
|
|
|
<SettingsSectionTitle>{tx("settings.sections.identity", "Identity")}</SettingsSectionTitle>
|
|
|
|
|
<SettingsGroup>
|
2026-05-29 03:42:53 +08:00
|
|
|
<SettingsRow title={tx("settings.rows.botName", "Bot name")} description={tx("settings.help.botName", "Shown wherever nanobot uses a display name.")}>
|
2026-05-19 22:42:38 +08:00
|
|
|
<Input
|
|
|
|
|
value={form.botName}
|
|
|
|
|
onChange={(event) => setForm((prev) => ({ ...prev, botName: event.target.value }))}
|
|
|
|
|
className="h-8 w-[220px] rounded-full text-[13px]"
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
2026-05-29 03:42:53 +08:00
|
|
|
<SettingsRow title={tx("settings.rows.botIcon", "Bot icon")} description={tx("settings.help.botIcon", "Short emoji or text shown with the bot name.")}>
|
2026-05-19 22:42:38 +08:00
|
|
|
<Input
|
|
|
|
|
value={form.botIcon}
|
|
|
|
|
onChange={(event) => setForm((prev) => ({ ...prev, botIcon: event.target.value }))}
|
|
|
|
|
className="h-8 w-[120px] rounded-full text-center text-[13px]"
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
2026-05-29 03:42:53 +08:00
|
|
|
<SettingsRow title={tx("settings.rows.timezone", "Timezone")} description={tx("settings.help.timezone", "Used for schedules and time-aware replies.")}>
|
2026-05-24 13:38:37 +08:00
|
|
|
<TimezonePicker
|
2026-05-19 22:42:38 +08:00
|
|
|
value={form.timezone}
|
2026-05-24 13:38:37 +08:00
|
|
|
onChange={(timezone) => setForm((prev) => ({ ...prev, timezone }))}
|
2026-05-19 22:42:38 +08:00
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<RestartSettingsFooter
|
|
|
|
|
dirty={dirty}
|
|
|
|
|
saving={saving}
|
|
|
|
|
pendingRestart={requiresRestartPending}
|
2026-05-29 03:42:53 +08:00
|
|
|
dirtyMessage={
|
|
|
|
|
isNativeHost
|
|
|
|
|
? tx("settings.status.hostRestartAfterSaving", "Save changes and nanobot will restart its engine.")
|
|
|
|
|
: tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")
|
|
|
|
|
}
|
|
|
|
|
pendingMessage={
|
|
|
|
|
isNativeHost
|
|
|
|
|
? tx("settings.status.hostRestartPending", "Saved. Restarting engine when ready.")
|
|
|
|
|
: tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
|
|
|
|
|
}
|
2026-05-19 22:42:38 +08:00
|
|
|
onSave={onSave}
|
|
|
|
|
onRestart={onRestart}
|
|
|
|
|
isRestarting={isRestarting}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsGroup>
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-05-29 03:42:53 +08:00
|
|
|
{isNativeHost ? (
|
|
|
|
|
<section>
|
|
|
|
|
<SettingsSectionTitle>{tx("settings.sections.nativeHost", "Native host")}</SettingsSectionTitle>
|
|
|
|
|
<SettingsGroup>
|
|
|
|
|
<ReadOnlyRow title={tx("settings.rows.engine", "Engine")} value={engineState} />
|
|
|
|
|
{settings.runtime_capabilities?.can_open_logs ? (
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.logs", "Logs")}
|
|
|
|
|
description={
|
|
|
|
|
hostActionMessage?.target === "logs"
|
|
|
|
|
? hostActionMessage.message
|
|
|
|
|
: tx("settings.help.logs", "Open the native engine log folder.")
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
void runHostAction(
|
|
|
|
|
"logs",
|
|
|
|
|
() => hostApi!.openLogs(),
|
|
|
|
|
() => tx("settings.status.logsOpened", "Opened logs folder."),
|
|
|
|
|
tx("settings.status.logsOpenFailed", "Could not open logs folder."),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
disabled={hostActionBusy !== null}
|
|
|
|
|
className="rounded-full"
|
|
|
|
|
>
|
|
|
|
|
{hostActionBusy === "logs"
|
|
|
|
|
? tx("settings.actions.opening", "Opening...")
|
|
|
|
|
: tx("settings.actions.open", "Open")}
|
|
|
|
|
</Button>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
) : null}
|
|
|
|
|
{settings.runtime_capabilities?.can_export_diagnostics ? (
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.diagnostics", "Diagnostics")}
|
|
|
|
|
description={
|
|
|
|
|
hostActionMessage?.target === "diagnostics"
|
|
|
|
|
? hostActionMessage.message
|
|
|
|
|
: diagnosticsPath
|
|
|
|
|
? diagnosticsPath
|
|
|
|
|
: tx("settings.help.diagnostics", "Export a small runtime report for support.")
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
void runHostAction(
|
|
|
|
|
"diagnostics",
|
|
|
|
|
async () => {
|
|
|
|
|
const path = await hostApi!.exportDiagnostics();
|
|
|
|
|
setDiagnosticsPath(path);
|
|
|
|
|
return path;
|
|
|
|
|
},
|
|
|
|
|
(path) =>
|
|
|
|
|
t("settings.status.diagnosticsExported", {
|
|
|
|
|
path: String(path ?? ""),
|
|
|
|
|
defaultValue: "Diagnostics exported to {{path}}.",
|
|
|
|
|
}),
|
|
|
|
|
tx("settings.status.diagnosticsExportFailed", "Could not export diagnostics."),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
disabled={hostActionBusy !== null}
|
|
|
|
|
className="rounded-full"
|
|
|
|
|
>
|
|
|
|
|
{hostActionBusy === "diagnostics"
|
|
|
|
|
? tx("settings.actions.exporting", "Exporting...")
|
|
|
|
|
: tx("settings.actions.export", "Export")}
|
|
|
|
|
</Button>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
) : null}
|
|
|
|
|
</SettingsGroup>
|
|
|
|
|
</section>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
<section>
|
|
|
|
|
<SettingsSectionTitle>{t("settings.sections.system")}</SettingsSectionTitle>
|
|
|
|
|
<SettingsGroup>
|
2026-06-06 19:49:33 +08:00
|
|
|
{!isNativeHost ? (
|
|
|
|
|
<ReadOnlyRow
|
|
|
|
|
title={tx("settings.rows.gateway", "Gateway")}
|
|
|
|
|
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`}
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
2026-05-29 03:42:53 +08:00
|
|
|
<ReadOnlyRow title={t("settings.rows.configPath")} value={settings.runtime.config_path} />
|
|
|
|
|
<ReadOnlyRow title={tx("settings.rows.workspacePath", "Default workspace")} value={settings.runtime.workspace_path} />
|
2026-05-19 22:42:38 +08:00
|
|
|
{onRestart && !requiresRestartPending ? (
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={t("settings.rows.restart")}
|
|
|
|
|
description={t("app.system.restartHint")}
|
|
|
|
|
>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={onRestart}
|
|
|
|
|
disabled={isRestarting}
|
|
|
|
|
className="rounded-full"
|
|
|
|
|
>
|
|
|
|
|
{isRestarting ? (
|
|
|
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
|
|
|
|
) : (
|
|
|
|
|
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
)}
|
2026-05-29 03:42:53 +08:00
|
|
|
{isRestarting ? restartingActionLabel : restartActionLabel}
|
2026-05-19 22:42:38 +08:00
|
|
|
</Button>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
) : null}
|
|
|
|
|
</SettingsGroup>
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 03:42:53 +08:00
|
|
|
function AdvancedSettings({
|
|
|
|
|
form,
|
|
|
|
|
dirty,
|
|
|
|
|
saving,
|
|
|
|
|
requiresRestartPending,
|
|
|
|
|
isNativeHostSurface,
|
|
|
|
|
onChangeForm,
|
|
|
|
|
onSave,
|
|
|
|
|
onRestart,
|
|
|
|
|
isRestarting,
|
|
|
|
|
}: {
|
|
|
|
|
form: NetworkSafetySettingsUpdate;
|
|
|
|
|
dirty: boolean;
|
|
|
|
|
saving: boolean;
|
|
|
|
|
requiresRestartPending: boolean;
|
|
|
|
|
isNativeHostSurface: boolean;
|
|
|
|
|
onChangeForm: Dispatch<SetStateAction<NetworkSafetySettingsUpdate>>;
|
|
|
|
|
onSave: () => void;
|
|
|
|
|
onRestart?: () => void;
|
|
|
|
|
isRestarting?: boolean;
|
|
|
|
|
}) {
|
2026-05-19 22:42:38 +08:00
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-7">
|
|
|
|
|
<section>
|
2026-05-29 03:42:53 +08:00
|
|
|
<SettingsSectionTitle>
|
|
|
|
|
{isNativeHostSurface
|
|
|
|
|
? tx("settings.sections.hostSafety", "App safety")
|
|
|
|
|
: tx("settings.sections.webuiSafety", "Web safety")}
|
|
|
|
|
</SettingsSectionTitle>
|
2026-05-19 22:42:38 +08:00
|
|
|
<SettingsGroup>
|
2026-05-29 03:42:53 +08:00
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.localServiceAccess", "Local Service Access")}
|
|
|
|
|
description={tx(
|
|
|
|
|
isNativeHostSurface ? "settings.help.localServiceAccessNative" : "settings.help.localServiceAccess",
|
|
|
|
|
isNativeHostSurface
|
|
|
|
|
? "Allow Full Access shell commands to reach services on this Mac."
|
|
|
|
|
: "Allow Full Access shell commands to reach localhost services.",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<ToggleButton
|
|
|
|
|
checked={form.webuiAllowLocalServiceAccess}
|
|
|
|
|
onChange={(webuiAllowLocalServiceAccess) =>
|
|
|
|
|
onChangeForm((prev) => ({ ...prev, webuiAllowLocalServiceAccess }))
|
|
|
|
|
}
|
|
|
|
|
ariaLabel={tx("settings.rows.localServiceAccess", "Local Service Access")}
|
|
|
|
|
label={form.webuiAllowLocalServiceAccess ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
title={tx("settings.rows.webuiDefaultAccess", "Default access")}
|
|
|
|
|
description={tx(
|
|
|
|
|
isNativeHostSurface ? "settings.help.webuiDefaultAccessNative" : "settings.help.webuiDefaultAccess",
|
|
|
|
|
isNativeHostSurface
|
|
|
|
|
? "Used by native chats without a project-specific permission."
|
|
|
|
|
: "Used by web chats without a project-specific permission.",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<SegmentedControl
|
|
|
|
|
value={form.webuiDefaultAccessMode}
|
|
|
|
|
options={[
|
|
|
|
|
{ value: "default", label: tx("settings.values.defaultPermission", "Default Permission") },
|
|
|
|
|
{ value: "full", label: tx("settings.values.fullAccess", "Full Access") },
|
|
|
|
|
]}
|
|
|
|
|
onChange={(webuiDefaultAccessMode) =>
|
|
|
|
|
onChangeForm((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
webuiDefaultAccessMode: webuiDefaultAccessMode as WebuiDefaultAccessMode,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<RestartSettingsFooter
|
|
|
|
|
dirty={dirty}
|
|
|
|
|
saving={saving}
|
|
|
|
|
pendingRestart={requiresRestartPending}
|
|
|
|
|
onSave={onSave}
|
|
|
|
|
onRestart={onRestart}
|
|
|
|
|
isRestarting={isRestarting}
|
|
|
|
|
/>
|
2026-05-19 22:42:38 +08:00
|
|
|
</SettingsGroup>
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-05-29 03:42:53 +08:00
|
|
|
<p className="max-w-3xl px-1 text-sm leading-6 text-muted-foreground">
|
|
|
|
|
{tx(
|
|
|
|
|
"settings.help.securityManagedControls",
|
|
|
|
|
"Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
|
|
|
|
|
)}
|
|
|
|
|
</p>
|
2026-05-19 22:42:38 +08:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 13:38:37 +08:00
|
|
|
function TimezonePicker({
|
|
|
|
|
value,
|
|
|
|
|
onChange,
|
|
|
|
|
}: {
|
|
|
|
|
value: string;
|
|
|
|
|
onChange: (timezone: string) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
|
|
|
const [query, setQuery] = useState("");
|
|
|
|
|
const options = useMemo(() => timezoneOptions(value), [value]);
|
|
|
|
|
const filteredOptions = useMemo(() => filterTimezoneOptions(options, query), [options, query]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<DropdownMenu onOpenChange={(open) => !open && setQuery("")}>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
className={cn(
|
|
|
|
|
"h-8 w-[220px] justify-between rounded-full border-input bg-background px-3 text-[13px] font-normal shadow-none",
|
|
|
|
|
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span className="truncate">{value || tx("settings.timezone.select", "Select timezone")}</span>
|
|
|
|
|
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
|
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent
|
|
|
|
|
align="end"
|
2026-05-29 03:42:53 +08:00
|
|
|
className="w-[340px] max-w-[calc(100vw-2rem)]"
|
2026-05-24 13:38:37 +08:00
|
|
|
>
|
|
|
|
|
<div className="sticky top-0 z-10 bg-popover px-1 pb-1">
|
|
|
|
|
<div className="flex h-9 items-center gap-2 rounded-full border border-input bg-background px-3">
|
|
|
|
|
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
|
|
|
|
<Input
|
|
|
|
|
autoFocus
|
|
|
|
|
value={query}
|
|
|
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
|
|
|
onKeyDown={(event) => event.stopPropagation()}
|
|
|
|
|
placeholder={tx("settings.timezone.search", "Search timezone")}
|
|
|
|
|
className="h-7 border-0 bg-transparent px-0 text-[13px] shadow-none focus-visible:ring-0"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-05-30 23:45:26 +08:00
|
|
|
<div
|
|
|
|
|
className="mt-1 max-h-[18rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent"
|
|
|
|
|
data-testid="timezone-picker-list"
|
|
|
|
|
>
|
2026-05-24 13:38:37 +08:00
|
|
|
{filteredOptions.length ? (
|
|
|
|
|
filteredOptions.map((option) => {
|
|
|
|
|
const selected = option.name === value;
|
|
|
|
|
return (
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
key={option.name}
|
|
|
|
|
onSelect={() => onChange(option.name)}
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex h-9 cursor-default items-center justify-between gap-3 rounded-[12px] px-2.5 text-[13px]",
|
|
|
|
|
"focus:bg-muted/85 focus:text-foreground",
|
|
|
|
|
selected && "bg-muted/80 text-foreground focus:bg-muted",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span className="min-w-0 truncate font-medium text-foreground">{option.name}</span>
|
|
|
|
|
<span className="ml-auto flex shrink-0 items-center gap-2">
|
|
|
|
|
<span className="text-[11.5px] font-medium text-muted-foreground/80">
|
|
|
|
|
{option.offset}
|
|
|
|
|
</span>
|
|
|
|
|
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
|
|
|
|
</span>
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
) : (
|
|
|
|
|
<div className="px-3 py-5 text-center text-[12px] text-muted-foreground">
|
|
|
|
|
{tx("settings.timezone.empty", "No matching timezones.")}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
function ProviderPicker({
|
|
|
|
|
providers,
|
|
|
|
|
value,
|
|
|
|
|
emptyLabel,
|
2026-05-24 13:38:37 +08:00
|
|
|
showProviderLogos = false,
|
2026-05-19 22:42:38 +08:00
|
|
|
onChange,
|
|
|
|
|
}: {
|
|
|
|
|
providers: Array<{ name: string; label: string }>;
|
|
|
|
|
value: string;
|
|
|
|
|
emptyLabel: string;
|
2026-05-24 13:38:37 +08:00
|
|
|
showProviderLogos?: boolean;
|
2026-05-19 22:42:38 +08:00
|
|
|
onChange: (provider: string) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const selectedProvider = providers.find((provider) => provider.name === value) ?? null;
|
|
|
|
|
const disabled = providers.length === 0;
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-31 17:00:42 +08:00
|
|
|
<DropdownMenu modal={false}>
|
2026-05-19 22:42:38 +08:00
|
|
|
<DropdownMenuTrigger asChild disabled={disabled}>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
className={cn(
|
|
|
|
|
"h-8 w-[210px] justify-between rounded-full border-input bg-background px-3 text-[13px] font-normal shadow-none",
|
|
|
|
|
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
|
|
|
|
|
disabled && "text-muted-foreground",
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-05-24 13:38:37 +08:00
|
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
|
|
|
{selectedProvider && showProviderLogos ? (
|
|
|
|
|
<ProviderPickerIcon
|
|
|
|
|
provider={selectedProvider.name}
|
|
|
|
|
showBrandLogos={showProviderLogos}
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
<span className="truncate">{selectedProvider?.label ?? emptyLabel}</span>
|
|
|
|
|
</span>
|
2026-05-19 22:42:38 +08:00
|
|
|
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
|
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent
|
|
|
|
|
align="end"
|
2026-05-30 23:45:26 +08:00
|
|
|
className="max-h-[18rem] w-[240px] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
|
2026-05-19 22:42:38 +08:00
|
|
|
>
|
|
|
|
|
{providers.map((provider) => {
|
|
|
|
|
const selected = provider.name === value;
|
2026-05-09 06:48:26 +00:00
|
|
|
return (
|
2026-05-19 22:42:38 +08:00
|
|
|
<DropdownMenuItem
|
|
|
|
|
key={provider.name}
|
|
|
|
|
onSelect={() => onChange(provider.name)}
|
2026-05-09 06:48:26 +00:00
|
|
|
className={cn(
|
2026-05-24 13:38:37 +08:00
|
|
|
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2.5 py-2 text-[13px]",
|
|
|
|
|
"focus:bg-muted/85 focus:text-foreground",
|
|
|
|
|
selected && "bg-muted/80 text-foreground focus:bg-muted",
|
2026-05-09 06:48:26 +00:00
|
|
|
)}
|
2026-05-08 15:31:52 +00:00
|
|
|
>
|
2026-05-24 13:38:37 +08:00
|
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
|
|
|
{showProviderLogos ? (
|
|
|
|
|
<ProviderPickerIcon
|
|
|
|
|
provider={provider.name}
|
|
|
|
|
showBrandLogos={showProviderLogos}
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
<span className="truncate">{provider.label}</span>
|
|
|
|
|
</span>
|
2026-05-19 22:42:38 +08:00
|
|
|
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
|
|
|
|
</DropdownMenuItem>
|
2026-05-09 06:48:26 +00:00
|
|
|
);
|
|
|
|
|
})}
|
2026-05-19 22:42:38 +08:00
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-09 06:48:26 +00:00
|
|
|
|
2026-05-30 23:45:26 +08:00
|
|
|
function ModelIdPicker({
|
|
|
|
|
token,
|
|
|
|
|
settings,
|
|
|
|
|
provider,
|
|
|
|
|
value,
|
|
|
|
|
showProviderLogos,
|
|
|
|
|
onChange,
|
|
|
|
|
}: {
|
|
|
|
|
token: string;
|
|
|
|
|
settings: SettingsPayload;
|
|
|
|
|
provider: string;
|
|
|
|
|
value: string;
|
|
|
|
|
showProviderLogos: boolean;
|
|
|
|
|
onChange: (model: string) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
const [query, setQuery] = useState("");
|
|
|
|
|
const [payload, setPayload] = useState<ProviderModelsPayload | null>(null);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const effectiveProvider =
|
|
|
|
|
provider === "auto" ? settings.agent.resolved_provider ?? provider : provider;
|
2026-06-06 19:49:33 +08:00
|
|
|
const hasConcreteProvider = Boolean(effectiveProvider && effectiveProvider !== "auto");
|
|
|
|
|
const providerRow = settingsProviderRow(settings, effectiveProvider);
|
|
|
|
|
const providerConfigured = settingsProviderConfigured(settings, effectiveProvider);
|
|
|
|
|
const providerRequiresConfiguration = hasConcreteProvider && !providerConfigured;
|
|
|
|
|
const providerUsesManualModelIds =
|
|
|
|
|
hasConcreteProvider && providerConfigured && providerRow?.auth_type === "oauth";
|
|
|
|
|
const canFetchModels =
|
|
|
|
|
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
|
2026-05-30 23:45:26 +08:00
|
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
|
|
|
const providerModels = payload?.models ?? [];
|
|
|
|
|
const visibleModels = providerModels
|
|
|
|
|
.filter((model) => {
|
|
|
|
|
if (!normalizedQuery) return true;
|
|
|
|
|
return [model.id, model.label ?? "", model.owned_by ?? ""]
|
|
|
|
|
.some((field) => field.toLowerCase().includes(normalizedQuery));
|
|
|
|
|
})
|
|
|
|
|
.slice(0, 80);
|
|
|
|
|
const isCatalog = payload?.catalog_kind === "catalog";
|
|
|
|
|
const defersModelList = DEFERRED_MODEL_LIST_PROVIDERS.has(effectiveProvider);
|
|
|
|
|
const hasDeferredSearchQuery =
|
|
|
|
|
normalizedQuery.length >= DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH;
|
|
|
|
|
const shouldFetchModels =
|
|
|
|
|
canFetchModels && (!defersModelList || hasDeferredSearchQuery);
|
|
|
|
|
const waitingForModelSearch =
|
|
|
|
|
open && canFetchModels && defersModelList && !hasDeferredSearchQuery;
|
|
|
|
|
const hasModelList = payload?.status === "available";
|
|
|
|
|
const showModels = Boolean(hasModelList && payload && (!isCatalog || normalizedQuery));
|
|
|
|
|
const customCandidate = query.trim();
|
2026-06-06 19:49:33 +08:00
|
|
|
const allowCustomModel = !providerRequiresConfiguration;
|
2026-05-30 23:45:26 +08:00
|
|
|
const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
|
|
|
|
|
const providerModelCount = payload?.model_count ?? providerModels.length;
|
2026-06-06 19:49:33 +08:00
|
|
|
const modelUnconfigured = !value.trim() || !providerConfigured;
|
2026-05-30 23:45:26 +08:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!open) return;
|
2026-06-06 19:49:33 +08:00
|
|
|
setQuery(providerUsesManualModelIds || !hasConcreteProvider ? value : "");
|
|
|
|
|
}, [open, effectiveProvider, hasConcreteProvider, providerUsesManualModelIds, value]);
|
2026-05-30 23:45:26 +08:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!open || !shouldFetchModels) {
|
|
|
|
|
setPayload(null);
|
|
|
|
|
setError(null);
|
|
|
|
|
setLoading(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
setPayload(null);
|
|
|
|
|
setError(null);
|
|
|
|
|
setLoading(true);
|
|
|
|
|
fetchProviderModels(token, effectiveProvider)
|
|
|
|
|
.then((nextPayload) => {
|
|
|
|
|
if (!cancelled) setPayload(nextPayload);
|
|
|
|
|
})
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
if (!cancelled) setError((err as Error).message);
|
|
|
|
|
})
|
|
|
|
|
.finally(() => {
|
|
|
|
|
if (!cancelled) setLoading(false);
|
|
|
|
|
});
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
|
|
|
|
}, [effectiveProvider, open, shouldFetchModels, token]);
|
|
|
|
|
|
|
|
|
|
const selectModel = (model: string) => {
|
|
|
|
|
onChange(model);
|
|
|
|
|
setOpen(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const renderModelRow = (
|
|
|
|
|
model: ProviderModelsPayload["models"][number],
|
|
|
|
|
options: { selected?: boolean } = {},
|
|
|
|
|
) => (
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
key={model.id}
|
|
|
|
|
onSelect={() => selectModel(model.id)}
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2 py-1.5 text-[12px]",
|
|
|
|
|
"focus:bg-muted/85 focus:text-foreground",
|
|
|
|
|
options.selected && "bg-muted/80 text-foreground focus:bg-muted",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span className="flex min-w-0 items-center gap-2">
|
2026-06-06 19:49:33 +08:00
|
|
|
<ProviderPickerIcon
|
|
|
|
|
provider={effectiveProvider}
|
|
|
|
|
showBrandLogos={showProviderLogos}
|
|
|
|
|
unconfigured={!providerConfigured}
|
|
|
|
|
/>
|
2026-05-30 23:45:26 +08:00
|
|
|
<span className="min-w-0 truncate font-medium text-foreground">
|
|
|
|
|
{model.label ?? model.id}
|
|
|
|
|
</span>
|
|
|
|
|
</span>
|
|
|
|
|
<span className="ml-2 flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
|
|
|
|
|
{model.context_window ? <span>{formatContextWindow(model.context_window)}</span> : null}
|
|
|
|
|
{options.selected ? <Check className="h-3.5 w-3.5 text-foreground" aria-hidden /> : null}
|
|
|
|
|
</span>
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
className={cn(
|
|
|
|
|
"h-9 w-[min(360px,70vw)] justify-between rounded-full border-input bg-background px-3 text-[12px] font-normal shadow-none",
|
|
|
|
|
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span className="flex min-w-0 items-center gap-2">
|
2026-06-06 19:49:33 +08:00
|
|
|
<ProviderPickerIcon
|
|
|
|
|
provider={effectiveProvider}
|
|
|
|
|
showBrandLogos={showProviderLogos}
|
|
|
|
|
unconfigured={modelUnconfigured}
|
|
|
|
|
/>
|
2026-05-30 23:45:26 +08:00
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
"min-w-0 truncate font-medium",
|
|
|
|
|
value ? "text-foreground" : "text-muted-foreground",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{value || tx("settings.models.selectModel", "Select model")}
|
|
|
|
|
</span>
|
|
|
|
|
</span>
|
|
|
|
|
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
|
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent
|
|
|
|
|
align="end"
|
|
|
|
|
className="w-[360px] max-w-[calc(100vw-2rem)] p-1.5"
|
|
|
|
|
>
|
|
|
|
|
<div className="p-1 pb-1.5">
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<Search
|
|
|
|
|
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
|
|
|
|
|
aria-hidden
|
|
|
|
|
/>
|
|
|
|
|
<Input
|
|
|
|
|
value={query}
|
|
|
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
|
|
|
onKeyDown={(event) => event.stopPropagation()}
|
|
|
|
|
placeholder={tx("settings.models.searchModels", "Search or type model ID")}
|
|
|
|
|
className="h-8 rounded-full pl-8 pr-3 text-[12px]"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-06 19:49:33 +08:00
|
|
|
{providerRequiresConfiguration ? (
|
|
|
|
|
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
|
|
|
|
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
|
|
|
|
|
</div>
|
|
|
|
|
) : providerUsesManualModelIds ? (
|
|
|
|
|
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
|
|
|
|
{tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
|
|
|
|
|
</div>
|
|
|
|
|
) : !canFetchModels ? (
|
2026-05-30 23:45:26 +08:00
|
|
|
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
|
|
|
|
{tx("settings.models.autoProviderCustomOnly", "Auto provider mode uses custom model IDs.")}
|
|
|
|
|
</div>
|
|
|
|
|
) : waitingForModelSearch ? (
|
|
|
|
|
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
|
|
|
|
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
|
|
|
|
|
</div>
|
|
|
|
|
) : loading ? (
|
|
|
|
|
<div className="flex items-center gap-2 px-2 py-1.5 text-[11px] text-muted-foreground">
|
|
|
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
|
|
|
|
{tx("settings.models.loadingModels", "Loading models...")}
|
|
|
|
|
</div>
|
|
|
|
|
) : error || payload?.status === "error" ? (
|
|
|
|
|
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
|
|
|
|
{payload?.message || error || tx("settings.models.loadFailed", "Model list unavailable.")}
|
|
|
|
|
</div>
|
|
|
|
|
) : payload?.status === "not_configured" ? (
|
|
|
|
|
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
|
|
|
|
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
|
|
|
|
|
</div>
|
|
|
|
|
) : payload?.status === "unsupported" || payload?.status === "missing_api_base" ? (
|
|
|
|
|
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
|
|
|
|
{payload.message || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
|
|
|
|
|
</div>
|
|
|
|
|
) : isCatalog && !normalizedQuery ? (
|
|
|
|
|
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
|
|
|
|
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
|
|
|
|
|
{providerModelCount ? ` ${providerModelCount} ${tx("settings.models.modelsAvailable", "available")}.` : ""}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{showModels && visibleModels.length ? (
|
|
|
|
|
<div className="max-h-[16rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent">
|
|
|
|
|
{visibleModels.map((model) =>
|
|
|
|
|
renderModelRow(model, { selected: model.id === value }),
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
) : showModels ? (
|
|
|
|
|
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">
|
|
|
|
|
{tx("settings.models.noModelResults", "No matching models.")}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-06-06 19:49:33 +08:00
|
|
|
{allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value ? (
|
2026-05-30 23:45:26 +08:00
|
|
|
<>
|
|
|
|
|
{showModels ? <DropdownMenuSeparator /> : null}
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
onSelect={() => selectModel(customCandidate)}
|
|
|
|
|
className="flex cursor-default items-center gap-2 rounded-[12px] px-2 py-1.5 text-[12px] focus:bg-muted/85"
|
|
|
|
|
>
|
|
|
|
|
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted/80 text-muted-foreground">
|
|
|
|
|
<Pencil className="h-3 w-3" aria-hidden />
|
|
|
|
|
</span>
|
|
|
|
|
<span className="min-w-0 truncate">
|
|
|
|
|
{tx("settings.models.useCustomModel", "Use")}{" "}
|
|
|
|
|
<span className="font-medium text-foreground">“{customCandidate}”</span>
|
|
|
|
|
</span>
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatContextWindow(tokens: number): string {
|
|
|
|
|
if (tokens >= 1_000_000) {
|
|
|
|
|
const value = tokens / 1_000_000;
|
|
|
|
|
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}M`;
|
|
|
|
|
}
|
|
|
|
|
if (tokens >= 1_000) {
|
|
|
|
|
const value = tokens / 1_000;
|
|
|
|
|
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}K`;
|
|
|
|
|
}
|
|
|
|
|
return String(tokens);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 13:38:37 +08:00
|
|
|
function ProviderPickerIcon({
|
|
|
|
|
provider,
|
|
|
|
|
showBrandLogos,
|
2026-06-06 19:49:33 +08:00
|
|
|
unconfigured = false,
|
2026-05-24 13:38:37 +08:00
|
|
|
}: {
|
|
|
|
|
provider: string;
|
|
|
|
|
showBrandLogos: boolean;
|
2026-06-06 19:49:33 +08:00
|
|
|
unconfigured?: boolean;
|
2026-05-24 13:38:37 +08:00
|
|
|
}) {
|
|
|
|
|
const [logoIndex, setLogoIndex] = useState(0);
|
|
|
|
|
const brand = providerBrand(provider);
|
2026-06-06 19:49:33 +08:00
|
|
|
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
|
2026-05-24 13:38:37 +08:00
|
|
|
const logoUrl = brand?.logoUrls[logoIndex];
|
|
|
|
|
|
|
|
|
|
useEffect(() => setLogoIndex(0), [provider]);
|
|
|
|
|
|
2026-06-06 19:49:33 +08:00
|
|
|
if (unconfigured) {
|
|
|
|
|
return (
|
|
|
|
|
<span
|
|
|
|
|
data-testid="provider-picker-unconfigured-icon"
|
|
|
|
|
className="grid h-5 w-5 shrink-0 place-items-center text-amber-700 dark:text-amber-200"
|
|
|
|
|
aria-hidden
|
|
|
|
|
>
|
|
|
|
|
<CircleAlert className="h-4 w-4" strokeWidth={1.8} />
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 13:38:37 +08:00
|
|
|
if (showBrandLogos && logoUrl) {
|
|
|
|
|
return (
|
|
|
|
|
<span
|
|
|
|
|
data-testid={`provider-picker-logo-${provider}`}
|
|
|
|
|
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-md border border-border/35 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]"
|
|
|
|
|
style={{ boxShadow: `inset 0 0 0 1px ${brand.color}22` }}
|
|
|
|
|
aria-hidden
|
|
|
|
|
>
|
|
|
|
|
<img
|
|
|
|
|
src={logoUrl}
|
|
|
|
|
alt=""
|
|
|
|
|
className="h-3.5 w-3.5 object-contain"
|
|
|
|
|
onError={() => setLogoIndex((index) => index + 1)}
|
|
|
|
|
/>
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (showBrandLogos && brand) {
|
|
|
|
|
return (
|
|
|
|
|
<span
|
|
|
|
|
data-testid={`provider-picker-logo-fallback-${provider}`}
|
|
|
|
|
className="grid h-5 w-5 shrink-0 place-items-center rounded-md text-[7.5px] font-semibold text-white shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)]"
|
|
|
|
|
style={{ backgroundColor: brand.color }}
|
|
|
|
|
aria-hidden
|
|
|
|
|
>
|
|
|
|
|
{brand.initials}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<span
|
|
|
|
|
className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground"
|
|
|
|
|
aria-hidden
|
|
|
|
|
>
|
|
|
|
|
<Icon className="h-3 w-3" strokeWidth={2} />
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
function ProviderSection({
|
|
|
|
|
title,
|
|
|
|
|
count,
|
|
|
|
|
empty,
|
|
|
|
|
children,
|
|
|
|
|
}: {
|
|
|
|
|
title: string;
|
|
|
|
|
count: number;
|
|
|
|
|
empty: string;
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<section className="space-y-3">
|
|
|
|
|
<ByokSectionHeader title={title} count={count} />
|
|
|
|
|
<div className="overflow-hidden rounded-[22px] border border-border/45 bg-card/86 shadow-[0_18px_65px_rgba(15,23,42,0.07)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_18px_65px_rgba(0,0,0,0.22)]">
|
|
|
|
|
{count > 0 ? (
|
|
|
|
|
<div className="divide-y divide-border/45">{children}</div>
|
|
|
|
|
) : (
|
|
|
|
|
<ByokEmptyState>{empty}</ByokEmptyState>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
2026-05-08 15:31:52 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ByokSectionHeader({ title, count }: { title: string; count: number }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center justify-between px-1">
|
|
|
|
|
<h2 className="text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
|
|
|
|
|
{title}
|
|
|
|
|
</h2>
|
|
|
|
|
<span className="rounded-full bg-muted px-2 py-0.5 text-[11.5px] font-medium text-muted-foreground">
|
|
|
|
|
{count}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ByokEmptyState({ children }: { children: ReactNode }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-[18px] border border-dashed border-border/65 bg-card/45 px-4 py-5 text-[13px] text-muted-foreground">
|
|
|
|
|
{children}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 22:25:12 +08:00
|
|
|
function ThirdPartyBrandNotice() {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
return (
|
|
|
|
|
<p className="px-1 text-[11.5px] leading-5 text-muted-foreground/75">
|
|
|
|
|
{t("settings.legal.thirdPartyBrands", {
|
|
|
|
|
defaultValue:
|
|
|
|
|
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.",
|
|
|
|
|
})}
|
|
|
|
|
</p>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 17:41:33 +08:00
|
|
|
function orderUnconfiguredProviders(
|
|
|
|
|
providers: SettingsPayload["providers"],
|
|
|
|
|
): SettingsPayload["providers"] {
|
|
|
|
|
return providers
|
|
|
|
|
.map((provider, index) => ({ provider, index }))
|
|
|
|
|
.sort((left, right) => {
|
|
|
|
|
const rank = providerVisibilityRank(left.provider) - providerVisibilityRank(right.provider);
|
|
|
|
|
return rank || left.index - right.index;
|
|
|
|
|
})
|
|
|
|
|
.map(({ provider }) => provider);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 03:42:53 +08:00
|
|
|
function uniqueProviders(
|
|
|
|
|
providers: SettingsPayload["providers"],
|
|
|
|
|
): SettingsPayload["providers"] {
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
return providers.filter((provider) => {
|
|
|
|
|
if (seen.has(provider.name)) return false;
|
|
|
|
|
seen.add(provider.name);
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 17:41:33 +08:00
|
|
|
function providerVisibilityRank(provider: SettingsPayload["providers"][number]): number {
|
|
|
|
|
const localRank = LOCAL_UNCONFIGURED_PROVIDER_ORDER.get(provider.name);
|
|
|
|
|
if (localRank !== undefined) return localRank;
|
|
|
|
|
if ((provider.api_key_required ?? true) === false) return 100;
|
|
|
|
|
return 200;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
function filterProviders(
|
|
|
|
|
providers: SettingsPayload["providers"],
|
|
|
|
|
query: string,
|
|
|
|
|
): SettingsPayload["providers"] {
|
|
|
|
|
const normalized = query.trim().toLowerCase();
|
|
|
|
|
if (!normalized) return providers;
|
|
|
|
|
return providers.filter((provider) =>
|
|
|
|
|
`${provider.name} ${provider.label} ${provider.api_base ?? ""} ${provider.default_api_base ?? ""}`
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.includes(normalized),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 13:38:37 +08:00
|
|
|
interface TimezoneOption {
|
|
|
|
|
name: string;
|
|
|
|
|
offset: string;
|
|
|
|
|
searchText: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function timezoneOptions(current: string): TimezoneOption[] {
|
|
|
|
|
return timezonesWithCurrent(current).map((name) => {
|
|
|
|
|
const offset = timezoneOffset(name);
|
|
|
|
|
return {
|
|
|
|
|
name,
|
|
|
|
|
offset,
|
|
|
|
|
searchText: `${name} ${name.replace(/_/g, " ")} ${offset}`.toLowerCase(),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function timezonesWithCurrent(current: string): string[] {
|
|
|
|
|
const intl = Intl as typeof Intl & {
|
|
|
|
|
supportedValuesOf?: (key: "timeZone") => string[];
|
|
|
|
|
};
|
2026-06-02 16:02:58 +08:00
|
|
|
let values: string[];
|
2026-05-24 13:38:37 +08:00
|
|
|
try {
|
|
|
|
|
values = intl.supportedValuesOf?.("timeZone") ?? [];
|
|
|
|
|
} catch {
|
|
|
|
|
values = [];
|
|
|
|
|
}
|
|
|
|
|
const deduped = new Set([...FALLBACK_TIMEZONES, ...values, current].filter(Boolean));
|
|
|
|
|
return Array.from(deduped).sort((left, right) => {
|
|
|
|
|
if (left === "UTC") return -1;
|
|
|
|
|
if (right === "UTC") return 1;
|
|
|
|
|
return left.localeCompare(right);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function filterTimezoneOptions(options: TimezoneOption[], query: string): TimezoneOption[] {
|
|
|
|
|
const normalized = query.trim().toLowerCase();
|
|
|
|
|
if (!normalized) return options;
|
|
|
|
|
return options.filter((option) => option.searchText.includes(normalized));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function timezoneOffset(timezone: string): string {
|
|
|
|
|
try {
|
|
|
|
|
const parts = new Intl.DateTimeFormat("en-US", {
|
|
|
|
|
timeZone: timezone,
|
|
|
|
|
timeZoneName: "shortOffset",
|
|
|
|
|
hour: "2-digit",
|
|
|
|
|
minute: "2-digit",
|
|
|
|
|
}).formatToParts(new Date());
|
|
|
|
|
const value = parts.find((part) => part.type === "timeZoneName")?.value;
|
|
|
|
|
return value ? value.replace(/^GMT$/, "UTC").replace(/^GMT/, "UTC") : "UTC";
|
|
|
|
|
} catch {
|
|
|
|
|
return "Custom timezone";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
function optionRowsWithCurrent(
|
|
|
|
|
options: Array<{ name: string; label: string }>,
|
|
|
|
|
value: string,
|
|
|
|
|
): Array<{ name: string; label: string }> {
|
|
|
|
|
if (!value || options.some((option) => option.name === value)) return options;
|
|
|
|
|
return [{ name: value, label: value }, ...options];
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 13:38:37 +08:00
|
|
|
function modelPresetProviderKey(
|
|
|
|
|
preset: SettingsPayload["model_presets"][number],
|
|
|
|
|
settings: SettingsPayload,
|
|
|
|
|
options: { draftProvider?: string } = {},
|
2026-05-19 22:42:38 +08:00
|
|
|
): string {
|
2026-05-24 13:38:37 +08:00
|
|
|
const provider = options.draftProvider ?? preset.provider;
|
|
|
|
|
if (provider === "auto") {
|
|
|
|
|
return settings.agent.resolved_provider || settings.agent.provider || preset.provider;
|
|
|
|
|
}
|
|
|
|
|
return provider;
|
2026-05-22 22:25:12 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-08 15:31:52 +00:00
|
|
|
const PROVIDER_ICONS: Record<string, LucideIcon> = {
|
|
|
|
|
custom: Hexagon,
|
|
|
|
|
openrouter: Sparkles,
|
2026-05-20 00:04:39 +08:00
|
|
|
skywork: Sparkles,
|
2026-05-08 15:31:52 +00:00
|
|
|
aihubmix: Triangle,
|
|
|
|
|
anthropic: Brain,
|
|
|
|
|
openai: Bot,
|
|
|
|
|
deepseek: Waves,
|
|
|
|
|
zhipu: Grid3X3,
|
|
|
|
|
dashscope: Cloud,
|
|
|
|
|
moonshot: Moon,
|
|
|
|
|
minimax: Zap,
|
|
|
|
|
minimax_anthropic: Brain,
|
|
|
|
|
groq: Cpu,
|
|
|
|
|
huggingface: Layers,
|
|
|
|
|
gemini: Gem,
|
|
|
|
|
mistral: Orbit,
|
|
|
|
|
siliconflow: Layers,
|
|
|
|
|
volcengine: Cloud,
|
|
|
|
|
volcengine_coding_plan: Cloud,
|
|
|
|
|
byteplus: Cloud,
|
|
|
|
|
byteplus_coding_plan: Cloud,
|
|
|
|
|
qianfan: Database,
|
2026-05-18 21:12:22 +08:00
|
|
|
ant_ling: Sparkles,
|
2026-05-08 15:31:52 +00:00
|
|
|
azure_openai: Cloud,
|
|
|
|
|
bedrock: Database,
|
2026-06-03 18:14:24 +00:00
|
|
|
bocha: Search,
|
2026-05-24 13:38:37 +08:00
|
|
|
brave: Search,
|
|
|
|
|
duckduckgo: Search,
|
|
|
|
|
exa: Search,
|
|
|
|
|
jina: Search,
|
|
|
|
|
kagi: Search,
|
|
|
|
|
olostep: Search,
|
|
|
|
|
searxng: Search,
|
|
|
|
|
tavily: Search,
|
2026-05-17 17:41:33 +08:00
|
|
|
vllm: Cpu,
|
|
|
|
|
ollama: Cpu,
|
|
|
|
|
lm_studio: Cpu,
|
|
|
|
|
atomic_chat: Cpu,
|
|
|
|
|
ovms: Cpu,
|
|
|
|
|
nvidia: Zap,
|
2026-05-08 15:31:52 +00:00
|
|
|
};
|
|
|
|
|
|
2026-05-22 22:25:12 +08:00
|
|
|
function ProviderIcon({
|
|
|
|
|
provider,
|
|
|
|
|
showBrandLogos,
|
|
|
|
|
}: {
|
|
|
|
|
provider: string;
|
|
|
|
|
showBrandLogos: boolean;
|
|
|
|
|
}) {
|
2026-05-24 13:38:37 +08:00
|
|
|
const [logoIndex, setLogoIndex] = useState(0);
|
2026-05-22 22:25:12 +08:00
|
|
|
const brand = providerBrand(provider);
|
2026-05-08 15:31:52 +00:00
|
|
|
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
|
2026-05-24 13:38:37 +08:00
|
|
|
const logoUrl = brand?.logoUrls[logoIndex];
|
|
|
|
|
|
|
|
|
|
useEffect(() => setLogoIndex(0), [provider]);
|
|
|
|
|
|
|
|
|
|
if (showBrandLogos && logoUrl) {
|
2026-05-22 22:25:12 +08:00
|
|
|
return (
|
|
|
|
|
<span
|
|
|
|
|
data-testid={`provider-logo-${provider}`}
|
|
|
|
|
className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-[14px] border border-border/45 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)]"
|
|
|
|
|
style={{ boxShadow: `inset 0 0 0 1px ${brand.color}22` }}
|
|
|
|
|
>
|
|
|
|
|
<img
|
2026-05-24 13:38:37 +08:00
|
|
|
src={logoUrl}
|
2026-05-22 22:25:12 +08:00
|
|
|
alt=""
|
|
|
|
|
className="h-6 w-6 object-contain"
|
2026-05-24 13:38:37 +08:00
|
|
|
onError={() => setLogoIndex((index) => index + 1)}
|
2026-05-22 22:25:12 +08:00
|
|
|
/>
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (showBrandLogos && brand) {
|
|
|
|
|
return (
|
|
|
|
|
<span
|
|
|
|
|
data-testid={`provider-logo-fallback-${provider}`}
|
|
|
|
|
className="grid h-10 w-10 shrink-0 place-items-center rounded-[14px] text-[11px] font-semibold text-white shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)]"
|
|
|
|
|
style={{ backgroundColor: brand.color }}
|
|
|
|
|
aria-hidden
|
|
|
|
|
>
|
|
|
|
|
{brand.initials}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-08 15:31:52 +00:00
|
|
|
return (
|
|
|
|
|
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-muted text-foreground/82 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)] dark:bg-muted/70">
|
|
|
|
|
<Icon className="h-5 w-5" strokeWidth={2} aria-hidden />
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 13:38:37 +08:00
|
|
|
function OverviewRowIcon({
|
|
|
|
|
icon: Icon,
|
|
|
|
|
}: {
|
|
|
|
|
icon: LucideIcon;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[12px] bg-muted text-foreground/82 transition-colors group-hover:bg-muted/80 dark:bg-muted/70">
|
|
|
|
|
<Icon className="h-4 w-4" aria-hidden />
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function OverviewValueLogo({
|
|
|
|
|
provider,
|
|
|
|
|
showBrandLogos,
|
|
|
|
|
}: {
|
|
|
|
|
provider: string | null | undefined;
|
|
|
|
|
showBrandLogos: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
const [logoIndex, setLogoIndex] = useState(0);
|
|
|
|
|
const brand = provider ? providerBrand(provider) : null;
|
|
|
|
|
const logoUrl = brand?.logoUrls[logoIndex];
|
|
|
|
|
|
|
|
|
|
useEffect(() => setLogoIndex(0), [provider]);
|
|
|
|
|
|
|
|
|
|
if (!provider || !showBrandLogos || !brand) return null;
|
|
|
|
|
|
|
|
|
|
if (logoUrl) {
|
|
|
|
|
return (
|
|
|
|
|
<span
|
|
|
|
|
data-testid={`overview-logo-${provider}`}
|
|
|
|
|
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-md border border-border/35 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]"
|
|
|
|
|
style={{ boxShadow: `inset 0 0 0 1px ${brand.color}22` }}
|
|
|
|
|
aria-hidden
|
|
|
|
|
>
|
|
|
|
|
<img
|
|
|
|
|
src={logoUrl}
|
|
|
|
|
alt=""
|
|
|
|
|
className="h-3.5 w-3.5 object-contain"
|
|
|
|
|
onError={() => setLogoIndex((index) => index + 1)}
|
|
|
|
|
/>
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<span
|
|
|
|
|
data-testid={`overview-logo-fallback-${provider}`}
|
|
|
|
|
className="grid h-5 w-5 shrink-0 place-items-center rounded-md text-[7.5px] font-semibold text-white shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)]"
|
|
|
|
|
style={{ backgroundColor: brand.color }}
|
|
|
|
|
aria-hidden
|
|
|
|
|
>
|
|
|
|
|
{brand.initials}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
function OverviewListRow({
|
|
|
|
|
icon: Icon,
|
2026-05-24 13:38:37 +08:00
|
|
|
valueLogoProvider,
|
2026-05-19 22:42:38 +08:00
|
|
|
title,
|
|
|
|
|
value,
|
|
|
|
|
caption,
|
2026-05-24 13:38:37 +08:00
|
|
|
showBrandLogos = false,
|
2026-05-19 22:42:38 +08:00
|
|
|
onClick,
|
|
|
|
|
}: {
|
|
|
|
|
icon: LucideIcon;
|
2026-05-24 13:38:37 +08:00
|
|
|
valueLogoProvider?: string | null;
|
2026-05-19 22:42:38 +08:00
|
|
|
title: string;
|
|
|
|
|
value: string;
|
|
|
|
|
caption: string;
|
2026-05-24 13:38:37 +08:00
|
|
|
showBrandLogos?: boolean;
|
2026-05-19 22:42:38 +08:00
|
|
|
onClick: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
className="group flex min-h-[68px] w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-muted/30 sm:px-5"
|
|
|
|
|
>
|
2026-05-24 13:38:37 +08:00
|
|
|
<OverviewRowIcon icon={Icon} />
|
2026-05-19 22:42:38 +08:00
|
|
|
<span className="min-w-0 flex-1">
|
|
|
|
|
<span className="block text-[14px] font-medium leading-5 text-foreground">{title}</span>
|
|
|
|
|
<span className="mt-0.5 block truncate text-[12px] leading-5 text-muted-foreground">{caption}</span>
|
|
|
|
|
</span>
|
|
|
|
|
<span className="ml-auto flex min-w-0 max-w-[48%] items-center gap-2">
|
2026-05-24 13:38:37 +08:00
|
|
|
<OverviewValueLogo provider={valueLogoProvider} showBrandLogos={showBrandLogos} />
|
2026-05-19 22:42:38 +08:00
|
|
|
<span className="truncate text-right text-[13px] leading-5 text-muted-foreground">
|
|
|
|
|
{value}
|
|
|
|
|
</span>
|
|
|
|
|
<ChevronRight
|
|
|
|
|
className="h-4 w-4 shrink-0 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5"
|
|
|
|
|
aria-hidden
|
|
|
|
|
/>
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 15:31:52 +00:00
|
|
|
function SettingsSectionTitle({ children }: { children: ReactNode }) {
|
|
|
|
|
return (
|
|
|
|
|
<h2 className="mb-2 px-1 text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
|
|
|
|
|
{children}
|
|
|
|
|
</h2>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SettingsGroup({ children }: { children: ReactNode }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="overflow-hidden rounded-[22px] border border-border/45 bg-card/86 shadow-[0_18px_65px_rgba(15,23,42,0.075)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_18px_65px_rgba(0,0,0,0.24)]">
|
|
|
|
|
<div className="divide-y divide-border/45">{children}</div>
|
2026-04-25 18:05:06 +00:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SettingsRow({
|
|
|
|
|
title,
|
2026-05-08 15:31:52 +00:00
|
|
|
description,
|
2026-04-25 18:05:06 +00:00
|
|
|
children,
|
|
|
|
|
}: {
|
|
|
|
|
title: string;
|
2026-05-08 15:31:52 +00:00
|
|
|
description?: string;
|
|
|
|
|
children?: ReactNode;
|
2026-04-25 18:05:06 +00:00
|
|
|
}) {
|
|
|
|
|
return (
|
2026-05-08 15:31:52 +00:00
|
|
|
<div className="flex min-h-[62px] flex-col gap-3 px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
2026-04-25 18:05:06 +00:00
|
|
|
<div className="min-w-0">
|
2026-05-08 15:31:52 +00:00
|
|
|
<div className="text-[14px] font-medium leading-5 text-foreground">{title}</div>
|
|
|
|
|
{description ? (
|
|
|
|
|
<div className="mt-0.5 max-w-[28rem] text-[12px] leading-5 text-muted-foreground">
|
|
|
|
|
{description}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-04-25 18:05:06 +00:00
|
|
|
</div>
|
2026-06-14 16:46:34 +08:00
|
|
|
{children ? <div className="min-w-0 sm:ml-6 sm:shrink-0">{children}</div> : null}
|
2026-04-25 18:05:06 +00:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 03:42:53 +08:00
|
|
|
function ReadOnlyRow({
|
|
|
|
|
title,
|
|
|
|
|
value,
|
|
|
|
|
description,
|
|
|
|
|
}: {
|
|
|
|
|
title: string;
|
|
|
|
|
value: string;
|
|
|
|
|
description?: string;
|
|
|
|
|
}) {
|
2026-05-19 22:42:38 +08:00
|
|
|
return (
|
2026-05-29 03:42:53 +08:00
|
|
|
<SettingsRow title={title} description={description}>
|
2026-06-14 16:46:34 +08:00
|
|
|
<span className="block max-w-full truncate text-left text-[13px] text-muted-foreground sm:max-w-[320px] sm:text-right">
|
2026-05-19 22:42:38 +08:00
|
|
|
{value}
|
|
|
|
|
</span>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 13:38:37 +08:00
|
|
|
function ModelPresetPicker({
|
|
|
|
|
presets,
|
|
|
|
|
value,
|
|
|
|
|
settings,
|
|
|
|
|
draftModel,
|
|
|
|
|
draftProvider,
|
2026-06-06 19:49:33 +08:00
|
|
|
providerConfigured,
|
2026-05-24 13:38:37 +08:00
|
|
|
showProviderLogos,
|
|
|
|
|
onChange,
|
|
|
|
|
onCreateConfiguration,
|
|
|
|
|
}: {
|
|
|
|
|
presets: SettingsPayload["model_presets"];
|
|
|
|
|
value: string;
|
|
|
|
|
settings: SettingsPayload;
|
|
|
|
|
draftModel: string;
|
|
|
|
|
draftProvider: string;
|
2026-06-06 19:49:33 +08:00
|
|
|
providerConfigured: boolean;
|
2026-05-24 13:38:37 +08:00
|
|
|
showProviderLogos: boolean;
|
|
|
|
|
onChange: (preset: string) => void;
|
|
|
|
|
onCreateConfiguration: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
|
|
|
const selectedPreset = presets.find((preset) => preset.name === value) ?? presets[0] ?? null;
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-31 17:00:42 +08:00
|
|
|
<DropdownMenu modal={false}>
|
2026-05-24 13:38:37 +08:00
|
|
|
<DropdownMenuTrigger asChild disabled={!presets.length}>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
2026-05-31 17:00:42 +08:00
|
|
|
aria-label={tx("settings.rows.currentModel", "Current configuration")}
|
2026-05-24 13:38:37 +08:00
|
|
|
disabled={!presets.length}
|
|
|
|
|
className={cn(
|
|
|
|
|
"h-12 w-[min(430px,72vw)] justify-between rounded-full border-input bg-background px-3.5 text-[13px] font-normal shadow-none",
|
|
|
|
|
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{selectedPreset ? (
|
|
|
|
|
<ModelPresetOptionContent
|
|
|
|
|
preset={selectedPreset}
|
|
|
|
|
settings={settings}
|
|
|
|
|
draftModel={draftModel}
|
|
|
|
|
draftProvider={draftProvider}
|
2026-06-06 19:49:33 +08:00
|
|
|
forceUnconfigured={selectedPreset?.is_default ? !providerConfigured : undefined}
|
2026-05-24 13:38:37 +08:00
|
|
|
showProviderLogos={showProviderLogos}
|
|
|
|
|
compact
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="truncate text-muted-foreground">
|
|
|
|
|
{tx("settings.models.selectModel", "Select model")}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
|
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent
|
|
|
|
|
align="end"
|
2026-05-30 23:45:26 +08:00
|
|
|
className="max-h-[20rem] w-[430px] max-w-[calc(100vw-2rem)] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
|
2026-05-24 13:38:37 +08:00
|
|
|
>
|
|
|
|
|
{presets.map((preset) => {
|
|
|
|
|
const selected = preset.name === value;
|
|
|
|
|
return (
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
key={preset.name}
|
|
|
|
|
onSelect={() => onChange(preset.name)}
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex cursor-default items-center justify-between gap-3 rounded-[12px] px-2.5 py-2 text-[13px]",
|
|
|
|
|
"focus:bg-muted/85 focus:text-foreground",
|
|
|
|
|
selected && "bg-muted/80 text-foreground focus:bg-muted",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<ModelPresetOptionContent
|
|
|
|
|
preset={preset}
|
|
|
|
|
settings={settings}
|
|
|
|
|
draftModel={draftModel}
|
|
|
|
|
draftProvider={draftProvider}
|
|
|
|
|
showProviderLogos={showProviderLogos}
|
|
|
|
|
/>
|
|
|
|
|
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
<div className="mt-1 border-t border-border/55 pt-1">
|
|
|
|
|
<DropdownMenuItem
|
2026-05-31 17:00:42 +08:00
|
|
|
onSelect={() => {
|
|
|
|
|
window.setTimeout(onCreateConfiguration, 0);
|
|
|
|
|
}}
|
2026-05-24 13:38:37 +08:00
|
|
|
className={cn(
|
|
|
|
|
"flex cursor-default items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] font-medium",
|
|
|
|
|
"text-foreground focus:bg-muted/85 focus:text-foreground",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
|
|
|
|
|
<Plus className="h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
</span>
|
|
|
|
|
<span>{tx("settings.models.addConfiguration", "Add configuration")}</span>
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
</div>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ModelPresetOptionContent({
|
|
|
|
|
preset,
|
|
|
|
|
settings,
|
|
|
|
|
draftModel,
|
|
|
|
|
draftProvider,
|
2026-06-06 19:49:33 +08:00
|
|
|
forceUnconfigured,
|
2026-05-24 13:38:37 +08:00
|
|
|
showProviderLogos,
|
|
|
|
|
compact = false,
|
|
|
|
|
}: {
|
|
|
|
|
preset: SettingsPayload["model_presets"][number];
|
|
|
|
|
settings: SettingsPayload;
|
|
|
|
|
draftModel: string;
|
|
|
|
|
draftProvider: string;
|
2026-06-06 19:49:33 +08:00
|
|
|
forceUnconfigured?: boolean;
|
2026-05-24 13:38:37 +08:00
|
|
|
showProviderLogos: boolean;
|
|
|
|
|
compact?: boolean;
|
|
|
|
|
}) {
|
2026-06-06 19:49:33 +08:00
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
2026-05-24 13:38:37 +08:00
|
|
|
const provider = modelPresetProviderKey(preset, settings, {
|
|
|
|
|
draftProvider: preset.is_default ? draftProvider : undefined,
|
|
|
|
|
});
|
|
|
|
|
const model = preset.is_default ? draftModel : preset.model;
|
|
|
|
|
const providerName = providerDisplayLabel(settings.providers, provider);
|
2026-06-06 19:49:33 +08:00
|
|
|
const providerConfigured =
|
|
|
|
|
forceUnconfigured === undefined
|
|
|
|
|
? settingsProviderConfigured(settings, provider)
|
|
|
|
|
: !forceUnconfigured;
|
|
|
|
|
const title = providerConfigured ? model || preset.label : tx("settings.values.notConfigured", "Not configured");
|
|
|
|
|
const caption = providerConfigured
|
|
|
|
|
? `${providerName}${preset.label ? ` · ${preset.label}` : ""}`
|
|
|
|
|
: providerName || model || preset.label
|
|
|
|
|
? [providerName, model || preset.label].filter(Boolean).join(" · ")
|
|
|
|
|
: tx("settings.byok.noConfiguredProviders", "No configured providers");
|
2026-05-24 13:38:37 +08:00
|
|
|
return (
|
|
|
|
|
<span className="flex min-w-0 items-center gap-2.5">
|
2026-06-06 19:49:33 +08:00
|
|
|
<ProviderPickerIcon
|
|
|
|
|
provider={provider}
|
|
|
|
|
showBrandLogos={showProviderLogos}
|
|
|
|
|
unconfigured={!providerConfigured}
|
|
|
|
|
/>
|
2026-05-24 13:38:37 +08:00
|
|
|
<span className="min-w-0 text-left leading-tight">
|
2026-06-06 19:49:33 +08:00
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
"block truncate font-medium",
|
|
|
|
|
providerConfigured ? "text-foreground" : "text-amber-800 dark:text-amber-200",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{title}
|
|
|
|
|
</span>
|
2026-05-24 13:38:37 +08:00
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
"mt-0.5 block truncate text-muted-foreground",
|
|
|
|
|
compact ? "text-[11.5px]" : "text-[12px]",
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-06-06 19:49:33 +08:00
|
|
|
{caption}
|
2026-05-24 13:38:37 +08:00
|
|
|
</span>
|
|
|
|
|
</span>
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 22:42:38 +08:00
|
|
|
function RestartSettingsFooter({
|
|
|
|
|
dirty,
|
|
|
|
|
saving,
|
|
|
|
|
pendingRestart,
|
|
|
|
|
disabled = false,
|
|
|
|
|
message,
|
|
|
|
|
dirtyMessage,
|
|
|
|
|
pendingMessage,
|
|
|
|
|
onSave,
|
|
|
|
|
onRestart,
|
|
|
|
|
onReset,
|
|
|
|
|
isRestarting,
|
|
|
|
|
}: {
|
|
|
|
|
dirty: boolean;
|
|
|
|
|
saving: boolean;
|
|
|
|
|
pendingRestart: boolean;
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
message?: string;
|
|
|
|
|
dirtyMessage?: string;
|
|
|
|
|
pendingMessage?: string;
|
|
|
|
|
onSave: () => void;
|
|
|
|
|
onRestart?: () => void;
|
|
|
|
|
onReset?: () => void;
|
|
|
|
|
isRestarting?: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
2026-05-29 03:42:53 +08:00
|
|
|
const isNativeHost = getHostApi() !== null;
|
|
|
|
|
const restartLabel = isNativeHost
|
|
|
|
|
? tx("app.system.restartEngine", "Restart engine")
|
|
|
|
|
: t("app.system.restart");
|
|
|
|
|
const restartingLabel = isNativeHost
|
|
|
|
|
? tx("app.system.restartingEngine", "Restarting engine...")
|
|
|
|
|
: t("app.system.restarting");
|
2026-05-19 22:42:38 +08:00
|
|
|
const statusMessage =
|
|
|
|
|
message ??
|
|
|
|
|
(pendingRestart && !dirty
|
|
|
|
|
? pendingMessage ?? tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
|
|
|
|
|
: dirty
|
|
|
|
|
? dirtyMessage ?? t("settings.status.unsaved")
|
|
|
|
|
: undefined);
|
|
|
|
|
const statusTone = disabled ? "danger" : dirty || pendingRestart ? "accent" : undefined;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
|
|
|
|
<div className="min-w-0 text-[13px] leading-5 text-muted-foreground">
|
|
|
|
|
<SettingsStatusMessage tone={statusTone}>{statusMessage}</SettingsStatusMessage>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex w-full shrink-0 flex-wrap justify-end gap-2 sm:w-auto">
|
|
|
|
|
{pendingRestart && !dirty && onRestart ? (
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={onRestart}
|
|
|
|
|
disabled={isRestarting}
|
|
|
|
|
className="rounded-full"
|
|
|
|
|
>
|
|
|
|
|
{isRestarting ? (
|
|
|
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
|
|
|
|
) : (
|
|
|
|
|
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
)}
|
2026-05-29 03:42:53 +08:00
|
|
|
{isRestarting ? restartingLabel : restartLabel}
|
2026-05-19 22:42:38 +08:00
|
|
|
</Button>
|
|
|
|
|
) : null}
|
|
|
|
|
{onReset ? (
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={onReset}
|
|
|
|
|
disabled={!dirty || saving}
|
|
|
|
|
className="rounded-full"
|
|
|
|
|
>
|
|
|
|
|
{t("settings.actions.cancel")}
|
|
|
|
|
</Button>
|
|
|
|
|
) : null}
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={onSave}
|
|
|
|
|
disabled={!dirty || disabled || saving}
|
|
|
|
|
className="rounded-full"
|
|
|
|
|
>
|
|
|
|
|
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 18:05:06 +00:00
|
|
|
function SettingsFooter({
|
|
|
|
|
dirty,
|
|
|
|
|
saving,
|
|
|
|
|
saved,
|
2026-05-29 03:42:53 +08:00
|
|
|
disabled = false,
|
|
|
|
|
message,
|
2026-04-25 18:05:06 +00:00
|
|
|
onSave,
|
|
|
|
|
}: {
|
|
|
|
|
dirty: boolean;
|
|
|
|
|
saving: boolean;
|
|
|
|
|
saved: boolean;
|
2026-05-29 03:42:53 +08:00
|
|
|
disabled?: boolean;
|
|
|
|
|
message?: string;
|
2026-04-25 18:05:06 +00:00
|
|
|
onSave: () => void;
|
|
|
|
|
}) {
|
2026-05-08 15:31:52 +00:00
|
|
|
const { t } = useTranslation();
|
2026-05-19 22:42:38 +08:00
|
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
2026-05-29 03:42:53 +08:00
|
|
|
const statusMessage = message ?? (dirty
|
2026-05-19 22:42:38 +08:00
|
|
|
? t("settings.status.unsaved")
|
|
|
|
|
: saved
|
|
|
|
|
? t("settings.status.savedRestart")
|
2026-05-29 03:42:53 +08:00
|
|
|
: tx("settings.status.upToDate", "Up to date."));
|
2026-04-25 18:05:06 +00:00
|
|
|
return (
|
2026-05-19 22:42:38 +08:00
|
|
|
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
2026-05-08 15:31:52 +00:00
|
|
|
<div className="text-[13px] text-muted-foreground">
|
2026-05-29 03:42:53 +08:00
|
|
|
<SettingsStatusMessage tone={disabled ? "danger" : dirty || saved ? "accent" : undefined}>
|
2026-05-19 22:42:38 +08:00
|
|
|
{statusMessage}
|
|
|
|
|
</SettingsStatusMessage>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex justify-end">
|
2026-05-29 03:42:53 +08:00
|
|
|
<Button size="sm" variant="outline" onClick={onSave} disabled={!dirty || disabled || saving} className="rounded-full">
|
2026-05-19 22:42:38 +08:00
|
|
|
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
|
|
|
|
|
</Button>
|
2026-04-25 18:05:06 +00:00
|
|
|
</div>
|
2026-05-19 22:42:38 +08:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SettingsStatusMessage({
|
|
|
|
|
children,
|
|
|
|
|
tone,
|
|
|
|
|
}: {
|
|
|
|
|
children?: ReactNode;
|
|
|
|
|
tone?: "accent" | "danger";
|
|
|
|
|
}) {
|
|
|
|
|
if (!children) return null;
|
|
|
|
|
return (
|
|
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
"inline-flex items-center gap-2",
|
|
|
|
|
tone === "accent" && "font-medium text-blue-600 dark:text-blue-300",
|
|
|
|
|
tone === "danger" && "font-medium text-destructive",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{tone ? (
|
|
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
"h-1.5 w-1.5 shrink-0 rounded-full",
|
|
|
|
|
tone === "accent" &&
|
|
|
|
|
"bg-blue-500 shadow-[0_0_0_3px_rgba(59,130,246,0.14)] dark:bg-blue-400 dark:shadow-[0_0_0_3px_rgba(96,165,250,0.18)]",
|
|
|
|
|
tone === "danger" && "bg-destructive/70",
|
|
|
|
|
)}
|
|
|
|
|
aria-hidden
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
<span>{children}</span>
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function StatusPill({
|
|
|
|
|
children,
|
|
|
|
|
tone = "neutral",
|
|
|
|
|
}: {
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
tone?: "neutral" | "success" | "warning";
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
"inline-flex max-w-[260px] items-center rounded-full px-2.5 py-1 text-[12px] font-medium",
|
|
|
|
|
tone === "success" && "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
|
|
|
|
tone === "warning" && "bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
|
|
|
|
tone === "neutral" && "bg-muted text-muted-foreground",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span className="truncate">{children}</span>
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SegmentedControl({
|
|
|
|
|
value,
|
|
|
|
|
options,
|
|
|
|
|
onChange,
|
|
|
|
|
}: {
|
|
|
|
|
value: string;
|
|
|
|
|
options: Array<{ value: string; label: string }>;
|
|
|
|
|
onChange: (value: string) => void;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="inline-flex h-8 items-center rounded-full bg-muted p-0.5 text-[12px] font-medium text-muted-foreground">
|
|
|
|
|
{options.map((option) => (
|
|
|
|
|
<button
|
|
|
|
|
key={option.value}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => onChange(option.value)}
|
|
|
|
|
className={cn(
|
|
|
|
|
"rounded-full px-3 py-1 transition-colors",
|
|
|
|
|
value === option.value && "bg-background text-foreground shadow-sm",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{option.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ToggleButton({
|
|
|
|
|
checked,
|
|
|
|
|
onChange,
|
2026-05-24 13:38:37 +08:00
|
|
|
ariaLabel,
|
2026-05-19 22:42:38 +08:00
|
|
|
label,
|
|
|
|
|
}: {
|
|
|
|
|
checked: boolean;
|
|
|
|
|
onChange: (checked: boolean) => void;
|
2026-05-24 13:38:37 +08:00
|
|
|
ariaLabel?: string;
|
2026-05-19 22:42:38 +08:00
|
|
|
label: string;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-05-24 13:38:37 +08:00
|
|
|
role="switch"
|
|
|
|
|
aria-checked={checked}
|
|
|
|
|
aria-label={ariaLabel ?? label}
|
2026-05-19 22:42:38 +08:00
|
|
|
onClick={() => onChange(!checked)}
|
|
|
|
|
className={cn(
|
2026-05-24 13:38:37 +08:00
|
|
|
"relative inline-flex h-[22px] w-[38px] shrink-0 items-center rounded-full p-[2px]",
|
|
|
|
|
"transition-colors duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
2026-05-19 22:42:38 +08:00
|
|
|
checked
|
2026-05-24 13:38:37 +08:00
|
|
|
? "bg-[#2997FF] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.035)]"
|
|
|
|
|
: "bg-muted shadow-[inset_0_0_0_1px_rgba(0,0,0,0.035)] hover:bg-muted/80",
|
2026-05-19 22:42:38 +08:00
|
|
|
)}
|
|
|
|
|
>
|
2026-05-24 13:38:37 +08:00
|
|
|
<span
|
|
|
|
|
aria-hidden
|
|
|
|
|
className={cn(
|
|
|
|
|
"h-[18px] w-[18px] rounded-full bg-background shadow-[0_1px_2px_rgba(0,0,0,0.18),0_2px_7px_rgba(0,0,0,0.11)]",
|
|
|
|
|
"transition-transform duration-200 ease-out",
|
|
|
|
|
checked ? "translate-x-[16px]" : "translate-x-0",
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<span className="sr-only">{label}</span>
|
2026-05-19 22:42:38 +08:00
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function NumberInput({
|
|
|
|
|
value,
|
|
|
|
|
min,
|
|
|
|
|
max,
|
|
|
|
|
onChange,
|
|
|
|
|
suffix,
|
|
|
|
|
}: {
|
|
|
|
|
value: number;
|
|
|
|
|
min: number;
|
|
|
|
|
max: number;
|
|
|
|
|
onChange: (value: number) => void;
|
|
|
|
|
suffix?: string;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Input
|
|
|
|
|
type="number"
|
|
|
|
|
min={min}
|
|
|
|
|
max={max}
|
|
|
|
|
value={value}
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
const parsed = Number(event.target.value);
|
|
|
|
|
if (Number.isFinite(parsed)) onChange(parsed);
|
|
|
|
|
}}
|
2026-06-14 16:46:34 +08:00
|
|
|
className="h-8 w-24 max-w-full rounded-full text-[13px]"
|
2026-05-19 22:42:38 +08:00
|
|
|
/>
|
|
|
|
|
{suffix ? <span className="text-[12px] text-muted-foreground">{suffix}</span> : null}
|
2026-04-25 18:05:06 +00:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|