feat: 增加项目管理部分
This commit is contained in:
parent
d5fdbb0cb3
commit
eff0700145
|
|
@ -4,6 +4,9 @@
|
|||
# # Allow directories
|
||||
!*/
|
||||
|
||||
# ignore workspaces
|
||||
/workspaces
|
||||
|
||||
# Allow docs and settings
|
||||
!/docs/*.md
|
||||
!/README.md
|
||||
|
|
|
|||
|
|
@ -3,22 +3,41 @@ import uuid
|
|||
from datetime import datetime
|
||||
from flask import Blueprint, request
|
||||
from backend import db
|
||||
from backend.models import Conversation
|
||||
from backend.models import Conversation, Project
|
||||
from backend.utils.helpers import ok, err, to_dict, get_or_create_default_user
|
||||
from backend.config import DEFAULT_MODEL
|
||||
|
||||
bp = Blueprint("conversations", __name__)
|
||||
|
||||
|
||||
def _conv_to_dict(conv, **extra):
|
||||
"""Convert conversation to dict with project info"""
|
||||
d = to_dict(conv, **extra)
|
||||
if conv.project_id:
|
||||
project = db.session.get(Project, conv.project_id)
|
||||
if project:
|
||||
d["project_name"] = project.name
|
||||
return d
|
||||
|
||||
|
||||
@bp.route("/api/conversations", methods=["GET", "POST"])
|
||||
def conversation_list():
|
||||
"""List or create conversations"""
|
||||
if request.method == "POST":
|
||||
d = request.json or {}
|
||||
user = get_or_create_default_user()
|
||||
|
||||
# Validate project_id if provided
|
||||
project_id = d.get("project_id")
|
||||
if project_id:
|
||||
project = db.session.get(Project, project_id)
|
||||
if not project:
|
||||
return err(404, "Project not found")
|
||||
|
||||
conv = Conversation(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user.id,
|
||||
project_id=project_id or None,
|
||||
title=d.get("title", ""),
|
||||
model=d.get("model", DEFAULT_MODEL),
|
||||
system_prompt=d.get("system_prompt", ""),
|
||||
|
|
@ -28,19 +47,25 @@ def conversation_list():
|
|||
)
|
||||
db.session.add(conv)
|
||||
db.session.commit()
|
||||
return ok(to_dict(conv))
|
||||
return ok(_conv_to_dict(conv))
|
||||
|
||||
# GET - list conversations
|
||||
cursor = request.args.get("cursor")
|
||||
limit = min(int(request.args.get("limit", 20)), 100)
|
||||
project_id = request.args.get("project_id")
|
||||
user = get_or_create_default_user()
|
||||
q = Conversation.query.filter_by(user_id=user.id)
|
||||
|
||||
# Filter by project if specified
|
||||
if project_id:
|
||||
q = q.filter_by(project_id=project_id)
|
||||
|
||||
if cursor:
|
||||
q = q.filter(Conversation.updated_at < (
|
||||
db.session.query(Conversation.updated_at).filter_by(id=cursor).scalar() or datetime.utcnow))
|
||||
rows = q.order_by(Conversation.updated_at.desc()).limit(limit + 1).all()
|
||||
|
||||
items = [to_dict(r, message_count=r.messages.count()) for r in rows[:limit]]
|
||||
items = [_conv_to_dict(r, message_count=r.messages.count()) for r in rows[:limit]]
|
||||
return ok({
|
||||
"items": items,
|
||||
"next_cursor": items[-1]["id"] if len(rows) > limit else None,
|
||||
|
|
@ -56,7 +81,7 @@ def conversation_detail(conv_id):
|
|||
return err(404, "conversation not found")
|
||||
|
||||
if request.method == "GET":
|
||||
return ok(to_dict(conv))
|
||||
return ok(_conv_to_dict(conv))
|
||||
|
||||
if request.method == "DELETE":
|
||||
db.session.delete(conv)
|
||||
|
|
@ -68,5 +93,15 @@ def conversation_detail(conv_id):
|
|||
for k in ("title", "model", "system_prompt", "temperature", "max_tokens", "thinking_enabled"):
|
||||
if k in d:
|
||||
setattr(conv, k, d[k])
|
||||
|
||||
# Support updating project_id
|
||||
if "project_id" in d:
|
||||
project_id = d["project_id"]
|
||||
if project_id:
|
||||
project = db.session.get(Project, project_id)
|
||||
if not project:
|
||||
return err(404, "Project not found")
|
||||
conv.project_id = project_id or None
|
||||
|
||||
db.session.commit()
|
||||
return ok(to_dict(conv))
|
||||
return ok(_conv_to_dict(conv))
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ def message_list(conv_id):
|
|||
db.session.commit()
|
||||
|
||||
tools_enabled = d.get("tools_enabled", True)
|
||||
project_id = d.get("project_id")
|
||||
project_id = d.get("project_id") or conv.project_id
|
||||
|
||||
return _chat_service.stream_response(conv, tools_enabled, project_id)
|
||||
|
||||
|
|
@ -112,7 +112,7 @@ def regenerate_message(conv_id, msg_id):
|
|||
# 获取工具启用状态
|
||||
d = request.json or {}
|
||||
tools_enabled = d.get("tools_enabled", True)
|
||||
project_id = d.get("project_id") # Get project_id from request
|
||||
project_id = d.get("project_id") or conv.project_id
|
||||
|
||||
# 流式重新生成
|
||||
return _chat_service.stream_response(conv, tools_enabled, project_id)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from backend.utils.workspace import (
|
|||
create_project_directory,
|
||||
delete_project_directory,
|
||||
get_project_path,
|
||||
copy_folder_to_project
|
||||
save_uploaded_files
|
||||
)
|
||||
|
||||
bp = Blueprint("projects", __name__)
|
||||
|
|
@ -201,49 +201,46 @@ def delete_project(project_id):
|
|||
|
||||
@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", "")
|
||||
|
||||
"""Upload a folder as a new project via file upload"""
|
||||
user_id = request.form.get("user_id", type=int)
|
||||
project_name = request.form.get("name", "").strip()
|
||||
description = request.form.get("description", "")
|
||||
|
||||
files = request.files.getlist("files")
|
||||
|
||||
if not user_id:
|
||||
return err(400, "Missing user_id")
|
||||
|
||||
if not folder_path:
|
||||
return err(400, "Missing folder_path")
|
||||
|
||||
|
||||
if not files:
|
||||
return err(400, "No files uploaded")
|
||||
|
||||
if not project_name:
|
||||
# Use folder name as project name
|
||||
project_name = os.path.basename(folder_path)
|
||||
|
||||
# Use first file's top-level folder name
|
||||
project_name = files[0].filename.split("/")[0] if files[0].filename else "untitled"
|
||||
|
||||
# Check if user exists
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return err(404, "User not found")
|
||||
|
||||
|
||||
# Check if project name already exists
|
||||
existing = Project.query.filter_by(user_id=user_id, name=project_name).first()
|
||||
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
|
||||
|
||||
# Write uploaded files to project directory
|
||||
try:
|
||||
stats = copy_folder_to_project(folder_path, absolute_path, project_name)
|
||||
stats = save_uploaded_files(files, absolute_path)
|
||||
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)}")
|
||||
|
||||
return err(500, f"Failed to save uploaded files: {str(e)}")
|
||||
|
||||
# Create project record
|
||||
project = Project(
|
||||
id=str(uuid.uuid4()),
|
||||
|
|
@ -252,10 +249,10 @@ def upload_project_folder():
|
|||
path=relative_path,
|
||||
description=description
|
||||
)
|
||||
|
||||
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
return ok({
|
||||
"id": project.id,
|
||||
"name": project.name,
|
||||
|
|
|
|||
|
|
@ -44,7 +44,11 @@ class ChatService:
|
|||
self.executor.clear_history()
|
||||
|
||||
# Build context for tool execution
|
||||
context = {"project_id": project_id} if project_id else None
|
||||
context = None
|
||||
if project_id:
|
||||
context = {"project_id": project_id}
|
||||
elif conv.project_id:
|
||||
context = {"project_id": conv.project_id}
|
||||
|
||||
def generate():
|
||||
messages = list(initial_messages)
|
||||
|
|
@ -142,8 +146,9 @@ class ChatService:
|
|||
yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'tool_call', 'id': tc['id'], 'name': tc['function']['name'], 'arguments': tc['function']['arguments']}, ensure_ascii=False)}\n\n"
|
||||
step_index += 1
|
||||
|
||||
# Execute this single tool call
|
||||
single_result = self.executor.process_tool_calls([tc], context)
|
||||
# Execute this single tool call (needs app context for db access)
|
||||
with app.app_context():
|
||||
single_result = self.executor.process_tool_calls([tc], context)
|
||||
tool_results.extend(single_result)
|
||||
|
||||
# Send tool result step immediately
|
||||
|
|
|
|||
|
|
@ -48,17 +48,13 @@ def _resolve_path(path: str, project_id: str = None) -> Tuple[Path, Path]:
|
|||
"type": "string",
|
||||
"description": "File path to read (relative to project root or absolute within project)"
|
||||
},
|
||||
"project_id": {
|
||||
"type": "string",
|
||||
"description": "Project ID for workspace isolation (required)"
|
||||
},
|
||||
"encoding": {
|
||||
"type": "string",
|
||||
"description": "File encoding, default utf-8",
|
||||
"default": "utf-8"
|
||||
}
|
||||
},
|
||||
"required": ["path", "project_id"]
|
||||
"required": ["path"]
|
||||
},
|
||||
category="file"
|
||||
)
|
||||
|
|
@ -112,10 +108,6 @@ def file_read(arguments: dict) -> dict:
|
|||
"type": "string",
|
||||
"description": "Content to write to the file"
|
||||
},
|
||||
"project_id": {
|
||||
"type": "string",
|
||||
"description": "Project ID for workspace isolation (required)"
|
||||
},
|
||||
"encoding": {
|
||||
"type": "string",
|
||||
"description": "File encoding, default utf-8",
|
||||
|
|
@ -128,7 +120,7 @@ def file_read(arguments: dict) -> dict:
|
|||
"default": "write"
|
||||
}
|
||||
},
|
||||
"required": ["path", "content", "project_id"]
|
||||
"required": ["path", "content"]
|
||||
},
|
||||
category="file"
|
||||
)
|
||||
|
|
@ -183,13 +175,9 @@ def file_write(arguments: dict) -> dict:
|
|||
"path": {
|
||||
"type": "string",
|
||||
"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", "project_id"]
|
||||
"required": ["path"]
|
||||
},
|
||||
category="file"
|
||||
)
|
||||
|
|
@ -237,13 +225,9 @@ def file_delete(arguments: dict) -> dict:
|
|||
"type": "string",
|
||||
"description": "Glob pattern to filter files, e.g. '*.py'",
|
||||
"default": "*"
|
||||
},
|
||||
"project_id": {
|
||||
"type": "string",
|
||||
"description": "Project ID for workspace isolation (required)"
|
||||
}
|
||||
},
|
||||
"required": ["project_id"]
|
||||
"required": []
|
||||
},
|
||||
category="file"
|
||||
)
|
||||
|
|
@ -308,13 +292,9 @@ def file_list(arguments: dict) -> dict:
|
|||
"path": {
|
||||
"type": "string",
|
||||
"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", "project_id"]
|
||||
"required": ["path"]
|
||||
},
|
||||
category="file"
|
||||
)
|
||||
|
|
@ -369,13 +349,9 @@ def file_exists(arguments: dict) -> dict:
|
|||
"path": {
|
||||
"type": "string",
|
||||
"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", "project_id"]
|
||||
"required": ["path"]
|
||||
},
|
||||
category="file"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -87,7 +87,6 @@ class ToolExecutor:
|
|||
|
||||
# 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"]
|
||||
|
||||
|
|
@ -96,7 +95,7 @@ class ToolExecutor:
|
|||
if call_key in seen_calls:
|
||||
# Skip duplicate, but still return a result
|
||||
results.append(self._create_tool_result(
|
||||
call_id, name,
|
||||
call_id, name,
|
||||
{"success": True, "data": None, "cached": True, "duplicate": True}
|
||||
))
|
||||
continue
|
||||
|
|
@ -148,15 +147,16 @@ class ToolExecutor:
|
|||
call_id: str,
|
||||
name: str,
|
||||
result: dict,
|
||||
execution_time: float = 0
|
||||
execution_time: float = 0,
|
||||
) -> dict:
|
||||
"""Create tool result message"""
|
||||
result["execution_time"] = execution_time
|
||||
content = json.dumps(result, ensure_ascii=False, default=str)
|
||||
return {
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"name": name,
|
||||
"content": json.dumps(result, ensure_ascii=False, default=str)
|
||||
"content": content
|
||||
}
|
||||
|
||||
def _create_error_result(
|
||||
|
|
|
|||
|
|
@ -137,6 +137,57 @@ def delete_project_directory(project_path: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
|
||||
|
||||
def save_uploaded_files(files, project_dir: Path) -> dict:
|
||||
"""
|
||||
Save uploaded files to project directory (for folder upload)
|
||||
|
||||
Args:
|
||||
files: List of FileStorage objects from Flask request.files
|
||||
project_dir: Target project directory
|
||||
|
||||
Returns:
|
||||
Dict with upload statistics
|
||||
"""
|
||||
file_count = 0
|
||||
dir_count = 0
|
||||
total_size = 0
|
||||
|
||||
for f in files:
|
||||
if not f.filename:
|
||||
continue
|
||||
|
||||
# filename contains relative path like "AlgoLab/src/main.py"
|
||||
# Skip the first segment (folder name) since project_dir already represents it
|
||||
parts = f.filename.split("/")
|
||||
if len(parts) > 1:
|
||||
relative = "/".join(parts[1:]) # "src/main.py"
|
||||
else:
|
||||
relative = f.filename # root-level file
|
||||
|
||||
target = project_dir / relative
|
||||
|
||||
# Create parent directories if needed
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Save file
|
||||
f.save(str(target))
|
||||
file_count += 1
|
||||
|
||||
# Count new directories
|
||||
if target.parent != project_dir:
|
||||
dir_count += 1
|
||||
|
||||
total_size += target.stat().st_size
|
||||
|
||||
return {
|
||||
"files": file_count,
|
||||
"directories": dir_count,
|
||||
"size": total_size
|
||||
}
|
||||
|
||||
|
||||
def copy_folder_to_project(source_path: str, project_dir: Path, project_name: str) -> dict:
|
||||
"""
|
||||
Copy a folder to project directory (for folder upload)
|
||||
|
|
|
|||
181
docs/Design.md
181
docs/Design.md
|
|
@ -64,7 +64,7 @@ backend/
|
|||
│ ├── crawler.py # 网页搜索、抓取
|
||||
│ ├── data.py # 计算器、文本、JSON
|
||||
│ ├── weather.py # 天气查询
|
||||
│ ├── file_ops.py # 文件操作(需要 project_id)
|
||||
│ ├── file_ops.py # 文件操作(project_id 自动注入)
|
||||
│ └── code.py # 代码执行
|
||||
│
|
||||
├── utils/ # 辅助函数
|
||||
|
|
@ -356,10 +356,10 @@ def process_tool_calls(self, tool_calls, context=None):
|
|||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `POST` | `/api/conversations` | 创建会话 |
|
||||
| `GET` | `/api/conversations` | 获取会话列表(游标分页) |
|
||||
| `POST` | `/api/conversations` | 创建会话(可选 `project_id` 绑定项目) |
|
||||
| `GET` | `/api/conversations` | 获取会话列表(可选 `project_id` 筛选,游标分页) |
|
||||
| `GET` | `/api/conversations/:id` | 获取会话详情 |
|
||||
| `PATCH` | `/api/conversations/:id` | 更新会话 |
|
||||
| `PATCH` | `/api/conversations/:id` | 更新会话(支持修改 `project_id`) |
|
||||
| `DELETE` | `/api/conversations/:id` | 删除会话 |
|
||||
|
||||
### 消息管理
|
||||
|
|
@ -541,6 +541,179 @@ GET /api/conversations?limit=20&cursor=conv_abc123
|
|||
|
||||
---
|
||||
|
||||
## 项目-对话关联机制
|
||||
|
||||
### 设计目标
|
||||
|
||||
将项目(Project)和对话(Conversation)建立**持久绑定关系**,实现:
|
||||
1. 创建对话时自动绑定当前选中的项目
|
||||
2. 对话列表支持按项目筛选/分组
|
||||
3. 工具执行自动使用对话所属项目的上下文,无需 AI 每次询问 `project_id`
|
||||
4. 支持对话在项目间迁移
|
||||
|
||||
### 数据模型(已存在)
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
Project ||--o{ Conversation : "包含"
|
||||
Conversation {
|
||||
string id PK
|
||||
int user_id FK
|
||||
string project_id FK " nullable, 可选绑定项目"
|
||||
string title
|
||||
}
|
||||
Project {
|
||||
string id PK
|
||||
int user_id FK
|
||||
string name
|
||||
}
|
||||
```
|
||||
|
||||
`Conversation.project_id` 是 nullable 的外键:
|
||||
- `null` = 未绑定项目(通用对话,文件工具不可用)
|
||||
- 非 null = 绑定到特定项目(工具自动使用该项目的工作空间)
|
||||
|
||||
### API 设计
|
||||
|
||||
#### 创建对话 `POST /api/conversations`
|
||||
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"title": "新对话",
|
||||
"project_id": "uuid-of-project" // 可选,传入则绑定项目
|
||||
}
|
||||
|
||||
// Response
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"id": "conv-uuid",
|
||||
"project_id": "uuid-of-project", // 回显绑定
|
||||
"project_name": "AlgoLab", // 附带项目名称,方便前端显示
|
||||
"title": "新对话",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 对话列表 `GET /api/conversations`
|
||||
|
||||
支持按项目筛选:
|
||||
|
||||
```
|
||||
GET /api/conversations?project_id=xxx # 仅返回该项目的对话
|
||||
GET /api/conversations # 返回所有对话(当前行为)
|
||||
```
|
||||
|
||||
响应中附带项目信息:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": "conv-1",
|
||||
"project_id": "proj-1",
|
||||
"project_name": "AlgoLab",
|
||||
"title": "分析数据",
|
||||
...
|
||||
},
|
||||
{
|
||||
"id": "conv-2",
|
||||
"project_id": null,
|
||||
"project_name": null,
|
||||
"title": "闲聊",
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 更新对话 `PATCH /api/conversations/:id`
|
||||
|
||||
支持修改 `project_id`(迁移对话到其他项目):
|
||||
|
||||
```json
|
||||
{
|
||||
"project_id": "new-project-uuid" // 设为 null 可解绑
|
||||
}
|
||||
```
|
||||
|
||||
#### 发送消息 `POST /api/conversations/:id/messages`
|
||||
|
||||
`project_id` 优先级:
|
||||
1. 请求体中的 `project_id`(前端显式传递)
|
||||
2. `conversation.project_id`(对话绑定的项目,自动回退)
|
||||
3. `null`(无项目上下文,文件工具报错提示)
|
||||
|
||||
```python
|
||||
# 伪代码
|
||||
effective_project_id = request_project_id or conv.project_id
|
||||
context = {"project_id": effective_project_id} if effective_project_id else None
|
||||
```
|
||||
|
||||
这样 AI **不需要**知道 `project_id`,后端会自动注入。建议将 `project_id` 从文件工具的 `required` 参数列表中移除,改为后端自动注入。
|
||||
|
||||
### 工具上下文自动注入(已实施)
|
||||
|
||||
`project_id` 已从所有文件工具的 `required` 参数列表中移除,改为后端自动注入。
|
||||
|
||||
**实施细节:**
|
||||
|
||||
1. **工具 Schema**:`file_*` 工具不再声明 `project_id` 参数,AI 不会看到也不会询问
|
||||
2. **自动注入**:`ToolExecutor` 在执行文件工具时自动从 context 注入 `project_id`
|
||||
3. **Context 构建**:`ChatService` 根据请求或对话绑定自动构建 `context = {"project_id": ...}`
|
||||
|
||||
```python
|
||||
# 工具定义 - 不再声明 project_id
|
||||
parameters = {
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "文件路径"},
|
||||
"pattern": {"type": "string", "description": "过滤模式", "default": "*"}
|
||||
},
|
||||
"required": [] # 所有参数有默认值,project_id 完全透明
|
||||
}
|
||||
|
||||
# ToolExecutor 自动注入(已有逻辑)
|
||||
if name.startswith("file_") and context and "project_id" in context:
|
||||
args["project_id"] = context["project_id"]
|
||||
```
|
||||
|
||||
### UI 交互设计
|
||||
|
||||
#### 侧边栏布局
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ [📁 AlgoLab ▼] │ ← 项目选择器
|
||||
├─────────────────────┤
|
||||
│ [+ 新对话] │
|
||||
├─────────────────────┤
|
||||
│ 📎 分析数据 3条 │ ← 属于当前项目的对话
|
||||
│ 📎 优化算法 5条 │
|
||||
│ 📎 调试测试 2条 │
|
||||
├─────────────────────┤
|
||||
│ 选择其他项目查看对话 │ ← 或切换项目
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
**交互规则:**
|
||||
1. 顶部项目选择器决定**当前工作空间**
|
||||
2. 选中项目后,对话列表**仅显示该项目的对话**
|
||||
3. 创建新对话时**自动绑定**当前项目
|
||||
4. 未选中项目时显示全部对话
|
||||
5. 切换项目不切换当前对话(保持对话焦点)
|
||||
|
||||
#### 对话项显示
|
||||
|
||||
- 对话标题前显示小圆点颜色,区分所属项目(可选)
|
||||
- 悬浮/详情中显示所属项目名称
|
||||
|
||||
---
|
||||
|
||||
## 配置文件
|
||||
|
||||
配置文件:`config.yml`
|
||||
|
|
|
|||
|
|
@ -122,9 +122,13 @@ def process_tool_calls(
|
|||
# backend/services/chat.py
|
||||
|
||||
def stream_response(self, conv, tools_enabled=True, project_id=None):
|
||||
# 构建上下文
|
||||
context = {"project_id": project_id} if project_id else None
|
||||
|
||||
# 构建上下文(优先使用请求传递的 project_id,否则回退到对话绑定的)
|
||||
context = None
|
||||
if project_id:
|
||||
context = {"project_id": project_id}
|
||||
elif conv.project_id:
|
||||
context = {"project_id": conv.project_id}
|
||||
|
||||
# 处理工具调用时自动注入
|
||||
tool_results = self.executor.process_tool_calls(tool_calls, context)
|
||||
```
|
||||
|
|
@ -133,9 +137,9 @@ def stream_response(self, conv, tools_enabled=True, project_id=None):
|
|||
|
||||
## 四、文件工具安全设计
|
||||
|
||||
### project_id 必需
|
||||
### project_id 自动注入(非 AI 必填参数)
|
||||
|
||||
所有文件工具都需要 `project_id` 参数:
|
||||
所有文件工具的 `project_id` 不再作为 AI 可见的必填参数,由 `ToolExecutor` 自动注入:
|
||||
|
||||
```python
|
||||
@tool(
|
||||
|
|
@ -145,16 +149,16 @@ def stream_response(self, conv, tools_enabled=True, project_id=None):
|
|||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "File path"},
|
||||
"project_id": {"type": "string", "description": "Project ID (required)"},
|
||||
"encoding": {"type": "string", "default": "utf-8"}
|
||||
},
|
||||
"required": ["path", "project_id"]
|
||||
"required": ["path"] # project_id 已移除,后端自动注入
|
||||
},
|
||||
category="file"
|
||||
)
|
||||
def file_read(arguments: dict) -> dict:
|
||||
# arguments["project_id"] 由 ToolExecutor 自动注入
|
||||
path, project_dir = _resolve_path(
|
||||
arguments["path"],
|
||||
arguments["path"],
|
||||
arguments.get("project_id")
|
||||
)
|
||||
# ...
|
||||
|
|
@ -229,16 +233,16 @@ file_read({"path": "src/main.py", "project_id": "xxx"})
|
|||
|
||||
### 5.4 文件操作工具 (file)
|
||||
|
||||
**所有文件工具需要 `project_id` 参数**
|
||||
**`project_id` 由后端自动注入,AI 无需感知此参数。**
|
||||
|
||||
| 工具名称 | 描述 | 参数 |
|
||||
| 工具名称 | 描述 | 参数(AI 可见) |
|
||||
|---------|------|------|
|
||||
| `file_read` | 读取文件内容 | `path`, `project_id`, `encoding` |
|
||||
| `file_write` | 写入文件 | `path`, `content`, `project_id`, `mode` |
|
||||
| `file_delete` | 删除文件 | `path`, `project_id` |
|
||||
| `file_list` | 列出目录内容 | `path`, `pattern`, `project_id` |
|
||||
| `file_exists` | 检查文件是否存在 | `path`, `project_id` |
|
||||
| `file_mkdir` | 创建目录 | `path`, `project_id` |
|
||||
| `file_read` | 读取文件内容 | `path`, `encoding` |
|
||||
| `file_write` | 写入文件 | `path`, `content`, `encoding`, `mode` |
|
||||
| `file_delete` | 删除文件 | `path` |
|
||||
| `file_list` | 列出目录内容 | `path`, `pattern` |
|
||||
| `file_exists` | 检查文件是否存在 | `path` |
|
||||
| `file_mkdir` | 创建目录 | `path` |
|
||||
|
||||
### 5.5 天气工具 (weather)
|
||||
|
||||
|
|
|
|||
|
|
@ -109,7 +109,8 @@ async function loadConversations(reset = true) {
|
|||
if (loadingConvs.value) return
|
||||
loadingConvs.value = true
|
||||
try {
|
||||
const res = await conversationApi.list(reset ? null : nextConvCursor.value)
|
||||
const projectId = currentProject.value?.id || null
|
||||
const res = await conversationApi.list(reset ? null : nextConvCursor.value, 20, projectId)
|
||||
if (reset) {
|
||||
conversations.value = res.data.items
|
||||
} else {
|
||||
|
|
@ -131,7 +132,10 @@ function loadMoreConversations() {
|
|||
// -- Create conversation --
|
||||
async function createConversation() {
|
||||
try {
|
||||
const res = await conversationApi.create({ title: '新对话' })
|
||||
const res = await conversationApi.create({
|
||||
title: '新对话',
|
||||
project_id: currentProject.value?.id || null,
|
||||
})
|
||||
conversations.value.unshift(res.data)
|
||||
await selectConversation(res.data.id)
|
||||
} catch (e) {
|
||||
|
|
@ -426,6 +430,9 @@ function updateToolsEnabled(val) {
|
|||
// -- Select project --
|
||||
function selectProject(project) {
|
||||
currentProject.value = project
|
||||
// Reload conversations filtered by the selected project
|
||||
nextConvCursor.value = null
|
||||
loadConversations(true)
|
||||
}
|
||||
|
||||
// -- Init --
|
||||
|
|
|
|||
|
|
@ -130,10 +130,11 @@ export const statsApi = {
|
|||
}
|
||||
|
||||
export const conversationApi = {
|
||||
list(cursor, limit = 20) {
|
||||
list(cursor, limit = 20, projectId = null) {
|
||||
const params = new URLSearchParams()
|
||||
if (cursor) params.set('cursor', cursor)
|
||||
if (limit) params.set('limit', limit)
|
||||
if (projectId) params.set('project_id', projectId)
|
||||
return request(`/conversations?${params}`)
|
||||
},
|
||||
|
||||
|
|
@ -207,9 +208,27 @@ export const projectApi = {
|
|||
},
|
||||
|
||||
uploadFolder(data) {
|
||||
return request('/projects/upload', {
|
||||
const formData = new FormData()
|
||||
formData.append('user_id', String(data.user_id))
|
||||
formData.append('name', data.name || '')
|
||||
formData.append('description', data.description || '')
|
||||
for (const file of data.files) {
|
||||
formData.append('files', file, file.webkitRelativePath)
|
||||
}
|
||||
return fetch(`${BASE}/projects/upload`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
body: formData,
|
||||
}).then(async res => {
|
||||
let json
|
||||
try {
|
||||
json = await res.json()
|
||||
} catch (_) {
|
||||
throw new Error(`服务器错误 (${res.status}),请确认后端已重启`)
|
||||
}
|
||||
if (json.code !== 0) {
|
||||
throw new Error(json.message || 'Request failed')
|
||||
}
|
||||
return json
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,10 +169,10 @@ const processItems = computed(() => {
|
|||
}
|
||||
}
|
||||
|
||||
// 流式中最后一个 tool_call 标记为 loading
|
||||
// 流式中最后一个 tool_call(尚无结果时)标记为 loading
|
||||
if (props.streaming && items.length > 0) {
|
||||
const last = items[items.length - 1]
|
||||
if (last.type === 'tool_call') {
|
||||
if (last.type === 'tool_call' && !last.result) {
|
||||
last.loading = true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,24 +79,31 @@
|
|||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>上传文件夹</h3>
|
||||
<button class="btn-close" @click="showUploadModal = false">×</button>
|
||||
<button class="btn-close" @click="closeUploadModal">×</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>
|
||||
<div class="input-with-action">
|
||||
<input v-model="uploadData.folderPath" type="text" placeholder="输入文件夹绝对路径或点击右侧按钮选择" />
|
||||
<button class="btn-browse" @click="selectFolder" type="button">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<label>选择文件夹</label>
|
||||
<div class="upload-drop-zone" :class="{ 'has-files': selectedFiles.length > 0 }" @click="triggerFolderInput">
|
||||
<template v-if="selectedFiles.length === 0">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="color: var(--text-tertiary)">
|
||||
<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"/>
|
||||
</svg>
|
||||
选择文件夹
|
||||
</button>
|
||||
<span>点击选择文件夹</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--success-color)" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
<span>{{ folderName }} <small>({{ selectedFiles.length }} 个文件)</small></span>
|
||||
</template>
|
||||
</div>
|
||||
<input ref="folderInput" type="file" webkitdirectory directory multiple style="display:none" @change="onFolderSelected" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>项目名称</label>
|
||||
<input v-model="uploadData.name" type="text" placeholder="留空则使用文件夹名称" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>描述(可选)</label>
|
||||
|
|
@ -104,8 +111,8 @@
|
|||
</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">
|
||||
<button class="btn-secondary" @click="closeUploadModal">取消</button>
|
||||
<button class="btn-primary" @click="uploadFolder" :disabled="selectedFiles.length === 0 || uploading">
|
||||
{{ uploading ? '上传中...' : '上传' }}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -161,30 +168,40 @@ const newProject = ref({
|
|||
|
||||
const uploadData = ref({
|
||||
name: '',
|
||||
folderPath: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
async function selectFolder() {
|
||||
try {
|
||||
if ('showDirectoryPicker' in window) {
|
||||
const dirHandle = await window.showDirectoryPicker()
|
||||
// 将文件夹名称自动填入项目名(如未填写)
|
||||
if (!uploadData.value.name.trim()) {
|
||||
uploadData.value.name = dirHandle.name
|
||||
}
|
||||
// 提示用户手动确认服务器路径
|
||||
if (!uploadData.value.folderPath.trim()) {
|
||||
uploadData.value.folderPath = dirHandle.name
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
console.error('Failed to select folder:', e)
|
||||
}
|
||||
const selectedFiles = ref([])
|
||||
const folderName = ref('')
|
||||
const folderInput = ref(null)
|
||||
|
||||
function triggerFolderInput() {
|
||||
folderInput.value?.click()
|
||||
}
|
||||
|
||||
function onFolderSelected(e) {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (files.length === 0) return
|
||||
|
||||
// 提取文件夹名称(所有文件的公共父目录名)
|
||||
const relativePaths = files.map(f => f.webkitRelativePath)
|
||||
folderName.value = relativePaths[0].split('/')[0]
|
||||
selectedFiles.value = files
|
||||
|
||||
// 自动填入项目名(如未填写)
|
||||
if (!uploadData.value.name.trim()) {
|
||||
uploadData.value.name = folderName.value
|
||||
}
|
||||
}
|
||||
|
||||
function closeUploadModal() {
|
||||
showUploadModal.value = false
|
||||
uploadData.value = { name: '', description: '' }
|
||||
selectedFiles.value = []
|
||||
folderName.value = ''
|
||||
if (folderInput.value) folderInput.value.value = ''
|
||||
}
|
||||
|
||||
// 固定用户ID(实际应用中应从登录状态获取)
|
||||
const userId = 1
|
||||
|
||||
|
|
@ -223,19 +240,18 @@ async function createProject() {
|
|||
}
|
||||
|
||||
async function uploadFolder() {
|
||||
if (!uploadData.value.folderPath.trim()) return
|
||||
|
||||
if (selectedFiles.value.length === 0) 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(),
|
||||
name: uploadData.value.name.trim() || folderName.value,
|
||||
description: uploadData.value.description.trim(),
|
||||
files: selectedFiles.value,
|
||||
})
|
||||
projects.value.unshift(res.data)
|
||||
showUploadModal.value = false
|
||||
uploadData.value = { name: '', folderPath: '', description: '' }
|
||||
closeUploadModal()
|
||||
emit('created', res.data)
|
||||
} catch (e) {
|
||||
console.error('Failed to upload folder:', e)
|
||||
|
|
@ -478,6 +494,37 @@ defineExpose({
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.upload-drop-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 24px 16px;
|
||||
border: 2px dashed var(--border-input);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.upload-drop-zone:hover {
|
||||
border-color: var(--accent-primary);
|
||||
background: var(--accent-primary-light);
|
||||
}
|
||||
|
||||
.upload-drop-zone.has-files {
|
||||
border-color: var(--success-color);
|
||||
border-style: solid;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.upload-drop-zone small {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-browse {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -7,19 +7,32 @@
|
|||
<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>
|
||||
<span>{{ currentProject?.name || '全部对话' }}</span>
|
||||
</div>
|
||||
<div class="project-selector-actions">
|
||||
<button
|
||||
v-if="currentProject"
|
||||
class="btn-clear-project"
|
||||
@click.stop="$emit('selectProject', null)"
|
||||
title="显示全部对话"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
|
|
@ -52,7 +65,8 @@
|
|||
<div class="conv-info">
|
||||
<div class="conv-title">{{ conv.title || '新对话' }}</div>
|
||||
<div class="conv-meta">
|
||||
{{ conv.message_count || 0 }} 条消息 · {{ formatTime(conv.updated_at) }}
|
||||
<span>{{ conv.message_count || 0 }} 条消息 · {{ formatTime(conv.updated_at) }}</span>
|
||||
<span v-if="!currentProject && conv.project_name" class="conv-project-badge">{{ conv.project_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-delete" @click.stop="$emit('delete', conv.id)" title="删除">
|
||||
|
|
@ -65,7 +79,7 @@
|
|||
|
||||
<div v-if="loading" class="loading-more">加载中...</div>
|
||||
<div v-if="!loading && conversations.length === 0" class="empty-hint">
|
||||
暂无对话
|
||||
{{ currentProject ? '该项目暂无对话' : '暂无对话' }}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
|
@ -167,6 +181,39 @@ function onScroll(e) {
|
|||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-current span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.project-selector-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn-clear-project {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-clear-project:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.project-panel {
|
||||
|
|
@ -252,11 +299,22 @@ function onScroll(e) {
|
|||
}
|
||||
|
||||
.conv-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.conv-project-badge {
|
||||
font-size: 11px;
|
||||
color: var(--accent-primary);
|
||||
opacity: 0.8;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
opacity: 0;
|
||||
background: none;
|
||||
|
|
|
|||
Loading…
Reference in New Issue