设置页面修改语言和Agent,其它页面实时更新状态

This commit is contained in:
xintaofei
2026-03-07 21:36:00 +08:00
parent 8f265f8c0c
commit 62d8e1a0b0
5 changed files with 215 additions and 12 deletions

View File

@@ -2,8 +2,8 @@ use std::collections::BTreeMap;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use serde::Deserialize; use serde::{Deserialize, Serialize};
use tauri::State; use tauri::{Emitter, State};
use crate::acp::binary_cache; use crate::acp::binary_cache;
use crate::acp::error::AcpError; use crate::acp::error::AcpError;
@@ -18,6 +18,26 @@ use crate::db::service::agent_setting_service;
use crate::db::AppDatabase; use crate::db::AppDatabase;
use crate::models::agent::AgentType; use crate::models::agent::AgentType;
const ACP_AGENTS_UPDATED_EVENT: &str = "app://acp-agents-updated";
#[derive(Serialize, Clone)]
#[serde(rename_all = "snake_case")]
struct AcpAgentsUpdatedEventPayload {
reason: &'static str,
agent_type: Option<AgentType>,
}
fn emit_acp_agents_updated(
app: &tauri::AppHandle,
reason: &'static str,
agent_type: Option<AgentType>,
) {
let _ = app.emit(
ACP_AGENTS_UPDATED_EVENT,
AcpAgentsUpdatedEventPayload { reason, agent_type },
);
}
fn parse_version_output(output: &std::process::Output) -> Option<String> { fn parse_version_output(output: &std::process::Output) -> Option<String> {
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
@@ -1531,6 +1551,7 @@ pub async fn acp_update_agent_preferences(
codex_auth_json: Option<String>, codex_auth_json: Option<String>,
codex_config_toml: Option<String>, codex_config_toml: Option<String>,
db: State<'_, AppDatabase>, db: State<'_, AppDatabase>,
app: tauri::AppHandle,
) -> Result<(), AcpError> { ) -> Result<(), AcpError> {
let default = agent_setting_service::AgentDefaultInput { let default = agent_setting_service::AgentDefaultInput {
agent_type, agent_type,
@@ -1585,6 +1606,7 @@ pub async fn acp_update_agent_preferences(
codex_config_toml.as_deref(), codex_config_toml.as_deref(),
)?; )?;
} }
emit_acp_agents_updated(&app, "preferences_updated", Some(agent_type));
return Ok(()); return Ok(());
} }
@@ -1595,6 +1617,7 @@ pub async fn acp_update_agent_preferences(
if let Some(raw) = config_json.as_deref() { if let Some(raw) = config_json.as_deref() {
persist_agent_local_config_json(agent_type, Some(raw))?; persist_agent_local_config_json(agent_type, Some(raw))?;
} }
emit_acp_agents_updated(&app, "preferences_updated", Some(agent_type));
return Ok(()); return Ok(());
} }
@@ -1613,11 +1636,15 @@ pub async fn acp_update_agent_preferences(
let local_patch_json = serde_json::to_string(&local_patch_value) let local_patch_json = serde_json::to_string(&local_patch_value)
.map_err(|e| AcpError::protocol(format!("serialize local patch failed: {e}")))?; .map_err(|e| AcpError::protocol(format!("serialize local patch failed: {e}")))?;
persist_agent_local_config_json(agent_type, Some(local_patch_json.as_str()))?; persist_agent_local_config_json(agent_type, Some(local_patch_json.as_str()))?;
emit_acp_agents_updated(&app, "preferences_updated", Some(agent_type));
Ok(()) Ok(())
} }
#[tauri::command] #[tauri::command]
pub async fn acp_download_agent_binary(agent_type: AgentType) -> Result<(), AcpError> { pub async fn acp_download_agent_binary(
agent_type: AgentType,
app: tauri::AppHandle,
) -> Result<(), AcpError> {
let meta = registry::get_agent_meta(agent_type); let meta = registry::get_agent_meta(agent_type);
match meta.distribution { match meta.distribution {
registry::AgentDistribution::Binary { registry::AgentDistribution::Binary {
@@ -1639,6 +1666,7 @@ pub async fn acp_download_agent_binary(agent_type: AgentType) -> Result<(), AcpE
let _ = binary_cache::ensure_binary_for_agent(agent_type, version, fallback.url, cmd) let _ = binary_cache::ensure_binary_for_agent(agent_type, version, fallback.url, cmd)
.await?; .await?;
emit_acp_agents_updated(&app, "binary_downloaded", Some(agent_type));
Ok(()) Ok(())
} }
registry::AgentDistribution::Npx { .. } | registry::AgentDistribution::Uvx { .. } => Err( registry::AgentDistribution::Npx { .. } | registry::AgentDistribution::Uvx { .. } => Err(
@@ -1678,6 +1706,7 @@ pub async fn acp_prepare_npx_agent(
agent_type: AgentType, agent_type: AgentType,
registry_version: Option<String>, registry_version: Option<String>,
db: State<'_, AppDatabase>, db: State<'_, AppDatabase>,
app: tauri::AppHandle,
) -> Result<String, AcpError> { ) -> Result<String, AcpError> {
let meta = registry::get_agent_meta(agent_type); let meta = registry::get_agent_meta(agent_type);
match meta.distribution { match meta.distribution {
@@ -1720,6 +1749,7 @@ pub async fn acp_prepare_npx_agent(
) )
.await .await
.map_err(|e| AcpError::protocol(e.to_string()))?; .map_err(|e| AcpError::protocol(e.to_string()))?;
emit_acp_agents_updated(&app, "npx_prepared", Some(agent_type));
Ok(resolved) Ok(resolved)
} }
registry::AgentDistribution::Binary { .. } => Err(AcpError::protocol( registry::AgentDistribution::Binary { .. } => Err(AcpError::protocol(
@@ -1736,6 +1766,7 @@ pub async fn acp_prepare_uvx_agent(
agent_type: AgentType, agent_type: AgentType,
registry_version: Option<String>, registry_version: Option<String>,
db: State<'_, AppDatabase>, db: State<'_, AppDatabase>,
app: tauri::AppHandle,
) -> Result<String, AcpError> { ) -> Result<String, AcpError> {
let meta = registry::get_agent_meta(agent_type); let meta = registry::get_agent_meta(agent_type);
match meta.distribution { match meta.distribution {
@@ -1778,6 +1809,7 @@ pub async fn acp_prepare_uvx_agent(
) )
.await .await
.map_err(|e| AcpError::protocol(e.to_string()))?; .map_err(|e| AcpError::protocol(e.to_string()))?;
emit_acp_agents_updated(&app, "uvx_prepared", Some(agent_type));
Ok(resolved) Ok(resolved)
} }
registry::AgentDistribution::Npx { .. } => Err(AcpError::protocol( registry::AgentDistribution::Npx { .. } => Err(AcpError::protocol(
@@ -1793,6 +1825,7 @@ pub async fn acp_prepare_uvx_agent(
pub async fn acp_uninstall_agent( pub async fn acp_uninstall_agent(
agent_type: AgentType, agent_type: AgentType,
db: State<'_, AppDatabase>, db: State<'_, AppDatabase>,
app: tauri::AppHandle,
) -> Result<(), AcpError> { ) -> Result<(), AcpError> {
let meta = registry::get_agent_meta(agent_type); let meta = registry::get_agent_meta(agent_type);
match meta.distribution { match meta.distribution {
@@ -1810,6 +1843,7 @@ pub async fn acp_uninstall_agent(
agent_setting_service::set_installed_version(&db.conn, agent_type, None) agent_setting_service::set_installed_version(&db.conn, agent_type, None)
.await .await
.map_err(|e| AcpError::protocol(e.to_string()))?; .map_err(|e| AcpError::protocol(e.to_string()))?;
emit_acp_agents_updated(&app, "agent_uninstalled", Some(agent_type));
Ok(()) Ok(())
} }
@@ -1817,6 +1851,7 @@ pub async fn acp_uninstall_agent(
pub async fn acp_reorder_agents( pub async fn acp_reorder_agents(
agent_types: Vec<AgentType>, agent_types: Vec<AgentType>,
db: State<'_, AppDatabase>, db: State<'_, AppDatabase>,
app: tauri::AppHandle,
) -> Result<(), AcpError> { ) -> Result<(), AcpError> {
if agent_types.is_empty() { if agent_types.is_empty() {
return Ok(()); return Ok(());
@@ -1831,6 +1866,7 @@ pub async fn acp_reorder_agents(
AcpError::protocol(message) AcpError::protocol(message)
} }
})?; })?;
emit_acp_agents_updated(&app, "agent_reordered", None);
Ok(()) Ok(())
} }

View File

@@ -1,5 +1,5 @@
use sea_orm::DatabaseConnection; use sea_orm::DatabaseConnection;
use tauri::State; use tauri::{Emitter, State};
use crate::app_error::AppCommandError; use crate::app_error::AppCommandError;
use crate::db::service::app_metadata_service; use crate::db::service::app_metadata_service;
@@ -9,6 +9,7 @@ use crate::network::proxy;
const SYSTEM_PROXY_SETTINGS_KEY: &str = "system_proxy_settings"; const SYSTEM_PROXY_SETTINGS_KEY: &str = "system_proxy_settings";
const SYSTEM_LANGUAGE_SETTINGS_KEY: &str = "system_language_settings"; const SYSTEM_LANGUAGE_SETTINGS_KEY: &str = "system_language_settings";
const LANGUAGE_SETTINGS_UPDATED_EVENT: &str = "app://language-settings-updated";
fn normalize_proxy_settings( fn normalize_proxy_settings(
settings: SystemProxySettings, settings: SystemProxySettings,
@@ -118,6 +119,7 @@ pub async fn get_system_language_settings(
pub async fn update_system_language_settings( pub async fn update_system_language_settings(
settings: SystemLanguageSettings, settings: SystemLanguageSettings,
db: State<'_, AppDatabase>, db: State<'_, AppDatabase>,
app: tauri::AppHandle,
) -> Result<SystemLanguageSettings, AppCommandError> { ) -> Result<SystemLanguageSettings, AppCommandError> {
let serialized = serde_json::to_string(&settings).map_err(|e| { let serialized = serde_json::to_string(&settings).map_err(|e| {
AppCommandError::invalid_input("Failed to serialize language settings") AppCommandError::invalid_input("Failed to serialize language settings")
@@ -128,5 +130,7 @@ pub async fn update_system_language_settings(
.await .await
.map_err(AppCommandError::from)?; .map_err(AppCommandError::from)?;
let _ = app.emit(LANGUAGE_SETTINGS_UPDATED_EVENT, &settings);
Ok(settings) Ok(settings)
} }

View File

@@ -8,6 +8,8 @@ import { AGENT_LABELS } from "@/lib/types"
import { AgentIcon } from "@/components/agent-icon" import { AgentIcon } from "@/components/agent-icon"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const ACP_AGENTS_UPDATED_EVENT = "app://acp-agents-updated"
interface AgentSelectorProps { interface AgentSelectorProps {
defaultAgentType?: AgentType defaultAgentType?: AgentType
onSelect: (agentType: AgentType) => void onSelect: (agentType: AgentType) => void
@@ -31,16 +33,20 @@ export function AgentSelector({
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
acpListAgents() let latestRequestId = 0
.then((list) => {
if (cancelled) return const reloadAgents = async () => {
const requestId = latestRequestId + 1
latestRequestId = requestId
try {
const list = await acpListAgents()
if (cancelled || requestId !== latestRequestId) return
const sorted = [...list].sort( const sorted = [...list].sort(
(a, b) => a.sort_order - b.sort_order || a.name.localeCompare(b.name) (a, b) => a.sort_order - b.sort_order || a.name.localeCompare(b.name)
) )
const visible = sorted.filter((a) => a.enabled) const visible = sorted.filter((a) => a.enabled)
setAgents(visible) setAgents(visible)
onAgentsLoaded?.(visible) onAgentsLoaded?.(visible)
// Auto-select default if it exists in the list
if (defaultAgentType) { if (defaultAgentType) {
const found = visible.find( const found = visible.find(
(a) => a.agent_type === defaultAgentType && a.available (a) => a.agent_type === defaultAgentType && a.available
@@ -48,7 +54,6 @@ export function AgentSelector({
if (found) { if (found) {
setSelected(found.agent_type) setSelected(found.agent_type)
} else { } else {
// Fall back to first available
const first = visible.find((a) => a.available) const first = visible.find((a) => a.available)
if (first) { if (first) {
setSelected(first.agent_type) setSelected(first.agent_type)
@@ -62,15 +67,44 @@ export function AgentSelector({
onSelect(first.agent_type) onSelect(first.agent_type)
} }
} }
}) } catch {
.catch(() => { if (!cancelled && requestId === latestRequestId) {
if (!cancelled) {
setAgents([]) setAgents([])
onAgentsLoaded?.([]) onAgentsLoaded?.([])
} }
}
}
void reloadAgents()
const onWindowFocus = () => {
void reloadAgents()
}
window.addEventListener("focus", onWindowFocus)
let unlisten: (() => void) | null = null
void import("@tauri-apps/api/event")
.then(({ listen }) =>
listen(ACP_AGENTS_UPDATED_EVENT, () => {
void reloadAgents()
})
)
.then((dispose) => {
if (cancelled) {
dispose()
return
}
unlisten = dispose
}) })
.catch(() => {
// Ignore when non-tauri runtime.
})
return () => { return () => {
cancelled = true cancelled = true
window.removeEventListener("focus", onWindowFocus)
if (unlisten) {
unlisten()
}
} }
}, [defaultAgentType, onAgentsLoaded, onSelect]) }, [defaultAgentType, onAgentsLoaded, onSelect])

View File

@@ -49,6 +49,8 @@ import {
import { Message, MessageContent } from "@/components/ai-elements/message" import { Message, MessageContent } from "@/components/ai-elements/message"
import { ContentPartsRenderer } from "@/components/message/content-parts-renderer" import { ContentPartsRenderer } from "@/components/message/content-parts-renderer"
const ACP_AGENTS_UPDATED_EVENT = "app://acp-agents-updated"
interface WelcomeInputPanelProps { interface WelcomeInputPanelProps {
defaultAgentType?: AgentType defaultAgentType?: AgentType
workingDir?: string workingDir?: string
@@ -56,6 +58,11 @@ interface WelcomeInputPanelProps {
isActive?: boolean isActive?: boolean
} }
interface AgentsUpdatedEventPayload {
reason?: string
agent_type?: AgentType | null
}
function normalizeErrorMessage(error: unknown): string { function normalizeErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message if (error instanceof Error) return error.message
return String(error) return String(error)
@@ -318,6 +325,7 @@ export function WelcomeInputPanel({
// the DB conversation ID and the ACP session ID are available. // the DB conversation ID and the ACP session ID are available.
const externalIdSavedRef = useRef(false) const externalIdSavedRef = useRef(false)
const sessionIdRef = useRef<string | null>(null) const sessionIdRef = useRef<string | null>(null)
const refreshingCurrentAgentRef = useRef(false)
useEffect(() => { useEffect(() => {
if (connSessionId) { if (connSessionId) {
sessionIdRef.current = connSessionId sessionIdRef.current = connSessionId
@@ -348,6 +356,75 @@ export function WelcomeInputPanel({
const isConnecting = const isConnecting =
connStatus === "connecting" || connStatus === "downloading" connStatus === "connecting" || connStatus === "downloading"
useEffect(() => {
let cancelled = false
let unlisten: (() => void) | null = null
const syncCurrentAgentStatus = async () => {
if (cancelled) return
if (phase !== "welcome") return
if (!workingDir) return
if (refreshingCurrentAgentRef.current) return
if (connStatus === "prompting" || isConnecting) return
refreshingCurrentAgentRef.current = true
try {
setAgentConnectError(null)
if (connStatus === "connected") {
await connDisconnect()
}
await connConnect(selectedAgentRef.current, workingDir, undefined, {
source: "auto_link",
})
if (!cancelled) {
setAgentConnectError(null)
}
} catch (error) {
if (!cancelled) {
setAgentConnectError(normalizeErrorMessage(error))
}
if (!isExpectedAutoLinkError(error)) {
console.error("[WelcomePanel] refresh current agent status:", error)
}
} finally {
refreshingCurrentAgentRef.current = false
}
}
void import("@tauri-apps/api/event")
.then(({ listen }) =>
listen<AgentsUpdatedEventPayload>(ACP_AGENTS_UPDATED_EVENT, (event) => {
if (cancelled) return
if (event.payload?.reason === "agent_reordered") return
const changedAgentType = event.payload?.agent_type
if (
changedAgentType &&
changedAgentType !== selectedAgentRef.current
) {
return
}
void syncCurrentAgentStatus()
})
)
.then((dispose) => {
if (cancelled) {
dispose()
return
}
unlisten = dispose
})
.catch(() => {
// Ignore when non-tauri runtime.
})
return () => {
cancelled = true
if (unlisten) {
unlisten()
}
}
}, [connConnect, connDisconnect, connStatus, isConnecting, phase, workingDir])
const prevStatusRef = useRef(connStatus) const prevStatusRef = useRef(connStatus)
// Accumulate history when prompting completes // Accumulate history when prompting completes

View File

@@ -34,6 +34,7 @@ interface AppI18nContextValue {
} }
const AppI18nContext = createContext<AppI18nContextValue | null>(null) const AppI18nContext = createContext<AppI18nContextValue | null>(null)
const LANGUAGE_SETTINGS_UPDATED_EVENT = "app://language-settings-updated"
function subscribeSystemLocale(onStoreChange: () => void) { function subscribeSystemLocale(onStoreChange: () => void) {
if (typeof window === "undefined") return () => {} if (typeof window === "undefined") return () => {}
@@ -128,6 +129,57 @@ export function AppI18nProvider({
[] []
) )
useEffect(() => {
if (typeof window === "undefined") return
const onStorage = (event: StorageEvent) => {
if (event.key !== LANGUAGE_SETTINGS_STORAGE_KEY || !event.newValue) return
try {
const next = normalizeLanguageSettings(
JSON.parse(event.newValue) as SystemLanguageSettings
)
setLanguageSettingsState(next)
} catch {
// Ignore malformed storage payloads.
}
}
window.addEventListener("storage", onStorage)
let unlisten: (() => void) | null = null
let cancelled = false
void import("@tauri-apps/api/event")
.then(({ listen }) =>
listen<SystemLanguageSettings>(
LANGUAGE_SETTINGS_UPDATED_EVENT,
(event) => {
if (cancelled) return
setLanguageSettings(event.payload)
}
)
)
.then((dispose) => {
if (cancelled) {
dispose()
return
}
unlisten = dispose
})
.catch(() => {
// Ignore when running in non-tauri environment.
})
return () => {
cancelled = true
window.removeEventListener("storage", onStorage)
if (unlisten) {
unlisten()
}
}
}, [setLanguageSettings])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false