feat: 增加项目管理功能
This commit is contained in:
parent
46cacc7fa2
commit
8325100c90
93
README.md
93
README.md
|
|
@ -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 等大语言模型
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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 *
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: File path (relative or absolute)
|
||||||
|
project_id: Project ID for workspace isolation
|
||||||
|
|
||||||
def _resolve_path(path: str) -> Path:
|
Returns:
|
||||||
"""Resolve path and ensure it's within allowed directory"""
|
Tuple of (resolved absolute path, project directory)
|
||||||
p = Path(path)
|
|
||||||
if not p.is_absolute():
|
|
||||||
p = BASE_DIR / p
|
|
||||||
p = p.resolve()
|
|
||||||
|
|
||||||
# Security check: ensure path is within BASE_DIR
|
Raises:
|
||||||
if BASE_DIR:
|
ValueError: If project_id is missing or path is outside project
|
||||||
try:
|
"""
|
||||||
p.relative_to(BASE_DIR.resolve())
|
if not project_id:
|
||||||
except ValueError:
|
raise ValueError("project_id is required for file operations")
|
||||||
raise ValueError(f"Path '{path}' is outside allowed directory")
|
|
||||||
|
|
||||||
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)}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue