@@ -1759,5 +1983,5 @@ export type ProviderFormValues = ProviderFormData & {
presetCategory?: ProviderCategory;
isPartner?: boolean;
meta?: ProviderMeta;
- providerKey?: string; // OpenCode: user-defined provider key
+ providerKey?: string;
};
diff --git a/src/components/providers/forms/ProviderPresetSelector.tsx b/src/components/providers/forms/ProviderPresetSelector.tsx
index 611b7c7c2..4ec882c22 100644
--- a/src/components/providers/forms/ProviderPresetSelector.tsx
+++ b/src/components/providers/forms/ProviderPresetSelector.tsx
@@ -40,7 +40,6 @@ export function ProviderPresetSelector({
}: ProviderPresetSelectorProps) {
const { t } = useTranslation();
- // 根据分类获取提示文字
const getCategoryHint = (): React.ReactNode => {
switch (category) {
case "official":
@@ -63,6 +62,11 @@ export function ProviderPresetSelector({
return t("providerForm.customApiKeyHint", {
defaultValue: "💡 自定义配置需手动填写所有必要字段",
});
+ case "omo":
+ return t("providerForm.omoHint", {
+ defaultValue:
+ "💡 OMO 配置管理 Agent 模型分配,写入 oh-my-opencode.jsonc",
+ });
default:
return t("providerPreset.hint", {
defaultValue: "选择预设后可继续调整下方字段。",
@@ -70,7 +74,6 @@ export function ProviderPresetSelector({
}
};
- // 渲染预设按钮的图标
const renderPresetIcon = (
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset,
) => {
@@ -91,7 +94,6 @@ export function ProviderPresetSelector({
}
};
- // 获取预设按钮的样式类名
const getPresetButtonClass = (
isSelected: boolean,
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset,
@@ -100,18 +102,15 @@ export function ProviderPresetSelector({
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
if (isSelected) {
- // 如果有自定义主题,使用自定义颜色
if (preset.theme?.backgroundColor) {
return `${baseClass} text-white`;
}
- // 默认使用主题蓝色
return `${baseClass} bg-blue-500 text-white dark:bg-blue-600`;
}
return `${baseClass} bg-accent text-muted-foreground hover:bg-accent/80`;
};
- // 获取预设按钮的内联样式(用于自定义背景色)
const getPresetButtonStyle = (
isSelected: boolean,
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset,
@@ -130,7 +129,6 @@ export function ProviderPresetSelector({
{t("providerPreset.label")}
- {/* 自定义按钮 */}
onPresetChange("custom")}
@@ -143,7 +141,6 @@ export function ProviderPresetSelector({
{t("providerPreset.custom")}
- {/* 预设按钮 */}
{categoryKeys.map((category) => {
const entries = groupedPresets[category];
if (!entries || entries.length === 0) return null;
@@ -174,7 +171,6 @@ export function ProviderPresetSelector({
})}
- {/* 统一供应商预设(新的一行) */}
{onUniversalPresetSelect && universalProviderPresets.length > 0 && (
<>
@@ -196,7 +192,6 @@ export function ProviderPresetSelector({
))}
- {/* 管理统一供应商按钮 */}
{onManageUniversalProviders && (
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/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,
appType: v === "all" ? undefined : v,
})
}
@@ -117,11 +194,16 @@ export function RequestLogTable() {
- setTempFilters({
- ...tempFilters,
- statusCode: v === "all" ? undefined : parseInt(v),
+ setDraftFilters({
+ ...draftFilters,
+ statusCode:
+ v === "all"
+ ? undefined
+ : Number.isFinite(Number.parseInt(v, 10))
+ ? Number.parseInt(v, 10)
+ : undefined,
})
}
>
@@ -144,10 +226,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")}
-
-
+
+ {refreshIntervalMs > 0 ? `${refreshIntervalMs / 1000}s` : "--"}
+
+
+
+ {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/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts
index cb725c74c..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: {
@@ -438,7 +434,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",
},
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 a110c227e..26e71a2ad 100644
--- a/src/config/opencodeProviderPresets.ts
+++ b/src/config/opencodeProviderPresets.ts
@@ -1,7 +1,3 @@
-/**
- * OpenCode 预设供应商配置模板
- * OpenCode 使用 AI SDK npm 包,配置结构与其他应用不同
- */
import type { ProviderCategory, OpenCodeProviderConfig } from "../types";
import type { PresetTheme, TemplateValueConfig } from "./claudeProviderPresets";
@@ -9,27 +5,18 @@ export interface OpenCodeProviderPreset {
name: string;
websiteUrl: string;
apiKeyUrl?: string;
- /** OpenCode settings_config 结构 */
settingsConfig: OpenCodeProviderConfig;
isOfficial?: boolean;
isPartner?: boolean;
partnerPromotionKey?: string;
category?: ProviderCategory;
- /** 模板变量定义 */
templateValues?: Record
;
- /** 视觉主题配置 */
theme?: PresetTheme;
- /** 图标名称 */
icon?: string;
- /** 图标颜色 */
iconColor?: string;
- /** 标记为自定义模板(用于 UI 区分) */
isCustomTemplate?: boolean;
}
-/**
- * OpenCode npm 包选项(AI SDK 生态)
- */
export const opencodeNpmPackages = [
{ value: "@ai-sdk/openai", label: "OpenAI" },
{ value: "@ai-sdk/openai-compatible", label: "OpenAI Compatible" },
@@ -37,11 +24,7 @@ export const opencodeNpmPackages = [
{ value: "@ai-sdk/google", label: "Google (Gemini)" },
] as const;
-/**
- * OpenCode 供应商预设列表
- */
export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
- // ========== 国产官方 ==========
{
name: "DeepSeek",
websiteUrl: "https://platform.deepseek.com",
@@ -137,23 +120,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",
@@ -476,7 +457,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
- // ========== 聚合网站 ==========
{
name: "AiHubMix",
websiteUrl: "https://aihubmix.com",
@@ -585,7 +565,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
- // ========== 第三方合作伙伴 ==========
{
name: "PackyCode",
websiteUrl: "https://www.packyapi.com",
@@ -651,7 +630,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: {
@@ -731,7 +710,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
- // ========== 自定义模板 ==========
{
name: "OpenAI Compatible",
websiteUrl: "",
@@ -760,4 +738,18 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
},
+
+ {
+ name: "Oh My OpenCode",
+ websiteUrl: "https://github.com/code-yeongyu/oh-my-opencode",
+ settingsConfig: {
+ npm: "",
+ options: {},
+ models: {},
+ },
+ category: "omo" as ProviderCategory,
+ icon: "opencode",
+ iconColor: "#8B5CF6",
+ isCustomTemplate: true,
+ },
];
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 0ee8f673f..947f410f6 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -32,6 +32,7 @@
"back": "Back",
"refresh": "Refresh",
"refreshing": "Refreshing...",
+ "import": "Import",
"all": "All",
"search": "Search",
"reset": "Reset",
@@ -114,6 +115,10 @@
"duplicate": "Duplicate",
"sortUpdateFailed": "Failed to update sort order",
"configureUsage": "Configure usage query",
+ "officialPartner": "Official Partner",
+ "openTerminal": "Open Terminal",
+ "terminalOpened": "Terminal opened",
+ "terminalOpenFailed": "Failed to open terminal",
"name": "Provider Name",
"namePlaceholder": "e.g., Claude Official",
"websiteUrl": "Website URL",
@@ -164,7 +169,8 @@
"deleteFailed": "Failed to delete provider: {{error}}",
"settingsSaved": "Settings saved",
"settingsSaveFailed": "Failed to save settings: {{error}}",
- "openAIChatFormatHint": "This provider uses OpenAI Chat format and requires the proxy service to be enabled"
+ "openAIChatFormatHint": "This provider uses OpenAI Chat format and requires the proxy service to be enabled",
+ "openLinkFailed": "Failed to open link"
},
"confirm": {
"deleteProvider": "Delete Provider",
@@ -486,6 +492,7 @@
"aggregatorApiKeyHint": "💡 Only need to fill in API Key, endpoint is preset",
"thirdPartyApiKeyHint": "💡 Only need to fill in API Key, endpoint is preset",
"customApiKeyHint": "💡 Custom configuration requires manually filling all necessary fields",
+ "omoHint": "💡 OMO config manages Agent model assignments and writes to oh-my-opencode.jsonc",
"officialHint": "💡 Official provider uses browser login, no API Key needed",
"getApiKey": "Get API Key",
"partnerPromotion": {
@@ -1283,6 +1290,9 @@
"agents": {
"title": "Agents"
},
+ "modelTest": {
+ "testProvider": "Test model"
+ },
"health": {
"operational": "Operational",
"degraded": "Degraded",
@@ -1521,6 +1531,7 @@
"deleted": "Universal provider deleted",
"addSuccess": "Universal provider added successfully",
"addFailed": "Failed to add universal provider",
+ "hint": "Cross-app unified config, auto-sync to Claude/Codex/Gemini",
"manage": "Manage",
"loadError": "Failed to load universal providers",
"saveError": "Failed to save universal provider",
@@ -1535,5 +1546,94 @@
"saveAndSyncError": "Failed to save and sync",
"configJsonPreview": "Config JSON Preview",
"configJsonPreviewHint": "The following configurations will be synced to each app (only the displayed fields will be overwritten, other custom settings will be preserved)"
+ },
+ "omo": {
+ "editProfile": "Edit OMO Config",
+ "newProfile": "New OMO Config",
+ "profileName": "Name",
+ "mainAgents": "Main Agents",
+ "subAgents": "Sub Agents",
+ "categories": "Categories",
+ "customAgents": "Custom Agents",
+ "noCustomAgents": "No custom agents",
+ "otherFields": "Other Config",
+ "globalConfig": "OMO Global Config",
+ "globalConfigShort": "OMO Config",
+ "globalConfigSaved": "Global config saved",
+ "addProfile": "Add OMO Provider",
+ "disabledItems": "Disabled Items",
+ "advanced": "Advanced Settings",
+ "profileCreated": "OMO config created",
+ "profileUpdated": "OMO config updated",
+ "invalidJson": "Other Fields contains invalid JSON",
+ "confirmDelete": "Delete Config",
+ "confirmDeleteMsg": "Delete \"{{name}}\"?",
+ "profileDeleted": "Config deleted",
+ "imported": "Imported as \"{{name}}\"",
+ "import": "Import",
+ "global": "Global",
+ "empty": "No OMO configs yet. Click + Add or Import from local.",
+ "applied": "Applied",
+ "apply": "Apply",
+ "enable": "Enable",
+ "enabled": "Enabled",
+ "disabled": "OMO disabled",
+ "disableFailed": "Failed to disable OMO: {{error}}",
+ "writeCommonConfig": "Write to common config",
+ "editCommonConfig": "Edit common config",
+ "editCommonConfigTitle": "Common Config",
+ "commonConfigHint": "OMO common config will be merged into all OMO configs that enable it",
+ "selectPlaceholder": "Select...",
+ "clear": "Clear",
+ "clearWrapped": "(Clear)",
+ "defaultWrapped": "(Default)",
+ "variantPlaceholder": "variant",
+ "selectEnabledModel": "Select enabled model",
+ "selectModelFirst": "Select model first",
+ "noEnabledModels": "No enabled models",
+ "noVariantsForModel": "No variants for model",
+ "currentValueNotEnabled": "{{value}} (current value, not enabled)",
+ "currentValueUnavailable": "{{value}} (current value, unavailable)",
+ "advancedLabel": "Advanced",
+ "advancedJsonInvalid": "Advanced JSON is invalid",
+ "advancedJsonHint": "temperature, top_p, budgetTokens, prompt_append, permission, etc. Leave empty for defaults",
+ "noEnabledModelsWarning": "No enabled models available. Configure and enable OpenCode models first.",
+ "importLocalReplaceSuccess": "Imported local file and replaced Agents/Categories/Other Fields",
+ "importLocalFailed": "Failed to read local file: {{error}}",
+ "agentKeyPlaceholder": "agent key",
+ "categoryKeyPlaceholder": "category key",
+ "modelNamePlaceholder": "model-name",
+ "custom": "Custom",
+ "customCategories": "Custom Categories",
+ "modelConfiguration": "Model Configuration",
+ "fillRecommended": "Fill Recommended",
+ "configSummary": "{{agents}} agents, {{categories}} categories configured · Click ⚙ for advanced params",
+ "enabledModelsCount": "{{count}} enabled models available",
+ "source": "from:",
+ "otherFieldsJson": "Other Fields (JSON)",
+ "searchOrType": "Search or type custom value...",
+ "noMatches": "No matches",
+ "jsonMustBeObject": "{{field}} must be a JSON object",
+ "jsonInvalid": "{{field}} contains invalid JSON",
+ "importGlobalSuccess": "Imported global config from local file (unsaved)",
+ "importGlobalFailed": "Failed to read local file: {{error}}",
+ "importLocal": "Import Local",
+ "saveGlobalConfig": "Save Global Config",
+ "schemaUrl": "$schema",
+ "resetDefault": "Reset",
+ "sisyphusAgentConfig": "Sisyphus Agent Config",
+ "disabledAgents": "Agents",
+ "disabledAgentsPlaceholder": "Disabled Agents",
+ "disabledMcps": "MCPs",
+ "disabledMcpsPlaceholder": "Disabled MCPs",
+ "disabledHooks": "Hooks",
+ "disabledHooksPlaceholder": "Disabled Hooks",
+ "disabledSkills": "Skills",
+ "disabledSkillsPlaceholder": "Disabled Skills",
+ "advancedLsp": "LSP Config",
+ "advancedExperimental": "Experimental Features",
+ "advancedBackgroundTask": "Background Tasks",
+ "advancedBrowserAutomation": "Browser Automation",
+ "advancedClaudeCode": "Claude Code"
}
}
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index 5833a1f32..e6fedd4f8 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -32,6 +32,7 @@
"back": "戻る",
"refresh": "更新",
"refreshing": "更新中...",
+ "import": "インポート",
"all": "すべて",
"search": "検索",
"reset": "リセット",
@@ -114,6 +115,10 @@
"duplicate": "複製",
"sortUpdateFailed": "並び順の更新に失敗しました",
"configureUsage": "利用状況を設定",
+ "officialPartner": "公式パートナー",
+ "openTerminal": "ターミナルを開く",
+ "terminalOpened": "ターミナルを開きました",
+ "terminalOpenFailed": "ターミナルを開けませんでした",
"name": "プロバイダー名",
"namePlaceholder": "例: Claude Official",
"websiteUrl": "Web サイト URL",
@@ -164,7 +169,8 @@
"deleteFailed": "プロバイダーの削除に失敗しました: {{error}}",
"settingsSaved": "設定を保存しました",
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}",
- "openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です"
+ "openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です",
+ "openLinkFailed": "リンクを開けませんでした"
},
"confirm": {
"deleteProvider": "プロバイダーを削除",
@@ -486,6 +492,7 @@
"aggregatorApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
"thirdPartyApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
"customApiKeyHint": "💡 カスタム設定では必要な項目をすべて手動で入力してください",
+ "omoHint": "💡 OMO 設定は Agent のモデル割り当てを管理し、oh-my-opencode.jsonc に書き込みます",
"officialHint": "💡 公式プロバイダーはブラウザログインで、API Key は不要です",
"getApiKey": "API Key を取得",
"partnerPromotion": {
@@ -1177,6 +1184,13 @@
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode"
+ },
+ "installFromZip": {
+ "button": "ZIP からインストール",
+ "installing": "インストール中...",
+ "successSingle": "スキル {{name}} をインストールしました",
+ "successMultiple": "{{count}} 件のスキルをインストールしました",
+ "noSkillsFound": "ZIP ファイルにスキルが見つかりません(SKILL.md が必要です)"
}
},
"deeplink": {
@@ -1274,6 +1288,9 @@
"agents": {
"title": "エージェント"
},
+ "modelTest": {
+ "testProvider": "モデルテスト"
+ },
"health": {
"operational": "正常",
"degraded": "低下",
@@ -1495,6 +1512,7 @@
"deleted": "統合プロバイダーを削除しました",
"addSuccess": "統合プロバイダーを追加しました",
"addFailed": "統合プロバイダーの追加に失敗しました",
+ "hint": "クロスアプリ統合設定。Claude/Codex/Gemini に自動同期します",
"manage": "管理",
"loadError": "統合プロバイダーの読み込みに失敗しました",
"saveError": "統合プロバイダーの保存に失敗しました",
@@ -1509,5 +1527,94 @@
"saveAndSyncError": "保存と同期に失敗しました",
"configJsonPreview": "設定 JSON プレビュー",
"configJsonPreviewHint": "以下は各アプリに同期される設定内容です(表示されているフィールドのみ上書きされ、他のカスタム設定は保持されます)"
+ },
+ "omo": {
+ "editProfile": "OMO 設定を編集",
+ "newProfile": "新規 OMO 設定",
+ "profileName": "名前",
+ "mainAgents": "メインエージェント",
+ "subAgents": "サブエージェント",
+ "categories": "カテゴリ",
+ "customAgents": "カスタムエージェント",
+ "noCustomAgents": "カスタムエージェントなし",
+ "otherFields": "その他の設定",
+ "globalConfig": "OMO グローバル設定",
+ "globalConfigShort": "OMO 設定",
+ "globalConfigSaved": "グローバル設定を保存しました",
+ "addProfile": "OMO プロバイダーを追加",
+ "disabledItems": "無効項目設定",
+ "advanced": "詳細設定",
+ "profileCreated": "OMO 設定を作成しました",
+ "profileUpdated": "OMO 設定を更新しました",
+ "invalidJson": "その他のフィールドに無効なJSONが含まれています",
+ "confirmDelete": "設定を削除",
+ "confirmDeleteMsg": "「{{name}}」を削除しますか?",
+ "profileDeleted": "設定を削除しました",
+ "imported": "「{{name}}」としてインポートしました",
+ "import": "インポート",
+ "global": "グローバル",
+ "empty": "OMO 設定がありません。+ 追加またはローカルからインポートしてください。",
+ "applied": "適用済み",
+ "apply": "適用",
+ "enable": "有効化",
+ "enabled": "有効中",
+ "disabled": "OMO を無効化しました",
+ "disableFailed": "OMO の無効化に失敗しました: {{error}}",
+ "writeCommonConfig": "共通設定に書き込む",
+ "editCommonConfig": "共通設定を編集",
+ "editCommonConfigTitle": "共通設定",
+ "commonConfigHint": "OMO 共通設定は有効にしたすべての OMO 設定に統合されます",
+ "selectPlaceholder": "選択してください...",
+ "clear": "クリア",
+ "clearWrapped": "(クリア)",
+ "defaultWrapped": "(デフォルト)",
+ "variantPlaceholder": "variant",
+ "selectEnabledModel": "有効なモデルを選択",
+ "selectModelFirst": "先にモデルを選択",
+ "noEnabledModels": "有効なモデルがありません",
+ "noVariantsForModel": "このモデルには思考レベルがありません",
+ "currentValueNotEnabled": "{{value}} (現在値・未有効)",
+ "currentValueUnavailable": "{{value}} (現在値・利用不可)",
+ "advancedLabel": "詳細",
+ "advancedJsonInvalid": "詳細 JSON が不正です",
+ "advancedJsonHint": "temperature, top_p, budgetTokens, prompt_append, permission など。空欄でデフォルトを使用します",
+ "noEnabledModelsWarning": "利用可能な有効モデルがありません。先に OpenCode モデルを有効化してください。",
+ "importLocalReplaceSuccess": "ローカルファイルから読み込み、Agents/Categories/Other Fields を置き換えました",
+ "importLocalFailed": "ローカルファイルの読み込みに失敗しました: {{error}}",
+ "agentKeyPlaceholder": "agent キー",
+ "categoryKeyPlaceholder": "カテゴリキー",
+ "modelNamePlaceholder": "model-name",
+ "custom": "カスタム",
+ "customCategories": "カスタムカテゴリ",
+ "modelConfiguration": "モデル設定",
+ "fillRecommended": "推奨を入力",
+ "configSummary": "{{agents}} 個の Agent、{{categories}} 個の Category を設定済み · ⚙ で詳細を展開",
+ "enabledModelsCount": "有効モデル {{count}} 件",
+ "source": "出典:",
+ "otherFieldsJson": "その他のフィールド (JSON)",
+ "searchOrType": "検索またはカスタム値を入力...",
+ "noMatches": "一致する項目がありません",
+ "jsonMustBeObject": "{{field}} は JSON オブジェクトである必要があります",
+ "jsonInvalid": "{{field}} に無効な JSON が含まれています",
+ "importGlobalSuccess": "ローカルファイルからグローバル設定を読み込みました(未保存)",
+ "importGlobalFailed": "ローカルファイルの読み込みに失敗しました: {{error}}",
+ "importLocal": "ローカルからインポート",
+ "saveGlobalConfig": "グローバル設定を保存",
+ "schemaUrl": "$schema",
+ "resetDefault": "デフォルトに戻す",
+ "sisyphusAgentConfig": "Sisyphus Agent 設定",
+ "disabledAgents": "Agents",
+ "disabledAgentsPlaceholder": "無効化する Agents",
+ "disabledMcps": "MCPs",
+ "disabledMcpsPlaceholder": "無効化する MCPs",
+ "disabledHooks": "Hooks",
+ "disabledHooksPlaceholder": "無効化する Hooks",
+ "disabledSkills": "Skills",
+ "disabledSkillsPlaceholder": "無効化する Skills",
+ "advancedLsp": "LSP 設定",
+ "advancedExperimental": "実験的機能",
+ "advancedBackgroundTask": "バックグラウンドタスク",
+ "advancedBrowserAutomation": "ブラウザ自動化",
+ "advancedClaudeCode": "Claude Code"
}
}
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index 8128ea97b..130830865 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -32,6 +32,7 @@
"back": "返回",
"refresh": "刷新",
"refreshing": "刷新中...",
+ "import": "导入",
"all": "全部",
"search": "查询",
"reset": "重置",
@@ -114,6 +115,10 @@
"duplicate": "复制",
"sortUpdateFailed": "排序更新失败",
"configureUsage": "配置用量查询",
+ "officialPartner": "官方合作伙伴",
+ "openTerminal": "打开终端",
+ "terminalOpened": "终端已打开",
+ "terminalOpenFailed": "打开终端失败",
"name": "供应商名称",
"namePlaceholder": "例如:Claude 官方",
"websiteUrl": "官网链接",
@@ -164,7 +169,8 @@
"deleteFailed": "删除供应商失败:{{error}}",
"settingsSaved": "设置已保存",
"settingsSaveFailed": "保存设置失败:{{error}}",
- "openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用"
+ "openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用",
+ "openLinkFailed": "链接打开失败"
},
"confirm": {
"deleteProvider": "删除供应商",
@@ -486,6 +492,7 @@
"aggregatorApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
"thirdPartyApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
"customApiKeyHint": "💡 自定义配置需手动填写所有必要字段",
+ "omoHint": "💡 OMO 配置管理 Agent 模型分配,写入 oh-my-opencode.jsonc",
"officialHint": "💡 官方供应商使用浏览器登录,无需配置 API Key",
"getApiKey": "获取 API Key",
"partnerPromotion": {
@@ -1283,6 +1290,9 @@
"agents": {
"title": "智能体"
},
+ "modelTest": {
+ "testProvider": "测试模型"
+ },
"health": {
"operational": "正常",
"degraded": "降级",
@@ -1521,6 +1531,7 @@
"deleted": "统一供应商已删除",
"addSuccess": "统一供应商添加成功",
"addFailed": "统一供应商添加失败",
+ "hint": "跨应用统一配置,自动同步到 Claude/Codex/Gemini",
"manage": "管理",
"loadError": "加载统一供应商失败",
"saveError": "保存统一供应商失败",
@@ -1535,5 +1546,94 @@
"saveAndSyncError": "保存并同步失败",
"configJsonPreview": "配置 JSON 预览",
"configJsonPreviewHint": "以下是将要同步到各应用的配置内容(仅覆盖显示的字段,保留其他自定义配置)"
+ },
+ "omo": {
+ "editProfile": "编辑 OMO 配置",
+ "newProfile": "新建 OMO 配置",
+ "profileName": "名称",
+ "mainAgents": "主 Agent",
+ "subAgents": "子 Agent",
+ "categories": "分类",
+ "customAgents": "自定义 Agent",
+ "noCustomAgents": "暂无自定义 Agent",
+ "otherFields": "其他配置",
+ "globalConfig": "OMO 全局配置",
+ "globalConfigShort": "OMO 配置",
+ "globalConfigSaved": "全局配置已保存",
+ "addProfile": "添加 OMO 配置",
+ "disabledItems": "禁用项设置",
+ "advanced": "高级设置",
+ "profileCreated": "OMO 配置已创建",
+ "profileUpdated": "OMO 配置已更新",
+ "invalidJson": "其他字段包含无效 JSON",
+ "confirmDelete": "删除配置",
+ "confirmDeleteMsg": "确定删除 \"{{name}}\" 吗?",
+ "profileDeleted": "配置已删除",
+ "imported": "已导入为 \"{{name}}\"",
+ "import": "导入",
+ "global": "全局",
+ "empty": "暂无配置。点击 + 添加或从本地导入。",
+ "applied": "已应用",
+ "apply": "应用",
+ "enable": "启用",
+ "enabled": "启用中",
+ "disabled": "OMO 已停用",
+ "disableFailed": "停用 OMO 失败: {{error}}",
+ "writeCommonConfig": "写入通用配置",
+ "editCommonConfig": "编辑通用配置",
+ "editCommonConfigTitle": "通用配置",
+ "commonConfigHint": "OMO 通用配置将合并到所有启用它的 OMO 配置中",
+ "selectPlaceholder": "请选择...",
+ "clear": "清空",
+ "clearWrapped": "(清空)",
+ "defaultWrapped": "(默认)",
+ "variantPlaceholder": "思考等级",
+ "selectEnabledModel": "选择已启用模型",
+ "selectModelFirst": "先选择模型",
+ "noEnabledModels": "暂无已启用模型",
+ "noVariantsForModel": "该模型无思考等级",
+ "currentValueNotEnabled": "{{value}}(当前值,未启用)",
+ "currentValueUnavailable": "{{value}}(当前值,未启用)",
+ "advancedLabel": "高级参数",
+ "advancedJsonInvalid": "高级参数 JSON 无效",
+ "advancedJsonHint": "temperature, top_p, budgetTokens, prompt_append, permission 等,留空使用默认值",
+ "noEnabledModelsWarning": "当前没有可用的已启用模型,请先启用并配置 OpenCode 模型",
+ "importLocalReplaceSuccess": "已从本地文件导入并覆盖 Agent/Category/Other Fields",
+ "importLocalFailed": "读取本地文件失败: {{error}}",
+ "agentKeyPlaceholder": "agent 键名",
+ "categoryKeyPlaceholder": "分类键名",
+ "modelNamePlaceholder": "模型名",
+ "custom": "自定义",
+ "customCategories": "自定义分类",
+ "modelConfiguration": "模型配置",
+ "fillRecommended": "填充推荐",
+ "configSummary": "已配置 {{agents}} 个 Agent,{{categories}} 个 Category · 点击 ⚙ 展开高级参数",
+ "enabledModelsCount": "可选已启用模型 {{count}} 个",
+ "source": "来源:",
+ "otherFieldsJson": "其他字段 (JSON)",
+ "searchOrType": "搜索或输入自定义值...",
+ "noMatches": "无匹配项",
+ "jsonMustBeObject": "{{field}} 必须是 JSON 对象",
+ "jsonInvalid": "{{field}} 包含无效 JSON",
+ "importGlobalSuccess": "已从本地文件导入全局配置(未保存)",
+ "importGlobalFailed": "读取本地文件失败: {{error}}",
+ "importLocal": "从本地导入",
+ "saveGlobalConfig": "保存全局配置",
+ "schemaUrl": "$schema",
+ "resetDefault": "重置默认",
+ "sisyphusAgentConfig": "Sisyphus Agent 设置",
+ "disabledAgents": "Agents",
+ "disabledAgentsPlaceholder": "禁用的 Agents",
+ "disabledMcps": "MCPs",
+ "disabledMcpsPlaceholder": "禁用的 MCPs",
+ "disabledHooks": "Hooks",
+ "disabledHooksPlaceholder": "禁用的 Hooks",
+ "disabledSkills": "Skills",
+ "disabledSkillsPlaceholder": "禁用的 Skills",
+ "advancedLsp": "LSP 配置",
+ "advancedExperimental": "实验性功能",
+ "advancedBackgroundTask": "后台任务",
+ "advancedBrowserAutomation": "浏览器自动化",
+ "advancedClaudeCode": "Claude Code"
}
}
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",
diff --git a/src/index.css b/src/index.css
index a2a405d45..b5094c7f5 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,9 +1,7 @@
-/* Tailwind CSS v3 指令 */
@tailwind base;
@tailwind components;
@tailwind utilities;
-/* shadcn/ui 主题变量 - 蓝色主题 */
@layer base {
:root {
--background: 0 0% 100%;
@@ -13,25 +11,20 @@
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
- /* 主色调:macOS 风格系统蓝 */
--primary: 210 100% 56%;
--primary-foreground: 0 0% 100%;
- /* 次要色:淡蓝灰 */
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
- /* 强调色 */
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
- /* 危险色:红色 */
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
- /* 边框和输入框 */
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 210 100% 56%;
@@ -40,7 +33,6 @@
}
.dark {
- /* 背景与卡片:接近 macOS 深色 systemBackground / windowBackground */
--background: 240 5% 12%;
--foreground: 0 0% 98%;
--card: 240 5% 16%;
@@ -48,7 +40,6 @@
--popover: 240 5% 16%;
--popover-foreground: 0 0% 98%;
- /* 暗色模式主色调:macOS 风格系统蓝(略微降低亮度) */
--primary: 210 100% 54%;
--primary-foreground: 0 0% 100%;
@@ -60,7 +51,6 @@
--accent: 240 5% 18%;
--accent-foreground: 0 0% 98%;
- /* 暗色模式危险色 */
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
@@ -70,7 +60,6 @@
}
}
-/* Glassmorphism Utilities */
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
@@ -100,7 +89,6 @@
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
}
-/* 供应商卡片选中状态 */
.glass-card-active {
background: rgba(59, 130, 246, 0.08);
border: 1px solid rgba(59, 130, 246, 0.4);
@@ -125,7 +113,6 @@
border-top: 2px solid hsl(var(--border));
}
-/* Tauri 拖拽区域 */
[data-tauri-drag-region] {
-webkit-app-region: drag;
}
@@ -135,19 +122,16 @@
-webkit-app-region: no-drag;
}
-/* 全局基础样式 */
* {
box-sizing: border-box;
- scrollbar-width: none; /* Firefox */
- -ms-overflow-style: none; /* IE 10+ */
+ scrollbar-width: none;
+ -ms-overflow-style: none;
}
html {
@apply font-sans antialiased;
line-height: 1.5;
- /* 让原生控件与滚动条随主题切换配色 */
color-scheme: light;
- /* 禁用 overscroll 回弹效果,防止下拉时顶部边框被拉下来 */
overscroll-behavior: none;
}
@@ -157,24 +141,19 @@ body {
color: hsl(var(--foreground));
}
-/* 暗色模式下启用暗色原生控件/滚动条配色 */
html.dark {
color-scheme: dark;
}
-/* 滚动条样式 - 完全隐藏(支持所有浏览器) */
::-webkit-scrollbar {
display: none;
}
-/* 焦点样式 */
*:focus-visible {
@apply outline-2 outline-blue-500 outline-offset-2;
}
-/* 统一边框设计系统 - 使用工具类定义 */
@layer utilities {
- /* 让滚动条悬浮于内容之上,避免出现/消失时挤压布局 */
.scroll-overlay {
scrollbar-gutter: stable both-edges;
padding-right: 0.5rem;
@@ -182,13 +161,11 @@ html.dark {
overflow-x: hidden;
}
- /* 默认边框:1px,使用主题边框颜色 */
.border-default {
border-width: 1px;
border-color: hsl(var(--border));
}
- /* 激活边框:2px,使用主色 */
.border-active {
border-width: 2px;
}
@@ -210,8 +187,45 @@ html.dark {
}
}
-/* 禁用 Edge / IE 的密码显示按钮 */
input[type="password"]::-ms-reveal,
input[type="password"]::-ms-clear {
display: none;
}
+
+::view-transition-old(root),
+::view-transition-new(root) {
+ animation: none;
+ mix-blend-mode: normal;
+}
+
+::view-transition-old(root) {
+ z-index: 1;
+}
+
+::view-transition-new(root) {
+ z-index: 9999;
+}
+
+@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%)
+ );
+ }
+}
+
+::view-transition-new(root) {
+ animation: theme-circle-expand 0.4s ease-out;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ ::view-transition-new(root) {
+ animation: none;
+ }
+}
diff --git a/src/lib/api/config.ts b/src/lib/api/config.ts
index 27d138944..73d8a0a61 100644
--- a/src/lib/api/config.ts
+++ b/src/lib/api/config.ts
@@ -2,9 +2,13 @@
import { invoke } from "@tauri-apps/api/core";
import type { Provider } from "@/types";
import { providersApi } from "./providers";
-import { detectContent } from "@/utils/commonConfigDetection";
+import {
+ detectContent,
+ type CommonConfigAppType,
+} from "@/utils/commonConfigDetection";
-export type AppType = "claude" | "codex" | "gemini";
+export type AppType = CommonConfigAppType | "omo";
+type SyncAppType = CommonConfigAppType;
// ============================================================================
// 同步防抖管理
@@ -25,7 +29,7 @@ export type SyncResultCallback = (result: SyncResult) => void;
// 每个 appType 的 debounce 定时器
const syncDebounceTimers: Record<
- AppType,
+ SyncAppType,
ReturnType | null
> = {
claude: null,
@@ -34,7 +38,7 @@ const syncDebounceTimers: Record<
};
// 每个 appType 的同步锁(防止并发)
-const syncInFlight: Record = {
+const syncInFlight: Record = {
claude: false,
codex: false,
gemini: false,
@@ -42,7 +46,7 @@ const syncInFlight: Record = {
// 每个 appType 的最新同步参数(用于 single-flight)
const pendingSyncParams: Record<
- AppType,
+ SyncAppType,
{
oldSnippet: string;
newSnippet: string;
@@ -104,7 +108,7 @@ export type ExtractCommonConfigSnippetOptions = {
};
export async function extractCommonConfigSnippet(
- appType: AppType,
+ appType: CommonConfigAppType,
options?: ExtractCommonConfigSnippetOptions,
): Promise {
const args: Record = { appType };
@@ -135,7 +139,7 @@ export async function extractCommonConfigSnippet(
* @param onComplete - 同步完成后的回调,用于通知 UI 层
*/
export function syncCommonConfigToProviders(
- appType: AppType,
+ appType: SyncAppType,
oldSnippet: string,
newSnippet: string,
updateFn: (
@@ -169,7 +173,7 @@ export function syncCommonConfigToProviders(
/**
* 带锁执行同步(防止并发)
*/
-async function executeSyncWithLock(appType: AppType): Promise {
+async function executeSyncWithLock(appType: SyncAppType): Promise {
// 如果正在同步中,等待下一次调度
if (syncInFlight[appType]) {
// 已有同步在执行,参数已保存,等同步完成后会检查是否需要再次执行
@@ -223,7 +227,7 @@ async function executeSyncWithLock(appType: AppType): Promise {
* @returns 同步结果,包含更新数量和可能的错误信息
*/
async function doSyncCommonConfigToProviders(
- appType: AppType,
+ appType: SyncAppType,
oldSnippet: string,
newSnippet: string,
updateFn: (
@@ -344,7 +348,7 @@ async function doSyncCommonConfigToProviders(
}
function detectCommonConfigEnabledByContent(
- appType: AppType,
+ appType: SyncAppType,
settingsConfigStr: string,
oldSnippet: string,
newSnippet: string,
@@ -382,7 +386,7 @@ interface UpdateProviderResult {
*/
function getSettingsConfigString(
provider: Provider,
- appType: AppType,
+ appType: SyncAppType,
): string | null {
// Runtime type may be string (JSON-serialized) despite Record declaration
const config = provider.settingsConfig as Record | string;
@@ -441,7 +445,7 @@ function getSettingsConfigString(
function updateProviderSettingsConfig(
provider: Provider,
updatedConfig: string,
- appType: AppType,
+ appType: SyncAppType,
): UpdateProviderResult {
switch (appType) {
case "claude":
diff --git a/src/lib/api/omo.ts b/src/lib/api/omo.ts
new file mode 100644
index 000000000..bbd8782f1
--- /dev/null
+++ b/src/lib/api/omo.ts
@@ -0,0 +1,10 @@
+import { invoke } from "@tauri-apps/api/core";
+import type { OmoLocalFileData } from "@/types/omo";
+
+export const omoApi = {
+ readLocalFile: (): Promise => invoke("read_omo_local_file"),
+ getCurrentOmoProviderId: (): Promise =>
+ invoke("get_current_omo_provider_id"),
+ getOmoProviderCount: (): Promise => invoke("get_omo_provider_count"),
+ disableCurrentOmo: (): Promise => invoke("disable_current_omo"),
+};
diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts
index cc664a314..89e3e486b 100644
--- a/src/lib/query/mutations.ts
+++ b/src/lib/query/mutations.ts
@@ -17,13 +17,15 @@ export const useAddProviderMutation = (appId: AppId) => {
let id: string;
if (appId === "opencode") {
- // OpenCode: use user-provided providerKey as ID
- if (!providerInput.providerKey) {
- throw new Error("Provider key is required for OpenCode");
+ if (providerInput.category === "omo") {
+ id = `omo-${generateUUID()}`;
+ } else {
+ if (!providerInput.providerKey) {
+ throw new Error("Provider key is required for OpenCode");
+ }
+ id = providerInput.providerKey;
}
- id = providerInput.providerKey;
} else {
- // Other apps: use random UUID
id = generateUUID();
}
@@ -32,7 +34,6 @@ export const useAddProviderMutation = (appId: AppId) => {
id,
createdAt: Date.now(),
};
- // Remove providerKey from the provider object before saving
delete (newProvider as any).providerKey;
await providersApi.add(newProvider, appId);
@@ -41,7 +42,15 @@ export const useAddProviderMutation = (appId: AppId) => {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["providers", appId] });
- // 更新托盘菜单(失败不影响主操作)
+ if (appId === "opencode") {
+ await queryClient.invalidateQueries({
+ queryKey: ["omo", "current-provider-id"],
+ });
+ await queryClient.invalidateQueries({
+ queryKey: ["omo", "provider-count"],
+ });
+ }
+
try {
await providersApi.updateTrayMenu();
} catch (trayError) {
@@ -115,7 +124,15 @@ export const useDeleteProviderMutation = (appId: AppId) => {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["providers", appId] });
- // 更新托盘菜单(失败不影响主操作)
+ if (appId === "opencode") {
+ await queryClient.invalidateQueries({
+ queryKey: ["omo", "current-provider-id"],
+ });
+ await queryClient.invalidateQueries({
+ queryKey: ["omo", "provider-count"],
+ });
+ }
+
try {
await providersApi.updateTrayMenu();
} catch (trayError) {
@@ -157,14 +174,15 @@ export const useSwitchProviderMutation = (appId: AppId) => {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["providers", appId] });
- // OpenCode: also invalidate live provider IDs cache to update button state
if (appId === "opencode") {
await queryClient.invalidateQueries({
queryKey: ["opencodeLiveProviderIds"],
});
+ await queryClient.invalidateQueries({
+ queryKey: ["omo", "current-provider-id"],
+ });
}
- // 更新托盘菜单(失败不影响主操作)
try {
await providersApi.updateTrayMenu();
} catch (trayError) {
@@ -173,14 +191,10 @@ export const useSwitchProviderMutation = (appId: AppId) => {
trayError,
);
}
-
- // Note: Success toast is handled by useProviderActions.switchProvider
- // to allow customization based on provider properties (e.g., apiFormat)
},
onError: (error: Error) => {
const detail = extractErrorMessage(error) || t("common.unknown");
- // 标题与详情分离,便于扫描 + 一键复制
toast.error(
t("notifications.switchFailedTitle", { defaultValue: "切换失败" }),
{
diff --git a/src/lib/query/omo.ts b/src/lib/query/omo.ts
new file mode 100644
index 000000000..54d5e4790
--- /dev/null
+++ b/src/lib/query/omo.ts
@@ -0,0 +1,95 @@
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { omoApi } from "@/lib/api/omo";
+import * as configApi from "@/lib/api/config";
+import type { OmoGlobalConfig } from "@/types/omo";
+
+export const omoKeys = {
+ all: ["omo"] as const,
+ globalConfig: () => [...omoKeys.all, "global-config"] as const,
+ currentProviderId: () => [...omoKeys.all, "current-provider-id"] as const,
+ providerCount: () => [...omoKeys.all, "provider-count"] as const,
+};
+
+function invalidateOmoQueries(queryClient: ReturnType) {
+ queryClient.invalidateQueries({ queryKey: omoKeys.globalConfig() });
+ queryClient.invalidateQueries({ queryKey: ["providers"] });
+ queryClient.invalidateQueries({ queryKey: omoKeys.currentProviderId() });
+ queryClient.invalidateQueries({ queryKey: omoKeys.providerCount() });
+}
+
+export function useOmoGlobalConfig(enabled = true) {
+ return useQuery({
+ queryKey: omoKeys.globalConfig(),
+ enabled,
+ queryFn: async (): Promise => {
+ const raw = await configApi.getCommonConfigSnippet("omo");
+ if (!raw) {
+ return {
+ id: "global",
+ disabledAgents: [],
+ disabledMcps: [],
+ disabledHooks: [],
+ disabledSkills: [],
+ updatedAt: new Date().toISOString(),
+ };
+ }
+ try {
+ return JSON.parse(raw) as OmoGlobalConfig;
+ } catch (error) {
+ console.warn(
+ "[omo] invalid global config json, fallback to defaults",
+ error,
+ );
+ return {
+ id: "global",
+ disabledAgents: [],
+ disabledMcps: [],
+ disabledHooks: [],
+ disabledSkills: [],
+ updatedAt: new Date().toISOString(),
+ };
+ }
+ },
+ });
+}
+
+export function useCurrentOmoProviderId(enabled = true) {
+ return useQuery({
+ queryKey: omoKeys.currentProviderId(),
+ queryFn: omoApi.getCurrentOmoProviderId,
+ enabled,
+ });
+}
+
+export function useOmoProviderCount(enabled = true) {
+ return useQuery({
+ queryKey: omoKeys.providerCount(),
+ queryFn: omoApi.getOmoProviderCount,
+ enabled,
+ });
+}
+
+export function useSaveOmoGlobalConfig() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async (input: OmoGlobalConfig) => {
+ const jsonStr = JSON.stringify(input);
+ await configApi.setCommonConfigSnippet("omo", jsonStr);
+ },
+ onSuccess: () => invalidateOmoQueries(queryClient),
+ });
+}
+
+export function useReadOmoLocalFile() {
+ return useMutation({
+ mutationFn: () => omoApi.readLocalFile(),
+ });
+}
+
+export function useDisableCurrentOmo() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: () => omoApi.disableCurrentOmo(),
+ onSuccess: () => invalidateOmoQueries(queryClient),
+ });
+}
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,
});
}
diff --git a/src/types.ts b/src/types.ts
index 3372a333b..9da1e3a15 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -3,7 +3,8 @@ export type ProviderCategory =
| "cn_official" // 开源官方(原"国产官方")
| "aggregator" // 聚合网站
| "third_party" // 第三方供应商
- | "custom"; // 自定义
+ | "custom" // 自定义
+ | "omo"; // Oh My OpenCode
export interface Provider {
id: string;
diff --git a/src/types/omo.ts b/src/types/omo.ts
new file mode 100644
index 000000000..df9102d65
--- /dev/null
+++ b/src/types/omo.ts
@@ -0,0 +1,361 @@
+export interface OmoGlobalConfig {
+ id: string;
+ schemaUrl?: string;
+ sisyphusAgent?: Record;
+ disabledAgents: string[];
+ disabledMcps: string[];
+ disabledHooks: string[];
+ disabledSkills: string[];
+ lsp?: Record;
+ experimental?: Record;
+ backgroundTask?: Record;
+ browserAutomationEngine?: Record;
+ claudeCode?: Record;
+ otherFields?: Record;
+ updatedAt: string;
+}
+
+export interface OmoLocalFileData {
+ agents?: Record>;
+ categories?: Record>;
+ otherFields?: Record;
+ global: OmoGlobalConfig;
+ filePath: string;
+ lastModified?: string;
+}
+
+export interface OmoAgentDef {
+ key: string;
+ display: string;
+ descZh: string;
+ descEn: string;
+ recommended?: string;
+ group: "main" | "sub";
+}
+
+export interface OmoCategoryDef {
+ key: string;
+ display: string;
+ descZh: string;
+ descEn: string;
+ recommended?: string;
+}
+
+export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [
+ {
+ key: "Sisyphus",
+ display: "Sisyphus",
+ descZh: "主编排者",
+ descEn: "Main orchestrator",
+ recommended: "claude-opus-4-6",
+ group: "main",
+ },
+ {
+ key: "Hephaestus",
+ display: "Hephaestus",
+ descZh: "自主深度工作者",
+ descEn: "Autonomous deep worker",
+ recommended: "gpt-5.3-codex",
+ group: "main",
+ },
+ {
+ key: "Prometheus",
+ display: "Prometheus",
+ descZh: "战略规划者",
+ descEn: "Strategic planner",
+ recommended: "claude-opus-4-6",
+ group: "main",
+ },
+ {
+ key: "Atlas",
+ display: "Atlas",
+ descZh: "任务管理者",
+ descEn: "Task manager",
+ recommended: "kimi-k2.5",
+ group: "main",
+ },
+ {
+ key: "oracle",
+ display: "Oracle",
+ descZh: "战略顾问",
+ descEn: "Strategic advisor",
+ recommended: "gpt-5.3",
+ group: "sub",
+ },
+ {
+ key: "librarian",
+ display: "Librarian",
+ descZh: "多仓库研究员",
+ descEn: "Multi-repo researcher",
+ recommended: "glm-4.7",
+ group: "sub",
+ },
+ {
+ key: "explore",
+ display: "Explore",
+ descZh: "快速代码搜索",
+ descEn: "Fast code search",
+ recommended: "grok-code-fast-1",
+ group: "sub",
+ },
+ {
+ key: "multimodal-looker",
+ display: "Multimodal-Looker",
+ descZh: "媒体分析器",
+ descEn: "Media analyzer",
+ recommended: "gemini-3-flash",
+ group: "sub",
+ },
+ {
+ key: "Metis",
+ display: "Metis",
+ descZh: "规划前分析顾问",
+ descEn: "Pre-plan analysis advisor",
+ recommended: "claude-opus-4-6",
+ group: "sub",
+ },
+ {
+ key: "Momus",
+ display: "Momus",
+ descZh: "计划审查者",
+ descEn: "Plan reviewer",
+ recommended: "gpt-5.3",
+ group: "sub",
+ },
+ {
+ key: "Sisyphus-Junior",
+ display: "Sisyphus-Junior",
+ descZh: "委托任务执行器",
+ descEn: "Delegated task executor",
+ group: "sub",
+ },
+];
+
+export const OMO_BUILTIN_CATEGORIES: OmoCategoryDef[] = [
+ {
+ key: "visual-engineering",
+ display: "Visual Engineering",
+ descZh: "视觉/前端工程",
+ descEn: "Visual/frontend engineering",
+ recommended: "gemini-3-pro",
+ },
+ {
+ key: "ultrabrain",
+ display: "Ultrabrain",
+ descZh: "超级思考",
+ descEn: "Ultra thinking",
+ recommended: "claude-opus-4-6",
+ },
+ {
+ key: "deep",
+ display: "Deep",
+ descZh: "深度工作",
+ descEn: "Deep work",
+ recommended: "gpt-5.3-codex",
+ },
+ {
+ key: "artistry",
+ display: "Artistry",
+ descZh: "创意/文艺",
+ descEn: "Creative/artistic",
+ recommended: "claude-opus-4-6",
+ },
+ {
+ key: "quick",
+ display: "Quick",
+ descZh: "快速响应",
+ descEn: "Quick response",
+ recommended: "gemini-3-flash",
+ },
+ {
+ key: "unspecified-low",
+ display: "Unspecified Low",
+ descZh: "通用低配",
+ descEn: "General low tier",
+ recommended: "gemini-3-flash",
+ },
+ {
+ key: "unspecified-high",
+ display: "Unspecified High",
+ descZh: "通用高配",
+ descEn: "General high tier",
+ recommended: "gpt-5.3-codex",
+ },
+ {
+ key: "writing",
+ display: "Writing",
+ descZh: "写作",
+ descEn: "Writing",
+ recommended: "claude-opus-4-6",
+ },
+];
+
+export const OMO_DISABLEABLE_AGENTS = [
+ { value: "Prometheus (Planner)", label: "Prometheus (Planner)" },
+ { value: "Atlas", label: "Atlas" },
+ { value: "oracle", label: "Oracle" },
+ { value: "librarian", label: "Librarian" },
+ { value: "explore", label: "Explore" },
+ { value: "multimodal-looker", label: "Multimodal Looker" },
+ { value: "frontend-ui-ux-engineer", label: "Frontend UI/UX Engineer" },
+ { value: "document-writer", label: "Document Writer" },
+ { value: "Sisyphus-Junior", label: "Sisyphus-Junior" },
+ { value: "Metis (Plan Consultant)", label: "Metis (Plan Consultant)" },
+ { value: "Momus (Plan Reviewer)", label: "Momus (Plan Reviewer)" },
+ { value: "OpenCode-Builder", label: "OpenCode-Builder" },
+] as const;
+
+export const OMO_DISABLEABLE_MCPS = [
+ { value: "context7", label: "context7" },
+ { value: "grep_app", label: "grep_app" },
+ { value: "websearch", label: "websearch" },
+] as const;
+
+export const OMO_DISABLEABLE_HOOKS = [
+ { value: "todo-continuation-enforcer", label: "todo-continuation-enforcer" },
+ { value: "context-window-monitor", label: "context-window-monitor" },
+ { value: "session-recovery", label: "session-recovery" },
+ { value: "session-notification", label: "session-notification" },
+ { value: "comment-checker", label: "comment-checker" },
+ { value: "grep-output-truncator", label: "grep-output-truncator" },
+ { value: "tool-output-truncator", label: "tool-output-truncator" },
+ {
+ value: "directory-agents-injector",
+ label: "directory-agents-injector",
+ },
+ {
+ value: "directory-readme-injector",
+ label: "directory-readme-injector",
+ },
+ {
+ value: "empty-task-response-detector",
+ label: "empty-task-response-detector",
+ },
+ { value: "think-mode", label: "think-mode" },
+ {
+ value: "anthropic-context-window-limit-recovery",
+ label: "anthropic-context-window-limit-recovery",
+ },
+ { value: "rules-injector", label: "rules-injector" },
+ { value: "background-notification", label: "background-notification" },
+ { value: "auto-update-checker", label: "auto-update-checker" },
+ { value: "startup-toast", label: "startup-toast" },
+ { value: "keyword-detector", label: "keyword-detector" },
+ { value: "agent-usage-reminder", label: "agent-usage-reminder" },
+ { value: "non-interactive-env", label: "non-interactive-env" },
+ { value: "interactive-bash-session", label: "interactive-bash-session" },
+ {
+ value: "compaction-context-injector",
+ label: "compaction-context-injector",
+ },
+ {
+ value: "thinking-block-validator",
+ label: "thinking-block-validator",
+ },
+ { value: "claude-code-hooks", label: "claude-code-hooks" },
+ { value: "ralph-loop", label: "ralph-loop" },
+ { value: "preemptive-compaction", label: "preemptive-compaction" },
+] as const;
+
+export const OMO_DISABLEABLE_SKILLS = [
+ { value: "playwright", label: "playwright" },
+ { value: "agent-browser", label: "agent-browser" },
+ { value: "git-master", label: "git-master" },
+] as const;
+
+export const OMO_DEFAULT_SCHEMA_URL =
+ "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json";
+
+export const OMO_SISYPHUS_AGENT_PLACEHOLDER = `{
+ "disabled": false,
+ "default_builder_enabled": false,
+ "planner_enabled": true,
+ "replace_plan": true
+}`;
+
+export const OMO_LSP_PLACEHOLDER = `{
+ "typescript-language-server": {
+ "command": ["typescript-language-server", "--stdio"],
+ "extensions": [".ts", ".tsx"],
+ "priority": 10
+ },
+ "pylsp": {
+ "disabled": true
+ }
+}`;
+
+export const OMO_EXPERIMENTAL_PLACEHOLDER = `{
+ "truncate_all_tool_outputs": true,
+ "aggressive_truncation": true,
+ "auto_resume": true
+}`;
+
+export const OMO_BACKGROUND_TASK_PLACEHOLDER = `{
+ "defaultConcurrency": 5,
+ "providerConcurrency": {
+ "anthropic": 3,
+ "openai": 5,
+ "google": 10
+ },
+ "modelConcurrency": {
+ "anthropic/claude-opus-4-6": 2,
+ "google/gemini-3-flash": 10
+ }
+}`;
+
+export const OMO_BROWSER_AUTOMATION_PLACEHOLDER = `{
+ "provider": "playwright"
+}`;
+
+export const OMO_CLAUDE_CODE_PLACEHOLDER = `{
+ "mcp": true,
+ "commands": true,
+ "skills": true,
+ "agents": true,
+ "hooks": true,
+ "plugins": true
+}`;
+
+export function mergeOmoConfigPreview(
+ global: OmoGlobalConfig,
+ agents: Record>,
+ categories: Record>,
+ otherFieldsStr: string,
+): Record {
+ const result: Record = {};
+
+ if (global.schemaUrl) result["$schema"] = global.schemaUrl;
+
+ if (global.sisyphusAgent) result["sisyphus_agent"] = global.sisyphusAgent;
+ if (global.disabledAgents?.length)
+ result["disabled_agents"] = global.disabledAgents;
+ if (global.disabledMcps?.length)
+ result["disabled_mcps"] = global.disabledMcps;
+ if (global.disabledHooks?.length)
+ result["disabled_hooks"] = global.disabledHooks;
+ if (global.disabledSkills?.length)
+ result["disabled_skills"] = global.disabledSkills;
+ if (global.lsp) result["lsp"] = global.lsp;
+ if (global.experimental) result["experimental"] = global.experimental;
+ if (global.backgroundTask) result["background_task"] = global.backgroundTask;
+ if (global.browserAutomationEngine)
+ result["browser_automation_engine"] = global.browserAutomationEngine;
+ if (global.claudeCode) result["claude_code"] = global.claudeCode;
+
+ if (global.otherFields) {
+ for (const [k, v] of Object.entries(global.otherFields)) {
+ result[k] = v;
+ }
+ }
+
+ if (Object.keys(agents).length > 0) result["agents"] = agents;
+ if (Object.keys(categories).length > 0) result["categories"] = categories;
+ try {
+ const other = JSON.parse(otherFieldsStr || "{}");
+ for (const [k, v] of Object.entries(other)) {
+ result[k] = v;
+ }
+ } catch {}
+
+ return result;
+}