feat: 增加用户认证系统

This commit is contained in:
ViperEkura 2026-03-26 20:47:51 +08:00
parent 55e28b8d3b
commit 39fb220cf2
12 changed files with 492 additions and 90 deletions

View File

@ -31,14 +31,16 @@ pip install -e .
backend_port: 3000
frontend_port: 4000
# LLM API
api_key: {{your-api-key}}
api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
# 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
# Available models
# Available models (each model can optionally specify its own api_key and api_url)
models:
- 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
@ -47,6 +49,14 @@ 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
# Database Configuration
db_type: sqlite
db_sqlite_file: nano_claw.db
@ -72,6 +82,7 @@ npm run dev
backend/
├── models.py # SQLAlchemy 数据模型
├── routes/ # API 路由
│ ├── auth.py # 认证(登录/注册/JWT
│ ├── conversations.py
│ ├── messages.py
│ ├── projects.py # 项目管理
@ -117,7 +128,8 @@ frontend/
| 方法 | 路径 | 说明 |
|------|------|------|
| `POST` | `/api/conversations` | 创建会话 |
| `POST` | `/api/auth/login` | 用户登录 |
| `POST` | `/api/auth/register` | 用户注册 |
| `GET` | `/api/conversations` | 会话列表 |
| `GET` | `/api/conversations/:id/messages` | 消息列表 |
| `POST` | `/api/conversations/:id/messages` | 发送消息SSE 流式) |

View File

@ -21,12 +21,49 @@ class User(db.Model):
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
username = db.Column(db.String(50), unique=True, nullable=False)
password = db.Column(db.String(255), nullable=True) # Allow NULL for third-party login
phone = db.Column(db.String(20))
password_hash = db.Column(db.String(255), nullable=True) # NULL for API-key-only auth
email = db.Column(db.String(120), unique=True, nullable=True)
avatar = db.Column(db.String(512), nullable=True)
role = db.Column(db.String(20), nullable=False, default="user") # user, admin
is_active = db.Column(db.Boolean, nullable=False, default=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
last_login_at = db.Column(db.DateTime, nullable=True)
conversations = db.relationship("Conversation", backref="user", lazy="dynamic",
cascade="all, delete-orphan",
order_by="Conversation.updated_at.desc()")
projects = db.relationship("Project", backref="user", lazy="dynamic",
cascade="all, delete-orphan")
@property
def password(self):
raise AttributeError("password is not readable")
@password.setter
def password(self, plain):
if plain:
from werkzeug.security import generate_password_hash
self.password_hash = generate_password_hash(plain)
else:
self.password_hash = None
def check_password(self, plain):
if not self.password_hash:
return False
from werkzeug.security import check_password_hash
return check_password_hash(self.password_hash, plain)
def to_dict(self):
return {
"id": self.id,
"username": self.username,
"email": self.email,
"avatar": self.avatar,
"role": self.role,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
"last_login_at": self.last_login_at.isoformat() if self.last_login_at else None,
}
class Conversation(db.Model):

View File

@ -6,6 +6,7 @@ from backend.routes.models import bp as models_bp
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.config import MODEL_CONFIG
@ -16,7 +17,11 @@ def register_routes(app: Flask):
glm_client = GLMClient(MODEL_CONFIG)
init_chat_service(glm_client)
# Initialize authentication system (reads auth_mode from config.yml)
init_auth(app)
# Register blueprints
app.register_blueprint(auth_bp)
app.register_blueprint(conversations_bp)
app.register_blueprint(messages_bp)
app.register_blueprint(models_bp)

259
backend/routes/auth.py Normal file
View File

@ -0,0 +1,259 @@
"""Authentication module - supports both single-user and multi-user modes.
Single-user mode (auth_mode: "single"):
- Auto-creates a default user on first startup
- All requests are authenticated as the default user (no token needed)
- Login endpoint returns a convenience token but it's optional
Multi-user mode (auth_mode: "multi"):
- Requires JWT token for all API requests (except login/register)
- Users must register and login to get a token
- Supports admin/user roles
"""
import time
import jwt
from datetime import datetime, timezone
from functools import wraps
from flask import Blueprint, request, g, current_app
from backend import db
from backend.models import User
from backend.utils.helpers import ok, err
bp = Blueprint("auth", __name__)
# Routes that don't require authentication
PUBLIC_ROUTES = {
"POST:/api/auth/login",
"POST:/api/auth/register",
"GET:/api/models",
"GET:/api/tools",
}
def get_auth_config():
"""Get auth configuration from app config."""
return current_app.config.get("AUTH_CONFIG", {
"mode": "single", # "single" or "multi"
"jwt_secret": "nano-claw-default-secret-change-in-production",
"jwt_expiry": 7 * 24 * 3600, # 7 days in seconds
})
def generate_token(user):
"""Generate a JWT token for a user."""
cfg = get_auth_config()
payload = {
"user_id": user.id,
"username": user.username,
"role": user.role,
"exp": int(time.time()) + cfg["jwt_expiry"],
}
return jwt.encode(payload, cfg["jwt_secret"], algorithm="HS256")
def _resolve_user():
"""Resolve the current user from request context.
In single-user mode: auto-creates and returns the default user.
In multi-user mode: validates the JWT token from Authorization header.
Returns None if authentication fails in multi-user mode.
"""
cfg = get_auth_config()
if cfg["mode"] == "single":
user = User.query.filter_by(username="default").first()
if not user:
user = User(username="default", role="admin")
db.session.add(user)
db.session.commit()
return user
# Multi-user mode: validate JWT
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
token = auth_header[7:]
try:
payload = jwt.decode(token, cfg["jwt_secret"], algorithms=["HS256"])
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return None
user = db.session.get(User, payload.get("user_id"))
if not user or not user.is_active:
return None
return user
def init_auth(app):
"""Register authentication hooks on the Flask app."""
cfg_path = app.config.get("AUTH_CONFIG_PATH")
if cfg_path:
from backend import load_config
full_cfg = load_config()
auth_mode = full_cfg.get("auth_mode", "single")
jwt_secret = full_cfg.get("jwt_secret", "nano-claw-default-secret-change-in-production")
else:
auth_mode = "single"
jwt_secret = "nano-claw-default-secret-change-in-production"
app.config["AUTH_CONFIG"] = {
"mode": auth_mode,
"jwt_secret": jwt_secret,
"jwt_expiry": 7 * 24 * 3600,
}
@app.before_request
def before_request_auth():
"""Authenticate user before each request."""
method_path = f"{request.method}:{request.path}"
# Skip auth for public routes
if method_path in PUBLIC_ROUTES:
return None
# Skip auth for static files
if request.path.startswith("/static"):
return None
# In single-user mode, always set the default user
cfg = get_auth_config()
if cfg["mode"] == "single":
g.current_user = _resolve_user()
return None
# Multi-user mode: validate token
user = _resolve_user()
if not user:
return err(401, "Unauthorized - please login")
g.current_user = user
# Update last_login_at (debounced: at most once per hour)
if (not user.last_login_at or
(datetime.now(timezone.utc) - user.last_login_at).total_seconds() > 3600):
user.last_login_at = datetime.now(timezone.utc)
db.session.commit()
return None
# --- Auth API Routes ---
@bp.route("/api/auth/login", methods=["POST"])
def login():
"""User login - returns JWT token."""
cfg = get_auth_config()
# Single-user mode: just return the default user's token
if cfg["mode"] == "single":
user = User.query.filter_by(username="default").first()
if not user:
return err(500, "Default user not initialized")
return ok({
"token": generate_token(user),
"user": user.to_dict(),
})
# Multi-user mode: validate credentials
data = request.get_json(silent=True) or {}
username = data.get("username", "").strip()
password = data.get("password", "")
if not username or not password:
return err(400, "Username and password are required")
user = User.query.filter_by(username=username).first()
if not user or not user.check_password(password):
return err(401, "Invalid username or password")
if not user.is_active:
return err(403, "Account is disabled")
user.last_login_at = datetime.now(timezone.utc)
db.session.commit()
return ok({
"token": generate_token(user),
"user": user.to_dict(),
})
@bp.route("/api/auth/register", methods=["POST"])
def register():
"""User registration - only available in multi-user mode."""
cfg = get_auth_config()
if cfg["mode"] == "single":
return err(403, "Registration is disabled in single-user mode")
data = request.get_json(silent=True) or {}
username = data.get("username", "").strip()
password = data.get("password", "")
email = data.get("email", "").strip() or None
if not username or not password:
return err(400, "Username and password are required")
if len(username) < 2 or len(username) > 50:
return err(400, "Username must be 2-50 characters")
if len(password) < 4:
return err(400, "Password must be at least 4 characters")
if email and "@" not in email:
return err(400, "Invalid email format")
if User.query.filter_by(username=username).first():
return err(409, f"Username '{username}' already exists")
if email and User.query.filter_by(email=email).first():
return err(409, "Email already registered")
user = User(username=username, password=password, email=email)
db.session.add(user)
db.session.commit()
return ok({
"token": generate_token(user),
"user": user.to_dict(),
})
@bp.route("/api/auth/profile", methods=["GET"])
def get_profile():
"""Get current user profile."""
user = getattr(g, "current_user", None)
if not user:
return err(401, "Not authenticated")
return ok(user.to_dict())
@bp.route("/api/auth/profile", methods=["PATCH"])
def update_profile():
"""Update current user profile."""
user = getattr(g, "current_user", None)
if not user:
return err(401, "Not authenticated")
data = request.get_json(silent=True) or {}
if "email" in data:
new_email = data["email"].strip() or None
if new_email and new_email != user.email:
if User.query.filter_by(email=new_email).first():
return err(409, "Email already registered")
user.email = new_email
if "avatar" in data:
user.avatar = data["avatar"]
if "password" in data:
new_password = data["password"]
if len(new_password) < 4:
return err(400, "Password must be at least 4 characters")
user.password = new_password
db.session.commit()
return ok(user.to_dict())
@bp.route("/api/auth/mode", methods=["GET"])
def get_auth_mode():
"""Get current authentication mode (public endpoint)."""
cfg = get_auth_config()
return ok({"mode": cfg["mode"]})

View File

@ -1,10 +1,10 @@
"""Conversation API routes"""
import uuid
from datetime import datetime, timezone
from flask import Blueprint, request
from flask import Blueprint, request, g
from backend import db
from backend.models import Conversation, Project
from backend.utils.helpers import ok, err, to_dict, get_or_create_default_user
from backend.utils.helpers import ok, err, to_dict
from backend.config import DEFAULT_MODEL
bp = Blueprint("conversations", __name__)
@ -23,15 +23,16 @@ def _conv_to_dict(conv, **extra):
@bp.route("/api/conversations", methods=["GET", "POST"])
def conversation_list():
"""List or create conversations"""
user = g.current_user
if request.method == "POST":
d = request.json or {}
user = get_or_create_default_user()
# Validate project_id if provided
project_id = d.get("project_id")
if project_id:
project = db.session.get(Project, project_id)
if not project:
if not project or project.user_id != user.id:
return err(404, "Project not found")
conv = Conversation(
@ -53,7 +54,6 @@ def conversation_list():
cursor = request.args.get("cursor")
limit = min(int(request.args.get("limit", 20)), 100)
project_id = request.args.get("project_id")
user = get_or_create_default_user()
q = Conversation.query.filter_by(user_id=user.id)
# Filter by project if specified
@ -76,8 +76,9 @@ def conversation_list():
@bp.route("/api/conversations/<conv_id>", methods=["GET", "PATCH", "DELETE"])
def conversation_detail(conv_id):
"""Get, update or delete a conversation"""
user = g.current_user
conv = db.session.get(Conversation, conv_id)
if not conv:
if not conv or conv.user_id != user.id:
return err(404, "conversation not found")
if request.method == "GET":
@ -99,7 +100,7 @@ def conversation_detail(conv_id):
project_id = d["project_id"]
if project_id:
project = db.session.get(Project, project_id)
if not project:
if not project or project.user_id != user.id:
return err(404, "Project not found")
conv.project_id = project_id or None

View File

@ -2,13 +2,12 @@
import json
import uuid
from datetime import datetime, timezone
from flask import Blueprint, request
from flask import Blueprint, request, g
from backend import db
from backend.models import Conversation, Message
from backend.utils.helpers import ok, err, message_to_dict
from backend.services.chat import ChatService
bp = Blueprint("messages", __name__)
# ChatService will be injected during registration
@ -21,10 +20,18 @@ def init_chat_service(glm_client):
_chat_service = ChatService(glm_client)
def _get_conv(conv_id):
"""Get conversation with ownership check."""
conv = db.session.get(Conversation, conv_id)
if not conv or conv.user_id != g.current_user.id:
return None
return conv
@bp.route("/api/conversations/<conv_id>/messages", methods=["GET", "POST"])
def message_list(conv_id):
"""List or create messages"""
conv = db.session.get(Conversation, conv_id)
conv = _get_conv(conv_id)
if not conv:
return err(404, "conversation not found")
@ -75,7 +82,7 @@ def message_list(conv_id):
@bp.route("/api/conversations/<conv_id>/messages/<msg_id>", methods=["DELETE"])
def delete_message(conv_id, msg_id):
"""Delete a message"""
conv = db.session.get(Conversation, conv_id)
conv = _get_conv(conv_id)
if not conv:
return err(404, "conversation not found")
msg = db.session.get(Message, msg_id)
@ -89,7 +96,7 @@ def delete_message(conv_id, msg_id):
@bp.route("/api/conversations/<conv_id>/regenerate/<msg_id>", methods=["POST"])
def regenerate_message(conv_id, msg_id):
"""Regenerate an assistant message"""
conv = db.session.get(Conversation, conv_id)
conv = _get_conv(conv_id)
if not conv:
return err(404, "conversation not found")

View File

@ -2,10 +2,10 @@
import os
import uuid
import shutil
from flask import Blueprint, request
from flask import Blueprint, request, g
from backend import db
from backend.models import Project, User
from backend.models import Project
from backend.utils.helpers import ok, err
from backend.utils.workspace import (
create_project_directory,
@ -20,13 +20,9 @@ bp = Blueprint("projects", __name__)
@bp.route("/api/projects", methods=["GET"])
def list_projects():
"""List all projects for a user"""
user_id = request.args.get("user_id", type=int)
if not user_id:
return err(400, "Missing user_id parameter")
projects = Project.query.filter_by(user_id=user_id).order_by(Project.updated_at.desc()).all()
"""List all projects for current user"""
user = g.current_user
projects = Project.query.filter_by(user_id=user.id).order_by(Project.updated_at.desc()).all()
return ok({
"projects": [
@ -48,41 +44,33 @@ def list_projects():
@bp.route("/api/projects", methods=["POST"])
def create_project():
"""Create a new project"""
user = g.current_user
data = request.get_json()
if not data:
return err(400, "No data provided")
user_id = data.get("user_id")
name = data.get("name", "").strip()
description = data.get("description", "")
if not user_id:
return err(400, "Missing user_id")
if not name:
return err(400, "Project name is required")
# Check if user exists
user = User.query.get(user_id)
if not user:
return err(404, "User not found")
# Check if project name already exists for this user
existing = Project.query.filter_by(user_id=user_id, name=name).first()
existing = Project.query.filter_by(user_id=user.id, name=name).first()
if existing:
return err(400, f"Project '{name}' already exists")
# Create project directory
try:
relative_path, absolute_path = create_project_directory(name, user_id)
relative_path, absolute_path = create_project_directory(name, user.id)
except Exception as e:
return err(500, f"Failed to create project directory: {str(e)}")
# Create project record
project = Project(
id=str(uuid.uuid4()),
user_id=user_id,
user_id=user.id,
name=name,
path=relative_path,
description=description
@ -202,15 +190,12 @@ def delete_project(project_id):
@bp.route("/api/projects/upload", methods=["POST"])
def upload_project_folder():
"""Upload a folder as a new project via file upload"""
user_id = request.form.get("user_id", type=int)
user = g.current_user
project_name = request.form.get("name", "").strip()
description = request.form.get("description", "")
files = request.files.getlist("files")
if not user_id:
return err(400, "Missing user_id")
if not files:
return err(400, "No files uploaded")
@ -218,19 +203,14 @@ def upload_project_folder():
# Use first file's top-level folder name
project_name = files[0].filename.split("/")[0] if files[0].filename else "untitled"
# Check if user exists
user = User.query.get(user_id)
if not user:
return err(404, "User not found")
# Check if project name already exists
existing = Project.query.filter_by(user_id=user_id, name=project_name).first()
existing = Project.query.filter_by(user_id=user.id, name=project_name).first()
if existing:
return err(400, f"Project '{project_name}' already exists")
# Create project directory first
try:
relative_path, absolute_path = create_project_directory(project_name, user_id)
relative_path, absolute_path = create_project_directory(project_name, user.id)
except Exception as e:
return err(500, f"Failed to create project directory: {str(e)}")

View File

@ -1,9 +1,9 @@
"""Token statistics API routes"""
from datetime import date, timedelta
from flask import Blueprint, request
from flask import Blueprint, request, g
from sqlalchemy import func
from backend.models import TokenUsage
from backend.utils.helpers import ok, err, get_or_create_default_user
from backend.utils.helpers import ok, err
bp = Blueprint("stats", __name__)
@ -11,7 +11,7 @@ bp = Blueprint("stats", __name__)
@bp.route("/api/stats/tokens", methods=["GET"])
def token_stats():
"""Get token usage statistics"""
user = get_or_create_default_user()
user = g.current_user
period = request.args.get("period", "daily")
today = date.today()

View File

@ -1,12 +1,11 @@
"""Chat completion service"""
import json
import uuid
from flask import current_app, Response
from flask import current_app, g, Response
from backend import db
from backend.models import Conversation, Message
from backend.tools import registry, ToolExecutor
from backend.utils.helpers import (
get_or_create_default_user,
record_token_usage,
build_messages,
)
@ -205,8 +204,9 @@ class ChatService:
db.session.add(msg)
db.session.commit()
user = get_or_create_default_user()
record_token_usage(user.id, conv_model, prompt_tokens, token_count)
user = g.get("current_user")
if user:
record_token_usage(user.id, conv_model, prompt_tokens, token_count)
# Check if we need to set title (first message in conversation)
conv = db.session.get(Conversation, conv_id)

View File

@ -7,8 +7,24 @@ from backend import db
from backend.models import Message, TokenUsage, User
def get_current_user():
"""Get the current authenticated user from request context (g.current_user)."""
from flask import g
return getattr(g, "current_user", None)
def get_or_create_default_user() -> User:
"""Get or create default user"""
"""Get or create default user.
.. deprecated::
Use g.current_user instead. This is kept for backward compatibility
and will be removed in a future version.
"""
from flask import g
user = getattr(g, "current_user", None)
if user:
return user
# Fallback: look up or create default user (should not happen with auth middleware)
user = User.query.filter_by(username="default").first()
if not user:
user = User(username="default", password=None)

View File

@ -42,6 +42,7 @@ backend/
├── routes/ # API 路由
│ ├── __init__.py
│ ├── auth.py # 认证(登录/注册/JWT
│ ├── conversations.py # 会话 CRUD
│ ├── messages.py # 消息 CRUD + 聊天
│ ├── models.py # 模型列表
@ -89,10 +90,18 @@ classDiagram
class User {
+Integer id
+String username
+String password
+String phone
+String password_hash
+String email
+String avatar
+String role
+Boolean is_active
+DateTime created_at
+DateTime last_login_at
+relationship conversations
+relationship projects
+to_dict() dict
+check_password(str) bool
+password(str)$ # property setter, 自动 hash
}
class Project {
@ -352,6 +361,16 @@ def process_tool_calls(self, tool_calls, context=None):
## API 总览
### 认证
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET` | `/api/auth/mode` | 获取当前认证模式(公开端点) |
| `POST` | `/api/auth/login` | 用户登录,返回 JWT token |
| `POST` | `/api/auth/register` | 用户注册(仅多用户模式可用) |
| `GET` | `/api/auth/profile` | 获取当前用户信息 |
| `PATCH` | `/api/auth/profile` | 更新当前用户信息 |
### 会话管理
| 方法 | 路径 | 说明 |
@ -441,12 +460,19 @@ def process_tool_calls(self, tool_calls, context=None):
### User用户
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | Integer | 自增主键 |
| `username` | String(50) | 用户名(唯一) |
| `password` | String(255) | 密码(可为空,支持第三方登录) |
| `phone` | String(20) | 手机号 |
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `id` | Integer | - | 自增主键 |
| `username` | String(50) | - | 用户名(唯一) |
| `password_hash` | String(255) | null | 密码哈希(可为空,支持 API-key-only 认证) |
| `email` | String(120) | null | 邮箱(唯一) |
| `avatar` | String(512) | null | 头像 URL |
| `role` | String(20) | "user" | 角色:`user` / `admin` |
| `is_active` | Boolean | true | 是否激活 |
| `created_at` | DateTime | now | 创建时间 |
| `last_login_at` | DateTime | null | 最后登录时间 |
`password` 通过 property setter 自动调用 `werkzeug``generate_password_hash` 存储,通过 `check_password()` 方法验证。
### Project项目
@ -527,13 +553,66 @@ GET /api/conversations?limit=20&cursor=conv_abc123
---
## 错误码
## 认证机制
### 概述
系统支持**单用户模式**和**多用户模式**,通过 `config.yml` 中的 `auth_mode` 切换。
### 单用户模式(`auth_mode: single`,默认)
- **无需登录**,前端不需要传 token
- 后端自动创建一个 `username="default"`、`role="admin"` 的用户
- 每次请求通过 `before_request` 钩子自动将 `g.current_user` 设为该默认用户
- 所有路由从 `g.current_user` 获取当前用户,无需前端传递 `user_id`
### 多用户模式(`auth_mode: multi`
- 除公开端点外,所有请求必须在 `Authorization` 头中携带 JWT token
- 用户通过 `/api/auth/register` 注册、`/api/auth/login` 登录获取 token
- Token 有效期 7 天,过期需重新登录
- 用户只能访问自己的数据(对话、项目、统计等)
### 认证流程
```
单用户模式:
请求 → before_request → 查找/创建 default 用户 → g.current_user → 路由处理
多用户模式:
请求 → before_request → 提取 Authorization header → 验证 JWT → 查找用户 → g.current_user → 路由处理
↓ 失败
返回 401
```
### 公开端点(无需认证)
| 端点 | 说明 |
|------|------|
| `POST /api/auth/login` | 登录 |
| `POST /api/auth/register` | 注册 |
| `GET /api/models` | 模型列表 |
| `GET /api/tools` | 工具列表 |
### 前端适配
前端 API 层(`frontend/src/api/index.js`)已预留 token 管理:
- `getToken()` / `setToken(token)` / `clearToken()`
- 所有请求自动附带 `Authorization: Bearer <token>`token 为空时不发送)
- 收到 401 时自动清除 token
切换到多用户模式时,只需补充登录/注册页面 UI。
---
| Code | 说明 |
|------|------|
| `0` | 成功 |
| `400` | 请求参数错误 |
| `401` | 未认证(多用户模式下缺少或无效 token |
| `403` | 禁止访问(账户禁用、单用户模式下注册等) |
| `404` | 资源不存在 |
| `409` | 资源冲突(用户名/邮箱已存在) |
| `500` | 服务器错误 |
错误响应:
@ -749,6 +828,11 @@ default_model: glm-5
# 工作区根目录
workspace_root: ./workspaces
# 认证模式single单用户无需登录 / multi多用户需要 JWT
auth_mode: single
# JWT 密钥(仅多用户模式使用,生产环境请替换为随机值)
jwt_secret: nano-claw-default-secret-change-in-production
# 数据库
db_type: mysql # mysql, sqlite, postgresql
db_host: localhost

View File

@ -15,6 +15,7 @@ dependencies = [
"beautifulsoup4>=4.12",
"httpx>=0.25",
"lxml>=5.0",
"PyJWT>=2.8",
]
[build-system]