diff --git a/README.md b/README.md index 7fe42f7..7fa28ff 100644 --- a/README.md +++ b/README.md @@ -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 兼容 API(DeepSeek、GLM、OpenAI、Moonshot、Qwen 等) diff --git a/backend/config.py b/backend/config.py index 6df0e52..937071c 100644 --- a/backend/config.py +++ b/backend/config.py @@ -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) diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py index e7820ce..ad0700e 100644 --- a/backend/routes/__init__.py +++ b/backend/routes/__init__.py @@ -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) diff --git a/backend/routes/messages.py b/backend/routes/messages.py index fc60d46..5fb0f20 100644 --- a/backend/routes/messages.py +++ b/backend/routes/messages.py @@ -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): diff --git a/backend/routes/models.py b/backend/routes/models.py index 14cf6f2..8fc4368 100644 --- a/backend/routes/models.py +++ b/backend/routes/models.py @@ -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) diff --git a/backend/services/__init__.py b/backend/services/__init__.py index 6a94280..0b78553 100644 --- a/backend/services/__init__.py +++ b/backend/services/__init__.py @@ -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", ] diff --git a/backend/services/chat.py b/backend/services/chat.py index 44e24c2..719faf5 100644 --- a/backend/services/chat.py +++ b/backend/services/chat.py @@ -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, diff --git a/backend/services/glm_client.py b/backend/services/glm_client.py deleted file mode 100644 index e094140..0000000 --- a/backend/services/glm_client.py +++ /dev/null @@ -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, - ) diff --git a/backend/services/llm_client.py b/backend/services/llm_client.py new file mode 100644 index 0000000..fbbaa20 --- /dev/null +++ b/backend/services/llm_client.py @@ -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 \ No newline at end of file diff --git a/backend/tools/builtin/code.py b/backend/tools/builtin/code.py index 1f642b0..42cfb45 100644 --- a/backend/tools/builtin/code.py +++ b/backend/tools/builtin/code.py @@ -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 diff --git a/docs/Design.md b/docs/Design.md index b7404e3..c740a16 100644 --- a/docs/Design.md +++ b/docs/Design.md @@ -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 兼容 API(DeepSeek、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 时使用 -``` \ No newline at end of file + +# 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 次,指数退避) \ No newline at end of file