feat(chat-api): refactor design document with concise architecture overview

- Replace detailed implementation plan with high-level architecture design
- Focus on core features: project isolation, streaming responses, session management
- Remove FlowNet-Lab reference and implementation specifics
- Streamline document from 1172 to 483 lines for better maintainability
This commit is contained in:
YueGuobin 2026-03-04 21:58:23 +08:00
parent b6c343c5b8
commit 03ab9cdf6c
6 changed files with 1017 additions and 1119 deletions

File diff suppressed because it is too large Load Diff

View File

@ -7,8 +7,10 @@ in the project directory.
"""
import asyncio
import json
import logging
import os
from datetime import datetime
from typing import AsyncGenerator, Dict, Any, Optional
from uuid import uuid4
@ -17,6 +19,7 @@ from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
from gns3server.agent.gns3_copilot.agent.gns3_copilot import agent_builder
from gns3server.agent.gns3_copilot.chat_sessions_repository import ChatSessionsRepository
log = logging.getLogger(__name__)
@ -84,12 +87,55 @@ class AgentService:
# CRITICAL: Initialize database schema
await self._checkpointer.setup()
# Create chat_sessions table in the same database
await self._create_chat_sessions_table(conn)
self._checkpointer_path = checkpointer_path
self._initialized = True
log.info("Project checkpointer created at: %s", checkpointer_path)
return self._checkpointer
async def _create_chat_sessions_table(self, conn: aiosqlite.Connection):
"""
Create the chat_sessions table in the checkpoint database.
Args:
conn: aiosqlite connection
"""
await conn.execute("""
CREATE TABLE IF NOT EXISTS chat_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id TEXT UNIQUE NOT NULL,
user_id TEXT NOT NULL,
project_id TEXT NOT NULL,
title TEXT DEFAULT 'New Conversation',
-- Statistics
message_count INTEGER DEFAULT 0,
llm_calls_count INTEGER DEFAULT 0,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
total_tokens INTEGER DEFAULT 0,
-- Timestamps
last_message_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Reserved fields (JSON strings)
metadata TEXT DEFAULT '{}',
stats TEXT DEFAULT '{}'
)
""")
# Create indexes
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)")
await conn.commit()
log.debug("chat_sessions table created in checkpoint database")
async def _get_graph(self):
"""Get or compile the LangGraph agent."""
if self._graph is None:
@ -131,6 +177,21 @@ class AgentService:
mode,
)
# Get or create chat session
repo = ChatSessionsRepository(self._checkpointer_conn)
session = await repo.get_session_by_thread(session_id)
is_new_session = session is None
if is_new_session:
# Create new session
session = await repo.create_session(
thread_id=session_id,
user_id=user_id or "",
project_id=project_id or "",
title="New Conversation"
)
log.debug("Created new chat session: thread_id=%s", session_id)
# Set request-scoped context variables (memory-only, not persisted)
if jwt_token:
from gns3server.agent.gns3_copilot.gns3_client import set_current_jwt_token
@ -165,14 +226,59 @@ class AgentService:
graph = await self._get_graph()
log.debug("LangGraph graph obtained, starting stream")
# Track statistics for session update
message_count = 1 # User message
llm_calls_count = 0
tool_calls_count = 0
input_tokens = 0
output_tokens = 0
last_message_at = datetime.utcnow().isoformat()
# Stream events
try:
async for event in graph.astream_events(inputs, config=config, version="v2"):
chunk = self._convert_event_to_chunk(event, session_id)
if chunk:
# Track statistics
if chunk.get("type") == "content":
message_count += 1 # AI response
elif chunk.get("type") == "tool_start":
tool_calls_count += 1
elif chunk.get("type") == "tool_end":
message_count += 1 # Tool message
# Track tokens if available
if chunk.get("type") == "content" and "input_tokens" in chunk:
input_tokens += chunk.get("input_tokens", 0)
if chunk.get("type") == "content" and "output_tokens" in chunk:
output_tokens += chunk.get("output_tokens", 0)
log.debug("Yielding chunk: type=%s", chunk.get("type"))
yield chunk
# Update session statistics after successful stream
await repo.update_session(
thread_id=session_id,
message_count=message_count,
llm_calls_count=llm_calls_count,
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=input_tokens + output_tokens,
last_message_at=last_message_at
)
log.debug("Updated session statistics: thread_id=%s, messages=%d, tokens=%d",
session_id, message_count, input_tokens + output_tokens)
# Sync auto-generated title from checkpoint state
final_state = await graph.aget_state(config)
if final_state and "conversation_title" in final_state.values:
generated_title = final_state.values["conversation_title"]
current_session = await repo.get_session_by_thread(session_id)
if current_session and current_session.title != generated_title:
await repo.update_session(thread_id=session_id, title=generated_title)
log.info("Auto-generated title synced: thread_id=%s, title=%s",
session_id, generated_title)
except Exception as e:
log.error("Error in stream_chat: %s", e, exc_info=True)
yield {"type": "error", "error": str(e), "session_id": session_id}
@ -288,6 +394,58 @@ class AgentService:
return result
async def list_sessions(self, user_id: Optional[str] = None, limit: int = 100) -> List[Dict[str, Any]]:
"""
List chat sessions for this project.
Args:
user_id: Filter by user ID (optional)
limit: Maximum number of sessions to return
Returns:
List of session dictionaries
"""
if not self._checkpointer_conn:
await self._get_checkpointer()
repo = ChatSessionsRepository(self._checkpointer_conn)
sessions = await repo.list_sessions(user_id=user_id, limit=limit)
return [s.to_dict() for s in sessions]
async def delete_session(self, session_id: str) -> bool:
"""
Delete a chat session and its checkpoints.
Args:
session_id: Thread ID to delete
Returns:
True if deleted, False if not found
"""
if not self._checkpointer_conn:
await self._get_checkpointer()
repo = ChatSessionsRepository(self._checkpointer_conn)
return await repo.delete_session(session_id)
async def rename_session(self, session_id: str, new_title: str) -> Optional[Dict[str, Any]]:
"""
Rename a chat session.
Args:
session_id: Thread ID
new_title: New title
Returns:
Updated session dictionary or None
"""
if not self._checkpointer_conn:
await self._get_checkpointer()
repo = ChatSessionsRepository(self._checkpointer_conn)
session = await repo.update_session(thread_id=session_id, title=new_title)
return session.to_dict() if session else None
async def close(self):
"""
Close the checkpointer connection and cleanup resources.

View File

@ -0,0 +1,365 @@
"""
Chat Sessions Repository for managing chat session data.
Provides CRUD operations for the chat_sessions table in the project's
checkpoint database.
"""
import json
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
from uuid import UUID
import aiosqlite
log = logging.getLogger(__name__)
class ChatSession:
"""Chat session model."""
def __init__(
self,
id: Optional[int] = None,
thread_id: str = "",
user_id: str = "",
project_id: str = "",
title: str = "New Conversation",
message_count: int = 0,
llm_calls_count: int = 0,
input_tokens: int = 0,
output_tokens: int = 0,
total_tokens: int = 0,
last_message_at: Optional[str] = None,
created_at: Optional[str] = None,
updated_at: Optional[str] = None,
metadata: str = "{}",
stats: str = "{}"
):
self.id = id
self.thread_id = thread_id
self.user_id = user_id
self.project_id = project_id
self.title = title
self.message_count = message_count
self.llm_calls_count = llm_calls_count
self.input_tokens = input_tokens
self.output_tokens = output_tokens
self.total_tokens = total_tokens
self.last_message_at = last_message_at
self.created_at = created_at
self.updated_at = updated_at
self.metadata = metadata
self.stats = stats
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"id": self.id,
"thread_id": self.thread_id,
"user_id": self.user_id,
"project_id": self.project_id,
"title": self.title,
"message_count": self.message_count,
"llm_calls_count": self.llm_calls_count,
"input_tokens": self.input_tokens,
"output_tokens": self.output_tokens,
"total_tokens": self.total_tokens,
"last_message_at": self.last_message_at,
"created_at": self.created_at,
"updated_at": self.updated_at,
"metadata": json.loads(self.metadata) if self.metadata else {},
"stats": json.loads(self.stats) if self.stats else {},
}
class ChatSessionsRepository:
"""
Repository for managing chat sessions in the checkpoint database.
"""
def __init__(self, conn: aiosqlite.Connection):
"""
Initialize repository with a database connection.
Args:
conn: aiosqlite connection to the checkpoint database
"""
self.conn = conn
async def create_session(
self,
thread_id: str,
user_id: str,
project_id: str,
title: str = "New Conversation"
) -> ChatSession:
"""
Create a new chat session.
Args:
thread_id: Unique thread identifier
user_id: User ID
project_id: Project ID
title: Session title
Returns:
Created ChatSession
"""
now = datetime.utcnow().isoformat()
cursor = await self.conn.execute(
"""
INSERT INTO chat_sessions (
thread_id, user_id, project_id, title,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?)
""",
(thread_id, user_id, project_id, title, now, now)
)
await self.conn.commit()
session_id = cursor.lastrowid
log.info("Created chat session: id=%s, thread_id=%s", session_id, thread_id)
return await self.get_session_by_id(session_id)
async def get_session_by_id(self, session_id: int) -> Optional[ChatSession]:
"""
Get a session by its database ID.
Args:
session_id: Database row ID
Returns:
ChatSession or None
"""
cursor = await self.conn.execute(
"SELECT * FROM chat_sessions WHERE id = ?",
(session_id,)
)
row = await cursor.fetchone()
if row:
return self._row_to_session(row)
return None
async def get_session_by_thread(self, thread_id: str) -> Optional[ChatSession]:
"""
Get a session by thread_id.
Args:
thread_id: Thread identifier
Returns:
ChatSession or None
"""
cursor = await self.conn.execute(
"SELECT * FROM chat_sessions WHERE thread_id = ?",
(thread_id,)
)
row = await cursor.fetchone()
if row:
return self._row_to_session(row)
return None
async def list_sessions(
self,
user_id: Optional[str] = None,
project_id: Optional[str] = None,
limit: int = 100
) -> List[ChatSession]:
"""
List sessions with optional filters.
Args:
user_id: Filter by user ID
project_id: Filter by project ID
limit: Maximum number of sessions to return
Returns:
List of ChatSession
"""
query = "SELECT * FROM chat_sessions"
params = []
conditions = []
if user_id:
conditions.append("user_id = ?")
params.append(user_id)
if project_id:
conditions.append("project_id = ?")
params.append(project_id)
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY updated_at DESC LIMIT ?"
params.append(limit)
cursor = await self.conn.execute(query, params)
rows = await cursor.fetchall()
return [self._row_to_session(row) for row in rows]
async def update_session(
self,
thread_id: str,
title: Optional[str] = None,
message_count: Optional[int] = None,
llm_calls_count: Optional[int] = None,
input_tokens: Optional[int] = None,
output_tokens: Optional[int] = None,
total_tokens: Optional[int] = None,
last_message_at: Optional[str] = None
) -> Optional[ChatSession]:
"""
Update a session.
Args:
thread_id: Thread identifier
title: New title
message_count: Increment message count
llm_calls_count: Increment LLM call count
input_tokens: Add to input tokens
output_tokens: Add to output tokens
total_tokens: Add to total tokens
last_message_at: Last message timestamp
Returns:
Updated ChatSession or None
"""
updates = []
params = []
now = datetime.utcnow().isoformat()
if title is not None:
updates.append("title = ?")
params.append(title)
if message_count is not None:
updates.append("message_count = message_count + ?")
params.append(message_count)
if llm_calls_count is not None:
updates.append("llm_calls_count = llm_calls_count + ?")
params.append(llm_calls_count)
if input_tokens is not None:
updates.append("input_tokens = input_tokens + ?")
params.append(input_tokens)
if output_tokens is not None:
updates.append("output_tokens = output_tokens + ?")
params.append(output_tokens)
if total_tokens is not None:
updates.append("total_tokens = total_tokens + ?")
params.append(total_tokens)
if last_message_at is not None:
updates.append("last_message_at = ?")
params.append(last_message_at)
if not updates:
return await self.get_session_by_thread(thread_id)
updates.append("updated_at = ?")
params.append(now)
params.append(thread_id)
query = f"UPDATE chat_sessions SET {', '.join(updates)} WHERE thread_id = ?"
await self.conn.execute(query, params)
await self.conn.commit()
log.debug("Updated chat session: thread_id=%s", thread_id)
return await self.get_session_by_thread(thread_id)
async def delete_session(self, thread_id: str) -> bool:
"""
Delete a session by thread_id.
Args:
thread_id: Thread identifier
Returns:
True if deleted, False if not found
"""
# First, delete the checkpoint data
await self.conn.execute(
"DELETE FROM checkpoints WHERE thread_id = ?",
(thread_id,)
)
# Then delete the session
cursor = await self.conn.execute(
"DELETE FROM chat_sessions WHERE thread_id = ?",
(thread_id,)
)
await self.conn.commit()
deleted = cursor.rowcount > 0
if deleted:
log.info("Deleted chat session and checkpoints: thread_id=%s", thread_id)
return deleted
async def delete_all_sessions(self, project_id: str) -> int:
"""
Delete all sessions for a project.
Args:
project_id: Project ID
Returns:
Number of sessions deleted
"""
# Get all thread_ids for this project
cursor = await self.conn.execute(
"SELECT thread_id FROM chat_sessions WHERE project_id = ?",
(project_id,)
)
rows = await cursor.fetchall()
thread_ids = [row[0] for row in rows]
# Delete checkpoints and sessions
for thread_id in thread_ids:
await self.conn.execute(
"DELETE FROM checkpoints WHERE thread_id = ?",
(thread_id,)
)
cursor = await self.conn.execute(
"DELETE FROM chat_sessions WHERE project_id = ?",
(project_id,)
)
await self.conn.commit()
deleted_count = cursor.rowcount
if deleted_count > 0:
log.info("Deleted %d sessions for project: %s", deleted_count, project_id)
return deleted_count
def _row_to_session(self, row) -> ChatSession:
"""Convert database row to ChatSession object."""
return ChatSession(
id=row[0],
thread_id=row[1],
user_id=row[2],
project_id=row[3],
title=row[4],
message_count=row[5],
llm_calls_count=row[6],
input_tokens=row[7],
output_tokens=row[8],
total_tokens=row[9],
last_message_at=row[10],
created_at=row[11],
updated_at=row[12],
metadata=row[13],
stats=row[14],
)

View File

@ -189,7 +189,7 @@ async def stream_chat(
"/sessions",
response_model=List[schemas.ChatSession],
summary="List chat sessions",
description="List all chat sessions for a project (not yet implemented)."
description="List all chat sessions for a project."
)
async def list_sessions(
project: Project = Depends(dep_project),
@ -197,9 +197,6 @@ async def list_sessions(
) -> list[schemas.ChatSession]:
"""
List chat sessions for a project.
Note: This endpoint is a placeholder. Full session listing functionality
requires checkpoint metadata inspection which is not yet implemented.
"""
# Check if project is opened
@ -209,8 +206,15 @@ async def list_sessions(
detail=f"Project must be opened to access chat sessions. Current status: {project.status}"
)
# TODO: Implement session listing from checkpoint metadata
return []
# Get AgentService for this project
agent_manager = await get_project_agent_manager()
agent_service = await agent_manager.get_agent(str(project.id), project.path)
# List sessions
sessions = await agent_service.list_sessions(user_id=str(current_user.user_id))
# Convert to schemas
return [schemas.ChatSession(**s) for s in sessions]
@router.get(
@ -250,7 +254,7 @@ async def get_history(
"/sessions/{session_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a chat session",
description="Delete a specific chat session (not yet implemented)."
description="Delete a specific chat session and its checkpoints."
)
async def delete_session(
session_id: str,
@ -259,9 +263,6 @@ async def delete_session(
):
"""
Delete a chat session.
Note: This endpoint is a placeholder. Full session deletion functionality
requires checkpoint manipulation which is not yet implemented.
"""
# Check if project is opened
@ -271,8 +272,54 @@ async def delete_session(
detail=f"Project must be opened to delete chat sessions. Current status: {project.status}"
)
# TODO: Implement session deletion from checkpoint
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Session deletion not yet implemented"
)
# Get AgentService for this project
agent_manager = await get_project_agent_manager()
agent_service = await agent_manager.get_agent(str(project.id), project.path)
# Delete session
deleted = await agent_service.delete_session(session_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
@router.patch(
"/sessions/{session_id}",
response_model=schemas.ChatSession,
summary="Rename a chat session",
description="Rename a specific chat session."
)
async def rename_session(
session_id: str,
request: schemas.RenameSession,
project: Project = Depends(dep_project),
current_user: schemas.User = Depends(get_current_active_user),
) -> schemas.ChatSession:
"""
Rename 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 rename 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)
# Rename session
session = await agent_service.rename_session(session_id, request.title)
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
return schemas.ChatSession(**session)

View File

@ -45,7 +45,8 @@ from .controller.chat import (
ChatResponse,
OpenAIMessage,
ConversationHistory,
ChatSession
ChatSession,
RenameSession
)
from .controller.rbac import RoleCreate, RoleUpdate, Role, Privilege, ACECreate, ACEUpdate, ACE
from .controller.pools import Resource, ResourceCreate, ResourcePoolCreate, ResourcePoolUpdate, ResourcePool

View File

@ -88,8 +88,24 @@ class ConversationHistory(BaseModel):
class ChatSession(BaseModel):
"""Chat session model."""
session_id: str = Field(..., description="Session ID")
id: Optional[int] = Field(None, description="Database ID")
thread_id: str = Field(..., description="Thread/session ID")
user_id: str = Field(..., description="User ID")
project_id: str = Field(..., description="Associated GNS3 project ID")
title: str = Field(..., description="Session title")
project_id: Optional[str] = Field(None, description="Associated GNS3 project ID")
message_count: int = Field(default=0, description="Number of messages")
llm_calls_count: int = Field(default=0, description="Number of LLM calls")
input_tokens: int = Field(default=0, description="Input tokens used")
output_tokens: int = Field(default=0, description="Output tokens generated")
total_tokens: int = Field(default=0, description="Total tokens used")
last_message_at: Optional[str] = Field(None, description="Last message timestamp (ISO 8601)")
created_at: Optional[str] = Field(None, description="Creation timestamp (ISO 8601)")
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")
class RenameSession(BaseModel):
"""Rename session request model."""
title: str = Field(..., description="New session title", min_length=1, max_length=255)