feat(gateway): 添加配置管理功能支持敏感信息脱敏

- 实现 API 端点 /api/config 用于获取和保存配置
- 添加配置信息脱敏功能,保护 API 密钥等敏感数据
- 集成配置验证逻辑,确保时区等参数有效性
- 在前端添加完整的配置管理页面界面
- 实现配置项的动态编辑和保存功能
- 添加连接设置功能用于 WebSocket 连接配置
- 提供多标签页界面分别管理不同配置模块
- 实现配置变更后的实时预览和保存确认
This commit is contained in:
oudecheng 2026-06-15 17:22:32 +08:00
parent 027e8661bc
commit 37f417007e
7 changed files with 728 additions and 19 deletions

108
convert_pdf.py Normal file
View File

@ -0,0 +1,108 @@
import fitz
from docx import Document
from docx.shared import Pt, Cm, RGBColor
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
pdf_path = r'C:\Users\qwer\.picobot\media\ws\aa56c052-ea10-4bc1-aed4-7d06770b6fd9_夜读 _ 明白了这4点就不难养出有主体性的孩子.pdf'
output_path = r'C:\Users\qwer\.picobot\media\夜读_明白了这4点_就不难养出有主体性的孩子.docx'
pdf_doc = fitz.open(pdf_path)
doc = Document()
# 页面边距
for section in doc.sections:
section.top_margin = Cm(2.54)
section.bottom_margin = Cm(2.54)
section.left_margin = Cm(3.18)
section.right_margin = Cm(3.18)
# 正文样式
style = doc.styles['Normal']
style.font.name = '宋体'
style.font.size = Pt(12)
style.paragraph_format.line_spacing = 1.5
style.paragraph_format.first_line_indent = Pt(24)
def add_run(paragraph, text, bold=False, size=None, color=None, italic=False, font_name=None):
run = paragraph.add_run(text)
run.bold = bold
if size: run.font.size = Pt(size)
if color: run.font.color.rgb = RGBColor(*color)
run.italic = italic
if font_name: run.font.name = font_name
return run
# 收集所有文本
full_text = []
for i, page in enumerate(pdf_doc):
text = page.get_text().strip()
if text:
full_text.append(text)
all_text = '\n'.join(full_text)
lines = [l.strip() for l in all_text.split('\n') if l.strip()]
# 定义段落标记
sections_headers = ['塑教育,提倡积极养育', '懂互动,给予丰盈幸福',
'有边界,养出人生底气', '稳情绪,才能赢得孩子']
skip_lines = ['南方都市报电商官方账号。', '南都甄选', '公众号']
first = True
for line in lines:
# 跳过广告行
if line in skip_lines:
continue
# 主标题
if first:
p = doc.add_paragraph()
p.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
p.paragraph_format.first_line_indent = Pt(0)
p.paragraph_format.space_after = Pt(12)
add_run(p, line, bold=True, size=22, font_name='黑体')
first = False
# 引用句(引号开头结尾、较短)
elif (line.startswith('"') and line.endswith('"')) or \
(line.startswith('"') and line.endswith('"') and len(line) < 60):
p = doc.add_paragraph()
p.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
p.paragraph_format.first_line_indent = Pt(0)
p.paragraph_format.space_before = Pt(6)
p.paragraph_format.space_after = Pt(6)
add_run(p, line, italic=True, size=12, color=(102, 102, 102))
# 日期来源
elif line in ['2026年6月14日 22:28 广东', '南方都市报']:
p = doc.add_paragraph()
p.alignment = WD_PARAGRAPH_ALIGNMENT.RIGHT
p.paragraph_format.first_line_indent = Pt(0)
add_run(p, line, size=10.5, color=(128, 128, 128))
# 4个小标题
elif line in sections_headers:
p = doc.add_paragraph()
p.paragraph_format.first_line_indent = Pt(0)
p.paragraph_format.space_before = Pt(18)
p.paragraph_format.space_after = Pt(6)
add_run(p, line, bold=True, size=15, font_name='黑体')
# 作者信息
elif any(line.startswith(x) for x in ['作者:', '统筹:', '图片:', '投稿邮箱:']):
p = doc.add_paragraph()
p.paragraph_format.first_line_indent = Pt(0)
add_run(p, line, size=10.5, color=(128, 128, 128))
# 末尾信息
elif '转载自' in line or '把世界当成' in line:
p = doc.add_paragraph()
p.paragraph_format.first_line_indent = Pt(0)
p.paragraph_format.space_before = Pt(6)
add_run(p, line, size=10.5, color=(102, 102, 102))
else:
doc.add_paragraph(line)
pdf_doc.close()
doc.save(output_path)
print('转换完成!')

View File

@ -843,7 +843,7 @@ impl LLMProviderConfig {
} }
} }
fn get_default_config_path() -> PathBuf { pub(crate) fn get_default_config_path() -> PathBuf {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".picobot").join("config.json") home.join(".picobot").join("config.json")
} }

View File

@ -1,5 +1,10 @@
use axum::Json; use axum::{Json, extract::State};
use serde::Serialize; use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use super::GatewayState;
use crate::config::{Config, get_default_config_path};
#[derive(Serialize)] #[derive(Serialize)]
pub struct HealthResponse { pub struct HealthResponse {
@ -13,3 +18,83 @@ pub async fn health() -> Json<HealthResponse> {
version: env!("CARGO_PKG_VERSION").to_string(), version: env!("CARGO_PKG_VERSION").to_string(),
}) })
} }
const API_KEY_MASK: &str = "••••••••";
/// Mask sensitive fields in config for safe display
fn mask_config(config: &Config) -> Config {
let mut masked = config.clone();
for provider in masked.providers.values_mut() {
if !provider.api_key.is_empty() {
let visible: String = provider.api_key.chars().take(4).collect();
provider.api_key = format!("{}{}", visible, API_KEY_MASK);
}
}
masked
}
/// Check if an api_key value is masked (contains the mask suffix)
fn is_masked_key(value: &str) -> bool {
value.ends_with(API_KEY_MASK)
}
#[derive(Deserialize)]
pub struct SaveConfigRequest {
pub config: Config,
}
#[derive(Serialize)]
pub struct SaveConfigResponse {
pub success: bool,
pub message: String,
pub config_path: String,
}
/// GET /api/config — Return current config with masked sensitive fields
pub async fn get_config(
State(state): State<Arc<GatewayState>>,
) -> Json<Config> {
Json(mask_config(&state.config))
}
/// PUT /api/config — Save config to file, preserving original api_keys if masked
pub async fn save_config(
State(state): State<Arc<GatewayState>>,
Json(req): Json<SaveConfigRequest>,
) -> Result<Json<SaveConfigResponse>, (StatusCode, String)> {
// Merge: preserve original api_keys if the submitted ones are masked
let mut new_config = req.config;
for (name, provider) in new_config.providers.iter_mut() {
if is_masked_key(&provider.api_key) {
// Restore original api_key
if let Some(original) = state.config.providers.get(name) {
provider.api_key = original.api_key.clone();
}
}
}
// Validate timezone
if let Err(e) = new_config.time.parse_timezone() {
return Err((StatusCode::BAD_REQUEST, format!("Invalid timezone: {}", e)));
}
// Determine config file path
let config_path = std::env::var("CONFIG_PATH")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| get_default_config_path());
// Serialize and write
let json = serde_json::to_string_pretty(&new_config)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Serialize error: {}", e)))?;
std::fs::write(&config_path, json)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Write error: {}", e)))?;
tracing::info!(path = %config_path.display(), "Config saved via API");
Ok(Json(SaveConfigResponse {
success: true,
message: "配置已保存,需要重启服务才能生效".to_string(),
config_path: config_path.to_string_lossy().to_string(),
}))
}

View File

@ -200,6 +200,7 @@ pub async fn run(
let app = if use_embedded { let app = if use_embedded {
Router::new() Router::new()
.route("/health", routing::get(http::health)) .route("/health", routing::get(http::health))
.route("/api/config", routing::get(http::get_config).put(http::save_config))
.route("/ws", routing::get(ws::ws_handler)) .route("/ws", routing::get(ws::ws_handler))
.fallback(static_handler) .fallback(static_handler)
.with_state(state.clone()) .with_state(state.clone())
@ -207,6 +208,7 @@ pub async fn run(
let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "static".to_string()); let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "static".to_string());
Router::new() Router::new()
.route("/health", routing::get(http::health)) .route("/health", routing::get(http::health))
.route("/api/config", routing::get(http::get_config).put(http::save_config))
.route("/ws", routing::get(ws::ws_handler)) .route("/ws", routing::get(ws::ws_handler))
.fallback_service(ServeDir::new(&static_dir)) .fallback_service(ServeDir::new(&static_dir))
.with_state(state.clone()) .with_state(state.clone())

View File

@ -1,12 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Zap, ArrowLeft, Bot, Clock, Sun, Moon, PanelRightOpen, X, Brain, Wifi } from 'lucide-react' import { Zap, ArrowLeft, Bot, Clock, Sun, Moon, PanelRightOpen, X, Brain, Settings as SettingsIcon } from 'lucide-react'
import { ChatContainer } from './components/Chat/ChatContainer' import { ChatContainer } from './components/Chat/ChatContainer'
import { TopicList } from './components/Sidebar/TopicList' import { TopicList } from './components/Sidebar/TopicList'
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList' import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
import { MemoryPanel } from './components/Panel/MemoryPanel' import { MemoryPanel } from './components/Panel/MemoryPanel'
import { SkillList } from './components/Panel/SkillList' import { SkillList } from './components/Panel/SkillList'
import { TodoPanel } from './components/Panel/TodoPanel' import { TodoPanel } from './components/Panel/TodoPanel'
import { SettingsModal, getGatewaySettings, buildWsUrl, type GatewaySettings } from './components/Settings/SettingsModal' import { getGatewaySettings, buildWsUrl, type GatewaySettings } from './components/Settings/SettingsModal'
import { ConfigPage } from './components/Settings/ConfigPage'
import { ConnectionStatus } from './components/ConnectionStatus' import { ConnectionStatus } from './components/ConnectionStatus'
import { ChannelSelector } from './components/Header/ChannelSelector' import { ChannelSelector } from './components/Header/ChannelSelector'
import { SessionSelector } from './components/Header/SessionSelector' import { SessionSelector } from './components/Header/SessionSelector'
@ -134,11 +135,10 @@ function App() {
return localStorage.getItem('picobot-show-thinking') !== 'false' return localStorage.getItem('picobot-show-thinking') !== 'false'
}) })
const [settingsOpen, setSettingsOpen] = useState(false) const [configPageOpen, setConfigPageOpen] = useState(false)
const handleSaveGatewaySettings = useCallback((newSettings: GatewaySettings) => { const handleSaveConnection = useCallback((host: string, port: number) => {
setGatewaySettings(newSettings) setGatewaySettings({ host, port })
setSettingsOpen(false)
}, []) }, [])
useEffect(() => { useEffect(() => {
@ -503,12 +503,12 @@ function App() {
<Brain className="h-4 w-4" /> <Brain className="h-4 w-4" />
</button> </button>
<button <button
onClick={() => setSettingsOpen(true)} onClick={() => setConfigPageOpen(true)}
className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-all" className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-all"
title="连接设置" title="系统配置"
aria-label="Connection settings" aria-label="System config"
> >
<Wifi className="h-4 w-4" /> <SettingsIcon className="h-4 w-4" />
</button> </button>
</div> </div>
<div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]"> <div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
@ -732,12 +732,9 @@ function App() {
sendCommand={sendMemoryCommand} sendCommand={sendMemoryCommand}
/> />
{/* 连接设置弹窗 */} {/* 系统配置页面 */}
{settingsOpen && ( {configPageOpen && (
<SettingsModal <ConfigPage onClose={() => setConfigPageOpen(false)} onSaveConnection={handleSaveConnection} />
onClose={() => setSettingsOpen(false)}
onSave={handleSaveGatewaySettings}
/>
)} )}
</div> </div>
) )

View File

@ -0,0 +1,516 @@
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,
} from 'lucide-react'
// ── Types ──────────────────────────────────────────────
interface ProviderConfig { type: string; base_url: string; api_key: string; extra_headers: Record<string, string>; llm_timeout_secs: number; memory_maintenance_timeout_secs: number }
interface ModelConfig { model_id: string; temperature?: number; max_tokens?: number; context_window_tokens?: number }
interface AgentConfig { provider: string; model: string; max_tool_iterations: number; tool_result_max_chars: number; context_tool_result_trim_chars: number }
interface GatewayConfig { host: string; port: number; show_tool_results: boolean; agent_prompt_reinject_every: number; max_concurrent_requests: number; session_ttl_hours?: number }
interface TimeConfig { timezone: string }
interface SchedulerConfig { enabled: boolean; tick_resolution_ms: number; worker_queue_capacity: number; misfire_policy: 'skip' | 'catch_up'; jobs?: any[] }
interface SkillsConfig { enabled: boolean; sources: string[]; max_index_chars: number; max_listed_skills: number }
interface TaskConfig { enabled: boolean; max_execution_secs: number; explore_max_execution_secs: number; ttl_hours: number; allowed_tools: string[] }
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 AppConfig {
providers: Record<string, ProviderConfig>
models: Record<string, ModelConfig>
agents: Record<string, AgentConfig>
time: TimeConfig
gateway: GatewayConfig
scheduler: SchedulerConfig
skills: SkillsConfig
tools: ToolsConfig
memory_maintenance: MemoryMaintenanceConfig
image_context: ImageContextConfig
subagents: SubagentsConfig
channels: Record<string, any>
mcpServers: Record<string, any>
}
interface ConfigPageProps {
onClose: () => void
onSaveConnection?: (host: string, port: number) => void
}
type TabId = 'connection' | 'gateway' | 'providers' | 'models' | 'agents' | 'time' | 'scheduler' | 'skills' | 'tools' | 'memory' | 'image' | 'subagents'
const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [
{ id: 'connection', label: '连接', icon: Wifi },
{ id: 'gateway', label: '网关', icon: Server },
{ id: 'providers', label: '服务商', icon: Cpu },
{ id: 'models', label: '模型', icon: Brain },
{ id: 'agents', label: '代理', icon: Bot },
{ id: 'time', label: '时间', icon: Clock },
{ id: 'scheduler', label: '调度器', icon: Calendar },
{ id: 'skills', label: '技能', icon: Wrench },
{ id: 'tools', label: '工具', icon: Settings },
{ id: 'memory', label: '记忆维护', icon: Users },
{ id: 'image', label: '图片上下文', icon: Image },
{ id: 'subagents', label: '子代理', icon: Bot },
]
// ── Shared UI primitives ───────────────────────────────
function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: string }) {
return (
<div className="space-y-1.5">
<label className="block text-[13px] font-medium text-[var(--text-secondary)]">{label}</label>
{children}
{hint && <p className="text-xs text-[var(--text-muted)]">{hint}</p>}
</div>
)
}
const inputCls = "w-full px-3 py-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-1 focus:ring-[var(--focus-ring)] transition-colors"
const selectCls = inputCls
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors duration-200 ${checked ? 'bg-[var(--accent-cyan)]' : 'bg-[var(--bg-hover)]'}`}
>
<span className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 mt-0.5 ${checked ? 'translate-x-5.5 ml-[22px]' : 'translate-x-0.5 ml-[2px]'}`} />
</button>
)
}
function TagEditor({ tags, onChange }: { tags: string[]; onChange: (t: string[]) => void }) {
const [input, setInput] = useState('')
const add = () => { const v = input.trim(); if (v && !tags.includes(v)) { onChange([...tags, v]); setInput('') } }
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-1.5">
{tags.map((t, i) => (
<span key={i} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)]">
{t}
<button onClick={() => onChange(tags.filter((_, j) => j !== i))} className="hover:text-white transition-colors"><X className="h-3 w-3" /></button>
</span>
))}
</div>
<div className="flex gap-2">
<input value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), add())} placeholder="输入后按 Enter" className={inputCls + ' !text-xs'} />
<button onClick={add} className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs">
<Plus className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
function SectionCard({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
<div className="px-4 py-2.5 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/30">
<h3 className="text-sm font-medium text-[var(--text-secondary)]">{title}</h3>
</div>
<div className="p-4 space-y-4">{children}</div>
</div>
)
}
function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete: () => void; onRename?: (n: string) => void }) {
const [editing, setEditing] = useState(false)
const [val, setVal] = useState(name)
return (
<div className="flex items-center gap-2 px-4 py-2 bg-[var(--bg-tertiary)]/50 border-b border-[var(--border-color)]">
{editing ? (
<input value={val} onChange={e => setVal(e.target.value)} onBlur={() => { setEditing(false); onRename?.(val.trim() || name) }} onKeyDown={e => e.key === 'Enter' && (setEditing(false), onRename?.(val.trim() || name))} className={inputCls + ' !py-1 !text-xs max-w-[200px]'} autoFocus />
) : (
<span className="text-sm font-mono text-[var(--accent-cyan)] cursor-pointer" onClick={() => onRename && setEditing(true)}>{name}</span>
)}
<div className="flex-1" />
<button onClick={onDelete} className="p-1 rounded text-red-400/60 hover:text-red-400 hover:bg-red-500/10 transition-colors"><Trash2 className="h-3.5 w-3.5" /></button>
</div>
)
}
// ── Main Component ─────────────────────────────────────
export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
const [config, setConfig] = useState<AppConfig | null>(null)
const [activeTab, setActiveTab] = useState<TabId>('gateway')
const [loading, setLoading] = useState(true)
// Connection settings (localStorage-based)
const [connHost, setConnHost] = useState(() => {
try { return localStorage.getItem('picobot-gateway-host') || '127.0.0.1' } catch { return '127.0.0.1' }
})
const [connPort, setConnPort] = useState(() => {
try { const p = parseInt(localStorage.getItem('picobot-gateway-port') || '19876', 10); return isNaN(p) ? 19876 : p } catch { return 19876 }
})
const [connError, setConnError] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [toast, setToast] = useState('')
const [dirty, setDirty] = useState(false)
// Load config
useEffect(() => {
fetch('/api/config').then(r => r.json()).then(data => {
setConfig(data)
setLoading(false)
}).catch(e => { setError('加载配置失败: ' + e.message); setLoading(false) })
}, [])
// ESC to close
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', h)
return () => document.removeEventListener('keydown', h)
}, [onClose])
const update = useCallback(<K extends keyof AppConfig>(key: K, value: AppConfig[K]) => {
setConfig(prev => prev ? { ...prev, [key]: value } : prev)
setDirty(true)
}, [])
const handleSave = async () => {
if (!config) return
setSaving(true); setError('')
try {
const resp = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config }),
})
const data = await resp.json()
if (!resp.ok) throw new Error(data.message || data.error || '保存失败')
setToast(data.message || '配置已保存')
setDirty(false)
setTimeout(() => setToast(''), 4000)
} catch (e: any) {
setError(e.message || '保存失败')
} finally {
setSaving(false)
}
}
// ── Render sections ──────────────────────────────────
if (loading) return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<Loader2 className="h-8 w-8 text-[var(--accent-cyan)] animate-spin" />
</div>
)
if (!config) return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<div className="text-red-400 text-center space-y-3">
<AlertTriangle className="h-10 w-10 mx-auto" />
<p>{error || '加载失败'}</p>
<button onClick={onClose} className="px-4 py-2 rounded-lg bg-[var(--bg-tertiary)] text-sm"></button>
</div>
</div>
)
const handleSaveConnection = () => {
const host = connHost.trim()
if (!host) { setConnError('主机地址不能为空'); return }
if (connPort < 1 || connPort > 65535) { setConnError('端口号必须在 1-65535 之间'); return }
setConnError('')
localStorage.setItem('picobot-gateway-host', host)
localStorage.setItem('picobot-gateway-port', String(connPort))
onSaveConnection?.(host, connPort)
setToast('连接设置已保存,正在重连...')
setTimeout(() => setToast(''), 3000)
}
const renderConnection = () => (
<div className="space-y-5">
<SectionCard title="WebSocket 连接">
<Field label="主机地址"><input value={connHost} onChange={e => { setConnHost(e.target.value); setConnError('') }} className={inputCls} placeholder="127.0.0.1" /></Field>
<Field label="端口号"><input type="number" value={connPort} onChange={e => { setConnPort(+e.target.value); setConnError('') }} min={1} max={65535} className={inputCls} placeholder="19876" /></Field>
{connError && <div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">{connError}</div>}
<div className="text-xs text-[var(--text-muted)] bg-[var(--overlay-dim)] rounded-lg px-3 py-2 font-mono">ws://{connHost.trim() || '...'}:{connPort || '...'}/ws</div>
</SectionCard>
<button onClick={handleSaveConnection} className="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium text-white bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30 hover:bg-[var(--accent-cyan)]/30 transition-all">
<Wifi className="h-4 w-4" />
</button>
</div>
)
const renderGateway = () => (
<div className="space-y-5">
<SectionCard title="连接">
<Field label="主机地址"><input value={config.gateway.host} onChange={e => update('gateway', { ...config.gateway, host: e.target.value })} className={inputCls} /></Field>
<Field label="端口"><input type="number" value={config.gateway.port} onChange={e => update('gateway', { ...config.gateway, port: +e.target.value })} className={inputCls} /></Field>
</SectionCard>
<SectionCard title="行为">
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]"></span><Toggle checked={config.gateway.show_tool_results} onChange={v => update('gateway', { ...config.gateway, show_tool_results: v })} /></div>
<Field label="Agent Prompt 重新注入间隔" hint="每多少轮对话重新注入系统提示"><input type="number" value={config.gateway.agent_prompt_reinject_every} onChange={e => update('gateway', { ...config.gateway, agent_prompt_reinject_every: +e.target.value })} className={inputCls} /></Field>
<Field label="最大并发请求数"><input type="number" value={config.gateway.max_concurrent_requests} onChange={e => update('gateway', { ...config.gateway, max_concurrent_requests: +e.target.value })} className={inputCls} /></Field>
<Field label="Session TTL (小时)" hint="留空表示不过期"><input type="number" value={config.gateway.session_ttl_hours ?? ''} onChange={e => { const v = e.target.value; update('gateway', { ...config.gateway, session_ttl_hours: v ? +v : undefined }) }} className={inputCls} placeholder="24" /></Field>
</SectionCard>
</div>
)
const renderProviders = () => {
const entries = Object.entries(config.providers)
const addProvider = () => {
const name = prompt('Provider 名称:')?.trim()
if (name && !config.providers[name]) {
update('providers', { ...config.providers, [name]: { type: 'openai', base_url: '', api_key: '', extra_headers: {}, llm_timeout_secs: 120, memory_maintenance_timeout_secs: 600 } })
}
}
const delProvider = (name: string) => { if (confirm(`删除 Provider "${name}"?`)) { const { [name]: _, ...rest } = config.providers; update('providers', rest) } }
const renameProvider = (oldName: string, newName: string) => {
if (newName === oldName || !newName) return
const entries = Object.entries(config.providers)
const newMap: Record<string, ProviderConfig> = {}
for (const [k, v] of entries) { newMap[k === oldName ? newName : k] = v }
update('providers', newMap)
}
const updProvider = (name: string, patch: Partial<ProviderConfig>) => {
update('providers', { ...config.providers, [name]: { ...config.providers[name], ...patch } })
}
return (
<div className="space-y-4">
{entries.map(([name, p]) => (
<div key={name} className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
<MapEntryHeader name={name} onDelete={() => delProvider(name)} onRename={n => renameProvider(name, n)} />
<div className="p-4 space-y-3">
<Field label="类型"><select value={p.type} onChange={e => updProvider(name, { type: e.target.value })} className={selectCls}><option value="openai">OpenAI</option><option value="anthropic">Anthropic</option></select></Field>
<Field label="Base URL"><input value={p.base_url} onChange={e => updProvider(name, { base_url: e.target.value })} className={inputCls} /></Field>
<Field label="API Key"><input type="password" value={p.api_key} onChange={e => updProvider(name, { api_key: e.target.value })} className={inputCls} /></Field>
<Field label="LLM 超时 (秒)"><input type="number" value={p.llm_timeout_secs} onChange={e => updProvider(name, { llm_timeout_secs: +e.target.value })} className={inputCls} /></Field>
<Field label="记忆维护超时 (秒)"><input type="number" value={p.memory_maintenance_timeout_secs} onChange={e => updProvider(name, { memory_maintenance_timeout_secs: +e.target.value })} className={inputCls} /></Field>
</div>
</div>
))}
<button onClick={addProvider} 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 renderModels = () => {
const entries = Object.entries(config.models)
const addModel = () => {
const name = prompt('Model 名称:')?.trim()
if (name && !config.models[name]) update('models', { ...config.models, [name]: { model_id: name } })
}
const delModel = (name: string) => { if (confirm(`删除 Model "${name}"?`)) { const { [name]: _, ...rest } = config.models; update('models', rest) } }
const updModel = (name: string, patch: Partial<ModelConfig>) => update('models', { ...config.models, [name]: { ...config.models[name], ...patch } })
return (
<div className="space-y-4">
{entries.map(([name, m]) => (
<div key={name} className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
<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>
</div>
</div>
))}
<button onClick={addModel} 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 renderAgents = () => {
const entries = Object.entries(config.agents)
const providerNames = Object.keys(config.providers)
const modelNames = Object.keys(config.models)
const addAgent = () => {
const name = prompt('Agent 名称:')?.trim()
if (name && !config.agents[name]) update('agents', { ...config.agents, [name]: { provider: providerNames[0] || '', model: modelNames[0] || '', max_tool_iterations: 100, tool_result_max_chars: 100000, context_tool_result_trim_chars: 2000 } })
}
const delAgent = (name: string) => { if (confirm(`删除 Agent "${name}"?`)) { const { [name]: _, ...rest } = config.agents; update('agents', rest) } }
const updAgent = (name: string, patch: Partial<AgentConfig>) => update('agents', { ...config.agents, [name]: { ...config.agents[name], ...patch } })
return (
<div className="space-y-4">
{entries.map(([name, a]) => (
<div key={name} className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
<MapEntryHeader name={name} onDelete={() => delAgent(name)} />
<div className="p-4 space-y-3">
<Field label="Provider"><select value={a.provider} onChange={e => updAgent(name, { provider: e.target.value })} className={selectCls}>{providerNames.map(p => <option key={p} value={p}>{p}</option>)}</select></Field>
<Field label="Model"><select value={a.model} onChange={e => updAgent(name, { model: e.target.value })} className={selectCls}>{modelNames.map(m => <option key={m} value={m}>{m}</option>)}</select></Field>
<Field label="最大工具迭代次数"><input type="number" value={a.max_tool_iterations} onChange={e => updAgent(name, { max_tool_iterations: +e.target.value })} className={inputCls} /></Field>
<Field label="工具结果最大字符数"><input type="number" value={a.tool_result_max_chars} onChange={e => updAgent(name, { tool_result_max_chars: +e.target.value })} className={inputCls} /></Field>
<Field label="上下文工具结果裁剪字符数"><input type="number" value={a.context_tool_result_trim_chars} onChange={e => updAgent(name, { context_tool_result_trim_chars: +e.target.value })} className={inputCls} /></Field>
</div>
</div>
))}
<button onClick={addAgent} 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 renderTime = () => (
<SectionCard title="时区设置">
<Field label="时区" hint="IANA 格式,如 Asia/Shanghai"><input value={config.time.timezone} onChange={e => update('time', { timezone: e.target.value })} className={inputCls} placeholder="Asia/Shanghai" /></Field>
</SectionCard>
)
const renderScheduler = () => (
<div className="space-y-5">
<SectionCard title="调度器">
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]"></span><Toggle checked={config.scheduler.enabled} onChange={v => update('scheduler', { ...config.scheduler, enabled: v })} /></div>
<Field label="Tick 分辨率 (ms)"><input type="number" value={config.scheduler.tick_resolution_ms} onChange={e => update('scheduler', { ...config.scheduler, tick_resolution_ms: +e.target.value })} className={inputCls} /></Field>
<Field label="工作队列容量"><input type="number" value={config.scheduler.worker_queue_capacity} onChange={e => update('scheduler', { ...config.scheduler, worker_queue_capacity: +e.target.value })} className={inputCls} /></Field>
<Field label="Misfire 策略"><select value={config.scheduler.misfire_policy} onChange={e => update('scheduler', { ...config.scheduler, misfire_policy: e.target.value as any })} className={selectCls}><option value="skip"> (Skip)</option><option value="catch_up"> (Catch Up)</option></select></Field>
</SectionCard>
</div>
)
const renderSkills = () => (
<div className="space-y-5">
<SectionCard title="技能系统">
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]"></span><Toggle checked={config.skills.enabled} onChange={v => update('skills', { ...config.skills, enabled: v })} /></div>
<Field label="最大索引字符数"><input type="number" value={config.skills.max_index_chars} onChange={e => update('skills', { ...config.skills, max_index_chars: +e.target.value })} className={inputCls} /></Field>
<Field label="最大展示技能数"><input type="number" value={config.skills.max_listed_skills} onChange={e => update('skills', { ...config.skills, max_listed_skills: +e.target.value })} className={inputCls} /></Field>
</SectionCard>
<SectionCard title="来源目录">
<TagEditor tags={config.skills.sources} onChange={v => update('skills', { ...config.skills, sources: v })} />
</SectionCard>
</div>
)
const renderTools = () => (
<div className="space-y-5">
<SectionCard title="禁用工具列表">
<TagEditor tags={config.tools.disabled} onChange={v => update('tools', { ...config.tools, disabled: v })} />
</SectionCard>
<SectionCard title="Task 子代理">
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]"> Task </span><Toggle checked={config.tools.task.enabled} onChange={v => update('tools', { ...config.tools, task: { ...config.tools.task, enabled: v } })} /></div>
<Field label="最大执行时间 (秒)"><input type="number" value={config.tools.task.max_execution_secs} onChange={e => update('tools', { ...config.tools, task: { ...config.tools.task, max_execution_secs: +e.target.value } })} className={inputCls} /></Field>
<Field label="探索模式最大执行时间 (秒)"><input type="number" value={config.tools.task.explore_max_execution_secs} onChange={e => update('tools', { ...config.tools, task: { ...config.tools.task, explore_max_execution_secs: +e.target.value } })} className={inputCls} /></Field>
<Field label="TTL (小时)"><input type="number" value={config.tools.task.ttl_hours} onChange={e => update('tools', { ...config.tools, task: { ...config.tools.task, ttl_hours: +e.target.value } })} className={inputCls} /></Field>
</SectionCard>
<SectionCard title="允许的工具列表">
<TagEditor tags={config.tools.task.allowed_tools} onChange={v => update('tools', { ...config.tools, task: { ...config.tools.task, allowed_tools: v } })} />
</SectionCard>
</div>
)
const renderMemory = () => (
<SectionCard title="记忆维护">
<Field label="最大合并比例" hint="0.0 - 1.0,单次最多合并/删除的记忆比例"><input type="number" step="0.05" min="0" max="1" value={config.memory_maintenance.max_merge_ratio} onChange={e => update('memory_maintenance', { ...config.memory_maintenance, max_merge_ratio: +e.target.value })} className={inputCls} /></Field>
<Field label="最小保留记忆数"><input type="number" value={config.memory_maintenance.min_memories_to_keep} onChange={e => update('memory_maintenance', { ...config.memory_maintenance, min_memories_to_keep: +e.target.value })} className={inputCls} /></Field>
<Field label="单组最大合并数"><input type="number" value={config.memory_maintenance.max_merge_per_group} onChange={e => update('memory_maintenance', { ...config.memory_maintenance, max_merge_per_group: +e.target.value })} className={inputCls} /></Field>
</SectionCard>
)
const renderImage = () => (
<SectionCard title="图片上下文">
<Field label="上下文中最大图片数" hint="发送给模型的图片数量上限"><input type="number" value={config.image_context.max_images_in_context} onChange={e => update('image_context', { ...config.image_context, max_images_in_context: +e.target.value })} className={inputCls} /></Field>
<Field label="图片最大存活轮次" hint="超过此轮次后不再提交给模型"><input type="number" value={config.image_context.max_image_age_rounds} onChange={e => update('image_context', { ...config.image_context, max_image_age_rounds: +e.target.value })} className={inputCls} /></Field>
</SectionCard>
)
const renderSubagents = () => (
<div className="space-y-5">
<SectionCard title="子代理">
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]"></span><Toggle checked={config.subagents.enabled} onChange={v => update('subagents', { ...config.subagents, enabled: v })} /></div>
</SectionCard>
<SectionCard title="来源目录">
<TagEditor tags={config.subagents.sources} onChange={v => update('subagents', { ...config.subagents, sources: v })} />
</SectionCard>
</div>
)
const renderContent = () => {
switch (activeTab) {
case 'connection': return renderConnection()
case 'gateway': return renderGateway()
case 'providers': return renderProviders()
case 'models': return renderModels()
case 'agents': return renderAgents()
case 'time': return renderTime()
case 'scheduler': return renderScheduler()
case 'skills': return renderSkills()
case 'tools': return renderTools()
case 'memory': return renderMemory()
case 'image': return renderImage()
case 'subagents': return renderSubagents()
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-[fadeIn_0.15s_ease-out]" onClick={onClose}>
<div
className="relative flex flex-col w-[92vw] max-w-4xl h-[85vh] rounded-2xl border border-[var(--border-color)] bg-[var(--bg-primary)] shadow-2xl overflow-hidden animate-[scaleIn_0.2s_ease-out]"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="shrink-0 flex items-center gap-3 px-6 py-4 border-b border-[var(--border-color)] bg-[var(--bg-secondary)]/80 backdrop-blur-md">
<Settings className="h-5 w-5 text-[var(--accent-cyan)]" />
<span className="text-lg font-semibold text-[var(--text-primary)]"></span>
{dirty && <span className="text-xs text-amber-400 bg-amber-500/10 px-2 py-0.5 rounded-full"></span>}
<div className="flex-1" />
<button onClick={onClose} className="p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors" title="关闭 (Esc)">
<X className="h-5 w-5" />
</button>
</div>
{/* Body */}
<div className="flex flex-1 min-h-0">
{/* Left sidebar tabs */}
<div className="shrink-0 w-48 border-r border-[var(--border-color)] bg-[var(--bg-secondary)]/40 overflow-y-auto py-2">
{TABS.map(tab => {
const Icon = tab.icon
const active = activeTab === tab.id
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center gap-2.5 px-4 py-2.5 text-sm transition-all relative ${
active
? 'text-[var(--accent-cyan)] bg-[var(--accent-cyan)]/5'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)]'
}`}
>
{active && <span className="absolute left-0 top-1 bottom-1 w-[2px] rounded-r bg-[var(--accent-cyan)]" />}
<Icon className="h-4 w-4 shrink-0" />
<span>{tab.label}</span>
</button>
)
})}
</div>
{/* Right content */}
<div className="flex-1 overflow-y-auto p-6">
{renderContent()}
</div>
</div>
{/* Footer */}
<div className="shrink-0 px-6 py-3 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]/80 backdrop-blur-md flex items-center gap-3">
{error && <span className="text-sm text-red-400 truncate max-w-xs">{error}</span>}
<div className="flex-1" />
<button onClick={onClose} className="px-4 py-2 rounded-lg text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors">
</button>
<button
onClick={handleSave}
disabled={saving || !dirty}
className="flex items-center gap-2 px-5 py-2 rounded-lg text-sm font-medium text-white bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30 hover:bg-[var(--accent-cyan)]/30 hover:border-[var(--accent-cyan)]/50 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{saving ? '保存中...' : '保存配置'}
</button>
</div>
{/* 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" />
{toast}
</div>
)}
</div>
</div>
)
}

View File

@ -20,6 +20,7 @@ export default defineConfig({
ws: true, ws: true,
}, },
'/health': `http://127.0.0.1:${gatewayPort}`, '/health': `http://127.0.0.1:${gatewayPort}`,
'/api': `http://127.0.0.1:${gatewayPort}`,
}, },
}, },
build: { build: {