feat(config): 增加 Feishu 渠道支持及 MCP 服务器管理界面

- 为配置中的 Channel 添加对 Feishu 配置的可变访问接口
- 在 HTTP 掩码和恢复中支持 Feishu 的 app_secret 字段处理
- 在前端配置页面新增 MCP 服务器配置面板,支持多类型参数编辑
- 在前端配置页面新增渠道管理面板,支持 Feishu 和 Wechat 渠道的增删改查
- 优化配置保存后的提示与刷新,提示需要重启服务生效
- 更新模型配置字段提示,使说明更加友好和明确
- 增加界面图标及样式调整,提升用户操作体验
This commit is contained in:
oudecheng 2026-06-16 14:57:22 +08:00
parent 37f417007e
commit 66e40fc714
3 changed files with 187 additions and 8 deletions

View File

@ -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> { pub fn as_wechat(&self) -> Option<&WechatChannelConfig> {
match self { match self {
Self::Tagged(TaggedChannelConfig::Wechat(config)) => Some(config), Self::Tagged(TaggedChannelConfig::Wechat(config)) => Some(config),

View File

@ -30,6 +30,14 @@ fn mask_config(config: &Config) -> Config {
provider.api_key = format!("{}{}", visible, API_KEY_MASK); 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 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 // Validate timezone
if let Err(e) = new_config.time.parse_timezone() { if let Err(e) = new_config.time.parse_timezone() {

View File

@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, type ReactNode } from 'react'
import { import {
Settings, Server, Cpu, Bot, Clock, Calendar, Wrench, Brain, Image, Settings, Server, Cpu, Bot, Clock, Calendar, Wrench, Brain, Image,
Users, Save, X, Plus, Trash2, AlertTriangle, Loader2, Wifi, Users, Save, X, Plus, Trash2, AlertTriangle, Loader2, Wifi,
CheckCircle, Plug, Radio,
} from 'lucide-react' } from 'lucide-react'
// ── Types ────────────────────────────────────────────── // ── 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 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 ImageContextConfig { max_images_in_context: number; max_image_age_rounds: number }
interface SubagentsConfig { enabled: boolean; sources: string[] } 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<string, string>
base_url?: string
headers?: Record<string, string>
description?: string
}
interface AppConfig { interface AppConfig {
providers: Record<string, ProviderConfig> providers: Record<string, ProviderConfig>
models: Record<string, ModelConfig> models: Record<string, ModelConfig>
@ -29,8 +41,9 @@ interface AppConfig {
memory_maintenance: MemoryMaintenanceConfig memory_maintenance: MemoryMaintenanceConfig
image_context: ImageContextConfig image_context: ImageContextConfig
subagents: SubagentsConfig subagents: SubagentsConfig
client: ClientConfig
channels: Record<string, any> channels: Record<string, any>
mcpServers: Record<string, any> mcpServers: Record<string, McpServerConfig>
} }
interface ConfigPageProps { interface ConfigPageProps {
@ -38,7 +51,7 @@ interface ConfigPageProps {
onSaveConnection?: (host: string, port: number) => void 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 }[] = [ const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [
{ id: 'connection', label: '连接', icon: Wifi }, { id: 'connection', label: '连接', icon: Wifi },
@ -53,6 +66,8 @@ const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [
{ id: 'memory', label: '记忆维护', icon: Users }, { id: 'memory', label: '记忆维护', icon: Users },
{ id: 'image', label: '图片上下文', icon: Image }, { id: 'image', label: '图片上下文', icon: Image },
{ id: 'subagents', label: '子代理', icon: Bot }, { id: 'subagents', label: '子代理', icon: Bot },
{ id: 'mcp', label: 'MCP 服务器', icon: Plug },
{ id: 'channels', label: '渠道', icon: Radio },
] ]
// ── Shared UI primitives ─────────────────────────────── // ── Shared UI primitives ───────────────────────────────
@ -181,9 +196,12 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
}) })
const data = await resp.json() const data = await resp.json()
if (!resp.ok) throw new Error(data.message || data.error || '保存失败') 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) setDirty(false)
setTimeout(() => setToast(''), 4000) setTimeout(() => setToast(''), 5000)
} catch (e: any) { } catch (e: any) {
setError(e.message || '保存失败') setError(e.message || '保存失败')
} finally { } finally {
@ -304,9 +322,9 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
<MapEntryHeader name={name} onDelete={() => delModel(name)} /> <MapEntryHeader name={name} onDelete={() => delModel(name)} />
<div className="p-4 space-y-3"> <div className="p-4 space-y-3">
<Field label="Model ID"><input value={m.model_id} onChange={e => updModel(name, { model_id: e.target.value })} className={inputCls} /></Field> <Field label="Model ID"><input value={m.model_id} onChange={e => updModel(name, { model_id: e.target.value })} className={inputCls} /></Field>
<Field label="Temperature" hint="留空使用默认"><input type="number" step="0.1" value={m.temperature ?? ''} onChange={e => updModel(name, { temperature: e.target.value ? +e.target.value : undefined })} className={inputCls} placeholder="0.7" /></Field> <Field label="Temperature" hint="控制回复随机性0 表示确定性输出,值越大越随机。留空使用模型默认"><input type="number" step="0.1" value={m.temperature ?? ''} onChange={e => updModel(name, { temperature: e.target.value ? +e.target.value : undefined })} className={inputCls} placeholder="0.7" /></Field>
<Field label="Max Tokens" hint="留空使用默认"><input type="number" value={m.max_tokens ?? ''} onChange={e => updModel(name, { max_tokens: e.target.value ? +e.target.value : undefined })} className={inputCls} /></Field> <Field label="Max Tokens" hint="模型单次回复最大生成 token 数,超出会被截断。留空使用模型默认值(如 4096/8192"><input type="number" value={m.max_tokens ?? ''} onChange={e => updModel(name, { max_tokens: e.target.value ? +e.target.value : undefined })} className={inputCls} placeholder="4096" /></Field>
<Field label="Context Window Tokens" hint="留空使用默认 (128000)"><input type="number" value={m.context_window_tokens ?? ''} onChange={e => updModel(name, { context_window_tokens: e.target.value ? +e.target.value : undefined })} className={inputCls} /></Field> <Field label="Context Window Tokens" hint="模型上下文窗口大小,用于内部历史消息压缩/裁剪计算。留空默认 128000"><input type="number" value={m.context_window_tokens ?? ''} onChange={e => updModel(name, { context_window_tokens: e.target.value ? +e.target.value : undefined })} className={inputCls} placeholder="128000" /></Field>
</div> </div>
</div> </div>
))} ))}
@ -421,6 +439,136 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
</div> </div>
) )
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<McpServerConfig>) => update('mcpServers', { ...config.mcpServers, [name]: { ...config.mcpServers[name], ...patch } })
return (
<div className="space-y-4">
{entries.map(([name, s]) => (
<div key={name} className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
<MapEntryHeader name={name} onDelete={() => delMcp(name)} />
<div className="p-4 space-y-3">
<Field label="传输类型">
<select value={s.type} onChange={e => updMcp(name, { type: e.target.value as McpServerConfig['type'] })} className={selectCls}>
<option value="stdio">stdio ()</option>
<option value="streamableHttp">streamableHttp (HTTP)</option>
</select>
</Field>
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]"></span><Toggle checked={s.is_active} onChange={v => updMcp(name, { is_active: v })} /></div>
<Field label="描述"><input value={s.description ?? ''} onChange={e => updMcp(name, { description: e.target.value || undefined })} className={inputCls} placeholder="可选描述" /></Field>
{s.type === 'stdio' && (
<>
<Field label="命令" hint="如 npx, node, cargo"><input value={s.command ?? ''} onChange={e => updMcp(name, { command: e.target.value })} className={inputCls} placeholder="npx" /></Field>
<Field label="参数" hint="空格分隔"><input value={(s.args ?? []).join(' ')} onChange={e => updMcp(name, { args: e.target.value ? e.target.value.split(/\s+/) : [] })} className={inputCls} placeholder="-y @modelcontextprotocol/server-filesystem /tmp" /></Field>
<Field label="环境变量" hint="KEY=VALUE每行一个">
<textarea
value={Object.entries(s.env ?? {}).map(([k, v]) => `${k}=${v}`).join('\n')}
onChange={e => {
const lines = e.target.value.split('\n').filter(l => l.includes('='))
const env: Record<string, string> = {}
lines.forEach(l => { const [k, ...rest] = l.split('='); if (k) env[k.trim()] = rest.join('=').trim() })
updMcp(name, { env: Object.keys(env).length > 0 ? env : undefined })
}}
className={inputCls + ' min-h-[60px] resize-y font-mono text-xs'}
placeholder="API_KEY=xxx"
/>
</Field>
</>
)}
{(s.type === 'streamableHttp' || s.type === 'http') && (
<>
<Field label="Base URL"><input value={s.base_url ?? ''} onChange={e => updMcp(name, { base_url: e.target.value })} className={inputCls} placeholder="http://localhost:3000/mcp" /></Field>
<Field label="请求头" hint="KEY=VALUE每行一个支持 ${ENV_VAR}">
<textarea
value={Object.entries(s.headers ?? {}).map(([k, v]) => `${k}=${v}`).join('\n')}
onChange={e => {
const lines = e.target.value.split('\n').filter(l => l.includes('='))
const headers: Record<string, string> = {}
lines.forEach(l => { const [k, ...rest] = l.split('='); if (k) headers[k.trim()] = rest.join('=').trim() })
updMcp(name, { headers: Object.keys(headers).length > 0 ? headers : undefined })
}}
className={inputCls + ' min-h-[60px] resize-y font-mono text-xs'}
placeholder="Authorization=Bearer ${TOKEN}"
/>
</Field>
</>
)}
</div>
</div>
))}
<button onClick={addMcp} className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-dashed border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:border-[var(--accent-cyan)]/30 transition-colors text-sm w-full justify-center">
<Plus className="h-4 w-4" /> MCP
</button>
</div>
)
}
const renderChannels = () => {
const entries = Object.entries(config.channels)
const addChannel = () => {
const name = prompt('渠道名称:')?.trim()
if (name && !config.channels[name]) {
update('channels', { ...config.channels, [name]: { type: 'feishu', enabled: false, app_id: '', app_secret: '' } })
}
}
const delChannel = (name: string) => { if (confirm(`删除渠道 "${name}"?`)) { const { [name]: _, ...rest } = config.channels; update('channels', rest) } }
const updChannel = (name: string, patch: Record<string, any>) => update('channels', { ...config.channels, [name]: { ...config.channels[name], ...patch } })
const getChannelType = (ch: any): string => {
if (ch.type) return ch.type
if (ch.app_id !== undefined || ch.app_secret !== undefined) return 'feishu'
if (ch.cred_path !== undefined) return 'wechat'
return 'feishu'
}
return (
<div className="space-y-4">
{entries.map(([name, ch]) => {
const chType = getChannelType(ch)
return (
<div key={name} className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
<MapEntryHeader name={name} onDelete={() => delChannel(name)} />
<div className="p-4 space-y-3">
<Field label="渠道类型">
<select value={chType} onChange={e => updChannel(name, { type: e.target.value })} className={selectCls}>
<option value="feishu"> (Feishu)</option>
<option value="wechat"> (WeChat)</option>
</select>
</Field>
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]"></span><Toggle checked={!!ch.enabled} onChange={v => updChannel(name, { enabled: v })} /></div>
{chType === 'feishu' && (
<>
<Field label="App ID"><input value={ch.app_id ?? ''} onChange={e => updChannel(name, { app_id: e.target.value })} className={inputCls} /></Field>
<Field label="App Secret"><input type="password" value={ch.app_secret ?? ''} onChange={e => updChannel(name, { app_secret: e.target.value })} className={inputCls} /></Field>
<Field label="绑定 Agent" hint="留空使用 default"><input value={ch.agent ?? ''} onChange={e => updChannel(name, { agent: e.target.value })} className={inputCls} placeholder="default" /></Field>
<Field label="最大消息字符数"><input type="number" value={ch.max_message_chars ?? 20000} onChange={e => updChannel(name, { max_message_chars: +e.target.value })} className={inputCls} /></Field>
<Field label="回复上下文最大字符数"><input type="number" value={ch.reply_context_max_chars ?? 20000} onChange={e => updChannel(name, { reply_context_max_chars: +e.target.value })} className={inputCls} /></Field>
</>
)}
{chType === 'wechat' && (
<>
<Field label="凭证文件路径"><input value={ch.cred_path ?? ''} onChange={e => updChannel(name, { cred_path: e.target.value })} className={inputCls} placeholder="~/.picobot/wechat/credentials.json" /></Field>
<Field label="Base URL"><input value={ch.base_url ?? 'https://ilinkai.weixin.qq.com'} onChange={e => updChannel(name, { base_url: e.target.value })} className={inputCls} /></Field>
<Field label="绑定 Agent" hint="留空使用 default"><input value={ch.agent ?? ''} onChange={e => updChannel(name, { agent: e.target.value })} className={inputCls} placeholder="default" /></Field>
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]"></span><Toggle checked={!!ch.force_login} onChange={v => updChannel(name, { force_login: v })} /></div>
</>
)}
</div>
</div>
)
})}
<button onClick={addChannel} className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-dashed border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:border-[var(--accent-cyan)]/30 transition-colors text-sm w-full justify-center">
<Plus className="h-4 w-4" />
</button>
</div>
)
}
const renderContent = () => { const renderContent = () => {
switch (activeTab) { switch (activeTab) {
case 'connection': return renderConnection() case 'connection': return renderConnection()
@ -435,6 +583,8 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
case 'memory': return renderMemory() case 'memory': return renderMemory()
case 'image': return renderImage() case 'image': return renderImage()
case 'subagents': return renderSubagents() case 'subagents': return renderSubagents()
case 'mcp': return renderMcp()
case 'channels': return renderChannels()
} }
} }
@ -506,7 +656,7 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
{/* Toast */} {/* Toast */}
{toast && ( {toast && (
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2 px-5 py-3 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-400 text-sm shadow-lg backdrop-blur-md animate-[fadeIn_0.2s_ease-out]"> <div className="absolute top-20 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2 px-5 py-3 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-400 text-sm shadow-lg backdrop-blur-md animate-[fadeIn_0.2s_ease-out]">
<AlertTriangle className="h-4 w-4" /> <CheckCircle className="h-4 w-4" />
{toast} {toast}
</div> </div>
)} )}