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
!*/
# ignore workspaces
/workspaces
# Allow docs and settings
!/docs/*.md
!/README.md

View File

@ -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))

View File

@ -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)

View File

@ -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,24 +201,22 @@ 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")
"""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", "")
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", "")
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)
@ -236,13 +234,12 @@ def upload_project_folder():
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(

View File

@ -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,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"
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)
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",
"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"
)

View 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"]
@ -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(

View File

@ -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)

View File

@ -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`

View File

@ -122,8 +122,12 @@ 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,14 +149,14 @@ 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.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)

View File

@ -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 --

View File

@ -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
})
},
}

View File

@ -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
}
}

View File

@ -79,24 +79,31 @@
<div class="modal">
<div class="modal-header">
<h3>上传文件夹</h3>
<button class="btn-close" @click="showUploadModal = false">&times;</button>
<button class="btn-close" @click="closeUploadModal">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>项目名称</label>
<input v-model="uploadData.name" type="text" placeholder="留空则使用文件夹名称" />
</div>
<div class="form-group">
<label>文件夹路径</label>
<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()
//
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 = 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)
}
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;

View File

@ -7,8 +7,20 @@
<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"
@ -22,6 +34,7 @@
</svg>
</div>
</div>
</div>
<!-- Project Manager Panel -->
<div v-if="showProjects" class="project-panel">
@ -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;