diff --git a/convert_pdf.py b/convert_pdf.py new file mode 100644 index 0000000..0f40bae --- /dev/null +++ b/convert_pdf.py @@ -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('转换完成!') diff --git a/src/config/mod.rs b/src/config/mod.rs index c4cc9ad..6614cba 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -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(".")); home.join(".picobot").join("config.json") } diff --git a/src/gateway/http.rs b/src/gateway/http.rs index beed167..4570412 100644 --- a/src/gateway/http.rs +++ b/src/gateway/http.rs @@ -1,5 +1,10 @@ -use axum::Json; -use serde::Serialize; +use axum::{Json, extract::State}; +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)] pub struct HealthResponse { @@ -13,3 +18,83 @@ pub async fn health() -> Json { 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>, +) -> Json { + 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>, + Json(req): Json, +) -> Result, (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(), + })) +} diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 4d9d724..2670b33 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -200,6 +200,7 @@ pub async fn run( let app = if use_embedded { Router::new() .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)) .fallback(static_handler) .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()); Router::new() .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)) .fallback_service(ServeDir::new(&static_dir)) .with_state(state.clone()) diff --git a/web/src/App.tsx b/web/src/App.tsx index ba420f9..285dd24 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,12 +1,13 @@ 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 { TopicList } from './components/Sidebar/TopicList' import { SchedulerJobList } from './components/Sidebar/SchedulerJobList' import { MemoryPanel } from './components/Panel/MemoryPanel' import { SkillList } from './components/Panel/SkillList' 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 { ChannelSelector } from './components/Header/ChannelSelector' import { SessionSelector } from './components/Header/SessionSelector' @@ -134,11 +135,10 @@ function App() { return localStorage.getItem('picobot-show-thinking') !== 'false' }) - const [settingsOpen, setSettingsOpen] = useState(false) + const [configPageOpen, setConfigPageOpen] = useState(false) - const handleSaveGatewaySettings = useCallback((newSettings: GatewaySettings) => { - setGatewaySettings(newSettings) - setSettingsOpen(false) + const handleSaveConnection = useCallback((host: string, port: number) => { + setGatewaySettings({ host, port }) }, []) useEffect(() => { @@ -503,12 +503,12 @@ function App() {
@@ -732,12 +732,9 @@ function App() { sendCommand={sendMemoryCommand} /> - {/* 连接设置弹窗 */} - {settingsOpen && ( - setSettingsOpen(false)} - onSave={handleSaveGatewaySettings} - /> + {/* 系统配置页面 */} + {configPageOpen && ( + setConfigPageOpen(false)} onSaveConnection={handleSaveConnection} /> )}
) diff --git a/web/src/components/Settings/ConfigPage.tsx b/web/src/components/Settings/ConfigPage.tsx new file mode 100644 index 0000000..7cd752d --- /dev/null +++ b/web/src/components/Settings/ConfigPage.tsx @@ -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; 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 + models: Record + agents: Record + time: TimeConfig + gateway: GatewayConfig + scheduler: SchedulerConfig + skills: SkillsConfig + tools: ToolsConfig + memory_maintenance: MemoryMaintenanceConfig + image_context: ImageContextConfig + subagents: SubagentsConfig + channels: Record + mcpServers: Record +} + +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 ( +
+ + {children} + {hint &&

{hint}

} +
+ ) +} + +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 ( + + ) +} + +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 ( +
+
+ {tags.map((t, i) => ( + + {t} + + + ))} +
+
+ setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), add())} placeholder="输入后按 Enter" className={inputCls + ' !text-xs'} /> + +
+
+ ) +} + +function SectionCard({ title, children }: { title: string; children: ReactNode }) { + return ( +
+
+

{title}

+
+
{children}
+
+ ) +} + +function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete: () => void; onRename?: (n: string) => void }) { + const [editing, setEditing] = useState(false) + const [val, setVal] = useState(name) + return ( +
+ {editing ? ( + 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 /> + ) : ( + onRename && setEditing(true)}>{name} + )} +
+ +
+ ) +} + +// ── Main Component ───────────────────────────────────── +export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { + const [config, setConfig] = useState(null) + const [activeTab, setActiveTab] = useState('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((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 ( +
+ +
+ ) + + if (!config) return ( +
+
+ +

{error || '加载失败'}

+ +
+
+ ) + + 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 = () => ( +
+ + { setConnHost(e.target.value); setConnError('') }} className={inputCls} placeholder="127.0.0.1" /> + { setConnPort(+e.target.value); setConnError('') }} min={1} max={65535} className={inputCls} placeholder="19876" /> + {connError &&
{connError}
} +
ws://{connHost.trim() || '...'}:{connPort || '...'}/ws
+
+ +
+ ) + + const renderGateway = () => ( +
+ + update('gateway', { ...config.gateway, host: e.target.value })} className={inputCls} /> + update('gateway', { ...config.gateway, port: +e.target.value })} className={inputCls} /> + + +
显示工具结果 update('gateway', { ...config.gateway, show_tool_results: v })} />
+ update('gateway', { ...config.gateway, agent_prompt_reinject_every: +e.target.value })} className={inputCls} /> + update('gateway', { ...config.gateway, max_concurrent_requests: +e.target.value })} className={inputCls} /> + { const v = e.target.value; update('gateway', { ...config.gateway, session_ttl_hours: v ? +v : undefined }) }} className={inputCls} placeholder="24" /> +
+
+ ) + + 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 = {} + for (const [k, v] of entries) { newMap[k === oldName ? newName : k] = v } + update('providers', newMap) + } + const updProvider = (name: string, patch: Partial) => { + update('providers', { ...config.providers, [name]: { ...config.providers[name], ...patch } }) + } + return ( +
+ {entries.map(([name, p]) => ( +
+ delProvider(name)} onRename={n => renameProvider(name, n)} /> +
+ + updProvider(name, { base_url: e.target.value })} className={inputCls} /> + updProvider(name, { api_key: e.target.value })} className={inputCls} /> + updProvider(name, { llm_timeout_secs: +e.target.value })} className={inputCls} /> + updProvider(name, { memory_maintenance_timeout_secs: +e.target.value })} className={inputCls} /> +
+
+ ))} + +
+ ) + } + + 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) => update('models', { ...config.models, [name]: { ...config.models[name], ...patch } }) + return ( +
+ {entries.map(([name, m]) => ( +
+ 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} /> +
+
+ ))} + +
+ ) + } + + 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) => update('agents', { ...config.agents, [name]: { ...config.agents[name], ...patch } }) + return ( +
+ {entries.map(([name, a]) => ( +
+ delAgent(name)} /> +
+ + + updAgent(name, { max_tool_iterations: +e.target.value })} className={inputCls} /> + updAgent(name, { tool_result_max_chars: +e.target.value })} className={inputCls} /> + updAgent(name, { context_tool_result_trim_chars: +e.target.value })} className={inputCls} /> +
+
+ ))} + +
+ ) + } + + const renderTime = () => ( + + update('time', { timezone: e.target.value })} className={inputCls} placeholder="Asia/Shanghai" /> + + ) + + const renderScheduler = () => ( +
+ +
启用调度器 update('scheduler', { ...config.scheduler, enabled: v })} />
+ update('scheduler', { ...config.scheduler, tick_resolution_ms: +e.target.value })} className={inputCls} /> + update('scheduler', { ...config.scheduler, worker_queue_capacity: +e.target.value })} className={inputCls} /> + +
+
+ ) + + const renderSkills = () => ( +
+ +
启用技能 update('skills', { ...config.skills, enabled: v })} />
+ update('skills', { ...config.skills, max_index_chars: +e.target.value })} className={inputCls} /> + update('skills', { ...config.skills, max_listed_skills: +e.target.value })} className={inputCls} /> +
+ + update('skills', { ...config.skills, sources: v })} /> + +
+ ) + + const renderTools = () => ( +
+ + update('tools', { ...config.tools, disabled: v })} /> + + +
启用 Task 工具 update('tools', { ...config.tools, task: { ...config.tools.task, enabled: v } })} />
+ update('tools', { ...config.tools, task: { ...config.tools.task, max_execution_secs: +e.target.value } })} className={inputCls} /> + update('tools', { ...config.tools, task: { ...config.tools.task, explore_max_execution_secs: +e.target.value } })} className={inputCls} /> + update('tools', { ...config.tools, task: { ...config.tools.task, ttl_hours: +e.target.value } })} className={inputCls} /> +
+ + update('tools', { ...config.tools, task: { ...config.tools.task, allowed_tools: v } })} /> + +
+ ) + + const renderMemory = () => ( + + update('memory_maintenance', { ...config.memory_maintenance, max_merge_ratio: +e.target.value })} className={inputCls} /> + update('memory_maintenance', { ...config.memory_maintenance, min_memories_to_keep: +e.target.value })} className={inputCls} /> + update('memory_maintenance', { ...config.memory_maintenance, max_merge_per_group: +e.target.value })} className={inputCls} /> + + ) + + const renderImage = () => ( + + update('image_context', { ...config.image_context, max_images_in_context: +e.target.value })} className={inputCls} /> + update('image_context', { ...config.image_context, max_image_age_rounds: +e.target.value })} className={inputCls} /> + + ) + + const renderSubagents = () => ( +
+ +
启用子代理发现 update('subagents', { ...config.subagents, enabled: v })} />
+
+ + update('subagents', { ...config.subagents, sources: v })} /> + +
+ ) + + 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 ( +
+
e.stopPropagation()} + > + {/* Header */} +
+ + 系统配置 + {dirty && 未保存} +
+ +
+ + {/* Body */} +
+ {/* Left sidebar tabs */} +
+ {TABS.map(tab => { + const Icon = tab.icon + const active = activeTab === tab.id + return ( + + ) + })} +
+ + {/* Right content */} +
+ {renderContent()} +
+
+ + {/* Footer */} +
+ {error && {error}} +
+ + +
+ + {/* Toast */} + {toast && ( +
+ + {toast} +
+ )} +
+
+ ) +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 4a1b2a5..dd002fb 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ ws: true, }, '/health': `http://127.0.0.1:${gatewayPort}`, + '/api': `http://127.0.0.1:${gatewayPort}`, }, }, build: {