mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-20 21:57:14 +08:00
feat: web multi session interface
This commit is contained in:
212
agent/chat/session_service.py
Normal file
212
agent/chat/session_service.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"""
|
||||||
|
SessionService - Manages multi-session lifecycle for both web channel and cloud client.
|
||||||
|
|
||||||
|
Provides a unified interface for listing, deleting, renaming, clearing context,
|
||||||
|
and generating AI titles for conversation sessions. Backed by ConversationStore
|
||||||
|
(SQLite) and AgentBridge (in-memory agent instances).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
def generate_session_title(user_message: str, assistant_reply: str = "") -> str:
|
||||||
|
"""
|
||||||
|
Generate a short session title by calling the current bot's reply_text.
|
||||||
|
Falls back to a truncated user message if the LLM call fails.
|
||||||
|
"""
|
||||||
|
fallback = user_message[:50].split("\n")[0].strip() or "New Chat"
|
||||||
|
try:
|
||||||
|
from bridge.bridge import Bridge
|
||||||
|
from models.session_manager import Session
|
||||||
|
bot = Bridge().get_bot("chat")
|
||||||
|
|
||||||
|
prompt_parts = [f"User: {user_message[:300]}"]
|
||||||
|
if assistant_reply:
|
||||||
|
prompt_parts.append(f"Assistant: {assistant_reply[:300]}")
|
||||||
|
|
||||||
|
session = Session("__title_gen__", system_prompt="")
|
||||||
|
session.messages = [
|
||||||
|
{"role": "user", "content": (
|
||||||
|
"Generate a very short title (max 15 characters for Chinese, max 6 words for English) "
|
||||||
|
"summarizing this conversation. Return ONLY the title text, nothing else.\n\n"
|
||||||
|
+ "\n".join(prompt_parts)
|
||||||
|
)}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = bot.reply_text(session)
|
||||||
|
raw = (result.get("content") or "").strip()
|
||||||
|
title = re.sub(r'<think>.*?</think>', '', raw, flags=re.DOTALL).strip().strip('"\'')
|
||||||
|
logger.info(f"[SessionService] Title generation result: '{title}' (len={len(title)})")
|
||||||
|
if title and len(title) <= 50:
|
||||||
|
return title
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[SessionService] Title generation failed: {e}")
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
class SessionService:
|
||||||
|
"""
|
||||||
|
High-level service for session lifecycle management.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
svc = SessionService()
|
||||||
|
result = svc.dispatch("list", {"channel_type": "web", "page": 1})
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _get_store(self):
|
||||||
|
from agent.memory import get_conversation_store
|
||||||
|
return get_conversation_store()
|
||||||
|
|
||||||
|
def _remove_agent(self, session_id: str):
|
||||||
|
"""Remove the in-memory Agent instance for a session if it exists."""
|
||||||
|
try:
|
||||||
|
from bridge.bridge import Bridge
|
||||||
|
ab = Bridge().get_agent_bridge()
|
||||||
|
if session_id in ab.agents:
|
||||||
|
del ab.agents[session_id]
|
||||||
|
logger.info(f"[SessionService] Removed agent instance: {session_id}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_sid(session_id: str) -> str:
|
||||||
|
if session_id and not session_id.startswith("session_"):
|
||||||
|
return f"session_{session_id}"
|
||||||
|
return session_id
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# actions
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def list_sessions(self, channel_type: Optional[str] = None,
|
||||||
|
page: int = 1, page_size: int = 50) -> dict:
|
||||||
|
store = self._get_store()
|
||||||
|
return store.list_sessions(
|
||||||
|
channel_type=channel_type,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_session(self, session_id: str) -> None:
|
||||||
|
if not session_id:
|
||||||
|
raise ValueError("session_id required")
|
||||||
|
session_id = self._normalize_sid(session_id)
|
||||||
|
|
||||||
|
store = self._get_store()
|
||||||
|
store.clear_session(session_id)
|
||||||
|
self._remove_agent(session_id)
|
||||||
|
logger.info(f"[SessionService] Session deleted: {session_id}")
|
||||||
|
|
||||||
|
def rename_session(self, session_id: str, title: str) -> None:
|
||||||
|
if not session_id:
|
||||||
|
raise ValueError("session_id required")
|
||||||
|
if not title:
|
||||||
|
raise ValueError("title required")
|
||||||
|
session_id = self._normalize_sid(session_id)
|
||||||
|
|
||||||
|
store = self._get_store()
|
||||||
|
found = store.rename_session(session_id, title)
|
||||||
|
if not found:
|
||||||
|
raise ValueError("session not found")
|
||||||
|
|
||||||
|
def clear_context(self, session_id: str) -> int:
|
||||||
|
"""
|
||||||
|
Set context boundary. Returns the new context_start_seq value.
|
||||||
|
"""
|
||||||
|
if not session_id:
|
||||||
|
raise ValueError("session_id required")
|
||||||
|
session_id = self._normalize_sid(session_id)
|
||||||
|
|
||||||
|
store = self._get_store()
|
||||||
|
new_seq = store.clear_context(session_id)
|
||||||
|
self._remove_agent(session_id)
|
||||||
|
return new_seq
|
||||||
|
|
||||||
|
def gen_title(self, session_id: str, user_message: str,
|
||||||
|
assistant_reply: str = "") -> str:
|
||||||
|
"""
|
||||||
|
Generate an AI title and persist it. Returns the generated title.
|
||||||
|
"""
|
||||||
|
if not session_id:
|
||||||
|
raise ValueError("session_id required")
|
||||||
|
if not user_message:
|
||||||
|
raise ValueError("user_message required")
|
||||||
|
session_id = self._normalize_sid(session_id)
|
||||||
|
|
||||||
|
title = generate_session_title(user_message, assistant_reply)
|
||||||
|
|
||||||
|
store = self._get_store()
|
||||||
|
updated = store.rename_session(session_id, title)
|
||||||
|
logger.info(f"[SessionService] Title set: sid={session_id}, "
|
||||||
|
f"title='{title}', db_updated={updated}")
|
||||||
|
return title
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# dispatch — single entry point for protocol messages
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def dispatch(self, action: str, payload: Optional[dict] = None) -> dict:
|
||||||
|
"""
|
||||||
|
Dispatch a session management action and return a protocol-compatible
|
||||||
|
response dict.
|
||||||
|
|
||||||
|
Action names use a ``*_session`` / session-prefixed convention so they
|
||||||
|
can coexist with history actions (e.g. ``query``) on the same HISTORY
|
||||||
|
message channel without ambiguity.
|
||||||
|
|
||||||
|
Supported actions:
|
||||||
|
- list_sessions: list sessions with pagination
|
||||||
|
- delete_session: delete a session
|
||||||
|
- rename_session: rename a session title
|
||||||
|
- clear_context: set context boundary
|
||||||
|
- generate_title: AI-generate a session title
|
||||||
|
|
||||||
|
:param action: one of the above action names
|
||||||
|
:param payload: action-specific payload
|
||||||
|
:return: dict with action, code, message, payload
|
||||||
|
"""
|
||||||
|
payload = payload or {}
|
||||||
|
try:
|
||||||
|
if action == "list_sessions":
|
||||||
|
result = self.list_sessions(
|
||||||
|
channel_type=payload.get("channel_type"),
|
||||||
|
page=int(payload.get("page", 1)),
|
||||||
|
page_size=int(payload.get("page_size", 50)),
|
||||||
|
)
|
||||||
|
return {"action": action, "code": 200, "message": "success", "payload": result}
|
||||||
|
|
||||||
|
elif action == "delete_session":
|
||||||
|
self.delete_session(payload.get("session_id", ""))
|
||||||
|
return {"action": action, "code": 200, "message": "success", "payload": None}
|
||||||
|
|
||||||
|
elif action == "rename_session":
|
||||||
|
self.rename_session(
|
||||||
|
payload.get("session_id", ""),
|
||||||
|
payload.get("title", "").strip(),
|
||||||
|
)
|
||||||
|
return {"action": action, "code": 200, "message": "success", "payload": None}
|
||||||
|
|
||||||
|
elif action == "clear_context":
|
||||||
|
new_seq = self.clear_context(payload.get("session_id", ""))
|
||||||
|
return {"action": action, "code": 200, "message": "success",
|
||||||
|
"payload": {"context_start_seq": new_seq}}
|
||||||
|
|
||||||
|
elif action == "generate_title":
|
||||||
|
title = self.gen_title(
|
||||||
|
payload.get("session_id", ""),
|
||||||
|
payload.get("user_message", ""),
|
||||||
|
payload.get("assistant_reply", ""),
|
||||||
|
)
|
||||||
|
return {"action": action, "code": 200, "message": "success",
|
||||||
|
"payload": {"title": title}}
|
||||||
|
|
||||||
|
else:
|
||||||
|
return {"action": action, "code": 400,
|
||||||
|
"message": f"unknown action: {action}", "payload": None}
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
return {"action": action, "code": 400, "message": str(e), "payload": None}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[SessionService] dispatch error: action={action}, error={e}")
|
||||||
|
return {"action": action, "code": 500, "message": str(e), "payload": None}
|
||||||
@@ -91,39 +91,9 @@ def _get_upload_dir() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _generate_session_title(user_message: str, assistant_reply: str = "") -> str:
|
def _generate_session_title(user_message: str, assistant_reply: str = "") -> str:
|
||||||
"""
|
"""Delegate to the shared SessionService implementation."""
|
||||||
Generate a short session title by calling the current bot's reply_text.
|
from agent.chat.session_service import generate_session_title
|
||||||
"""
|
return generate_session_title(user_message, assistant_reply)
|
||||||
import re
|
|
||||||
fallback = user_message[:50].split("\n")[0].strip() or "New Chat"
|
|
||||||
try:
|
|
||||||
from bridge.bridge import Bridge
|
|
||||||
from models.session_manager import Session
|
|
||||||
bot = Bridge().get_bot("chat")
|
|
||||||
|
|
||||||
prompt_parts = [f"User: {user_message[:300]}"]
|
|
||||||
if assistant_reply:
|
|
||||||
prompt_parts.append(f"Assistant: {assistant_reply[:300]}")
|
|
||||||
|
|
||||||
session = Session("__title_gen__", system_prompt="")
|
|
||||||
session.messages = [
|
|
||||||
{"role": "user", "content": (
|
|
||||||
"Generate a very short title (max 15 characters for Chinese, max 6 words for English) "
|
|
||||||
"summarizing this conversation. Return ONLY the title text, nothing else.\n\n"
|
|
||||||
+ "\n".join(prompt_parts)
|
|
||||||
)}
|
|
||||||
]
|
|
||||||
|
|
||||||
result = bot.reply_text(session)
|
|
||||||
raw = (result.get("content") or "").strip()
|
|
||||||
# Strip <think>...</think> reasoning blocks
|
|
||||||
title = re.sub(r'<think>.*?</think>', '', raw, flags=re.DOTALL).strip().strip('"\'')
|
|
||||||
logger.info(f"[WebChannel] Title generation result: '{title}' (len={len(title)})")
|
|
||||||
if title and len(title) <= 50:
|
|
||||||
return title
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[WebChannel] Title generation failed: {e}")
|
|
||||||
return fallback
|
|
||||||
|
|
||||||
|
|
||||||
class WebMessage(ChatMessage):
|
class WebMessage(ChatMessage):
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ class CloudClient(LinkAIClient):
|
|||||||
self._memory_service = None
|
self._memory_service = None
|
||||||
self._knowledge_service = None
|
self._knowledge_service = None
|
||||||
self._chat_service = None
|
self._chat_service = None
|
||||||
|
self._session_service = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def skill_service(self):
|
def skill_service(self):
|
||||||
@@ -118,6 +119,18 @@ class CloudClient(LinkAIClient):
|
|||||||
logger.error(f"[CloudClient] Failed to init ChatService: {e}")
|
logger.error(f"[CloudClient] Failed to init ChatService: {e}")
|
||||||
return self._chat_service
|
return self._chat_service
|
||||||
|
|
||||||
|
@property
|
||||||
|
def session_service(self):
|
||||||
|
"""Lazy-init SessionService."""
|
||||||
|
if self._session_service is None:
|
||||||
|
try:
|
||||||
|
from agent.chat.session_service import SessionService
|
||||||
|
self._session_service = SessionService()
|
||||||
|
logger.debug("[CloudClient] SessionService initialised")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[CloudClient] Failed to init SessionService: {e}")
|
||||||
|
return self._session_service
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# message push callback
|
# message push callback
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -546,12 +559,23 @@ class CloudClient(LinkAIClient):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# history callback
|
# history callback
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
# Session-related actions handled via the HISTORY channel
|
||||||
|
_SESSION_ACTIONS = {
|
||||||
|
"list_sessions", "delete_session", "rename_session",
|
||||||
|
"clear_context", "generate_title",
|
||||||
|
}
|
||||||
|
|
||||||
def on_history(self, data: dict) -> dict:
|
def on_history(self, data: dict) -> dict:
|
||||||
"""
|
"""
|
||||||
Handle HISTORY messages from the cloud console.
|
Handle HISTORY messages from the cloud console.
|
||||||
Returns paginated conversation history for a session.
|
|
||||||
|
|
||||||
:param data: message data with 'action' and 'payload' (session_id, page, page_size)
|
Supports both history query and session management actions
|
||||||
|
through a unified HISTORY message channel:
|
||||||
|
- query: paginated conversation history
|
||||||
|
- list_sessions / delete_session / rename_session /
|
||||||
|
clear_context / generate_title: session lifecycle
|
||||||
|
|
||||||
|
:param data: message data with 'action' and 'payload'
|
||||||
:return: response dict
|
:return: response dict
|
||||||
"""
|
"""
|
||||||
action = data.get("action", "query")
|
action = data.get("action", "query")
|
||||||
@@ -561,8 +585,19 @@ class CloudClient(LinkAIClient):
|
|||||||
if action == "query":
|
if action == "query":
|
||||||
return self._query_history(payload)
|
return self._query_history(payload)
|
||||||
|
|
||||||
|
if action in self._SESSION_ACTIONS:
|
||||||
|
return self._dispatch_session(action, payload)
|
||||||
|
|
||||||
return {"action": action, "code": 404, "message": f"unknown action: {action}", "payload": None}
|
return {"action": action, "code": 404, "message": f"unknown action: {action}", "payload": None}
|
||||||
|
|
||||||
|
def _dispatch_session(self, action: str, payload: dict) -> dict:
|
||||||
|
"""Delegate session actions to SessionService."""
|
||||||
|
svc = self.session_service
|
||||||
|
if svc is None:
|
||||||
|
return {"action": action, "code": 500,
|
||||||
|
"message": "SessionService not available", "payload": None}
|
||||||
|
return svc.dispatch(action, payload)
|
||||||
|
|
||||||
def _query_history(self, payload: dict) -> dict:
|
def _query_history(self, payload: dict) -> dict:
|
||||||
"""Query paginated conversation history using ConversationStore."""
|
"""Query paginated conversation history using ConversationStore."""
|
||||||
session_id = payload.get("session_id", "")
|
session_id = payload.get("session_id", "")
|
||||||
|
|||||||
Reference in New Issue
Block a user