refactor: 支持多 LLM 提供商自动检测

This commit is contained in:
ViperEkura 2026-03-27 13:21:20 +08:00
parent f25eb4ecfc
commit c2aff3e4a6
11 changed files with 377 additions and 204 deletions

172
README.md
View File

@ -4,14 +4,15 @@
## 功能特性 ## 功能特性
- 💬 **多轮对话** - 支持上下文管理的多轮对话 - 多轮对话 - 支持上下文管理的多轮对话
- 🔧 **工具调用** - 网页搜索、代码执行、文件操作等 - 工具调用 - 网页搜索、代码执行、文件操作等 13 个内置工具
- 🧠 **思维链** - 支持链式思考推理 - 思维链 - 支持链式思考推理DeepSeek R1 / GLM thinking
- 📁 **工作目录** - 项目级文件隔离,安全操作 - 工作目录 - 项目级文件隔离,安全操作
- 📊 **Token 统计** - 按日/周/月统计使用量 - Token 统计 - 按日/周/月统计使用量
- 🔄 **流式响应** - 实时 SSE 流式输出 - 流式响应 - 实时 SSE 流式输出,穿插显示思考/文本/工具调用
- 📝 **代码编辑器** - 基于 CodeMirror 6支持 15+ 语言语法高亮和暗色主题 - 代码编辑器 - 基于 CodeMirror 6支持 15+ 语言语法高亮和暗色主题
- 💾 **多数据库** - 支持 MySQL、SQLite、PostgreSQL - 多数据库 - 支持 MySQL、SQLite、PostgreSQL
- 多用户/单用户 - 支持单用户免登录和多用户 JWT 认证两种模式
## 快速开始 ## 快速开始
@ -32,37 +33,47 @@ pip install -e .
backend_port: 3000 backend_port: 3000
frontend_port: 4000 frontend_port: 4000
# LLM API (global defaults, can be overridden per model) # Max agentic loop iterations (tool call rounds)
default_api_key: {{your-api-key}} max_iterations: 15
default_api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
# Available models (each model can optionally specify its own api_key and api_url) # Available models
# Each model must have its own id, name, api_url, api_key
models: models:
- id: deepseek-chat
name: DeepSeek V3
api_url: https://api.deepseek.com/chat/completions
api_key: sk-xxx
- id: glm-5 - id: glm-5
name: GLM-5 name: GLM-5
# api_key: xxx # Optional, falls back to default_api_key api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
# api_url: xxx # Optional, falls back to default_api_url api_key: xxx
- id: glm-4-plus
name: GLM-4 Plus
default_model: glm-5 default_model: deepseek-chat
# Workspace root directory
workspace_root: ./workspaces
# Authentication
# "single": Single-user mode - no login required, auto-creates default user
# "multi": Multi-user mode - requires JWT, users must register/login
auth_mode: single
# JWT secret (only used in multi-user mode, change for production!)
jwt_secret: nano-claw-default-secret-change-in-production
# Database Configuration # Database Configuration
# Supported types: mysql, sqlite, postgresql
db_type: sqlite db_type: sqlite
# MySQL/PostgreSQL Settings (ignored for sqlite)
# db_host: localhost
# db_port: 3306
# db_user: root
# db_password: "123456"
# db_name: nano_claw
# SQLite Settings (ignored for mysql/postgresql)
db_sqlite_file: nano_claw.db db_sqlite_file: nano_claw.db
# Workspace Configuration
workspace_root: ./workspaces
# Authentication (optional, defaults to single-user mode)
# auth_mode: single # "single" (default) or "multi"
# jwt_secret: nano-claw-default-secret-change-in-production
``` ```
> **说明**`api_key` 支持环境变量替换,例如 `api_key: ${DEEPSEEK_API_KEY}`
### 3. 启动后端 ### 3. 启动后端
```bash ```bash
@ -81,30 +92,42 @@ npm run dev
``` ```
backend/ backend/
├── __init__.py # 应用工厂数据库初始化load_config()
├── config.py # 配置加载与验证
├── models.py # SQLAlchemy 数据模型 ├── models.py # SQLAlchemy 数据模型
├── run.py # 入口文件
├── routes/ # API 路由 ├── routes/ # API 路由
│ ├── auth.py # 认证(登录/注册/JWT │ ├── auth.py # 认证(登录/注册/JWT/profile
│ ├── conversations.py │ ├── conversations.py # 会话 CRUD
│ ├── messages.py │ ├── messages.py # 消息 CRUD + 聊天SSE 流式)
│ ├── projects.py # 项目管理 │ ├── models.py # 模型列表
│ └── ... │ ├── projects.py # 项目管理 + 文件操作
│ ├── stats.py # Token 使用统计
│ └── tools.py # 工具列表
├── services/ # 业务逻辑 ├── services/ # 业务逻辑
│ ├── chat.py # 聊天补全服务 │ ├── chat.py # 聊天补全服务SSE 流式 + 多轮工具调用)
│ └── glm_client.py │ └── llm_client.py # OpenAI 兼容 LLM API 客户端
├── tools/ # 工具系统 ├── tools/ # 工具系统
│ ├── core.py # 核心类 │ ├── core.py # 核心类ToolDefinition/ToolRegistry
│ ├── executor.py # 工具执行器 │ ├── factory.py # @tool 装饰器 + register_tool()
│ ├── executor.py # 工具执行器(缓存、去重、上下文注入)
│ ├── services.py # 辅助服务(搜索/抓取/计算)
│ └── builtin/ # 内置工具 │ └── builtin/ # 内置工具
├── utils/ # 辅助函数 │ ├── crawler.py # 网页搜索、抓取
│ ├── helpers.py │ ├── data.py # 计算器、文本、JSON 处理
│ └── workspace.py # 工作目录工具 │ ├── weather.py # 天气查询(模拟)
└── migrations/ # 数据库迁移 │ ├── file_ops.py # 文件操作6 个工具project_id 自动注入)
│ └── code.py # Python 代码执行(沙箱)
└── utils/ # 辅助函数
├── helpers.py # 通用函数ok/err/build_messages 等)
└── workspace.py # 工作目录工具(路径验证、项目目录管理)
frontend/ frontend/
└── src/ └── src/
├── api/ # API 请求层 ├── api/ # API 请求层request + SSE 流解析)
├── components/ # Vue 组件 ├── components/ # Vue 组件12 个)
└── views/ # 页面 ├── composables/ # 组合式函数(主题/模态框/Toast
└── utils/ # 工具模块Markdown 渲染/代码高亮/图标)
``` ```
## 工作目录系统 ## 工作目录系统
@ -117,28 +140,63 @@ frontend/
1. **创建项目** - 在侧边栏点击"新建项目"或上传文件夹 1. **创建项目** - 在侧边栏点击"新建项目"或上传文件夹
2. **选择项目** - 在对话中选择当前工作目录 2. **选择项目** - 在对话中选择当前工作目录
3. **文件操作** - AI 自动在项目目录内执行文件操作 3. **文件操作** - AI 自动在项目目录内执行文件操作`project_id` 由后端自动注入,对 AI 透明)
### 安全机制 ### 安全机制
- 所有文件操作需要 `project_id` 参数 - 所有文件工具的 `project_id` 由后端自动注入AI 不可见也不可伪造
- 后端强制验证路径在项目目录内 - 后端强制验证路径在项目目录内
- 阻止目录遍历攻击(如 `../../../etc/passwd` - 阻止目录遍历攻击(如 `../../../etc/passwd`
## API 概览 ## API 概览
### 认证
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|------|------|------| |------|------|------|
| `GET` | `/api/auth/mode` | 获取当前认证模式 |
| `POST` | `/api/auth/login` | 用户登录 | | `POST` | `/api/auth/login` | 用户登录 |
| `POST` | `/api/auth/register` | 用户注册 | | `POST` | `/api/auth/register` | 用户注册 |
| `GET` | `/api/conversations` | 会话列表 | | `GET` | `/api/auth/profile` | 获取当前用户信息 |
| `PATCH` | `/api/auth/profile` | 更新当前用户信息 |
### 会话管理
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET/POST` | `/api/conversations` | 会话列表 / 创建会话 |
| `GET` | `/api/conversations/:id` | 会话详情 |
| `PATCH` | `/api/conversations/:id` | 更新会话 |
| `DELETE` | `/api/conversations/:id` | 删除会话 |
### 消息管理
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET` | `/api/conversations/:id/messages` | 消息列表 | | `GET` | `/api/conversations/:id/messages` | 消息列表 |
| `POST` | `/api/conversations/:id/messages` | 发送消息SSE 流式) | | `POST` | `/api/conversations/:id/messages` | 发送消息SSE 流式) |
| `GET` | `/api/projects` | 项目列表 | | `DELETE` | `/api/conversations/:id/messages/:mid` | 删除消息 |
| `POST` | `/api/projects` | 创建项目 | | `POST` | `/api/conversations/:id/regenerate/:mid` | 重新生成消息 |
| `POST` | `/api/projects/upload` | 上传文件夹 |
| `GET` | `/api/tools` | 工具列表 | ### 项目管理
| `GET` | `/api/stats/tokens` | Token 统计 |
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET/POST` | `/api/projects` | 项目列表 / 创建项目 |
| `GET/PUT/DELETE` | `/api/projects/:id` | 项目详情 / 更新 / 删除 |
| `POST` | `/api/projects/upload` | 上传文件夹作为项目 |
| `GET` | `/api/projects/:id/files` | 列出项目文件 |
| `GET/PUT/PATCH/DELETE` | `/api/projects/:id/files/:path` | 文件 CRUD |
| `POST` | `/api/projects/:id/directories` | 创建目录 |
| `POST` | `/api/projects/:id/search` | 搜索文件内容 |
### 其他
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET` | `/api/models` | 获取可用模型列表 |
| `GET` | `/api/tools` | 获取工具列表 |
| `GET` | `/api/stats/tokens` | Token 使用统计 |
## 内置工具 ## 内置工具
@ -147,16 +205,16 @@ frontend/
| **爬虫** | web_search, fetch_page, crawl_batch | 网页搜索和抓取 | | **爬虫** | web_search, fetch_page, crawl_batch | 网页搜索和抓取 |
| **数据处理** | calculator, text_process, json_process | 数学计算和文本处理 | | **数据处理** | calculator, text_process, json_process | 数学计算和文本处理 |
| **代码执行** | execute_python | 沙箱环境执行 Python | | **代码执行** | execute_python | 沙箱环境执行 Python |
| **文件操作** | file_read, file_write, file_list 等 | **需要 project_id** | | **文件操作** | file_read, file_write, file_delete, file_list, file_exists, file_mkdir | project_id 自动注入 |
| **天气** | get_weather | 天气查询(模拟) | | **天气** | get_weather | 天气查询(模拟) |
## 文档 ## 文档
- [后端设计](docs/Design.md) - 架构设计、数据模型、API 文档 - [后端设计](docs/Design.md) - 架构设计、数据模型、API 文档、SSE 事件
- [工具系统](docs/ToolSystemDesign.md) - 工具开发指南、安全设计 - [工具系统](docs/ToolSystemDesign.md) - 工具开发指南、安全设计
## 技术栈 ## 技术栈
- **后端**: Python 3.11+, Flask, SQLAlchemy - **后端**: Python 3.10+, Flask, SQLAlchemy, requests
- **前端**: Vue 3, Vite, CodeMirror 6 - **前端**: Vue 3, Vite 6, CodeMirror 6, marked, highlight.js, KaTeX
- **LLM**: 支持 GLM 等大语言模型 - **LLM**: 支持任何 OpenAI 兼容 APIDeepSeek、GLM、OpenAI、Moonshot、Qwen 等)

View File

@ -1,26 +1,35 @@
"""Configuration management""" """Configuration management"""
import sys
from backend import load_config from backend import load_config
_cfg = load_config() _cfg = load_config()
# Global defaults
DEFAULT_API_URL = _cfg.get("api_url", "") or _cfg.get("default_api_url", "")
DEFAULT_API_KEY = _cfg.get("api_key", "") or _cfg.get("default_api_key", "")
# Model list (for /api/models endpoint) # Model list (for /api/models endpoint)
MODELS = _cfg.get("models", []) MODELS = _cfg.get("models", [])
# Per-model config lookup: {model_id: {api_url, api_key}} # Validate each model has required fields at startup
# Falls back to global defaults if not specified per model _REQUIRED_MODEL_KEYS = {"id", "name", "api_url", "api_key"}
MODEL_CONFIG = {} _model_ids_seen = set()
for _m in MODELS: for _i, _m in enumerate(MODELS):
_mid = _m["id"] _missing = _REQUIRED_MODEL_KEYS - set(_m.keys())
MODEL_CONFIG[_mid] = { if _missing:
"api_url": _m.get("api_url", DEFAULT_API_URL), print(f"[config] ERROR: models[{_i}] missing required fields: {_missing}", file=sys.stderr)
"api_key": _m.get("api_key", DEFAULT_API_KEY), sys.exit(1)
} if _m["id"] in _model_ids_seen:
print(f"[config] ERROR: duplicate model id '{_m['id']}'", file=sys.stderr)
sys.exit(1)
_model_ids_seen.add(_m["id"])
DEFAULT_MODEL = _cfg.get("default_model", "glm-5") # Per-model config lookup: {model_id: {api_url, api_key}}
MODEL_CONFIG = {m["id"]: {"api_url": m["api_url"], "api_key": m["api_key"]} for m in MODELS}
# default_model must exist in models
DEFAULT_MODEL = _cfg.get("default_model", "")
if DEFAULT_MODEL and DEFAULT_MODEL not in MODEL_CONFIG:
print(f"[config] ERROR: default_model '{DEFAULT_MODEL}' not found in models", file=sys.stderr)
sys.exit(1)
if MODELS and not DEFAULT_MODEL:
DEFAULT_MODEL = MODELS[0]["id"]
# Max agentic loop iterations (tool call rounds) # Max agentic loop iterations (tool call rounds)
MAX_ITERATIONS = _cfg.get("max_iterations", 5) MAX_ITERATIONS = _cfg.get("max_iterations", 5)

View File

@ -7,15 +7,15 @@ from backend.routes.tools import bp as tools_bp
from backend.routes.stats import bp as stats_bp from backend.routes.stats import bp as stats_bp
from backend.routes.projects import bp as projects_bp from backend.routes.projects import bp as projects_bp
from backend.routes.auth import bp as auth_bp, init_auth from backend.routes.auth import bp as auth_bp, init_auth
from backend.services.glm_client import GLMClient from backend.services.llm_client import LLMClient
from backend.config import MODEL_CONFIG from backend.config import MODEL_CONFIG
def register_routes(app: Flask): def register_routes(app: Flask):
"""Register all route blueprints""" """Register all route blueprints"""
# Initialize GLM client with per-model config # Initialize LLM client with per-model config
glm_client = GLMClient(MODEL_CONFIG) client = LLMClient(MODEL_CONFIG)
init_chat_service(glm_client) init_chat_service(client)
# Initialize authentication system (reads auth_mode from config.yml) # Initialize authentication system (reads auth_mode from config.yml)
init_auth(app) init_auth(app)

View File

@ -14,10 +14,10 @@ bp = Blueprint("messages", __name__)
_chat_service = None _chat_service = None
def init_chat_service(glm_client): def init_chat_service(client):
"""Initialize chat service with GLM client""" """Initialize chat service with LLM client"""
global _chat_service global _chat_service
_chat_service = ChatService(glm_client) _chat_service = ChatService(client)
def _get_conv(conv_id): def _get_conv(conv_id):

View File

@ -5,8 +5,15 @@ from backend.config import MODELS
bp = Blueprint("models", __name__) bp = Blueprint("models", __name__)
# Keys that should never be exposed to the frontend
_SENSITIVE_KEYS = {"api_key", "api_url"}
@bp.route("/api/models", methods=["GET"]) @bp.route("/api/models", methods=["GET"])
def list_models(): def list_models():
"""Get available model list""" """Get available model list (without sensitive fields like api_key)"""
return ok(MODELS) safe_models = [
{k: v for k, v in m.items() if k not in _SENSITIVE_KEYS}
for m in MODELS
]
return ok(safe_models)

View File

@ -1,8 +1,8 @@
"""Backend services""" """Backend services"""
from backend.services.glm_client import GLMClient from backend.services.llm_client import LLMClient
from backend.services.chat import ChatService from backend.services.chat import ChatService
__all__ = [ __all__ = [
"GLMClient", "LLMClient",
"ChatService", "ChatService",
] ]

View File

@ -9,15 +9,15 @@ from backend.utils.helpers import (
record_token_usage, record_token_usage,
build_messages, build_messages,
) )
from backend.services.glm_client import GLMClient from backend.services.llm_client import LLMClient
from backend.config import MAX_ITERATIONS from backend.config import MAX_ITERATIONS
class ChatService: class ChatService:
"""Chat completion service with tool support""" """Chat completion service with tool support"""
def __init__(self, glm_client: GLMClient): def __init__(self, llm: LLMClient):
self.glm_client = glm_client self.llm = llm
self.executor = ToolExecutor(registry=registry) self.executor = ToolExecutor(registry=registry)
@ -76,7 +76,7 @@ class ChatService:
try: try:
with app.app_context(): with app.app_context():
active_conv = db.session.get(Conversation, conv_id) active_conv = db.session.get(Conversation, conv_id)
resp = self.glm_client.call( resp = self.llm.call(
model=active_conv.model, model=active_conv.model,
messages=messages, messages=messages,
max_tokens=active_conv.max_tokens, max_tokens=active_conv.max_tokens,

View File

@ -1,59 +0,0 @@
"""GLM API client"""
import requests
from typing import Optional, List
class GLMClient:
"""GLM API client for chat completions"""
def __init__(self, model_config: dict):
"""Initialize with per-model config lookup.
Args:
model_config: {model_id: {"api_url": ..., "api_key": ...}}
"""
self.model_config = model_config
def _get_credentials(self, model: str):
"""Get api_url and api_key for a model, with fallback."""
cfg = self.model_config.get(model, {})
return cfg.get("api_url", ""), cfg.get("api_key", "")
def call(
self,
model: str,
messages: List[dict],
max_tokens: int = 65536,
temperature: float = 1.0,
thinking_enabled: bool = False,
tools: Optional[List[dict]] = None,
stream: bool = False,
timeout: int = 120,
):
"""Call GLM API"""
api_url, api_key = self._get_credentials(model)
body = {
"model": model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
}
if thinking_enabled:
body["thinking"] = {"type": "enabled"}
if tools:
body["tools"] = tools
body["tool_choice"] = "auto"
if stream:
body["stream"] = True
body["stream_options"] = {"include_usage": True}
return requests.post(
api_url,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}"
},
json=body,
stream=stream,
timeout=timeout,
)

View File

@ -0,0 +1,140 @@
"""OpenAI-compatible LLM API client
Supports any provider that follows the OpenAI chat completions API format:
- Zhipu GLM (open.bigmodel.cn)
- DeepSeek (api.deepseek.com)
- OpenAI, Moonshot, Qwen, etc.
"""
import os
import re
import time
import requests
from typing import Optional, List
def _resolve_env_vars(value: str) -> str:
"""Replace ${VAR} or $VAR with environment variable values."""
if not isinstance(value, str):
return value
def _replace(m):
var = m.group(1) or m.group(2)
return os.environ.get(var, m.group(0))
return re.sub(r'\$\{(\w+)\}|\$(\w+)', _replace, value)
def _detect_provider(api_url: str) -> str:
"""Detect provider from api_url, returns provider name."""
if "deepseek" in api_url:
return "deepseek"
elif "bigmodel" in api_url:
return "glm"
else:
return "openai"
class LLMClient:
"""OpenAI-compatible LLM API client.
Each model must have its own api_url and api_key configured in MODEL_CONFIG.
"""
def __init__(self, model_config: dict):
"""Initialize with per-model config lookup.
Args:
model_config: {model_id: {"api_url": ..., "api_key": ...}}
"""
self.model_config = model_config
def _get_credentials(self, model: str):
"""Get api_url and api_key for a model, with env-var expansion."""
cfg = self.model_config.get(model)
if not cfg:
raise ValueError(f"Unknown model: '{model}', not found in config")
api_url = _resolve_env_vars(cfg.get("api_url", ""))
api_key = _resolve_env_vars(cfg.get("api_key", ""))
if not api_url:
raise ValueError(f"Model '{model}' has no api_url configured")
if not api_key:
raise ValueError(f"Model '{model}' has no api_key configured")
return api_url, api_key
def _build_body(self, model, messages, max_tokens, temperature, thinking_enabled, tools, stream, api_url):
"""Build request body with provider-specific parameter adaptation."""
provider = _detect_provider(api_url)
body = {
"model": model,
"messages": messages,
"temperature": temperature,
}
# --- Provider-specific: max_tokens ---
if provider == "deepseek":
body["max_tokens"] = min(max_tokens, 8192)
elif provider == "glm":
body["max_tokens"] = min(max_tokens, 65536)
else:
body["max_tokens"] = max_tokens
# --- Provider-specific: thinking ---
if thinking_enabled:
if provider == "glm":
body["thinking"] = {"type": "enabled"}
elif provider == "deepseek":
pass # deepseek-reasoner has built-in reasoning, no extra param
# --- Provider-specific: tools ---
if tools:
body["tools"] = tools
body["tool_choice"] = "auto"
# --- Provider-specific: stream ---
if stream:
body["stream"] = True
if provider == "glm":
body["stream_options"] = {"include_usage": True}
elif provider == "deepseek":
pass # DeepSeek does not support stream_options
return body
def call(
self,
model: str,
messages: List[dict],
max_tokens: int = 65536,
temperature: float = 1.0,
thinking_enabled: bool = False,
tools: Optional[List[dict]] = None,
stream: bool = False,
timeout: int = 120,
max_retries: int = 3,
):
"""Call LLM API with retry on rate limit (429)"""
api_url, api_key = self._get_credentials(model)
body = self._build_body(
model, messages, max_tokens, temperature,
thinking_enabled, tools, stream, api_url,
)
for attempt in range(max_retries + 1):
resp = requests.post(
api_url,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}"
},
json=body,
stream=stream,
timeout=timeout,
)
if resp.status_code == 429 and attempt < max_retries:
wait = 2 ** attempt
time.sleep(wait)
continue
return resp
return resp

View File

@ -9,21 +9,28 @@ from pathlib import Path
from backend.tools.factory import tool from backend.tools.factory import tool
# Whitelist of allowed modules # Blacklist of dangerous modules - all other modules are allowed
ALLOWED_MODULES = { BLOCKED_MODULES = {
# Standard library - math and data processing # System-level access
"math", "random", "statistics", "itertools", "functools", "operator", "os", "sys", "subprocess", "shutil", "signal", "ctypes",
"collections", "decimal", "fractions", "numbers", "multiprocessing", "threading", "_thread",
# String processing # Network access
"string", "re", "textwrap", "unicodedata", "socket", "http", "urllib", "requests", "ftplib", "smtplib",
# Data formats "telnetlib", "xmlrpc", "asyncio",
"json", "csv", "datetime", "time", # File system / I/O
# Data structures "pathlib", "io", "glob", "tempfile", "shutil", "fnmatch",
"heapq", "bisect", "array", "copy", # Code execution / introspection
# Type related "importlib", "pkgutil", "code", "codeop", "compileall",
"typing", "types", "dataclasses", "runpy", "pdb", "profile", "cProfile",
# Dangerous stdlib
"webbrowser", "antigravity", "turtle",
# IPC / persistence
"pickle", "shelve", "marshal", "sqlite3", "dbm",
# Process / shell
"commands", "pipes", "pty", "posix", "posixpath",
} }
# Blacklist of dangerous builtins # Blacklist of dangerous builtins
BLOCKED_BUILTINS = { BLOCKED_BUILTINS = {
"eval", "exec", "compile", "open", "input", "eval", "exec", "compile", "open", "input",
@ -35,13 +42,14 @@ BLOCKED_BUILTINS = {
@tool( @tool(
name="execute_python", name="execute_python",
description="Execute Python code in a sandboxed environment. Supports math, data processing, and string operations. Max execution time: 10 seconds.", description="Execute Python code in a sandboxed environment. Most standard library modules are allowed, with dangerous modules (os, subprocess, socket, etc.) blocked. Max execution time: 10 seconds.",
parameters={ parameters={
"type": "object", "type": "object",
"properties": { "properties": {
"code": { "code": {
"type": "string", "type": "string",
"description": "Python code to execute. Only standard library modules allowed." "description": "Python code to execute. Dangerous modules (os, subprocess, socket, etc.) are blocked."
} }
}, },
"required": ["code"] "required": ["code"]
@ -53,7 +61,7 @@ def execute_python(arguments: dict) -> dict:
Execute Python code safely with sandboxing. Execute Python code safely with sandboxing.
Security measures: Security measures:
1. Restricted imports (whitelist) 1. Blocked dangerous imports (blacklist)
2. Blocked dangerous builtins 2. Blocked dangerous builtins
3. Timeout limit (10s) 3. Timeout limit (10s)
4. No file system access 4. No file system access
@ -66,7 +74,7 @@ def execute_python(arguments: dict) -> dict:
if dangerous_imports: if dangerous_imports:
return { return {
"success": False, "success": False,
"error": f"Blocked imports: {', '.join(dangerous_imports)}. Only standard library modules allowed: {', '.join(sorted(ALLOWED_MODULES))}" "error": f"Blocked imports: {', '.join(dangerous_imports)}. These modules are not allowed for security reasons."
} }
# Security check: detect dangerous function calls # Security check: detect dangerous function calls
@ -124,7 +132,7 @@ def _build_safe_code(code: str) -> str:
def _check_dangerous_imports(code: str) -> list: def _check_dangerous_imports(code: str) -> list:
"""Check for disallowed imports""" """Check for blocked (blacklisted) imports"""
try: try:
tree = ast.parse(code) tree = ast.parse(code)
except SyntaxError: except SyntaxError:
@ -135,12 +143,12 @@ def _check_dangerous_imports(code: str) -> list:
if isinstance(node, ast.Import): if isinstance(node, ast.Import):
for alias in node.names: for alias in node.names:
module = alias.name.split(".")[0] module = alias.name.split(".")[0]
if module not in ALLOWED_MODULES: if module in BLOCKED_MODULES:
dangerous.append(module) dangerous.append(module)
elif isinstance(node, ast.ImportFrom): elif isinstance(node, ast.ImportFrom):
if node.module: if node.module:
module = node.module.split(".")[0] module = node.module.split(".")[0]
if module not in ALLOWED_MODULES: if module in BLOCKED_MODULES:
dangerous.append(module) dangerous.append(module)
return dangerous return dangerous

View File

@ -53,7 +53,7 @@ backend/
├── services/ # 业务逻辑 ├── services/ # 业务逻辑
│ ├── __init__.py │ ├── __init__.py
│ ├── chat.py # 聊天补全服务 │ ├── chat.py # 聊天补全服务
│ └── glm_client.py # GLM API 客户端 │ └── llm_client.py # OpenAI 兼容 LLM API 客户端
├── tools/ # 工具系统 ├── tools/ # 工具系统
│ ├── __init__.py │ ├── __init__.py
@ -72,9 +72,6 @@ backend/
│ ├── __init__.py │ ├── __init__.py
│ ├── helpers.py # 通用函数 │ ├── helpers.py # 通用函数
│ └── workspace.py # 工作目录工具 │ └── workspace.py # 工作目录工具
└── migrations/ # 数据库迁移
└── add_project_support.py
``` ```
--- ---
@ -250,16 +247,18 @@ classDiagram
direction TB direction TB
class ChatService { class ChatService {
-GLMClient glm_client -LLMClient llm
-ToolExecutor executor -ToolExecutor executor
+stream_response(conv, tools_enabled, project_id) Response +stream_response(conv, tools_enabled, project_id) Response
-_build_tool_calls_json(calls, results) list -_build_tool_calls_json(calls, results) list
-_process_tool_calls_delta(delta, list) list -_process_tool_calls_delta(delta, list) list
} }
class GLMClient { class LLMClient {
-dict model_config -dict model_config
+_get_credentials(model) (api_url, api_key) +_get_credentials(model) (api_url, api_key)
+_detect_provider(api_url) str
+_build_body(model, messages, ...) dict
+call(model, messages, kwargs) Response +call(model, messages, kwargs) Response
} }
@ -268,11 +267,10 @@ classDiagram
-dict _cache -dict _cache
-list _call_history -list _call_history
+process_tool_calls(calls, context) list +process_tool_calls(calls, context) list
+build_request(messages, model, tools) dict
+clear_history() void +clear_history() void
} }
ChatService --> GLMClient : 使用 ChatService --> LLMClient : 使用
ChatService --> ToolExecutor : 使用 ChatService --> ToolExecutor : 使用
``` ```
@ -356,6 +354,9 @@ def delete_project_directory(project_path: str) -> bool:
def copy_folder_to_project(source_path: str, project_dir: Path, project_name: str) -> dict: def copy_folder_to_project(source_path: str, project_dir: Path, project_name: str) -> dict:
"""复制文件夹到项目目录""" """复制文件夹到项目目录"""
def save_uploaded_files(files, project_dir: Path) -> dict:
"""保存上传文件到项目目录"""
``` ```
### 安全机制 ### 安全机制
@ -548,17 +549,17 @@ def process_tool_calls(self, tool_calls, context=None):
1. **读取**:通过 `response.body.getReader()` 获取可读流,循环 `reader.read()` 读取二进制 chunk 1. **读取**:通过 `response.body.getReader()` 获取可读流,循环 `reader.read()` 读取二进制 chunk
2. **解码拼接**`TextDecoder` 将二进制解码为 UTF-8 字符串,追加到 `buffer`(处理跨 chunk 的不完整行) 2. **解码拼接**`TextDecoder` 将二进制解码为 UTF-8 字符串,追加到 `buffer`(处理跨 chunk 的不完整行)
3. **切行**:按 `\n` 分割,最后一段保留在 `buffer` 中(可能是不完整的 SSE 行) 3. **切行**:按 `\n` 分割,最后一段保留在 `buffer` 中(可能是不完整的 SSE 行)
4. **解析分发**:逐行匹配 `event: xxx` 设置事件类型,`data: {...}` 解析 JSON 后分发到对应回调(`onThinking` / `onMessage` / `onProcessStep` / `onDone` / `onError` 4. **解析分发**:逐行匹配 `event: xxx` 设置事件类型,`data: {...}` 解析 JSON 后分发到对应回调(`onProcessStep` / `onDone` / `onError`
``` ```
后端 yield: event: thinking\ndata: {"content":"..."}\n\n 后端 yield: event: process_step\ndata: {"id":"step-0","type":"thinking","content":"..."}\n\n
↓ TCP可能跨多个网络包 ↓ TCP可能跨多个网络包
reader.read(): [二进制片段1] → [二进制片段2] → ... reader.read(): [二进制片段1] → [二进制片段2] → ...
buffer 拼接: "event: thinking\ndata: {\"content\":\"...\"}\n\n" buffer 拼接: "event: process_step\ndata: {\"id\":\"step-0\",...}\n\n"
↓ split('\n') ↓ split('\n')
逐行解析: event: → "thinking" 逐行解析: event: → "process_step"
data: → JSON.parse → onThinking(data.content) data: → JSON.parse → onProcessStep(data)
``` ```
--- ---
@ -923,41 +924,50 @@ if name.startswith("file_") and context and "project_id" in context:
backend_port: 3000 backend_port: 3000
frontend_port: 4000 frontend_port: 4000
# LLM API全局默认值每个 model 可单独覆盖) # 智能体循环最大迭代次数(工具调用轮次上限,默认 5
default_api_key: your-api-key max_iterations: 15
default_api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
# 可用模型列表 # 可用模型列表(每个模型必须指定 api_url 和 api_key
# 支持任何 OpenAI 兼容 APIDeepSeek、GLM、OpenAI、Moonshot、Qwen 等)
models: models:
- id: deepseek-chat
name: DeepSeek V3
api_url: https://api.deepseek.com/chat/completions
api_key: sk-xxx
- id: glm-5 - id: glm-5
name: GLM-5 name: GLM-5
# api_key: ... # 可选,不指定则用 default_api_key api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
# api_url: ... # 可选,不指定则用 default_api_url api_key: xxx
- id: glm-5-turbo
name: GLM-5 Turbo
api_key: another-key # 该模型使用独立凭证
api_url: https://other.api.com/chat/completions
# 默认模型 # 默认模型(必须存在于 models 列表中)
default_model: glm-5 default_model: deepseek-chat
# 智能体循环最大迭代次数(工具调用轮次上限,默认 5
max_iterations: 5
# 工作区根目录 # 工作区根目录
workspace_root: ./workspaces workspace_root: ./workspaces
# 认证模式single单用户无需登录 / multi多用户需要 JWT # 认证模式(可选,默认 single
# single: 单用户模式,无需登录,自动创建默认用户
# multi: 多用户模式,需要 JWT 认证
auth_mode: single auth_mode: single
# JWT 密钥(仅多用户模式使用,生产环境请替换为随机值) # JWT 密钥(仅多用户模式使用,生产环境请替换为随机值)
jwt_secret: nano-claw-default-secret-change-in-production jwt_secret: nano-claw-default-secret-change-in-production
# 数据库 # 数据库(支持 mysql, sqlite, postgresql
db_type: mysql # mysql, sqlite, postgresql db_type: sqlite
# MySQL/PostgreSQL 配置sqlite 模式下忽略)
db_host: localhost db_host: localhost
db_port: 3306 db_port: 3306
db_user: root db_user: root
db_password: "" db_password: "123456"
db_name: nano_claw db_name: nano_claw
db_sqlite_file: app.db # SQLite 时使用
# SQLite 配置mysql/postgresql 模式下忽略)
db_sqlite_file: nano_claw.db
``` ```
> **说明**
> - `api_key``api_url` 支持环境变量替换,例如 `api_key: ${DEEPSEEK_API_KEY}`
> - 不配置 `auth_mode` 时默认为 `single` 模式
> - `LLMClient` 会根据 `api_url` 自动检测提供商DeepSeek / GLM / OpenAI并适配不同的参数max_tokens 上限、thinking 参数、stream_options 等)
> - 遇到 429 限流时自动重试(最多 3 次,指数退避)