feat(config): 增加 Feishu 渠道支持及 MCP 服务器管理界面
- 为配置中的 Channel 添加对 Feishu 配置的可变访问接口 - 在 HTTP 掩码和恢复中支持 Feishu 的 app_secret 字段处理 - 在前端配置页面新增 MCP 服务器配置面板,支持多类型参数编辑 - 在前端配置页面新增渠道管理面板,支持 Feishu 和 Wechat 渠道的增删改查 - 优化配置保存后的提示与刷新,提示需要重启服务生效 - 更新模型配置字段提示,使说明更加友好和明确 - 增加界面图标及样式调整,提升用户操作体验
This commit is contained in:
parent
37f417007e
commit
66e40fc714
@ -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),
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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<string, string>
|
||||
base_url?: string
|
||||
headers?: Record<string, string>
|
||||
description?: string
|
||||
}
|
||||
interface AppConfig {
|
||||
providers: Record<string, ProviderConfig>
|
||||
models: Record<string, ModelConfig>
|
||||
@ -29,8 +41,9 @@ interface AppConfig {
|
||||
memory_maintenance: MemoryMaintenanceConfig
|
||||
image_context: ImageContextConfig
|
||||
subagents: SubagentsConfig
|
||||
client: ClientConfig
|
||||
channels: Record<string, any>
|
||||
mcpServers: Record<string, any>
|
||||
mcpServers: Record<string, McpServerConfig>
|
||||
}
|
||||
|
||||
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) {
|
||||
<MapEntryHeader name={name} onDelete={() => delModel(name)} />
|
||||
<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="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="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="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="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="模型单次回复最大生成 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} placeholder="128000" /></Field>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -421,6 +439,136 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
</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 = () => {
|
||||
switch (activeTab) {
|
||||
case 'connection': return renderConnection()
|
||||
@ -435,6 +583,8 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
case 'memory': return renderMemory()
|
||||
case 'image': return renderImage()
|
||||
case 'subagents': return renderSubagents()
|
||||
case 'mcp': return renderMcp()
|
||||
case 'channels': return renderChannels()
|
||||
}
|
||||
}
|
||||
|
||||
@ -506,7 +656,7 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
{/* 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]">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user