feat: 增加项目管理功能

This commit is contained in:
ViperEkura 2026-03-26 11:48:56 +08:00
parent 46cacc7fa2
commit 8325100c90
20 changed files with 1815 additions and 1005 deletions

View File

@ -1,12 +1,13 @@
# NanoClaw # NanoClaw
基于 GLM 大语言模型的 AI 对话应用,支持工具调用、思维链和流式回复 基于 LLM 大语言模型的 AI 对话应用,支持工具调用、思维链、流式回复和工作目录隔离
## 功能特性 ## 功能特性
- 💬 **多轮对话** - 支持上下文管理的多轮对话 - 💬 **多轮对话** - 支持上下文管理的多轮对话
- 🔧 **工具调用** - 网页搜索、代码执行、文件操作等 - 🔧 **工具调用** - 网页搜索、代码执行、文件操作等
- 🧠 **思维链** - 支持链式思考推理 - 🧠 **思维链** - 支持链式思考推理
- 📁 **工作目录** - 项目级文件隔离,安全操作
- 📊 **Token 统计** - 按日/周/月统计使用量 - 📊 **Token 统计** - 按日/周/月统计使用量
- 🔄 **流式响应** - 实时 SSE 流式输出 - 🔄 **流式响应** - 实时 SSE 流式输出
- 💾 **多数据库** - 支持 MySQL、SQLite、PostgreSQL - 💾 **多数据库** - 支持 MySQL、SQLite、PostgreSQL
@ -28,7 +29,7 @@ pip install -e .
backend_port: 3000 backend_port: 3000
frontend_port: 4000 frontend_port: 4000
# AI API # LLM API
api_key: {{your-api-key}} api_key: {{your-api-key}}
api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
@ -36,40 +37,32 @@ api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
models: models:
- id: glm-5 - id: glm-5
name: GLM-5 name: GLM-5
- id: glm-5-turbo - id: glm-4-plus
name: GLM-5 Turbo name: GLM-4 Plus
- id: glm-4.5
name: GLM-4.5
- id: glm-4.6
name: GLM-4.6
- id: glm-4.7
name: GLM-4.7
default_model: glm-5 default_model: glm-5
# Workspace root directory
workspace_root: ./workspaces
# 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
``` ```
### 3. 启动后端 ### 3. 数据库迁移(首次运行或升级)
```bash
python -m backend.migrations.add_project_support
```
### 4. 启动后端
```bash ```bash
python -m backend.run python -m backend.run
``` ```
### 4. 启动前端 ### 5. 启动前端
```bash ```bash
cd frontend cd frontend
@ -83,6 +76,10 @@ npm run dev
backend/ backend/
├── models.py # SQLAlchemy 数据模型 ├── models.py # SQLAlchemy 数据模型
├── routes/ # API 路由 ├── routes/ # API 路由
│ ├── conversations.py
│ ├── messages.py
│ ├── projects.py # 项目管理
│ └── ...
├── services/ # 业务逻辑 ├── services/ # 业务逻辑
│ ├── chat.py # 聊天补全服务 │ ├── chat.py # 聊天补全服务
│ └── glm_client.py │ └── glm_client.py
@ -90,7 +87,10 @@ backend/
│ ├── core.py # 核心类 │ ├── core.py # 核心类
│ ├── executor.py # 工具执行器 │ ├── executor.py # 工具执行器
│ └── builtin/ # 内置工具 │ └── builtin/ # 内置工具
└── utils/ # 辅助函数 ├── utils/ # 辅助函数
│ ├── helpers.py
│ └── workspace.py # 工作目录工具
└── migrations/ # 数据库迁移
frontend/ frontend/
└── src/ └── src/
@ -99,6 +99,24 @@ frontend/
└── views/ # 页面 └── views/ # 页面
``` ```
## 工作目录系统
### 概述
工作目录系统为文件操作提供安全隔离,确保 AI 只能访问指定项目目录内的文件。
### 使用流程
1. **创建项目** - 在侧边栏点击"新建项目"或上传文件夹
2. **选择项目** - 在对话中选择当前工作目录
3. **文件操作** - AI 自动在项目目录内执行文件操作
### 安全机制
- 所有文件操作需要 `project_id` 参数
- 后端强制验证路径在项目目录内
- 阻止目录遍历攻击(如 `../../../etc/passwd`
## API 概览 ## API 概览
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
@ -107,26 +125,29 @@ frontend/
| `GET` | `/api/conversations` | 会话列表 | | `GET` | `/api/conversations` | 会话列表 |
| `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` | 项目列表 |
| `POST` | `/api/projects` | 创建项目 |
| `POST` | `/api/projects/upload` | 上传文件夹 |
| `GET` | `/api/tools` | 工具列表 | | `GET` | `/api/tools` | 工具列表 |
| `GET` | `/api/stats/tokens` | Token 统计 | | `GET` | `/api/stats/tokens` | Token 统计 |
## 内置工具 ## 内置工具
| 分类 | 工具 | | 分类 | 工具 | 说明 |
|------|------| |------|------|------|
| **爬虫** | web_search, fetch_page, crawl_batch | | **爬虫** | web_search, fetch_page, crawl_batch | 网页搜索和抓取 |
| **数据处理** | calculator, text_process, json_process | | **数据处理** | calculator, text_process, json_process | 数学计算和文本处理 |
| **代码执行** | execute_python(沙箱环境) | | **代码执行** | execute_python | 沙箱环境执行 Python |
| **文件操作** | file_read, file_write, file_list 等 | | **文件操作** | file_read, file_write, file_list 等 | **需要 project_id** |
| **天气** | get_weather | | **天气** | get_weather | 天气查询(模拟) |
## 文档 ## 文档
- [后端设计](docs/Design.md) - 架构设计、类图、API 文档 - [后端设计](docs/Design.md) - 架构设计、数据模型、API 文档
- [工具系统](docs/ToolSystemDesign.md) - 工具开发指南 - [工具系统](docs/ToolSystemDesign.md) - 工具开发指南、安全设计
## 技术栈 ## 技术栈
- **后端**: Python 3.11+, Flask - **后端**: Python 3.11+, Flask, SQLAlchemy
- **前端**: Vue 3 - **前端**: Vue 3, Vite
- **大模型**: GLM API智谱AI - **LLM**: 支持 GLM 等大语言模型

View File

@ -58,7 +58,7 @@ def create_app():
db.init_app(app) db.init_app(app)
# Import after db is initialized # Import after db is initialized
from backend.models import User, Conversation, Message, TokenUsage from backend.models import User, Conversation, Message, TokenUsage, Project
from backend.routes import register_routes from backend.routes import register_routes
from backend.tools import init_tools from backend.tools import init_tools

View File

@ -44,6 +44,7 @@ class Conversation(db.Model):
id = db.Column(db.String(64), primary_key=True) id = db.Column(db.String(64), primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
project_id = db.Column(db.String(64), db.ForeignKey("projects.id"), nullable=True, index=True)
title = db.Column(db.String(255), nullable=False, default="") title = db.Column(db.String(255), nullable=False, default="")
model = db.Column(db.String(64), nullable=False, default="glm-5") model = db.Column(db.String(64), nullable=False, default="glm-5")
system_prompt = db.Column(db.Text, default="") system_prompt = db.Column(db.Text, default="")
@ -91,3 +92,25 @@ class TokenUsage(db.Model):
db.UniqueConstraint("user_id", "date", "model", name="uq_user_date_model"), db.UniqueConstraint("user_id", "date", "model", name="uq_user_date_model"),
db.Index("ix_token_usage_date_model", "date", "model"), # Composite index db.Index("ix_token_usage_date_model", "date", "model"), # Composite index
) )
class Project(db.Model):
"""Project model for workspace isolation"""
__tablename__ = "projects"
id = db.Column(db.String(64), primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
name = db.Column(db.String(255), nullable=False)
path = db.Column(db.String(512), nullable=False) # Relative path within workspace root
description = db.Column(db.Text, default="")
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), index=True)
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
# Relationship: one project can have multiple conversations
conversations = db.relationship("Conversation", backref="project", lazy="dynamic",
cascade="all, delete-orphan")
__table_args__ = (
db.UniqueConstraint("user_id", "name", name="uq_user_project_name"),
)

View File

@ -5,6 +5,7 @@ from backend.routes.messages import bp as messages_bp, init_chat_service
from backend.routes.models import bp as models_bp from backend.routes.models import bp as models_bp
from backend.routes.tools import bp as tools_bp 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.services.glm_client import GLMClient from backend.services.glm_client import GLMClient
from backend.config import API_URL, API_KEY from backend.config import API_URL, API_KEY
@ -21,3 +22,4 @@ def register_routes(app: Flask):
app.register_blueprint(models_bp) app.register_blueprint(models_bp)
app.register_blueprint(tools_bp) app.register_blueprint(tools_bp)
app.register_blueprint(stats_bp) app.register_blueprint(stats_bp)
app.register_blueprint(projects_bp)

View File

@ -48,6 +48,7 @@ def message_list(conv_id):
d = request.json or {} d = request.json or {}
text = (d.get("text") or "").strip() text = (d.get("text") or "").strip()
attachments = d.get("attachments") # [{"name": "a.py", "extension": "py", "content": "..."}] attachments = d.get("attachments") # [{"name": "a.py", "extension": "py", "content": "..."}]
project_id = d.get("project_id") # Get project_id from request
if not text and not attachments: if not text and not attachments:
return err(400, "text or attachments is required") return err(400, "text or attachments is required")
@ -69,9 +70,9 @@ def message_list(conv_id):
tools_enabled = d.get("tools_enabled", True) tools_enabled = d.get("tools_enabled", True)
if d.get("stream", False): if d.get("stream", False):
return _chat_service.stream_response(conv, tools_enabled) return _chat_service.stream_response(conv, tools_enabled, project_id)
return _chat_service.sync_response(conv, tools_enabled) return _chat_service.sync_response(conv, tools_enabled, project_id)
@bp.route("/api/conversations/<conv_id>/messages/<msg_id>", methods=["DELETE"]) @bp.route("/api/conversations/<conv_id>/messages/<msg_id>", methods=["DELETE"])
@ -113,6 +114,7 @@ def regenerate_message(conv_id, msg_id):
# 获取工具启用状态 # 获取工具启用状态
d = request.json or {} d = request.json or {}
tools_enabled = d.get("tools_enabled", True) tools_enabled = d.get("tools_enabled", True)
project_id = d.get("project_id") # Get project_id from request
# 流式重新生成 # 流式重新生成
return _chat_service.stream_response(conv, tools_enabled) return _chat_service.stream_response(conv, tools_enabled, project_id)

331
backend/routes/projects.py Normal file
View File

@ -0,0 +1,331 @@
"""Project management API routes"""
import os
import uuid
import shutil
from flask import Blueprint, request, jsonify
from werkzeug.utils import secure_filename
from backend import db
from backend.models import Project, User
from backend.utils.helpers import ok, err
from backend.utils.workspace import (
create_project_directory,
delete_project_directory,
get_project_path,
copy_folder_to_project
)
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()
return ok({
"projects": [
{
"id": p.id,
"name": p.name,
"path": p.path,
"description": p.description,
"created_at": p.created_at.isoformat() if p.created_at else None,
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
"conversation_count": p.conversations.count()
}
for p in projects
],
"total": len(projects)
})
@bp.route("/api/projects", methods=["POST"])
def create_project():
"""Create a new project"""
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()
if existing:
return err(400, f"Project '{name}' already exists")
# Create project directory
try:
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,
name=name,
path=relative_path,
description=description
)
db.session.add(project)
db.session.commit()
return ok({
"id": project.id,
"name": project.name,
"path": project.path,
"description": project.description,
"created_at": project.created_at.isoformat()
})
@bp.route("/api/projects/<project_id>", methods=["GET"])
def get_project(project_id):
"""Get project details"""
project = Project.query.get(project_id)
if not project:
return err(404, "Project not found")
# Get absolute path
absolute_path = get_project_path(project.id, project.path)
# Get directory statistics
file_count = sum(1 for _ in absolute_path.rglob("*") if _.is_file())
dir_count = sum(1 for _ in absolute_path.rglob("*") if _.is_dir())
total_size = sum(f.stat().st_size for f in absolute_path.rglob("*") if f.is_file())
return ok({
"id": project.id,
"name": project.name,
"path": project.path,
"absolute_path": str(absolute_path),
"description": project.description,
"created_at": project.created_at.isoformat() if project.created_at else None,
"updated_at": project.updated_at.isoformat() if project.updated_at else None,
"conversation_count": project.conversations.count(),
"stats": {
"files": file_count,
"directories": dir_count,
"total_size": total_size
}
})
@bp.route("/api/projects/<project_id>", methods=["PUT"])
def update_project(project_id):
"""Update project details"""
project = Project.query.get(project_id)
if not project:
return err(404, "Project not found")
data = request.get_json()
if not data:
return err(400, "No data provided")
# Update name if provided
if "name" in data:
name = data["name"].strip()
if not name:
return err(400, "Project name cannot be empty")
# Check if new name conflicts with existing project
existing = Project.query.filter(
Project.user_id == project.user_id,
Project.name == name,
Project.id != project_id
).first()
if existing:
return err(400, f"Project '{name}' already exists")
project.name = name
# Update description if provided
if "description" in data:
project.description = data["description"]
db.session.commit()
return ok({
"id": project.id,
"name": project.name,
"description": project.description,
"updated_at": project.updated_at.isoformat()
})
@bp.route("/api/projects/<project_id>", methods=["DELETE"])
def delete_project(project_id):
"""Delete a project"""
project = Project.query.get(project_id)
if not project:
return err(404, "Project not found")
# Delete project directory
try:
delete_project_directory(project.path)
except Exception as e:
return err(500, f"Failed to delete project directory: {str(e)}")
# Delete project record (cascades to conversations and messages)
db.session.delete(project)
db.session.commit()
return ok({"message": "Project deleted successfully"})
@bp.route("/api/projects/upload", methods=["POST"])
def upload_project_folder():
"""Upload a folder as a new project (via temporary upload)"""
if "folder_path" not in request.json:
return err(400, "Missing folder_path in request body")
user_id = request.json.get("user_id")
folder_path = request.json.get("folder_path")
project_name = request.json.get("name")
description = request.json.get("description", "")
if not user_id:
return err(400, "Missing user_id")
if not folder_path:
return err(400, "Missing folder_path")
if not project_name:
# Use folder name as project name
project_name = os.path.basename(folder_path)
# 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()
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)
except Exception as e:
return err(500, f"Failed to create project directory: {str(e)}")
# Copy folder contents to project directory
try:
stats = copy_folder_to_project(folder_path, absolute_path, project_name)
except Exception as e:
# Clean up created directory on error
shutil.rmtree(absolute_path, ignore_errors=True)
return err(500, f"Failed to copy folder: {str(e)}")
# Create project record
project = Project(
id=str(uuid.uuid4()),
user_id=user_id,
name=project_name,
path=relative_path,
description=description
)
db.session.add(project)
db.session.commit()
return ok({
"id": project.id,
"name": project.name,
"path": project.path,
"description": project.description,
"created_at": project.created_at.isoformat(),
"stats": stats
})
@bp.route("/api/projects/<project_id>/files", methods=["GET"])
def list_project_files(project_id):
"""List files in a project directory"""
project = Project.query.get(project_id)
if not project:
return err(404, "Project not found")
project_dir = get_project_path(project.id, project.path)
# Get subdirectory parameter
subdir = request.args.get("path", "")
try:
target_dir = project_dir / subdir if subdir else project_dir
target_dir = target_dir.resolve()
# Validate path is within project
target_dir.relative_to(project_dir.resolve())
except ValueError:
return err(403, "Invalid path: outside project directory")
if not target_dir.exists():
return err(404, "Directory not found")
if not target_dir.is_dir():
return err(400, "Path is not a directory")
# List files
files = []
directories = []
try:
for item in target_dir.iterdir():
# Skip hidden files
if item.name.startswith("."):
continue
relative_path = item.relative_to(project_dir)
if item.is_file():
files.append({
"name": item.name,
"path": str(relative_path),
"size": item.stat().st_size,
"extension": item.suffix
})
elif item.is_dir():
directories.append({
"name": item.name,
"path": str(relative_path)
})
except Exception as e:
return err(500, f"Failed to list directory: {str(e)}")
return ok({
"project_id": project_id,
"current_path": str(subdir) if subdir else "/",
"files": files,
"directories": directories,
"total_files": len(files),
"total_dirs": len(directories)
})

View File

@ -8,7 +8,7 @@ from backend.tools import registry, ToolExecutor
from backend.utils.helpers import ( from backend.utils.helpers import (
get_or_create_default_user, get_or_create_default_user,
record_token_usage, record_token_usage,
build_glm_messages, build_messages,
ok, ok,
err, err,
to_dict, to_dict,
@ -26,14 +26,23 @@ class ChatService:
self.executor = ToolExecutor(registry=registry) self.executor = ToolExecutor(registry=registry)
def sync_response(self, conv: Conversation, tools_enabled: bool = True): def sync_response(self, conv: Conversation, tools_enabled: bool = True, project_id: str = None):
"""Sync response with tool call support""" """Sync response with tool call support
Args:
conv: Conversation object
tools_enabled: Whether to enable tools
project_id: Project ID for workspace isolation
"""
tools = registry.list_all() if tools_enabled else None tools = registry.list_all() if tools_enabled else None
messages = build_glm_messages(conv) messages = build_messages(conv, project_id)
# Clear tool call history for new request # Clear tool call history for new request
self.executor.clear_history() self.executor.clear_history()
# Build context for tool execution
context = {"project_id": project_id} if project_id else None
all_tool_calls = [] all_tool_calls = []
all_tool_results = [] all_tool_results = []
@ -119,27 +128,35 @@ class ChatService:
all_tool_calls.extend(tool_calls) all_tool_calls.extend(tool_calls)
messages.append(message) messages.append(message)
tool_results = self.executor.process_tool_calls(tool_calls) tool_results = self.executor.process_tool_calls(tool_calls, context)
all_tool_results.extend(tool_results) all_tool_results.extend(tool_results)
messages.extend(tool_results) messages.extend(tool_results)
return err(500, "exceeded maximum tool call iterations") return err(500, "exceeded maximum tool call iterations")
def stream_response(self, conv: Conversation, tools_enabled: bool = True): def stream_response(self, conv: Conversation, tools_enabled: bool = True, project_id: str = None):
"""Stream response with tool call support """Stream response with tool call support
Uses 'process_step' events to send thinking and tool calls in order, Uses 'process_step' events to send thinking and tool calls in order,
allowing them to be interleaved properly in the frontend. allowing them to be interleaved properly in the frontend.
Args:
conv: Conversation object
tools_enabled: Whether to enable tools
project_id: Project ID for workspace isolation
""" """
conv_id = conv.id conv_id = conv.id
conv_model = conv.model conv_model = conv.model
app = current_app._get_current_object() app = current_app._get_current_object()
tools = registry.list_all() if tools_enabled else None tools = registry.list_all() if tools_enabled else None
initial_messages = build_glm_messages(conv) initial_messages = build_messages(conv, project_id)
# Clear tool call history for new request # Clear tool call history for new request
self.executor.clear_history() self.executor.clear_history()
# Build context for tool execution
context = {"project_id": project_id} if project_id else None
def generate(): def generate():
messages = list(initial_messages) messages = list(initial_messages)
all_tool_calls = [] all_tool_calls = []
@ -232,17 +249,16 @@ class ChatService:
step_index += 1 step_index += 1
# Execute this single tool call # Execute this single tool call
single_result = self.executor.process_tool_calls([tc]) single_result = self.executor.process_tool_calls([tc], context)
tool_results.extend(single_result) tool_results.extend(single_result)
# Send tool result step immediately # Send tool result step immediately
tr = single_result[0] tr = single_result[0]
try: try:
result_data = json.loads(tr["content"]) result_content = json.loads(tr["content"])
skipped = result_data.get("skipped", False) skipped = result_content.get("skipped", False)
except: except:
skipped = False skipped = False
yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'tool_result', 'id': tr['tool_call_id'], 'name': tr['name'], 'content': tr['content'], 'skipped': skipped}, ensure_ascii=False)}\n\n" yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'tool_result', 'id': tr['tool_call_id'], 'name': tr['name'], 'content': tr['content'], 'skipped': skipped}, ensure_ascii=False)}\n\n"
step_index += 1 step_index += 1
@ -330,7 +346,7 @@ class ChatService:
) )
def _build_tool_calls_json(self, tool_calls: list, tool_results: list) -> list: def _build_tool_calls_json(self, tool_calls: list, tool_results: list) -> list:
"""Build tool calls JSON structure""" """Build tool calls JSON structure - matches streaming format"""
result = [] result = []
for i, tc in enumerate(tool_calls): for i, tc in enumerate(tool_calls):
result_content = tool_results[i]["content"] if i < len(tool_results) else None result_content = tool_results[i]["content"] if i < len(tool_results) else None
@ -348,10 +364,14 @@ class ChatService:
except: except:
pass pass
# Keep same structure as streaming format
result.append({ result.append({
"id": tc.get("id", ""), "id": tc.get("id", ""),
"name": tc["function"]["name"], "type": tc.get("type", "function"),
"arguments": tc["function"]["arguments"], "function": {
"name": tc["function"]["name"],
"arguments": tc["function"]["arguments"],
},
"result": result_content, "result": result_content,
"success": success, "success": success,
"skipped": skipped, "skipped": skipped,

View File

@ -1,4 +1,6 @@
"""Built-in tools""" """Built-in tools"""
from backend.tools.builtin.code import *
from backend.tools.builtin.crawler import * from backend.tools.builtin.crawler import *
from backend.tools.builtin.data import * from backend.tools.builtin.data import *
from backend.tools.builtin.file_ops import * from backend.tools.builtin.file_ops import *
from backend.tools.builtin.weather import *

View File

@ -2,41 +2,55 @@
import os import os
import json import json
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional, Tuple
from backend.tools.factory import tool from backend.tools.factory import tool
from backend import db
from backend.models import Project
from backend.utils.workspace import get_project_path, validate_path_in_project
# Base directory for file operations (sandbox) def _resolve_path(path: str, project_id: str = None) -> Tuple[Path, Path]:
# Set to None to allow any path, or set a specific directory for security """
BASE_DIR = Path(__file__).parent.parent.parent.parent # project root Resolve path and ensure it's within project directory
def _resolve_path(path: str) -> Path:
"""Resolve path and ensure it's within allowed directory"""
p = Path(path)
if not p.is_absolute():
p = BASE_DIR / p
p = p.resolve()
# Security check: ensure path is within BASE_DIR Args:
if BASE_DIR: path: File path (relative or absolute)
try: project_id: Project ID for workspace isolation
p.relative_to(BASE_DIR.resolve())
except ValueError: Returns:
raise ValueError(f"Path '{path}' is outside allowed directory") Tuple of (resolved absolute path, project directory)
Raises:
ValueError: If project_id is missing or path is outside project
"""
if not project_id:
raise ValueError("project_id is required for file operations")
return p # Get project from database
project = db.session.get(Project, project_id)
if not project:
raise ValueError(f"Project not found: {project_id}")
# Get project directory
project_dir = get_project_path(project.id, project.path)
# Validate and resolve path
return validate_path_in_project(path, project_dir), project_dir
@tool( @tool(
name="file_read", name="file_read",
description="Read content from a file. Use when you need to read file content.", description="Read content from a file within the project workspace. Use when you need to read file content.",
parameters={ parameters={
"type": "object", "type": "object",
"properties": { "properties": {
"path": { "path": {
"type": "string", "type": "string",
"description": "File path to read (relative to project root or absolute)" "description": "File path to read (relative to project root or absolute within project)"
},
"project_id": {
"type": "string",
"description": "Project ID for workspace isolation (required)"
}, },
"encoding": { "encoding": {
"type": "string", "type": "string",
@ -44,7 +58,7 @@ def _resolve_path(path: str) -> Path:
"default": "utf-8" "default": "utf-8"
} }
}, },
"required": ["path"] "required": ["path", "project_id"]
}, },
category="file" category="file"
) )
@ -55,46 +69,53 @@ def file_read(arguments: dict) -> dict:
Args: Args:
arguments: { arguments: {
"path": "file.txt", "path": "file.txt",
"project_id": "project-uuid",
"encoding": "utf-8" "encoding": "utf-8"
} }
Returns: Returns:
{"content": "...", "size": 100} {"success": true, "content": "...", "size": 100}
""" """
try: try:
path = _resolve_path(arguments["path"]) path, project_dir = _resolve_path(arguments["path"], arguments.get("project_id"))
encoding = arguments.get("encoding", "utf-8") encoding = arguments.get("encoding", "utf-8")
if not path.exists(): if not path.exists():
return {"error": f"File not found: {path}"} return {"success": False, "error": f"File not found: {path}"}
if not path.is_file(): if not path.is_file():
return {"error": f"Path is not a file: {path}"} return {"success": False, "error": f"Path is not a file: {path}"}
content = path.read_text(encoding=encoding) content = path.read_text(encoding=encoding)
return { return {
"success": True,
"content": content, "content": content,
"size": len(content), "size": len(content),
"path": str(path) "path": str(path.relative_to(project_dir))
} }
except Exception as e: except Exception as e:
return {"error": str(e)} return {"success": False, "error": str(e)}
@tool( @tool(
name="file_write", name="file_write",
description="Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Use when you need to create or update a file.", description="Write content to a file within the project workspace. Creates the file if it doesn't exist, overwrites if it does. Use when you need to create or update a file.",
parameters={ parameters={
"type": "object", "type": "object",
"properties": { "properties": {
"path": { "path": {
"type": "string", "type": "string",
"description": "File path to write (relative to project root or absolute)" "description": "File path to write (relative to project root or absolute within project)"
}, },
"content": { "content": {
"type": "string", "type": "string",
"description": "Content to write to the file" "description": "Content to write to the file"
}, },
"project_id": {
"type": "string",
"description": "Project ID for workspace isolation (required)"
},
"encoding": { "encoding": {
"type": "string", "type": "string",
"description": "File encoding, default utf-8", "description": "File encoding, default utf-8",
@ -107,7 +128,7 @@ def file_read(arguments: dict) -> dict:
"default": "write" "default": "write"
} }
}, },
"required": ["path", "content"] "required": ["path", "content", "project_id"]
}, },
category="file" category="file"
) )
@ -119,6 +140,7 @@ def file_write(arguments: dict) -> dict:
arguments: { arguments: {
"path": "file.txt", "path": "file.txt",
"content": "Hello World", "content": "Hello World",
"project_id": "project-uuid",
"encoding": "utf-8", "encoding": "utf-8",
"mode": "write" "mode": "write"
} }
@ -127,7 +149,7 @@ def file_write(arguments: dict) -> dict:
{"success": true, "size": 11} {"success": true, "size": 11}
""" """
try: try:
path = _resolve_path(arguments["path"]) path, project_dir = _resolve_path(arguments["path"], arguments.get("project_id"))
content = arguments["content"] content = arguments["content"]
encoding = arguments.get("encoding", "utf-8") encoding = arguments.get("encoding", "utf-8")
mode = arguments.get("mode", "write") mode = arguments.get("mode", "write")
@ -145,25 +167,29 @@ def file_write(arguments: dict) -> dict:
return { return {
"success": True, "success": True,
"size": len(content), "size": len(content),
"path": str(path), "path": str(path.relative_to(project_dir)),
"mode": mode "mode": mode
} }
except Exception as e: except Exception as e:
return {"error": str(e)} return {"success": False, "error": str(e)}
@tool( @tool(
name="file_delete", name="file_delete",
description="Delete a file. Use when you need to remove a file.", description="Delete a file within the project workspace. Use when you need to remove a file.",
parameters={ parameters={
"type": "object", "type": "object",
"properties": { "properties": {
"path": { "path": {
"type": "string", "type": "string",
"description": "File path to delete (relative to project root or absolute)" "description": "File path to delete (relative to project root or absolute within project)"
},
"project_id": {
"type": "string",
"description": "Project ID for workspace isolation (required)"
} }
}, },
"required": ["path"] "required": ["path", "project_id"]
}, },
category="file" category="file"
) )
@ -173,45 +199,51 @@ def file_delete(arguments: dict) -> dict:
Args: Args:
arguments: { arguments: {
"path": "file.txt" "path": "file.txt",
"project_id": "project-uuid"
} }
Returns: Returns:
{"success": true} {"success": true}
""" """
try: try:
path = _resolve_path(arguments["path"]) path, project_dir = _resolve_path(arguments["path"], arguments.get("project_id"))
if not path.exists(): if not path.exists():
return {"error": f"File not found: {path}"} return {"success": False, "error": f"File not found: {path}"}
if not path.is_file(): if not path.is_file():
return {"error": f"Path is not a file: {path}"} return {"success": False, "error": f"Path is not a file: {path}"}
rel_path = str(path.relative_to(project_dir))
path.unlink() path.unlink()
return {"success": True, "path": str(path)} return {"success": True, "path": rel_path}
except Exception as e: except Exception as e:
return {"error": str(e)} return {"success": False, "error": str(e)}
@tool( @tool(
name="file_list", name="file_list",
description="List files and directories in a directory. Use when you need to see what files exist.", description="List files and directories in a directory within the project workspace. Use when you need to see what files exist.",
parameters={ parameters={
"type": "object", "type": "object",
"properties": { "properties": {
"path": { "path": {
"type": "string", "type": "string",
"description": "Directory path to list (relative to project root or absolute)", "description": "Directory path to list (relative to project root or absolute within project)",
"default": "." "default": "."
}, },
"pattern": { "pattern": {
"type": "string", "type": "string",
"description": "Glob pattern to filter files, e.g. '*.py'", "description": "Glob pattern to filter files, e.g. '*.py'",
"default": "*" "default": "*"
},
"project_id": {
"type": "string",
"description": "Project ID for workspace isolation (required)"
} }
}, },
"required": [] "required": ["project_id"]
}, },
category="file" category="file"
) )
@ -222,21 +254,22 @@ def file_list(arguments: dict) -> dict:
Args: Args:
arguments: { arguments: {
"path": ".", "path": ".",
"pattern": "*" "pattern": "*",
"project_id": "project-uuid"
} }
Returns: Returns:
{"files": [...], "directories": [...]} {"success": true, "files": [...], "directories": [...]}
""" """
try: try:
path = _resolve_path(arguments.get("path", ".")) path, project_dir = _resolve_path(arguments.get("path", "."), arguments.get("project_id"))
pattern = arguments.get("pattern", "*") pattern = arguments.get("pattern", "*")
if not path.exists(): if not path.exists():
return {"error": f"Directory not found: {path}"} return {"success": False, "error": f"Directory not found: {path}"}
if not path.is_dir(): if not path.is_dir():
return {"error": f"Path is not a directory: {path}"} return {"success": False, "error": f"Path is not a directory: {path}"}
files = [] files = []
directories = [] directories = []
@ -246,37 +279,42 @@ def file_list(arguments: dict) -> dict:
files.append({ files.append({
"name": item.name, "name": item.name,
"size": item.stat().st_size, "size": item.stat().st_size,
"path": str(item.relative_to(BASE_DIR)) if BASE_DIR else str(item) "path": str(item.relative_to(project_dir))
}) })
elif item.is_dir(): elif item.is_dir():
directories.append({ directories.append({
"name": item.name, "name": item.name,
"path": str(item.relative_to(BASE_DIR)) if BASE_DIR else str(item) "path": str(item.relative_to(project_dir))
}) })
return { return {
"path": str(path), "success": True,
"path": str(path.relative_to(project_dir)),
"files": files, "files": files,
"directories": directories, "directories": directories,
"total_files": len(files), "total_files": len(files),
"total_dirs": len(directories) "total_dirs": len(directories)
} }
except Exception as e: except Exception as e:
return {"error": str(e)} return {"success": False, "error": str(e)}
@tool( @tool(
name="file_exists", name="file_exists",
description="Check if a file or directory exists. Use when you need to verify file existence.", description="Check if a file or directory exists within the project workspace. Use when you need to verify file existence.",
parameters={ parameters={
"type": "object", "type": "object",
"properties": { "properties": {
"path": { "path": {
"type": "string", "type": "string",
"description": "Path to check (relative to project root or absolute)" "description": "Path to check (relative to project root or absolute within project)"
},
"project_id": {
"type": "string",
"description": "Project ID for workspace isolation (required)"
} }
}, },
"required": ["path"] "required": ["path", "project_id"]
}, },
category="file" category="file"
) )
@ -286,53 +324,58 @@ def file_exists(arguments: dict) -> dict:
Args: Args:
arguments: { arguments: {
"path": "file.txt" "path": "file.txt",
"project_id": "project-uuid"
} }
Returns: Returns:
{"exists": true, "type": "file"} {"exists": true, "type": "file"}
""" """
try: try:
path = _resolve_path(arguments["path"]) path, project_dir = _resolve_path(arguments["path"], arguments.get("project_id"))
if not path.exists(): if not path.exists():
return {"exists": False, "path": str(path)} return {"exists": False, "path": str(path.relative_to(project_dir))}
if path.is_file(): if path.is_file():
return { return {
"exists": True, "exists": True,
"type": "file", "type": "file",
"path": str(path), "path": str(path.relative_to(project_dir)),
"size": path.stat().st_size "size": path.stat().st_size
} }
elif path.is_dir(): elif path.is_dir():
return { return {
"exists": True, "exists": True,
"type": "directory", "type": "directory",
"path": str(path) "path": str(path.relative_to(project_dir))
} }
else: else:
return { return {
"exists": True, "exists": True,
"type": "other", "type": "other",
"path": str(path) "path": str(path.relative_to(project_dir))
} }
except Exception as e: except Exception as e:
return {"error": str(e)} return {"success": False, "error": str(e)}
@tool( @tool(
name="file_mkdir", name="file_mkdir",
description="Create a directory. Creates parent directories if needed. Use when you need to create a folder.", description="Create a directory within the project workspace. Creates parent directories if needed. Use when you need to create a folder.",
parameters={ parameters={
"type": "object", "type": "object",
"properties": { "properties": {
"path": { "path": {
"type": "string", "type": "string",
"description": "Directory path to create (relative to project root or absolute)" "description": "Directory path to create (relative to project root or absolute within project)"
},
"project_id": {
"type": "string",
"description": "Project ID for workspace isolation (required)"
} }
}, },
"required": ["path"] "required": ["path", "project_id"]
}, },
category="file" category="file"
) )
@ -342,19 +385,23 @@ def file_mkdir(arguments: dict) -> dict:
Args: Args:
arguments: { arguments: {
"path": "new/folder" "path": "new/folder",
"project_id": "project-uuid"
} }
Returns: Returns:
{"success": true} {"success": true}
""" """
try: try:
path = _resolve_path(arguments["path"]) path, project_dir = _resolve_path(arguments["path"], arguments.get("project_id"))
created = not path.exists()
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
return { return {
"success": True, "success": True,
"path": str(path), "path": str(path.relative_to(project_dir)),
"created": not path.exists() or path.is_dir() "created": created
} }
except Exception as e: except Exception as e:
return {"error": str(e)} return {"success": False, "error": str(e)}

View File

@ -70,7 +70,7 @@ class ToolExecutor:
Args: Args:
tool_calls: Tool call list returned by LLM tool_calls: Tool call list returned by LLM
context: Optional context info (user_id, etc.) context: Optional context info (user_id, project_id, etc.)
Returns: Returns:
Tool response message list, can be appended to messages Tool response message list, can be appended to messages
@ -91,6 +91,12 @@ class ToolExecutor:
)) ))
continue continue
# Inject context into tool arguments
if context:
# For file operation tools, inject project_id automatically
if name.startswith("file_") and "project_id" in context:
args["project_id"] = context["project_id"]
# Check for duplicate within same batch # Check for duplicate within same batch
call_key = f"{name}:{json.dumps(args, sort_keys=True)}" call_key = f"{name}:{json.dumps(args, sort_keys=True)}"
if call_key in seen_calls: if call_key in seen_calls:

View File

@ -1,5 +1,5 @@
"""Backend utilities""" """Backend utilities"""
from backend.utils.helpers import ok, err, to_dict, get_or_create_default_user, record_token_usage, build_glm_messages from backend.utils.helpers import ok, err, to_dict, get_or_create_default_user, record_token_usage, build_messages
__all__ = [ __all__ = [
"ok", "ok",
@ -7,5 +7,5 @@ __all__ = [
"to_dict", "to_dict",
"get_or_create_default_user", "get_or_create_default_user",
"record_token_usage", "record_token_usage",
"build_glm_messages", "build_messages",
] ]

View File

@ -98,9 +98,16 @@ def record_token_usage(user_id, model, prompt_tokens, completion_tokens):
db.session.commit() db.session.commit()
def build_glm_messages(conv): def build_messages(conv, project_id=None):
"""Build messages list for GLM API from conversation""" """Build messages list for LLM API from conversation
Args:
conv: Conversation object
project_id: Project ID (used for context injection, backend enforces workspace isolation)
"""
msgs = [] msgs = []
# System prompt (project_id is handled by backend for security)
if conv.system_prompt: if conv.system_prompt:
msgs.append({"role": "system", "content": conv.system_prompt}) msgs.append({"role": "system", "content": conv.system_prompt})
# Query messages directly to avoid detached instance warning # Query messages directly to avoid detached instance warning

180
backend/utils/workspace.py Normal file
View File

@ -0,0 +1,180 @@
"""Workspace path validation utilities"""
import os
import shutil
from pathlib import Path
from typing import Optional
from flask import current_app
from backend import load_config
def get_workspace_root() -> Path:
"""Get workspace root directory from config"""
cfg = load_config()
workspace_root = cfg.get("workspace_root", "./workspaces")
# Convert to absolute path
workspace_path = Path(workspace_root)
if not workspace_path.is_absolute():
# Relative to project root
workspace_path = Path(__file__).parent.parent.parent / workspace_root
# Create if not exists
workspace_path.mkdir(parents=True, exist_ok=True)
return workspace_path.resolve()
def get_project_path(project_id: str, project_path: str) -> Path:
"""
Get absolute path for a project
Args:
project_id: Project ID
project_path: Relative path stored in database
Returns:
Absolute path to project directory
"""
workspace_root = get_workspace_root()
project_dir = workspace_root / project_path
# Create if not exists
project_dir.mkdir(parents=True, exist_ok=True)
return project_dir.resolve()
def validate_path_in_project(path: str, project_dir: Path) -> Path:
"""
Validate that a path is within the project directory
Args:
path: Path to validate (can be relative or absolute)
project_dir: Project directory path
Returns:
Resolved absolute path
Raises:
ValueError: If path is outside project directory
"""
p = Path(path)
# If relative, resolve against project directory
if not p.is_absolute():
p = project_dir / p
# Resolve to absolute path
p = p.resolve()
# Security check: ensure path is within project directory
try:
p.relative_to(project_dir.resolve())
except ValueError:
raise ValueError(f"Path '{path}' is outside project directory")
return p
def create_project_directory(name: str, user_id: int) -> tuple[str, Path]:
"""
Create a new project directory
Args:
name: Project name
user_id: User ID
Returns:
Tuple of (relative_path, absolute_path)
"""
workspace_root = get_workspace_root()
# Create user-specific directory
user_dir = workspace_root / f"user_{user_id}"
user_dir.mkdir(parents=True, exist_ok=True)
# Create project directory
project_dir = user_dir / name
# Handle name conflicts
counter = 1
original_name = name
while project_dir.exists():
name = f"{original_name}_{counter}"
project_dir = user_dir / name
counter += 1
project_dir.mkdir(parents=True, exist_ok=True)
# Return relative path (from workspace root) and absolute path
relative_path = f"user_{user_id}/{name}"
return relative_path, project_dir.resolve()
def delete_project_directory(project_path: str) -> bool:
"""
Delete a project directory
Args:
project_path: Relative path from workspace root
Returns:
True if deleted successfully
"""
workspace_root = get_workspace_root()
project_dir = workspace_root / project_path
if project_dir.exists() and project_dir.is_dir():
# Verify it's within workspace root (security check)
try:
project_dir.resolve().relative_to(workspace_root.resolve())
shutil.rmtree(project_dir)
return True
except ValueError:
raise ValueError("Cannot delete directory outside workspace root")
return False
def copy_folder_to_project(source_path: str, project_dir: Path, project_name: str) -> dict:
"""
Copy a folder to project directory (for folder upload)
Args:
source_path: Source folder path
project_dir: Target project directory
project_name: Project name
Returns:
Dict with copy statistics
"""
source = Path(source_path)
if not source.exists():
raise ValueError(f"Source path does not exist: {source_path}")
if not source.is_dir():
raise ValueError(f"Source path is not a directory: {source_path}")
# Security check: don't copy from sensitive system directories
sensitive_dirs = ["/etc", "/usr", "/bin", "/sbin", "/root", "/home"]
for sensitive in sensitive_dirs:
if str(source.resolve()).startswith(sensitive):
raise ValueError(f"Cannot copy from system directory: {sensitive}")
# Copy directory
if project_dir.exists():
shutil.rmtree(project_dir)
shutil.copytree(source, project_dir)
# Count files
file_count = sum(1 for _ in project_dir.rglob("*") if _.is_file())
dir_count = sum(1 for _ in project_dir.rglob("*") if _.is_dir())
return {
"files": file_count,
"directories": dir_count,
"size": sum(f.stat().st_size for f in project_dir.rglob("*") if f.is_file())
}

View File

@ -16,14 +16,14 @@ graph TB
end end
subgraph External[外部服务] subgraph External[外部服务]
GLM[GLM API] LLM[LLM API]
WEB[Web Resources] WEB[Web Resources]
end end
UI -->|REST/SSE| API UI -->|REST/SSE| API
API --> SVC API --> SVC
API --> TOOLS API --> TOOLS
SVC --> GLM SVC --> LLM
TOOLS --> WEB TOOLS --> WEB
SVC --> DB SVC --> DB
TOOLS --> DB TOOLS --> DB
@ -45,6 +45,7 @@ backend/
│ ├── conversations.py # 会话 CRUD │ ├── conversations.py # 会话 CRUD
│ ├── messages.py # 消息 CRUD + 聊天 │ ├── messages.py # 消息 CRUD + 聊天
│ ├── models.py # 模型列表 │ ├── models.py # 模型列表
│ ├── projects.py # 项目管理
│ ├── stats.py # Token 统计 │ ├── stats.py # Token 统计
│ └── tools.py # 工具列表 │ └── tools.py # 工具列表
@ -63,12 +64,16 @@ backend/
│ ├── crawler.py # 网页搜索、抓取 │ ├── crawler.py # 网页搜索、抓取
│ ├── data.py # 计算器、文本、JSON │ ├── data.py # 计算器、文本、JSON
│ ├── weather.py # 天气查询 │ ├── weather.py # 天气查询
│ ├── file_ops.py # 文件操作 │ ├── file_ops.py # 文件操作(需要 project_id
│ └── code.py # 代码执行 │ └── code.py # 代码执行
└── utils/ # 辅助函数 ├── utils/ # 辅助函数
├── __init__.py │ ├── __init__.py
└── helpers.py # 通用函数 │ ├── helpers.py # 通用函数
│ └── workspace.py # 工作目录工具
└── migrations/ # 数据库迁移
└── add_project_support.py
``` ```
--- ---
@ -87,11 +92,24 @@ classDiagram
+String password +String password
+String phone +String phone
+relationship conversations +relationship conversations
+relationship projects
}
class Project {
+String id
+Integer user_id
+String name
+String path
+String description
+DateTime created_at
+DateTime updated_at
+relationship conversations
} }
class Conversation { class Conversation {
+String id +String id
+Integer user_id +Integer user_id
+String project_id
+String title +String title
+String model +String model
+String system_prompt +String system_prompt
@ -124,6 +142,8 @@ classDiagram
} }
User "1" --> "*" Conversation : 拥有 User "1" --> "*" Conversation : 拥有
User "1" --> "*" Project : 拥有
Project "1" --> "*" Conversation : 包含
Conversation "1" --> "*" Message : 包含 Conversation "1" --> "*" Message : 包含
User "1" --> "*" TokenUsage : 消耗 User "1" --> "*" TokenUsage : 消耗
``` ```
@ -150,8 +170,11 @@ classDiagram
"tool_calls": [ "tool_calls": [
{ {
"id": "call_xxx", "id": "call_xxx",
"name": "read_file", "type": "function",
"arguments": "{\"path\": \"...\"}", "function": {
"name": "file_read",
"arguments": "{\"path\": \"...\"}"
},
"result": "{\"content\": \"...\"}", "result": "{\"content\": \"...\"}",
"success": true, "success": true,
"skipped": false, "skipped": false,
@ -171,8 +194,8 @@ classDiagram
-GLMClient glm_client -GLMClient glm_client
-ToolExecutor executor -ToolExecutor executor
+Integer MAX_ITERATIONS +Integer MAX_ITERATIONS
+sync_response(conv, tools_enabled) Response +sync_response(conv, tools_enabled, project_id) Response
+stream_response(conv, tools_enabled) Response +stream_response(conv, tools_enabled, project_id) Response
-_build_tool_calls_json(calls, results) list -_build_tool_calls_json(calls, results) list
-_message_to_dict(msg) dict -_message_to_dict(msg) dict
-_process_tool_calls_delta(delta, list) list -_process_tool_calls_delta(delta, list) list
@ -249,6 +272,86 @@ classDiagram
--- ---
## 工作目录系统
### 概述
工作目录系统为文件操作工具提供安全隔离,确保所有文件操作都在项目目录内执行。
### 核心函数
```python
# backend/utils/workspace.py
def get_workspace_root() -> Path:
"""获取工作区根目录"""
def get_project_path(project_id: str, project_path: str) -> Path:
"""获取项目绝对路径"""
def validate_path_in_project(path: str, project_dir: Path) -> Path:
"""验证路径在项目目录内(核心安全函数)"""
def create_project_directory(name: str, user_id: int) -> tuple:
"""创建项目目录"""
def delete_project_directory(project_path: str) -> bool:
"""删除项目目录"""
def copy_folder_to_project(source_path: str, project_dir: Path, project_name: str) -> dict:
"""复制文件夹到项目目录"""
```
### 安全机制
`validate_path_in_project()` 是核心安全函数:
```python
def validate_path_in_project(path: str, project_dir: Path) -> Path:
p = Path(path)
# 相对路径转换为绝对路径
if not p.is_absolute():
p = project_dir / p
p = p.resolve()
# 安全检查:确保路径在项目目录内
try:
p.relative_to(project_dir.resolve())
except ValueError:
raise ValueError(f"Path '{path}' is outside project directory")
return p
```
即使传入恶意路径,后端也会拒绝:
```python
"../../../etc/passwd" # 尝试跳出项目目录 -> ValueError
"/etc/passwd" # 绝对路径攻击 -> ValueError
```
### project_id 自动注入
工具执行器自动为文件工具注入 `project_id`
```python
# backend/tools/executor.py
def process_tool_calls(self, tool_calls, context=None):
for call in tool_calls:
name = call["function"]["name"]
args = json.loads(call["function"]["arguments"])
# 自动注入 project_id
if context and name.startswith("file_") and "project_id" in context:
args["project_id"] = context["project_id"]
result = self.registry.execute(name, args)
```
---
## API 总览 ## API 总览
### 会话管理 ### 会话管理
@ -268,6 +371,19 @@ classDiagram
| `GET` | `/api/conversations/:id/messages` | 获取消息列表(游标分页) | | `GET` | `/api/conversations/:id/messages` | 获取消息列表(游标分页) |
| `POST` | `/api/conversations/:id/messages` | 发送消息(支持 SSE 流式) | | `POST` | `/api/conversations/:id/messages` | 发送消息(支持 SSE 流式) |
| `DELETE` | `/api/conversations/:id/messages/:mid` | 删除消息 | | `DELETE` | `/api/conversations/:id/messages/:mid` | 删除消息 |
| `POST` | `/api/conversations/:id/regenerate/:mid` | 重新生成消息 |
### 项目管理
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET` | `/api/projects` | 获取项目列表 |
| `POST` | `/api/projects` | 创建项目 |
| `GET` | `/api/projects/:id` | 获取项目详情 |
| `PUT` | `/api/projects/:id` | 更新项目 |
| `DELETE` | `/api/projects/:id` | 删除项目 |
| `POST` | `/api/projects/upload` | 上传文件夹作为项目 |
| `GET` | `/api/projects/:id/files` | 列出项目文件 |
### 其他 ### 其他
@ -292,26 +408,6 @@ classDiagram
| `error` | 错误信息 | | `error` | 错误信息 |
| `done` | 回复结束,携带 message_id 和 token_count | | `done` | 回复结束,携带 message_id 和 token_count |
### 思考与工具调用交替流程
```
iteration 1:
thinking_start -> 前端清空 streamThinking
thinking (增量) -> 前端累加到 streamThinking
process_step(thinking, "思考内容A")
tool_calls -> 批量通知(兼容)
process_step(tool_call, "file_read") -> 调用工具
process_step(tool_result, {...}) -> 立即返回结果
process_step(tool_call, "file_list") -> 下一个工具
process_step(tool_result, {...}) -> 立即返回结果
iteration 2:
thinking_start -> 前端清空 streamThinking
thinking (增量) -> 前端累加到 streamThinking
process_step(thinking, "思考内容B")
done
```
### process_step 事件格式 ### process_step 事件格式
```json ```json
@ -346,12 +442,25 @@ iteration 2:
| `password` | String(255) | 密码(可为空,支持第三方登录) | | `password` | String(255) | 密码(可为空,支持第三方登录) |
| `phone` | String(20) | 手机号 | | `phone` | String(20) | 手机号 |
### Project项目
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | String(64) | UUID 主键 |
| `user_id` | Integer | 外键关联 User |
| `name` | String(255) | 项目名称(用户内唯一) |
| `path` | String(512) | 相对路径(如 user_1/my_project |
| `description` | Text | 项目描述 |
| `created_at` | DateTime | 创建时间 |
| `updated_at` | DateTime | 更新时间 |
### Conversation会话 ### Conversation会话
| 字段 | 类型 | 默认值 | 说明 | | 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------| |------|------|--------|------|
| `id` | String(64) | UUID | 主键 | | `id` | String(64) | UUID | 主键 |
| `user_id` | Integer | - | 外键关联 User | | `user_id` | Integer | - | 外键关联 User |
| `project_id` | String(64) | null | 外键关联 Project可选 |
| `title` | String(255) | "" | 会话标题 | | `title` | String(255) | "" | 会话标题 |
| `model` | String(64) | "glm-5" | 模型名称 | | `model` | String(64) | "glm-5" | 模型名称 |
| `system_prompt` | Text | "" | 系统提示词 | | `system_prompt` | Text | "" | 系统提示词 |
@ -440,10 +549,13 @@ GET /api/conversations?limit=20&cursor=conv_abc123
backend_port: 3000 backend_port: 3000
frontend_port: 4000 frontend_port: 4000
# GLM API # LLM API
api_key: your-api-key api_key: your-api-key
api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
# 工作区根目录
workspace_root: ./workspaces
# 数据库 # 数据库
db_type: mysql # mysql, sqlite, postgresql db_type: mysql # mysql, sqlite, postgresql
db_host: localhost db_host: localhost
@ -452,4 +564,4 @@ db_user: root
db_password: "" db_password: ""
db_name: nano_claw db_name: nano_claw
db_sqlite_file: app.db # SQLite 时使用 db_sqlite_file: app.db # SQLite 时使用
``` ```

File diff suppressed because it is too large Load Diff

View File

@ -5,10 +5,12 @@
:current-id="currentConvId" :current-id="currentConvId"
:loading="loadingConvs" :loading="loadingConvs"
:has-more="hasMoreConvs" :has-more="hasMoreConvs"
:current-project="currentProject"
@select="selectConversation" @select="selectConversation"
@create="createConversation" @create="createConversation"
@delete="deleteConversation" @delete="deleteConversation"
@load-more="loadMoreConversations" @load-more="loadMoreConversations"
@select-project="selectProject"
/> />
<ChatView <ChatView
@ -78,6 +80,7 @@ let currentStreamPromise = null
// -- UI state -- // -- UI state --
const showSettings = ref(false) const showSettings = ref(false)
const toolsEnabled = ref(localStorage.getItem('tools_enabled') !== 'false') // const toolsEnabled = ref(localStorage.getItem('tools_enabled') !== 'false') //
const currentProject = ref(null) // Current selected project
const currentConv = computed(() => const currentConv = computed(() =>
conversations.value.find(c => c.id === currentConvId.value) || null conversations.value.find(c => c.id === currentConvId.value) || null
@ -211,7 +214,7 @@ async function sendMessage(data) {
streamToolCalls.value = [] streamToolCalls.value = []
streamProcessSteps.value = [] streamProcessSteps.value = []
currentStreamPromise = messageApi.send(convId, { text, attachments }, { currentStreamPromise = messageApi.send(convId, { text, attachments, projectId: currentProject.value?.id }, {
stream: true, stream: true,
toolsEnabled: toolsEnabled.value, toolsEnabled: toolsEnabled.value,
onThinkingStart() { onThinkingStart() {
@ -383,6 +386,7 @@ async function regenerateMessage(msgId) {
currentStreamPromise = messageApi.regenerate(convId, msgId, { currentStreamPromise = messageApi.regenerate(convId, msgId, {
toolsEnabled: toolsEnabled.value, toolsEnabled: toolsEnabled.value,
projectId: currentProject.value?.id,
onThinkingStart() { onThinkingStart() {
if (currentConvId.value === convId) { if (currentConvId.value === convId) {
streamThinking.value = '' streamThinking.value = ''
@ -493,6 +497,11 @@ function updateToolsEnabled(val) {
localStorage.setItem('tools_enabled', String(val)) localStorage.setItem('tools_enabled', String(val))
} }
// -- Select project --
function selectProject(project) {
currentProject.value = project
}
// -- Init -- // -- Init --
onMounted(() => { onMounted(() => {
loadConversations() loadConversations()

View File

@ -99,7 +99,7 @@ export const messageApi = {
if (!stream) { if (!stream) {
return request(`/conversations/${convId}/messages`, { return request(`/conversations/${convId}/messages`, {
method: 'POST', method: 'POST',
body: { text: data.text, attachments: data.attachments, stream: false, tools_enabled: toolsEnabled }, body: { text: data.text, attachments: data.attachments, stream: false, tools_enabled: toolsEnabled, project_id: data.projectId },
}) })
} }
@ -110,7 +110,7 @@ export const messageApi = {
const res = await fetch(`${BASE}/conversations/${convId}/messages`, { const res = await fetch(`${BASE}/conversations/${convId}/messages`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: data.text, attachments: data.attachments, stream: true, tools_enabled: toolsEnabled }), body: JSON.stringify({ text: data.text, attachments: data.attachments, stream: true, tools_enabled: toolsEnabled, project_id: data.projectId }),
signal: controller.signal, signal: controller.signal,
}) })
@ -173,7 +173,7 @@ export const messageApi = {
return request(`/conversations/${convId}/messages/${msgId}`, { method: 'DELETE' }) return request(`/conversations/${convId}/messages/${msgId}`, { method: 'DELETE' })
}, },
regenerate(convId, msgId, { toolsEnabled = true, onThinkingStart, onThinking, onMessage, onToolCalls, onToolResult, onProcessStep, onDone, onError } = {}) { regenerate(convId, msgId, { toolsEnabled = true, projectId, onThinkingStart, onThinking, onMessage, onToolCalls, onToolResult, onProcessStep, onDone, onError } = {}) {
const controller = new AbortController() const controller = new AbortController()
const promise = (async () => { const promise = (async () => {
@ -181,7 +181,7 @@ export const messageApi = {
const res = await fetch(`${BASE}/conversations/${convId}/regenerate/${msgId}`, { const res = await fetch(`${BASE}/conversations/${convId}/regenerate/${msgId}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tools_enabled: toolsEnabled }), body: JSON.stringify({ tools_enabled: toolsEnabled, project_id: projectId }),
signal: controller.signal, signal: controller.signal,
}) })
@ -240,3 +240,43 @@ export const messageApi = {
return promise return promise
}, },
} }
export const projectApi = {
list(userId) {
return request(`/projects?user_id=${userId}`)
},
create(data) {
return request('/projects', {
method: 'POST',
body: data,
})
},
get(projectId) {
return request(`/projects/${projectId}`)
},
update(projectId, data) {
return request(`/projects/${projectId}`, {
method: 'PUT',
body: data,
})
},
delete(projectId) {
return request(`/projects/${projectId}`, { method: 'DELETE' })
},
uploadFolder(data) {
return request('/projects/upload', {
method: 'POST',
body: data,
})
},
listFiles(projectId, path = '') {
const params = path ? `?path=${encodeURIComponent(path)}` : ''
return request(`/projects/${projectId}/files${params}`)
},
}

View File

@ -175,10 +175,12 @@ const processItems = computed(() => {
if (props.toolCalls && props.toolCalls.length > 0) { if (props.toolCalls && props.toolCalls.length > 0) {
props.toolCalls.forEach((call, i) => { props.toolCalls.forEach((call, i) => {
const toolName = call.function?.name || '未知工具'
items.push({ items.push({
type: 'tool_call', type: 'tool_call',
label: `调用工具: ${call.function?.name || '未知工具'}`, label: `调用工具: ${toolName}`,
toolName: call.function?.name || '未知工具', toolName: toolName,
arguments: formatArgs(call.function?.arguments), arguments: formatArgs(call.function?.arguments),
id: call.id, id: call.id,
index: idx, index: idx,
@ -191,7 +193,7 @@ const processItems = computed(() => {
const resultSummary = getResultSummary(call.result) const resultSummary = getResultSummary(call.result)
items.push({ items.push({
type: 'tool_result', type: 'tool_result',
label: `工具返回: ${call.function?.name || '未知工具'}`, label: `工具返回: ${toolName}`,
content: formatResult(call.result), content: formatResult(call.result),
summary: resultSummary.text, summary: resultSummary.text,
isSuccess: resultSummary.success, isSuccess: resultSummary.success,
@ -204,7 +206,7 @@ const processItems = computed(() => {
} else if (props.streaming) { } else if (props.streaming) {
// //
items[items.length - 1].loading = true items[items.length - 1].loading = true
items[items.length - 1].label = `执行工具: ${call.function?.name || '未知工具'}` items[items.length - 1].label = `执行工具: ${toolName}`
} }
}) })
} }

View File

@ -0,0 +1,518 @@
<template>
<div class="project-manager">
<div class="project-header">
<h3>项目管理</h3>
<button class="btn-icon" @click="showCreateModal = true" title="创建项目">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<button class="btn-icon" @click="showUploadModal = true" title="上传文件夹">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17,8 12,3 7,8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
</button>
</div>
<div class="project-list">
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="projects.length === 0" class="empty">
<p>暂无项目</p>
<p class="hint">创建项目或上传文件夹开始使用</p>
</div>
<div
v-else
v-for="project in projects"
:key="project.id"
class="project-item"
:class="{ active: currentProject?.id === project.id }"
@click="$emit('select', project)"
>
<div class="project-info">
<div class="project-name">{{ project.name }}</div>
<div class="project-meta">
<span>{{ project.conversation_count || 0 }} 个对话</span>
</div>
</div>
<button class="btn-icon danger" @click.stop="confirmDelete(project)" title="删除项目">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3,6 5,6 21,6"></polyline>
<path d="M19,6v14a2,2,0,0,1-2,2H7a2,2,0,0,1-2-2V6m3,0V4a2,2,0,0,1,2-2h4a2,2,0,0,1,2,2v2"></path>
</svg>
</button>
</div>
</div>
<!-- 创建项目模态框 -->
<div v-if="showCreateModal" class="modal-overlay" @click.self="showCreateModal = false">
<div class="modal">
<div class="modal-header">
<h3>创建项目</h3>
<button class="btn-close" @click="showCreateModal = false">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>项目名称</label>
<input v-model="newProject.name" type="text" placeholder="输入项目名称" />
</div>
<div class="form-group">
<label>描述可选</label>
<textarea v-model="newProject.description" placeholder="输入项目描述" rows="3"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="showCreateModal = false">取消</button>
<button class="btn-primary" @click="createProject" :disabled="!newProject.name.trim() || creating">
{{ creating ? '创建中...' : '创建' }}
</button>
</div>
</div>
</div>
<!-- 上传文件夹模态框 -->
<div v-if="showUploadModal" class="modal-overlay" @click.self="showUploadModal = false">
<div class="modal">
<div class="modal-header">
<h3>上传文件夹</h3>
<button class="btn-close" @click="showUploadModal = false">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>项目名称</label>
<input v-model="uploadData.name" type="text" placeholder="留空则使用文件夹名称" />
</div>
<div class="form-group">
<label>文件夹路径</label>
<input v-model="uploadData.folderPath" type="text" placeholder="输入文件夹绝对路径" />
</div>
<div class="form-group">
<label>描述可选</label>
<textarea v-model="uploadData.description" placeholder="输入项目描述" rows="3"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="showUploadModal = false">取消</button>
<button class="btn-primary" @click="uploadFolder" :disabled="!uploadData.folderPath.trim() || uploading">
{{ uploading ? '上传中...' : '上传' }}
</button>
</div>
</div>
</div>
<!-- 删除确认模态框 -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header">
<h3>确认删除</h3>
<button class="btn-close" @click="showDeleteModal = false">&times;</button>
</div>
<div class="modal-body">
<p>确定要删除项目 <strong>{{ projectToDelete?.name }}</strong> </p>
<p class="warning">这将同时删除项目中的所有文件和对话记录此操作不可恢复</p>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="showDeleteModal = false">取消</button>
<button class="btn-danger" @click="deleteProject" :disabled="deleting">
{{ deleting ? '删除中...' : '删除' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { projectApi } from '../api'
const props = defineProps({
currentProject: Object,
})
const emit = defineEmits(['select', 'created', 'deleted'])
const projects = ref([])
const loading = ref(false)
const showCreateModal = ref(false)
const showUploadModal = ref(false)
const showDeleteModal = ref(false)
const creating = ref(false)
const uploading = ref(false)
const deleting = ref(false)
const projectToDelete = ref(null)
const newProject = ref({
name: '',
description: '',
})
const uploadData = ref({
name: '',
folderPath: '',
description: '',
})
// ID
const userId = 1
async function loadProjects() {
loading.value = true
try {
const res = await projectApi.list(userId)
projects.value = res.data.projects || []
} catch (e) {
console.error('Failed to load projects:', e)
} finally {
loading.value = false
}
}
async function createProject() {
if (!newProject.value.name.trim()) return
creating.value = true
try {
const res = await projectApi.create({
user_id: userId,
name: newProject.value.name.trim(),
description: newProject.value.description.trim(),
})
projects.value.unshift(res.data)
showCreateModal.value = false
newProject.value = { name: '', description: '' }
emit('created', res.data)
} catch (e) {
console.error('Failed to create project:', e)
alert('创建项目失败: ' + e.message)
} finally {
creating.value = false
}
}
async function uploadFolder() {
if (!uploadData.value.folderPath.trim()) return
uploading.value = true
try {
const res = await projectApi.uploadFolder({
user_id: userId,
name: uploadData.value.name.trim() || undefined,
folder_path: uploadData.value.folderPath.trim(),
description: uploadData.value.description.trim(),
})
projects.value.unshift(res.data)
showUploadModal.value = false
uploadData.value = { name: '', folderPath: '', description: '' }
emit('created', res.data)
} catch (e) {
console.error('Failed to upload folder:', e)
alert('上传文件夹失败: ' + e.message)
} finally {
uploading.value = false
}
}
function confirmDelete(project) {
projectToDelete.value = project
showDeleteModal.value = true
}
async function deleteProject() {
if (!projectToDelete.value) return
deleting.value = true
try {
await projectApi.delete(projectToDelete.value.id)
projects.value = projects.value.filter(p => p.id !== projectToDelete.value.id)
showDeleteModal.value = false
emit('deleted', projectToDelete.value.id)
projectToDelete.value = null
} catch (e) {
console.error('Failed to delete project:', e)
alert('删除项目失败: ' + e.message)
} finally {
deleting.value = false
}
}
onMounted(() => {
loadProjects()
})
defineExpose({
loadProjects,
})
</script>
<style scoped>
.project-manager {
display: flex;
flex-direction: column;
height: 100%;
}
.project-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-light);
}
.project-header h3 {
flex: 1;
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.btn-icon {
padding: 6px;
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.btn-icon:hover {
background: var(--bg-hover);
color: var(--accent-primary);
}
.btn-icon.danger:hover {
background: var(--danger-bg);
color: var(--danger-color);
}
.project-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.loading, .empty {
text-align: center;
padding: 32px 16px;
color: var(--text-secondary);
font-size: 13px;
}
.empty .hint {
margin-top: 8px;
font-size: 12px;
color: var(--text-tertiary);
}
.project-item {
display: flex;
align-items: center;
padding: 10px 12px;
margin-bottom: 4px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.project-item:hover {
background: var(--bg-hover);
}
.project-item.active {
background: var(--accent-primary-light);
}
.project-info {
flex: 1;
min-width: 0;
}
.project-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-meta {
font-size: 12px;
color: var(--text-tertiary);
}
/* Modal styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--overlay-bg);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-primary);
border-radius: 12px;
width: 90%;
max-width: 480px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-light);
}
.modal-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.btn-close {
border: none;
background: transparent;
font-size: 24px;
color: var(--text-tertiary);
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-close:hover {
color: var(--text-primary);
}
.modal-body {
padding: 20px;
}
.form-group {
margin-bottom: 16px;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 8px;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-input);
border-radius: 8px;
background: var(--bg-input);
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-primary);
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.modal-footer {
display: flex;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid var(--border-light);
}
.btn-primary,
.btn-secondary,
.btn-danger {
flex: 1;
padding: 10px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: var(--accent-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-primary-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-secondary:hover {
background: var(--bg-hover);
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-danger:hover:not(:disabled) {
opacity: 0.9;
}
.btn-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.warning {
margin-top: 12px;
padding: 12px;
background: var(--danger-bg);
border-radius: 8px;
font-size: 13px;
color: var(--danger-color);
}
</style>

View File

@ -1,5 +1,39 @@
<template> <template>
<aside class="sidebar"> <aside class="sidebar">
<!-- Project Selector -->
<div class="project-section">
<div class="project-selector" @click="showProjects = !showProjects">
<div class="project-current">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
<span>{{ currentProject?.name || '选择项目' }}</span>
</div>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
:style="{ transform: showProjects ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</div>
</div>
<!-- Project Manager Panel -->
<div v-if="showProjects" class="project-panel">
<ProjectManager
ref="projectManagerRef"
:current-project="currentProject"
@select="selectProject"
@created="onProjectCreated"
@deleted="onProjectDeleted"
/>
</div>
<div class="sidebar-header"> <div class="sidebar-header">
<button class="btn-new" @click="$emit('create')"> <button class="btn-new" @click="$emit('create')">
<span class="icon">+</span> <span class="icon">+</span>
@ -39,14 +73,38 @@
</template> </template>
<script setup> <script setup>
defineProps({ import { ref } from 'vue'
import ProjectManager from './ProjectManager.vue'
const props = defineProps({
conversations: { type: Array, required: true }, conversations: { type: Array, required: true },
currentId: { type: String, default: null }, currentId: { type: String, default: null },
loading: { type: Boolean, default: false }, loading: { type: Boolean, default: false },
hasMore: { type: Boolean, default: false }, hasMore: { type: Boolean, default: false },
currentProject: { type: Object, default: null },
}) })
const emit = defineEmits(['select', 'create', 'delete', 'loadMore']) const emit = defineEmits(['select', 'create', 'delete', 'loadMore', 'selectProject'])
const showProjects = ref(false)
const projectManagerRef = ref(null)
function selectProject(project) {
emit('selectProject', project)
showProjects.value = false
}
function onProjectCreated(project) {
// Auto-select newly created project
emit('selectProject', project)
}
function onProjectDeleted(projectId) {
// If deleted project is current, clear selection
if (props.currentProject?.id === projectId) {
emit('selectProject', null)
}
}
function formatTime(iso) { function formatTime(iso) {
if (!iso) return '' if (!iso) return ''
@ -85,6 +143,44 @@ function onContextMenu(e, conv) {
transition: all 0.2s; transition: all 0.2s;
} }
.project-section {
padding: 12px 16px;
border-bottom: 1px solid var(--border-light);
}
.project-selector {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.project-selector:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.project-current {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.project-panel {
max-height: 300px;
overflow-y: auto;
border-bottom: 1px solid var(--border-light);
background: var(--bg-secondary);
}
.sidebar-header { .sidebar-header {
padding: 16px; padding: 16px;
} }