diff --git a/docs/ai-chat-api-design.md b/docs/ai-chat-api-design.md index cee64bd25..ea8151c6e 100644 --- a/docs/ai-chat-api-design.md +++ b/docs/ai-chat-api-design.md @@ -125,6 +125,42 @@ GNS3 Copilot Agent 需要以下信息才能正常工作: | updated_at | TIMESTAMP | 更新时间 | | metadata | TEXT | 预留元数据(JSON) | | stats | TEXT | 额外统计信息(JSON) | +| pinned | BOOLEAN | 是否置顶(默认 FALSE) | + +**索引**: +- `idx_thread_id`:thread_id 唯一索引 +- `idx_user_project`:user_id + project_id 复合索引 +- `idx_pinned_updated`:pinned + updated_at 复合索引(用于置顶排序) + +### 数据库迁移 + +**实现位置**:`agent_service.py` 的 `_create_chat_sessions_table` 方法 + +**迁移策略**: +- 使用 `PRAGMA table_info(chat_sessions)` 检查列是否存在 +- 如果 `pinned` 列不存在,执行 `ALTER TABLE ADD COLUMN` 添加该列 +- 确保列存在后再创建索引 + +**代码示例**: +```python +# Check if pinned column exists, add it if not (migration for existing databases) +cursor = await conn.execute("PRAGMA table_info(chat_sessions)") +columns = await cursor.fetchall() +column_names = [col[1] for col in columns] + +if "pinned" not in column_names: + log.debug("Adding pinned column to existing chat_sessions table") + await conn.execute("ALTER TABLE chat_sessions ADD COLUMN pinned BOOLEAN DEFAULT FALSE") + await conn.commit() + +# Create pinned index (after column is guaranteed to exist) +await conn.execute("CREATE INDEX IF NOT EXISTS idx_pinned_updated ON chat_sessions(pinned DESC, updated_at DESC)") +``` + +**优势**: +- 向后兼容:现有数据库自动升级,无需手动干预 +- 幂等性:重复执行不会报错 +- 零停机:迁移在初始化时自动完成 ### ChatSessionsRepository @@ -132,10 +168,11 @@ GNS3 Copilot Agent 需要以下信息才能正常工作: - **create_session**:创建新会话 - **get_session_by_thread**:根据 thread_id 查询会话 -- **list_sessions**:列出用户的会话(支持过滤和分页) +- **list_sessions**:列出用户的会话(支持过滤和分页,按 pinned 和 updated_at 排序) - **update_session**:更新会话(支持增量更新计数器) - **delete_session**:删除会话及其 checkpoints - **delete_all_sessions**:删除项目的所有会话 +- **pin_session**:置顶或取消置顶会话 ### 统计信息自动收集 @@ -249,10 +286,12 @@ Chat API 使用 Server-Sent Events (SSE) 进行流式传输。 | 方法 | 端点 | 说明 | |------|------|------| | POST | `/stream` | 流式 Chat(主要接口) | -| GET | `/sessions` | 列出会话 | +| GET | `/sessions` | 列出会话(按置顶和更新时间排序) | | GET | `/sessions/{session_id}/history` | 获取会话历史 | | PATCH | `/sessions/{session_id}` | 重命名会话 | | DELETE | `/sessions/{session_id}` | 删除会话 | +| PUT | `/sessions/{session_id}/pin` | 置顶会话 | +| DELETE | `/sessions/{session_id}/pin` | 取消置顶会话 | ### POST /v3/projects/{project_id}/chat/stream @@ -272,7 +311,7 @@ Chat API 使用 Server-Sent Events (SSE) 进行流式传输。 **功能**:列出项目的所有会话 -**响应**:会话列表,包含统计信息(消息数、token 使用量等) +**响应**:会话列表,包含统计信息(消息数、token 使用量等),按置顶状态和更新时间排序 ### GET /v3/projects/{project_id}/chat/sessions/{session_id}/history @@ -303,6 +342,23 @@ Chat API 使用 Server-Sent Events (SSE) 进行流式传输。 **响应**:204 No Content +### PUT /v3/projects/{project_id}/chat/sessions/{session_id}/pin + +**功能**:置顶会话到列表顶部 + +**响应**:更新后的会话信息(包含 pinned=true) + +### DELETE /v3/projects/{project_id}/chat/sessions/{session_id}/pin + +**功能**:取消置顶会话 + +**响应**:更新后的会话信息(包含 pinned=false) + +**排序规则**: +- 置顶会话(pinned=true)排在最前面 +- 置顶会话之间按 updated_at 降序排列 +- 普通会话按 updated_at 降序排列 + ## 数据模型 ### ChatRequest @@ -339,6 +395,9 @@ Chat API 使用 Server-Sent Events (SSE) 进行流式传输。 - metadata: 元数据 JSON 字符串(存储 mode、status、tags 等) - stats: 额外统计 JSON 字符串(存储工具调用次数等) +**会话管理**: +- pinned: 是否置顶到列表顶部(默认 false) + ### ConversationHistory - thread_id: str - 会话 ID diff --git a/gns3server/agent/gns3_copilot/agent_service.py b/gns3server/agent/gns3_copilot/agent_service.py index 091a38f62..70fc166e3 100644 --- a/gns3server/agent/gns3_copilot/agent_service.py +++ b/gns3server/agent/gns3_copilot/agent_service.py @@ -126,7 +126,10 @@ class AgentService: -- Reserved fields (JSON strings) metadata TEXT DEFAULT '{}', - stats TEXT DEFAULT '{}' + stats TEXT DEFAULT '{}', + + -- Pin feature + pinned BOOLEAN DEFAULT FALSE ) """) @@ -134,6 +137,19 @@ class AgentService: await conn.execute("CREATE INDEX IF NOT EXISTS idx_thread_id ON chat_sessions(thread_id)") await conn.execute("CREATE INDEX IF NOT EXISTS idx_user_project ON chat_sessions(user_id, project_id)") + # Check if pinned column exists, add it if not (migration for existing databases) + cursor = await conn.execute("PRAGMA table_info(chat_sessions)") + columns = await cursor.fetchall() + column_names = [col[1] for col in columns] + + if "pinned" not in column_names: + log.debug("Adding pinned column to existing chat_sessions table") + await conn.execute("ALTER TABLE chat_sessions ADD COLUMN pinned BOOLEAN DEFAULT FALSE") + await conn.commit() + + # Create pinned index (after column is guaranteed to exist) + await conn.execute("CREATE INDEX IF NOT EXISTS idx_pinned_updated ON chat_sessions(pinned DESC, updated_at DESC)") + await conn.commit() log.debug("chat_sessions table created in checkpoint database") @@ -469,6 +485,24 @@ class AgentService: session = await repo.update_session(thread_id=session_id, title=new_title) return session.to_dict() if session else None + async def pin_session(self, session_id: str, pinned: bool = True) -> Optional[Dict[str, Any]]: + """ + Pin or unpin a chat session. + + Args: + session_id: Thread ID + pinned: True to pin, False to unpin + + Returns: + Updated session dictionary or None + """ + if not self._checkpointer_conn: + await self._get_checkpointer() + + repo = ChatSessionsRepository(self._checkpointer_conn) + session = await repo.pin_session(thread_id=session_id, pinned=pinned) + return session.to_dict() if session else None + async def close(self): """ Close the checkpointer connection and cleanup resources. diff --git a/gns3server/agent/gns3_copilot/chat_sessions_repository.py b/gns3server/agent/gns3_copilot/chat_sessions_repository.py index 9ccb4502d..fde214f35 100644 --- a/gns3server/agent/gns3_copilot/chat_sessions_repository.py +++ b/gns3server/agent/gns3_copilot/chat_sessions_repository.py @@ -35,7 +35,8 @@ class ChatSession: created_at: Optional[str] = None, updated_at: Optional[str] = None, metadata: str = "{}", - stats: str = "{}" + stats: str = "{}", + pinned: bool = False ): self.id = id self.thread_id = thread_id @@ -52,6 +53,7 @@ class ChatSession: self.updated_at = updated_at self.metadata = metadata self.stats = stats + self.pinned = pinned def to_dict(self) -> Dict[str, Any]: """Convert to dictionary.""" @@ -71,6 +73,7 @@ class ChatSession: "updated_at": self.updated_at, "metadata": json.loads(self.metadata) if self.metadata else {}, "stats": json.loads(self.stats) if self.stats else {}, + "pinned": self.pinned, } @@ -195,7 +198,8 @@ class ChatSessionsRepository: if conditions: query += " WHERE " + " AND ".join(conditions) - query += " ORDER BY updated_at DESC LIMIT ?" + # Sort by pinned status first, then by updated_at + query += " ORDER BY pinned DESC, updated_at DESC LIMIT ?" params.append(limit) cursor = await self.conn.execute(query, params) @@ -344,6 +348,27 @@ class ChatSessionsRepository: return deleted_count + async def pin_session(self, thread_id: str, pinned: bool = True) -> Optional[ChatSession]: + """ + Pin or unpin a session. + + Args: + thread_id: Thread identifier + pinned: True to pin, False to unpin + + Returns: + Updated ChatSession or None + """ + now = datetime.utcnow().isoformat() + await self.conn.execute( + "UPDATE chat_sessions SET pinned = ?, updated_at = ? WHERE thread_id = ?", + (1 if pinned else 0, now, thread_id) + ) + await self.conn.commit() + + log.debug("Session pin status updated: thread_id=%s, pinned=%s", thread_id, pinned) + return await self.get_session_by_thread(thread_id) + def _row_to_session(self, row) -> ChatSession: """Convert database row to ChatSession object.""" return ChatSession( @@ -362,4 +387,5 @@ class ChatSessionsRepository: updated_at=row[12], metadata=row[13], stats=row[14], + pinned=bool(row[15]) if len(row) > 15 else False, ) diff --git a/gns3server/api/routes/controller/chat.py b/gns3server/api/routes/controller/chat.py index efb45d138..f559089f1 100644 --- a/gns3server/api/routes/controller/chat.py +++ b/gns3server/api/routes/controller/chat.py @@ -323,3 +323,79 @@ async def rename_session( ) return schemas.ChatSession(**session) + + +@router.put( + "/sessions/{session_id}/pin", + response_model=schemas.ChatSession, + summary="Pin a chat session", + description="Pin a chat session to the top of the list." +) +async def pin_session( + session_id: str, + project: Project = Depends(dep_project), + current_user: schemas.User = Depends(get_current_active_user), +) -> schemas.ChatSession: + """ + Pin a chat session. + """ + + # Check if project is opened + if project.status != "opened": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Project must be opened to pin chat sessions. Current status: {project.status}" + ) + + # Get AgentService for this project + agent_manager = await get_project_agent_manager() + agent_service = await agent_manager.get_agent(str(project.id), project.path) + + # Pin session + session = await agent_service.pin_session(session_id, pinned=True) + + if not session: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Session '{session_id}' not found" + ) + + return schemas.ChatSession(**session) + + +@router.delete( + "/sessions/{session_id}/pin", + response_model=schemas.ChatSession, + summary="Unpin a chat session", + description="Unpin a chat session from the top of the list." +) +async def unpin_session( + session_id: str, + project: Project = Depends(dep_project), + current_user: schemas.User = Depends(get_current_active_user), +) -> schemas.ChatSession: + """ + Unpin a chat session. + """ + + # Check if project is opened + if project.status != "opened": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Project must be opened to unpin chat sessions. Current status: {project.status}" + ) + + # Get AgentService for this project + agent_manager = await get_project_agent_manager() + agent_service = await agent_manager.get_agent(str(project.id), project.path) + + # Unpin session + session = await agent_service.pin_session(session_id, pinned=False) + + if not session: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Session '{session_id}' not found" + ) + + return schemas.ChatSession(**session) diff --git a/gns3server/schemas/controller/chat.py b/gns3server/schemas/controller/chat.py index 0662d65f2..64ede4925 100644 --- a/gns3server/schemas/controller/chat.py +++ b/gns3server/schemas/controller/chat.py @@ -103,6 +103,7 @@ class ChatSession(BaseModel): updated_at: Optional[str] = Field(None, description="Last update timestamp (ISO 8601)") metadata: Dict[str, Any] = Field(default_factory=dict, description="Session metadata") stats: Dict[str, Any] = Field(default_factory=dict, description="Session statistics") + pinned: bool = Field(default=False, description="Whether the session is pinned to the top") class RenameSession(BaseModel):