diff --git a/.gitignore b/.gitignore index 576cf0b..fcd3615 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ # # Allow directories !*/ +# ignore workspaces +/workspaces + # Allow docs and settings !/docs/*.md !/README.md diff --git a/backend/routes/conversations.py b/backend/routes/conversations.py index 909bd58..14f9874 100644 --- a/backend/routes/conversations.py +++ b/backend/routes/conversations.py @@ -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)) diff --git a/backend/routes/messages.py b/backend/routes/messages.py index 2e08404..55f8e2f 100644 --- a/backend/routes/messages.py +++ b/backend/routes/messages.py @@ -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) diff --git a/backend/routes/projects.py b/backend/routes/projects.py index bdb6245..641109b 100644 --- a/backend/routes/projects.py +++ b/backend/routes/projects.py @@ -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, diff --git a/backend/services/chat.py b/backend/services/chat.py index efd8c13..0c6f4c2 100644 --- a/backend/services/chat.py +++ b/backend/services/chat.py @@ -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 diff --git a/backend/tools/builtin/file_ops.py b/backend/tools/builtin/file_ops.py index af9bcf4..3a74978 100644 --- a/backend/tools/builtin/file_ops.py +++ b/backend/tools/builtin/file_ops.py @@ -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" ) diff --git a/backend/tools/executor.py b/backend/tools/executor.py index 85a0585..6db9611 100644 --- a/backend/tools/executor.py +++ b/backend/tools/executor.py @@ -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( diff --git a/backend/utils/workspace.py b/backend/utils/workspace.py index af44bdd..2fce117 100644 --- a/backend/utils/workspace.py +++ b/backend/utils/workspace.py @@ -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) diff --git a/docs/Design.md b/docs/Design.md index 2289f0c..68e015b 100644 --- a/docs/Design.md +++ b/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` diff --git a/docs/ToolSystemDesign.md b/docs/ToolSystemDesign.md index eeb0172..b197dc7 100644 --- a/docs/ToolSystemDesign.md +++ b/docs/ToolSystemDesign.md @@ -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) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 41d151a..d56d9b7 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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 -- diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index b94c393..81e0e8c 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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 }) }, } diff --git a/frontend/src/components/ProcessBlock.vue b/frontend/src/components/ProcessBlock.vue index eb6d00a..9f9c2c6 100644 --- a/frontend/src/components/ProcessBlock.vue +++ b/frontend/src/components/ProcessBlock.vue @@ -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 } } diff --git a/frontend/src/components/ProjectManager.vue b/frontend/src/components/ProjectManager.vue index 688348e..dd8be0e 100644 --- a/frontend/src/components/ProjectManager.vue +++ b/frontend/src/components/ProjectManager.vue @@ -79,24 +79,31 @@ @@ -52,7 +65,8 @@
{{ conv.title || '新对话' }}
- {{ conv.message_count || 0 }} 条消息 · {{ formatTime(conv.updated_at) }} + {{ conv.message_count || 0 }} 条消息 · {{ formatTime(conv.updated_at) }} + {{ conv.project_name }}