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
This commit is contained in:
Dex Miller
2026-02-09 12:18:20 +08:00
committed by GitHub
parent 5502c74a79
commit ed47480fc7
35 changed files with 4304 additions and 737 deletions
+25 -62
View File
@@ -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<Provider | null>(null);
const [usageProvider, setUsageProvider] = useState<Provider | null>(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<EnvConflict[]>([]);
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 (
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
<AnimatePresence mode="wait">
<motion.div
@@ -648,7 +621,9 @@ function App() {
}
activeProviderId={activeProviderId}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onEdit={(provider) => {
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),避免上边框无法拖动 */}
<div
className="fixed top-0 left-0 right-0 z-[60]"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag", height: DRAG_BAR_HEIGHT } as any}
/>
{/* 环境变量警告横幅 */}
{showEnvBanner && envConflicts.length > 0 && (
<EnvWarningBanner
conflicts={envConflicts}
@@ -710,7 +686,6 @@ function App() {
sessionStorage.setItem("env_banner_dismissed", "true");
}}
onDeleted={async () => {
// 删除后重新检测
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() {
>
<Wrench className="flex-shrink-0 w-4 h-4" />
</Button>
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
{/* {isClaudeApp && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("agents")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="Agents"
>
<Bot className="w-4 h-4" />
</Button>
)} */}
<Button
variant="ghost"
size="sm"
+1 -13
View File
@@ -17,7 +17,6 @@ import { UniversalProviderPanel } from "@/components/universal";
import { providerPresets } from "@/config/claudeProviderPresets";
import { codexProviderPresets } from "@/config/codexProviderPresets";
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
// Note: opencodeProviderPresets is loaded via ProviderForm, not needed here
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
interface AddProviderDialogProps {
@@ -36,7 +35,6 @@ export function AddProviderDialog({
onSubmit,
}: AddProviderDialogProps) {
const { t } = useTranslation();
// OpenCode doesn't support universal providers
const showUniversalTab = appId !== "opencode";
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
"app-specific",
@@ -45,7 +43,6 @@ export function AddProviderDialog({
const [selectedUniversalPreset, setSelectedUniversalPreset] =
useState<UniversalProviderPreset | null>(null);
// Handle universal provider save
const handleUniversalProviderSave = useCallback(
async (provider: UniversalProvider) => {
try {
@@ -73,7 +70,6 @@ export function AddProviderDialog({
[t, onOpenChange],
);
// Close universal form and return to main dialog
const handleUniversalFormClose = useCallback(() => {
setUniversalFormOpen(false);
setSelectedUniversalPreset(null);
@@ -86,7 +82,6 @@ export function AddProviderDialog({
unknown
>;
// 构造基础提交数据
const providerData: Omit<Provider, "id"> & { providerKey?: string } = {
name: values.name.trim(),
notes: values.notes?.trim() || undefined,
@@ -98,7 +93,6 @@ export function AddProviderDialog({
...(values.meta ? { meta: values.meta } : {}),
};
// OpenCode: pass providerKey for ID generation
if (appId === "opencode" && values.providerKey) {
providerData.providerKey = values.providerKey;
}
@@ -107,8 +101,7 @@ export function AddProviderDialog({
providerData.meta?.custom_endpoints &&
Object.keys(providerData.meta.custom_endpoints).length > 0;
if (!hasCustomEndpoints) {
// 收集端点候选(仅在缺少自定义端点时兜底)
if (!hasCustomEndpoints && values.presetCategory !== "omo") {
const urlSet = new Set<string>();
const addUrl = (rawUrl?: string) => {
@@ -163,7 +156,6 @@ export function AddProviderDialog({
}
}
}
// Note: OpenCode doesn't use endpointCandidates - it handles endpoints internally
}
if (appId === "claude") {
@@ -187,7 +179,6 @@ export function AddProviderDialog({
addUrl(env.GOOGLE_GEMINI_BASE_URL);
}
} else if (appId === "opencode") {
// OpenCode uses options.baseURL
const options = parsedConfig.options as
| Record<string, any>
| undefined;
@@ -221,7 +212,6 @@ export function AddProviderDialog({
[appId, onSubmit, onOpenChange],
);
// 动态 footer:根据当前 Tab 显示不同按钮
const footer =
!showUniversalTab || activeTab === "app-specific" ? (
<>
@@ -296,7 +286,6 @@ export function AddProviderDialog({
</TabsContent>
</Tabs>
) : (
// OpenCode: directly show form without tabs
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
@@ -306,7 +295,6 @@ export function AddProviderDialog({
/>
)}
{/* Universal Provider Form Modal */}
{showUniversalTab && (
<UniversalProviderFormModal
isOpen={universalFormOpen}
+40 -20
View File
@@ -19,20 +19,20 @@ import type { AppId } from "@/lib/api";
interface ProviderActionsProps {
appId?: AppId;
isCurrent: boolean;
/** OpenCode: 是否已添加到配置 */
isInConfig?: boolean;
isTesting?: boolean;
isProxyTakeover?: boolean;
isOmo?: boolean;
isLastOmo?: boolean;
onSwitch: () => void;
onEdit: () => void;
onDuplicate: () => void;
onTest?: () => void;
onConfigureUsage: () => void;
onDelete: () => void;
/** OpenCode: remove from live config (not delete from database) */
onRemoveFromConfig?: () => void;
onDisableOmo?: () => void;
onOpenTerminal?: () => void;
// 故障转移相关
isAutoFailoverEnabled?: boolean;
isInFailoverQueue?: boolean;
onToggleFailover?: (enabled: boolean) => void;
@@ -44,6 +44,8 @@ export function ProviderActions({
isInConfig = false,
isTesting,
isProxyTakeover = false,
isOmo = false,
isLastOmo = false,
onSwitch,
onEdit,
onDuplicate,
@@ -51,8 +53,8 @@ export function ProviderActions({
onConfigureUsage,
onDelete,
onRemoveFromConfig,
onDisableOmo,
onOpenTerminal,
// 故障转移相关
isAutoFailoverEnabled = false,
isInFailoverQueue = false,
onToggleFailover,
@@ -60,19 +62,20 @@ export function ProviderActions({
const { t } = useTranslation();
const iconButtonClass = "h-8 w-8 p-1";
// OpenCode 使用累加模式
const isOpenCodeMode = appId === "opencode";
const isOpenCodeMode = appId === "opencode" && !isOmo;
// 故障转移模式下的按钮逻辑(OpenCode 不支持故障转移)
const isFailoverMode =
!isOpenCodeMode && isAutoFailoverEnabled && onToggleFailover;
!isOpenCodeMode && !isOmo && isAutoFailoverEnabled && onToggleFailover;
// 处理主按钮点击
const handleMainButtonClick = () => {
if (isOpenCodeMode) {
// OpenCode 模式:切换配置状态(添加/移除)
if (isOmo) {
if (isCurrent) {
onDisableOmo?.();
} else {
onSwitch();
}
} else if (isOpenCodeMode) {
if (isInConfig) {
// Use onRemoveFromConfig if available, otherwise fall back to onDelete
if (onRemoveFromConfig) {
onRemoveFromConfig();
} else {
@@ -82,17 +85,33 @@ export function ProviderActions({
onSwitch(); // 添加到配置
}
} else if (isFailoverMode) {
// 故障转移模式:切换队列状态
onToggleFailover(!isInFailoverQueue);
} else {
// 普通模式:切换供应商
onSwitch();
}
};
// 主按钮的状态和样式
const getMainButtonState = () => {
// OpenCode 累加模式
if (isOmo) {
if (isCurrent) {
return {
disabled: false,
variant: "secondary" as const,
className:
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
icon: <Check className="h-4 w-4" />,
text: t("provider.inUse"),
};
}
return {
disabled: false,
variant: "default" as const,
className: "",
icon: <Play className="h-4 w-4" />,
text: t("provider.enable"),
};
}
if (isOpenCodeMode) {
if (isInConfig) {
return {
@@ -114,7 +133,6 @@ export function ProviderActions({
};
}
// 故障转移模式
if (isFailoverMode) {
if (isInFailoverQueue) {
return {
@@ -136,7 +154,6 @@ export function ProviderActions({
};
}
// 普通模式
if (isCurrent) {
return {
disabled: true,
@@ -161,8 +178,11 @@ export function ProviderActions({
const buttonState = getMainButtonState();
// OpenCode 模式下删除按钮始终可用(主按钮"移除"是从 live 配置移除,删除是从数据库删除)
const canDelete = isOpenCodeMode ? true : !isCurrent;
const canDelete = isOmo
? !(isLastOmo && isCurrent)
: isOpenCodeMode
? true
: !isCurrent;
return (
<div className="flex items-center gap-1.5">
+22 -40
View File
@@ -27,11 +27,13 @@ interface ProviderCardProps {
isCurrent: boolean;
appId: AppId;
isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json
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;
onConfigureUsage: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onDuplicate: (provider: Provider) => void;
@@ -41,7 +43,6 @@ interface ProviderCardProps {
isProxyRunning: boolean;
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
dragHandleProps?: DragHandleProps;
// 故障转移相关
isAutoFailoverEnabled?: boolean; // 是否开启自动故障转移
failoverPriority?: number; // 故障转移优先级(1 = P1, 2 = P2, ...
isInFailoverQueue?: boolean; // 是否在故障转移队列中
@@ -50,17 +51,14 @@ interface ProviderCardProps {
}
const extractApiUrl = (provider: Provider, fallbackText: string) => {
// 优先级 1: 备注
if (provider.notes?.trim()) {
return provider.notes.trim();
}
// 优先级 2: 官网地址
if (provider.websiteUrl) {
return provider.websiteUrl;
}
// 优先级 3: 从配置中提取请求地址
const config = provider.settingsConfig;
if (config && typeof config === "object") {
@@ -89,10 +87,13 @@ export function ProviderCard({
isCurrent,
appId,
isInConfig = true,
isOmo = false,
isLastOmo = false,
onSwitch,
onEdit,
onDelete,
onRemoveFromConfig,
onDisableOmo,
onConfigureUsage,
onOpenWebsite,
onDuplicate,
@@ -102,7 +103,6 @@ export function ProviderCard({
isProxyRunning,
isProxyTakeover = false,
dragHandleProps,
// 故障转移相关
isAutoFailoverEnabled = false,
failoverPriority,
isInFailoverQueue = false,
@@ -111,7 +111,6 @@ export function ProviderCard({
}: ProviderCardProps) {
const { t } = useTranslation();
// 获取供应商健康状态
const { data: health } = useProviderHealth(provider.id, appId);
const fallbackUrlText = t("provider.notConfigured", {
@@ -122,24 +121,18 @@ export function ProviderCard({
return extractApiUrl(provider, fallbackUrlText);
}, [provider, fallbackUrlText]);
// 判断是否为可点击的 URL(备注不可点击)
const isClickableUrl = useMemo(() => {
// 如果有备注,则不可点击
if (provider.notes?.trim()) {
return false;
}
// 如果显示的是回退文本,也不可点击
if (displayUrl === fallbackUrlText) {
return false;
}
// 其他情况(官网地址或请求地址)可点击
return true;
}, [provider.notes, displayUrl, fallbackUrlText]);
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
// 获取用量数据以判断是否有多套餐
// OpenCode(累加模式):使用 isInConfig 代替 isCurrent
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
const autoQueryInterval = shouldAutoQuery
? provider.meta?.usage_script?.autoQueryInterval || 0
@@ -153,21 +146,17 @@ export function ProviderCard({
const hasMultiplePlans =
usage?.success && usage.data && usage.data.length > 1;
// 多套餐默认展开
const [isExpanded, setIsExpanded] = useState(false);
// 操作按钮容器 ref,用于动态计算宽度
const actionsRef = useRef<HTMLDivElement>(null);
const [actionsWidth, setActionsWidth] = useState(0);
// 当检测到多套餐时自动展开
useEffect(() => {
if (hasMultiplePlans) {
setIsExpanded(true);
}
}, [hasMultiplePlans]);
// 动态获取操作按钮宽度
useEffect(() => {
if (actionsRef.current) {
const updateWidth = () => {
@@ -175,7 +164,6 @@ export function ProviderCard({
setActionsWidth(width);
};
updateWidth();
// 监听窗口大小变化
window.addEventListener("resize", updateWidth);
return () => window.removeEventListener("resize", updateWidth);
}
@@ -188,32 +176,27 @@ export function ProviderCard({
onOpenWebsite(displayUrl);
};
// 判断是否是"当前使用中"的供应商
// - OpenCode(累加模式):不存在"当前"概念,始终返回 false
// - 故障转移模式:代理实际使用的供应商(activeProviderId
// - 代理接管模式(非故障转移):isCurrent
// - 普通模式:isCurrent
const isActiveProvider =
appId === "opencode"
const isActiveProvider = isOmo
? isCurrent
: appId === "opencode"
? false
: isAutoFailoverEnabled
? activeProviderId === provider.id
: isCurrent;
// 判断是否使用绿色(代理接管模式)还是蓝色(普通模式)
const shouldUseGreen = isProxyTakeover && isActiveProvider;
const shouldUseBlue = !isProxyTakeover && isActiveProvider;
const shouldUseGreen = !isOmo && isProxyTakeover && isActiveProvider;
const shouldUseBlue =
(isOmo && isActiveProvider) ||
(!isOmo && !isProxyTakeover && isActiveProvider);
return (
<div
className={cn(
"relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300",
"bg-card text-card-foreground group",
// hover 时的边框效果
isAutoFailoverEnabled || isProxyTakeover
? "hover:border-emerald-500/50"
: "hover:border-border-active",
// 当前激活的供应商边框样式
shouldUseGreen &&
"border-emerald-500/60 shadow-sm shadow-emerald-500/10",
shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10",
@@ -225,7 +208,6 @@ export function ProviderCard({
<div
className={cn(
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
// 代理接管模式使用绿色渐变,普通模式使用蓝色渐变
shouldUseGreen && "from-emerald-500/10",
shouldUseBlue && "from-blue-500/10",
!isActiveProvider && "from-primary/10",
@@ -248,7 +230,6 @@ export function ProviderCard({
<GripVertical className="h-4 w-4" />
</button>
{/* 供应商图标 */}
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
<ProviderIcon
icon={provider.icon}
@@ -264,14 +245,18 @@ export function ProviderCard({
{provider.name}
</h3>
{/* 健康状态徽章 */}
{isOmo && (
<span className="inline-flex items-center rounded-md bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-700 dark:bg-violet-900/40 dark:text-violet-300">
OMO
</span>
)}
{isProxyRunning && isInFailoverQueue && health && (
<ProviderHealthBadge
consecutiveFailures={health.consecutive_failures}
/>
)}
{/* 故障转移优先级徽章 */}
{isAutoFailoverEnabled &&
isInFailoverQueue &&
failoverPriority && (
@@ -318,10 +303,8 @@ export function ProviderCard({
} as React.CSSProperties
}
>
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
<div className="ml-auto">
<div className="flex items-center gap-1 transition-transform duration-200 group-hover:-translate-x-[var(--actions-width)] group-focus-within:-translate-x-[var(--actions-width)]">
{/* 多套餐时显示套餐数量,单套餐时显示详细信息 */}
{hasMultiplePlans ? (
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<span className="font-medium">
@@ -342,7 +325,6 @@ export function ProviderCard({
inline={true}
/>
)}
{/* 展开/折叠按钮 - 仅在有多套餐时显示 */}
{hasMultiplePlans && (
<button
onClick={(e) => {
@@ -366,7 +348,6 @@ export function ProviderCard({
</div>
</div>
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入,与用量信息保持间距 */}
<div
ref={actionsRef}
className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pl-3 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0"
@@ -377,6 +358,8 @@ export function ProviderCard({
isInConfig={isInConfig}
isTesting={isTesting}
isProxyTakeover={isProxyTakeover}
isOmo={isOmo}
isLastOmo={isLastOmo}
onSwitch={() => 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({
</div>
</div>
{/* 展开的完整套餐列表 */}
{isExpanded && hasMultiplePlans && (
<div className="mt-4 pt-4 border-t border-border-default">
<UsageFooter
+54 -50
View File
@@ -20,7 +20,6 @@ import type { Provider } from "@/types";
import type { AppId } from "@/lib/api";
import { providersApi } from "@/lib/api/providers";
import { useDragSort } from "@/hooks/useDragSort";
// import { useStreamCheck } from "@/hooks/useStreamCheck"; // 测试功能已隐藏
import { ProviderCard } from "@/components/providers/ProviderCard";
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
import {
@@ -29,6 +28,7 @@ import {
useAddToFailoverQueue,
useRemoveFromFailoverQueue,
} from "@/lib/query/failover";
import { useCurrentOmoProviderId, useOmoProviderCount } from "@/lib/query/omo";
import { useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -40,8 +40,8 @@ interface ProviderListProps {
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;
@@ -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<HTMLInputElement>(null);
@@ -215,36 +205,44 @@ export function ProviderList({
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{filteredProviders.map((provider) => (
<SortableProviderCard
key={provider.id}
provider={provider}
isCurrent={provider.id === currentProviderId}
appId={appId}
isInConfig={isProviderInConfig(provider.id)}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
onRemoveFromConfig={onRemoveFromConfig}
onDuplicate={onDuplicate}
onConfigureUsage={onConfigureUsage}
onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal}
// onTest 功能已隐藏 - 供应商请求格式复杂难以统一测试
// onTest={appId !== "opencode" ? handleTest : undefined}
isTesting={false} // isChecking(provider.id) - 测试功能已隐藏
isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyTakeover}
// 故障转移相关:联动状态
isAutoFailoverEnabled={isFailoverModeActive}
failoverPriority={getFailoverPriority(provider.id)}
isInFailoverQueue={isInFailoverQueue(provider.id)}
onToggleFailover={(enabled) =>
handleToggleFailover(provider.id, enabled)
}
activeProviderId={activeProviderId}
/>
))}
{filteredProviders.map((provider) => {
const isOmo = provider.category === "omo";
const isOmoCurrent = isOmo && provider.id === (currentOmoId || "");
return (
<SortableProviderCard
key={provider.id}
provider={provider}
isCurrent={
isOmo ? isOmoCurrent : provider.id === currentProviderId
}
appId={appId}
isInConfig={isProviderInConfig(provider.id)}
isOmo={isOmo}
isLastOmo={
isOmo && (omoProviderCount ?? 0) <= 1 && isOmoCurrent
}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
onRemoveFromConfig={onRemoveFromConfig}
onDisableOmo={onDisableOmo}
onDuplicate={onDuplicate}
onConfigureUsage={onConfigureUsage}
onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal}
isTesting={false} // isChecking(provider.id) - 测试功能已隐藏
isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyTakeover}
isAutoFailoverEnabled={isFailoverModeActive}
failoverPriority={getFailoverPriority(provider.id)}
isInFailoverQueue={isInFailoverQueue(provider.id)}
onToggleFailover={(enabled) =>
handleToggleFailover(provider.id, enabled)
}
activeProviderId={activeProviderId}
/>
);
})}
</div>
</SortableContext>
</DndContext>
@@ -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}
@@ -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<void>;
isSaving: boolean;
onGlobalConfigStateChange: (config: OmoGlobalConfig) => void;
globalConfigRef: React.RefObject<OmoGlobalConfigFieldsRef | null>;
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 (
<>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>{t("provider.configJson")}</Label>
<div className="flex items-center gap-2">
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={useCommonConfig}
onChange={(e) => onCommonConfigToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
<span>
{t("omo.writeCommonConfig", {
defaultValue: "Write to common config",
})}
</span>
</label>
</div>
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={onEditClick}
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
>
{t("omo.editCommonConfig", { defaultValue: "Edit common config" })}
</button>
</div>
<JsonEditor
value={previewValue}
onChange={() => {}}
darkMode={isDarkMode}
rows={14}
showValidation={false}
language="json"
/>
</div>
<FullScreenPanel
isOpen={isModalOpen}
title={t("omo.editCommonConfigTitle", {
defaultValue: "Edit OMO Common Config",
})}
onClose={onModalClose}
footer={
<>
<Button
type="button"
variant="outline"
onClick={handleImportLocal}
disabled={isImporting}
className="gap-2"
>
{isImporting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<FolderInput className="w-4 h-4" />
)}
{t("common.import", { defaultValue: "Import" })}
</Button>
<Button type="button" variant="outline" onClick={onModalClose}>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={onSave}
disabled={isSaving}
className="gap-2"
>
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("omo.commonConfigHint", {
defaultValue:
"OMO common config will be merged into all OMO configs that enable it",
})}
</p>
<OmoGlobalConfigFields
key={fieldsKey}
ref={globalConfigRef as React.Ref<OmoGlobalConfigFieldsRef>}
onStateChange={onGlobalConfigStateChange}
hideSaveButtons
/>
</div>
</FullScreenPanel>
</>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,739 @@
import {
useState,
useEffect,
useCallback,
forwardRef,
useImperativeHandle,
} 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 {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Save,
Loader2,
X,
FolderInput,
RotateCcw,
ChevronsUpDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import type { OmoGlobalConfig } from "@/types/omo";
import {
OMO_DISABLEABLE_AGENTS,
OMO_DISABLEABLE_MCPS,
OMO_DISABLEABLE_HOOKS,
OMO_DISABLEABLE_SKILLS,
OMO_DEFAULT_SCHEMA_URL,
OMO_SISYPHUS_AGENT_PLACEHOLDER,
OMO_LSP_PLACEHOLDER,
OMO_EXPERIMENTAL_PLACEHOLDER,
OMO_BACKGROUND_TASK_PLACEHOLDER,
OMO_BROWSER_AUTOMATION_PLACEHOLDER,
OMO_CLAUDE_CODE_PLACEHOLDER,
} from "@/types/omo";
import {
useOmoGlobalConfig,
useSaveOmoGlobalConfig,
useReadOmoLocalFile,
} from "@/lib/query/omo";
interface PresetOption {
readonly value: string;
readonly label: string;
}
export interface OmoGlobalConfigFieldsRef {
buildCurrentConfig: () => OmoGlobalConfig;
buildCurrentConfigStrict: () => OmoGlobalConfig;
importFromLocal: () => Promise<void>;
}
interface OmoGlobalConfigFieldsProps {
onStateChange?: (config: OmoGlobalConfig) => void;
hideSaveButtons?: boolean;
}
type OmoAdvancedFieldKey =
| "lspStr"
| "experimentalStr"
| "backgroundTaskStr"
| "browserStr"
| "claudeCodeStr";
const OMO_ADVANCED_JSON_FIELDS: ReadonlyArray<{
key: OmoAdvancedFieldKey;
labelKey: string;
defaultLabel: string;
placeholder: string;
minHeight: string;
}> = [
{
key: "lspStr",
labelKey: "omo.advancedLsp",
defaultLabel: "LSP Config",
placeholder: OMO_LSP_PLACEHOLDER,
minHeight: "200px",
},
{
key: "experimentalStr",
labelKey: "omo.advancedExperimental",
defaultLabel: "Experimental Features",
placeholder: OMO_EXPERIMENTAL_PLACEHOLDER,
minHeight: "120px",
},
{
key: "backgroundTaskStr",
labelKey: "omo.advancedBackgroundTask",
defaultLabel: "Background Tasks",
placeholder: OMO_BACKGROUND_TASK_PLACEHOLDER,
minHeight: "250px",
},
{
key: "browserStr",
labelKey: "omo.advancedBrowserAutomation",
defaultLabel: "Browser Automation",
placeholder: OMO_BROWSER_AUTOMATION_PLACEHOLDER,
minHeight: "80px",
},
{
key: "claudeCodeStr",
labelKey: "omo.advancedClaudeCode",
defaultLabel: "Claude Code",
placeholder: OMO_CLAUDE_CODE_PLACEHOLDER,
minHeight: "180px",
},
];
function TagListEditor({
label,
values,
onChange,
placeholder,
presets,
}: {
label: string;
values: string[];
onChange: (values: string[]) => void;
placeholder?: string;
presets?: readonly PresetOption[];
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const toggleValue = (v: string) => {
if (values.includes(v)) {
onChange(values.filter((x) => x !== v));
} else {
onChange([...values, v]);
}
};
const customValue = search.trim();
const canAddCustom = customValue.length > 0 && !values.includes(customValue);
const triggerText =
values.length === 0
? placeholder || t("omo.selectPlaceholder", { defaultValue: "Select..." })
: values.length === 1
? values[0]
: `${values[0]} +${values.length - 1}`;
const availablePresets = presets?.filter(
(p) =>
!search.trim() ||
p.label.toLowerCase().includes(search.toLowerCase()) ||
p.value.toLowerCase().includes(search.toLowerCase()),
);
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-sm">{label}</Label>
{values.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs text-muted-foreground"
onClick={() => onChange([])}
>
{t("omo.clear", { defaultValue: "Clear" })}
</Button>
)}
</div>
{values.length > 0 && (
<div className="flex flex-wrap gap-1">
{values.map((v, i) => (
<Badge
key={`${v}-${i}`}
variant="secondary"
className="text-xs gap-1"
>
{v}
<button
type="button"
onClick={() => onChange(values.filter((_, idx) => idx !== i))}
className="hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"flex items-center justify-between w-full h-8 px-3 rounded-md border border-input bg-background text-sm",
"hover:bg-accent hover:text-accent-foreground transition-colors",
open && "ring-2 ring-ring",
)}
aria-expanded={open}
>
<span
className={cn(
"truncate",
values.length > 0 ? "text-foreground" : "text-muted-foreground",
)}
>
{triggerText}
</span>
<ChevronsUpDown className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={6}
className="w-[var(--radix-dropdown-menu-trigger-width)] p-0 z-[120]"
>
<div className="p-1.5 border-b border-border/30">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter" && canAddCustom) {
e.preventDefault();
onChange([...values, customValue]);
setSearch("");
}
}}
placeholder={
placeholder ||
t("omo.searchOrType", {
defaultValue: "Search or type custom value...",
})
}
className="h-7 text-sm"
autoFocus
/>
</div>
{canAddCustom && (
<button
type="button"
className="w-full px-2.5 py-1.5 text-left text-sm border-b border-border/30 hover:bg-accent"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onChange([...values, customValue]);
setSearch("");
}}
>
+ {customValue}
</button>
)}
<div className="max-h-48 overflow-auto py-1">
{availablePresets && availablePresets.length > 0 ? (
availablePresets.map((p) => {
const checked = values.includes(p.value);
return (
<DropdownMenuCheckboxItem
key={p.value}
checked={checked}
onSelect={(e) => e.preventDefault()}
onCheckedChange={() => toggleValue(p.value)}
className="text-sm"
>
{p.label}
</DropdownMenuCheckboxItem>
);
})
) : (
<div className="px-2.5 py-2 text-sm text-muted-foreground">
{t("omo.noMatches", { defaultValue: "No matches" })}
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
function JsonTextareaField({
label,
value,
onChange,
placeholder,
minHeight,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
minHeight?: string;
}) {
return (
<div className="space-y-1.5">
<Label className="text-sm">{label}</Label>
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || "{}"}
className="font-mono text-sm"
style={{ minHeight: minHeight || "100px" }}
/>
</div>
);
}
export const OmoGlobalConfigFields = forwardRef<
OmoGlobalConfigFieldsRef,
OmoGlobalConfigFieldsProps
>(function OmoGlobalConfigFields({ onStateChange, hideSaveButtons }, ref) {
const { t } = useTranslation();
const { data: config } = useOmoGlobalConfig();
const saveMutation = useSaveOmoGlobalConfig();
const [schemaUrl, setSchemaUrl] = useState(OMO_DEFAULT_SCHEMA_URL);
const [sisyphusAgentStr, setSisyphusAgentStr] = useState("");
const [disabledAgents, setDisabledAgents] = useState<string[]>([]);
const [disabledMcps, setDisabledMcps] = useState<string[]>([]);
const [disabledHooks, setDisabledHooks] = useState<string[]>([]);
const [disabledSkills, setDisabledSkills] = useState<string[]>([]);
const [lspStr, setLspStr] = useState("");
const [experimentalStr, setExperimentalStr] = useState("");
const [backgroundTaskStr, setBackgroundTaskStr] = useState("");
const [browserStr, setBrowserStr] = useState("");
const [claudeCodeStr, setClaudeCodeStr] = useState("");
const [otherFieldsStr, setOtherFieldsStr] = useState("");
const [loaded, setLoaded] = useState(false);
const applyGlobalState = useCallback((global: OmoGlobalConfig) => {
setSchemaUrl(global.schemaUrl || OMO_DEFAULT_SCHEMA_URL);
setSisyphusAgentStr(
global.sisyphusAgent ? JSON.stringify(global.sisyphusAgent, null, 2) : "",
);
setDisabledAgents(global.disabledAgents || []);
setDisabledMcps(global.disabledMcps || []);
setDisabledHooks(global.disabledHooks || []);
setDisabledSkills(global.disabledSkills || []);
setLspStr(global.lsp ? JSON.stringify(global.lsp, null, 2) : "");
setExperimentalStr(
global.experimental ? JSON.stringify(global.experimental, null, 2) : "",
);
setBackgroundTaskStr(
global.backgroundTask
? JSON.stringify(global.backgroundTask, null, 2)
: "",
);
setBrowserStr(
global.browserAutomationEngine
? JSON.stringify(global.browserAutomationEngine, null, 2)
: "",
);
setClaudeCodeStr(
global.claudeCode ? JSON.stringify(global.claudeCode, null, 2) : "",
);
setOtherFieldsStr(
global.otherFields ? JSON.stringify(global.otherFields, null, 2) : "",
);
}, []);
useEffect(() => {
if (config && !loaded) {
applyGlobalState(config);
setLoaded(true);
}
}, [config, loaded, applyGlobalState]);
const parseJsonField = useCallback(
(
fieldName: string,
raw: string,
strict: boolean,
): Record<string, unknown> | undefined => {
if (!raw.trim()) return undefined;
try {
const parsed: unknown = JSON.parse(raw);
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed)
) {
if (strict) {
throw new Error(
t("omo.jsonMustBeObject", {
field: fieldName,
defaultValue: "{{field}} must be a JSON object",
}),
);
}
return undefined;
}
return parsed as Record<string, unknown>;
} catch (error) {
if (strict) {
if (error instanceof Error) {
throw error;
}
throw new Error(
t("omo.jsonInvalid", {
field: fieldName,
defaultValue: "{{field}} contains invalid JSON",
}),
);
}
return undefined;
}
},
[t],
);
const buildCurrentConfigInternal = useCallback(
(strict: boolean): OmoGlobalConfig => {
return {
id: "global",
schemaUrl: schemaUrl || undefined,
sisyphusAgent: parseJsonField(
t("omo.sisyphusAgentConfig", {
defaultValue: "Sisyphus Agent",
}),
sisyphusAgentStr,
strict,
),
disabledAgents,
disabledMcps,
disabledHooks,
disabledSkills,
lsp: parseJsonField(
t("omo.advancedLsp", { defaultValue: "LSP" }),
lspStr,
strict,
),
experimental: parseJsonField(
t("omo.advancedExperimental", { defaultValue: "Experimental" }),
experimentalStr,
strict,
),
backgroundTask: parseJsonField(
t("omo.advancedBackgroundTask", {
defaultValue: "Background Task",
}),
backgroundTaskStr,
strict,
),
browserAutomationEngine: parseJsonField(
t("omo.advancedBrowserAutomation", {
defaultValue: "Browser Automation",
}),
browserStr,
strict,
),
claudeCode: parseJsonField(
t("omo.advancedClaudeCode", { defaultValue: "Claude Code" }),
claudeCodeStr,
strict,
),
otherFields: parseJsonField(
t("omo.otherFields", {
defaultValue: "Other Config",
}),
otherFieldsStr,
strict,
),
updatedAt: new Date().toISOString(),
};
},
[
schemaUrl,
sisyphusAgentStr,
disabledAgents,
disabledMcps,
disabledHooks,
disabledSkills,
lspStr,
experimentalStr,
backgroundTaskStr,
browserStr,
claudeCodeStr,
otherFieldsStr,
parseJsonField,
],
);
const buildCurrentConfig = useCallback(
() => buildCurrentConfigInternal(false),
[buildCurrentConfigInternal],
);
const buildCurrentConfigStrict = useCallback(
() => buildCurrentConfigInternal(true),
[buildCurrentConfigInternal],
);
useEffect(() => {
if (loaded && onStateChange) {
onStateChange(buildCurrentConfig());
}
}, [loaded, onStateChange, buildCurrentConfig]);
const handleSaveGlobal = useCallback(async () => {
try {
const result = buildCurrentConfigStrict();
await saveMutation.mutateAsync(result);
toast.success(
t("omo.globalConfigSaved", {
defaultValue: "Global config saved",
}),
);
} catch (err) {
toast.error(String(err));
}
}, [buildCurrentConfigStrict, saveMutation, t]);
const disabledCount =
disabledAgents.length +
disabledMcps.length +
disabledHooks.length +
disabledSkills.length;
const advancedFieldValues: Record<OmoAdvancedFieldKey, string> = {
lspStr,
experimentalStr,
backgroundTaskStr,
browserStr,
claudeCodeStr,
};
const advancedFieldSetters: Record<
OmoAdvancedFieldKey,
(value: string) => void
> = {
lspStr: setLspStr,
experimentalStr: setExperimentalStr,
backgroundTaskStr: setBackgroundTaskStr,
browserStr: setBrowserStr,
claudeCodeStr: setClaudeCodeStr,
};
const disabledEditorConfigs = [
{
key: "agents",
label: t("omo.disabledAgents", { defaultValue: "Agents" }),
values: disabledAgents,
onChange: setDisabledAgents,
placeholder: t("omo.disabledAgentsPlaceholder", {
defaultValue: "Disabled Agents",
}),
presets: OMO_DISABLEABLE_AGENTS,
},
{
key: "mcps",
label: t("omo.disabledMcps", { defaultValue: "MCPs" }),
values: disabledMcps,
onChange: setDisabledMcps,
placeholder: t("omo.disabledMcpsPlaceholder", {
defaultValue: "Disabled MCPs",
}),
presets: OMO_DISABLEABLE_MCPS,
},
{
key: "hooks",
label: t("omo.disabledHooks", { defaultValue: "Hooks" }),
values: disabledHooks,
onChange: setDisabledHooks,
placeholder: t("omo.disabledHooksPlaceholder", {
defaultValue: "Disabled Hooks",
}),
presets: OMO_DISABLEABLE_HOOKS,
},
{
key: "skills",
label: t("omo.disabledSkills", { defaultValue: "Skills" }),
values: disabledSkills,
onChange: setDisabledSkills,
placeholder: t("omo.disabledSkillsPlaceholder", {
defaultValue: "Disabled Skills",
}),
presets: OMO_DISABLEABLE_SKILLS,
},
] as const;
const readLocalFile = useReadOmoLocalFile();
const handleImportGlobalFromLocal = useCallback(async () => {
try {
const data = await readLocalFile.mutateAsync();
applyGlobalState(data.global);
toast.success(
t("omo.importGlobalSuccess", {
defaultValue: "Imported global config from local file (unsaved)",
}),
);
} catch (err) {
toast.error(
t("omo.importGlobalFailed", {
error: String(err),
defaultValue: "Failed to read local file: {{error}}",
}),
);
}
}, [readLocalFile, applyGlobalState, t]);
useImperativeHandle(
ref,
() => ({
buildCurrentConfig,
buildCurrentConfigStrict,
importFromLocal: handleImportGlobalFromLocal,
}),
[buildCurrentConfig, buildCurrentConfigStrict, handleImportGlobalFromLocal],
);
return (
<div className="space-y-4">
{!hideSaveButtons && (
<div className="flex items-center justify-end gap-1.5">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={readLocalFile.isPending}
onClick={handleImportGlobalFromLocal}
>
{readLocalFile.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<FolderInput className="h-3.5 w-3.5 mr-1" />
)}
{t("omo.importLocal", { defaultValue: "Import Local" })}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
disabled={saveMutation.isPending}
onClick={handleSaveGlobal}
>
{saveMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Save className="h-3.5 w-3.5 mr-1" />
)}
{t("omo.saveGlobalConfig", { defaultValue: "Save Global Config" })}
</Button>
</div>
)}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-sm">
{t("omo.schemaUrl", { defaultValue: "$schema" })}
</Label>
{schemaUrl !== OMO_DEFAULT_SCHEMA_URL && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 text-xs px-1.5"
onClick={() => setSchemaUrl(OMO_DEFAULT_SCHEMA_URL)}
>
<RotateCcw className="h-3 w-3 mr-0.5" />
{t("omo.resetDefault", { defaultValue: "Reset" })}
</Button>
)}
</div>
<Input
value={schemaUrl}
onChange={(e) => setSchemaUrl(e.target.value)}
placeholder={OMO_DEFAULT_SCHEMA_URL}
className="text-sm h-8"
/>
</div>
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-2">
<Label className="text-sm font-semibold">
{t("omo.sisyphusAgentConfig", {
defaultValue: "Sisyphus Agent",
})}
</Label>
<Textarea
value={sisyphusAgentStr}
onChange={(e) => setSisyphusAgentStr(e.target.value)}
placeholder={OMO_SISYPHUS_AGENT_PLACEHOLDER}
className="font-mono text-sm"
style={{ minHeight: "140px" }}
/>
</div>
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-3">
<div className="flex items-center gap-2">
<Label className="text-sm font-semibold">
{t("omo.disabledItems", { defaultValue: "Disabled Items" })}
</Label>
{disabledCount > 0 && (
<Badge variant="secondary" className="text-xs h-5">
{disabledCount}
</Badge>
)}
</div>
{disabledEditorConfigs.map((editor) => (
<TagListEditor
key={editor.key}
label={editor.label}
values={editor.values}
onChange={editor.onChange}
placeholder={editor.placeholder}
presets={editor.presets}
/>
))}
</div>
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-2">
<Label className="text-sm font-semibold">
{t("omo.advanced", { defaultValue: "Advanced Settings" })}
</Label>
{OMO_ADVANCED_JSON_FIELDS.map((field) => (
<JsonTextareaField
key={field.key}
label={t(field.labelKey, { defaultValue: field.defaultLabel })}
value={advancedFieldValues[field.key]}
onChange={advancedFieldSetters[field.key]}
placeholder={field.placeholder}
minHeight={field.minHeight}
/>
))}
<JsonTextareaField
label={t("omo.otherFields", {
defaultValue: "Other Config",
})}
value={otherFieldsStr}
onChange={setOtherFieldsStr}
/>
</div>
</div>
);
});
File diff suppressed because it is too large Load Diff
@@ -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({
<div className="space-y-3">
<FormLabel>{t("providerPreset.label")}</FormLabel>
<div className="flex flex-wrap gap-2">
{/* 自定义按钮 */}
<button
type="button"
onClick={() => onPresetChange("custom")}
@@ -143,7 +141,6 @@ export function ProviderPresetSelector({
{t("providerPreset.custom")}
</button>
{/* 预设按钮 */}
{categoryKeys.map((category) => {
const entries = groupedPresets[category];
if (!entries || entries.length === 0) return null;
@@ -174,7 +171,6 @@ export function ProviderPresetSelector({
})}
</div>
{/* 统一供应商预设(新的一行) */}
{onUniversalPresetSelect && universalProviderPresets.length > 0 && (
<>
<div className="flex flex-wrap items-center gap-2">
@@ -196,7 +192,6 @@ export function ProviderPresetSelector({
</span>
</button>
))}
{/* 管理统一供应商按钮 */}
{onManageUniversalProviders && (
<button
type="button"
+14 -20
View File
@@ -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<string, TemplateValueConfig>;
/** 视觉主题配置 */
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",
@@ -474,7 +457,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
// ========== 聚合网站 ==========
{
name: "AiHubMix",
websiteUrl: "https://aihubmix.com",
@@ -583,7 +565,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
// ========== 第三方合作伙伴 ==========
{
name: "PackyCode",
websiteUrl: "https://www.packyapi.com",
@@ -729,7 +710,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
// ========== 自定义模板 ==========
{
name: "OpenAI Compatible",
websiteUrl: "",
@@ -758,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,
},
];
+101 -1
View File
@@ -32,6 +32,7 @@
"back": "Back",
"refresh": "Refresh",
"refreshing": "Refreshing...",
"import": "Import",
"all": "All",
"search": "Search",
"reset": "Reset",
@@ -106,6 +107,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",
@@ -156,7 +161,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",
@@ -478,6 +484,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": {
@@ -1267,6 +1274,9 @@
"agents": {
"title": "Agents"
},
"modelTest": {
"testProvider": "Test model"
},
"health": {
"operational": "Operational",
"degraded": "Degraded",
@@ -1505,6 +1515,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",
@@ -1519,5 +1530,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"
}
}
+108 -1
View File
@@ -32,6 +32,7 @@
"back": "戻る",
"refresh": "更新",
"refreshing": "更新中...",
"import": "インポート",
"all": "すべて",
"search": "検索",
"reset": "リセット",
@@ -106,6 +107,10 @@
"duplicate": "複製",
"sortUpdateFailed": "並び順の更新に失敗しました",
"configureUsage": "利用状況を設定",
"officialPartner": "公式パートナー",
"openTerminal": "ターミナルを開く",
"terminalOpened": "ターミナルを開きました",
"terminalOpenFailed": "ターミナルを開けませんでした",
"name": "プロバイダー名",
"namePlaceholder": "例: Claude Official",
"websiteUrl": "Web サイト URL",
@@ -156,7 +161,8 @@
"deleteFailed": "プロバイダーの削除に失敗しました: {{error}}",
"settingsSaved": "設定を保存しました",
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}",
"openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です"
"openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です",
"openLinkFailed": "リンクを開けませんでした"
},
"confirm": {
"deleteProvider": "プロバイダーを削除",
@@ -478,6 +484,7 @@
"aggregatorApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
"thirdPartyApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
"customApiKeyHint": "💡 カスタム設定では必要な項目をすべて手動で入力してください",
"omoHint": "💡 OMO 設定は Agent のモデル割り当てを管理し、oh-my-opencode.jsonc に書き込みます",
"officialHint": "💡 公式プロバイダーはブラウザログインで、API Key は不要です",
"getApiKey": "API Key を取得",
"partnerPromotion": {
@@ -1161,6 +1168,13 @@
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode"
},
"installFromZip": {
"button": "ZIP からインストール",
"installing": "インストール中...",
"successSingle": "スキル {{name}} をインストールしました",
"successMultiple": "{{count}} 件のスキルをインストールしました",
"noSkillsFound": "ZIP ファイルにスキルが見つかりません(SKILL.md が必要です)"
}
},
"deeplink": {
@@ -1258,6 +1272,9 @@
"agents": {
"title": "エージェント"
},
"modelTest": {
"testProvider": "モデルテスト"
},
"health": {
"operational": "正常",
"degraded": "低下",
@@ -1479,6 +1496,7 @@
"deleted": "統合プロバイダーを削除しました",
"addSuccess": "統合プロバイダーを追加しました",
"addFailed": "統合プロバイダーの追加に失敗しました",
"hint": "クロスアプリ統合設定。Claude/Codex/Gemini に自動同期します",
"manage": "管理",
"loadError": "統合プロバイダーの読み込みに失敗しました",
"saveError": "統合プロバイダーの保存に失敗しました",
@@ -1493,5 +1511,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"
}
}
+101 -1
View File
@@ -32,6 +32,7 @@
"back": "返回",
"refresh": "刷新",
"refreshing": "刷新中...",
"import": "导入",
"all": "全部",
"search": "查询",
"reset": "重置",
@@ -106,6 +107,10 @@
"duplicate": "复制",
"sortUpdateFailed": "排序更新失败",
"configureUsage": "配置用量查询",
"officialPartner": "官方合作伙伴",
"openTerminal": "打开终端",
"terminalOpened": "终端已打开",
"terminalOpenFailed": "打开终端失败",
"name": "供应商名称",
"namePlaceholder": "例如:Claude 官方",
"websiteUrl": "官网链接",
@@ -156,7 +161,8 @@
"deleteFailed": "删除供应商失败:{{error}}",
"settingsSaved": "设置已保存",
"settingsSaveFailed": "保存设置失败:{{error}}",
"openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用"
"openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用",
"openLinkFailed": "链接打开失败"
},
"confirm": {
"deleteProvider": "删除供应商",
@@ -478,6 +484,7 @@
"aggregatorApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
"thirdPartyApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
"customApiKeyHint": "💡 自定义配置需手动填写所有必要字段",
"omoHint": "💡 OMO 配置管理 Agent 模型分配,写入 oh-my-opencode.jsonc",
"officialHint": "💡 官方供应商使用浏览器登录,无需配置 API Key",
"getApiKey": "获取 API Key",
"partnerPromotion": {
@@ -1267,6 +1274,9 @@
"agents": {
"title": "智能体"
},
"modelTest": {
"testProvider": "测试模型"
},
"health": {
"operational": "正常",
"degraded": "降级",
@@ -1505,6 +1515,7 @@
"deleted": "统一供应商已删除",
"addSuccess": "统一供应商添加成功",
"addFailed": "统一供应商添加失败",
"hint": "跨应用统一配置,自动同步到 Claude/Codex/Gemini",
"manage": "管理",
"loadError": "加载统一供应商失败",
"saveError": "保存统一供应商失败",
@@ -1519,5 +1530,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"
}
}
+9 -34
View File
@@ -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,20 +187,17 @@ html.dark {
}
}
/* 禁用 Edge / IE 的密码显示按钮 */
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;
}
@@ -232,25 +206,26 @@ input[type="password"]::-ms-clear {
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%));
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%));
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;
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
// 配置相关 API
import { invoke } from "@tauri-apps/api/core";
export type AppType = "claude" | "codex" | "gemini";
export type AppType = "claude" | "codex" | "gemini" | "omo";
/**
* 获取 Claude 通用配置片段(已废弃,使用 getCommonConfigSnippet
@@ -63,7 +63,7 @@ export type ExtractCommonConfigSnippetOptions = {
};
export async function extractCommonConfigSnippet(
appType: AppType,
appType: Exclude<AppType, "omo">,
options?: ExtractCommonConfigSnippetOptions,
): Promise<string> {
const args: Record<string, unknown> = { appType };
+10
View File
@@ -0,0 +1,10 @@
import { invoke } from "@tauri-apps/api/core";
import type { OmoLocalFileData } from "@/types/omo";
export const omoApi = {
readLocalFile: (): Promise<OmoLocalFileData> => invoke("read_omo_local_file"),
getCurrentOmoProviderId: (): Promise<string> =>
invoke("get_current_omo_provider_id"),
getOmoProviderCount: (): Promise<number> => invoke("get_omo_provider_count"),
disableCurrentOmo: (): Promise<void> => invoke("disable_current_omo"),
};
+28 -14
View File
@@ -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: "切换失败" }),
{
+95
View File
@@ -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<typeof useQueryClient>) {
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<OmoGlobalConfig> => {
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),
});
}
+2 -1
View File
@@ -3,7 +3,8 @@ export type ProviderCategory =
| "cn_official" // 开源官方(原"国产官方")
| "aggregator" // 聚合网站
| "third_party" // 第三方供应商
| "custom"; // 自定义
| "custom" // 自定义
| "omo"; // Oh My OpenCode
export interface Provider {
id: string;
+361
View File
@@ -0,0 +1,361 @@
export interface OmoGlobalConfig {
id: string;
schemaUrl?: string;
sisyphusAgent?: Record<string, unknown>;
disabledAgents: string[];
disabledMcps: string[];
disabledHooks: string[];
disabledSkills: string[];
lsp?: Record<string, unknown>;
experimental?: Record<string, unknown>;
backgroundTask?: Record<string, unknown>;
browserAutomationEngine?: Record<string, unknown>;
claudeCode?: Record<string, unknown>;
otherFields?: Record<string, unknown>;
updatedAt: string;
}
export interface OmoLocalFileData {
agents?: Record<string, Record<string, unknown>>;
categories?: Record<string, Record<string, unknown>>;
otherFields?: Record<string, unknown>;
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<string, Record<string, unknown>>,
categories: Record<string, Record<string, unknown>>,
otherFieldsStr: string,
): Record<string, unknown> {
const result: Record<string, unknown> = {};
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;
}