Compare commits

..

5 Commits

Author SHA1 Message Date
Jason 2ac4a64087 test(mcp): update test to reflect new MCP sync architecture
Remove MCP-related assertions from sync_codex_provider_writes_auth_and_config
test since provider switching no longer triggers MCP sync in v3.7.0+.

MCP synchronization is now handled independently by McpService,
not as part of the provider switch flow.
2025-12-27 21:05:18 +08:00
majiayu000 6f555cb274 fix(mcp): 移除同步Codex Provider时的旧MCP同步调用
sync_enabled_to_codex使用旧的config.mcp.codex结构,
在v3.7.0统一结构中该字段为空,导致MCP配置被错误清除。
MCP同步应通过McpService进行。

Fixes #403
2025-12-24 11:34:46 +08:00
Jason 7d495aa772 feat(ui): add exit animation to FullScreenPanel dialogs
- Wrap FullScreenPanel content with AnimatePresence for exit animation
- Add exit={{ opacity: 0 }} to enable fade-out on close
- Use useRef + useEffect to preserve provider data during exit animation
- Follow React best practices by updating refs in useEffect instead of render

Affected components:
- EditProviderDialog
- UsageScriptModal
- AddProviderDialog
- McpFormModal
- ProxySettingsDialog
2025-12-23 16:18:28 +08:00
Jason db8180aa31 fix(ui): reduce header spacing and fix layout shift on view switch
- Change right-side button container from min-h-[40px] to h-[32px]
  for more compact header layout
- Remove conditional padding (pt-6/pt-4) from main content area
  to eliminate layout jump during view transitions
2025-12-23 16:18:28 +08:00
YoVinchen 1586451862 Feat/auto failover switch (#440)
* feat(failover): add auto-failover master switch with proxy integration

- Add persistent auto_failover_enabled setting in database
- Add get/set_auto_failover_enabled commands
- Provider router respects master switch state
- Proxy shutdown automatically disables failover
- Enabling failover auto-starts proxy server
- Optimistic updates for failover queue toggle

* feat(proxy): persist proxy takeover state across app restarts

- Add proxy_takeover_{app_type} settings for per-app state tracking
- Restore proxy takeover state automatically on app startup
- Preserve state on normal exit, clear on manual stop
- Add stop_with_restore_keep_state method for graceful shutdown

* fix(proxy): set takeover state for all apps in start_with_takeover

* fix(windows): hide console window when checking CLI versions

Add CREATE_NO_WINDOW flag to prevent command prompt from flashing
when detecting claude/codex/gemini CLI versions on Windows.

* refactor(failover): make auto-failover toggle per-app independent

- Change setting key from 'auto_failover_enabled' to 'auto_failover_enabled_{app_type}'
- Update provider_router to check per-app failover setting
- When failover disabled, use current provider only; when enabled, use queue order
- Add unit tests for failover enabled/disabled behavior

* feat(failover): auto-switch to higher priority provider on recovery

- After circuit breaker reset, check if recovered provider has higher priority
- Automatically switch back if queue_order is lower (higher priority)
- Stream health check now resets circuit breaker on success/degraded

* chore: remove unused start_proxy_with_takeover command

- Remove command registration from lib.rs
- Add comment clarifying failover queue is preserved on proxy stop

* feat(ui): integrate failover controls into provider cards

- Add failover toggle button to provider card actions
- Show priority badge (P1, P2, ...) for queued providers
- Highlight active provider with green border in failover mode
- Sync drag-drop order with failover queue
- Move per-app failover toggle to FailoverQueueManager
- Simplify SettingsPage failover section

* test(providers): add mocks for failover hooks in ProviderList tests

* refactor(failover): merge failover_queue table into providers

- Add in_failover_queue field to providers table
- Remove standalone failover_queue table and related indexes
- Simplify queue ordering by reusing sort_index field
- Remove reorder_failover_queue and set_failover_item_enabled commands
- Update frontend to use simplified FailoverQueueItem type

* fix(database): ensure in_failover_queue column exists for v2 databases

Add column check in create_tables to handle existing v2 databases
that were created before the failover queue refactor.

* fix(ui): differentiate active provider border color by proxy mode

- Use green border/gradient when proxy takeover is active
- Use blue border/gradient in normal mode (no proxy)
- Improves visual distinction between proxy and non-proxy states

* fix(database): clear provider health record when removing from failover queue

When a provider is removed from the failover queue, its health monitoring
is no longer needed. This change ensures the health record is also deleted
from the database to prevent stale data.

* fix(failover): improve cache cleanup for provider health and circuit breaker

- Use removeQueries instead of invalidateQueries when stopping proxy to
  completely clear health and circuit breaker caches
- Clear provider health and circuit breaker caches when removing from
  failover queue
- Refresh failover queue after drag-sort since queue order depends on
  sort_index
- Only show health badge when provider is in failover queue

* style: apply prettier formatting to App.tsx and ProviderList.tsx

* fix(proxy): handle missing health records and clear health on proxy stop

- Return default healthy state when provider health record not found
- Add clear_provider_health_for_app to clear health for specific app
- Clear app health records when stopping proxy takeover

* fix(proxy): track actual provider used in forwarding for accurate logging

Introduce ForwardResult and ForwardError structs to return the actual
provider that handled the request. This ensures usage statistics and
error logs reflect the correct provider after failover.
2025-12-23 12:37:36 +08:00
4 changed files with 74 additions and 63 deletions
+3 -1
View File
@@ -146,7 +146,9 @@ impl ConfigService {
let cfg_text = settings.get("config").and_then(Value::as_str);
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
crate::mcp::sync_enabled_to_codex(config)?;
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
// MCP 的启用/禁用应通过 McpService::toggle_app 进行
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
+5 -15
View File
@@ -76,19 +76,8 @@ fn sync_codex_provider_writes_auth_and_config() {
let mut config = MultiAppConfig::default();
// 添加入测 MCP 启用项,确保 sync_enabled_to_codex 会写入 TOML
config.mcp.codex.servers.insert(
"echo-server".into(),
json!({
"id": "echo-server",
"enabled": true,
"server": {
"type": "stdio",
"command": "echo",
"args": ["hello"]
}
}),
);
// 注意:v3.7.0 后 MCP 同步由 McpService 独立处理,不再通过 provider 切换触发
// 此测试仅验证 auth.json 和 config.toml 基础配置的写入
let provider_config = json!({
"auth": {
@@ -133,9 +122,10 @@ fn sync_codex_provider_writes_auth_and_config() {
);
let toml_text = fs::read_to_string(&config_path).expect("read config.toml");
// 验证基础配置正确写入
assert!(
toml_text.contains("command = \"echo\""),
"config.toml should contain serialized enabled MCP server"
toml_text.contains("base_url"),
"config.toml should contain base_url from provider config"
);
// 当前供应商应同步最新 config 文本
+24 -8
View File
@@ -65,6 +65,22 @@ function App() {
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
const [showEnvBanner, setShowEnvBanner] = useState(false);
// 保存最后一个有效的 provider,用于动画退出期间显示内容
const lastUsageProviderRef = useRef<Provider | null>(null);
const lastEditingProviderRef = useRef<Provider | null>(null);
useEffect(() => {
if (usageProvider) {
lastUsageProviderRef.current = usageProvider;
}
}, [usageProvider]);
useEffect(() => {
if (editingProvider) {
lastEditingProviderRef.current = editingProvider;
}
}, [editingProvider]);
const promptPanelRef = useRef<any>(null);
const mcpPanelRef = useRef<any>(null);
const skillsPageRef = useRef<any>(null);
@@ -512,7 +528,7 @@ function App() {
</div>
<div
className="flex items-center gap-2 min-h-[40px]"
className="flex items-center gap-2 h-[32px]"
style={{ WebkitAppRegion: "no-drag" } as any}
>
{currentView === "prompts" && (
@@ -625,9 +641,7 @@ function App() {
</header>
<main className="flex-1 pb-12 animate-fade-in ">
<div
className={cn("pb-12", currentView === "providers" ? "pt-6" : "pt-4")}
>
<div className="pb-12">
{renderContent()}
</div>
</main>
@@ -641,7 +655,7 @@ function App() {
<EditProviderDialog
open={Boolean(editingProvider)}
provider={editingProvider}
provider={lastEditingProviderRef.current}
onOpenChange={(open) => {
if (!open) {
setEditingProvider(null);
@@ -652,14 +666,16 @@ function App() {
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
/>
{usageProvider && (
{lastUsageProviderRef.current && (
<UsageScriptModal
provider={usageProvider}
provider={lastUsageProviderRef.current}
appId={activeApp}
isOpen={Boolean(usageProvider)}
onClose={() => setUsageProvider(null)}
onSave={(script) => {
void saveUsageScript(usageProvider, script);
if (usageProvider) {
void saveUsageScript(usageProvider, script);
}
}}
/>
)}
+42 -39
View File
@@ -1,6 +1,6 @@
import React from "react";
import { createPortal } from "react-dom";
import { motion } from "framer-motion";
import { motion, AnimatePresence } from "framer-motion";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -33,49 +33,52 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
};
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-[60] flex flex-col"
style={{ backgroundColor: "hsl(var(--background))" }}
>
{/* Header */}
<div
className="flex-shrink-0 py-3 border-b border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }}
>
<div className="h-4 w-full" data-tauri-drag-region />
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
<Button type="button" variant="outline" size="icon" onClick={onClose}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto scroll-overlay">
<div className="mx-auto max-w-[56rem] px-6 py-6 space-y-6 w-full">
{children}
</div>
</div>
{/* Footer */}
{footer && (
<div
className="flex-shrink-0 py-4 border-t border-border-default"
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-[60] flex flex-col"
style={{ backgroundColor: "hsl(var(--background))" }}
>
<div className="mx-auto max-w-[56rem] px-6 flex items-center justify-end gap-3">
{footer}
{/* Header */}
<div
className="flex-shrink-0 py-3 border-b border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }}
>
<div className="h-4 w-full" data-tauri-drag-region />
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
<Button type="button" variant="outline" size="icon" onClick={onClose}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto scroll-overlay">
<div className="mx-auto max-w-[56rem] px-6 py-6 space-y-6 w-full">
{children}
</div>
</div>
{/* Footer */}
{footer && (
<div
className="flex-shrink-0 py-4 border-t border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }}
>
<div className="mx-auto max-w-[56rem] px-6 flex items-center justify-end gap-3">
{footer}
</div>
</div>
)}
</motion.div>
)}
</motion.div>,
</AnimatePresence>,
document.body,
);
};