diff --git a/src/config/mod.rs b/src/config/mod.rs index 6614cba..cf5eead 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -315,6 +315,15 @@ impl ChannelConfig { } } + pub fn as_feishu_mut(&mut self) -> Option<&mut FeishuChannelConfig> { + match self { + Self::Tagged(TaggedChannelConfig::Feishu(config)) | Self::LegacyFeishu(config) => { + Some(config) + } + Self::Tagged(TaggedChannelConfig::Wechat(_)) => None, + } + } + pub fn as_wechat(&self) -> Option<&WechatChannelConfig> { match self { Self::Tagged(TaggedChannelConfig::Wechat(config)) => Some(config), diff --git a/src/gateway/http.rs b/src/gateway/http.rs index 4570412..c9fa1b3 100644 --- a/src/gateway/http.rs +++ b/src/gateway/http.rs @@ -30,6 +30,14 @@ fn mask_config(config: &Config) -> Config { provider.api_key = format!("{}{}", visible, API_KEY_MASK); } } + for channel in masked.channels.values_mut() { + if let Some(feishu) = channel.as_feishu_mut() { + if !feishu.app_secret.is_empty() { + let visible: String = feishu.app_secret.chars().take(4).collect(); + feishu.app_secret = format!("{}{}", visible, API_KEY_MASK); + } + } + } masked } @@ -72,6 +80,18 @@ pub async fn save_config( } } } + // Merge: preserve original app_secrets if the submitted ones are masked + for (name, channel) in new_config.channels.iter_mut() { + if let Some(feishu) = channel.as_feishu_mut() { + if is_masked_key(&feishu.app_secret) { + if let Some(original_channel) = state.config.channels.get(name) { + if let Some(original_feishu) = original_channel.as_feishu() { + feishu.app_secret = original_feishu.app_secret.clone(); + } + } + } + } + } // Validate timezone if let Err(e) = new_config.time.parse_timezone() { diff --git a/web/src/components/Settings/ConfigPage.tsx b/web/src/components/Settings/ConfigPage.tsx index 7cd752d..7d8afbb 100644 --- a/web/src/components/Settings/ConfigPage.tsx +++ b/web/src/components/Settings/ConfigPage.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, type ReactNode } from 'react' import { Settings, Server, Cpu, Bot, Clock, Calendar, Wrench, Brain, Image, Users, Save, X, Plus, Trash2, AlertTriangle, Loader2, Wifi, + CheckCircle, Plug, Radio, } from 'lucide-react' // ── Types ────────────────────────────────────────────── @@ -17,6 +18,17 @@ interface ToolsConfig { disabled: string[]; task: TaskConfig } interface MemoryMaintenanceConfig { max_merge_ratio: number; min_memories_to_keep: number; max_merge_per_group: number } interface ImageContextConfig { max_images_in_context: number; max_image_age_rounds: number } interface SubagentsConfig { enabled: boolean; sources: string[] } +interface ClientConfig { gateway_url: string } +interface McpServerConfig { + type: 'stdio' | 'streamableHttp' | 'http' + is_active: boolean + command?: string + args?: string[] + env?: Record + base_url?: string + headers?: Record + description?: string +} interface AppConfig { providers: Record models: Record @@ -29,8 +41,9 @@ interface AppConfig { memory_maintenance: MemoryMaintenanceConfig image_context: ImageContextConfig subagents: SubagentsConfig + client: ClientConfig channels: Record - mcpServers: Record + mcpServers: Record } interface ConfigPageProps { @@ -38,7 +51,7 @@ interface ConfigPageProps { onSaveConnection?: (host: string, port: number) => void } -type TabId = 'connection' | 'gateway' | 'providers' | 'models' | 'agents' | 'time' | 'scheduler' | 'skills' | 'tools' | 'memory' | 'image' | 'subagents' +type TabId = 'connection' | 'gateway' | 'providers' | 'models' | 'agents' | 'time' | 'scheduler' | 'skills' | 'tools' | 'memory' | 'image' | 'subagents' | 'mcp' | 'channels' const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [ { id: 'connection', label: '连接', icon: Wifi }, @@ -53,6 +66,8 @@ const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [ { id: 'memory', label: '记忆维护', icon: Users }, { id: 'image', label: '图片上下文', icon: Image }, { id: 'subagents', label: '子代理', icon: Bot }, + { id: 'mcp', label: 'MCP 服务器', icon: Plug }, + { id: 'channels', label: '渠道', icon: Radio }, ] // ── Shared UI primitives ─────────────────────────────── @@ -181,9 +196,12 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { }) const data = await resp.json() if (!resp.ok) throw new Error(data.message || data.error || '保存失败') - setToast(data.message || '配置已保存') + // Reload config from server to get masked values + const refreshed = await fetch('/api/config').then(r => r.json()) + setConfig(refreshed) + setToast(data.message || '配置已保存,需要重启服务才能生效') setDirty(false) - setTimeout(() => setToast(''), 4000) + setTimeout(() => setToast(''), 5000) } catch (e: any) { setError(e.message || '保存失败') } finally { @@ -304,9 +322,9 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { delModel(name)} />
updModel(name, { model_id: e.target.value })} className={inputCls} /> - updModel(name, { temperature: e.target.value ? +e.target.value : undefined })} className={inputCls} placeholder="0.7" /> - updModel(name, { max_tokens: e.target.value ? +e.target.value : undefined })} className={inputCls} /> - updModel(name, { context_window_tokens: e.target.value ? +e.target.value : undefined })} className={inputCls} /> + updModel(name, { temperature: e.target.value ? +e.target.value : undefined })} className={inputCls} placeholder="0.7" /> + updModel(name, { max_tokens: e.target.value ? +e.target.value : undefined })} className={inputCls} placeholder="4096" /> + updModel(name, { context_window_tokens: e.target.value ? +e.target.value : undefined })} className={inputCls} placeholder="128000" />
))} @@ -421,6 +439,136 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { ) + const renderMcp = () => { + const entries = Object.entries(config.mcpServers) + const addMcp = () => { + const name = prompt('MCP 服务器名称:')?.trim() + if (name && !config.mcpServers[name]) { + update('mcpServers', { ...config.mcpServers, [name]: { type: 'stdio', is_active: true, command: '', args: [] } }) + } + } + const delMcp = (name: string) => { if (confirm(`删除 MCP 服务器 "${name}"?`)) { const { [name]: _, ...rest } = config.mcpServers; update('mcpServers', rest) } } + const updMcp = (name: string, patch: Partial) => update('mcpServers', { ...config.mcpServers, [name]: { ...config.mcpServers[name], ...patch } }) + return ( +
+ {entries.map(([name, s]) => ( +
+ delMcp(name)} /> +
+ + + +
启用 updMcp(name, { is_active: v })} />
+ updMcp(name, { description: e.target.value || undefined })} className={inputCls} placeholder="可选描述" /> + {s.type === 'stdio' && ( + <> + updMcp(name, { command: e.target.value })} className={inputCls} placeholder="npx" /> + updMcp(name, { args: e.target.value ? e.target.value.split(/\s+/) : [] })} className={inputCls} placeholder="-y @modelcontextprotocol/server-filesystem /tmp" /> + +