feat: 增加项目管理功能
This commit is contained in:
parent
46cacc7fa2
commit
8325100c90
93
README.md
93
README.md
|
|
@ -1,12 +1,13 @@
|
|||
# NanoClaw
|
||||
|
||||
基于 GLM 大语言模型的 AI 对话应用,支持工具调用、思维链和流式回复。
|
||||
基于 LLM 大语言模型的 AI 对话应用,支持工具调用、思维链、流式回复和工作目录隔离。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 💬 **多轮对话** - 支持上下文管理的多轮对话
|
||||
- 🔧 **工具调用** - 网页搜索、代码执行、文件操作等
|
||||
- 🧠 **思维链** - 支持链式思考推理
|
||||
- 📁 **工作目录** - 项目级文件隔离,安全操作
|
||||
- 📊 **Token 统计** - 按日/周/月统计使用量
|
||||
- 🔄 **流式响应** - 实时 SSE 流式输出
|
||||
- 💾 **多数据库** - 支持 MySQL、SQLite、PostgreSQL
|
||||
|
|
@ -28,7 +29,7 @@ pip install -e .
|
|||
backend_port: 3000
|
||||
frontend_port: 4000
|
||||
|
||||
# AI API
|
||||
# LLM API
|
||||
api_key: {{your-api-key}}
|
||||
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:
|
||||
- id: glm-5
|
||||
name: GLM-5
|
||||
- id: glm-5-turbo
|
||||
name: GLM-5 Turbo
|
||||
- id: glm-4.5
|
||||
name: GLM-4.5
|
||||
- id: glm-4.6
|
||||
name: GLM-4.6
|
||||
- id: glm-4.7
|
||||
name: GLM-4.7
|
||||
- id: glm-4-plus
|
||||
name: GLM-4 Plus
|
||||
|
||||
default_model: glm-5
|
||||
|
||||
# Workspace root directory
|
||||
workspace_root: ./workspaces
|
||||
|
||||
# Database Configuration
|
||||
# Supported types: mysql, sqlite, postgresql
|
||||
db_type: sqlite
|
||||
|
||||
# MySQL/PostgreSQL Settings (ignored for sqlite)
|
||||
db_host: localhost
|
||||
db_port: 3306
|
||||
db_user: root
|
||||
db_password: "123456"
|
||||
db_name: nano_claw
|
||||
|
||||
# SQLite Settings (ignored for mysql/postgresql)
|
||||
db_sqlite_file: nano_claw.db
|
||||
|
||||
```
|
||||
|
||||
### 3. 启动后端
|
||||
### 3. 数据库迁移(首次运行或升级)
|
||||
|
||||
```bash
|
||||
python -m backend.migrations.add_project_support
|
||||
```
|
||||
|
||||
### 4. 启动后端
|
||||
|
||||
```bash
|
||||
python -m backend.run
|
||||
```
|
||||
|
||||
### 4. 启动前端
|
||||
### 5. 启动前端
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
|
@ -83,6 +76,10 @@ npm run dev
|
|||
backend/
|
||||
├── models.py # SQLAlchemy 数据模型
|
||||
├── routes/ # API 路由
|
||||
│ ├── conversations.py
|
||||
│ ├── messages.py
|
||||
│ ├── projects.py # 项目管理
|
||||
│ └── ...
|
||||
├── services/ # 业务逻辑
|
||||
│ ├── chat.py # 聊天补全服务
|
||||
│ └── glm_client.py
|
||||
|
|
@ -90,7 +87,10 @@ backend/
|
|||
│ ├── core.py # 核心类
|
||||
│ ├── executor.py # 工具执行器
|
||||
│ └── builtin/ # 内置工具
|
||||
└── utils/ # 辅助函数
|
||||
├── utils/ # 辅助函数
|
||||
│ ├── helpers.py
|
||||
│ └── workspace.py # 工作目录工具
|
||||
└── migrations/ # 数据库迁移
|
||||
|
||||
frontend/
|
||||
└── src/
|
||||
|
|
@ -99,6 +99,24 @@ frontend/
|
|||
└── views/ # 页面
|
||||
```
|
||||
|
||||
## 工作目录系统
|
||||
|
||||
### 概述
|
||||
|
||||
工作目录系统为文件操作提供安全隔离,确保 AI 只能访问指定项目目录内的文件。
|
||||
|
||||
### 使用流程
|
||||
|
||||
1. **创建项目** - 在侧边栏点击"新建项目"或上传文件夹
|
||||
2. **选择项目** - 在对话中选择当前工作目录
|
||||
3. **文件操作** - AI 自动在项目目录内执行文件操作
|
||||
|
||||
### 安全机制
|
||||
|
||||
- 所有文件操作需要 `project_id` 参数
|
||||
- 后端强制验证路径在项目目录内
|
||||
- 阻止目录遍历攻击(如 `../../../etc/passwd`)
|
||||
|
||||
## API 概览
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|
|
@ -107,26 +125,29 @@ frontend/
|
|||
| `GET` | `/api/conversations` | 会话列表 |
|
||||
| `GET` | `/api/conversations/:id/messages` | 消息列表 |
|
||||
| `POST` | `/api/conversations/:id/messages` | 发送消息(SSE) |
|
||||
| `GET` | `/api/projects` | 项目列表 |
|
||||
| `POST` | `/api/projects` | 创建项目 |
|
||||
| `POST` | `/api/projects/upload` | 上传文件夹 |
|
||||
| `GET` | `/api/tools` | 工具列表 |
|
||||
| `GET` | `/api/stats/tokens` | Token 统计 |
|
||||
|
||||
## 内置工具
|
||||
|
||||
| 分类 | 工具 |
|
||||
|------|------|
|
||||
| **爬虫** | web_search, fetch_page, crawl_batch |
|
||||
| **数据处理** | calculator, text_process, json_process |
|
||||
| **代码执行** | execute_python(沙箱环境) |
|
||||
| **文件操作** | file_read, file_write, file_list 等 |
|
||||
| **天气** | get_weather |
|
||||
| 分类 | 工具 | 说明 |
|
||||
|------|------|------|
|
||||
| **爬虫** | web_search, fetch_page, crawl_batch | 网页搜索和抓取 |
|
||||
| **数据处理** | calculator, text_process, json_process | 数学计算和文本处理 |
|
||||
| **代码执行** | execute_python | 沙箱环境执行 Python |
|
||||
| **文件操作** | file_read, file_write, file_list 等 | **需要 project_id** |
|
||||
| **天气** | get_weather | 天气查询(模拟) |
|
||||
|
||||
## 文档
|
||||
|
||||
- [后端设计](docs/Design.md) - 架构设计、类图、API 文档
|
||||
- [工具系统](docs/ToolSystemDesign.md) - 工具开发指南
|
||||
- [后端设计](docs/Design.md) - 架构设计、数据模型、API 文档
|
||||
- [工具系统](docs/ToolSystemDesign.md) - 工具开发指南、安全设计
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**: Python 3.11+, Flask
|
||||
- **前端**: Vue 3
|
||||
- **大模型**: GLM API(智谱AI)
|
||||
- **后端**: Python 3.11+, Flask, SQLAlchemy
|
||||
- **前端**: Vue 3, Vite
|
||||
- **LLM**: 支持 GLM 等大语言模型
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ def create_app():
|
|||
db.init_app(app)
|
||||
|
||||
# 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.tools import init_tools
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ class Conversation(db.Model):
|
|||
|
||||
id = db.Column(db.String(64), primary_key=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="")
|
||||
model = db.Column(db.String(64), nullable=False, default="glm-5")
|
||||
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.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"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.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.services.glm_client import GLMClient
|
||||
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(tools_bp)
|
||||
app.register_blueprint(stats_bp)
|
||||
app.register_blueprint(projects_bp)
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ def message_list(conv_id):
|
|||
d = request.json or {}
|
||||
text = (d.get("text") or "").strip()
|
||||
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:
|
||||
return err(400, "text or attachments is required")
|
||||
|
|
@ -69,9 +70,9 @@ def message_list(conv_id):
|
|||
tools_enabled = d.get("tools_enabled", True)
|
||||
|
||||
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"])
|
||||
|
|
@ -113,6 +114,7 @@ def regenerate_message(conv_id, msg_id):
|
|||
# 获取工具启用状态
|
||||
d = request.json or {}
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -8,7 +8,7 @@ from backend.tools import registry, ToolExecutor
|
|||
from backend.utils.helpers import (
|
||||
get_or_create_default_user,
|
||||
record_token_usage,
|
||||
build_glm_messages,
|
||||
build_messages,
|
||||
ok,
|
||||
err,
|
||||
to_dict,
|
||||
|
|
@ -26,14 +26,23 @@ class ChatService:
|
|||
self.executor = ToolExecutor(registry=registry)
|
||||
|
||||
|
||||
def sync_response(self, conv: Conversation, tools_enabled: bool = True):
|
||||
"""Sync response with tool call support"""
|
||||
def sync_response(self, conv: Conversation, tools_enabled: bool = True, project_id: str = None):
|
||||
"""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
|
||||
messages = build_glm_messages(conv)
|
||||
messages = build_messages(conv, project_id)
|
||||
|
||||
# Clear tool call history for new request
|
||||
self.executor.clear_history()
|
||||
|
||||
# Build context for tool execution
|
||||
context = {"project_id": project_id} if project_id else None
|
||||
|
||||
all_tool_calls = []
|
||||
all_tool_results = []
|
||||
|
||||
|
|
@ -119,27 +128,35 @@ class ChatService:
|
|||
all_tool_calls.extend(tool_calls)
|
||||
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)
|
||||
messages.extend(tool_results)
|
||||
|
||||
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
|
||||
|
||||
Uses 'process_step' events to send thinking and tool calls in order,
|
||||
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_model = conv.model
|
||||
app = current_app._get_current_object()
|
||||
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
|
||||
self.executor.clear_history()
|
||||
|
||||
# Build context for tool execution
|
||||
context = {"project_id": project_id} if project_id else None
|
||||
|
||||
def generate():
|
||||
messages = list(initial_messages)
|
||||
all_tool_calls = []
|
||||
|
|
@ -232,17 +249,16 @@ class ChatService:
|
|||
step_index += 1
|
||||
|
||||
# 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)
|
||||
|
||||
# Send tool result step immediately
|
||||
tr = single_result[0]
|
||||
try:
|
||||
result_data = json.loads(tr["content"])
|
||||
skipped = result_data.get("skipped", False)
|
||||
result_content = json.loads(tr["content"])
|
||||
skipped = result_content.get("skipped", False)
|
||||
except:
|
||||
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"
|
||||
step_index += 1
|
||||
|
||||
|
|
@ -330,7 +346,7 @@ class ChatService:
|
|||
)
|
||||
|
||||
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 = []
|
||||
for i, tc in enumerate(tool_calls):
|
||||
result_content = tool_results[i]["content"] if i < len(tool_results) else None
|
||||
|
|
@ -348,10 +364,14 @@ class ChatService:
|
|||
except:
|
||||
pass
|
||||
|
||||
# Keep same structure as streaming format
|
||||
result.append({
|
||||
"id": tc.get("id", ""),
|
||||
"type": tc.get("type", "function"),
|
||||
"function": {
|
||||
"name": tc["function"]["name"],
|
||||
"arguments": tc["function"]["arguments"],
|
||||
},
|
||||
"result": result_content,
|
||||
"success": success,
|
||||
"skipped": skipped,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
"""Built-in tools"""
|
||||
from backend.tools.builtin.code import *
|
||||
from backend.tools.builtin.crawler import *
|
||||
from backend.tools.builtin.data import *
|
||||
from backend.tools.builtin.file_ops import *
|
||||
from backend.tools.builtin.weather import *
|
||||
|
|
|
|||
|
|
@ -2,41 +2,55 @@
|
|||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, Tuple
|
||||
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)
|
||||
# Set to None to allow any path, or set a specific directory for security
|
||||
BASE_DIR = Path(__file__).parent.parent.parent.parent # project root
|
||||
def _resolve_path(path: str, project_id: str = None) -> Tuple[Path, Path]:
|
||||
"""
|
||||
Resolve path and ensure it's within project directory
|
||||
|
||||
Args:
|
||||
path: File path (relative or absolute)
|
||||
project_id: Project ID for workspace isolation
|
||||
|
||||
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()
|
||||
Returns:
|
||||
Tuple of (resolved absolute path, project directory)
|
||||
|
||||
# Security check: ensure path is within BASE_DIR
|
||||
if BASE_DIR:
|
||||
try:
|
||||
p.relative_to(BASE_DIR.resolve())
|
||||
except ValueError:
|
||||
raise ValueError(f"Path '{path}' is outside allowed 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(
|
||||
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={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"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": {
|
||||
"type": "string",
|
||||
|
|
@ -44,7 +58,7 @@ def _resolve_path(path: str) -> Path:
|
|||
"default": "utf-8"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
"required": ["path", "project_id"]
|
||||
},
|
||||
category="file"
|
||||
)
|
||||
|
|
@ -55,46 +69,53 @@ def file_read(arguments: dict) -> dict:
|
|||
Args:
|
||||
arguments: {
|
||||
"path": "file.txt",
|
||||
"project_id": "project-uuid",
|
||||
"encoding": "utf-8"
|
||||
}
|
||||
|
||||
Returns:
|
||||
{"content": "...", "size": 100}
|
||||
{"success": true, "content": "...", "size": 100}
|
||||
"""
|
||||
try:
|
||||
path = _resolve_path(arguments["path"])
|
||||
path, project_dir = _resolve_path(arguments["path"], arguments.get("project_id"))
|
||||
encoding = arguments.get("encoding", "utf-8")
|
||||
|
||||
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():
|
||||
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)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"content": content,
|
||||
"size": len(content),
|
||||
"path": str(path)
|
||||
"path": str(path.relative_to(project_dir))
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@tool(
|
||||
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={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"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": {
|
||||
"type": "string",
|
||||
"description": "Content to write to the file"
|
||||
},
|
||||
"project_id": {
|
||||
"type": "string",
|
||||
"description": "Project ID for workspace isolation (required)"
|
||||
},
|
||||
"encoding": {
|
||||
"type": "string",
|
||||
"description": "File encoding, default utf-8",
|
||||
|
|
@ -107,7 +128,7 @@ def file_read(arguments: dict) -> dict:
|
|||
"default": "write"
|
||||
}
|
||||
},
|
||||
"required": ["path", "content"]
|
||||
"required": ["path", "content", "project_id"]
|
||||
},
|
||||
category="file"
|
||||
)
|
||||
|
|
@ -119,6 +140,7 @@ def file_write(arguments: dict) -> dict:
|
|||
arguments: {
|
||||
"path": "file.txt",
|
||||
"content": "Hello World",
|
||||
"project_id": "project-uuid",
|
||||
"encoding": "utf-8",
|
||||
"mode": "write"
|
||||
}
|
||||
|
|
@ -127,7 +149,7 @@ def file_write(arguments: dict) -> dict:
|
|||
{"success": true, "size": 11}
|
||||
"""
|
||||
try:
|
||||
path = _resolve_path(arguments["path"])
|
||||
path, project_dir = _resolve_path(arguments["path"], arguments.get("project_id"))
|
||||
content = arguments["content"]
|
||||
encoding = arguments.get("encoding", "utf-8")
|
||||
mode = arguments.get("mode", "write")
|
||||
|
|
@ -145,25 +167,29 @@ def file_write(arguments: dict) -> dict:
|
|||
return {
|
||||
"success": True,
|
||||
"size": len(content),
|
||||
"path": str(path),
|
||||
"path": str(path.relative_to(project_dir)),
|
||||
"mode": mode
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@tool(
|
||||
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={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"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"
|
||||
)
|
||||
|
|
@ -173,45 +199,51 @@ def file_delete(arguments: dict) -> dict:
|
|||
|
||||
Args:
|
||||
arguments: {
|
||||
"path": "file.txt"
|
||||
"path": "file.txt",
|
||||
"project_id": "project-uuid"
|
||||
}
|
||||
|
||||
Returns:
|
||||
{"success": true}
|
||||
"""
|
||||
try:
|
||||
path = _resolve_path(arguments["path"])
|
||||
path, project_dir = _resolve_path(arguments["path"], arguments.get("project_id"))
|
||||
|
||||
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():
|
||||
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()
|
||||
return {"success": True, "path": str(path)}
|
||||
return {"success": True, "path": rel_path}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@tool(
|
||||
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={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"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": "."
|
||||
},
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Glob pattern to filter files, e.g. '*.py'",
|
||||
"default": "*"
|
||||
},
|
||||
"project_id": {
|
||||
"type": "string",
|
||||
"description": "Project ID for workspace isolation (required)"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
"required": ["project_id"]
|
||||
},
|
||||
category="file"
|
||||
)
|
||||
|
|
@ -222,21 +254,22 @@ def file_list(arguments: dict) -> dict:
|
|||
Args:
|
||||
arguments: {
|
||||
"path": ".",
|
||||
"pattern": "*"
|
||||
"pattern": "*",
|
||||
"project_id": "project-uuid"
|
||||
}
|
||||
|
||||
Returns:
|
||||
{"files": [...], "directories": [...]}
|
||||
{"success": true, "files": [...], "directories": [...]}
|
||||
"""
|
||||
try:
|
||||
path = _resolve_path(arguments.get("path", "."))
|
||||
path, project_dir = _resolve_path(arguments.get("path", "."), arguments.get("project_id"))
|
||||
pattern = arguments.get("pattern", "*")
|
||||
|
||||
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():
|
||||
return {"error": f"Path is not a directory: {path}"}
|
||||
return {"success": False, "error": f"Path is not a directory: {path}"}
|
||||
|
||||
files = []
|
||||
directories = []
|
||||
|
|
@ -246,37 +279,42 @@ def file_list(arguments: dict) -> dict:
|
|||
files.append({
|
||||
"name": item.name,
|
||||
"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():
|
||||
directories.append({
|
||||
"name": item.name,
|
||||
"path": str(item.relative_to(BASE_DIR)) if BASE_DIR else str(item)
|
||||
"path": str(item.relative_to(project_dir))
|
||||
})
|
||||
|
||||
return {
|
||||
"path": str(path),
|
||||
"success": True,
|
||||
"path": str(path.relative_to(project_dir)),
|
||||
"files": files,
|
||||
"directories": directories,
|
||||
"total_files": len(files),
|
||||
"total_dirs": len(directories)
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@tool(
|
||||
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={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"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"
|
||||
)
|
||||
|
|
@ -286,53 +324,58 @@ def file_exists(arguments: dict) -> dict:
|
|||
|
||||
Args:
|
||||
arguments: {
|
||||
"path": "file.txt"
|
||||
"path": "file.txt",
|
||||
"project_id": "project-uuid"
|
||||
}
|
||||
|
||||
Returns:
|
||||
{"exists": true, "type": "file"}
|
||||
"""
|
||||
try:
|
||||
path = _resolve_path(arguments["path"])
|
||||
path, project_dir = _resolve_path(arguments["path"], arguments.get("project_id"))
|
||||
|
||||
if not path.exists():
|
||||
return {"exists": False, "path": str(path)}
|
||||
return {"exists": False, "path": str(path.relative_to(project_dir))}
|
||||
|
||||
if path.is_file():
|
||||
return {
|
||||
"exists": True,
|
||||
"type": "file",
|
||||
"path": str(path),
|
||||
"path": str(path.relative_to(project_dir)),
|
||||
"size": path.stat().st_size
|
||||
}
|
||||
elif path.is_dir():
|
||||
return {
|
||||
"exists": True,
|
||||
"type": "directory",
|
||||
"path": str(path)
|
||||
"path": str(path.relative_to(project_dir))
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"exists": True,
|
||||
"type": "other",
|
||||
"path": str(path)
|
||||
"path": str(path.relative_to(project_dir))
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@tool(
|
||||
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={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"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"
|
||||
)
|
||||
|
|
@ -342,19 +385,23 @@ def file_mkdir(arguments: dict) -> dict:
|
|||
|
||||
Args:
|
||||
arguments: {
|
||||
"path": "new/folder"
|
||||
"path": "new/folder",
|
||||
"project_id": "project-uuid"
|
||||
}
|
||||
|
||||
Returns:
|
||||
{"success": true}
|
||||
"""
|
||||
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)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": str(path),
|
||||
"created": not path.exists() or path.is_dir()
|
||||
"path": str(path.relative_to(project_dir)),
|
||||
"created": created
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return {"success": False, "error": str(e)}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ class ToolExecutor:
|
|||
|
||||
Args:
|
||||
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:
|
||||
Tool response message list, can be appended to messages
|
||||
|
|
@ -91,6 +91,12 @@ class ToolExecutor:
|
|||
))
|
||||
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
|
||||
call_key = f"{name}:{json.dumps(args, sort_keys=True)}"
|
||||
if call_key in seen_calls:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"""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__ = [
|
||||
"ok",
|
||||
|
|
@ -7,5 +7,5 @@ __all__ = [
|
|||
"to_dict",
|
||||
"get_or_create_default_user",
|
||||
"record_token_usage",
|
||||
"build_glm_messages",
|
||||
"build_messages",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -98,9 +98,16 @@ def record_token_usage(user_id, model, prompt_tokens, completion_tokens):
|
|||
db.session.commit()
|
||||
|
||||
|
||||
def build_glm_messages(conv):
|
||||
"""Build messages list for GLM API from conversation"""
|
||||
def build_messages(conv, project_id=None):
|
||||
"""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 = []
|
||||
|
||||
# System prompt (project_id is handled by backend for security)
|
||||
if conv.system_prompt:
|
||||
msgs.append({"role": "system", "content": conv.system_prompt})
|
||||
# Query messages directly to avoid detached instance warning
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
174
docs/Design.md
174
docs/Design.md
|
|
@ -16,14 +16,14 @@ graph TB
|
|||
end
|
||||
|
||||
subgraph External[外部服务]
|
||||
GLM[GLM API]
|
||||
LLM[LLM API]
|
||||
WEB[Web Resources]
|
||||
end
|
||||
|
||||
UI -->|REST/SSE| API
|
||||
API --> SVC
|
||||
API --> TOOLS
|
||||
SVC --> GLM
|
||||
SVC --> LLM
|
||||
TOOLS --> WEB
|
||||
SVC --> DB
|
||||
TOOLS --> DB
|
||||
|
|
@ -45,6 +45,7 @@ backend/
|
|||
│ ├── conversations.py # 会话 CRUD
|
||||
│ ├── messages.py # 消息 CRUD + 聊天
|
||||
│ ├── models.py # 模型列表
|
||||
│ ├── projects.py # 项目管理
|
||||
│ ├── stats.py # Token 统计
|
||||
│ └── tools.py # 工具列表
|
||||
│
|
||||
|
|
@ -63,12 +64,16 @@ backend/
|
|||
│ ├── crawler.py # 网页搜索、抓取
|
||||
│ ├── data.py # 计算器、文本、JSON
|
||||
│ ├── weather.py # 天气查询
|
||||
│ ├── file_ops.py # 文件操作
|
||||
│ ├── file_ops.py # 文件操作(需要 project_id)
|
||||
│ └── code.py # 代码执行
|
||||
│
|
||||
└── utils/ # 辅助函数
|
||||
├── __init__.py
|
||||
└── helpers.py # 通用函数
|
||||
├── utils/ # 辅助函数
|
||||
│ ├── __init__.py
|
||||
│ ├── helpers.py # 通用函数
|
||||
│ └── workspace.py # 工作目录工具
|
||||
│
|
||||
└── migrations/ # 数据库迁移
|
||||
└── add_project_support.py
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -87,11 +92,24 @@ classDiagram
|
|||
+String password
|
||||
+String phone
|
||||
+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 {
|
||||
+String id
|
||||
+Integer user_id
|
||||
+String project_id
|
||||
+String title
|
||||
+String model
|
||||
+String system_prompt
|
||||
|
|
@ -124,6 +142,8 @@ classDiagram
|
|||
}
|
||||
|
||||
User "1" --> "*" Conversation : 拥有
|
||||
User "1" --> "*" Project : 拥有
|
||||
Project "1" --> "*" Conversation : 包含
|
||||
Conversation "1" --> "*" Message : 包含
|
||||
User "1" --> "*" TokenUsage : 消耗
|
||||
```
|
||||
|
|
@ -150,8 +170,11 @@ classDiagram
|
|||
"tool_calls": [
|
||||
{
|
||||
"id": "call_xxx",
|
||||
"name": "read_file",
|
||||
"arguments": "{\"path\": \"...\"}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "file_read",
|
||||
"arguments": "{\"path\": \"...\"}"
|
||||
},
|
||||
"result": "{\"content\": \"...\"}",
|
||||
"success": true,
|
||||
"skipped": false,
|
||||
|
|
@ -171,8 +194,8 @@ classDiagram
|
|||
-GLMClient glm_client
|
||||
-ToolExecutor executor
|
||||
+Integer MAX_ITERATIONS
|
||||
+sync_response(conv, tools_enabled) Response
|
||||
+stream_response(conv, tools_enabled) Response
|
||||
+sync_response(conv, tools_enabled, project_id) Response
|
||||
+stream_response(conv, tools_enabled, project_id) Response
|
||||
-_build_tool_calls_json(calls, results) list
|
||||
-_message_to_dict(msg) dict
|
||||
-_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 总览
|
||||
|
||||
### 会话管理
|
||||
|
|
@ -268,6 +371,19 @@ classDiagram
|
|||
| `GET` | `/api/conversations/:id/messages` | 获取消息列表(游标分页) |
|
||||
| `POST` | `/api/conversations/:id/messages` | 发送消息(支持 SSE 流式) |
|
||||
| `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` | 错误信息 |
|
||||
| `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 事件格式
|
||||
|
||||
```json
|
||||
|
|
@ -346,12 +442,25 @@ iteration 2:
|
|||
| `password` | String(255) | 密码(可为空,支持第三方登录) |
|
||||
| `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(会话)
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `id` | String(64) | UUID | 主键 |
|
||||
| `user_id` | Integer | - | 外键关联 User |
|
||||
| `project_id` | String(64) | null | 外键关联 Project(可选) |
|
||||
| `title` | String(255) | "" | 会话标题 |
|
||||
| `model` | String(64) | "glm-5" | 模型名称 |
|
||||
| `system_prompt` | Text | "" | 系统提示词 |
|
||||
|
|
@ -440,10 +549,13 @@ GET /api/conversations?limit=20&cursor=conv_abc123
|
|||
backend_port: 3000
|
||||
frontend_port: 4000
|
||||
|
||||
# GLM API
|
||||
# LLM API
|
||||
api_key: your-api-key
|
||||
api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
|
||||
|
||||
# 工作区根目录
|
||||
workspace_root: ./workspaces
|
||||
|
||||
# 数据库
|
||||
db_type: mysql # mysql, sqlite, postgresql
|
||||
db_host: localhost
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -5,10 +5,12 @@
|
|||
:current-id="currentConvId"
|
||||
:loading="loadingConvs"
|
||||
:has-more="hasMoreConvs"
|
||||
:current-project="currentProject"
|
||||
@select="selectConversation"
|
||||
@create="createConversation"
|
||||
@delete="deleteConversation"
|
||||
@load-more="loadMoreConversations"
|
||||
@select-project="selectProject"
|
||||
/>
|
||||
|
||||
<ChatView
|
||||
|
|
@ -78,6 +80,7 @@ let currentStreamPromise = null
|
|||
// -- UI state --
|
||||
const showSettings = ref(false)
|
||||
const toolsEnabled = ref(localStorage.getItem('tools_enabled') !== 'false') // 默认开启
|
||||
const currentProject = ref(null) // Current selected project
|
||||
|
||||
const currentConv = computed(() =>
|
||||
conversations.value.find(c => c.id === currentConvId.value) || null
|
||||
|
|
@ -211,7 +214,7 @@ async function sendMessage(data) {
|
|||
streamToolCalls.value = []
|
||||
streamProcessSteps.value = []
|
||||
|
||||
currentStreamPromise = messageApi.send(convId, { text, attachments }, {
|
||||
currentStreamPromise = messageApi.send(convId, { text, attachments, projectId: currentProject.value?.id }, {
|
||||
stream: true,
|
||||
toolsEnabled: toolsEnabled.value,
|
||||
onThinkingStart() {
|
||||
|
|
@ -383,6 +386,7 @@ async function regenerateMessage(msgId) {
|
|||
|
||||
currentStreamPromise = messageApi.regenerate(convId, msgId, {
|
||||
toolsEnabled: toolsEnabled.value,
|
||||
projectId: currentProject.value?.id,
|
||||
onThinkingStart() {
|
||||
if (currentConvId.value === convId) {
|
||||
streamThinking.value = ''
|
||||
|
|
@ -493,6 +497,11 @@ function updateToolsEnabled(val) {
|
|||
localStorage.setItem('tools_enabled', String(val))
|
||||
}
|
||||
|
||||
// -- Select project --
|
||||
function selectProject(project) {
|
||||
currentProject.value = project
|
||||
}
|
||||
|
||||
// -- Init --
|
||||
onMounted(() => {
|
||||
loadConversations()
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ export const messageApi = {
|
|||
if (!stream) {
|
||||
return request(`/conversations/${convId}/messages`, {
|
||||
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`, {
|
||||
method: 'POST',
|
||||
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,
|
||||
})
|
||||
|
||||
|
|
@ -173,7 +173,7 @@ export const messageApi = {
|
|||
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 promise = (async () => {
|
||||
|
|
@ -181,7 +181,7 @@ export const messageApi = {
|
|||
const res = await fetch(`${BASE}/conversations/${convId}/regenerate/${msgId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tools_enabled: toolsEnabled }),
|
||||
body: JSON.stringify({ tools_enabled: toolsEnabled, project_id: projectId }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
|
|
@ -240,3 +240,43 @@ export const messageApi = {
|
|||
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}`)
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,10 +175,12 @@ const processItems = computed(() => {
|
|||
|
||||
if (props.toolCalls && props.toolCalls.length > 0) {
|
||||
props.toolCalls.forEach((call, i) => {
|
||||
const toolName = call.function?.name || '未知工具'
|
||||
|
||||
items.push({
|
||||
type: 'tool_call',
|
||||
label: `调用工具: ${call.function?.name || '未知工具'}`,
|
||||
toolName: call.function?.name || '未知工具',
|
||||
label: `调用工具: ${toolName}`,
|
||||
toolName: toolName,
|
||||
arguments: formatArgs(call.function?.arguments),
|
||||
id: call.id,
|
||||
index: idx,
|
||||
|
|
@ -191,7 +193,7 @@ const processItems = computed(() => {
|
|||
const resultSummary = getResultSummary(call.result)
|
||||
items.push({
|
||||
type: 'tool_result',
|
||||
label: `工具返回: ${call.function?.name || '未知工具'}`,
|
||||
label: `工具返回: ${toolName}`,
|
||||
content: formatResult(call.result),
|
||||
summary: resultSummary.text,
|
||||
isSuccess: resultSummary.success,
|
||||
|
|
@ -204,7 +206,7 @@ const processItems = computed(() => {
|
|||
} else if (props.streaming) {
|
||||
// 工具正在执行中
|
||||
items[items.length - 1].loading = true
|
||||
items[items.length - 1].label = `执行工具: ${call.function?.name || '未知工具'}`
|
||||
items[items.length - 1].label = `执行工具: ${toolName}`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">×</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">×</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">×</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>
|
||||
|
|
@ -1,5 +1,39 @@
|
|||
<template>
|
||||
<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">
|
||||
<button class="btn-new" @click="$emit('create')">
|
||||
<span class="icon">+</span>
|
||||
|
|
@ -39,14 +73,38 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
import { ref } from 'vue'
|
||||
import ProjectManager from './ProjectManager.vue'
|
||||
|
||||
const props = defineProps({
|
||||
conversations: { type: Array, required: true },
|
||||
currentId: { type: String, default: null },
|
||||
loading: { 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) {
|
||||
if (!iso) return ''
|
||||
|
|
@ -85,6 +143,44 @@ function onContextMenu(e, conv) {
|
|||
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 {
|
||||
padding: 16px;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue