mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-09-13 13:35:43 +03:00
feat(chat): add session pinning feature with database migration
- Add `pinned` column to chat_sessions table with default FALSE
- Implement database migration for existing installations using PRAGMA table_info
- Create composite index for pinned + updated_at sorting
- Add pin/unpin API endpoints (PUT/DELETE /sessions/{id}/pin)
- Update session listing to sort by pinned status then updated_at
- Extend ChatSessionsRepository with pin_session method
- Update API documentation to reflect new pinning functionality
The feature allows users to pin important chat sessions to the top of the list. Sessions are sorted with pinned sessions first (by updated_at), followed by regular sessions (by updated_at). Database migration ensures backward compatibility with existing installations.
This commit is contained in:
parent
f688a2d5c0
commit
4a017b1a7f
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user