feat: 增加项目管理部分

This commit is contained in:
ViperEkura 2026-03-26 15:25:47 +08:00
parent d5fdbb0cb3
commit eff0700145
15 changed files with 525 additions and 150 deletions

3
.gitignore vendored
View File

@ -4,6 +4,9 @@
# # Allow directories # # Allow directories
!*/ !*/
# ignore workspaces
/workspaces
# Allow docs and settings # Allow docs and settings
!/docs/*.md !/docs/*.md
!/README.md !/README.md

View File

@ -3,22 +3,41 @@ import uuid
from datetime import datetime from datetime import datetime
from flask import Blueprint, request from flask import Blueprint, request
from backend import db 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.utils.helpers import ok, err, to_dict, get_or_create_default_user
from backend.config import DEFAULT_MODEL from backend.config import DEFAULT_MODEL
bp = Blueprint("conversations", __name__) 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"]) @bp.route("/api/conversations", methods=["GET", "POST"])
def conversation_list(): def conversation_list():
"""List or create conversations""" """List or create conversations"""
if request.method == "POST": if request.method == "POST":
d = request.json or {} d = request.json or {}
user = get_or_create_default_user() 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( conv = Conversation(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
user_id=user.id, user_id=user.id,
project_id=project_id or None,
title=d.get("title", ""), title=d.get("title", ""),
model=d.get("model", DEFAULT_MODEL), model=d.get("model", DEFAULT_MODEL),
system_prompt=d.get("system_prompt", ""), system_prompt=d.get("system_prompt", ""),
@ -28,19 +47,25 @@ def conversation_list():
) )
db.session.add(conv) db.session.add(conv)
db.session.commit() db.session.commit()
return ok(to_dict(conv)) return ok(_conv_to_dict(conv))
# GET - list conversations # GET - list conversations
cursor = request.args.get("cursor") cursor = request.args.get("cursor")
limit = min(int(request.args.get("limit", 20)), 100) limit = min(int(request.args.get("limit", 20)), 100)
project_id = request.args.get("project_id")
user = get_or_create_default_user() user = get_or_create_default_user()
q = Conversation.query.filter_by(user_id=user.id) 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: if cursor:
q = q.filter(Conversation.updated_at < ( q = q.filter(Conversation.updated_at < (
db.session.query(Conversation.updated_at).filter_by(id=cursor).scalar() or datetime.utcnow)) 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() 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({ return ok({
"items": items, "items": items,
"next_cursor": items[-1]["id"] if len(rows) > limit else None, "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") return err(404, "conversation not found")
if request.method == "GET": if request.method == "GET":
return ok(to_dict(conv)) return ok(_conv_to_dict(conv))
if request.method == "DELETE": if request.method == "DELETE":
db.session.delete(conv) 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"): for k in ("title", "model", "system_prompt", "temperature", "max_tokens", "thinking_enabled"):
if k in d: if k in d:
setattr(conv, k, d[k]) 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() db.session.commit()
return ok(to_dict(conv)) return ok(_conv_to_dict(conv))

View File

@ -68,7 +68,7 @@ def message_list(conv_id):
db.session.commit() db.session.commit()
tools_enabled = d.get("tools_enabled", True) 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) 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 {} 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 project_id = d.get("project_id") or conv.project_id
# 流式重新生成 # 流式重新生成
return _chat_service.stream_response(conv, tools_enabled, project_id) return _chat_service.stream_response(conv, tools_enabled, project_id)

View File

@ -12,7 +12,7 @@ from backend.utils.workspace import (
create_project_directory, create_project_directory,
delete_project_directory, delete_project_directory,
get_project_path, get_project_path,
copy_folder_to_project save_uploaded_files
) )
bp = Blueprint("projects", __name__) bp = Blueprint("projects", __name__)
@ -201,24 +201,22 @@ def delete_project(project_id):
@bp.route("/api/projects/upload", methods=["POST"]) @bp.route("/api/projects/upload", methods=["POST"])
def upload_project_folder(): def upload_project_folder():
"""Upload a folder as a new project (via temporary upload)""" """Upload a folder as a new project via file upload"""
if "folder_path" not in request.json: user_id = request.form.get("user_id", type=int)
return err(400, "Missing folder_path in request body") project_name = request.form.get("name", "").strip()
description = request.form.get("description", "")
user_id = request.json.get("user_id") files = request.files.getlist("files")
folder_path = request.json.get("folder_path")
project_name = request.json.get("name")
description = request.json.get("description", "")
if not user_id: if not user_id:
return err(400, "Missing user_id") return err(400, "Missing user_id")
if not folder_path: if not files:
return err(400, "Missing folder_path") return err(400, "No files uploaded")
if not project_name: if not project_name:
# Use folder name as project name # Use first file's top-level folder name
project_name = os.path.basename(folder_path) project_name = files[0].filename.split("/")[0] if files[0].filename else "untitled"
# Check if user exists # Check if user exists
user = User.query.get(user_id) user = User.query.get(user_id)
@ -236,13 +234,12 @@ def upload_project_folder():
except Exception as e: except Exception as e:
return err(500, f"Failed to create project directory: {str(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: try:
stats = copy_folder_to_project(folder_path, absolute_path, project_name) stats = save_uploaded_files(files, absolute_path)
except Exception as e: except Exception as e:
# Clean up created directory on error
shutil.rmtree(absolute_path, ignore_errors=True) 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 # Create project record
project = Project( project = Project(

View File

@ -44,7 +44,11 @@ class ChatService:
self.executor.clear_history() self.executor.clear_history()
# Build context for tool execution # 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(): def generate():
messages = list(initial_messages) messages = list(initial_messages)
@ -142,7 +146,8 @@ 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" 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 step_index += 1
# Execute this single tool call # Execute this single tool call (needs app context for db access)
with app.app_context():
single_result = self.executor.process_tool_calls([tc], context) single_result = self.executor.process_tool_calls([tc], context)
tool_results.extend(single_result) tool_results.extend(single_result)

View File

@ -48,17 +48,13 @@ def _resolve_path(path: str, project_id: str = None) -> Tuple[Path, Path]:
"type": "string", "type": "string",
"description": "File path to read (relative to project root or absolute within project)" "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",
"description": "File encoding, default utf-8", "description": "File encoding, default utf-8",
"default": "utf-8" "default": "utf-8"
} }
}, },
"required": ["path", "project_id"] "required": ["path"]
}, },
category="file" category="file"
) )
@ -112,10 +108,6 @@ def file_read(arguments: dict) -> dict:
"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",
@ -128,7 +120,7 @@ def file_read(arguments: dict) -> dict:
"default": "write" "default": "write"
} }
}, },
"required": ["path", "content", "project_id"] "required": ["path", "content"]
}, },
category="file" category="file"
) )
@ -183,13 +175,9 @@ def file_write(arguments: dict) -> dict:
"path": { "path": {
"type": "string", "type": "string",
"description": "File path to delete (relative to project root or absolute within project)" "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" category="file"
) )
@ -237,13 +225,9 @@ def file_delete(arguments: dict) -> dict:
"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": ["project_id"] "required": []
}, },
category="file" category="file"
) )
@ -308,13 +292,9 @@ def file_list(arguments: dict) -> dict:
"path": { "path": {
"type": "string", "type": "string",
"description": "Path to check (relative to project root or absolute within project)" "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" category="file"
) )
@ -369,13 +349,9 @@ def file_exists(arguments: dict) -> dict:
"path": { "path": {
"type": "string", "type": "string",
"description": "Directory path to create (relative to project root or absolute within project)" "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" category="file"
) )

View File

@ -87,7 +87,6 @@ class ToolExecutor:
# Inject context into tool arguments # Inject context into tool arguments
if context: if context:
# For file operation tools, inject project_id automatically
if name.startswith("file_") and "project_id" in context: if name.startswith("file_") and "project_id" in context:
args["project_id"] = context["project_id"] args["project_id"] = context["project_id"]
@ -148,15 +147,16 @@ class ToolExecutor:
call_id: str, call_id: str,
name: str, name: str,
result: dict, result: dict,
execution_time: float = 0 execution_time: float = 0,
) -> dict: ) -> dict:
"""Create tool result message""" """Create tool result message"""
result["execution_time"] = execution_time result["execution_time"] = execution_time
content = json.dumps(result, ensure_ascii=False, default=str)
return { return {
"role": "tool", "role": "tool",
"tool_call_id": call_id, "tool_call_id": call_id,
"name": name, "name": name,
"content": json.dumps(result, ensure_ascii=False, default=str) "content": content
} }
def _create_error_result( def _create_error_result(

View File

@ -137,6 +137,57 @@ def delete_project_directory(project_path: str) -> bool:
return False 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: def copy_folder_to_project(source_path: str, project_dir: Path, project_name: str) -> dict:
""" """
Copy a folder to project directory (for folder upload) Copy a folder to project directory (for folder upload)

View File

@ -64,7 +64,7 @@ backend/
│ ├── crawler.py # 网页搜索、抓取 │ ├── crawler.py # 网页搜索、抓取
│ ├── data.py # 计算器、文本、JSON │ ├── data.py # 计算器、文本、JSON
│ ├── weather.py # 天气查询 │ ├── weather.py # 天气查询
│ ├── file_ops.py # 文件操作(需要 project_id │ ├── file_ops.py # 文件操作project_id 自动注入
│ └── code.py # 代码执行 │ └── code.py # 代码执行
├── utils/ # 辅助函数 ├── utils/ # 辅助函数
@ -356,10 +356,10 @@ def process_tool_calls(self, tool_calls, context=None):
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|------|------|------| |------|------|------|
| `POST` | `/api/conversations` | 创建会话 | | `POST` | `/api/conversations` | 创建会话(可选 `project_id` 绑定项目) |
| `GET` | `/api/conversations` | 获取会话列表(游标分页) | | `GET` | `/api/conversations` | 获取会话列表(可选 `project_id` 筛选,游标分页) |
| `GET` | `/api/conversations/:id` | 获取会话详情 | | `GET` | `/api/conversations/:id` | 获取会话详情 |
| `PATCH` | `/api/conversations/:id` | 更新会话 | | `PATCH` | `/api/conversations/:id` | 更新会话(支持修改 `project_id` |
| `DELETE` | `/api/conversations/: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` 配置文件:`config.yml`

View File

@ -122,8 +122,12 @@ def process_tool_calls(
# backend/services/chat.py # backend/services/chat.py
def stream_response(self, conv, tools_enabled=True, project_id=None): def stream_response(self, conv, tools_enabled=True, project_id=None):
# 构建上下文 # 构建上下文(优先使用请求传递的 project_id否则回退到对话绑定的
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}
# 处理工具调用时自动注入 # 处理工具调用时自动注入
tool_results = self.executor.process_tool_calls(tool_calls, context) 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 ```python
@tool( @tool(
@ -145,14 +149,14 @@ def stream_response(self, conv, tools_enabled=True, project_id=None):
"type": "object", "type": "object",
"properties": { "properties": {
"path": {"type": "string", "description": "File path"}, "path": {"type": "string", "description": "File path"},
"project_id": {"type": "string", "description": "Project ID (required)"},
"encoding": {"type": "string", "default": "utf-8"} "encoding": {"type": "string", "default": "utf-8"}
}, },
"required": ["path", "project_id"] "required": ["path"] # project_id 已移除,后端自动注入
}, },
category="file" category="file"
) )
def file_read(arguments: dict) -> dict: def file_read(arguments: dict) -> dict:
# arguments["project_id"] 由 ToolExecutor 自动注入
path, project_dir = _resolve_path( path, project_dir = _resolve_path(
arguments["path"], arguments["path"],
arguments.get("project_id") arguments.get("project_id")
@ -229,16 +233,16 @@ file_read({"path": "src/main.py", "project_id": "xxx"})
### 5.4 文件操作工具 (file) ### 5.4 文件操作工具 (file)
**所有文件工具需要 `project_id` 参数** **`project_id` 由后端自动注入AI 无需感知此参数**
| 工具名称 | 描述 | 参数 | | 工具名称 | 描述 | 参数AI 可见) |
|---------|------|------| |---------|------|------|
| `file_read` | 读取文件内容 | `path`, `project_id`, `encoding` | | `file_read` | 读取文件内容 | `path`, `encoding` |
| `file_write` | 写入文件 | `path`, `content`, `project_id`, `mode` | | `file_write` | 写入文件 | `path`, `content`, `encoding`, `mode` |
| `file_delete` | 删除文件 | `path`, `project_id` | | `file_delete` | 删除文件 | `path` |
| `file_list` | 列出目录内容 | `path`, `pattern`, `project_id` | | `file_list` | 列出目录内容 | `path`, `pattern` |
| `file_exists` | 检查文件是否存在 | `path`, `project_id` | | `file_exists` | 检查文件是否存在 | `path` |
| `file_mkdir` | 创建目录 | `path`, `project_id` | | `file_mkdir` | 创建目录 | `path` |
### 5.5 天气工具 (weather) ### 5.5 天气工具 (weather)

View File

@ -109,7 +109,8 @@ async function loadConversations(reset = true) {
if (loadingConvs.value) return if (loadingConvs.value) return
loadingConvs.value = true loadingConvs.value = true
try { 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) { if (reset) {
conversations.value = res.data.items conversations.value = res.data.items
} else { } else {
@ -131,7 +132,10 @@ function loadMoreConversations() {
// -- Create conversation -- // -- Create conversation --
async function createConversation() { async function createConversation() {
try { try {
const res = await conversationApi.create({ title: '新对话' }) const res = await conversationApi.create({
title: '新对话',
project_id: currentProject.value?.id || null,
})
conversations.value.unshift(res.data) conversations.value.unshift(res.data)
await selectConversation(res.data.id) await selectConversation(res.data.id)
} catch (e) { } catch (e) {
@ -426,6 +430,9 @@ function updateToolsEnabled(val) {
// -- Select project -- // -- Select project --
function selectProject(project) { function selectProject(project) {
currentProject.value = project currentProject.value = project
// Reload conversations filtered by the selected project
nextConvCursor.value = null
loadConversations(true)
} }
// -- Init -- // -- Init --

View File

@ -130,10 +130,11 @@ export const statsApi = {
} }
export const conversationApi = { export const conversationApi = {
list(cursor, limit = 20) { list(cursor, limit = 20, projectId = null) {
const params = new URLSearchParams() const params = new URLSearchParams()
if (cursor) params.set('cursor', cursor) if (cursor) params.set('cursor', cursor)
if (limit) params.set('limit', limit) if (limit) params.set('limit', limit)
if (projectId) params.set('project_id', projectId)
return request(`/conversations?${params}`) return request(`/conversations?${params}`)
}, },
@ -207,9 +208,27 @@ export const projectApi = {
}, },
uploadFolder(data) { 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', 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
}) })
}, },
} }

View File

@ -169,10 +169,10 @@ const processItems = computed(() => {
} }
} }
// tool_call loading // tool_call loading
if (props.streaming && items.length > 0) { if (props.streaming && items.length > 0) {
const last = items[items.length - 1] const last = items[items.length - 1]
if (last.type === 'tool_call') { if (last.type === 'tool_call' && !last.result) {
last.loading = true last.loading = true
} }
} }

View File

@ -79,24 +79,31 @@
<div class="modal"> <div class="modal">
<div class="modal-header"> <div class="modal-header">
<h3>上传文件夹</h3> <h3>上传文件夹</h3>
<button class="btn-close" @click="showUploadModal = false">&times;</button> <button class="btn-close" @click="closeUploadModal">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <div class="form-group">
<label>项目名称</label> <label>选择文件夹</label>
<input v-model="uploadData.name" type="text" placeholder="留空则使用文件夹名称" /> <div class="upload-drop-zone" :class="{ 'has-files': selectedFiles.length > 0 }" @click="triggerFolderInput">
</div> <template v-if="selectedFiles.length === 0">
<div class="form-group"> <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="color: var(--text-tertiary)">
<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">
<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 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> </svg>
选择文件夹 <span>点击选择文件夹</span>
</button> </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> </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>
<div class="form-group"> <div class="form-group">
<label>描述可选</label> <label>描述可选</label>
@ -104,8 +111,8 @@
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn-secondary" @click="showUploadModal = false">取消</button> <button class="btn-secondary" @click="closeUploadModal">取消</button>
<button class="btn-primary" @click="uploadFolder" :disabled="!uploadData.folderPath.trim() || uploading"> <button class="btn-primary" @click="uploadFolder" :disabled="selectedFiles.length === 0 || uploading">
{{ uploading ? '上传中...' : '上传' }} {{ uploading ? '上传中...' : '上传' }}
</button> </button>
</div> </div>
@ -161,28 +168,38 @@ const newProject = ref({
const uploadData = ref({ const uploadData = ref({
name: '', name: '',
folderPath: '',
description: '', description: '',
}) })
async function selectFolder() { const selectedFiles = ref([])
try { const folderName = ref('')
if ('showDirectoryPicker' in window) { const folderInput = ref(null)
const dirHandle = await window.showDirectoryPicker()
// 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()) { if (!uploadData.value.name.trim()) {
uploadData.value.name = dirHandle.name uploadData.value.name = folderName.value
}
//
if (!uploadData.value.folderPath.trim()) {
uploadData.value.folderPath = dirHandle.name
}
}
} catch (e) {
if (e.name !== 'AbortError') {
console.error('Failed to select folder:', e)
} }
} }
function closeUploadModal() {
showUploadModal.value = false
uploadData.value = { name: '', description: '' }
selectedFiles.value = []
folderName.value = ''
if (folderInput.value) folderInput.value.value = ''
} }
// ID // ID
@ -223,19 +240,18 @@ async function createProject() {
} }
async function uploadFolder() { async function uploadFolder() {
if (!uploadData.value.folderPath.trim()) return if (selectedFiles.value.length === 0) return
uploading.value = true uploading.value = true
try { try {
const res = await projectApi.uploadFolder({ const res = await projectApi.uploadFolder({
user_id: userId, user_id: userId,
name: uploadData.value.name.trim() || undefined, name: uploadData.value.name.trim() || folderName.value,
folder_path: uploadData.value.folderPath.trim(),
description: uploadData.value.description.trim(), description: uploadData.value.description.trim(),
files: selectedFiles.value,
}) })
projects.value.unshift(res.data) projects.value.unshift(res.data)
showUploadModal.value = false closeUploadModal()
uploadData.value = { name: '', folderPath: '', description: '' }
emit('created', res.data) emit('created', res.data)
} catch (e) { } catch (e) {
console.error('Failed to upload folder:', e) console.error('Failed to upload folder:', e)
@ -478,6 +494,37 @@ defineExpose({
min-width: 0; 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 { .btn-browse {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;

View File

@ -7,8 +7,20 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <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> <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> </svg>
<span>{{ currentProject?.name || '选择项目' }}</span> <span>{{ currentProject?.name || '全部对话' }}</span>
</div> </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 <svg
width="16" width="16"
height="16" height="16"
@ -22,6 +34,7 @@
</svg> </svg>
</div> </div>
</div> </div>
</div>
<!-- Project Manager Panel --> <!-- Project Manager Panel -->
<div v-if="showProjects" class="project-panel"> <div v-if="showProjects" class="project-panel">
@ -52,7 +65,8 @@
<div class="conv-info"> <div class="conv-info">
<div class="conv-title">{{ conv.title || '新对话' }}</div> <div class="conv-title">{{ conv.title || '新对话' }}</div>
<div class="conv-meta"> <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>
</div> </div>
<button class="btn-delete" @click.stop="$emit('delete', conv.id)" title="删除"> <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" class="loading-more">加载中...</div>
<div v-if="!loading && conversations.length === 0" class="empty-hint"> <div v-if="!loading && conversations.length === 0" class="empty-hint">
暂无对话 {{ currentProject ? '该项目暂无对话' : '暂无对话' }}
</div> </div>
</div> </div>
</aside> </aside>
@ -167,6 +181,39 @@ function onScroll(e) {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: var(--text-primary); 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 { .project-panel {
@ -252,11 +299,22 @@ function onScroll(e) {
} }
.conv-meta { .conv-meta {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px; font-size: 12px;
color: var(--text-tertiary); color: var(--text-tertiary);
margin-top: 2px; margin-top: 2px;
} }
.conv-project-badge {
font-size: 11px;
color: var(--accent-primary);
opacity: 0.8;
flex-shrink: 0;
margin-left: 8px;
}
.btn-delete { .btn-delete {
opacity: 0; opacity: 0;
background: none; background: none;