Files

1836 lines
70 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Settings API 端点"""
import json
import os
import re
import shutil
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from loguru import logger
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from backend.database import get_db
from backend.modules.config.loader import config_loader
from backend.modules.config.schema import AppConfig, ModelConfig, ProviderConfig, WorkspaceConfig
from backend.modules.external_agents.conversation import profile_supports_native_session
from backend.modules.external_agents.registry import ExternalAgentRegistry
from backend.modules.providers.runtime import get_provider_runtime_state
from backend.modules.workspace import seed_bundled_workspace_resources, workspace_manager
from backend.version import APP_VERSION
router = APIRouter(prefix="/api/settings", tags=["settings"])
def _normalize_api_mode_value(value: Any) -> str:
return "chat_completions"
def _coerce_boolean_value(value: Any, *, field_name: str) -> bool:
"""将用户输入安全转换为布尔值,拒绝模糊字符串。"""
if isinstance(value, bool):
return value
if isinstance(value, int) and value in {0, 1}:
return bool(value)
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"true", "1", "yes", "on"}:
return True
if normalized in {"false", "0", "no", "off"}:
return False
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{field_name} must be a boolean",
)
def _list_enabled_channel_account_ids(config: AppConfig, channel_name: str) -> List[str]:
"""列出指定渠道当前已启用的账号 ID。"""
channel_cfg = getattr(config.channels, channel_name, None)
if channel_cfg is None:
return []
ordered_account_ids: List[str] = []
if bool(getattr(channel_cfg, "enabled", False)):
top_level_account_id = str(getattr(channel_cfg, "account_id", "default") or "default")
ordered_account_ids.append(top_level_account_id)
accounts = getattr(channel_cfg, "accounts", {}) or {}
for raw_account_id, account_cfg in accounts.items():
if isinstance(account_cfg, dict):
enabled = bool(account_cfg.get("enabled", False))
account_id = str(raw_account_id or account_cfg.get("account_id") or "default")
else:
enabled = bool(getattr(account_cfg, "enabled", False))
account_id = str(raw_account_id or getattr(account_cfg, "account_id", "default") or "default")
if enabled:
ordered_account_ids.append(account_id)
deduped: List[str] = []
for account_id in ordered_account_ids:
if account_id not in deduped:
deduped.append(account_id)
return deduped
def _normalize_pattern_list(value: Any) -> List[str]:
"""清理正则模式列表,去掉空白项并去重,保留原顺序。"""
if not isinstance(value, list):
return []
normalized: List[str] = []
for item in value:
text = str(item or "").strip()
if not text or text in normalized:
continue
normalized.append(text)
return normalized
def _validate_regex_patterns_or_raise(patterns: List[str], *, field_name: str) -> None:
"""保存配置前校验正则表达式,尽早返回明确错误。"""
for index, pattern in enumerate(patterns):
try:
re.compile(pattern)
except re.error as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{field_name}[{index}] 无效: {exc}",
) from exc
def _validate_cron_schedule_or_raise(schedule: Any, *, field_name: str) -> str:
"""校验 cron 表达式。"""
from croniter import croniter
normalized = str(schedule or "").strip()
if not normalized:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{field_name} 不能为空",
)
try:
croniter(normalized)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{field_name} 无效: {e}",
) from e
return normalized
def _validate_and_normalize_heartbeat_settings(config: AppConfig) -> None:
"""校验问候助手配置,并在单机器人场景下自动补全真实账号 ID。"""
heartbeat = config.persona.heartbeat
heartbeat.account_id = str(getattr(heartbeat, "account_id", "default") or "default").strip() or "default"
heartbeat.schedule = _validate_cron_schedule_or_raise(
heartbeat.schedule,
field_name="persona.heartbeat.schedule",
)
if not heartbeat.enabled:
return
heartbeat.channel = str(heartbeat.channel or "").strip()
heartbeat.chat_id = str(heartbeat.chat_id or "").strip()
if not heartbeat.channel:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="启用问候助手前必须先选择推送渠道",
)
if not heartbeat.chat_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="启用问候助手前必须填写推送目标 ID",
)
enabled_account_ids = _list_enabled_channel_account_ids(config, heartbeat.channel)
if not enabled_account_ids:
available_channels = ", ".join(config.channels.model_dump().keys())
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"问候助手渠道 '{heartbeat.channel}' 当前没有可用机器人实例。"
f"可用渠道: {available_channels}"
),
)
if heartbeat.account_id in enabled_account_ids:
return
if heartbeat.account_id == "default" and len(enabled_account_ids) == 1:
heartbeat.account_id = enabled_account_ids[0]
return
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"问候助手账号 ID '{heartbeat.account_id}' 无效,"
f"当前渠道可用账号: {', '.join(enabled_account_ids)}"
),
)
def _heartbeat_plan_settings_changed(before: Optional[Dict[str, Any]], after: Optional[Dict[str, Any]]) -> bool:
"""判断是否需要刷新当天随机问候计划。"""
if not before or not after:
return False
watched_keys = ("quiet_start", "quiet_end", "max_greets_per_day")
return any(before.get(key) != after.get(key) for key in watched_keys)
class ExternalCodingProfilePayload(BaseModel):
"""外部编码工具 profile 配置。"""
name: str = Field(..., min_length=1)
aliases: List[str] = Field(default_factory=list)
type: str = "cli"
icon_svg: str = ""
enabled: bool = False
description: str = ""
command: str = ""
args: List[str] = Field(default_factory=list)
working_dir: str = ""
stdin_template: Optional[str] = None
env: Dict[str, str] = Field(default_factory=dict)
inherit_env: List[str] = Field(default_factory=list)
session_mode: str = "history"
history_message_count: int = Field(default=10, ge=1, le=50)
timeout: Optional[int] = None
success_exit_codes: List[int] = Field(default_factory=lambda: [0])
class ExternalCodingToolsConfigPayload(BaseModel):
"""外部编码工具完整配置。"""
version: int = 1
profiles: List[ExternalCodingProfilePayload] = Field(default_factory=list)
class ExternalCodingToolsConfigResponse(ExternalCodingToolsConfigPayload):
"""外部编码工具配置响应。"""
config_path: str
class ExternalCodingProfileCheckRequest(BaseModel):
"""外部编码工具 profile 检查请求。"""
profile: ExternalCodingProfilePayload
class ExternalCodingProfileCheckResponse(BaseModel):
"""外部编码工具 profile 检查结果。"""
success: bool
message: str
resolved_command: Optional[str] = None
missing_env: List[str] = Field(default_factory=list)
def _get_external_coding_registry() -> ExternalAgentRegistry:
"""基于当前工作空间创建外部编码工具注册表。"""
workspace = Path(config_loader.config.workspace.path).resolve()
workspace.mkdir(parents=True, exist_ok=True)
return ExternalAgentRegistry(workspace=workspace)
def _load_external_coding_tools_config() -> tuple[ExternalAgentRegistry, Dict[str, Any]]:
"""加载外部编码工具配置原始 JSON。"""
registry = _get_external_coding_registry()
try:
raw = json.loads(registry.config_path.read_text(encoding="utf-8"))
except FileNotFoundError:
registry._ensure_default_config()
raw = json.loads(registry.config_path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="外部编码工具配置文件格式无效",
)
return registry, raw
def _validate_external_coding_tools_payload(
payload: ExternalCodingToolsConfigPayload,
registry: ExternalAgentRegistry,
) -> Dict[str, Any]:
"""复用 registry 校验待保存的外部编码工具配置。"""
raw = payload.model_dump()
profiles = raw.get("profiles", [])
parsed_profiles = []
for index, item in enumerate(profiles):
parsed_profiles.append(registry._parse_profile(item, index))
command = str(item.get("command", "")).strip()
if not command:
raise HTTPException(status_code=400, detail=f"profile '{item.get('name')}' 缺少 command")
try:
registry._validate_profile_collisions(parsed_profiles)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return raw
def _resolve_profile_command(command: str) -> Optional[str]:
"""解析 profile command 到实际可执行路径。"""
normalized = str(command or "").strip()
if not normalized:
return None
if os.path.isabs(normalized):
return normalized if os.path.isfile(normalized) and os.access(normalized, os.X_OK) else None
return shutil.which(normalized)
def _validate_workspace_path_or_raise(path: str) -> Path:
"""验证工作空间路径;失败时返回 400而不是污染运行态或配置。"""
from backend.modules.workspace import workspace_manager
normalized = (path or "").strip()
if not normalized:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="路径不能为空"
)
try:
return workspace_manager.prepare_workspace_path(normalized)
except Exception as e:
logger.warning(f"拒绝保存不可用工作空间路径: {normalized}, error: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"工作空间路径不可用: {str(e)}"
) from e
def _hot_reload_workspace_runtime(request: Request, workspace_path: Path) -> None:
"""将新的工作空间路径热更新到当前运行态。"""
from backend.modules.agent.memory import MemoryStore
workspace_path = workspace_manager.activate_workspace_path(workspace_path)
seed_bundled_workspace_resources(workspace_path)
memory_dir = workspace_path / "memory"
memory_dir.mkdir(parents=True, exist_ok=True)
memory_store = MemoryStore(memory_dir)
message_handler = getattr(request.app.state, 'message_handler', None)
if message_handler:
try:
message_handler.reload_config(
workspace=workspace_path,
memory_store=memory_store,
)
logger.info(f"Message handler workspace reloaded: {workspace_path}")
except Exception as e:
logger.warning(f"Failed to reload message handler workspace: {e}")
shared = getattr(request.app.state, 'shared', None)
if shared:
try:
shared['workspace'] = workspace_path
shared['memory'] = memory_store
tool_params = shared.get('tool_params')
if isinstance(tool_params, dict):
tool_params['workspace'] = workspace_path
context_builder = shared.get('context_builder')
if context_builder:
if hasattr(context_builder, 'update_workspace'):
context_builder.update_workspace(workspace_path)
else:
context_builder.workspace = workspace_path
context_builder.memory = memory_store
subagent_manager = shared.get('subagent_manager')
if subagent_manager:
subagent_manager.workspace = workspace_path
skills_loader = shared.get('skills')
if skills_loader:
try:
skills_dir = workspace_path / 'skills'
skills_dir.mkdir(parents=True, exist_ok=True)
if hasattr(skills_loader, 'workspace_skills'):
skills_loader.workspace_skills = skills_dir
if hasattr(skills_loader, 'config_file'):
skills_loader.config_file = workspace_path / '.skills_config.json'
if hasattr(skills_loader, 'reload'):
skills_loader.reload()
logger.info(f"Skills loader workspace reloaded: {skills_dir}")
except Exception as e:
logger.warning(f"Failed to reload skills after workspace change: {e}")
_rebuild_shared_tool_registry_runtime(request)
request.app.state.skills = shared.get('skills')
request.app.state.memory = memory_store
logger.info(f"Shared components workspace reloaded: {workspace_path}")
except Exception as e:
logger.warning(f"Failed to reload shared components workspace: {e}")
def _rebuild_shared_tool_registry_runtime(request: Request) -> None:
"""重建全局共享工具注册表,保证依赖共享注册表的渠道读取到最新配置。"""
shared = getattr(request.app.state, 'shared', None)
if not shared:
return
tool_params = shared.get('tool_params')
if not isinstance(tool_params, dict):
return
from backend.modules.tools.setup import register_all_tools
register_kwargs = dict(tool_params)
channel_manager = getattr(request.app.state, 'channel_manager', None)
if channel_manager is not None:
register_kwargs['channel_manager'] = channel_manager
tool_params['channel_manager'] = channel_manager
shared['tool_registry'] = register_all_tools(
**register_kwargs,
memory_store=shared.get('memory'),
)
logger.info("Shared tool registry reloaded")
def _prepare_message_handler_reload_params(
config: AppConfig,
*,
reload_provider_model: bool = False,
reload_persona: bool = False,
reload_security: bool = False,
) -> Dict[str, object]:
"""根据最新配置构建渠道消息处理器的热重载参数。"""
reload_params: Dict[str, object] = {}
if reload_provider_model:
try:
from backend.modules.providers import create_provider
from backend.modules.providers.runtime import find_first_selectable_provider, get_provider_runtime_state
provider_id = config.model.provider
runtime_state = get_provider_runtime_state(config, provider_id)
if not runtime_state.selectable:
fallback_state = find_first_selectable_provider(config)
if fallback_state:
provider_id = fallback_state.provider_id
runtime_state = fallback_state
reload_params['provider'] = create_provider(
api_key=runtime_state.api_key or None,
api_keys=runtime_state.api_keys or None,
api_base=runtime_state.api_base,
default_model=config.model.model,
api_mode=config.model.api_mode,
timeout=600.0,
max_retries=3,
provider_id=provider_id,
)
reload_params['model'] = config.model.model
reload_params['temperature'] = config.model.temperature
reload_params['max_tokens'] = config.model.max_tokens
reload_params['max_iterations'] = config.model.max_iterations
reload_params['thinking_enabled'] = config.model.thinking_enabled
reload_params['max_history_messages'] = config.persona.max_history_messages
logger.info("Prepared AI config for hot reload")
except Exception as e:
logger.warning(f"Failed to prepare AI config for reload: {e}")
if reload_persona:
reload_params['persona_config'] = config.persona
reload_params['max_history_messages'] = config.persona.max_history_messages
logger.info(
"Prepared persona config for hot reload: "
f"{config.persona.ai_name}, {config.persona.user_name}, "
f"{getattr(config.persona, 'user_address', '')}"
)
if reload_security:
reload_params['tool_params_updates'] = {
"command_timeout": config.security.command_timeout,
"max_output_length": config.security.max_output_length,
"allow_dangerous": not config.security.dangerous_commands_blocked,
"restrict_to_workspace": config.security.restrict_to_workspace,
"custom_deny_patterns": config.security.custom_deny_patterns,
"custom_allow_patterns": (
config.security.custom_allow_patterns
if config.security.command_whitelist_enabled
else None
),
"audit_log_enabled": config.security.audit_log_enabled,
}
logger.info("Prepared tool security config for hot reload")
return reload_params
def _reload_cron_runtime(
request: Request,
config: AppConfig,
reload_params: Dict[str, object],
*,
workspace_path: Optional[Path] = None,
reload_provider_model: bool = False,
reload_persona: bool = False,
reload_security: bool = False,
) -> None:
"""将最新配置同步到 cron agent / heartbeat 运行态。"""
cron_executor = getattr(request.app.state, 'cron_executor', None)
if not cron_executor:
return
shared = getattr(request.app.state, 'shared', None) or {}
provider = reload_params.get('provider') if reload_provider_model else None
tool_updates = reload_params.get('tool_params_updates')
if provider is not None:
shared['provider'] = provider
subagent_manager = shared.get('subagent_manager')
if subagent_manager is not None:
subagent_manager.provider = provider
else:
subagent_manager = shared.get('subagent_manager')
if subagent_manager is not None and reload_provider_model:
subagent_manager.model = config.model.model
subagent_manager.temperature = config.model.temperature
subagent_manager.max_tokens = config.model.max_tokens
tool_params = shared.get('tool_params')
if isinstance(tool_params, dict):
if workspace_path is not None:
tool_params['workspace'] = workspace_path
if isinstance(tool_updates, dict):
tool_params.update(tool_updates)
cron_agent = getattr(cron_executor, 'agent', None)
if cron_agent is not None:
if provider is not None:
cron_agent.provider = provider
if reload_provider_model:
cron_agent.model = config.model.model
cron_agent.temperature = config.model.temperature
cron_agent.max_tokens = config.model.max_tokens
cron_agent.max_iterations = config.model.max_iterations
cron_agent.thinking_enabled = config.model.thinking_enabled
if workspace_path is not None:
cron_agent.workspace = workspace_path
if isinstance(tool_params, dict) and (workspace_path is not None or reload_security):
try:
from backend.modules.tools.setup import register_all_tools
cron_agent.tools = register_all_tools(
**tool_params,
memory_store=shared.get('memory'),
)
logger.info("Cron agent tool registry reloaded")
except Exception as e:
logger.warning(f"Failed to reload cron agent tools: {e}")
heartbeat_service = getattr(cron_executor, 'heartbeat_service', None)
if heartbeat_service is not None:
if provider is not None:
heartbeat_service.provider = provider
if reload_provider_model:
heartbeat_service.model = config.model.model
if workspace_path is not None:
heartbeat_service.workspace = workspace_path
heartbeat_service._state_file = workspace_path / "memory" / "heartbeat_state.json"
if reload_persona or reload_provider_model or workspace_path is not None:
heartbeat_cfg = config.persona.heartbeat
heartbeat_service.ai_name = config.persona.ai_name or "小C"
heartbeat_service.user_name = config.persona.user_name or "主人"
heartbeat_service.user_address = config.persona.user_address or ""
heartbeat_service.personality = config.persona.personality or "professional"
heartbeat_service.custom_personality = config.persona.custom_personality or ""
heartbeat_service.idle_threshold_hours = heartbeat_cfg.idle_threshold_hours
heartbeat_service.quiet_start = heartbeat_cfg.quiet_start
heartbeat_service.quiet_end = heartbeat_cfg.quiet_end
heartbeat_service.max_greets_per_day = heartbeat_cfg.max_greets_per_day
if (
provider is not None
or workspace_path is not None
or reload_persona
or reload_provider_model
or reload_security
):
logger.info("Cron runtime reloaded successfully")
async def _apply_saved_config_runtime(
req: Request,
config: AppConfig,
*,
workspace_path: Optional[Path] = None,
reload_persona: bool = False,
reload_provider_model: bool = False,
reload_security: bool = False,
sync_heartbeat: bool = False,
) -> None:
"""将已保存的配置同步到当前运行态。"""
if workspace_path is not None:
try:
_hot_reload_workspace_runtime(req, workspace_path.resolve())
except Exception as e:
logger.warning(f"Failed to hot reload workspace: {e}")
if reload_persona:
try:
shared = getattr(req.app.state, 'shared', None)
if shared and 'context_builder' in shared:
context_builder = shared['context_builder']
if hasattr(context_builder, 'update_persona_config'):
context_builder.update_persona_config(config.persona)
logger.info("Hot reloaded persona config")
except Exception as e:
logger.warning(f"Failed to hot reload persona config: {e}")
message_handler = getattr(req.app.state, 'message_handler', None)
if message_handler:
reload_params = _prepare_message_handler_reload_params(
config,
reload_provider_model=reload_provider_model,
reload_persona=reload_persona,
reload_security=reload_security,
)
if reload_params:
try:
message_handler.reload_config(**reload_params)
logger.info("Channel message handler reloaded successfully")
except Exception as e:
logger.warning(f"Failed to reload channel handler config: {e}")
else:
reload_params = _prepare_message_handler_reload_params(
config,
reload_provider_model=reload_provider_model,
reload_persona=reload_persona,
reload_security=reload_security,
)
if reload_security and workspace_path is None:
try:
_rebuild_shared_tool_registry_runtime(req)
except Exception as e:
logger.warning(f"Failed to reload shared tool registry: {e}")
try:
_reload_cron_runtime(
req,
config,
reload_params,
workspace_path=workspace_path,
reload_provider_model=reload_provider_model,
reload_persona=reload_persona,
reload_security=reload_security,
)
except Exception as e:
logger.warning(f"Failed to reload cron runtime: {e}")
if sync_heartbeat:
try:
from backend.database import get_db_session_factory
from backend.modules.agent.heartbeat import ensure_heartbeat_job
db_session_factory = get_db_session_factory()
await ensure_heartbeat_job(
db_session_factory,
heartbeat_config=config.persona.heartbeat,
)
scheduler = getattr(req.app.state, 'cron_scheduler', None)
if scheduler:
await scheduler.trigger_reschedule()
except Exception as e:
logger.warning(f"Failed to sync heartbeat cron job: {e}")
@router.get("/security/dangerous-patterns")
async def get_dangerous_patterns():
"""
获取内置的危险命令模式及其描述
Returns:
List[dict]: 危险命令模式列表,每个包含 pattern, description, key
"""
# 内置危险模式及其描述
patterns = [
{
"pattern": r"\brm\s+-[rf]{1,2}\b",
"description": "删除文件和目录rm -rf",
"key": "rm_rf"
},
{
"pattern": r"\bdel\s+/[fq]\b",
"description": "强制删除文件Windows del /f",
"key": "del_force"
},
{
"pattern": r"\brmdir\s+/s\b",
"description": "递归删除目录Windows rmdir /s",
"key": "rmdir_recursive"
},
{
"pattern": r"\b(format|mkfs|diskpart)\b",
"description": "磁盘格式化和分区操作",
"key": "disk_operations"
},
{
"pattern": r"\bdd\s+if=",
"description": "磁盘数据复制命令",
"key": "dd_command"
},
{
"pattern": r">\s*/dev/sd",
"description": "直接写入磁盘设备",
"key": "write_device"
},
{
"pattern": r"\b(shutdown|reboot|poweroff|halt)\b",
"description": "系统关机/重启命令",
"key": "power_operations"
},
{
"pattern": r":\(\)\s*\{.*\};\s*:",
"description": "Fork 炸弹攻击",
"key": "fork_bomb"
},
{
"pattern": r"\binit\s+[06]\b",
"description": "系统初始化级别切换",
"key": "init_shutdown"
}
]
return {
"success": True,
"patterns": patterns
}
# ============================================================================
# Request/Response Models
# ============================================================================
class ProviderMetadataResponse(BaseModel):
"""Provider 元数据响应"""
id: str = Field(..., description="Provider ID")
name: str = Field(..., description="显示名称")
default_api_base: Optional[str] = Field(None, description="默认 API 基础 URL")
default_model: Optional[str] = Field(None, description="默认模型名称")
enabled: bool = Field(False, description="是否已启用")
configured: bool = Field(False, description="配置是否完整")
selectable: bool = Field(False, description="是否可用于实际请求")
requires_api_key: bool = Field(True, description="是否要求 API Key")
requires_api_base: bool = Field(False, description="是否要求自定义 API Base")
status: str = Field("disabled", description="运行状态")
reason: str = Field("disabled", description="状态原因")
provider_group: str = Field("experimental", description="前端展示分组")
thinking_control_tier: str = Field("unsupported", description="思考开关支持等级")
class ProviderConfigResponse(BaseModel):
"""Provider 配置响应"""
enabled: bool = Field(..., description="是否启用")
api_key: Optional[str] = Field(None, description="API 密钥(脱敏)")
api_keys: List[str] = Field(default_factory=list, description="API 密钥列表")
api_base: Optional[str] = Field(None, description="API 基础 URL")
class ModelConfigResponse(BaseModel):
"""模型配置响应"""
provider: str = Field(..., description="Provider 名称")
model: str = Field(..., description="模型名称")
api_mode: str = Field(..., description="OpenAI API 模式,固定为 chat.completions")
temperature: float = Field(..., description="温度参数0 表示不发送")
max_tokens: int = Field(..., description="最大 token 数")
max_iterations: int = Field(..., description="最大迭代次数")
thinking_enabled: bool = Field(..., description="是否启用思考模式")
class WorkspaceConfigResponse(BaseModel):
"""工作空间配置响应"""
path: str = Field(..., description="工作空间路径")
class SecurityConfigResponse(BaseModel):
"""安全配置响应"""
# 危险命令检测
dangerous_commands_blocked: bool = Field(..., description="是否阻止危险命令")
custom_deny_patterns: List[str] = Field(..., description="自定义拒绝模式列表")
# 命令白名单
command_whitelist_enabled: bool = Field(..., description="是否启用命令白名单")
custom_allow_patterns: List[str] = Field(..., description="自定义允许模式列表")
# 审计日志
audit_log_enabled: bool = Field(..., description="是否启用审计日志")
# 其他安全选项
command_timeout: int = Field(..., description="命令超时时间(秒)")
subagent_timeout: int = Field(..., description="子代理超时时间(秒)")
max_output_length: int = Field(..., description="最大输出长度")
restrict_to_workspace: bool = Field(..., description="是否限制在工作空间内")
class HeartbeatConfigResponse(BaseModel):
"""主动问候配置响应"""
enabled: bool = Field(..., description="是否启用")
channel: str = Field(..., description="推送渠道")
account_id: str = Field(default="default", description="推送机器人账号 ID")
chat_id: str = Field(..., description="推送目标 ID")
schedule: str = Field(..., description="检查频率 cron 表达式")
idle_threshold_hours: int = Field(..., description="空闲阈值(小时)")
quiet_start: int = Field(..., description="免打扰开始时间")
quiet_end: int = Field(..., description="免打扰结束时间")
max_greets_per_day: int = Field(..., description="每天最多问候次数")
class PersonaConfigResponse(BaseModel):
"""用户信息和AI人设配置响应"""
ai_name: str = Field(..., description="AI的名字")
user_name: str = Field(..., description="用户的称呼")
user_address: str = Field(default="", description="用户的常用地址")
output_language: str = Field(default="中文", description="AI默认输出语言")
personality: str = Field(..., description="AI的性格类型")
custom_personality: str = Field(..., description="自定义性格描述")
max_history_messages: int = Field(
...,
description="最大对话历史条数0 表示不限且关闭短期上下文总结",
)
enable_short_context_summary: bool = Field(
default=False,
description="是否启用短期上下文摘要缓存,默认关闭",
)
heartbeat: HeartbeatConfigResponse = Field(..., description="主动问候配置")
class SettingsResponse(BaseModel):
"""设置响应"""
providers: Dict[str, ProviderConfigResponse] = Field(..., description="Provider 配置")
model: ModelConfigResponse = Field(..., description="模型配置")
workspace: WorkspaceConfigResponse = Field(..., description="工作空间配置")
security: SecurityConfigResponse = Field(..., description="安全配置")
persona: PersonaConfigResponse = Field(..., description="用户信息和AI人设配置")
workspace_migration: Optional[dict] = Field(None, description="工作区迁移提示信息")
class UpdateSettingsRequest(BaseModel):
"""更新设置请求"""
providers: Optional[Dict[str, dict]] = Field(None, description="Provider 配置")
model: Optional[dict] = Field(None, description="模型配置")
workspace: Optional[dict] = Field(None, description="工作空间配置")
security: Optional[dict] = Field(None, description="安全配置")
persona: Optional[dict] = Field(None, description="用户信息和AI人设配置")
class TestConnectionRequest(BaseModel):
"""测试连接请求"""
provider: str = Field(..., description="Provider 名称")
api_key: str = Field(default="", description="API 密钥")
api_base: Optional[str] = Field(None, description="API 基础 URL")
model: Optional[str] = Field(None, description="模型名称(可选)")
api_mode: Optional[str] = Field(None, description="API 模式(仅保留兼容字段)")
temperature: Optional[float] = Field(None, description="温度参数可选0 表示不发送)")
max_tokens: Optional[int] = Field(None, description="最大 token 数可选0 表示不发送)")
thinking_enabled: Optional[bool] = Field(None, description="是否启用思考模式(可选)")
class TestConnectionResponse(BaseModel):
"""测试连接响应"""
success: bool = Field(..., description="是否成功")
message: Optional[str] = Field(None, description="消息")
error: Optional[str] = Field(None, description="错误信息")
# ============================================================================
# Settings Endpoints
# ============================================================================
@router.get("/providers", response_model=List[ProviderMetadataResponse])
async def get_available_providers() -> List[ProviderMetadataResponse]:
"""
获取所有可用的 Provider
Returns:
List[ProviderMetadataResponse]: Provider 列表
"""
from backend.modules.providers.registry import get_all_providers
config = config_loader.config
providers = get_all_providers()
response: List[ProviderMetadataResponse] = []
for meta in providers.values():
runtime_state = get_provider_runtime_state(config, meta.id)
response.append(
ProviderMetadataResponse(
id=meta.id,
name=meta.name,
default_api_base=meta.default_api_base,
default_model=meta.default_model,
enabled=runtime_state.enabled,
configured=runtime_state.configured,
selectable=runtime_state.selectable,
requires_api_key=runtime_state.requires_api_key,
requires_api_base=runtime_state.requires_api_base,
status=runtime_state.status,
reason=runtime_state.reason,
provider_group=meta.provider_group,
thinking_control_tier=meta.thinking_control_tier,
)
)
return response
@router.get("", response_model=SettingsResponse)
async def get_settings() -> SettingsResponse:
"""
获取所有设置
Returns:
SettingsResponse: 设置信息
"""
try:
config = config_loader.config
# 构建 providers 响应(不脱敏,直接返回)
providers_response = {}
for name, provider_config in config.providers.items():
providers_response[name] = ProviderConfigResponse(
enabled=provider_config.enabled,
api_key=provider_config.api_key,
api_keys=provider_config.get_effective_api_keys(),
api_base=provider_config.api_base,
)
# 构建响应
return SettingsResponse(
providers=providers_response,
model=ModelConfigResponse(
provider=config.model.provider,
model=config.model.model,
api_mode=_normalize_api_mode_value(config.model.api_mode),
temperature=config.model.temperature,
max_tokens=config.model.max_tokens,
max_iterations=config.model.max_iterations,
thinking_enabled=config.model.thinking_enabled,
),
workspace=WorkspaceConfigResponse(
path=config.workspace.path,
),
security=SecurityConfigResponse(
dangerous_commands_blocked=config.security.dangerous_commands_blocked,
custom_deny_patterns=config.security.custom_deny_patterns,
command_whitelist_enabled=config.security.command_whitelist_enabled,
custom_allow_patterns=config.security.custom_allow_patterns,
audit_log_enabled=config.security.audit_log_enabled,
command_timeout=config.security.command_timeout,
subagent_timeout=config.security.subagent_timeout,
max_output_length=config.security.max_output_length,
restrict_to_workspace=config.security.restrict_to_workspace,
),
persona=PersonaConfigResponse(
ai_name=config.persona.ai_name,
user_name=config.persona.user_name,
user_address=getattr(config.persona, 'user_address', ''),
output_language=getattr(config.persona, 'output_language', '中文'),
personality=config.persona.personality,
custom_personality=config.persona.custom_personality,
max_history_messages=config.persona.max_history_messages,
enable_short_context_summary=getattr(
config.persona,
'enable_short_context_summary',
False,
),
heartbeat=HeartbeatConfigResponse(
enabled=config.persona.heartbeat.enabled,
channel=config.persona.heartbeat.channel,
account_id=config.persona.heartbeat.account_id,
chat_id=config.persona.heartbeat.chat_id,
schedule=config.persona.heartbeat.schedule,
idle_threshold_hours=config.persona.heartbeat.idle_threshold_hours,
quiet_start=config.persona.heartbeat.quiet_start,
quiet_end=config.persona.heartbeat.quiet_end,
max_greets_per_day=config.persona.heartbeat.max_greets_per_day,
),
),
workspace_migration=None, # GET 请求不返回迁移信息
)
except Exception as e:
logger.exception(f"Failed to get settings: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get settings: {str(e)}"
)
@router.put("", response_model=SettingsResponse)
async def update_settings(request: UpdateSettingsRequest, req: Request) -> SettingsResponse:
"""
更新设置
Args:
request: 更新设置请求
Returns:
SettingsResponse: 更新后的设置
"""
try:
previous_heartbeat = config_loader.config.persona.heartbeat.model_dump()
config = config_loader.config.model_copy(deep=True)
workspace_migration_info = None
old_workspace = None
validated_workspace_path = None
if request.providers:
for name, provider_data in request.providers.items():
# 如果 provider 不存在,自动创建
if name not in config.providers:
config.providers[name] = ProviderConfig()
provider_config = config.providers[name]
if "enabled" in provider_data:
provider_config.enabled = provider_data["enabled"]
if "api_key" in provider_data:
provider_config.api_key = provider_data["api_key"]
if "api_keys" in provider_data:
raw_keys = provider_data["api_keys"]
if isinstance(raw_keys, list):
provider_config.api_keys = [
k for k in raw_keys if isinstance(k, str) and k.strip()
]
else:
provider_config.api_keys = []
if "api_base" in provider_data:
provider_config.api_base = provider_data["api_base"]
if request.model:
if "provider" in request.model:
config.model.provider = request.model["provider"]
if "model" in request.model:
config.model.model = request.model["model"]
if "api_mode" in request.model:
config.model.api_mode = _normalize_api_mode_value(request.model["api_mode"])
if "temperature" in request.model:
config.model.temperature = request.model["temperature"]
if "max_tokens" in request.model:
config.model.max_tokens = request.model["max_tokens"]
if "max_iterations" in request.model:
config.model.max_iterations = request.model["max_iterations"]
if "thinking_enabled" in request.model:
config.model.thinking_enabled = request.model["thinking_enabled"]
if request.workspace:
if "path" in request.workspace:
requested_workspace = request.workspace["path"]
if isinstance(requested_workspace, str) and requested_workspace.strip():
old_workspace = workspace_manager.get_workspace_path()
validated_workspace_path = _validate_workspace_path_or_raise(requested_workspace)
config.workspace.path = str(validated_workspace_path)
else:
config.workspace.path = requested_workspace
if request.security:
if "dangerous_commands_blocked" in request.security:
config.security.dangerous_commands_blocked = request.security["dangerous_commands_blocked"]
if "custom_deny_patterns" in request.security:
config.security.custom_deny_patterns = _normalize_pattern_list(
request.security["custom_deny_patterns"]
)
if "command_whitelist_enabled" in request.security:
config.security.command_whitelist_enabled = request.security["command_whitelist_enabled"]
if "custom_allow_patterns" in request.security:
config.security.custom_allow_patterns = _normalize_pattern_list(
request.security["custom_allow_patterns"]
)
if "audit_log_enabled" in request.security:
config.security.audit_log_enabled = request.security["audit_log_enabled"]
if "command_timeout" in request.security:
timeout = request.security["command_timeout"]
# 处理空字符串或无效值
if timeout == "" or timeout is None:
timeout = 180
elif isinstance(timeout, str):
try:
timeout = int(timeout)
except ValueError:
timeout = 180
# 确保在有效范围内
timeout = max(10, min(1800, int(timeout)))
config.security.command_timeout = timeout
if "subagent_timeout" in request.security:
timeout = request.security["subagent_timeout"]
# 处理空字符串或无效值
if timeout == "" or timeout is None:
timeout = 1200 # 默认值
elif isinstance(timeout, str):
try:
timeout = int(timeout)
except ValueError:
timeout = 1200
# 确保在有效范围内
timeout = max(60, min(3600, int(timeout)))
config.security.subagent_timeout = timeout
if "max_output_length" in request.security:
length = request.security["max_output_length"]
# 处理空字符串或无效值
if length == "" or length is None:
length = 10000 # 默认值
elif isinstance(length, str):
try:
length = int(length)
except ValueError:
length = 10000
# 确保在有效范围内
length = max(100, min(1000000, int(length)))
config.security.max_output_length = length
if "restrict_to_workspace" in request.security:
config.security.restrict_to_workspace = request.security["restrict_to_workspace"]
_validate_regex_patterns_or_raise(
config.security.custom_deny_patterns,
field_name="security.custom_deny_patterns",
)
_validate_regex_patterns_or_raise(
config.security.custom_allow_patterns,
field_name="security.custom_allow_patterns",
)
if request.persona:
if "ai_name" in request.persona:
config.persona.ai_name = request.persona["ai_name"]
if "user_name" in request.persona:
config.persona.user_name = request.persona["user_name"]
if "user_address" in request.persona:
config.persona.user_address = request.persona["user_address"]
if "output_language" in request.persona:
config.persona.output_language = request.persona["output_language"] or "中文"
if "personality" in request.persona:
config.persona.personality = request.persona["personality"]
if "custom_personality" in request.persona:
config.persona.custom_personality = request.persona["custom_personality"]
if "max_history_messages" in request.persona:
config.persona.max_history_messages = request.persona["max_history_messages"]
if "enable_short_context_summary" in request.persona:
config.persona.enable_short_context_summary = _coerce_boolean_value(
request.persona["enable_short_context_summary"],
field_name="persona.enable_short_context_summary",
)
if "heartbeat" in request.persona:
hb = request.persona["heartbeat"]
if isinstance(hb, dict):
if "enabled" in hb:
config.persona.heartbeat.enabled = hb["enabled"]
if "channel" in hb:
config.persona.heartbeat.channel = hb["channel"]
if "account_id" in hb:
config.persona.heartbeat.account_id = hb["account_id"] or "default"
if "chat_id" in hb:
config.persona.heartbeat.chat_id = hb["chat_id"]
if "schedule" in hb:
config.persona.heartbeat.schedule = hb["schedule"]
if "idle_threshold_hours" in hb:
config.persona.heartbeat.idle_threshold_hours = hb["idle_threshold_hours"]
if "quiet_start" in hb:
config.persona.heartbeat.quiet_start = hb["quiet_start"]
if "quiet_end" in hb:
config.persona.heartbeat.quiet_end = hb["quiet_end"]
if "max_greets_per_day" in hb:
config.persona.heartbeat.max_greets_per_day = hb["max_greets_per_day"]
if "heartbeat" in request.persona:
_validate_and_normalize_heartbeat_settings(config)
# 保存配置await 确保写入完成)
await config_loader.save_config(config)
config = config_loader.config
runtime_workspace = validated_workspace_path.resolve() if validated_workspace_path is not None else None
# 工作区迁移提示(如果有变更)
if runtime_workspace is not None:
try:
if old_workspace is not None:
migration_check = workspace_manager.check_skills_migration_needed(
old_workspace, runtime_workspace
)
if migration_check["needed"]:
workspace_migration_info = {
"migration_needed": True,
"old_path": str(old_workspace),
"new_path": str(runtime_workspace),
"old_skills_count": migration_check["old_skills_count"],
"new_skills_count": migration_check["new_skills_count"],
"message": f"检测到旧工作区有 {migration_check['old_skills_count']} 个技能,新工作区只有 {migration_check['new_skills_count']} 个。建议手动迁移技能文件。"
}
logger.warning(f"Skills migration may be needed: {workspace_migration_info['message']}")
except Exception as e:
logger.warning(f"Failed to check workspace migration: {e}")
await _apply_saved_config_runtime(
req,
config,
workspace_path=runtime_workspace,
reload_persona=bool(request.persona),
reload_provider_model=bool(request.providers or request.model),
reload_security=bool(request.security),
sync_heartbeat=bool(request.persona and "heartbeat" in request.persona),
)
if request.persona and "heartbeat" in request.persona:
current_heartbeat = config.persona.heartbeat.model_dump()
if _heartbeat_plan_settings_changed(previous_heartbeat, current_heartbeat):
cron_executor = getattr(req.app.state, "cron_executor", None)
heartbeat_service = getattr(cron_executor, "heartbeat_service", None)
if heartbeat_service is not None and hasattr(heartbeat_service, "refresh_today_schedule"):
try:
heartbeat_service.refresh_today_schedule(reason="设置页修改问候参数")
except Exception as e:
logger.warning(f"Failed to refresh heartbeat today schedule: {e}")
logger.info("Settings updated successfully")
# 返回更新后的设置
response = await get_settings()
# 添加工作区迁移提示(如果有)
if workspace_migration_info:
response.workspace_migration = workspace_migration_info
return response
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to update settings: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update settings: {str(e)}"
)
@router.get("/external-coding-tools", response_model=ExternalCodingToolsConfigResponse)
async def get_external_coding_tools_config() -> ExternalCodingToolsConfigResponse:
"""读取工作空间中的外部编码工具配置。"""
try:
registry, raw = _load_external_coding_tools_config()
payload = ExternalCodingToolsConfigPayload(**raw)
return ExternalCodingToolsConfigResponse(
config_path=str(registry.config_path),
**payload.model_dump(),
)
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to load external coding tools config: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to load external coding tools config: {str(e)}",
)
@router.put("/external-coding-tools", response_model=ExternalCodingToolsConfigResponse)
async def update_external_coding_tools_config(
request: ExternalCodingToolsConfigPayload,
) -> ExternalCodingToolsConfigResponse:
"""保存工作空间中的外部编码工具配置。"""
try:
registry = _get_external_coding_registry()
raw = _validate_external_coding_tools_payload(request, registry)
registry.config_path.parent.mkdir(parents=True, exist_ok=True)
registry.config_path.write_text(
json.dumps(raw, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
logger.info(f"Updated external coding tools config: {registry.config_path}")
return ExternalCodingToolsConfigResponse(
config_path=str(registry.config_path),
**request.model_dump(),
)
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to save external coding tools config: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to save external coding tools config: {str(e)}",
)
@router.post("/external-coding-tools/check", response_model=ExternalCodingProfileCheckResponse)
async def check_external_coding_tool_profile(
request: ExternalCodingProfileCheckRequest,
) -> ExternalCodingProfileCheckResponse:
"""检查单个外部编码工具 profile 的命令解析和环境变量情况。"""
try:
registry = _get_external_coding_registry()
payload = request.profile.model_dump()
parsed_profile = registry._parse_profile(payload, 0)
command = str(payload.get("command", "")).strip()
resolved_command = _resolve_profile_command(command)
missing_env = [
env_name
for env_name in (payload.get("inherit_env", []) or [])
if str(env_name).strip() and not os.getenv(str(env_name).strip())
]
if resolved_command:
message = f"命令可用: {resolved_command}"
if missing_env:
message += f";缺少环境变量: {', '.join(missing_env)}"
if (
parsed_profile.session_mode == "native"
and not profile_supports_native_session(parsed_profile)
):
message += ";当前原生会话模式会自动回退为最近历史模式"
return ExternalCodingProfileCheckResponse(
success=True,
message=message,
resolved_command=resolved_command,
missing_env=missing_env,
)
return ExternalCodingProfileCheckResponse(
success=False,
message=f"未找到可执行命令: {command}",
resolved_command=None,
missing_env=missing_env,
)
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to check external coding tool profile: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to check external coding tool profile: {str(e)}",
)
@router.post("/test-connection", response_model=TestConnectionResponse)
async def test_connection(request: TestConnectionRequest) -> TestConnectionResponse:
"""
测试 Provider 连接
Args:
request: 测试连接请求
Returns:
TestConnectionResponse: 测试结果
"""
logger.info(f"Testing connection to {request.provider} with model {request.model}")
try:
from backend.modules.providers import create_provider
from backend.modules.providers.registry import get_provider_metadata
# 获取 provider 元数据
provider_meta = get_provider_metadata(request.provider)
if not provider_meta:
return TestConnectionResponse(
success=False,
error=f"未知的 provider: {request.provider}",
)
# 使用用户提供的配置
test_model = (request.model or provider_meta.default_model or "").strip()
test_api_base = request.api_base or provider_meta.default_api_base
if not test_model:
return TestConnectionResponse(
success=False,
error="请先填写模型名称,再测试连接。",
)
logger.info(f"Using {provider_meta.name}, model: {test_model}, base: {test_api_base}")
# 创建临时 provider
provider = create_provider(
api_key=request.api_key,
api_base=test_api_base,
default_model=test_model,
api_mode=_normalize_api_mode_value(request.api_mode or config_loader.config.model.api_mode),
timeout=30.0,
max_retries=1,
provider_id=request.provider,
)
# 测试简单的聊天请求
test_messages = [{"role": "user", "content": "Hello"}]
response_received = False
error_message = None
response_content = ""
request_kwargs: Dict[str, Any] = {
"messages": test_messages,
"tools": None,
"model": test_model,
"max_tokens": request.max_tokens or 0,
}
if request.temperature is not None and request.temperature > 0:
request_kwargs["temperature"] = request.temperature
if request.thinking_enabled is not None:
request_kwargs["thinking_enabled"] = request.thinking_enabled
async for chunk in provider.chat_stream(**request_kwargs):
if chunk.error:
error_message = chunk.error
logger.error(f"Provider returned error: {chunk.error}")
break
if chunk.content:
response_content += chunk.content
response_received = True
if chunk.finish_reason:
logger.info(f"Stream finished with reason: {chunk.finish_reason}")
response_received = True
break
if error_message:
return TestConnectionResponse(
success=False,
error=error_message,
)
if response_received:
logger.info(f"Connection test successful for {request.provider}")
success_msg = f"Successfully connected to {request.provider}"
if response_content:
success_msg += f", received response: {response_content[:50]}"
return TestConnectionResponse(
success=True,
message=success_msg,
)
else:
return TestConnectionResponse(
success=False,
error="No response received from provider",
)
except Exception as e:
logger.exception(f"Connection test failed: {e}")
return TestConnectionResponse(
success=False,
error=str(e),
)
# ============================================================================
# 配置导出导入
# ============================================================================
@router.post("/workspace/select-directory")
async def select_directory():
"""
选择目录
Returns:
dict: 选择的目录路径
"""
try:
from backend.utils.file_dialog import select_directory as desktop_select_directory, is_desktop_environment
# 检查是否在桌面环境中
if not is_desktop_environment():
return {
"success": False,
"message": "目录选择功能仅在桌面环境中可用",
"path": None
}
# 打开目录选择对话框
selected_path = desktop_select_directory("选择工作空间目录")
if selected_path:
return {
"success": True,
"message": "目录选择成功",
"path": selected_path
}
else:
return {
"success": False,
"message": "用户取消选择",
"path": None
}
except Exception as e:
logger.error(f"选择目录失败: {e}")
return {
"success": False,
"message": f"选择目录失败: {str(e)}",
"path": None
}
@router.get("/workspace/info")
async def get_workspace_info(force: bool = False):
"""
获取工作空间信息
Args:
force: 是否强制刷新缓存
Returns:
dict: 工作空间详细信息
"""
try:
from backend.modules.workspace import workspace_manager
info = workspace_manager.get_workspace_info(force_refresh=force)
# 新的API返回格式已经包含格式化的大小直接返回
return info
except Exception as e:
logger.error(f"获取工作空间信息失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"获取工作空间信息失败: {str(e)}"
)
@router.post("/workspace/clean-temp")
async def clean_temp_files(request: Request):
"""
清理临时文件
Returns:
dict: 清理结果
"""
try:
from backend.modules.workspace import workspace_manager
# 获取请求参数
try:
body = await request.json()
max_age_hours = body.get('max_age_hours', 24)
clean_all = body.get('clean_all', False)
except Exception:
max_age_hours = 24
clean_all = False
# 确保参数在合理范围内
max_age_hours = max(1, min(168, int(max_age_hours))) # 1小时到7天
result = workspace_manager.clean_temp_files(max_age_hours, clean_all)
return result
except Exception as e:
logger.error(f"清理临时文件失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"清理临时文件失败: {str(e)}"
)
@router.post("/workspace/set-path")
async def set_workspace_path(request: Request):
"""
设置工作空间路径(支持热重载,无需重启)
Returns:
dict: 设置结果
"""
try:
from backend.modules.workspace import workspace_manager
from backend.modules.config.loader import config_loader
# 获取请求参数
try:
body = await request.json()
path = body.get('path', '').strip()
except Exception:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的请求参数"
)
if not path:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="路径不能为空"
)
workspace_path = _validate_workspace_path_or_raise(path)
updated_config = config_loader.config.model_copy(deep=True)
updated_config.workspace.path = str(workspace_path)
await config_loader.save_config(updated_config)
_hot_reload_workspace_runtime(request, workspace_path)
return {
"success": True,
"message": f"工作空间路径已设置为: {workspace_path}(已热重载,无需重启)",
"path": str(workspace_manager.workspace_path),
"reloaded": True
}
except HTTPException:
raise
except Exception as e:
logger.error(f"设置工作空间路径失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"设置工作空间路径失败: {str(e)}"
)
@router.get("/export")
async def export_settings(
include_api_keys: bool = False,
sections: Optional[str] = None
):
"""
导出配置
Args:
include_api_keys: 是否包含 API 密钥(默认不包含,保护敏感信息)
sections: 要导出的配置节逗号分隔providers,model,persona
Returns:
JSON 格式的配置文件
"""
try:
from datetime import datetime
# 添加日志以便调试
logger.info(f"导出配置请求: include_api_keys={include_api_keys} (type={type(include_api_keys).__name__}), sections={sections}")
config = config_loader.config
config_dict = config.model_dump()
# 过滤配置节
if sections:
section_list = [s.strip() for s in sections.split(',')]
config_dict = {k: v for k, v in config_dict.items() if k in section_list}
logger.info(f"过滤配置节: {section_list}")
# 移除敏感信息
if not include_api_keys:
logger.info("移除敏感信息API 密钥)")
# 移除 provider API 密钥
if 'providers' in config_dict:
for provider in config_dict['providers'].values():
if isinstance(provider, dict) and 'api_key' in provider:
provider['api_key'] = ""
# 移除渠道密钥
if 'channels' in config_dict:
for channel_name, channel_data in config_dict['channels'].items():
if isinstance(channel_data, dict):
# 移除各种密钥字段
for key in ['token', 'secret', 'app_secret', 'secret_key',
'client_secret', 'encoding_aes_key', 'encrypt_key']:
if key in channel_data:
channel_data[key] = ""
else:
logger.info("保留敏感信息(包含 API 密钥)")
# 构建导出数据
export_data = {
"version": "1.0.0",
"exported_at": datetime.utcnow().isoformat() + "Z",
"app_version": APP_VERSION,
"config": config_dict
}
logger.info(f"配置导出成功sections={sections}, include_api_keys={include_api_keys}")
return export_data
except Exception as e:
logger.error(f"导出配置失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"导出失败: {str(e)}"
)
class ImportSettingsRequest(BaseModel):
"""导入配置请求"""
version: str = Field(..., description="配置文件版本")
config: dict = Field(..., description="配置数据")
merge: bool = Field(default=False, description="是否合并现有配置")
sections: Optional[List[str]] = Field(None, description="要导入的配置节")
@router.post("/import")
async def import_settings(request: ImportSettingsRequest, req: Request):
"""
导入配置
Args:
request: 导入配置请求
Returns:
导入结果和更新后的配置
"""
try:
# 检查版本兼容性
version = request.version
if not version.startswith("1."):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"不支持的配置版本: {version},当前仅支持 1.x 版本"
)
import_config = request.config
# 过滤配置节
if request.sections:
import_config = {k: v for k, v in import_config.items() if k in request.sections}
# 获取当前配置
current_config = config_loader.config
current_dict = current_config.model_dump()
# 合并或覆盖
if request.merge:
# 深度合并
merged_dict = _deep_merge(current_dict, import_config)
logger.info(f"配置合并模式sections={request.sections}")
else:
# 覆盖指定节
merged_dict = current_dict.copy()
merged_dict.update(import_config)
logger.info(f"配置覆盖模式sections={request.sections}")
# 验证配置
try:
new_config = AppConfig(**merged_dict)
except Exception as e:
logger.error(f"配置验证失败: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"配置验证失败: {str(e)}"
)
# 保存配置
try:
await config_loader.save_config(new_config)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"配置中的工作空间路径不可用: {str(e)}"
) from e
config = config_loader.config
imported_sections = set(import_config.keys())
runtime_workspace = None
if "workspace" in imported_sections:
try:
runtime_workspace = Path(config.workspace.path).resolve()
except Exception as e:
logger.warning(f"Failed to resolve imported workspace for hot reload: {e}")
await _apply_saved_config_runtime(
req,
config,
workspace_path=runtime_workspace,
reload_persona="persona" in imported_sections,
reload_provider_model=bool({"providers", "model"} & imported_sections),
sync_heartbeat="persona" in imported_sections,
)
logger.info("配置导入成功")
# 返回更新后的配置
return {
"success": True,
"message": "配置导入成功",
"settings": await get_settings()
}
except HTTPException:
raise
except Exception as e:
logger.error(f"导入配置失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"导入失败: {str(e)}"
)
def _deep_merge(base: dict, update: dict) -> dict:
"""
深度合并字典
Args:
base: 基础字典
update: 更新字典
Returns:
合并后的字典
"""
result = base.copy()
for key, value in update.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = _deep_merge(result[key], value)
else:
result[key] = value
return result