feat(gateway): 添加配置管理功能支持敏感信息脱敏
- 实现 API 端点 /api/config 用于获取和保存配置 - 添加配置信息脱敏功能,保护 API 密钥等敏感数据 - 集成配置验证逻辑,确保时区等参数有效性 - 在前端添加完整的配置管理页面界面 - 实现配置项的动态编辑和保存功能 - 添加连接设置功能用于 WebSocket 连接配置 - 提供多标签页界面分别管理不同配置模块 - 实现配置变更后的实时预览和保存确认
This commit is contained in:
parent
027e8661bc
commit
37f417007e
108
convert_pdf.py
Normal file
108
convert_pdf.py
Normal 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('转换完成!')
|
||||||
@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
516
web/src/components/Settings/ConfigPage.tsx
Normal file
516
web/src/components/Settings/ConfigPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user