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

182
README.md
View File

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

View File

@ -1,26 +1,35 @@
"""Configuration management"""
import sys
from backend import 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)
MODELS = _cfg.get("models", [])
# Per-model config lookup: {model_id: {api_url, api_key}}
# Falls back to global defaults if not specified per model
MODEL_CONFIG = {}
for _m in MODELS:
_mid = _m["id"]
MODEL_CONFIG[_mid] = {
"api_url": _m.get("api_url", DEFAULT_API_URL),
"api_key": _m.get("api_key", DEFAULT_API_KEY),
}
# Validate each model has required fields at startup
_REQUIRED_MODEL_KEYS = {"id", "name", "api_url", "api_key"}
_model_ids_seen = set()
for _i, _m in enumerate(MODELS):
_missing = _REQUIRED_MODEL_KEYS - set(_m.keys())
if _missing:
print(f"[config] ERROR: models[{_i}] missing required fields: {_missing}", file=sys.stderr)
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_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.projects import bp as projects_bp
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
def register_routes(app: Flask):
"""Register all route blueprints"""
# Initialize GLM client with per-model config
glm_client = GLMClient(MODEL_CONFIG)
init_chat_service(glm_client)
# Initialize LLM client with per-model config
client = LLMClient(MODEL_CONFIG)
init_chat_service(client)
# Initialize authentication system (reads auth_mode from config.yml)
init_auth(app)

View File

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

View File

@ -5,8 +5,15 @@ from backend.config import MODELS
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"])
def list_models():
"""Get available model list"""
return ok(MODELS)
"""Get available model list (without sensitive fields like api_key)"""
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"""
from backend.services.glm_client import GLMClient
from backend.services.llm_client import LLMClient
from backend.services.chat import ChatService
__all__ = [
"GLMClient",
"LLMClient",
"ChatService",
]

View File

@ -9,15 +9,15 @@ from backend.utils.helpers import (
record_token_usage,
build_messages,
)
from backend.services.glm_client import GLMClient
from backend.services.llm_client import LLMClient
from backend.config import MAX_ITERATIONS
class ChatService:
"""Chat completion service with tool support"""
def __init__(self, glm_client: GLMClient):
self.glm_client = glm_client
def __init__(self, llm: LLMClient):
self.llm = llm
self.executor = ToolExecutor(registry=registry)
@ -76,7 +76,7 @@ class ChatService:
try:
with app.app_context():
active_conv = db.session.get(Conversation, conv_id)
resp = self.glm_client.call(
resp = self.llm.call(
model=active_conv.model,
messages=messages,
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
# Whitelist of allowed modules
ALLOWED_MODULES = {
# Standard library - math and data processing
"math", "random", "statistics", "itertools", "functools", "operator",
"collections", "decimal", "fractions", "numbers",
# String processing
"string", "re", "textwrap", "unicodedata",
# Data formats
"json", "csv", "datetime", "time",
# Data structures
"heapq", "bisect", "array", "copy",
# Type related
"typing", "types", "dataclasses",
# Blacklist of dangerous modules - all other modules are allowed
BLOCKED_MODULES = {
# System-level access
"os", "sys", "subprocess", "shutil", "signal", "ctypes",
"multiprocessing", "threading", "_thread",
# Network access
"socket", "http", "urllib", "requests", "ftplib", "smtplib",
"telnetlib", "xmlrpc", "asyncio",
# File system / I/O
"pathlib", "io", "glob", "tempfile", "shutil", "fnmatch",
# Code execution / introspection
"importlib", "pkgutil", "code", "codeop", "compileall",
"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
BLOCKED_BUILTINS = {
"eval", "exec", "compile", "open", "input",
@ -35,13 +42,14 @@ BLOCKED_BUILTINS = {
@tool(
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={
"type": "object",
"properties": {
"code": {
"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"]
@ -53,7 +61,7 @@ def execute_python(arguments: dict) -> dict:
Execute Python code safely with sandboxing.
Security measures:
1. Restricted imports (whitelist)
1. Blocked dangerous imports (blacklist)
2. Blocked dangerous builtins
3. Timeout limit (10s)
4. No file system access
@ -66,7 +74,7 @@ def execute_python(arguments: dict) -> dict:
if dangerous_imports:
return {
"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
@ -124,7 +132,7 @@ def _build_safe_code(code: str) -> str:
def _check_dangerous_imports(code: str) -> list:
"""Check for disallowed imports"""
"""Check for blocked (blacklisted) imports"""
try:
tree = ast.parse(code)
except SyntaxError:
@ -135,12 +143,12 @@ def _check_dangerous_imports(code: str) -> list:
if isinstance(node, ast.Import):
for alias in node.names:
module = alias.name.split(".")[0]
if module not in ALLOWED_MODULES:
if module in BLOCKED_MODULES:
dangerous.append(module)
elif isinstance(node, ast.ImportFrom):
if node.module:
module = node.module.split(".")[0]
if module not in ALLOWED_MODULES:
if module in BLOCKED_MODULES:
dangerous.append(module)
return dangerous

View File

@ -53,7 +53,7 @@ backend/
├── services/ # 业务逻辑
│ ├── __init__.py
│ ├── chat.py # 聊天补全服务
│ └── glm_client.py # GLM API 客户端
│ └── llm_client.py # OpenAI 兼容 LLM API 客户端
├── tools/ # 工具系统
│ ├── __init__.py
@ -72,9 +72,6 @@ backend/
│ ├── __init__.py
│ ├── helpers.py # 通用函数
│ └── workspace.py # 工作目录工具
└── migrations/ # 数据库迁移
└── add_project_support.py
```
---
@ -250,16 +247,18 @@ classDiagram
direction TB
class ChatService {
-GLMClient glm_client
-LLMClient llm
-ToolExecutor executor
+stream_response(conv, tools_enabled, project_id) Response
-_build_tool_calls_json(calls, results) list
-_process_tool_calls_delta(delta, list) list
}
class GLMClient {
class LLMClient {
-dict model_config
+_get_credentials(model) (api_url, api_key)
+_detect_provider(api_url) str
+_build_body(model, messages, ...) dict
+call(model, messages, kwargs) Response
}
@ -268,11 +267,10 @@ classDiagram
-dict _cache
-list _call_history
+process_tool_calls(calls, context) list
+build_request(messages, model, tools) dict
+clear_history() void
}
ChatService --> GLMClient : 使用
ChatService --> LLMClient : 使用
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 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
2. **解码拼接**`TextDecoder` 将二进制解码为 UTF-8 字符串,追加到 `buffer`(处理跨 chunk 的不完整行)
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可能跨多个网络包
reader.read(): [二进制片段1] → [二进制片段2] → ...
buffer 拼接: "event: thinking\ndata: {\"content\":\"...\"}\n\n"
buffer 拼接: "event: process_step\ndata: {\"id\":\"step-0\",...}\n\n"
↓ split('\n')
逐行解析: event: → "thinking"
data: → JSON.parse → onThinking(data.content)
逐行解析: event: → "process_step"
data: → JSON.parse → onProcessStep(data)
```
---
@ -923,41 +924,50 @@ if name.startswith("file_") and context and "project_id" in context:
backend_port: 3000
frontend_port: 4000
# LLM API全局默认值每个 model 可单独覆盖)
default_api_key: your-api-key
default_api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
# 智能体循环最大迭代次数(工具调用轮次上限,默认 5
max_iterations: 15
# 可用模型列表
# 可用模型列表(每个模型必须指定 api_url 和 api_key
# 支持任何 OpenAI 兼容 APIDeepSeek、GLM、OpenAI、Moonshot、Qwen 等)
models:
- id: deepseek-chat
name: DeepSeek V3
api_url: https://api.deepseek.com/chat/completions
api_key: sk-xxx
- id: glm-5
name: GLM-5
# api_key: ... # 可选,不指定则用 default_api_key
# api_url: ... # 可选,不指定则用 default_api_url
- id: glm-5-turbo
name: GLM-5 Turbo
api_key: another-key # 该模型使用独立凭证
api_url: https://other.api.com/chat/completions
api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
api_key: xxx
# 默认模型
default_model: glm-5
# 智能体循环最大迭代次数(工具调用轮次上限,默认 5
max_iterations: 5
# 默认模型(必须存在于 models 列表中)
default_model: deepseek-chat
# 工作区根目录
workspace_root: ./workspaces
# 认证模式single单用户无需登录 / multi多用户需要 JWT
# 认证模式(可选,默认 single
# single: 单用户模式,无需登录,自动创建默认用户
# multi: 多用户模式,需要 JWT 认证
auth_mode: single
# JWT 密钥(仅多用户模式使用,生产环境请替换为随机值)
jwt_secret: nano-claw-default-secret-change-in-production
# 数据库
db_type: mysql # mysql, sqlite, postgresql
# 数据库(支持 mysql, sqlite, postgresql
db_type: sqlite
# MySQL/PostgreSQL 配置sqlite 模式下忽略)
db_host: localhost
db_port: 3306
db_user: root
db_password: ""
db_password: "123456"
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 次,指数退避)