From 87b80c66b2767fec9f0a62e3f62ab4afee80567f Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Fri, 6 Feb 2026 22:00:33 +0800 Subject: [PATCH 01/13] feat(usage): enhance dashboard with auto-refresh control and robust formatting (#942) * style: format code and apply clippy lint fixes * feat(usage): enhance dashboard with auto-refresh control and robust formatting - Add configurable auto-refresh interval toggle (off/5s/10s/30s/60s) to usage dashboard - Extract shared format utilities (fmtUsd, fmtInt, parseFiniteNumber, getLocaleFromLanguage) - Refactor request log time filtering to rolling vs fixed mode with validation - Use stable serializable query keys instead of filter objects - Handle NaN/Infinity safely in number formatting across all usage components - Use RFC 3339 date format in backend trend data --- src-tauri/src/services/usage_stats.rs | 2 +- src/components/usage/ModelStatsTable.tsx | 15 +- src/components/usage/ProviderStatsTable.tsx | 15 +- src/components/usage/RequestLogTable.tsx | 217 ++++++++++++++------ src/components/usage/UsageDashboard.tsx | 80 +++++--- src/components/usage/UsageSummaryCards.tsx | 15 +- src/components/usage/UsageTrendChart.tsx | 33 +-- src/components/usage/format.ts | 39 ++++ src/lib/query/usage.ts | 115 ++++++++--- 9 files changed, 401 insertions(+), 130 deletions(-) create mode 100644 src/components/usage/format.ts diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index 759f8f2c4..d050b864a 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -272,7 +272,7 @@ impl Database { .single() .unwrap_or_else(Local::now); - let date = bucket_start.format("%Y-%m-%dT%H:%M:%S").to_string(); + let date = bucket_start.to_rfc3339(); if let Some(mut stat) = map.remove(&i) { stat.date = date; diff --git a/src/components/usage/ModelStatsTable.tsx b/src/components/usage/ModelStatsTable.tsx index 171fffd04..20f83e628 100644 --- a/src/components/usage/ModelStatsTable.tsx +++ b/src/components/usage/ModelStatsTable.tsx @@ -8,10 +8,17 @@ import { TableRow, } from "@/components/ui/table"; import { useModelStats } from "@/lib/query/usage"; +import { fmtUsd } from "./format"; -export function ModelStatsTable() { +interface ModelStatsTableProps { + refreshIntervalMs: number; +} + +export function ModelStatsTable({ refreshIntervalMs }: ModelStatsTableProps) { const { t } = useTranslation(); - const { data: stats, isLoading } = useModelStats(); + const { data: stats, isLoading } = useModelStats({ + refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, + }); if (isLoading) { return
; @@ -60,10 +67,10 @@ export function ModelStatsTable() { {stat.totalTokens.toLocaleString()} - ${parseFloat(stat.totalCost).toFixed(4)} + {fmtUsd(stat.totalCost, 4)} - ${parseFloat(stat.avgCostPerRequest).toFixed(6)} + {fmtUsd(stat.avgCostPerRequest, 6)} )) diff --git a/src/components/usage/ProviderStatsTable.tsx b/src/components/usage/ProviderStatsTable.tsx index be6e72f51..e21e1f6c0 100644 --- a/src/components/usage/ProviderStatsTable.tsx +++ b/src/components/usage/ProviderStatsTable.tsx @@ -8,10 +8,19 @@ import { TableRow, } from "@/components/ui/table"; import { useProviderStats } from "@/lib/query/usage"; +import { fmtUsd } from "./format"; -export function ProviderStatsTable() { +interface ProviderStatsTableProps { + refreshIntervalMs: number; +} + +export function ProviderStatsTable({ + refreshIntervalMs, +}: ProviderStatsTableProps) { const { t } = useTranslation(); - const { data: stats, isLoading } = useProviderStats(); + const { data: stats, isLoading } = useProviderStats({ + refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, + }); if (isLoading) { return
; @@ -63,7 +72,7 @@ export function ProviderStatsTable() { {stat.totalTokens.toLocaleString()} - ${parseFloat(stat.totalCost).toFixed(4)} + {fmtUsd(stat.totalCost, 4)} {stat.successRate.toFixed(1)}% diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index e069d1b64..691a8e075 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -21,44 +21,122 @@ import { useRequestLogs, usageKeys } from "@/lib/query/usage"; import { useQueryClient } from "@tanstack/react-query"; import type { LogFilters } from "@/types/usage"; import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react"; +import { + fmtInt, + fmtUsd, + getLocaleFromLanguage, + parseFiniteNumber, +} from "./format"; -export function RequestLogTable() { +interface RequestLogTableProps { + refreshIntervalMs: number; +} + +const ONE_DAY_SECONDS = 24 * 60 * 60; +const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS; + +type TimeMode = "rolling" | "fixed"; + +export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) { const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); - // 默认时间范围:过去24小时 - const getDefaultFilters = (): LogFilters => { + const getRollingRange = () => { const now = Math.floor(Date.now() / 1000); - const oneDayAgo = now - 24 * 60 * 60; + const oneDayAgo = now - ONE_DAY_SECONDS; return { startDate: oneDayAgo, endDate: now }; }; - const [filters, setFilters] = useState(getDefaultFilters); - const [tempFilters, setTempFilters] = useState(getDefaultFilters); + const [appliedTimeMode, setAppliedTimeMode] = useState("rolling"); + const [draftTimeMode, setDraftTimeMode] = useState("rolling"); + + const [appliedFilters, setAppliedFilters] = useState({}); + const [draftFilters, setDraftFilters] = useState({}); const [page, setPage] = useState(0); const pageSize = 20; + const [validationError, setValidationError] = useState(null); - const { data: result, isLoading } = useRequestLogs(filters, page, pageSize); + const { data: result, isLoading } = useRequestLogs({ + filters: appliedFilters, + timeMode: appliedTimeMode, + rollingWindowSeconds: ONE_DAY_SECONDS, + page, + pageSize, + options: { + refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, + }, + }); const logs = result?.data ?? []; const total = result?.total ?? 0; const totalPages = Math.ceil(total / pageSize); const handleSearch = () => { - setFilters(tempFilters); + setValidationError(null); + + if (draftTimeMode === "fixed") { + const start = draftFilters.startDate; + const end = draftFilters.endDate; + + if (typeof start !== "number" || typeof end !== "number") { + setValidationError( + t("usage.invalidTimeRange", "请选择完整的开始/结束时间"), + ); + return; + } + + if (start > end) { + setValidationError( + t("usage.invalidTimeRangeOrder", "开始时间不能晚于结束时间"), + ); + return; + } + + if (end - start > MAX_FIXED_RANGE_SECONDS) { + setValidationError( + t("usage.timeRangeTooLarge", "时间范围过大,请缩小范围"), + ); + return; + } + } + + setAppliedTimeMode(draftTimeMode); + setAppliedFilters((prev) => { + const next = { ...prev, ...draftFilters }; + if (draftTimeMode === "rolling") { + delete next.startDate; + delete next.endDate; + } + return next; + }); setPage(0); }; const handleReset = () => { - const defaults = getDefaultFilters(); - setTempFilters(defaults); - setFilters(defaults); + setValidationError(null); + setAppliedTimeMode("rolling"); + setDraftTimeMode("rolling"); + setDraftFilters({}); + setAppliedFilters({}); setPage(0); }; const handleRefresh = () => { + const key = { + timeMode: appliedTimeMode, + rollingWindowSeconds: + appliedTimeMode === "rolling" ? ONE_DAY_SECONDS : undefined, + appType: appliedFilters.appType, + providerName: appliedFilters.providerName, + model: appliedFilters.model, + statusCode: appliedFilters.statusCode, + startDate: + appliedTimeMode === "fixed" ? appliedFilters.startDate : undefined, + endDate: appliedTimeMode === "fixed" ? appliedFilters.endDate : undefined, + }; + queryClient.invalidateQueries({ - queryKey: usageKeys.logs(filters, page, pageSize), + queryKey: usageKeys.logs(key, page, pageSize), }); }; @@ -84,12 +162,11 @@ export function RequestLogTable() { return Math.floor(timestamp / 1000); }; - const dateLocale = - i18n.language === "zh" - ? "zh-CN" - : i18n.language === "ja" - ? "ja-JP" - : "en-US"; + const language = i18n.resolvedLanguage || i18n.language || "en"; + const locale = getLocaleFromLanguage(language); + + const rollingRangeForDisplay = + draftTimeMode === "rolling" ? getRollingRange() : null; return (
@@ -97,10 +174,10 @@ export function RequestLogTable() {
- setTempFilters({ - ...tempFilters, + setDraftFilters({ + ...draftFilters, providerName: e.target.value || undefined, }) } @@ -156,10 +238,10 @@ export function RequestLogTable() { - setTempFilters({ - ...tempFilters, + setDraftFilters({ + ...draftFilters, model: e.target.value || undefined, }) } @@ -174,14 +256,18 @@ export function RequestLogTable() { type="datetime-local" className="h-8 w-[200px] bg-background" value={ - tempFilters.startDate - ? timestampToLocalDatetime(tempFilters.startDate) + (rollingRangeForDisplay?.startDate ?? draftFilters.startDate) + ? timestampToLocalDatetime( + (rollingRangeForDisplay?.startDate ?? + draftFilters.startDate) as number, + ) : "" } onChange={(e) => { const timestamp = localDatetimeToTimestamp(e.target.value); - setTempFilters({ - ...tempFilters, + setDraftTimeMode("fixed"); + setDraftFilters({ + ...draftFilters, startDate: timestamp, }); }} @@ -191,14 +277,18 @@ export function RequestLogTable() { type="datetime-local" className="h-8 w-[200px] bg-background" value={ - tempFilters.endDate - ? timestampToLocalDatetime(tempFilters.endDate) + (rollingRangeForDisplay?.endDate ?? draftFilters.endDate) + ? timestampToLocalDatetime( + (rollingRangeForDisplay?.endDate ?? + draftFilters.endDate) as number, + ) : "" } onChange={(e) => { const timestamp = localDatetimeToTimestamp(e.target.value); - setTempFilters({ - ...tempFilters, + setDraftTimeMode("fixed"); + setDraftFilters({ + ...draftFilters, endDate: timestamp, }); }} @@ -234,6 +324,10 @@ export function RequestLogTable() {
+ + {validationError && ( +
{validationError}
+ )}
{isLoading ? ( @@ -293,9 +387,7 @@ export function RequestLogTable() { logs.map((log) => ( - {new Date(log.createdAt * 1000).toLocaleString( - dateLocale, - )} + {new Date(log.createdAt * 1000).toLocaleString(locale)} {log.providerName || t("usage.unknownProvider")} @@ -321,19 +413,19 @@ export function RequestLogTable() { )} - {log.inputTokens.toLocaleString()} + {fmtInt(log.inputTokens, locale)} - {log.outputTokens.toLocaleString()} + {fmtInt(log.outputTokens, locale)} - {log.cacheReadTokens.toLocaleString()} + {fmtInt(log.cacheReadTokens, locale)} - {log.cacheCreationTokens.toLocaleString()} + {fmtInt(log.cacheCreationTokens, locale)} - {parseFloat(log.costMultiplier) !== 1 ? ( + {(parseFiniteNumber(log.costMultiplier) ?? 1) !== 1 ? ( ×{log.costMultiplier} @@ -342,24 +434,30 @@ export function RequestLogTable() { )} - ${parseFloat(log.totalCostUsd).toFixed(6)} + {fmtUsd(log.totalCostUsd, 6)}
{(() => { - const durationSec = - (log.durationMs ?? log.latencyMs) / 1000; - const durationColor = - durationSec <= 5 + const durationMs = + typeof log.durationMs === "number" + ? log.durationMs + : log.latencyMs; + const durationSec = durationMs / 1000; + const durationColor = Number.isFinite(durationSec) + ? durationSec <= 5 ? "bg-green-100 text-green-800" : durationSec <= 120 ? "bg-orange-100 text-orange-800" - : "bg-red-200 text-red-900"; + : "bg-red-200 text-red-900" + : "bg-gray-100 text-gray-700"; return ( - {Math.round(durationSec)}s + {Number.isFinite(durationSec) + ? `${Math.round(durationSec)}s` + : "--"} ); })()} @@ -367,17 +465,20 @@ export function RequestLogTable() { log.firstTokenMs != null && (() => { const firstSec = log.firstTokenMs / 1000; - const firstColor = - firstSec <= 5 + const firstColor = Number.isFinite(firstSec) + ? firstSec <= 5 ? "bg-green-100 text-green-800" : firstSec <= 120 ? "bg-orange-100 text-orange-800" - : "bg-red-200 text-red-900"; + : "bg-red-200 text-red-900" + : "bg-gray-100 text-gray-700"; return ( - {firstSec.toFixed(1)}s + {Number.isFinite(firstSec) + ? `${firstSec.toFixed(1)}s` + : "--"} ); })()} diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx index 49974ae79..01dd3764d 100644 --- a/src/components/usage/UsageDashboard.tsx +++ b/src/components/usage/UsageDashboard.tsx @@ -8,11 +8,28 @@ import { ProviderStatsTable } from "./ProviderStatsTable"; import { ModelStatsTable } from "./ModelStatsTable"; import type { TimeRange } from "@/types/usage"; import { motion } from "framer-motion"; -import { BarChart3, ListFilter, Activity } from "lucide-react"; +import { BarChart3, ListFilter, Activity, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useQueryClient } from "@tanstack/react-query"; +import { usageKeys } from "@/lib/query/usage"; export function UsageDashboard() { const { t } = useTranslation(); + const queryClient = useQueryClient(); const [timeRange, setTimeRange] = useState("1d"); + const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000); + + const refreshIntervalOptionsMs = [0, 5000, 10000, 30000, 60000] as const; + const changeRefreshInterval = () => { + const currentIndex = refreshIntervalOptionsMs.indexOf( + refreshIntervalMs as (typeof refreshIntervalOptionsMs)[number], + ); + const safeIndex = currentIndex >= 0 ? currentIndex : 3; // default 30s + const nextIndex = (safeIndex + 1) % refreshIntervalOptionsMs.length; + const next = refreshIntervalOptionsMs[nextIndex]; + setRefreshIntervalMs(next); + queryClient.invalidateQueries({ queryKey: usageKeys.all }); + }; const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30; @@ -34,32 +51,45 @@ export function UsageDashboard() { onValueChange={(v) => setTimeRange(v as TimeRange)} className="w-full sm:w-auto" > - - + + + + {t("usage.today")} + + + {t("usage.last7days")} + + + {t("usage.last30days")} + + +
- + - +
@@ -86,15 +116,15 @@ export function UsageDashboard() { transition={{ delay: 0.2 }} > - + - + - + diff --git a/src/components/usage/UsageSummaryCards.tsx b/src/components/usage/UsageSummaryCards.tsx index f3a6176ba..a281b2f2c 100644 --- a/src/components/usage/UsageSummaryCards.tsx +++ b/src/components/usage/UsageSummaryCards.tsx @@ -4,19 +4,26 @@ import { Card, CardContent } from "@/components/ui/card"; import { useUsageSummary } from "@/lib/query/usage"; import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react"; import { motion } from "framer-motion"; +import { fmtUsd, parseFiniteNumber } from "./format"; interface UsageSummaryCardsProps { days: number; + refreshIntervalMs: number; } -export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { +export function UsageSummaryCards({ + days, + refreshIntervalMs, +}: UsageSummaryCardsProps) { const { t } = useTranslation(); - const { data: summary, isLoading } = useUsageSummary(days); + const { data: summary, isLoading } = useUsageSummary(days, { + refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, + }); const stats = useMemo(() => { const totalRequests = summary?.totalRequests ?? 0; - const totalCost = parseFloat(summary?.totalCost || "0"); + const totalCost = parseFiniteNumber(summary?.totalCost); const inputTokens = summary?.totalInputTokens ?? 0; const outputTokens = summary?.totalOutputTokens ?? 0; @@ -37,7 +44,7 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { }, { title: t("usage.totalCost"), - value: `$${totalCost.toFixed(4)}`, + value: totalCost == null ? "--" : fmtUsd(totalCost, 4), icon: DollarSign, color: "text-green-500", bg: "bg-green-500/10", diff --git a/src/components/usage/UsageTrendChart.tsx b/src/components/usage/UsageTrendChart.tsx index 376014e02..ba66a83f4 100644 --- a/src/components/usage/UsageTrendChart.tsx +++ b/src/components/usage/UsageTrendChart.tsx @@ -11,14 +11,26 @@ import { } from "recharts"; import { useUsageTrends } from "@/lib/query/usage"; import { Loader2 } from "lucide-react"; +import { + fmtInt, + fmtUsd, + getLocaleFromLanguage, + parseFiniteNumber, +} from "./format"; interface UsageTrendChartProps { days: number; + refreshIntervalMs: number; } -export function UsageTrendChart({ days }: UsageTrendChartProps) { +export function UsageTrendChart({ + days, + refreshIntervalMs, +}: UsageTrendChartProps) { const { t, i18n } = useTranslation(); - const { data: trends, isLoading } = useUsageTrends(days); + const { data: trends, isLoading } = useUsageTrends(days, { + refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, + }); if (isLoading) { return ( @@ -29,15 +41,12 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) { } const isToday = days === 1; - const dateLocale = - i18n.language === "zh" - ? "zh-CN" - : i18n.language === "ja" - ? "ja-JP" - : "en-US"; + const language = i18n.resolvedLanguage || i18n.language || "en"; + const dateLocale = getLocaleFromLanguage(language); const chartData = trends?.map((stat) => { const pointDate = new Date(stat.date); + const cost = parseFiniteNumber(stat.totalCost); return { rawDate: stat.date, label: isToday @@ -56,7 +65,7 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) { outputTokens: stat.totalOutputTokens, cacheCreationTokens: stat.totalCacheCreationTokens, cacheReadTokens: stat.totalCacheReadTokens, - cost: parseFloat(stat.totalCost), + cost: cost ?? null, }; }) || []; @@ -79,9 +88,9 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) { /> {entry.name}: - {entry.name.includes(t("usage.cost", "成本")) - ? `$${typeof entry.value === "number" ? entry.value.toFixed(6) : entry.value}` - : entry.value.toLocaleString()} + {entry.dataKey === "cost" + ? fmtUsd(entry.value, 6) + : fmtInt(entry.value, dateLocale)}
))} diff --git a/src/components/usage/format.ts b/src/components/usage/format.ts new file mode 100644 index 000000000..e6df42238 --- /dev/null +++ b/src/components/usage/format.ts @@ -0,0 +1,39 @@ +export function parseFiniteNumber(value: unknown): number | null { + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + + if (typeof value === "string") { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : null; + } + + return null; +} + +export function fmtInt( + value: unknown, + locale?: string, + fallback: string = "--", +): string { + const num = parseFiniteNumber(value); + if (num == null) return fallback; + return new Intl.NumberFormat(locale).format(Math.trunc(num)); +} + +export function fmtUsd( + value: unknown, + digits: number, + fallback: string = "--", +): string { + const num = parseFiniteNumber(value); + if (num == null) return fallback; + return `$${num.toFixed(digits)}`; +} + +export function getLocaleFromLanguage(language: string): string { + if (!language) return "en-US"; + if (language.startsWith("zh")) return "zh-CN"; + if (language.startsWith("ja")) return "ja-JP"; + return "en-US"; +} diff --git a/src/lib/query/usage.ts b/src/lib/query/usage.ts index bf220c549..180c264d8 100644 --- a/src/lib/query/usage.ts +++ b/src/lib/query/usage.ts @@ -2,6 +2,35 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { usageApi } from "@/lib/api/usage"; import type { LogFilters } from "@/types/usage"; +const DEFAULT_REFETCH_INTERVAL_MS = 30000; + +type UsageQueryOptions = { + refetchInterval?: number | false; + refetchIntervalInBackground?: boolean; +}; + +type RequestLogsTimeMode = "rolling" | "fixed"; + +type RequestLogsQueryArgs = { + filters: LogFilters; + timeMode: RequestLogsTimeMode; + page?: number; + pageSize?: number; + rollingWindowSeconds?: number; + options?: UsageQueryOptions; +}; + +type RequestLogsKey = { + timeMode: RequestLogsTimeMode; + rollingWindowSeconds?: number; + appType?: string; + providerName?: string; + model?: string; + statusCode?: number; + startDate?: number; + endDate?: number; +}; + // Query keys export const usageKeys = { all: ["usage"] as const, @@ -9,8 +38,21 @@ export const usageKeys = { trends: (days: number) => [...usageKeys.all, "trends", days] as const, providerStats: () => [...usageKeys.all, "provider-stats"] as const, modelStats: () => [...usageKeys.all, "model-stats"] as const, - logs: (filters: LogFilters, page: number, pageSize: number) => - [...usageKeys.all, "logs", filters, page, pageSize] as const, + logs: (key: RequestLogsKey, page: number, pageSize: number) => + [ + ...usageKeys.all, + "logs", + key.timeMode, + key.rollingWindowSeconds ?? 0, + key.appType ?? "", + key.providerName ?? "", + key.model ?? "", + key.statusCode ?? -1, + key.startDate ?? 0, + key.endDate ?? 0, + page, + pageSize, + ] as const, detail: (requestId: string) => [...usageKeys.all, "detail", requestId] as const, pricing: () => [...usageKeys.all, "pricing"] as const, @@ -25,58 +67,85 @@ const getWindow = (days: number) => { }; // Hooks -export function useUsageSummary(days: number) { +export function useUsageSummary(days: number, options?: UsageQueryOptions) { return useQuery({ queryKey: usageKeys.summary(days), queryFn: () => { const { startDate, endDate } = getWindow(days); return usageApi.getUsageSummary(startDate, endDate); }, - refetchInterval: 30000, // 每30秒自动刷新 - refetchIntervalInBackground: false, // 后台不刷新 + refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新 + refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, // 后台不刷新 }); } -export function useUsageTrends(days: number) { +export function useUsageTrends(days: number, options?: UsageQueryOptions) { return useQuery({ queryKey: usageKeys.trends(days), queryFn: () => { const { startDate, endDate } = getWindow(days); return usageApi.getUsageTrends(startDate, endDate); }, - refetchInterval: 30000, // 每30秒自动刷新 - refetchIntervalInBackground: false, + refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新 + refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, }); } -export function useProviderStats() { +export function useProviderStats(options?: UsageQueryOptions) { return useQuery({ queryKey: usageKeys.providerStats(), queryFn: usageApi.getProviderStats, - refetchInterval: 30000, // 每30秒自动刷新 - refetchIntervalInBackground: false, + refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新 + refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, }); } -export function useModelStats() { +export function useModelStats(options?: UsageQueryOptions) { return useQuery({ queryKey: usageKeys.modelStats(), queryFn: usageApi.getModelStats, - refetchInterval: 30000, // 每30秒自动刷新 - refetchIntervalInBackground: false, + refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新 + refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, }); } -export function useRequestLogs( - filters: LogFilters, - page: number = 0, - pageSize: number = 20, -) { +const getRollingRange = (windowSeconds: number) => { + const endDate = Math.floor(Date.now() / 1000); + const startDate = endDate - windowSeconds; + return { startDate, endDate }; +}; + +export function useRequestLogs({ + filters, + timeMode, + page = 0, + pageSize = 20, + rollingWindowSeconds = 24 * 60 * 60, + options, +}: RequestLogsQueryArgs) { + const key: RequestLogsKey = { + timeMode, + rollingWindowSeconds: + timeMode === "rolling" ? rollingWindowSeconds : undefined, + appType: filters.appType, + providerName: filters.providerName, + model: filters.model, + statusCode: filters.statusCode, + startDate: timeMode === "fixed" ? filters.startDate : undefined, + endDate: timeMode === "fixed" ? filters.endDate : undefined, + }; + return useQuery({ - queryKey: usageKeys.logs(filters, page, pageSize), - queryFn: () => usageApi.getRequestLogs(filters, page, pageSize), - refetchInterval: 30000, // 每30秒自动刷新 - refetchIntervalInBackground: false, + queryKey: usageKeys.logs(key, page, pageSize), + queryFn: () => { + const effectiveFilters = + timeMode === "rolling" + ? { ...filters, ...getRollingRange(rollingWindowSeconds) } + : filters; + return usageApi.getRequestLogs(effectiveFilters, page, pageSize); + }, + refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新 + refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, }); } From b8538a699661cbdae8af204104e1080cd724d80d Mon Sep 17 00:00:00 2001 From: funnytime <162818986+funnytime75@users.noreply.github.com> Date: Fri, 6 Feb 2026 22:14:29 +0800 Subject: [PATCH 02/13] feat: circular reveal animation for theme switching (#905) --- src/components/mode-toggle.tsx | 6 ++-- src/components/settings/ThemeSettings.tsx | 8 ++--- src/components/theme-provider.tsx | 28 ++++++++++++++-- src/index.css | 39 +++++++++++++++++++++++ 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/src/components/mode-toggle.tsx b/src/components/mode-toggle.tsx index 20d579e5a..6c1bef5c6 100644 --- a/src/components/mode-toggle.tsx +++ b/src/components/mode-toggle.tsx @@ -7,13 +7,13 @@ export function ModeToggle() { const { theme, setTheme } = useTheme(); const { t } = useTranslation(); - const toggleTheme = () => { + const toggleTheme = (event: React.MouseEvent) => { // 如果当前是 dark 或 system(且系统是暗色),切换到 light // 否则切换到 dark if (theme === "dark") { - setTheme("light"); + setTheme("light", event); } else { - setTheme("dark"); + setTheme("dark", event); } }; diff --git a/src/components/settings/ThemeSettings.tsx b/src/components/settings/ThemeSettings.tsx index 7dd3f3952..f22c51d69 100644 --- a/src/components/settings/ThemeSettings.tsx +++ b/src/components/settings/ThemeSettings.tsx @@ -19,21 +19,21 @@ export function ThemeSettings() {
setTheme("light")} + onClick={(e) => setTheme("light", e)} icon={Sun} > {t("settings.themeLight")} setTheme("dark")} + onClick={(e) => setTheme("dark", e)} icon={Moon} > {t("settings.themeDark")} setTheme("system")} + onClick={(e) => setTheme("system", e)} icon={Monitor} > {t("settings.themeSystem")} @@ -45,7 +45,7 @@ export function ThemeSettings() { interface ThemeButtonProps { active: boolean; - onClick: () => void; + onClick: (event: React.MouseEvent) => void; icon: React.ComponentType<{ className?: string }>; children: React.ReactNode; } diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx index 0a1fec2ae..3a32f46ad 100644 --- a/src/components/theme-provider.tsx +++ b/src/components/theme-provider.tsx @@ -17,7 +17,7 @@ interface ThemeProviderProps { interface ThemeContextValue { theme: Theme; - setTheme: (theme: Theme) => void; + setTheme: (theme: Theme, event?: React.MouseEvent) => void; } const ThemeProviderContext = createContext( @@ -146,8 +146,30 @@ export function ThemeProvider({ const value = useMemo( () => ({ theme, - setTheme: (nextTheme: Theme) => { - setThemeState(nextTheme); + setTheme: (nextTheme: Theme, event?: React.MouseEvent) => { + // Skip if same theme + if (nextTheme === theme) return; + + // Set transition origin coordinates from click event + const x = event?.clientX ?? window.innerWidth / 2; + const y = event?.clientY ?? window.innerHeight / 2; + document.documentElement.style.setProperty( + "--theme-transition-x", + `${x}px`, + ); + document.documentElement.style.setProperty( + "--theme-transition-y", + `${y}px`, + ); + + // Use View Transitions API if available, otherwise fall back to instant change + if (document.startViewTransition) { + document.startViewTransition(() => { + setThemeState(nextTheme); + }); + } else { + setThemeState(nextTheme); + } }, }), [theme], diff --git a/src/index.css b/src/index.css index a2a405d45..c51cc7300 100644 --- a/src/index.css +++ b/src/index.css @@ -215,3 +215,42 @@ input[type="password"]::-ms-reveal, input[type="password"]::-ms-clear { display: none; } + +/* Theme transition animation using View Transitions API */ +::view-transition-old(root), +::view-transition-new(root) { + animation: none; + mix-blend-mode: normal; +} + +/* Old snapshot stays behind, new snapshot animates on top */ +::view-transition-old(root) { + z-index: 1; +} + +::view-transition-new(root) { + z-index: 9999; +} + +/* Circular expand animation from click position */ +@keyframes theme-circle-expand { + from { + clip-path: circle(0% at var(--theme-transition-x, 50%) var(--theme-transition-y, 50%)); + } + + to { + clip-path: circle(150% at var(--theme-transition-x, 50%) var(--theme-transition-y, 50%)); + } +} + +/* Apply animation to new snapshot - works for both light and dark transitions */ +::view-transition-new(root) { + animation: theme-circle-expand 0.4s ease-out; +} + +/* Respect user preference for reduced motion */ +@media (prefers-reduced-motion: reduce) { + ::view-transition-new(root) { + animation: none; + } +} \ No newline at end of file From 3d91c381d92b9aa118172cbbab63268535fe2fa1 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 6 Feb 2026 16:10:48 +0800 Subject: [PATCH 03/13] fix(provider): unify AIGoCode API base URLs to https://api.aigocode.com --- src/config/claudeProviderPresets.ts | 2 +- src/config/codexProviderPresets.ts | 2 +- src/config/geminiProviderPresets.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index cb725c74c..08e8e9c8a 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -438,7 +438,7 @@ export const providerPresets: ProviderPreset[] = [ apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH", settingsConfig: { env: { - ANTHROPIC_BASE_URL: "https://api.aigocode.com/api", + ANTHROPIC_BASE_URL: "https://api.aigocode.com", ANTHROPIC_AUTH_TOKEN: "", }, }, diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index d1c897965..9eac74c51 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -183,7 +183,7 @@ requires_openai_auth = true`, auth: generateThirdPartyAuth(""), config: generateThirdPartyConfig( "aigocode", - "https://api.aigocode.com/openai", + "https://api.aigocode.com", "gpt-5.2", ), endpointCandidates: ["https://api.aigocode.com"], diff --git a/src/config/geminiProviderPresets.ts b/src/config/geminiProviderPresets.ts index ffdc70745..ec1e1958c 100644 --- a/src/config/geminiProviderPresets.ts +++ b/src/config/geminiProviderPresets.ts @@ -102,17 +102,17 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [ apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH", settingsConfig: { env: { - GOOGLE_GEMINI_BASE_URL: "https://api.aigocode.com/gemini", + GOOGLE_GEMINI_BASE_URL: "https://api.aigocode.com", GEMINI_MODEL: "gemini-3-pro", }, }, - baseURL: "https://api.aigocode.com/gemini", + baseURL: "https://api.aigocode.com", model: "gemini-3-pro", description: "AIGoCode", category: "third_party", isPartner: true, partnerPromotionKey: "aigocode", - endpointCandidates: ["https://api.aigocode.com/gemini"], + endpointCandidates: ["https://api.aigocode.com"], icon: "aigocode", iconColor: "#5B7FFF", }, From ded9980fbfc7d3b4bdc8be7d0c14e4d337aac475 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 6 Feb 2026 16:13:00 +0800 Subject: [PATCH 04/13] fix(provider): remove /v1 suffix from AIGoCode OpenCode base URL --- src/config/opencodeProviderPresets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index a110c227e..3a30fe62e 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -651,7 +651,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ npm: "@ai-sdk/anthropic", name: "AIGoCode", options: { - baseURL: "https://api.aigocode.com/v1", + baseURL: "https://api.aigocode.com", apiKey: "", }, models: { From d82027f107f1a8a0a6b1b4ac04fc1eab5f7d62c2 Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Sat, 7 Feb 2026 11:06:48 +0800 Subject: [PATCH 05/13] feat(pricing): add claude-opus-4-6 and gpt-5.3-codex models, use incremental seeding (#943) - Add claude-opus-4-6-20260206 pricing (same as opus-4-5) - Add gpt-5.3-codex series pricing (same as gpt-5.2-codex) - Change seed_model_pricing to INSERT OR IGNORE for incremental upsert - Remove count==0 guard in ensure_model_pricing_seeded so new models are appended on every startup without overwriting user customizations --- src-tauri/src/database/schema.rs | 57 ++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 33a3f6c74..bca3c2de3 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -919,7 +919,16 @@ impl Database { /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> { let pricing_data = [ - // Claude 4.5 系列 (Latest Models) + // Claude 4.6 系列 + ( + "claude-opus-4-6-20260206", + "Claude Opus 4.6", + "5", + "25", + "0.50", + "6.25", + ), + // Claude 4.5 系列 ( "claude-opus-4-5-20251101", "Claude Opus 4.5", @@ -1025,6 +1034,40 @@ impl Database { "0.175", "0", ), + // GPT-5.3 Codex 系列 + ("gpt-5.3-codex", "GPT-5.3 Codex", "1.75", "14", "0.175", "0"), + ( + "gpt-5.3-codex-low", + "GPT-5.3 Codex", + "1.75", + "14", + "0.175", + "0", + ), + ( + "gpt-5.3-codex-medium", + "GPT-5.3 Codex", + "1.75", + "14", + "0.175", + "0", + ), + ( + "gpt-5.3-codex-high", + "GPT-5.3 Codex", + "1.75", + "14", + "0.175", + "0", + ), + ( + "gpt-5.3-codex-xhigh", + "GPT-5.3 Codex", + "1.75", + "14", + "0.175", + "0", + ), // GPT-5.1 系列 ("gpt-5.1", "GPT-5.1", "1.25", "10", "0.125", "0"), ("gpt-5.1-low", "GPT-5.1", "1.25", "10", "0.125", "0"), @@ -1212,7 +1255,7 @@ impl Database { for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data { conn.execute( - "INSERT OR REPLACE INTO model_pricing ( + "INSERT OR IGNORE INTO model_pricing ( model_id, display_name, input_cost_per_million, output_cost_per_million, cache_read_cost_per_million, cache_creation_cost_per_million ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", @@ -1239,14 +1282,8 @@ impl Database { } fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> { - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0)) - .map_err(|e| AppError::Database(format!("统计模型定价数据失败: {e}")))?; - - if count == 0 { - Self::seed_model_pricing(conn)?; - } - Ok(()) + // 每次启动都执行 INSERT OR IGNORE,增量追加新模型,已有数据不覆盖 + Self::seed_model_pricing(conn) } // --- 辅助方法 --- From fa7c9514cc57fc0e1153edfc822a1bb5bb8fd64b Mon Sep 17 00:00:00 2001 From: myjustify <1406023741@qq.com> Date: Sun, 8 Feb 2026 23:30:53 +0800 Subject: [PATCH 06/13] feat(env): add Volta package manager path detection (#969) Add ~/.volta/bin to CLI version scanning paths to support tools installed via Volta (claude, codex, gemini, etc.). --- src-tauri/src/commands/misc.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 8e6da2bb4..db229776b 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -324,6 +324,7 @@ fn scan_cli_version(tool: &str) -> (Option, Option) { home.join(".local/bin"), // Native install (official recommended) home.join(".npm-global/bin"), home.join("n/bin"), // n version manager + home.join(".volta/bin"), // Volta package manager ]; #[cfg(target_os = "macos")] From 5502c74a793f3eed3211334f8d8b1b872cc54243 Mon Sep 17 00:00:00 2001 From: makoMako <48956204+zhu-jl18@users.noreply.github.com> Date: Sun, 8 Feb 2026 23:32:40 +0800 Subject: [PATCH 07/13] feat: rename Qwen Coder preset to Bailian (#965) - replace Qwen Coder preset with Bailian for Claude and OpenCode - add Bailian icon asset and metadata mapping - remove Bailian preset default model values - rename Claude preset Kimi k2 to Kimi --- src/config/claudeProviderPresets.ts | 12 ++++-------- src/config/iconInference.ts | 1 + src/config/opencodeProviderPresets.ts | 12 +++++------- src/icons/extracted/bailian.svg | 1 + src/icons/extracted/index.ts | 1 + src/icons/extracted/metadata.ts | 7 +++++++ 6 files changed, 19 insertions(+), 15 deletions(-) create mode 100644 src/icons/extracted/bailian.svg diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 08e8e9c8a..01b9b19dd 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -125,24 +125,20 @@ export const providerPresets: ProviderPreset[] = [ iconColor: "#0F62FE", }, { - name: "Qwen Coder", + name: "Bailian", websiteUrl: "https://bailian.console.aliyun.com", settingsConfig: { env: { ANTHROPIC_BASE_URL: "https://dashscope.aliyuncs.com/apps/anthropic", ANTHROPIC_AUTH_TOKEN: "", - ANTHROPIC_MODEL: "qwen3-max", - ANTHROPIC_DEFAULT_HAIKU_MODEL: "qwen3-max", - ANTHROPIC_DEFAULT_SONNET_MODEL: "qwen3-max", - ANTHROPIC_DEFAULT_OPUS_MODEL: "qwen3-max", }, }, category: "cn_official", - icon: "qwen", - iconColor: "#FF6A00", + icon: "bailian", + iconColor: "#624AFF", }, { - name: "Kimi k2", + name: "Kimi", websiteUrl: "https://platform.moonshot.cn/console", settingsConfig: { env: { diff --git a/src/config/iconInference.ts b/src/config/iconInference.ts index 561975447..b2f37ab5e 100644 --- a/src/config/iconInference.ts +++ b/src/config/iconInference.ts @@ -10,6 +10,7 @@ const iconMappings = { zhipu: { icon: "zhipu", iconColor: "#0F62FE" }, glm: { icon: "zhipu", iconColor: "#0F62FE" }, qwen: { icon: "qwen", iconColor: "#FF6A00" }, + bailian: { icon: "bailian", iconColor: "#624AFF" }, alibaba: { icon: "alibaba", iconColor: "#FF6A00" }, aliyun: { icon: "alibaba", iconColor: "#FF6A00" }, kimi: { icon: "kimi", iconColor: "#6366F1" }, diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index 3a30fe62e..2bdf48fc7 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -137,23 +137,21 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, }, { - name: "Qwen Coder", + name: "Bailian", websiteUrl: "https://bailian.console.aliyun.com", apiKeyUrl: "https://bailian.console.aliyun.com/#/api-key", settingsConfig: { npm: "@ai-sdk/openai-compatible", - name: "Qwen Coder", + name: "Bailian", options: { baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", apiKey: "", }, - models: { - "qwen3-max": { name: "Qwen3 Max" }, - }, + models: {}, }, category: "cn_official", - icon: "qwen", - iconColor: "#FF6A00", + icon: "bailian", + iconColor: "#624AFF", templateValues: { baseURL: { label: "Base URL", diff --git a/src/icons/extracted/bailian.svg b/src/icons/extracted/bailian.svg new file mode 100644 index 000000000..c75f1d8be --- /dev/null +++ b/src/icons/extracted/bailian.svg @@ -0,0 +1 @@ +BaiLian diff --git a/src/icons/extracted/index.ts b/src/icons/extracted/index.ts index a1edddcb9..dcc9bb12d 100644 --- a/src/icons/extracted/index.ts +++ b/src/icons/extracted/index.ts @@ -60,6 +60,7 @@ export const icons: Record = { catcoder: `KwaiKAT`, mcp: `ModelContextProtocol`, nvidia: `Nvidia`, + bailian: `BaiLian`, }; export const iconList = Object.keys(icons); diff --git a/src/icons/extracted/metadata.ts b/src/icons/extracted/metadata.ts index 4a79f2201..0c103d028 100644 --- a/src/icons/extracted/metadata.ts +++ b/src/icons/extracted/metadata.ts @@ -44,6 +44,13 @@ export const iconMetadata: Record = { keywords: ["ernie", "wenxin"], defaultColor: "#2932E1", }, + bailian: { + name: "bailian", + displayName: "Bailian", + category: "ai-provider", + keywords: ["bailian", "dashscope", "aliyun", "alibaba"], + defaultColor: "#624AFF", + }, bytedance: { name: "bytedance", displayName: "bytedance", From ed47480fc74c29b203176d1a9172ef47230ea626 Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Mon, 9 Feb 2026 12:18:20 +0800 Subject: [PATCH 08/13] feat(omo): integrate Oh My OpenCode profile management (#972) * feat(omo): integrate Oh My OpenCode profile management into Provider system Adds full-stack OMO support: backend config read/write/import, OMO-specific provider CRUD with exclusive switching, frontend profile editor with agent/category/model configuration, global config management, and i18n support. * feat(omo): add model/variant dropdowns from enabled providers Replace model text inputs with Select dropdowns sourced from enabled OpenCode providers, add thinking-level variant selection, and prevent auto-enabling newly added OMO providers. * fix(omo): use standard provider action styles for OMO switch button * fix(omo): replace hardcoded isZh strings with proper i18n t() calls --- src-tauri/src/commands/config.rs | 37 +- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/omo.rs | 50 + src-tauri/src/commands/provider.rs | 53 +- src-tauri/src/database/dao/mod.rs | 2 + src-tauri/src/database/dao/omo.rs | 73 ++ src-tauri/src/database/dao/providers.rs | 157 ++- src-tauri/src/database/mod.rs | 1 + src-tauri/src/error.rs | 2 + src-tauri/src/lib.rs | 26 + src-tauri/src/opencode_config.rs | 125 +- src-tauri/src/services/mod.rs | 2 + src-tauri/src/services/omo.rs | 437 +++++++ src-tauri/src/services/provider/mod.rs | 102 +- src/App.tsx | 87 +- .../providers/AddProviderDialog.tsx | 14 +- src/components/providers/ProviderActions.tsx | 60 +- src/components/providers/ProviderCard.tsx | 62 +- src/components/providers/ProviderList.tsx | 104 +- .../providers/forms/OmoCommonConfigEditor.tsx | 161 +++ .../providers/forms/OmoFormFields.tsx | 1031 +++++++++++++++++ .../providers/forms/OmoGlobalConfigFields.tsx | 739 ++++++++++++ .../providers/forms/ProviderForm.tsx | 794 ++++++++----- .../forms/ProviderPresetSelector.tsx | 15 +- src/config/opencodeProviderPresets.ts | 34 +- src/i18n/locales/en.json | 102 +- src/i18n/locales/ja.json | 109 +- src/i18n/locales/zh.json | 102 +- src/index.css | 43 +- src/lib/api/config.ts | 4 +- src/lib/api/omo.ts | 10 + src/lib/query/mutations.ts | 42 +- src/lib/query/omo.ts | 95 ++ src/types.ts | 3 +- src/types/omo.ts | 361 ++++++ 35 files changed, 4304 insertions(+), 737 deletions(-) create mode 100644 src-tauri/src/commands/omo.rs create mode 100644 src-tauri/src/database/dao/omo.rs create mode 100644 src-tauri/src/services/omo.rs create mode 100644 src/components/providers/forms/OmoCommonConfigEditor.tsx create mode 100644 src/components/providers/forms/OmoFormFields.tsx create mode 100644 src/components/providers/forms/OmoGlobalConfigFields.tsx create mode 100644 src/lib/api/omo.ts create mode 100644 src/lib/query/omo.ts create mode 100644 src/types/omo.ts diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index f000a4d29..c70939e04 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -9,7 +9,6 @@ use crate::codex_config; use crate::config::{self, get_claude_settings_path, ConfigStatus}; use crate::settings; -/// 获取 Claude Code 配置状态 #[tauri::command] pub async fn get_claude_config_status() -> Result { Ok(config::get_claude_config_status()) @@ -63,13 +62,11 @@ pub async fn get_config_status(app: String) -> Result { } } -/// 获取 Claude Code 配置文件路径 #[tauri::command] pub async fn get_claude_code_config_path() -> Result { Ok(get_claude_settings_path().to_string_lossy().to_string()) } -/// 获取当前生效的配置目录 #[tauri::command] pub async fn get_config_dir(app: String) -> Result { let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? { @@ -82,7 +79,6 @@ pub async fn get_config_dir(app: String) -> Result { Ok(dir.to_string_lossy().to_string()) } -/// 打开配置文件夹 #[tauri::command] pub async fn open_config_folder(handle: AppHandle, app: String) -> Result { let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? { @@ -104,7 +100,6 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result Result { let config_path = config::get_app_config_path(); Ok(config_path.to_string_lossy().to_string()) } -/// 打开应用配置文件夹 #[tauri::command] pub async fn open_app_config_folder(handle: AppHandle) -> Result { let config_dir = config::get_app_config_dir(); @@ -160,7 +153,6 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result { Ok(true) } -/// 获取 Claude 通用配置片段(已废弃,使用 get_common_config_snippet) #[tauri::command] pub async fn get_claude_common_config_snippet( state: tauri::State<'_, crate::store::AppState>, @@ -171,13 +163,11 @@ pub async fn get_claude_common_config_snippet( .map_err(|e| e.to_string()) } -/// 设置 Claude 通用配置片段(已废弃,使用 set_common_config_snippet) #[tauri::command] pub async fn set_claude_common_config_snippet( snippet: String, state: tauri::State<'_, crate::store::AppState>, ) -> Result<(), String> { - // 验证是否为有效的 JSON(如果不为空) if !snippet.trim().is_empty() { serde_json::from_str::(&snippet).map_err(invalid_json_format_error)?; } @@ -195,7 +185,6 @@ pub async fn set_claude_common_config_snippet( Ok(()) } -/// 获取通用配置片段(统一接口) #[tauri::command] pub async fn get_common_config_snippet( app_type: String, @@ -207,25 +196,19 @@ pub async fn get_common_config_snippet( .map_err(|e| e.to_string()) } -/// 设置通用配置片段(统一接口) #[tauri::command] pub async fn set_common_config_snippet( app_type: String, snippet: String, state: tauri::State<'_, crate::store::AppState>, ) -> Result<(), String> { - // 验证格式(根据应用类型) if !snippet.trim().is_empty() { match app_type.as_str() { - "claude" | "gemini" => { - // 验证 JSON 格式 + "claude" | "gemini" | "omo" => { serde_json::from_str::(&snippet) .map_err(invalid_json_format_error)?; } - "codex" => { - // TOML 格式暂不验证(或可使用 toml crate) - // 注意:TOML 验证较为复杂,暂时跳过 - } + "codex" => {} _ => {} } } @@ -240,14 +223,20 @@ pub async fn set_common_config_snippet( .db .set_config_snippet(&app_type, value) .map_err(|e| e.to_string())?; + + if app_type == "omo" + && state + .db + .get_current_omo_provider("opencode") + .map_err(|e| e.to_string())? + .is_some() + { + crate::services::OmoService::write_config_to_file(state.inner()) + .map_err(|e| e.to_string())?; + } Ok(()) } -/// 提取通用配置片段 -/// -/// 优先从 `settingsConfig`(编辑器当前内容)提取;若未提供,则从当前激活供应商提取。 -/// -/// 提取时会自动排除差异化字段(API Key、模型配置、端点等),返回可复用的通用配置片段。 #[tauri::command] pub async fn extract_common_config_snippet( appType: String, diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ff81fde2e..7976c4316 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -8,6 +8,7 @@ mod global_proxy; mod import_export; mod mcp; mod misc; +mod omo; mod plugin; mod prompt; mod provider; @@ -26,6 +27,7 @@ pub use global_proxy::*; pub use import_export::*; pub use mcp::*; pub use misc::*; +pub use omo::*; pub use plugin::*; pub use prompt::*; pub use provider::*; diff --git a/src-tauri/src/commands/omo.rs b/src-tauri/src/commands/omo.rs new file mode 100644 index 000000000..9b3b4be47 --- /dev/null +++ b/src-tauri/src/commands/omo.rs @@ -0,0 +1,50 @@ +use tauri::State; + +use crate::services::omo::OmoLocalFileData; +use crate::services::OmoService; +use crate::store::AppState; + +#[tauri::command] +pub async fn read_omo_local_file() -> Result { + OmoService::read_local_file().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn get_current_omo_provider_id(state: State<'_, AppState>) -> Result { + let provider = state + .db + .get_current_omo_provider("opencode") + .map_err(|e| e.to_string())?; + Ok(provider.map(|p| p.id).unwrap_or_default()) +} + +#[tauri::command] +pub async fn disable_current_omo(state: State<'_, AppState>) -> Result<(), String> { + let providers = state + .db + .get_all_providers("opencode") + .map_err(|e| e.to_string())?; + for (id, p) in &providers { + if p.category.as_deref() == Some("omo") { + state + .db + .clear_omo_provider_current("opencode", id) + .map_err(|e| e.to_string())?; + } + } + OmoService::delete_config_file().map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub async fn get_omo_provider_count(state: State<'_, AppState>) -> Result { + let providers = state + .db + .get_all_providers("opencode") + .map_err(|e| e.to_string())?; + let count = providers + .values() + .filter(|p| p.category.as_deref() == Some("omo")) + .count(); + Ok(count) +} diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index b1b94a742..47de928f5 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -8,7 +8,6 @@ use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, Spee use crate::store::AppState; use std::str::FromStr; -/// 获取所有供应商 #[tauri::command] pub fn get_providers( state: State<'_, AppState>, @@ -18,14 +17,12 @@ pub fn get_providers( ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string()) } -/// 获取当前供应商ID #[tauri::command] pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::current(state.inner(), app_type).map_err(|e| e.to_string()) } -/// 添加供应商 #[tauri::command] pub fn add_provider( state: State<'_, AppState>, @@ -36,7 +33,6 @@ pub fn add_provider( ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string()) } -/// 更新供应商 #[tauri::command] pub fn update_provider( state: State<'_, AppState>, @@ -47,7 +43,6 @@ pub fn update_provider( ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string()) } -/// 删除供应商 #[tauri::command] pub fn delete_provider( state: State<'_, AppState>, @@ -60,17 +55,18 @@ pub fn delete_provider( .map_err(|e| e.to_string()) } -/// Remove provider from live config only (for additive mode apps like OpenCode) -/// Does NOT delete from database - provider remains in the list #[tauri::command] -pub fn remove_provider_from_live_config(app: String, id: String) -> Result { +pub fn remove_provider_from_live_config( + state: tauri::State<'_, AppState>, + app: String, + id: String, +) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; - ProviderService::remove_from_live_config(app_type, &id) + ProviderService::remove_from_live_config(state.inner(), app_type, &id) .map(|_| true) .map_err(|e| e.to_string()) } -/// 切换供应商 fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { ProviderService::switch(state, app_type, id) } @@ -108,14 +104,12 @@ pub fn import_default_config_test_hook( import_default_config_internal(state, app_type) } -/// 导入当前配置为默认供应商 #[tauri::command] pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; import_default_config_internal(&state, app_type).map_err(Into::into) } -/// 查询供应商用量 #[allow(non_snake_case)] #[tauri::command] pub async fn queryProviderUsage( @@ -129,7 +123,6 @@ pub async fn queryProviderUsage( .map_err(|e| e.to_string()) } -/// 测试用量脚本(使用当前编辑器中的脚本,不保存) #[allow(non_snake_case)] #[allow(clippy::too_many_arguments)] #[tauri::command] @@ -162,14 +155,12 @@ pub async fn testUsageScript( .map_err(|e| e.to_string()) } -/// 读取当前生效的配置内容 #[tauri::command] pub fn read_live_provider_settings(app: String) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::read_live_settings(app_type).map_err(|e| e.to_string()) } -/// 测试第三方/自定义供应商端点的网络延迟 #[tauri::command] pub async fn test_api_endpoints( urls: Vec, @@ -180,7 +171,6 @@ pub async fn test_api_endpoints( .map_err(|e| e.to_string()) } -/// 获取自定义端点列表 #[tauri::command] pub fn get_custom_endpoints( state: State<'_, AppState>, @@ -192,7 +182,6 @@ pub fn get_custom_endpoints( .map_err(|e| e.to_string()) } -/// 添加自定义端点 #[tauri::command] pub fn add_custom_endpoint( state: State<'_, AppState>, @@ -205,7 +194,6 @@ pub fn add_custom_endpoint( .map_err(|e| e.to_string()) } -/// 删除自定义端点 #[tauri::command] pub fn remove_custom_endpoint( state: State<'_, AppState>, @@ -218,7 +206,6 @@ pub fn remove_custom_endpoint( .map_err(|e| e.to_string()) } -/// 更新端点最后使用时间 #[tauri::command] pub fn update_endpoint_last_used( state: State<'_, AppState>, @@ -231,7 +218,6 @@ pub fn update_endpoint_last_used( .map_err(|e| e.to_string()) } -/// 更新多个供应商的排序 #[tauri::command] pub fn update_providers_sort_order( state: State<'_, AppState>, @@ -242,24 +228,16 @@ pub fn update_providers_sort_order( ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string()) } -// ============================================================================ -// 统一供应商(Universal Provider)命令 -// ============================================================================ - use crate::provider::UniversalProvider; use std::collections::HashMap; use tauri::{AppHandle, Emitter}; -/// 统一供应商同步完成事件的 payload #[derive(Clone, serde::Serialize)] pub struct UniversalProviderSyncedEvent { - /// 操作类型: "upsert" | "delete" | "sync" pub action: String, - /// 统一供应商 ID pub id: String, } -/// 发送统一供应商同步事件,通知前端刷新供应商列表 fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) { let _ = app.emit( "universal-provider-synced", @@ -270,7 +248,6 @@ fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) { ); } -/// 获取所有统一供应商 #[tauri::command] pub fn get_universal_providers( state: State<'_, AppState>, @@ -278,7 +255,6 @@ pub fn get_universal_providers( ProviderService::list_universal(state.inner()).map_err(|e| e.to_string()) } -/// 获取单个统一供应商 #[tauri::command] pub fn get_universal_provider( state: State<'_, AppState>, @@ -287,7 +263,6 @@ pub fn get_universal_provider( ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string()) } -/// 添加或更新统一供应商 #[tauri::command] pub fn upsert_universal_provider( app: AppHandle, @@ -298,13 +273,11 @@ pub fn upsert_universal_provider( let result = ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?; - // 发送事件通知前端刷新 emit_universal_provider_synced(&app, "upsert", &id); Ok(result) } -/// 删除统一供应商 #[tauri::command] pub fn delete_universal_provider( app: AppHandle, @@ -314,13 +287,11 @@ pub fn delete_universal_provider( let result = ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?; - // 发送事件通知前端刷新 emit_universal_provider_synced(&app, "delete", &id); Ok(result) } -/// 同步统一供应商到各应用(手动触发) #[tauri::command] pub fn sync_universal_provider( app: AppHandle, @@ -330,29 +301,17 @@ pub fn sync_universal_provider( let result = ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?; - // 发送事件通知前端刷新 emit_universal_provider_synced(&app, "sync", &id); Ok(result) } -// ============================================================================ -// OpenCode 专属命令 -// ============================================================================ - -/// 从 OpenCode live 配置导入供应商到数据库 -/// -/// 这是 OpenCode 特有的功能,因为 OpenCode 使用累加模式, -/// 用户可能已经在 opencode.json 中配置了供应商。 #[tauri::command] pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result { crate::services::provider::import_opencode_providers_from_live(state.inner()) .map_err(|e| e.to_string()) } -/// 获取 OpenCode live 配置中的供应商 ID 列表 -/// -/// 用于前端判断供应商是否已添加到 opencode.json #[tauri::command] pub fn get_opencode_live_provider_ids() -> Result, String> { crate::opencode_config::get_providers() diff --git a/src-tauri/src/database/dao/mod.rs b/src-tauri/src/database/dao/mod.rs index 81ac6a6c1..53ea93dfa 100644 --- a/src-tauri/src/database/dao/mod.rs +++ b/src-tauri/src/database/dao/mod.rs @@ -4,6 +4,7 @@ pub mod failover; pub mod mcp; +pub mod omo; pub mod prompts; pub mod providers; pub mod proxy; @@ -15,3 +16,4 @@ pub mod universal_providers; // 所有 DAO 方法都通过 Database impl 提供,无需单独导出 // 导出 FailoverQueueItem 供外部使用 pub use failover::FailoverQueueItem; +pub use omo::OmoGlobalConfig; diff --git a/src-tauri/src/database/dao/omo.rs b/src-tauri/src/database/dao/omo.rs new file mode 100644 index 000000000..00f4fdb34 --- /dev/null +++ b/src-tauri/src/database/dao/omo.rs @@ -0,0 +1,73 @@ +use crate::database::Database; +use crate::error::AppError; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OmoGlobalConfig { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub schema_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sisyphus_agent: Option, + #[serde(default)] + pub disabled_agents: Vec, + #[serde(default)] + pub disabled_mcps: Vec, + #[serde(default)] + pub disabled_hooks: Vec, + #[serde(default)] + pub disabled_skills: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub lsp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub experimental: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub background_task: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub browser_automation_engine: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub claude_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub other_fields: Option, + pub updated_at: String, +} + +impl Default for OmoGlobalConfig { + fn default() -> Self { + Self { + id: "global".to_string(), + schema_url: None, + sisyphus_agent: None, + disabled_agents: vec![], + disabled_mcps: vec![], + disabled_hooks: vec![], + disabled_skills: vec![], + lsp: None, + experimental: None, + background_task: None, + browser_automation_engine: None, + claude_code: None, + other_fields: None, + updated_at: chrono::Utc::now().to_rfc3339(), + } + } +} + +impl Database { + pub fn get_omo_global_config(&self) -> Result { + let json_str = self.get_setting("common_config_omo")?; + match json_str { + Some(s) => serde_json::from_str::(&s) + .map_err(|e| AppError::Config(format!("Failed to parse common_config_omo: {e}"))), + None => Ok(OmoGlobalConfig::default()), + } + } + + pub fn save_omo_global_config(&self, config: &OmoGlobalConfig) -> Result<(), AppError> { + let json_str = serde_json::to_string(config) + .map_err(|e| AppError::Config(format!("JSON serialization failed: {e}")))?; + self.set_setting("common_config_omo", &json_str)?; + Ok(()) + } +} diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs index 71872799a..0bf9961d2 100644 --- a/src-tauri/src/database/dao/providers.rs +++ b/src-tauri/src/database/dao/providers.rs @@ -1,7 +1,3 @@ -//! 供应商数据访问对象 -//! -//! 提供供应商(Provider)的 CRUD 操作。 - use crate::database::{lock_conn, Database}; use crate::error::AppError; use crate::provider::{Provider, ProviderMeta}; @@ -9,8 +5,18 @@ use indexmap::IndexMap; use rusqlite::params; use std::collections::HashMap; +type OmoProviderRow = ( + String, + String, + String, + Option, + Option, + Option, + Option, + String, +); + impl Database { - /// 获取指定应用类型的所有供应商 pub fn get_all_providers( &self, app_type: &str, @@ -66,7 +72,6 @@ impl Database { let (id, mut provider) = provider_res.map_err(|e| AppError::Database(e.to_string()))?; provider.id = id.clone(); - // 加载 endpoints let mut stmt_endpoints = conn.prepare( "SELECT url, added_at FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 ORDER BY added_at ASC, url ASC" ).map_err(|e| AppError::Database(e.to_string()))?; @@ -103,7 +108,6 @@ impl Database { Ok(providers) } - /// 获取当前激活的供应商 ID pub fn get_current_provider(&self, app_type: &str) -> Result, AppError> { let conn = lock_conn!(self.conn); let mut stmt = conn @@ -123,7 +127,6 @@ impl Database { } } - /// 根据 ID 获取单个供应商 pub fn get_provider_by_id( &self, id: &str, @@ -174,21 +177,15 @@ impl Database { } } - /// 保存供应商(新增或更新) - /// - /// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理 - /// (add_custom_endpoint / remove_custom_endpoint),避免覆盖用户的修改。 pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> { let mut conn = lock_conn!(self.conn); let tx = conn .transaction() .map_err(|e| AppError::Database(e.to_string()))?; - // 处理 meta:取出 endpoints 以便单独处理 let mut meta_clone = provider.meta.clone().unwrap_or_default(); let endpoints = std::mem::take(&mut meta_clone.custom_endpoints); - // 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue) let existing: Option<(bool, bool)> = tx .query_row( "SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2", @@ -202,7 +199,6 @@ impl Database { existing.unwrap_or((false, provider.in_failover_queue)); if is_update { - // 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE tx.execute( "UPDATE providers SET name = ?1, @@ -241,7 +237,6 @@ impl Database { ) .map_err(|e| AppError::Database(e.to_string()))?; } else { - // 新增模式:使用 INSERT tx.execute( "INSERT INTO providers ( id, app_type, name, settings_config, website_url, category, @@ -268,7 +263,6 @@ impl Database { ) .map_err(|e| AppError::Database(e.to_string()))?; - // 只有新增时才同步 endpoints for (url, endpoint) in endpoints { tx.execute( "INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) @@ -283,7 +277,6 @@ impl Database { Ok(()) } - /// 删除供应商 pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> { let conn = lock_conn!(self.conn); conn.execute( @@ -294,21 +287,18 @@ impl Database { Ok(()) } - /// 设置当前供应商 pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> { let mut conn = lock_conn!(self.conn); let tx = conn .transaction() .map_err(|e| AppError::Database(e.to_string()))?; - // 重置所有为 0 tx.execute( "UPDATE providers SET is_current = 0 WHERE app_type = ?1", params![app_type], ) .map_err(|e| AppError::Database(e.to_string()))?; - // 设置新的当前供应商 tx.execute( "UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2", params![id, app_type], @@ -319,7 +309,6 @@ impl Database { Ok(()) } - /// 更新供应商的 settings_config(仅更新配置,不改变其他字段) pub fn update_provider_settings_config( &self, app_type: &str, @@ -341,7 +330,6 @@ impl Database { Ok(()) } - /// 添加自定义端点 pub fn add_custom_endpoint( &self, app_type: &str, @@ -357,7 +345,6 @@ impl Database { Ok(()) } - /// 移除自定义端点 pub fn remove_custom_endpoint( &self, app_type: &str, @@ -372,4 +359,126 @@ impl Database { .map_err(|e| AppError::Database(e.to_string()))?; Ok(()) } + + pub fn set_omo_provider_current( + &self, + app_type: &str, + provider_id: &str, + ) -> Result<(), AppError> { + let mut conn = lock_conn!(self.conn); + let tx = conn + .transaction() + .map_err(|e| AppError::Database(e.to_string()))?; + tx.execute( + "UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = 'omo'", + params![app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + let updated = tx + .execute( + "UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2 AND category = 'omo'", + params![provider_id, app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + if updated != 1 { + return Err(AppError::Database(format!( + "Failed to set OMO provider current: provider '{provider_id}' not found in app '{app_type}'" + ))); + } + tx.commit().map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + pub fn is_omo_provider_current( + &self, + app_type: &str, + provider_id: &str, + ) -> Result { + let conn = lock_conn!(self.conn); + match conn.query_row( + "SELECT is_current FROM providers + WHERE id = ?1 AND app_type = ?2 AND category = 'omo'", + params![provider_id, app_type], + |row| row.get(0), + ) { + Ok(is_current) => Ok(is_current), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false), + Err(e) => Err(AppError::Database(e.to_string())), + } + } + + pub fn clear_omo_provider_current( + &self, + app_type: &str, + provider_id: &str, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "UPDATE providers SET is_current = 0 + WHERE id = ?1 AND app_type = ?2 AND category = 'omo'", + params![provider_id, app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + pub fn get_current_omo_provider(&self, app_type: &str) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let row_data: Result = conn.query_row( + "SELECT id, name, settings_config, category, created_at, sort_index, notes, meta + FROM providers + WHERE app_type = ?1 AND category = 'omo' AND is_current = 1 + LIMIT 1", + params![app_type], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + )) + }, + ); + + let (id, name, settings_config_str, category, created_at, sort_index, notes, meta_str) = + match row_data { + Ok(v) => v, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(AppError::Database(e.to_string())), + }; + + let settings_config = serde_json::from_str(&settings_config_str).map_err(|e| { + AppError::Database(format!( + "Failed to parse OMO provider settings_config (provider_id={id}): {e}" + )) + })?; + let meta: crate::provider::ProviderMeta = if meta_str.trim().is_empty() { + crate::provider::ProviderMeta::default() + } else { + serde_json::from_str(&meta_str).map_err(|e| { + AppError::Database(format!( + "Failed to parse OMO provider meta (provider_id={id}): {e}" + )) + })? + }; + + Ok(Some(Provider { + id, + name, + settings_config, + website_url: None, + category, + created_at, + sort_index, + notes, + meta: Some(meta), + icon: None, + icon_color: None, + in_failover_queue: false, + })) + } } diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 2ba3ed591..41c26e978 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -33,6 +33,7 @@ mod tests; // DAO 类型导出供外部使用 pub use dao::FailoverQueueItem; +pub use dao::OmoGlobalConfig; use crate::config::get_app_config_dir; use crate::error::AppError; diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 9d8c622f1..4c5790654 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -52,6 +52,8 @@ pub enum AppError { }, #[error("数据库错误: {0}")] Database(String), + #[error("OMO 配置文件不存在")] + OmoConfigNotFound, #[error("所有供应商已熔断,无可用渠道")] AllProvidersCircuitOpen, #[error("未配置供应商")] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5dd10a152..9c246ff90 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -503,6 +503,28 @@ pub fn run() { Err(e) => log::debug!("○ Failed to import OpenCode providers: {e}"), } + // 2.2 OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入) + { + let has_omo = app_state + .db + .get_all_providers("opencode") + .map(|providers| providers.values().any(|p| p.category.as_deref() == Some("omo"))) + .unwrap_or(false); + if !has_omo { + match crate::services::OmoService::import_from_local(&app_state) { + Ok(provider) => { + log::info!("✓ Imported OMO config from local as provider '{}'", provider.name); + } + Err(AppError::OmoConfigNotFound) => { + log::debug!("○ No OMO config to import"); + } + Err(e) => { + log::warn!("✗ Failed to import OMO config from local: {e}"); + } + } + } + } + // 3. 导入 MCP 服务器配置(表空时触发) if app_state.db.is_mcp_table_empty().unwrap_or(false) { log::info!("MCP table empty, importing from live configurations..."); @@ -958,6 +980,10 @@ pub fn run() { commands::scan_local_proxies, // Window theme control commands::set_window_theme, + commands::read_omo_local_file, + commands::get_current_omo_provider_id, + commands::get_omo_provider_count, + commands::disable_current_omo, ]); let app = builder diff --git a/src-tauri/src/opencode_config.rs b/src-tauri/src/opencode_config.rs index d14daf9b5..f79fcbc4b 100644 --- a/src-tauri/src/opencode_config.rs +++ b/src-tauri/src/opencode_config.rs @@ -1,26 +1,3 @@ -//! OpenCode 配置文件读写模块 -//! -//! 处理 `~/.config/opencode/opencode.json` 配置文件的读写操作。 -//! OpenCode 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。 -//! -//! ## 配置文件格式 -//! -//! ```json -//! { -//! "$schema": "https://opencode.ai/config.json", -//! "provider": { -//! "my-provider": { -//! "npm": "@ai-sdk/openai-compatible", -//! "options": { "baseURL": "...", "apiKey": "{env:API_KEY}" }, -//! "models": { "gpt-4o": { "name": "GPT-4o" } } -//! } -//! }, -//! "mcp": { -//! "my-server": { "type": "local", "command": ["..."] } -//! } -//! } -//! ``` - use crate::config::write_json_file; use crate::error::AppError; use crate::provider::OpenCodeProviderConfig; @@ -29,52 +6,29 @@ use indexmap::IndexMap; use serde_json::{json, Map, Value}; use std::path::PathBuf; -// ============================================================================ -// Path Functions -// ============================================================================ - -/// 获取 OpenCode 配置目录 -/// -/// 默认路径: `~/.config/opencode/` -/// 可通过 settings.opencode_config_dir 覆盖 pub fn get_opencode_dir() -> PathBuf { if let Some(override_dir) = get_opencode_override_dir() { return override_dir; } - // 所有平台统一使用 ~/.config/opencode dirs::home_dir() .map(|h| h.join(".config").join("opencode")) .unwrap_or_else(|| PathBuf::from(".config").join("opencode")) } -/// 获取 OpenCode 配置文件路径 -/// -/// 返回 `~/.config/opencode/opencode.json` pub fn get_opencode_config_path() -> PathBuf { get_opencode_dir().join("opencode.json") } -/// 获取 OpenCode 环境变量文件路径(如果存在) -/// -/// 返回 `~/.config/opencode/.env` #[allow(dead_code)] pub fn get_opencode_env_path() -> PathBuf { get_opencode_dir().join(".env") } -// ============================================================================ -// Core Read/Write Functions -// ============================================================================ - -/// 读取 OpenCode 配置文件 -/// -/// 返回完整的配置 JSON 对象 pub fn read_opencode_config() -> Result { let path = get_opencode_config_path(); if !path.exists() { - // Return empty config with schema return Ok(json!({ "$schema": "https://opencode.ai/config.json" })); @@ -84,23 +38,14 @@ pub fn read_opencode_config() -> Result { serde_json::from_str(&content).map_err(|e| AppError::json(&path, e)) } -/// 写入 OpenCode 配置文件(原子写入) -/// -/// 使用临时文件 + 重命名确保原子性 pub fn write_opencode_config(config: &Value) -> Result<(), AppError> { let path = get_opencode_config_path(); - // 复用统一的原子写入逻辑(兼容 Windows 上目标文件已存在的情况) write_json_file(&path, config)?; log::debug!("OpenCode config written to {path:?}"); Ok(()) } -// ============================================================================ -// Provider Functions (Untyped - for raw JSON operations) -// ============================================================================ - -/// 获取所有供应商配置(原始 JSON) pub fn get_providers() -> Result, AppError> { let config = read_opencode_config()?; Ok(config @@ -110,7 +55,6 @@ pub fn get_providers() -> Result, AppError> { .unwrap_or_default()) } -/// 设置供应商配置(原始 JSON) pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> { let mut full_config = read_opencode_config()?; @@ -128,7 +72,6 @@ pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> { write_opencode_config(&full_config) } -/// 删除供应商配置 pub fn remove_provider(id: &str) -> Result<(), AppError> { let mut config = read_opencode_config()?; @@ -139,11 +82,6 @@ pub fn remove_provider(id: &str) -> Result<(), AppError> { write_opencode_config(&config) } -// ============================================================================ -// Provider Functions (Typed - using OpenCodeProviderConfig) -// ============================================================================ - -/// 获取所有供应商配置(类型化) pub fn get_typed_providers() -> Result, AppError> { let providers = get_providers()?; let mut result = IndexMap::new(); @@ -155,7 +93,6 @@ pub fn get_typed_providers() -> Result, } Err(e) => { log::warn!("Failed to parse provider '{id}': {e}"); - // Skip invalid providers but continue } } } @@ -163,17 +100,11 @@ pub fn get_typed_providers() -> Result, Ok(result) } -/// 设置供应商配置(类型化) pub fn set_typed_provider(id: &str, config: &OpenCodeProviderConfig) -> Result<(), AppError> { let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?; set_provider(id, value) } -// ============================================================================ -// MCP Functions -// ============================================================================ - -/// 获取所有 MCP 服务器配置 pub fn get_mcp_servers() -> Result, AppError> { let config = read_opencode_config()?; Ok(config @@ -183,7 +114,6 @@ pub fn get_mcp_servers() -> Result, AppError> { .unwrap_or_default()) } -/// 设置 MCP 服务器配置 pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> { let mut full_config = read_opencode_config()?; @@ -198,7 +128,6 @@ pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> { write_opencode_config(&full_config) } -/// 删除 MCP 服务器配置 pub fn remove_mcp_server(id: &str) -> Result<(), AppError> { let mut config = read_opencode_config()?; @@ -208,3 +137,57 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> { write_opencode_config(&config) } + +pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> { + let mut config = read_opencode_config()?; + + let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut()); + + match plugins { + Some(arr) => { + if plugin_name.starts_with("oh-my-opencode") + && !plugin_name.starts_with("oh-my-opencode-slim") + { + arr.retain(|v| { + v.as_str() + .map(|s| !s.starts_with("oh-my-opencode-slim")) + .unwrap_or(true) + }); + } + + let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name)); + if !already_exists { + arr.push(Value::String(plugin_name.to_string())); + } + } + None => { + config["plugin"] = json!([plugin_name]); + } + } + + write_opencode_config(&config) +} + +pub fn remove_plugin_by_prefix(prefix: &str) -> Result<(), AppError> { + let mut config = read_opencode_config()?; + + if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) { + arr.retain(|v| { + v.as_str() + .map(|s| { + if !s.starts_with(prefix) { + return true; // Keep: doesn't match prefix at all + } + let rest = &s[prefix.len()..]; + rest.starts_with('-') + }) + .unwrap_or(true) + }); + + if arr.is_empty() { + config.as_object_mut().map(|obj| obj.remove("plugin")); + } + } + + write_opencode_config(&config) +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 07abe16e4..0875fdeb3 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -2,6 +2,7 @@ pub mod config; pub mod env_checker; pub mod env_manager; pub mod mcp; +pub mod omo; pub mod prompt; pub mod provider; pub mod proxy; @@ -12,6 +13,7 @@ pub mod usage_stats; pub use config::ConfigService; pub use mcp::McpService; +pub use omo::OmoService; pub use prompt::PromptService; pub use provider::{ProviderService, ProviderSortUpdate}; pub use proxy::ProxyService; diff --git a/src-tauri/src/services/omo.rs b/src-tauri/src/services/omo.rs new file mode 100644 index 000000000..45ed9479b --- /dev/null +++ b/src-tauri/src/services/omo.rs @@ -0,0 +1,437 @@ +use crate::config::write_json_file; +use crate::database::OmoGlobalConfig; +use crate::error::AppError; +use crate::opencode_config::get_opencode_dir; +use crate::store::AppState; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OmoLocalFileData { + pub agents: Option, + pub categories: Option, + pub other_fields: Option, + pub global: OmoGlobalConfig, + pub file_path: String, + pub last_modified: Option, +} + +type OmoProfileData = (Option, Option, Option, bool); + +pub struct OmoService; + +impl OmoService { + fn config_path() -> PathBuf { + get_opencode_dir().join("oh-my-opencode.jsonc") + } + + fn resolve_local_config_path() -> Result { + let config_path = Self::config_path(); + if config_path.exists() { + return Ok(config_path); + } + + let json_path = config_path.with_extension("json"); + if json_path.exists() { + return Ok(json_path); + } + + Err(AppError::OmoConfigNotFound) + } + + fn read_jsonc_object(path: &Path) -> Result, AppError> { + let content = std::fs::read_to_string(path).map_err(|e| AppError::io(path, e))?; + let cleaned = Self::strip_jsonc_comments(&content); + let parsed: Value = serde_json::from_str(&cleaned) + .map_err(|e| AppError::Config(format!("Failed to parse oh-my-opencode config: {e}")))?; + parsed + .as_object() + .cloned() + .ok_or_else(|| AppError::Config("Expected JSON object".to_string())) + } + + fn extract_other_fields(obj: &Map) -> Map { + const KNOWN_KEYS: [&str; 13] = [ + "$schema", + "agents", + "categories", + "sisyphus_agent", + "disabled_agents", + "disabled_mcps", + "disabled_hooks", + "disabled_skills", + "lsp", + "experimental", + "background_task", + "browser_automation_engine", + "claude_code", + ]; + + let mut other = Map::new(); + for (k, v) in obj { + if !KNOWN_KEYS.contains(&k.as_str()) { + other.insert(k.clone(), v.clone()); + } + } + other + } + + fn extract_string_array(val: &Value) -> Vec { + val.as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() + } + + fn merge_global_from_obj(obj: &Map, global: &mut OmoGlobalConfig) { + if let Some(v) = obj.get("$schema") { + global.schema_url = v.as_str().map(|s| s.to_string()); + } + for (key, target) in [ + ("disabled_agents", &mut global.disabled_agents), + ("disabled_mcps", &mut global.disabled_mcps), + ("disabled_hooks", &mut global.disabled_hooks), + ("disabled_skills", &mut global.disabled_skills), + ] { + if let Some(v) = obj.get(key) { + *target = Self::extract_string_array(v); + } + } + for (key, target) in [ + ("sisyphus_agent", &mut global.sisyphus_agent), + ("lsp", &mut global.lsp), + ("experimental", &mut global.experimental), + ("background_task", &mut global.background_task), + ( + "browser_automation_engine", + &mut global.browser_automation_engine, + ), + ("claude_code", &mut global.claude_code), + ] { + if let Some(v) = obj.get(key) { + *target = Some(v.clone()); + } + } + } + + fn insert_opt_value(result: &mut Map, key: &str, value: &Option) { + if let Some(v) = value { + result.insert(key.to_string(), v.clone()); + } + } + + fn insert_string_array(result: &mut Map, key: &str, values: &[String]) { + if !values.is_empty() { + result.insert( + key.to_string(), + serde_json::to_value(values).unwrap_or(Value::Array(vec![])), + ); + } + } + + fn insert_object_entries(result: &mut Map, value: Option<&Value>) { + if let Some(Value::Object(map)) = value { + for (k, v) in map { + result.insert(k.clone(), v.clone()); + } + } + } + + pub fn delete_config_file() -> Result<(), AppError> { + let config_path = Self::config_path(); + if config_path.exists() { + std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?; + log::info!("OMO config file deleted: {config_path:?}"); + } + crate::opencode_config::remove_plugin_by_prefix("oh-my-opencode")?; + Ok(()) + } + + pub fn write_config_to_file(state: &AppState) -> Result<(), AppError> { + let global = state.db.get_omo_global_config()?; + let current_omo = state.db.get_current_omo_provider("opencode")?; + + let profile_data = current_omo.as_ref().map(|p| { + let agents = p.settings_config.get("agents").cloned(); + let categories = p.settings_config.get("categories").cloned(); + let other_fields = p.settings_config.get("otherFields").cloned(); + let use_common_config = p + .settings_config + .get("useCommonConfig") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + (agents, categories, other_fields, use_common_config) + }); + + let merged = Self::merge_config(&global, profile_data.as_ref()); + let config_path = Self::config_path(); + + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + write_json_file(&config_path, &merged)?; + + crate::opencode_config::add_plugin("oh-my-opencode@latest")?; + + log::info!("OMO config written to {config_path:?}"); + Ok(()) + } + + fn merge_config(global: &OmoGlobalConfig, profile_data: Option<&OmoProfileData>) -> Value { + let mut result = Map::new(); + let use_common_config = profile_data.map(|(_, _, _, v)| *v).unwrap_or(true); + + if use_common_config { + if let Some(url) = &global.schema_url { + result.insert("$schema".to_string(), Value::String(url.clone())); + } + + Self::insert_opt_value(&mut result, "sisyphus_agent", &global.sisyphus_agent); + Self::insert_string_array(&mut result, "disabled_agents", &global.disabled_agents); + Self::insert_string_array(&mut result, "disabled_mcps", &global.disabled_mcps); + Self::insert_string_array(&mut result, "disabled_hooks", &global.disabled_hooks); + Self::insert_string_array(&mut result, "disabled_skills", &global.disabled_skills); + Self::insert_opt_value(&mut result, "lsp", &global.lsp); + Self::insert_opt_value(&mut result, "experimental", &global.experimental); + Self::insert_opt_value(&mut result, "background_task", &global.background_task); + Self::insert_opt_value( + &mut result, + "browser_automation_engine", + &global.browser_automation_engine, + ); + Self::insert_opt_value(&mut result, "claude_code", &global.claude_code); + + Self::insert_object_entries(&mut result, global.other_fields.as_ref()); + } + + if let Some((agents, categories, other_fields, _)) = profile_data { + Self::insert_opt_value(&mut result, "agents", agents); + Self::insert_opt_value(&mut result, "categories", categories); + Self::insert_object_entries(&mut result, other_fields.as_ref()); + } + + Value::Object(result) + } + + pub fn import_from_local(state: &AppState) -> Result { + let actual_path = Self::resolve_local_config_path()?; + Self::import_from_path(state, &actual_path) + } + + fn import_from_path( + state: &AppState, + path: &std::path::Path, + ) -> Result { + let obj = Self::read_jsonc_object(path)?; + + let mut settings = Map::new(); + if let Some(agents) = obj.get("agents") { + settings.insert("agents".to_string(), agents.clone()); + } + if let Some(categories) = obj.get("categories") { + settings.insert("categories".to_string(), categories.clone()); + } + settings.insert("useCommonConfig".to_string(), Value::Bool(true)); + + let other = Self::extract_other_fields(&obj); + if !other.is_empty() { + settings.insert("otherFields".to_string(), Value::Object(other)); + } + + let mut global = state.db.get_omo_global_config()?; + Self::merge_global_from_obj(&obj, &mut global); + global.updated_at = chrono::Utc::now().to_rfc3339(); + state.db.save_omo_global_config(&global)?; + + let provider_id = format!("omo-{}", uuid::Uuid::new_v4()); + let name = format!("Imported {}", chrono::Local::now().format("%Y-%m-%d %H:%M")); + let settings_config = + serde_json::to_value(&settings).unwrap_or_else(|_| serde_json::json!({})); + + let provider = crate::provider::Provider { + id: provider_id, + name, + settings_config, + website_url: None, + category: Some("omo".to_string()), + created_at: Some(chrono::Utc::now().timestamp_millis()), + sort_index: None, + notes: None, + meta: None, + icon: None, + icon_color: None, + in_failover_queue: false, + }; + + state.db.save_provider("opencode", &provider)?; + state + .db + .set_omo_provider_current("opencode", &provider.id)?; + Self::write_config_to_file(state)?; + Ok(provider) + } + + pub fn read_local_file() -> Result { + let actual_path = Self::resolve_local_config_path()?; + let metadata = std::fs::metadata(&actual_path).ok(); + let last_modified = metadata + .and_then(|m| m.modified().ok()) + .map(|t| chrono::DateTime::::from(t).to_rfc3339()); + + let obj = Self::read_jsonc_object(&actual_path)?; + + let agents = obj.get("agents").cloned(); + let categories = obj.get("categories").cloned(); + + let other = Self::extract_other_fields(&obj); + let other_fields = if other.is_empty() { + None + } else { + Some(Value::Object(other)) + }; + + let mut global = OmoGlobalConfig::default(); + Self::merge_global_from_obj(&obj, &mut global); + + Ok(OmoLocalFileData { + agents, + categories, + other_fields, + global, + file_path: actual_path.to_string_lossy().to_string(), + last_modified, + }) + } + + fn strip_jsonc_comments(input: &str) -> String { + let mut result = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + let mut in_string = false; + let mut escape = false; + + while let Some(&c) = chars.peek() { + if in_string { + result.push(c); + chars.next(); + if escape { + escape = false; + } else if c == '\\' { + escape = true; + } else if c == '"' { + in_string = false; + } + } else if c == '"' { + in_string = true; + result.push(c); + chars.next(); + } else if c == '/' { + chars.next(); + match chars.peek() { + Some('/') => { + chars.next(); + while let Some(&nc) = chars.peek() { + if nc == '\n' { + break; + } + chars.next(); + } + } + Some('*') => { + chars.next(); + while let Some(nc) = chars.next() { + if nc == '*' { + if let Some(&'/') = chars.peek() { + chars.next(); + break; + } + } + } + } + _ => { + result.push('/'); + } + } + } else { + result.push(c); + chars.next(); + } + } + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_strip_jsonc_comments() { + let input = r#"{ + // This is a comment + "key": "value", // inline comment + /* multi + line */ + "key2": "val//ue" +}"#; + let result = OmoService::strip_jsonc_comments(input); + let parsed: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed["key"], "value"); + assert_eq!(parsed["key2"], "val//ue"); + } + + #[test] + fn test_merge_config_empty() { + let global = OmoGlobalConfig::default(); + let merged = OmoService::merge_config(&global, None); + assert!(merged.is_object()); + } + + #[test] + fn test_merge_config_with_profile() { + let global = OmoGlobalConfig { + schema_url: Some("https://example.com/schema.json".to_string()), + disabled_agents: vec!["explore".to_string()], + ..Default::default() + }; + let agents = Some(serde_json::json!({ + "Sisyphus": { "model": "claude-opus-4-5" } + })); + let categories = None; + let other_fields = None; + let profile_data = (agents, categories, other_fields, true); + let merged = OmoService::merge_config(&global, Some(&profile_data)); + let obj = merged.as_object().unwrap(); + + assert_eq!(obj["$schema"], "https://example.com/schema.json"); + assert_eq!(obj["disabled_agents"], serde_json::json!(["explore"])); + assert!(obj.contains_key("agents")); + assert_eq!(obj["agents"]["Sisyphus"]["model"], "claude-opus-4-5"); + } + + #[test] + fn test_merge_config_without_common_config() { + let global = OmoGlobalConfig { + schema_url: Some("https://example.com/schema.json".to_string()), + disabled_agents: vec!["explore".to_string()], + ..Default::default() + }; + let agents = Some(serde_json::json!({ + "Sisyphus": { "model": "claude-opus-4-5" } + })); + let categories = None; + let other_fields = None; + let profile_data = (agents, categories, other_fields, false); + let merged = OmoService::merge_config(&global, Some(&profile_data)); + let obj = merged.as_object().unwrap(); + + assert!(!obj.contains_key("$schema")); + assert!(!obj.contains_key("disabled_agents")); + assert!(obj.contains_key("agents")); + } +} diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 71f6cbe1b..c5f6abef7 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -164,6 +164,12 @@ impl ProviderService { // OpenCode uses additive mode - always write to live config if matches!(app_type, AppType::OpenCode) { + // OMO providers use exclusive mode and write to dedicated config file. + if provider.category.as_deref() == Some("omo") { + // Do not auto-enable newly added OMO providers. + // Users must explicitly switch/apply an OMO provider to activate it. + return Ok(true); + } write_live_snapshot(&app_type, &provider)?; return Ok(true); } @@ -197,6 +203,15 @@ impl ProviderService { // OpenCode uses additive mode - always update in live config if matches!(app_type, AppType::OpenCode) { + if provider.category.as_deref() == Some("omo") { + let is_omo_current = state + .db + .is_omo_provider_current(app_type.as_str(), &provider.id)?; + if is_omo_current { + crate::services::OmoService::write_config_to_file(state)?; + } + return Ok(true); + } write_live_snapshot(&app_type, &provider)?; return Ok(true); } @@ -242,6 +257,35 @@ impl ProviderService { pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { // OpenCode uses additive mode - no current provider concept if matches!(app_type, AppType::OpenCode) { + let is_omo = state + .db + .get_provider_by_id(id, app_type.as_str())? + .and_then(|p| p.category) + .as_deref() + == Some("omo"); + + if is_omo { + let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?; + let omo_count = state + .db + .get_all_providers(app_type.as_str())? + .values() + .filter(|p| p.category.as_deref() == Some("omo")) + .count(); + + if omo_count <= 1 && was_current { + return Err(AppError::Message( + "无法删除当前启用的最后一个 OMO 配置,请先停用".to_string(), + )); + } + + state.db.delete_provider(app_type.as_str(), id)?; + if was_current { + crate::services::OmoService::delete_config_file()?; + } + return Ok(()); + } + // Remove from database state.db.delete_provider(app_type.as_str(), id)?; // Also remove from live config @@ -267,10 +311,32 @@ impl ProviderService { /// Does NOT delete from database - provider remains in the list. /// This is used when user wants to "remove" a provider from active config /// but keep it available for future use. - pub fn remove_from_live_config(app_type: AppType, id: &str) -> Result<(), AppError> { + pub fn remove_from_live_config( + state: &AppState, + app_type: AppType, + id: &str, + ) -> Result<(), AppError> { match app_type { AppType::OpenCode => { - remove_opencode_provider_from_live(id)?; + let is_omo = state + .db + .get_provider_by_id(id, app_type.as_str())? + .and_then(|p| p.category) + .as_deref() + == Some("omo"); + + if is_omo { + state.db.clear_omo_provider_current(app_type.as_str(), id)?; + let still_has_current = + state.db.get_current_omo_provider("opencode")?.is_some(); + if still_has_current { + crate::services::OmoService::write_config_to_file(state)?; + } else { + crate::services::OmoService::delete_config_file()?; + } + } else { + remove_opencode_provider_from_live(id)?; + } } // Future: add other additive mode apps here _ => { @@ -302,6 +368,11 @@ impl ProviderService { .get(id) .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?; + // OMO providers are switched through their own exclusive path. + if matches!(app_type, AppType::OpenCode) && _provider.category.as_deref() == Some("omo") { + return Self::switch_normal(state, app_type, id, &providers); + } + // Check if proxy takeover mode is active AND proxy server is actually running // Both conditions must be true to use hot-switch mode // Use blocking wait since this is a sync function @@ -373,25 +444,28 @@ impl ProviderService { .get(id) .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?; + if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo") { + state.db.set_omo_provider_current(app_type.as_str(), id)?; + crate::services::OmoService::write_config_to_file(state)?; + return Ok(()); + } + // Backfill: Backfill current live config to current provider // Use effective current provider (validated existence) to ensure backfill targets valid provider let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?; - if let Some(current_id) = current_id { - if current_id != id { - // OpenCode uses additive mode - all providers coexist in the same file, - // no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini) - if !matches!(app_type, AppType::OpenCode) { - // Only backfill when switching to a different provider - if let Ok(live_config) = read_live_settings(app_type.clone()) { - if let Some(mut current_provider) = providers.get(¤t_id).cloned() { - current_provider.settings_config = live_config; - // Ignore backfill failure, don't affect switch flow - let _ = state.db.save_provider(app_type.as_str(), ¤t_provider); - } + match (current_id, matches!(app_type, AppType::OpenCode)) { + (Some(current_id), false) if current_id != id => { + // Only backfill when switching to a different provider. + if let Ok(live_config) = read_live_settings(app_type.clone()) { + if let Some(mut current_provider) = providers.get(¤t_id).cloned() { + current_provider.settings_config = live_config; + // Ignore backfill failure, don't affect switch flow. + let _ = state.db.save_provider(app_type.as_str(), ¤t_provider); } } } + _ => {} } // OpenCode uses additive mode - skip setting is_current (no such concept) diff --git a/src/App.tsx b/src/App.tsx index de9c6881c..7ecf95d77 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,6 @@ import { Plus, Settings, ArrowLeft, - // Bot, // TODO: Agents 功能开发中,暂时不需要 Book, Wrench, RefreshCw, @@ -56,6 +55,7 @@ import { UniversalProviderPanel } from "@/components/universal"; import { McpIcon } from "@/components/BrandIcons"; import { Button } from "@/components/ui/button"; import { SessionManagerPage } from "@/components/sessions/SessionManagerPage"; +import { useDisableCurrentOmo } from "@/lib/query/omo"; type View = | "providers" @@ -68,7 +68,6 @@ type View = | "universal" | "sessions"; -// macOS Overlay mode needs space for traffic light buttons, Windows/Linux use native titlebar const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px const HEADER_HEIGHT = 64; // px const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT; @@ -118,7 +117,6 @@ function App() { localStorage.setItem(VIEW_STORAGE_KEY, currentView); }, [currentView]); - // Get settings for visibleApps const { data: settingsData } = useSettingsQuery(); const visibleApps: VisibleApps = settingsData?.visibleApps ?? { claude: true, @@ -127,7 +125,6 @@ function App() { opencode: true, }; - // Get first visible app for fallback const getFirstVisibleApp = (): AppId => { if (visibleApps.claude) return "claude"; if (visibleApps.codex) return "codex"; @@ -136,7 +133,6 @@ function App() { return "claude"; // fallback }; - // If current active app is hidden, switch to first visible app useEffect(() => { if (!visibleApps[activeApp]) { setActiveApp(getFirstVisibleApp()); @@ -145,7 +141,6 @@ function App() { const [editingProvider, setEditingProvider] = useState(null); const [usageProvider, setUsageProvider] = useState(null); - // Confirm action state: 'remove' = remove from live config, 'delete' = delete from database const [confirmAction, setConfirmAction] = useState<{ provider: Provider; action: "remove" | "delete"; @@ -153,7 +148,6 @@ function App() { const [envConflicts, setEnvConflicts] = useState([]); const [showEnvBanner, setShowEnvBanner] = useState(false); - // 使用 Hook 保存最后有效值,用于动画退出期间保持内容显示 const effectiveEditingProvider = useLastValidValue(editingProvider); const effectiveUsageProvider = useLastValidValue(usageProvider); @@ -164,15 +158,12 @@ function App() { const addActionButtonClass = "bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8"; - // 获取代理服务状态 const { isRunning: isProxyRunning, takeoverStatus, status: proxyStatus, } = useProxyStatus(); - // 当前应用的代理是否开启 const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false; - // 当前应用代理实际使用的供应商 ID(从 active_targets 中获取) const activeProviderId = useMemo(() => { const target = proxyStatus?.active_targets?.find( (t) => t.app_type === activeApp, @@ -180,7 +171,6 @@ function App() { return target?.provider_id; }, [proxyStatus?.active_targets, activeApp]); - // 获取供应商列表,当代理服务运行时自动刷新 const { data, isLoading, refetch } = useProvidersQuery(activeApp, { isProxyRunning, }); @@ -188,7 +178,6 @@ function App() { const currentProviderId = data?.currentProviderId ?? ""; const hasSkillsSupport = true; - // 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作 const { addProvider, updateProvider, @@ -197,7 +186,23 @@ function App() { saveUsageScript, } = useProviderActions(activeApp); - // 监听来自托盘菜单的切换事件 + const disableOmoMutation = useDisableCurrentOmo(); + const handleDisableOmo = () => { + disableOmoMutation.mutate(undefined, { + onSuccess: () => { + toast.success(t("omo.disabled", { defaultValue: "OMO 已停用" })); + }, + onError: (error: Error) => { + toast.error( + t("omo.disableFailed", { + defaultValue: "停用 OMO 失败: {{error}}", + error: extractErrorMessage(error), + }), + ); + }, + }); + }; + useEffect(() => { let unsubscribe: (() => void) | undefined; @@ -221,7 +226,6 @@ function App() { }; }, [activeApp, refetch]); - // 监听统一供应商同步事件,刷新所有应用的供应商列表 useEffect(() => { let unsubscribe: (() => void) | undefined; @@ -229,10 +233,7 @@ function App() { try { const { listen } = await import("@tauri-apps/api/event"); unsubscribe = await listen("universal-provider-synced", async () => { - // 统一供应商同步后刷新所有应用的供应商列表 - // 使用 invalidateQueries 使所有 providers 查询失效 await queryClient.invalidateQueries({ queryKey: ["providers"] }); - // 同时更新托盘菜单 try { await providersApi.updateTrayMenu(); } catch (error) { @@ -253,7 +254,6 @@ function App() { }; }, [queryClient]); - // 应用启动时检测所有应用的环境变量冲突 useEffect(() => { const checkEnvOnStartup = async () => { try { @@ -278,7 +278,6 @@ function App() { checkEnvOnStartup(); }, []); - // 应用启动时检查是否刚完成了配置迁移 useEffect(() => { const checkMigration = async () => { try { @@ -297,7 +296,6 @@ function App() { checkMigration(); }, [t]); - // 应用启动时检查是否刚完成了 Skills 自动导入(统一管理 SSOT) useEffect(() => { const checkSkillsMigration = async () => { try { @@ -326,14 +324,12 @@ function App() { checkSkillsMigration(); }, [t, queryClient]); - // 切换应用时检测当前应用的环境变量冲突 useEffect(() => { const checkEnvOnSwitch = async () => { try { const conflicts = await checkEnvConflicts(activeApp); if (conflicts.length > 0) { - // 合并新检测到的冲突 setEnvConflicts((prev) => { const existingKeys = new Set( prev.map((c) => `${c.varName}:${c.sourcePath}`), @@ -359,7 +355,6 @@ function App() { checkEnvOnSwitch(); }, [activeApp]); - // 全局键盘快捷键 const currentViewRef = useRef(currentView); useEffect(() => { @@ -368,17 +363,14 @@ function App() { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - // Cmd/Ctrl + , 打开设置 if (event.key === "," && (event.metaKey || event.ctrlKey)) { event.preventDefault(); setCurrentView("settings"); return; } - // ESC 键返回 if (event.key !== "Escape" || event.defaultPrevented) return; - // 如果有模态框打开(通过 overflow hidden 判断),则不处理全局 ESC,交给模态框处理 if (document.body.style.overflow === "hidden") return; const view = currentViewRef.current; @@ -396,7 +388,6 @@ function App() { }; }, []); - // 打开网站链接 const handleOpenWebsite = async (url: string) => { try { await settingsApi.openExternal(url); @@ -410,22 +401,17 @@ function App() { } }; - // 编辑供应商 const handleEditProvider = async (provider: Provider) => { await updateProvider(provider); setEditingProvider(null); }; - // 确认删除/移除供应商 const handleConfirmAction = async () => { if (!confirmAction) return; const { provider, action } = confirmAction; if (action === "remove") { - // Remove from live config only (for additive mode apps like OpenCode) - // Does NOT delete from database - provider remains in the list await providersApi.removeFromLiveConfig(provider.id, activeApp); - // Invalidate queries to refresh the isInConfig state await queryClient.invalidateQueries({ queryKey: ["opencodeLiveProviderIds"], }); @@ -436,13 +422,11 @@ function App() { { closeButton: true }, ); } else { - // Delete from database await deleteProvider(provider.id); } setConfirmAction(null); }; - // Generate a unique provider key for OpenCode duplication const generateUniqueOpencodeKey = ( originalKey: string, existingKeys: string[], @@ -453,7 +437,6 @@ function App() { return baseKey; } - // If -copy already exists, try -copy-2, -copy-3, ... let counter = 2; while (existingKeys.includes(`${baseKey}-${counter}`)) { counter++; @@ -461,9 +444,7 @@ function App() { return `${baseKey}-${counter}`; }; - // 复制供应商 const handleDuplicateProvider = async (provider: Provider) => { - // 1️⃣ 计算新的 sortIndex:如果原供应商有 sortIndex,则复制它 const newSortIndex = provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined; @@ -482,7 +463,6 @@ function App() { iconColor: provider.iconColor, }; - // OpenCode: generate unique provider key (used as ID) if (activeApp === "opencode") { const existingKeys = Object.keys(providers); duplicatedProvider.providerKey = generateUniqueOpencodeKey( @@ -491,7 +471,6 @@ function App() { ); } - // 2️⃣ 如果原供应商有 sortIndex,需要将后续所有供应商的 sortIndex +1 if (provider.sortIndex !== undefined) { const updates = Object.values(providers) .filter( @@ -505,7 +484,6 @@ function App() { sortIndex: p.sortIndex! + 1, })); - // 先更新现有供应商的 sortIndex,为新供应商腾出位置 if (updates.length > 0) { try { await providersApi.updateSortOrder(updates, activeApp); @@ -521,11 +499,9 @@ function App() { } } - // 3️⃣ 添加复制的供应商 await addProvider(duplicatedProvider); }; - // 打开提供商终端 const handleOpenTerminal = async (provider: Provider) => { try { await providersApi.openTerminal(provider.id, activeApp); @@ -545,10 +521,8 @@ function App() { } }; - // 导入配置成功后刷新 const handleImportSuccess = async () => { try { - // 导入会影响所有应用的供应商数据:刷新所有 providers 缓存 await queryClient.invalidateQueries({ queryKey: ["providers"], refetchType: "all", @@ -626,7 +600,6 @@ function App() { default: return (
- {/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
{ + setEditingProvider(provider); + }} onDelete={(provider) => setConfirmAction({ provider, action: "delete" }) } @@ -658,6 +633,9 @@ function App() { setConfirmAction({ provider, action: "remove" }) : undefined } + onDisableOmo={ + activeApp === "opencode" ? handleDisableOmo : undefined + } onDuplicate={handleDuplicateProvider} onConfigureUsage={setUsageProvider} onOpenWebsite={handleOpenWebsite} @@ -695,13 +673,11 @@ function App() { className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30" style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }} > - {/* 全局拖拽区域(顶部 28px),避免上边框无法拖动 */}
- {/* 环境变量警告横幅 */} {showEnvBanner && envConflicts.length > 0 && ( { - // 删除后重新检测 try { const allConflicts = await checkAllEnvConflicts(); const flatConflicts = Object.values(allConflicts).flat(); @@ -822,7 +797,7 @@ function App() { setSettingsDefaultTab("usage"); setCurrentView("settings"); }} - title={t("settings.usage.title", { + title={t("usage.title", { defaultValue: "使用统计", })} className="hover:bg-black/5 dark:hover:bg-white/5" @@ -970,18 +945,6 @@ function App() { > - {/* TODO: Agents 功能开发中,暂时隐藏入口 */} - {/* {isClaudeApp && ( - - )} */} - {/* 供应商图标 */}
- {/* 健康状态徽章 */} + {isOmo && ( + + OMO + + )} + {isProxyRunning && isInFailoverQueue && health && ( )} - {/* 故障转移优先级徽章 */} {isAutoFailoverEnabled && isInFailoverQueue && failoverPriority && ( @@ -318,10 +303,8 @@ export function ProviderCard({ } as React.CSSProperties } > - {/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
- {/* 多套餐时显示套餐数量,单套餐时显示详细信息 */} {hasMultiplePlans ? (
@@ -342,7 +325,6 @@ export function ProviderCard({ inline={true} /> )} - {/* 展开/折叠按钮 - 仅在有多套餐时显示 */} {hasMultiplePlans && (
- {/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入,与用量信息保持间距 */}
onSwitch(provider)} onEdit={() => onEdit(provider)} onDuplicate={() => onDuplicate(provider)} @@ -388,10 +371,10 @@ export function ProviderCard({ ? () => onRemoveFromConfig(provider) : undefined } + onDisableOmo={onDisableOmo} onOpenTerminal={ onOpenTerminal ? () => onOpenTerminal(provider) : undefined } - // 故障转移相关 isAutoFailoverEnabled={isAutoFailoverEnabled} isInFailoverQueue={isInFailoverQueue} onToggleFailover={onToggleFailover} @@ -400,7 +383,6 @@ export function ProviderCard({
- {/* 展开的完整套餐列表 */} {isExpanded && hasMultiplePlans && (
void; onEdit: (provider: Provider) => void; onDelete: (provider: Provider) => void; - /** OpenCode: remove from live config (not delete from database) */ onRemoveFromConfig?: (provider: Provider) => void; + onDisableOmo?: () => void; onDuplicate: (provider: Provider) => void; onConfigureUsage?: (provider: Provider) => void; onOpenWebsite: (url: string) => void; @@ -61,6 +61,7 @@ export function ProviderList({ onEdit, onDelete, onRemoveFromConfig, + onDisableOmo, onDuplicate, onConfigureUsage, onOpenWebsite, @@ -77,14 +78,12 @@ export function ProviderList({ appId, ); - // OpenCode: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig const { data: opencodeLiveIds } = useQuery({ queryKey: ["opencodeLiveProviderIds"], queryFn: () => providersApi.getOpenCodeLiveProviderIds(), enabled: appId === "opencode", }); - // OpenCode: 判断供应商是否已添加到 opencode.json const isProviderInConfig = useCallback( (providerId: string): boolean => { if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true @@ -93,20 +92,18 @@ export function ProviderList({ [appId, opencodeLiveIds], ); - // 流式健康检查 - 功能已隐藏 - // const { checkProvider, isChecking } = useStreamCheck(appId); - - // 故障转移相关 const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId); const { data: failoverQueue } = useFailoverQueue(appId); const addToQueue = useAddToFailoverQueue(); const removeFromQueue = useRemoveFromFailoverQueue(); - // 联动状态:只有当前应用开启代理接管且故障转移开启时才启用故障转移模式 const isFailoverModeActive = isProxyTakeover === true && isAutoFailoverEnabled === true; - // 计算供应商在故障转移队列中的优先级(基于 sortIndex 排序) + const isOpenCode = appId === "opencode"; + const { data: currentOmoId } = useCurrentOmoProviderId(isOpenCode); + const { data: omoProviderCount } = useOmoProviderCount(isOpenCode); + const getFailoverPriority = useCallback( (providerId: string): number | undefined => { if (!isFailoverModeActive || !failoverQueue) return undefined; @@ -118,7 +115,6 @@ export function ProviderList({ [isFailoverModeActive, failoverQueue], ); - // 判断供应商是否在故障转移队列中 const isInFailoverQueue = useCallback( (providerId: string): boolean => { if (!isFailoverModeActive || !failoverQueue) return false; @@ -127,7 +123,6 @@ export function ProviderList({ [isFailoverModeActive, failoverQueue], ); - // 切换供应商的故障转移队列状态 const handleToggleFailover = useCallback( (providerId: string, enabled: boolean) => { if (enabled) { @@ -139,11 +134,6 @@ export function ProviderList({ [appId, addToQueue, removeFromQueue], ); - // handleTest 功能已隐藏 - 供应商请求格式复杂难以统一测试 - // const handleTest = (provider: Provider) => { - // checkProvider(provider.id, provider.name); - // }; - const [searchTerm, setSearchTerm] = useState(""); const [isSearchOpen, setIsSearchOpen] = useState(false); const searchInputRef = useRef(null); @@ -215,36 +205,44 @@ export function ProviderList({ strategy={verticalListSortingStrategy} >
- {filteredProviders.map((provider) => ( - - handleToggleFailover(provider.id, enabled) - } - activeProviderId={activeProviderId} - /> - ))} + {filteredProviders.map((provider) => { + const isOmo = provider.category === "omo"; + const isOmoCurrent = isOmo && provider.id === (currentOmoId || ""); + return ( + + handleToggleFailover(provider.id, enabled) + } + activeProviderId={activeProviderId} + /> + ); + })}
@@ -334,11 +332,13 @@ interface SortableProviderCardProps { isCurrent: boolean; appId: AppId; isInConfig: boolean; + isOmo: boolean; + isLastOmo: boolean; onSwitch: (provider: Provider) => void; onEdit: (provider: Provider) => void; onDelete: (provider: Provider) => void; - /** OpenCode: remove from live config (not delete from database) */ onRemoveFromConfig?: (provider: Provider) => void; + onDisableOmo?: () => void; onDuplicate: (provider: Provider) => void; onConfigureUsage?: (provider: Provider) => void; onOpenWebsite: (url: string) => void; @@ -347,7 +347,6 @@ interface SortableProviderCardProps { isTesting: boolean; isProxyRunning: boolean; isProxyTakeover: boolean; - // 故障转移相关 isAutoFailoverEnabled: boolean; failoverPriority?: number; isInFailoverQueue: boolean; @@ -360,10 +359,13 @@ function SortableProviderCard({ isCurrent, appId, isInConfig, + isOmo, + isLastOmo, onSwitch, onEdit, onDelete, onRemoveFromConfig, + onDisableOmo, onDuplicate, onConfigureUsage, onOpenWebsite, @@ -399,10 +401,13 @@ function SortableProviderCard({ isCurrent={isCurrent} appId={appId} isInConfig={isInConfig} + isOmo={isOmo} + isLastOmo={isLastOmo} onSwitch={onSwitch} onEdit={onEdit} onDelete={onDelete} onRemoveFromConfig={onRemoveFromConfig} + onDisableOmo={onDisableOmo} onDuplicate={onDuplicate} onConfigureUsage={ onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined @@ -418,7 +423,6 @@ function SortableProviderCard({ listeners, isDragging, }} - // 故障转移相关 isAutoFailoverEnabled={isAutoFailoverEnabled} failoverPriority={failoverPriority} isInFailoverQueue={isInFailoverQueue} diff --git a/src/components/providers/forms/OmoCommonConfigEditor.tsx b/src/components/providers/forms/OmoCommonConfigEditor.tsx new file mode 100644 index 000000000..575d3c54b --- /dev/null +++ b/src/components/providers/forms/OmoCommonConfigEditor.tsx @@ -0,0 +1,161 @@ +import { useTranslation } from "react-i18next"; +import { useEffect, useState } from "react"; +import { FullScreenPanel } from "@/components/common/FullScreenPanel"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Save, FolderInput, Loader2 } from "lucide-react"; +import JsonEditor from "@/components/JsonEditor"; +import { + OmoGlobalConfigFields, + type OmoGlobalConfigFieldsRef, +} from "./OmoGlobalConfigFields"; +import type { OmoGlobalConfig } from "@/types/omo"; + +interface OmoCommonConfigEditorProps { + previewValue: string; + useCommonConfig: boolean; + onCommonConfigToggle: (checked: boolean) => void; + isModalOpen: boolean; + onEditClick: () => void; + onModalClose: () => void; + onSave: () => Promise; + isSaving: boolean; + onGlobalConfigStateChange: (config: OmoGlobalConfig) => void; + globalConfigRef: React.RefObject; + fieldsKey: number; +} + +export function OmoCommonConfigEditor({ + previewValue, + useCommonConfig, + onCommonConfigToggle, + isModalOpen, + onEditClick, + onModalClose, + onSave, + isSaving, + onGlobalConfigStateChange, + globalConfigRef, + fieldsKey, +}: OmoCommonConfigEditorProps) { + const { t } = useTranslation(); + const [isDarkMode, setIsDarkMode] = useState(false); + const [isImporting, setIsImporting] = useState(false); + useEffect(() => { + const syncDarkMode = () => + setIsDarkMode(document.documentElement.classList.contains("dark")); + syncDarkMode(); + const observer = new MutationObserver(syncDarkMode); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + return () => observer.disconnect(); + }, []); + const handleImportLocal = async () => { + if (!globalConfigRef.current) return; + setIsImporting(true); + try { + await globalConfigRef.current.importFromLocal(); + } finally { + setIsImporting(false); + } + }; + return ( + <> +
+
+ +
+ +
+
+
+ +
+ {}} + darkMode={isDarkMode} + rows={14} + showValidation={false} + language="json" + /> +
+ + + + + + } + > +
+

+ {t("omo.commonConfigHint", { + defaultValue: + "OMO common config will be merged into all OMO configs that enable it", + })} +

+ } + onStateChange={onGlobalConfigStateChange} + hideSaveButtons + /> +
+
+ + ); +} diff --git a/src/components/providers/forms/OmoFormFields.tsx b/src/components/providers/forms/OmoFormFields.tsx new file mode 100644 index 000000000..2ee356a3c --- /dev/null +++ b/src/components/providers/forms/OmoFormFields.tsx @@ -0,0 +1,1031 @@ +import { useState, useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Plus, + Trash2, + ChevronDown, + ChevronRight, + Wand2, + Settings, + FolderInput, + Loader2, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { useReadOmoLocalFile } from "@/lib/query/omo"; +import { + OMO_BUILTIN_AGENTS, + OMO_BUILTIN_CATEGORIES, + type OmoAgentDef, + type OmoCategoryDef, +} from "@/types/omo"; + +const ADVANCED_PLACEHOLDER = `{ + "temperature": 0.5, + "top_p": 0.9, + "budgetTokens": 20000, + "prompt_append": "", + "permission": { "edit": "allow", "bash": "ask" } +}`; + +interface OmoFormFieldsProps { + modelOptions: Array<{ value: string; label: string }>; + modelVariantsMap?: Record; + agents: Record>; + onAgentsChange: (agents: Record>) => void; + categories: Record>; + onCategoriesChange: ( + categories: Record>, + ) => void; + otherFieldsStr: string; + onOtherFieldsStrChange: (value: string) => void; +} + +type CustomModelItem = { key: string; model: string }; +type BuiltinModelDef = Pick< + OmoAgentDef | OmoCategoryDef, + "key" | "display" | "descZh" | "descEn" | "recommended" +>; +type ModelOption = { value: string; label: string }; + +const BUILTIN_AGENT_KEYS = new Set(OMO_BUILTIN_AGENTS.map((a) => a.key)); +const BUILTIN_CATEGORY_KEYS = new Set(OMO_BUILTIN_CATEGORIES.map((c) => c.key)); +const EMPTY_MODEL_VALUE = "__cc_switch_omo_model_empty__"; +const UNAVAILABLE_MODEL_VALUE = "__cc_switch_omo_model_unavailable__"; +const EMPTY_VARIANT_VALUE = "__cc_switch_omo_variant_empty__"; +const UNAVAILABLE_VARIANT_VALUE = "__cc_switch_omo_variant_unavailable__"; + +function getAdvancedStr(config: Record | undefined): string { + if (!config) return ""; + const adv: Record = {}; + for (const [k, v] of Object.entries(config)) { + if (k !== "model" && k !== "variant") adv[k] = v; + } + return Object.keys(adv).length > 0 ? JSON.stringify(adv, null, 2) : ""; +} + +function collectCustomModels( + store: Record>, + builtinKeys: Set, +): CustomModelItem[] { + const customs: CustomModelItem[] = []; + for (const [k, v] of Object.entries(store)) { + if (!builtinKeys.has(k) && typeof v === "object" && v !== null) { + customs.push({ + key: k, + model: ((v as Record).model as string) || "", + }); + } + } + return customs; +} + +function mergeCustomModelsIntoStore( + store: Record>, + builtinKeys: Set, + customs: CustomModelItem[], +): Record> { + const updated = { ...store }; + for (const key of Object.keys(updated)) { + if (!builtinKeys.has(key)) delete updated[key]; + } + for (const custom of customs) { + if (custom.key.trim()) { + updated[custom.key] = { ...updated[custom.key], model: custom.model }; + } + } + return updated; +} + +export function OmoFormFields({ + modelOptions, + modelVariantsMap = {}, + agents, + onAgentsChange, + categories, + onCategoriesChange, + otherFieldsStr, + onOtherFieldsStrChange, +}: OmoFormFieldsProps) { + const { t, i18n } = useTranslation(); + const isZh = i18n.language?.startsWith("zh"); + + const [mainAgentsOpen, setMainAgentsOpen] = useState(true); + const [subAgentsOpen, setSubAgentsOpen] = useState(true); + const [categoriesOpen, setCategoriesOpen] = useState(true); + const [otherFieldsOpen, setOtherFieldsOpen] = useState(false); + + const [expandedAgents, setExpandedAgents] = useState>( + {}, + ); + const [expandedCategories, setExpandedCategories] = useState< + Record + >({}); + const [agentAdvancedDrafts, setAgentAdvancedDrafts] = useState< + Record + >({}); + const [categoryAdvancedDrafts, setCategoryAdvancedDrafts] = useState< + Record + >({}); + + const [customAgents, setCustomAgents] = useState(() => + collectCustomModels(agents, BUILTIN_AGENT_KEYS), + ); + + const [customCategories, setCustomCategories] = useState( + () => collectCustomModels(categories, BUILTIN_CATEGORY_KEYS), + ); + + useEffect(() => { + setCustomAgents(collectCustomModels(agents, BUILTIN_AGENT_KEYS)); + }, [agents]); + + useEffect(() => { + setCustomCategories(collectCustomModels(categories, BUILTIN_CATEGORY_KEYS)); + }, [categories]); + + const syncCustomAgents = useCallback( + (customs: CustomModelItem[]) => { + onAgentsChange( + mergeCustomModelsIntoStore(agents, BUILTIN_AGENT_KEYS, customs), + ); + }, + [agents, onAgentsChange], + ); + + const syncCustomCategories = useCallback( + (customs: CustomModelItem[]) => { + onCategoriesChange( + mergeCustomModelsIntoStore(categories, BUILTIN_CATEGORY_KEYS, customs), + ); + }, + [categories, onCategoriesChange], + ); + + const buildEffectiveModelOptions = useCallback( + (currentModel: string): ModelOption[] => { + if (!currentModel) return modelOptions; + if (modelOptions.some((item) => item.value === currentModel)) { + return modelOptions; + } + return [ + { + value: currentModel, + label: t("omo.currentValueNotEnabled", { + value: currentModel, + defaultValue: "{{value}} (current value, not enabled)", + }), + }, + ...modelOptions, + ]; + }, + [modelOptions, t], + ); + + const resolveRecommendedModel = useCallback( + (recommended?: string): string | undefined => { + if (!recommended || modelOptions.length === 0) return undefined; + + const exact = modelOptions.find((item) => item.value === recommended); + if (exact) return exact.value; + + const bySuffix = modelOptions.find((item) => + item.value.endsWith(`/${recommended}`), + ); + return bySuffix?.value; + }, + [modelOptions], + ); + + const renderModelSelect = ( + currentModel: string, + onChange: (value: string) => void, + placeholder?: string, + ) => { + const options = buildEffectiveModelOptions(currentModel); + return ( + + ); + }; + + const buildEffectiveVariantOptions = useCallback( + (currentModel: string, currentVariant: string): string[] => { + const variantKeys = modelVariantsMap[currentModel] || []; + if (!currentVariant || variantKeys.includes(currentVariant)) { + return variantKeys; + } + return [currentVariant, ...variantKeys]; + }, + [modelVariantsMap], + ); + + const renderVariantSelect = ( + currentModel: string, + currentVariant: string, + onChange: (value: string) => void, + ) => { + const variantOptions = buildEffectiveVariantOptions( + currentModel, + currentVariant, + ); + const hasModel = Boolean(currentModel); + const firstIsUnavailable = + Boolean(currentVariant) && + !(modelVariantsMap[currentModel] || []).includes(currentVariant); + + return ( + + ); + }; + + const handleModelChange = ( + key: string, + model: string, + store: Record>, + setter: (v: Record>) => void, + ) => { + if (model.trim()) { + const nextEntry: Record = { + ...(store[key] || {}), + model, + }; + const currentVariant = + typeof nextEntry.variant === "string" ? nextEntry.variant : ""; + if (currentVariant) { + const validVariants = modelVariantsMap[model] || []; + if (!validVariants.includes(currentVariant)) { + delete nextEntry.variant; + } + } + setter({ ...store, [key]: nextEntry }); + } else { + const existing = store[key]; + if (existing) { + const adv = { ...existing }; + delete adv.model; + delete adv.variant; + if (Object.keys(adv).length > 0) { + setter({ ...store, [key]: adv }); + } else { + const next = { ...store }; + delete next[key]; + setter(next); + } + } + } + }; + + const handleVariantChange = ( + key: string, + variant: string, + store: Record>, + setter: (v: Record>) => void, + ) => { + const existing = store[key]; + if (variant.trim()) { + setter({ ...store, [key]: { ...existing, variant } }); + return; + } + + if (!existing) return; + const nextEntry = { ...existing }; + delete nextEntry.variant; + if (Object.keys(nextEntry).length > 0) { + setter({ ...store, [key]: nextEntry }); + return; + } + + const next = { ...store }; + delete next[key]; + setter(next); + }; + + const handleAdvancedChange = ( + key: string, + rawJson: string, + store: Record>, + setter: (v: Record>) => void, + ): boolean => { + const currentModel = (store[key]?.model as string) || ""; + const currentVariant = (store[key]?.variant as string) || ""; + if (!rawJson.trim()) { + if (currentModel || currentVariant) { + setter({ + ...store, + [key]: { + ...(currentModel ? { model: currentModel } : {}), + ...(currentVariant ? { variant: currentVariant } : {}), + }, + }); + } else { + const next = { ...store }; + delete next[key]; + setter(next); + } + return true; + } + try { + const parsed = JSON.parse(rawJson); + if ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) + ) { + const parsedAdvanced = { ...(parsed as Record) }; + delete parsedAdvanced.model; + delete parsedAdvanced.variant; + setter({ + ...store, + [key]: { + ...(currentModel ? { model: currentModel } : {}), + ...(currentVariant ? { variant: currentVariant } : {}), + ...parsedAdvanced, + }, + }); + return true; + } + return false; + } catch { + return false; + } + }; + + type AdvancedScope = "agent" | "category"; + + const setAdvancedDraft = ( + scope: AdvancedScope, + key: string, + value: string, + ) => { + if (scope === "agent") { + setAgentAdvancedDrafts((prev) => ({ ...prev, [key]: value })); + return; + } + setCategoryAdvancedDrafts((prev) => ({ ...prev, [key]: value })); + }; + + const removeAdvancedDraft = (scope: AdvancedScope, key: string) => { + if (scope === "agent") { + setAgentAdvancedDrafts((prev) => { + const copied = { ...prev }; + delete copied[key]; + return copied; + }); + return; + } + setCategoryAdvancedDrafts((prev) => { + const copied = { ...prev }; + delete copied[key]; + return copied; + }); + }; + + const toggleAdvancedEditor = ( + scope: AdvancedScope, + key: string, + advStr: string, + isExpanded: boolean, + ) => { + const willOpen = !isExpanded; + if (scope === "agent") { + setExpandedAgents((prev) => ({ ...prev, [key]: willOpen })); + if (willOpen && agentAdvancedDrafts[key] === undefined) { + setAdvancedDraft(scope, key, advStr); + } + return; + } + setExpandedCategories((prev) => ({ ...prev, [key]: willOpen })); + if (willOpen && categoryAdvancedDrafts[key] === undefined) { + setAdvancedDraft(scope, key, advStr); + } + }; + + const renderAdvancedEditor = ({ + scope, + draftKey, + configKey, + draftValue, + store, + setter, + showHint, + }: { + scope: AdvancedScope; + draftKey: string; + configKey: string; + draftValue: string; + store: Record>; + setter: (value: Record>) => void; + showHint?: boolean; + }) => ( +
+