Merge pull request #3678 from AstrBotDevs/refactor/webchat-session

refactor: Implement WebChat session management and migration
This commit is contained in:
Soulter
2025-11-20 17:23:10 +08:00
committed by GitHub
12 changed files with 708 additions and 182 deletions

View File

@@ -22,6 +22,7 @@ from astrbot.core.config.default import VERSION
from astrbot.core.conversation_mgr import ConversationManager
from astrbot.core.db import BaseDatabase
from astrbot.core.db.migration.migra_45_to_46 import migrate_45_to_46
from astrbot.core.db.migration.migra_webchat_session import migrate_webchat_session
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
from astrbot.core.persona_mgr import PersonaManager
from astrbot.core.pipeline.scheduler import PipelineContext, PipelineScheduler
@@ -103,6 +104,13 @@ class AstrBotCoreLifecycle:
logger.error(f"Migration from version 4.5 to 4.6 failed: {e!s}")
logger.error(traceback.format_exc())
# migration for webchat session
try:
await migrate_webchat_session(self.db)
except Exception as e:
logger.error(f"Migration for webchat session failed: {e!s}")
logger.error(traceback.format_exc())
# 初始化事件队列
self.event_queue = Queue()

View File

@@ -13,6 +13,7 @@ from astrbot.core.db.po import (
ConversationV2,
Persona,
PlatformMessageHistory,
PlatformSession,
PlatformStat,
Preference,
Stats,
@@ -183,7 +184,7 @@ class BaseDatabase(abc.ABC):
user_id: str,
offset_sec: int = 86400,
) -> None:
"""Delete platform message history records older than the specified offset."""
"""Delete platform message history records newer than the specified offset."""
...
@abc.abstractmethod
@@ -313,3 +314,51 @@ class BaseDatabase(abc.ABC):
) -> tuple[list[dict], int]:
"""Get paginated session conversations with joined conversation and persona details, support search and platform filter."""
...
# ====
# Platform Session Management
# ====
@abc.abstractmethod
async def create_platform_session(
self,
creator: str,
platform_id: str = "webchat",
session_id: str | None = None,
display_name: str | None = None,
is_group: int = 0,
) -> PlatformSession:
"""Create a new Platform session."""
...
@abc.abstractmethod
async def get_platform_session_by_id(
self, session_id: str
) -> PlatformSession | None:
"""Get a Platform session by its ID."""
...
@abc.abstractmethod
async def get_platform_sessions_by_creator(
self,
creator: str,
platform_id: str | None = None,
page: int = 1,
page_size: int = 20,
) -> list[PlatformSession]:
"""Get all Platform sessions for a specific creator (username) and optionally platform."""
...
@abc.abstractmethod
async def update_platform_session(
self,
session_id: str,
display_name: str | None = None,
) -> None:
"""Update a Platform session's updated_at timestamp and optionally display_name."""
...
@abc.abstractmethod
async def delete_platform_session(self, session_id: str) -> None:
"""Delete a Platform session by its ID."""
...

View File

@@ -0,0 +1,131 @@
"""Migration script for WebChat sessions.
This migration creates PlatformSession from existing platform_message_history records.
Changes:
- Creates platform_sessions table
- Adds platform_id field (default: 'webchat')
- Adds display_name field
- Session_id format: {platform_id}_{uuid}
"""
from sqlalchemy import func, select
from sqlmodel import col
from astrbot.api import logger, sp
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import ConversationV2, PlatformMessageHistory, PlatformSession
async def migrate_webchat_session(db_helper: BaseDatabase):
"""Create PlatformSession records from platform_message_history.
This migration extracts all unique user_ids from platform_message_history
where platform_id='webchat' and creates corresponding PlatformSession records.
"""
# 检查是否已经完成迁移
migration_done = await db_helper.get_preference(
"global", "global", "migration_done_webchat_session"
)
if migration_done:
return
logger.info("开始执行数据库迁移WebChat 会话迁移)...")
try:
async with db_helper.get_db() as session:
# 从 platform_message_history 创建 PlatformSession
query = (
select(
col(PlatformMessageHistory.user_id),
col(PlatformMessageHistory.sender_name),
func.min(PlatformMessageHistory.created_at).label("earliest"),
func.max(PlatformMessageHistory.updated_at).label("latest"),
)
.where(col(PlatformMessageHistory.platform_id) == "webchat")
.where(col(PlatformMessageHistory.sender_id) == "astrbot")
.group_by(col(PlatformMessageHistory.user_id))
)
result = await session.execute(query)
webchat_users = result.all()
if not webchat_users:
logger.info("没有找到需要迁移的 WebChat 数据")
await sp.put_async(
"global", "global", "migration_done_webchat_session", True
)
return
logger.info(f"找到 {len(webchat_users)} 个 WebChat 会话需要迁移")
# 检查已存在的会话
existing_query = select(col(PlatformSession.session_id))
existing_result = await session.execute(existing_query)
existing_session_ids = {row[0] for row in existing_result.fetchall()}
# 查询 Conversations 表中的 title用于设置 display_name
# 对于每个 user_id对应的 conversation user_id 格式为: webchat:FriendMessage:webchat!astrbot!{user_id}
user_ids_to_query = [
f"webchat:FriendMessage:webchat!astrbot!{user_id}"
for user_id, _, _, _ in webchat_users
]
conv_query = select(
col(ConversationV2.user_id), col(ConversationV2.title)
).where(col(ConversationV2.user_id).in_(user_ids_to_query))
conv_result = await session.execute(conv_query)
# 创建 user_id -> title 的映射字典
title_map = {
user_id.replace("webchat:FriendMessage:webchat!astrbot!", ""): title
for user_id, title in conv_result.fetchall()
}
# 批量创建 PlatformSession 记录
sessions_to_add = []
skipped_count = 0
for user_id, sender_name, created_at, updated_at in webchat_users:
# user_id 就是 webchat_conv_id (session_id)
session_id = user_id
# sender_name 通常是 username但可能为 None
creator = sender_name if sender_name else "guest"
# 检查是否已经存在该会话
if session_id in existing_session_ids:
logger.debug(f"会话 {session_id} 已存在,跳过")
skipped_count += 1
continue
# 从 Conversations 表中获取 display_name
display_name = title_map.get(user_id)
# 创建新的 PlatformSession保留原有的时间戳
new_session = PlatformSession(
session_id=session_id,
platform_id="webchat",
creator=creator,
is_group=0,
created_at=created_at,
updated_at=updated_at,
display_name=display_name,
)
sessions_to_add.append(new_session)
# 批量插入
if sessions_to_add:
session.add_all(sessions_to_add)
await session.commit()
logger.info(
f"WebChat 会话迁移完成!成功迁移: {len(sessions_to_add)}, 跳过: {skipped_count}",
)
else:
logger.info("没有新会话需要迁移")
# 标记迁移完成
await sp.put_async("global", "global", "migration_done_webchat_session", True)
except Exception as e:
logger.error(f"迁移过程中发生错误: {e}", exc_info=True)
raise

View File

@@ -155,6 +155,48 @@ class PlatformMessageHistory(SQLModel, table=True):
)
class PlatformSession(SQLModel, table=True):
"""Platform session table for managing user sessions across different platforms.
A session represents a chat window for a specific user on a specific platform.
Each session can have multiple conversations (对话) associated with it.
"""
__tablename__ = "platform_sessions" # type: ignore
inner_id: int | None = Field(
primary_key=True,
sa_column_kwargs={"autoincrement": True},
default=None,
)
session_id: str = Field(
max_length=100,
nullable=False,
unique=True,
default_factory=lambda: f"webchat_{uuid.uuid4()}",
)
platform_id: str = Field(default="webchat", nullable=False)
"""Platform identifier (e.g., 'webchat', 'qq', 'discord')"""
creator: str = Field(nullable=False)
"""Username of the session creator"""
display_name: str | None = Field(default=None, max_length=255)
"""Display name for the session"""
is_group: int = Field(default=0, nullable=False)
"""0 for private chat, 1 for group chat (not implemented yet)"""
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = (
UniqueConstraint(
"session_id",
name="uix_platform_session_id",
),
)
class Attachment(SQLModel, table=True):
"""This class represents attachments for messages in AstrBot.

View File

@@ -1,7 +1,7 @@
import asyncio
import threading
import typing as T
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import col, delete, desc, func, or_, select, text, update
@@ -12,6 +12,7 @@ from astrbot.core.db.po import (
ConversationV2,
Persona,
PlatformMessageHistory,
PlatformSession,
PlatformStat,
Preference,
SQLModel,
@@ -412,7 +413,7 @@ class SQLiteDatabase(BaseDatabase):
user_id,
offset_sec=86400,
):
"""Delete platform message history records older than the specified offset."""
"""Delete platform message history records newer than the specified offset."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
@@ -422,7 +423,7 @@ class SQLiteDatabase(BaseDatabase):
delete(PlatformMessageHistory).where(
col(PlatformMessageHistory.platform_id) == platform_id,
col(PlatformMessageHistory.user_id) == user_id,
col(PlatformMessageHistory.created_at) < cutoff_time,
col(PlatformMessageHistory.created_at) >= cutoff_time,
),
)
@@ -709,3 +710,101 @@ class SQLiteDatabase(BaseDatabase):
t.start()
t.join()
return result
# ====
# Platform Session Management
# ====
async def create_platform_session(
self,
creator: str,
platform_id: str = "webchat",
session_id: str | None = None,
display_name: str | None = None,
is_group: int = 0,
) -> PlatformSession:
"""Create a new Platform session."""
kwargs = {}
if session_id:
kwargs["session_id"] = session_id
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
new_session = PlatformSession(
creator=creator,
platform_id=platform_id,
display_name=display_name,
is_group=is_group,
**kwargs,
)
session.add(new_session)
await session.flush()
await session.refresh(new_session)
return new_session
async def get_platform_session_by_id(
self, session_id: str
) -> PlatformSession | None:
"""Get a Platform session by its ID."""
async with self.get_db() as session:
session: AsyncSession
query = select(PlatformSession).where(
PlatformSession.session_id == session_id,
)
result = await session.execute(query)
return result.scalar_one_or_none()
async def get_platform_sessions_by_creator(
self,
creator: str,
platform_id: str | None = None,
page: int = 1,
page_size: int = 20,
) -> list[PlatformSession]:
"""Get all Platform sessions for a specific creator (username) and optionally platform."""
async with self.get_db() as session:
session: AsyncSession
offset = (page - 1) * page_size
query = select(PlatformSession).where(PlatformSession.creator == creator)
if platform_id:
query = query.where(PlatformSession.platform_id == platform_id)
query = (
query.order_by(desc(PlatformSession.updated_at))
.offset(offset)
.limit(page_size)
)
result = await session.execute(query)
return list(result.scalars().all())
async def update_platform_session(
self,
session_id: str,
display_name: str | None = None,
) -> None:
"""Update a Platform session's updated_at timestamp and optionally display_name."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
values: dict[str, T.Any] = {"updated_at": datetime.now(timezone.utc)}
if display_name is not None:
values["display_name"] = display_name
await session.execute(
update(PlatformSession)
.where(col(PlatformSession.session_id == session_id))
.values(**values),
)
async def delete_platform_session(self, session_id: str) -> None:
"""Delete a Platform session by its ID."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
await session.execute(
delete(PlatformSession).where(
col(PlatformSession.session_id == session_id),
),
)

View File

@@ -10,7 +10,6 @@ from quart import g, make_response, request
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
from astrbot.core.platform.astr_message_event import MessageSession
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
@@ -36,11 +35,14 @@ class ChatRoute(Route):
super().__init__(context)
self.routes = {
"/chat/send": ("POST", self.chat),
"/chat/new_conversation": ("GET", self.new_conversation),
"/chat/conversations": ("GET", self.get_conversations),
"/chat/get_conversation": ("GET", self.get_conversation),
"/chat/delete_conversation": ("GET", self.delete_conversation),
"/chat/rename_conversation": ("POST", self.rename_conversation),
"/chat/new_session": ("GET", self.new_session),
"/chat/sessions": ("GET", self.get_sessions),
"/chat/get_session": ("GET", self.get_session),
"/chat/delete_session": ("GET", self.delete_webchat_session),
"/chat/update_session_display_name": (
"POST",
self.update_session_display_name,
),
"/chat/get_file": ("GET", self.get_file),
"/chat/post_image": ("POST", self.post_image),
"/chat/post_file": ("POST", self.post_file),
@@ -53,6 +55,7 @@ class ChatRoute(Route):
self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"]
self.conv_mgr = core_lifecycle.conversation_manager
self.platform_history_mgr = core_lifecycle.platform_message_history_manager
self.db = db
self.running_convs: dict[str, bool] = {}
@@ -116,11 +119,14 @@ class ChatRoute(Route):
if "message" not in post_data and "image_url" not in post_data:
return Response().error("Missing key: message or image_url").__dict__
if "conversation_id" not in post_data:
return Response().error("Missing key: conversation_id").__dict__
if "session_id" not in post_data and "conversation_id" not in post_data:
return (
Response().error("Missing key: session_id or conversation_id").__dict__
)
message = post_data["message"]
conversation_id = post_data["conversation_id"]
# conversation_id = post_data["conversation_id"]
session_id = post_data.get("session_id", post_data.get("conversation_id"))
image_url = post_data.get("image_url")
audio_url = post_data.get("audio_url")
selected_provider = post_data.get("selected_provider")
@@ -133,11 +139,11 @@ class ChatRoute(Route):
.error("Message and image_url and audio_url are empty")
.__dict__
)
if not conversation_id:
return Response().error("conversation_id is empty").__dict__
if not session_id:
return Response().error("session_id is empty").__dict__
# 追加用户消息
webchat_conv_id = await self._get_webchat_conv_id_from_conv_id(conversation_id)
webchat_conv_id = session_id
# 获取会话特定的队列
back_queue = webchat_queue_mgr.get_or_create_back_queue(webchat_conv_id)
@@ -245,88 +251,110 @@ class ChatRoute(Route):
response.timeout = None # fix SSE auto disconnect issue
return response
async def _get_webchat_conv_id_from_conv_id(self, conversation_id: str) -> str:
"""从对话 ID 中提取 WebChat 会话 ID
NOTE: 关于这里为什么要单独做一个 WebChat 的 Conversation ID 出来,这个是为了向前兼容。
"""
conversation = await self.conv_mgr.get_conversation(
unified_msg_origin="webchat",
conversation_id=conversation_id,
)
if not conversation:
raise ValueError(f"Conversation with ID {conversation_id} not found.")
conv_user_id = conversation.user_id
webchat_session_id = MessageSession.from_str(conv_user_id).session_id
if "!" not in webchat_session_id:
raise ValueError(f"Invalid conv user ID: {conv_user_id}")
return webchat_session_id.split("!")[-1]
async def delete_conversation(self):
conversation_id = request.args.get("conversation_id")
if not conversation_id:
return Response().error("Missing key: conversation_id").__dict__
async def delete_webchat_session(self):
"""Delete a Platform session and all its related data."""
session_id = request.args.get("session_id")
if not session_id:
return Response().error("Missing key: session_id").__dict__
username = g.get("username", "guest")
# Clean up queues when deleting conversation
webchat_queue_mgr.remove_queues(conversation_id)
webchat_conv_id = await self._get_webchat_conv_id_from_conv_id(conversation_id)
await self.conv_mgr.delete_conversation(
unified_msg_origin=f"webchat:FriendMessage:webchat!{username}!{webchat_conv_id}",
conversation_id=conversation_id,
)
# 验证会话是否存在且属于当前用户
session = await self.db.get_platform_session_by_id(session_id)
if not session:
return Response().error(f"Session {session_id} not found").__dict__
if session.creator != username:
return Response().error("Permission denied").__dict__
# 删除该会话下的所有对话
unified_msg_origin = f"{session.platform_id}:FriendMessage:{session.platform_id}!{username}!{session_id}"
await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin)
# 删除消息历史
await self.platform_history_mgr.delete(
platform_id="webchat",
user_id=webchat_conv_id,
platform_id=session.platform_id,
user_id=session_id,
offset_sec=99999999,
)
# 清理队列(仅对 webchat
if session.platform_id == "webchat":
webchat_queue_mgr.remove_queues(session_id)
# 删除会话
await self.db.delete_platform_session(session_id)
return Response().ok().__dict__
async def new_conversation(self):
async def new_session(self):
"""Create a new Platform session (default: webchat)."""
username = g.get("username", "guest")
webchat_conv_id = str(uuid.uuid4())
conv_id = await self.conv_mgr.new_conversation(
unified_msg_origin=f"webchat:FriendMessage:webchat!{username}!{webchat_conv_id}",
platform_id="webchat",
content=[],
# 获取可选的 platform_id 参数,默认为 webchat
platform_id = request.args.get("platform_id", "webchat")
# 创建新会话
session = await self.db.create_platform_session(
creator=username,
platform_id=platform_id,
is_group=0,
)
return Response().ok(data={"conversation_id": conv_id}).__dict__
async def rename_conversation(self):
post_data = await request.json
if "conversation_id" not in post_data or "title" not in post_data:
return Response().error("Missing key: conversation_id or title").__dict__
conversation_id = post_data["conversation_id"]
title = post_data["title"]
await self.conv_mgr.update_conversation(
unified_msg_origin="webchat", # fake
conversation_id=conversation_id,
title=title,
return (
Response()
.ok(
data={
"session_id": session.session_id,
"platform_id": session.platform_id,
}
)
.__dict__
)
return Response().ok(message="重命名成功!").__dict__
async def get_conversations(self):
conversations = await self.conv_mgr.get_conversations(platform_id="webchat")
# remove content
conversations_ = []
for conv in conversations:
conv.history = None
conversations_.append(conv)
return Response().ok(data=conversations_).__dict__
async def get_sessions(self):
"""Get all Platform sessions for the current user."""
username = g.get("username", "guest")
async def get_conversation(self):
conversation_id = request.args.get("conversation_id")
if not conversation_id:
return Response().error("Missing key: conversation_id").__dict__
# 获取可选的 platform_id 参数
platform_id = request.args.get("platform_id")
webchat_conv_id = await self._get_webchat_conv_id_from_conv_id(conversation_id)
sessions = await self.db.get_platform_sessions_by_creator(
creator=username,
platform_id=platform_id,
page=1,
page_size=100, # 暂时返回前100个
)
# Get platform message history
# 转换为字典格式,并添加额外信息
sessions_data = []
for session in sessions:
sessions_data.append(
{
"session_id": session.session_id,
"platform_id": session.platform_id,
"creator": session.creator,
"display_name": session.display_name,
"is_group": session.is_group,
"created_at": session.created_at.astimezone().isoformat(),
"updated_at": session.updated_at.astimezone().isoformat(),
}
)
return Response().ok(data=sessions_data).__dict__
async def get_session(self):
"""Get session information and message history by session_id."""
session_id = request.args.get("session_id")
if not session_id:
return Response().error("Missing key: session_id").__dict__
# 获取会话信息以确定 platform_id
session = await self.db.get_platform_session_by_id(session_id)
platform_id = session.platform_id if session else "webchat"
# Get platform message history using session_id
history_ls = await self.platform_history_mgr.get(
platform_id="webchat",
user_id=webchat_conv_id,
platform_id=platform_id,
user_id=session_id,
page=1,
page_size=1000,
)
@@ -338,8 +366,37 @@ class ChatRoute(Route):
.ok(
data={
"history": history_res,
"is_running": self.running_convs.get(webchat_conv_id, False),
"is_running": self.running_convs.get(session_id, False),
},
)
.__dict__
)
async def update_session_display_name(self):
"""Update a Platform session's display name."""
post_data = await request.json
session_id = post_data.get("session_id")
display_name = post_data.get("display_name")
if not session_id:
return Response().error("Missing key: session_id").__dict__
if display_name is None:
return Response().error("Missing key: display_name").__dict__
username = g.get("username", "guest")
# 验证会话是否存在且属于当前用户
session = await self.db.get_platform_session_by_id(session_id)
if not session:
return Response().error(f"Session {session_id} not found").__dict__
if session.creator != username:
return Response().error("Permission denied").__dict__
# 更新 display_name
await self.db.update_platform_session(
session_id=session_id,
display_name=display_name,
)
return Response().ok().__dict__

View File

@@ -6,9 +6,9 @@
<div class="chat-layout">
<ConversationSidebar
:conversations="conversations"
:selectedConversations="selectedConversations"
:currCid="currCid"
:sessions="sessions"
:selectedSessions="selectedSessions"
:currSessionId="currSessionId"
:isDark="isDark"
:chatboxMode="chatboxMode"
:isMobile="isMobile"
@@ -40,7 +40,7 @@
<v-tooltip :text="tm('actions.fullscreen')" v-if="!chatboxMode">
<template v-slot:activator="{ props }">
<v-icon v-bind="props"
@click="router.push(currCid ? `/chatbox/${currCid}` : '/chatbox')"
@click="router.push(currSessionId ? `/chatbox/${currSessionId}` : '/chatbox')"
class="fullscreen-icon">mdi-fullscreen</v-icon>
</template>
</v-tooltip>
@@ -62,7 +62,7 @@
<!-- router 推送到 /chat -->
<v-tooltip :text="tm('actions.exitFullscreen')" v-if="chatboxMode">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" @click="router.push(currCid ? `/chat/${currCid}` : '/chat')"
<v-icon v-bind="props" @click="router.push(currSessionId ? `/chat/${currSessionId}` : '/chat')"
class="fullscreen-icon">mdi-fullscreen-exit</v-icon>
</template>
</v-tooltip>
@@ -142,7 +142,7 @@ import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
import MessageList from '@/components/chat/MessageList.vue';
import ConversationSidebar from '@/components/chat/ConversationSidebar.vue';
import ChatInput from '@/components/chat/ChatInput.vue';
import { useConversations } from '@/composables/useConversations';
import { useSessions } from '@/composables/useSessions';
import { useMessages } from '@/composables/useMessages';
import { useMediaHandling } from '@/composables/useMediaHandling';
import { useRecording } from '@/composables/useRecording';
@@ -169,22 +169,22 @@ const previewImageUrl = ref('');
// 使用 composables
const {
conversations,
selectedConversations,
currCid,
pendingCid,
sessions,
selectedSessions,
currSessionId,
pendingSessionId,
editTitleDialog,
editingTitle,
editingCid,
getCurrentConversation,
getConversations,
newConversation,
deleteConversation: deleteConv,
editingSessionId,
getCurrentSession,
getSessions,
newSession,
deleteSession: deleteSessionFn,
showEditTitleDialog,
saveTitle,
updateConversationTitle,
updateSessionTitle,
newChat
} = useConversations(props.chatboxMode);
} = useSessions(props.chatboxMode);
const {
stagedImagesName,
@@ -206,10 +206,10 @@ const {
isStreaming,
isConvRunning,
enableStreaming,
getConversationMessages: getConvMessages,
getSessionMessages: getSessionMsg,
sendMessage: sendMsg,
toggleStreaming
} = useMessages(currCid, getMediaFile, updateConversationTitle, getConversations);
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
// 组件引用
const messageList = ref<InstanceType<typeof MessageList> | null>(null);
@@ -248,13 +248,13 @@ function openImagePreview(imageUrl: string) {
imagePreviewDialog.value = true;
}
async function handleSelectConversation(cids: string[]) {
if (!cids[0]) return;
async function handleSelectConversation(sessionIds: string[]) {
if (!sessionIds[0]) return;
// 更新 URL
const basePath = props.chatboxMode ? '/chatbox' : '/chat';
if (route.path !== `${basePath}/${cids[0]}`) {
router.push(`${basePath}/${cids[0]}`);
if (route.path !== `${basePath}/${sessionIds[0]}`) {
router.push(`${basePath}/${sessionIds[0]}`);
return;
}
@@ -263,10 +263,10 @@ async function handleSelectConversation(cids: string[]) {
closeMobileSidebar();
}
currCid.value = cids[0];
selectedConversations.value = [cids[0]];
currSessionId.value = sessionIds[0];
selectedSessions.value = [sessionIds[0]];
await getConvMessages(cids[0], router);
await getSessionMsg(sessionIds[0], router);
nextTick(() => {
messageList.value?.scrollToBottom();
@@ -278,8 +278,8 @@ function handleNewChat() {
messages.value = [];
}
async function handleDeleteConversation(cid: string) {
await deleteConv(cid);
async function handleDeleteConversation(sessionId: string) {
await deleteSessionFn(sessionId);
messages.value = [];
}
@@ -303,8 +303,8 @@ async function handleSendMessage() {
return;
}
if (!currCid.value) {
await newConversation();
if (!currSessionId.value) {
await newSession();
}
const promptToSend = prompt.value.trim();
@@ -340,15 +340,15 @@ watch(
}
if (to.startsWith('/chat/') || to.startsWith('/chatbox/')) {
const pathCid = to.split('/')[2];
if (pathCid && pathCid !== currCid.value) {
if (conversations.value.length > 0) {
const conversation = conversations.value.find(c => c.cid === pathCid);
if (conversation) {
handleSelectConversation([pathCid]);
const pathSessionId = to.split('/')[2];
if (pathSessionId && pathSessionId !== currSessionId.value) {
if (sessions.value.length > 0) {
const session = sessions.value.find(s => s.session_id === pathSessionId);
if (session) {
handleSelectConversation([pathSessionId]);
}
} else {
pendingCid.value = pathCid;
pendingSessionId.value = pathSessionId;
}
}
}
@@ -357,25 +357,25 @@ watch(
);
// 会话列表加载后处理待定会话
watch(conversations, (newConversations) => {
if (pendingCid.value && newConversations.length > 0) {
const conversation = newConversations.find(c => c.cid === pendingCid.value);
if (conversation) {
selectedConversations.value = [pendingCid.value];
handleSelectConversation([pendingCid.value]);
pendingCid.value = null;
watch(sessions, (newSessions) => {
if (pendingSessionId.value && newSessions.length > 0) {
const session = newSessions.find(s => s.session_id === pendingSessionId.value);
if (session) {
selectedSessions.value = [pendingSessionId.value];
handleSelectConversation([pendingSessionId.value]);
pendingSessionId.value = null;
}
} else if (!currCid.value && newConversations.length > 0) {
const firstConversation = newConversations[0];
selectedConversations.value = [firstConversation.cid];
handleSelectConversation([firstConversation.cid]);
} else if (!currSessionId.value && newSessions.length > 0) {
const firstSession = newSessions[0];
selectedSessions.value = [firstSession.session_id];
handleSelectConversation([firstSession.session_id]);
}
});
onMounted(() => {
checkMobile();
window.addEventListener('resize', checkMobile);
getConversations();
getSessions();
});
onBeforeUnmount(() => {
@@ -506,4 +506,4 @@ onBeforeUnmount(() => {
padding: 0 !important;
}
}
</style>
</style>

View File

@@ -31,10 +31,10 @@
</div>
<div style="padding: 16px; padding-top: 8px;">
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currCid"
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId"
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-plus"
style="background-color: transparent !important; border-radius: 4px;">{{ tm('actions.newChat') }}</v-btn>
<v-btn icon="mdi-plus" rounded="lg" @click="$emit('newChat')" :disabled="!currCid"
<v-btn icon="mdi-plus" rounded="lg" @click="$emit('newChat')" :disabled="!currSessionId"
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
</div>
@@ -44,27 +44,27 @@
<div style="overflow-y: auto; flex-grow: 1;" :class="{ 'fade-in': sidebarHoverExpanded }"
v-if="!sidebarCollapsed || isMobile">
<v-card v-if="conversations.length > 0" flat style="background-color: transparent;">
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
<v-list density="compact" nav class="conversation-list"
style="background-color: transparent;" :selected="selectedConversations"
style="background-color: transparent;" :selected="selectedSessions"
@update:selected="$emit('selectConversation', $event)">
<v-list-item v-for="item in conversations" :key="item.cid" :value="item.cid"
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
rounded="lg" class="conversation-item" active-color="secondary">
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title">
{{ item.title || tm('conversation.newConversation') }}
{{ item.display_name || tm('conversation.newConversation') }}
</v-list-item-title>
<v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
{{ formatDate(item.updated_at) }}
{{ new Date(item.updated_at).toLocaleString() }}
</v-list-item-subtitle>
<template v-if="!sidebarCollapsed || isMobile" v-slot:append>
<div class="conversation-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-title-btn"
@click.stop="$emit('editTitle', item.cid, item.title)" />
@click.stop="$emit('editTitle', item.session_id, item.display_name)" />
<v-btn icon="mdi-delete" size="x-small" variant="text"
class="delete-conversation-btn" color="error"
@click.stop="$emit('deleteConversation', item.cid)" />
@click.stop="$emit('deleteConversation', item.session_id)" />
</div>
</template>
</v-list-item>
@@ -72,7 +72,7 @@
</v-card>
<v-fade-transition>
<div class="no-conversations" v-if="conversations.length === 0">
<div class="no-conversations" v-if="sessions.length === 0">
<v-icon icon="mdi-message-text-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="no-conversations-text" v-if="!sidebarCollapsed || sidebarHoverExpanded || isMobile">
{{ tm('conversation.noHistory') }}
@@ -86,12 +86,12 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import type { Conversation } from '@/composables/useConversations';
import type { Session } from '@/composables/useSessions';
interface Props {
conversations: Conversation[];
selectedConversations: string[];
currCid: string;
sessions: Session[];
selectedSessions: string[];
currSessionId: string;
isDark: boolean;
chatboxMode: boolean;
isMobile: boolean;
@@ -102,9 +102,9 @@ const props = defineProps<Props>();
const emit = defineEmits<{
newChat: [];
selectConversation: [cids: string[]];
editTitle: [cid: string, title: string];
deleteConversation: [cid: string];
selectConversation: [sessionIds: string[]];
editTitle: [sessionId: string, title: string];
deleteConversation: [sessionId: string];
closeMobileSidebar: [];
}>();
@@ -159,21 +159,6 @@ function handleSidebarMouseLeave() {
}
sidebarHoverExpanded.value = false;
}
function formatDate(timestamp: number): string {
const date = new Date(timestamp * 1000);
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
};
const locale = t('core.common.locale') || 'zh-CN';
return date.toLocaleString(locale, options).replace(/\//g, '-').replace(/, /g, ' ');
}
</script>
<style scoped>
@@ -308,3 +293,4 @@ function formatDate(timestamp: number): string {
}
}
</style>

View File

@@ -18,10 +18,10 @@ export interface Message {
}
export function useMessages(
currCid: Ref<string>,
currSessionId: Ref<string>,
getMediaFile: (filename: string) => Promise<string>,
updateConversationTitle: (cid: string, title: string) => void,
onConversationsUpdate: () => void
updateSessionTitle: (sessionId: string, title: string) => void,
onSessionsUpdate: () => void
) {
const messages = ref<Message[]>([]);
const isStreaming = ref(false);
@@ -41,23 +41,23 @@ export function useMessages(
localStorage.setItem('enableStreaming', JSON.stringify(enableStreaming.value));
}
async function getConversationMessages(cid: string, router: any) {
if (!cid) return;
async function getSessionMessages(sessionId: string, router: any) {
if (!sessionId) return;
try {
const response = await axios.get('/api/chat/get_conversation?conversation_id=' + cid);
const response = await axios.get('/api/chat/get_session?session_id=' + sessionId);
isConvRunning.value = response.data.data.is_running || false;
let history = response.data.data.history;
if (isConvRunning.value) {
if (!isToastedRunningInfo.value) {
useToast().info("该话正在运行中。", { timeout: 5000 });
useToast().info("该话正在运行中。", { timeout: 5000 });
isToastedRunningInfo.value = true;
}
// 如果话还在运行3秒后重新获取消息
// 如果话还在运行3秒后重新获取消息
setTimeout(() => {
getConversationMessages(currCid.value, router);
getSessionMessages(currSessionId.value, router);
}, 3000);
}
@@ -159,7 +159,7 @@ export function useMessages(
},
body: JSON.stringify({
message: prompt,
conversation_id: currCid.value,
session_id: currSessionId.value,
image_url: imageNames,
audio_url: audioName ? [audioName] : [],
selected_provider: selectedProviderId,
@@ -256,7 +256,7 @@ export function useMessages(
}
}
} else if (chunk_json.type === 'update_title') {
updateConversationTitle(chunk_json.cid, chunk_json.data);
updateSessionTitle(chunk_json.session_id, chunk_json.data);
}
if ((chunk_json.type === 'break' && chunk_json.streaming) || !chunk_json.streaming) {
@@ -272,8 +272,8 @@ export function useMessages(
}
}
// 获取最新的话列表
onConversationsUpdate();
// 获取最新的话列表
onSessionsUpdate();
} catch (err) {
console.error('发送消息失败:', err);
@@ -296,8 +296,9 @@ export function useMessages(
isStreaming,
isConvRunning,
enableStreaming,
getConversationMessages,
getSessionMessages,
sendMessage,
toggleStreaming
};
}

View File

@@ -0,0 +1,145 @@
import { ref, computed } from 'vue';
import axios from 'axios';
import { useRouter } from 'vue-router';
export interface Session {
session_id: string;
display_name: string;
updated_at: string;
}
export function useSessions(chatboxMode: boolean = false) {
const router = useRouter();
const sessions = ref<Session[]>([]);
const selectedSessions = ref<string[]>([]);
const currSessionId = ref('');
const pendingSessionId = ref<string | null>(null);
// 编辑标题相关
const editTitleDialog = ref(false);
const editingTitle = ref('');
const editingSessionId = ref('');
const getCurrentSession = computed(() => {
if (!currSessionId.value) return null;
return sessions.value.find(s => s.session_id === currSessionId.value);
});
async function getSessions() {
try {
const response = await axios.get('/api/chat/sessions');
sessions.value = response.data.data;
// 处理待加载的会话
if (pendingSessionId.value) {
const session = sessions.value.find(s => s.session_id === pendingSessionId.value);
if (session) {
selectedSessions.value = [pendingSessionId.value];
pendingSessionId.value = null;
}
} else if (!currSessionId.value && sessions.value.length > 0) {
// 默认选择第一个会话
const firstSession = sessions.value[0];
selectedSessions.value = [firstSession.session_id];
}
} catch (err: any) {
if (err.response?.status === 401) {
router.push('/auth/login?redirect=/chatbox');
}
console.error(err);
}
}
async function newSession() {
try {
const response = await axios.get('/api/chat/new_session');
const sessionId = response.data.data.session_id;
currSessionId.value = sessionId;
// 更新 URL
const basePath = chatboxMode ? '/chatbox' : '/chat';
router.push(`${basePath}/${sessionId}`);
await getSessions();
return sessionId;
} catch (err) {
console.error(err);
throw err;
}
}
async function deleteSession(sessionId: string) {
try {
await axios.get('/api/chat/delete_session?session_id=' + sessionId);
await getSessions();
currSessionId.value = '';
selectedSessions.value = [];
} catch (err) {
console.error(err);
}
}
function showEditTitleDialog(sessionId: string, title: string) {
editingSessionId.value = sessionId;
editingTitle.value = title || '';
editTitleDialog.value = true;
}
async function saveTitle() {
if (!editingSessionId.value) return;
const trimmedTitle = editingTitle.value.trim();
try {
await axios.post('/api/chat/update_session_display_name', {
session_id: editingSessionId.value,
display_name: trimmedTitle
});
// 更新本地会话标题
const session = sessions.value.find(s => s.session_id === editingSessionId.value);
if (session) {
session.display_name = trimmedTitle;
}
editTitleDialog.value = false;
} catch (err) {
console.error('重命名会话失败:', err);
}
}
function updateSessionTitle(sessionId: string, title: string) {
const session = sessions.value.find(s => s.session_id === sessionId);
if (session) {
session.display_name = title;
}
}
function newChat(closeMobileSidebar?: () => void) {
currSessionId.value = '';
selectedSessions.value = [];
const basePath = chatboxMode ? '/chatbox' : '/chat';
router.push(basePath);
if (closeMobileSidebar) {
closeMobileSidebar();
}
}
return {
sessions,
selectedSessions,
currSessionId,
pendingSessionId,
editTitleDialog,
editingTitle,
editingSessionId,
getCurrentSession,
getSessions,
newSession,
deleteSession,
showEditTitleDialog,
saveTitle,
updateSessionTitle,
newChat
};
}

View File

@@ -47,7 +47,11 @@
"noHistory": "No conversation history",
"systemStatus": "System Status",
"llmService": "LLM Service",
"speechToText": "Speech to Text"
"speechToText": "Speech to Text",
"editDisplayName": "Edit Session Name",
"displayName": "Session Name",
"displayNameUpdated": "Session name updated",
"displayNameUpdateFailed": "Failed to update session name"
},
"modes": {
"darkMode": "Switch to Dark Mode",

View File

@@ -43,11 +43,15 @@
"exitFullscreen": "退出全屏"
},
"conversation": {
"newConversation": "新对话",
"newConversation": "新的聊天",
"noHistory": "暂无对话历史",
"systemStatus": "系统状态",
"llmService": "LLM 服务",
"speechToText": "语音转文本"
"speechToText": "语音转文本",
"editDisplayName": "编辑会话名称",
"displayName": "会话名称",
"displayNameUpdated": "会话名称已更新",
"displayNameUpdateFailed": "更新会话名称失败"
},
"modes": {
"darkMode": "切换到夜间模式",