Compare commits

...

2 Commits

Author SHA1 Message Date
Soulter 6750d6c238 fix: remove console log from getProjects function 2026-01-14 19:15:17 +08:00
Soulter fbecefae25 feat: chatui-project 2026-01-14 19:11:12 +08:00
20 changed files with 1708 additions and 110 deletions
+84 -2
View File
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
from astrbot.core.db.po import (
Attachment,
ChatUIProject,
CommandConfig,
CommandConflict,
ConversationV2,
@@ -17,6 +18,7 @@ from astrbot.core.db.po import (
PlatformSession,
PlatformStat,
Preference,
SessionProjectRelation,
Stats,
)
@@ -446,8 +448,11 @@ class BaseDatabase(abc.ABC):
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."""
) -> list[dict]:
"""Get all Platform sessions for a specific creator (username) and optionally platform.
Returns a list of dicts containing session info and project info (if session belongs to a project).
"""
...
@abc.abstractmethod
@@ -463,3 +468,80 @@ class BaseDatabase(abc.ABC):
async def delete_platform_session(self, session_id: str) -> None:
"""Delete a Platform session by its ID."""
...
# ====
# ChatUI Project Management
# ====
@abc.abstractmethod
async def create_chatui_project(
self,
creator: str,
title: str,
emoji: str | None = "📁",
description: str | None = None,
) -> ChatUIProject:
"""Create a new ChatUI project."""
...
@abc.abstractmethod
async def get_chatui_project_by_id(self, project_id: str) -> ChatUIProject | None:
"""Get a ChatUI project by its ID."""
...
@abc.abstractmethod
async def get_chatui_projects_by_creator(
self,
creator: str,
page: int = 1,
page_size: int = 100,
) -> list[ChatUIProject]:
"""Get all ChatUI projects for a specific creator."""
...
@abc.abstractmethod
async def update_chatui_project(
self,
project_id: str,
title: str | None = None,
emoji: str | None = None,
description: str | None = None,
) -> None:
"""Update a ChatUI project."""
...
@abc.abstractmethod
async def delete_chatui_project(self, project_id: str) -> None:
"""Delete a ChatUI project by its ID."""
...
@abc.abstractmethod
async def add_session_to_project(
self,
session_id: str,
project_id: str,
) -> SessionProjectRelation:
"""Add a session to a project."""
...
@abc.abstractmethod
async def remove_session_from_project(self, session_id: str) -> None:
"""Remove a session from its project."""
...
@abc.abstractmethod
async def get_project_sessions(
self,
project_id: str,
page: int = 1,
page_size: int = 100,
) -> list[PlatformSession]:
"""Get all sessions in a project."""
...
@abc.abstractmethod
async def get_project_by_session(
self, session_id: str, creator: str
) -> ChatUIProject | None:
"""Get the project that a session belongs to."""
...
+65
View File
@@ -239,6 +239,71 @@ class Attachment(SQLModel, table=True):
)
class ChatUIProject(SQLModel, table=True):
"""This class represents projects for organizing ChatUI conversations.
Projects allow users to group related conversations together.
"""
__tablename__: str = "chatui_projects"
inner_id: int | None = Field(
primary_key=True,
sa_column_kwargs={"autoincrement": True},
default=None,
)
project_id: str = Field(
max_length=36,
nullable=False,
unique=True,
default_factory=lambda: str(uuid.uuid4()),
)
creator: str = Field(nullable=False)
"""Username of the project creator"""
emoji: str | None = Field(default="📁", max_length=10)
"""Emoji icon for the project"""
title: str = Field(nullable=False, max_length=255)
"""Title of the project"""
description: str | None = Field(default=None, max_length=1000)
"""Description of the project"""
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(
"project_id",
name="uix_chatui_project_id",
),
)
class SessionProjectRelation(SQLModel, table=True):
"""This class represents the relationship between platform sessions and ChatUI projects."""
__tablename__: str = "session_project_relations"
id: int | None = Field(
primary_key=True,
sa_column_kwargs={"autoincrement": True},
default=None,
)
session_id: str = Field(nullable=False, max_length=100)
"""Session ID from PlatformSession"""
project_id: str = Field(nullable=False, max_length=36)
"""Project ID from ChatUIProject"""
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
__table_args__ = (
UniqueConstraint(
"session_id",
name="uix_session_project_relation",
),
)
class CommandConfig(SQLModel, table=True):
"""Per-command configuration overrides for dashboard management."""
+225 -4
View File
@@ -11,6 +11,7 @@ from sqlmodel import col, delete, desc, func, or_, select, text, update
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import (
Attachment,
ChatUIProject,
CommandConfig,
CommandConflict,
ConversationV2,
@@ -19,6 +20,7 @@ from astrbot.core.db.po import (
PlatformSession,
PlatformStat,
Preference,
SessionProjectRelation,
SQLModel,
)
from astrbot.core.db.po import (
@@ -1060,12 +1062,35 @@ class SQLiteDatabase(BaseDatabase):
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."""
) -> list[dict]:
"""Get all Platform sessions for a specific creator (username) and optionally platform.
Returns a list of dicts containing session info and project info (if session belongs to a project).
"""
async with self.get_db() as session:
session: AsyncSession
offset = (page - 1) * page_size
query = select(PlatformSession).where(PlatformSession.creator == creator)
# LEFT JOIN with SessionProjectRelation and ChatUIProject to get project info
query = (
select(
PlatformSession,
col(ChatUIProject.project_id),
col(ChatUIProject.title).label("project_title"),
col(ChatUIProject.emoji).label("project_emoji"),
)
.outerjoin(
SessionProjectRelation,
col(PlatformSession.session_id)
== col(SessionProjectRelation.session_id),
)
.outerjoin(
ChatUIProject,
col(SessionProjectRelation.project_id)
== col(ChatUIProject.project_id),
)
.where(col(PlatformSession.creator) == creator)
)
if platform_id:
query = query.where(PlatformSession.platform_id == platform_id)
@@ -1076,7 +1101,24 @@ class SQLiteDatabase(BaseDatabase):
.limit(page_size)
)
result = await session.execute(query)
return list(result.scalars().all())
# Convert to list of dicts with session and project info
sessions_with_projects = []
for row in result.all():
platform_session = row[0]
project_id = row[1]
project_title = row[2]
project_emoji = row[3]
session_dict = {
"session": platform_session,
"project_id": project_id,
"project_title": project_title,
"project_emoji": project_emoji,
}
sessions_with_projects.append(session_dict)
return sessions_with_projects
async def update_platform_session(
self,
@@ -1107,3 +1149,182 @@ class SQLiteDatabase(BaseDatabase):
col(PlatformSession.session_id) == session_id,
),
)
# ====
# ChatUI Project Management
# ====
async def create_chatui_project(
self,
creator: str,
title: str,
emoji: str | None = "📁",
description: str | None = None,
) -> ChatUIProject:
"""Create a new ChatUI project."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
project = ChatUIProject(
creator=creator,
title=title,
emoji=emoji,
description=description,
)
session.add(project)
await session.flush()
await session.refresh(project)
return project
async def get_chatui_project_by_id(self, project_id: str) -> ChatUIProject | None:
"""Get a ChatUI project by its ID."""
async with self.get_db() as session:
session: AsyncSession
result = await session.execute(
select(ChatUIProject).where(
col(ChatUIProject.project_id) == project_id,
),
)
return result.scalar_one_or_none()
async def get_chatui_projects_by_creator(
self,
creator: str,
page: int = 1,
page_size: int = 100,
) -> list[ChatUIProject]:
"""Get all ChatUI projects for a specific creator."""
async with self.get_db() as session:
session: AsyncSession
offset = (page - 1) * page_size
result = await session.execute(
select(ChatUIProject)
.where(col(ChatUIProject.creator) == creator)
.order_by(desc(ChatUIProject.updated_at))
.limit(page_size)
.offset(offset),
)
return list(result.scalars().all())
async def update_chatui_project(
self,
project_id: str,
title: str | None = None,
emoji: str | None = None,
description: str | None = None,
) -> None:
"""Update a ChatUI project."""
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 title is not None:
values["title"] = title
if emoji is not None:
values["emoji"] = emoji
if description is not None:
values["description"] = description
await session.execute(
update(ChatUIProject)
.where(col(ChatUIProject.project_id) == project_id)
.values(**values),
)
async def delete_chatui_project(self, project_id: str) -> None:
"""Delete a ChatUI project by its ID."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
# First remove all session relations
await session.execute(
delete(SessionProjectRelation).where(
col(SessionProjectRelation.project_id) == project_id,
),
)
# Then delete the project
await session.execute(
delete(ChatUIProject).where(
col(ChatUIProject.project_id) == project_id,
),
)
async def add_session_to_project(
self,
session_id: str,
project_id: str,
) -> SessionProjectRelation:
"""Add a session to a project."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
# First remove existing relation if any
await session.execute(
delete(SessionProjectRelation).where(
col(SessionProjectRelation.session_id) == session_id,
),
)
# Then create new relation
relation = SessionProjectRelation(
session_id=session_id,
project_id=project_id,
)
session.add(relation)
await session.flush()
await session.refresh(relation)
return relation
async def remove_session_from_project(self, session_id: str) -> None:
"""Remove a session from its project."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
await session.execute(
delete(SessionProjectRelation).where(
col(SessionProjectRelation.session_id) == session_id,
),
)
async def get_project_sessions(
self,
project_id: str,
page: int = 1,
page_size: int = 100,
) -> list[PlatformSession]:
"""Get all sessions in a project."""
async with self.get_db() as session:
session: AsyncSession
offset = (page - 1) * page_size
result = await session.execute(
select(PlatformSession)
.join(
SessionProjectRelation,
col(PlatformSession.session_id)
== col(SessionProjectRelation.session_id),
)
.where(col(SessionProjectRelation.project_id) == project_id)
.order_by(desc(PlatformSession.updated_at))
.limit(page_size)
.offset(offset),
)
return list(result.scalars().all())
async def get_project_by_session(
self, session_id: str, creator: str
) -> ChatUIProject | None:
"""Get the project that a session belongs to."""
async with self.get_db() as session:
session: AsyncSession
result = await session.execute(
select(ChatUIProject)
.join(
SessionProjectRelation,
col(ChatUIProject.project_id)
== col(SessionProjectRelation.project_id),
)
.where(
col(SessionProjectRelation.session_id) == session_id,
col(ChatUIProject.creator) == creator,
),
)
return result.scalar_one_or_none()
+2
View File
@@ -1,6 +1,7 @@
from .auth import AuthRoute
from .backup import BackupRoute
from .chat import ChatRoute
from .chatui_project import ChatUIProjectRoute
from .command import CommandRoute
from .config import ConfigRoute
from .conversation import ConversationRoute
@@ -20,6 +21,7 @@ __all__ = [
"AuthRoute",
"BackupRoute",
"ChatRoute",
"ChatUIProjectRoute",
"CommandRoute",
"ConfigRoute",
"ConversationRoute",
+30 -12
View File
@@ -618,9 +618,17 @@ class ChatRoute(Route):
page_size=100, # 暂时返回前100个
)
# 转换为字典格式,并添加额外信息
# 转换为字典格式,并添加项目信息
# get_platform_sessions_by_creator 现在返回 list[dict] 包含 session 和项目字段
sessions_data = []
for session in sessions:
for item in sessions:
session = item["session"]
project_id = item["project_id"]
# 跳过属于项目的会话(在侧边栏对话列表中不显示)
if project_id is not None:
continue
sessions_data.append(
{
"session_id": session.session_id,
@@ -645,6 +653,12 @@ class ChatRoute(Route):
session = await self.db.get_platform_session_by_id(session_id)
platform_id = session.platform_id if session else "webchat"
# 获取项目信息(如果会话属于某个项目)
username = g.get("username", "guest")
project_info = await self.db.get_project_by_session(
session_id=session_id, creator=username
)
# Get platform message history using session_id
history_ls = await self.platform_history_mgr.get(
platform_id=platform_id,
@@ -655,16 +669,20 @@ class ChatRoute(Route):
history_res = [history.model_dump() for history in history_ls]
return (
Response()
.ok(
data={
"history": history_res,
"is_running": self.running_convs.get(session_id, False),
},
)
.__dict__
)
response_data = {
"history": history_res,
"is_running": self.running_convs.get(session_id, False),
}
# 如果会话属于项目,添加项目信息
if project_info:
response_data["project"] = {
"project_id": project_info.project_id,
"title": project_info.title,
"emoji": project_info.emoji,
}
return Response().ok(data=response_data).__dict__
async def update_session_display_name(self):
"""Update a Platform session's display name."""
+245
View File
@@ -0,0 +1,245 @@
from quart import g, request
from astrbot.core.db import BaseDatabase
from .route import Response, Route, RouteContext
class ChatUIProjectRoute(Route):
def __init__(self, context: RouteContext, db: BaseDatabase) -> None:
super().__init__(context)
self.routes = {
"/chatui_project/create": ("POST", self.create_project),
"/chatui_project/list": ("GET", self.list_projects),
"/chatui_project/get": ("GET", self.get_project),
"/chatui_project/update": ("POST", self.update_chatui_project),
"/chatui_project/delete": ("GET", self.delete_project),
"/chatui_project/add_session": ("POST", self.add_session_to_project),
"/chatui_project/remove_session": (
"POST",
self.remove_session_from_project,
),
"/chatui_project/get_sessions": ("GET", self.get_project_sessions),
}
self.db = db
self.register_routes()
async def create_project(self):
"""Create a new ChatUI project."""
username = g.get("username", "guest")
post_data = await request.json
title = post_data.get("title")
emoji = post_data.get("emoji", "📁")
description = post_data.get("description")
if not title:
return Response().error("Missing key: title").__dict__
project = await self.db.create_chatui_project(
creator=username,
title=title,
emoji=emoji,
description=description,
)
return (
Response()
.ok(
data={
"project_id": project.project_id,
"title": project.title,
"emoji": project.emoji,
"description": project.description,
"created_at": project.created_at.astimezone().isoformat(),
"updated_at": project.updated_at.astimezone().isoformat(),
}
)
.__dict__
)
async def list_projects(self):
"""Get all ChatUI projects for the current user."""
username = g.get("username", "guest")
projects = await self.db.get_chatui_projects_by_creator(creator=username)
projects_data = [
{
"project_id": project.project_id,
"title": project.title,
"emoji": project.emoji,
"description": project.description,
"created_at": project.created_at.astimezone().isoformat(),
"updated_at": project.updated_at.astimezone().isoformat(),
}
for project in projects
]
return Response().ok(data=projects_data).__dict__
async def get_project(self):
"""Get a specific ChatUI project."""
project_id = request.args.get("project_id")
if not project_id:
return Response().error("Missing key: project_id").__dict__
username = g.get("username", "guest")
project = await self.db.get_chatui_project_by_id(project_id)
if not project:
return Response().error(f"Project {project_id} not found").__dict__
# Verify ownership
if project.creator != username:
return Response().error("Permission denied").__dict__
return (
Response()
.ok(
data={
"project_id": project.project_id,
"title": project.title,
"emoji": project.emoji,
"description": project.description,
"created_at": project.created_at.astimezone().isoformat(),
"updated_at": project.updated_at.astimezone().isoformat(),
}
)
.__dict__
)
async def update_chatui_project(self):
"""Update a ChatUI project."""
post_data = await request.json
project_id = post_data.get("project_id")
title = post_data.get("title")
emoji = post_data.get("emoji")
description = post_data.get("description")
if not project_id:
return Response().error("Missing key: project_id").__dict__
username = g.get("username", "guest")
# Verify ownership
project = await self.db.get_chatui_project_by_id(project_id)
if not project:
return Response().error(f"Project {project_id} not found").__dict__
if project.creator != username:
return Response().error("Permission denied").__dict__
await self.db.update_chatui_project(
project_id=project_id,
title=title,
emoji=emoji,
description=description,
)
return Response().ok().__dict__
async def delete_project(self):
"""Delete a ChatUI project."""
project_id = request.args.get("project_id")
if not project_id:
return Response().error("Missing key: project_id").__dict__
username = g.get("username", "guest")
# Verify ownership
project = await self.db.get_chatui_project_by_id(project_id)
if not project:
return Response().error(f"Project {project_id} not found").__dict__
if project.creator != username:
return Response().error("Permission denied").__dict__
await self.db.delete_chatui_project(project_id)
return Response().ok().__dict__
async def add_session_to_project(self):
"""Add a session to a project."""
post_data = await request.json
session_id = post_data.get("session_id")
project_id = post_data.get("project_id")
if not session_id:
return Response().error("Missing key: session_id").__dict__
if not project_id:
return Response().error("Missing key: project_id").__dict__
username = g.get("username", "guest")
# Verify project ownership
project = await self.db.get_chatui_project_by_id(project_id)
if not project:
return Response().error(f"Project {project_id} not found").__dict__
if project.creator != username:
return Response().error("Permission denied").__dict__
# Verify session ownership
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__
await self.db.add_session_to_project(session_id, project_id)
return Response().ok().__dict__
async def remove_session_from_project(self):
"""Remove a session from its project."""
post_data = await request.json
session_id = post_data.get("session_id")
if not session_id:
return Response().error("Missing key: session_id").__dict__
username = g.get("username", "guest")
# Verify session ownership
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__
await self.db.remove_session_from_project(session_id)
return Response().ok().__dict__
async def get_project_sessions(self):
"""Get all sessions in a project."""
project_id = request.args.get("project_id")
if not project_id:
return Response().error("Missing key: project_id").__dict__
username = g.get("username", "guest")
# Verify project ownership
project = await self.db.get_chatui_project_by_id(project_id)
if not project:
return Response().error(f"Project {project_id} not found").__dict__
if project.creator != username:
return Response().error("Permission denied").__dict__
sessions = await self.db.get_project_sessions(project_id)
sessions_data = [
{
"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(),
}
for session in sessions
]
return Response().ok(data=sessions_data).__dict__
+1
View File
@@ -74,6 +74,7 @@ class AstrBotDashboard:
self.sfr = StaticFileRoute(self.context)
self.ar = AuthRoute(self.context)
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
self.chatui_project_route = ChatUIProjectRoute(self.context, db)
self.tools_root = ToolsRoute(self.context, core_lifecycle)
self.conversation_route = ConversationRoute(self.context, db, core_lifecycle)
self.file_route = FileRoute(self.context)
+201 -33
View File
@@ -9,10 +9,12 @@
:sessions="sessions"
:selectedSessions="selectedSessions"
:currSessionId="currSessionId"
:selectedProjectId="selectedProjectId"
:isDark="isDark"
:chatboxMode="chatboxMode"
:isMobile="isMobile"
:mobileMenuOpen="mobileMenuOpen"
:projects="projects"
@newChat="handleNewChat"
@selectConversation="handleSelectConversation"
@editTitle="showEditTitleDialog"
@@ -20,6 +22,10 @@
@closeMobileSidebar="closeMobileSidebar"
@toggleTheme="toggleTheme"
@toggleFullscreen="toggleFullscreen"
@selectProject="handleSelectProject"
@createProject="showCreateProjectDialog"
@editProject="showEditProjectDialog"
@deleteProject="handleDeleteProject"
/>
<!-- 右侧聊天内容区域 -->
@@ -32,7 +38,17 @@
</v-btn>
</div>
<div class="message-list-wrapper" v-if="messages && messages.length > 0">
<!-- 面包屑导航 -->
<div v-if="currentSessionProject && messages && messages.length > 0" class="breadcrumb-container">
<div class="breadcrumb-content">
<span class="breadcrumb-emoji">{{ currentSessionProject.emoji || '📁' }}</span>
<span class="breadcrumb-project" @click="handleSelectProject(currentSessionProject.project_id)">{{ currentSessionProject.title }}</span>
<v-icon size="small" class="breadcrumb-separator">mdi-chevron-right</v-icon>
<span class="breadcrumb-session">{{ getCurrentSession?.display_name || tm('conversation.newConversation') }}</span>
</div>
</div>
<div class="message-list-wrapper" v-if="currSessionId && !selectedProjectId">
<MessageList :messages="messages" :isDark="isDark"
:isStreaming="isStreaming || isConvRunning"
:isLoadingMessages="isLoadingMessages"
@@ -42,23 +58,70 @@
ref="messageList" />
<div class="message-list-fade" :class="{ 'fade-dark': isDark }"></div>
</div>
<div class="welcome-container fade-in" v-else>
<div v-if="isLoadingMessages" class="loading-overlay-welcome">
<v-progress-circular
indeterminate
size="48"
width="4"
color="primary"
></v-progress-circular>
</div>
<div v-else class="welcome-title">
<span>Hello, I'm</span>
<span class="bot-name">AstrBot ⭐</span>
</div>
</div>
<ProjectView
v-else-if="selectedProjectId"
:project="currentProject"
:sessions="projectSessions"
@selectSession="(sessionId) => handleSelectConversation([sessionId])"
@editSessionTitle="showEditTitleDialog"
@deleteSession="handleDeleteConversation"
>
<ChatInput
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles"
:disabled="isStreaming"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:replyTo="replyTo"
@send="handleSendMessage"
@toggleStreaming="toggleStreaming"
@removeImage="removeImage"
@removeAudio="removeAudio"
@removeFile="removeFile"
@startRecording="handleStartRecording"
@stopRecording="handleStopRecording"
@pasteImage="handlePaste"
@fileSelect="handleFileSelect"
@clearReply="clearReply"
ref="chatInputRef"
/>
</ProjectView>
<WelcomeView
v-else
:isLoading="isLoadingMessages"
>
<ChatInput
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles"
:disabled="isStreaming"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:replyTo="replyTo"
@send="handleSendMessage"
@toggleStreaming="toggleStreaming"
@removeImage="removeImage"
@removeAudio="removeAudio"
@removeFile="removeFile"
@startRecording="handleStartRecording"
@stopRecording="handleStopRecording"
@pasteImage="handlePaste"
@fileSelect="handleFileSelect"
@clearReply="clearReply"
ref="chatInputRef"
/>
</WelcomeView>
<!-- 输入区域 -->
<ChatInput
v-if="currSessionId && !selectedProjectId"
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
@@ -114,6 +177,13 @@
</v-card-text>
</v-card>
</v-dialog>
<!-- 创建/编辑项目对话框 -->
<ProjectDialog
v-model="projectDialog"
:project="editingProject"
@save="handleSaveProject"
/>
</template>
<script setup lang="ts">
@@ -122,14 +192,19 @@ import { useRouter, useRoute } from 'vue-router';
import { useCustomizerStore } from '@/stores/customizer';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';
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 ProjectDialog from '@/components/chat/ProjectDialog.vue';
import ProjectView from '@/components/chat/ProjectView.vue';
import WelcomeView from '@/components/chat/WelcomeView.vue';
import type { ProjectFormData } from '@/components/chat/ProjectDialog.vue';
import { useSessions } from '@/composables/useSessions';
import { useMessages } from '@/composables/useMessages';
import { useMediaHandling } from '@/composables/useMediaHandling';
import { useRecording } from '@/composables/useRecording';
import { useProjects } from '@/composables/useProjects';
import type { Project } from '@/components/chat/ProjectList.vue';
interface Props {
chatboxMode?: boolean;
@@ -189,11 +264,23 @@ const {
const { isRecording, startRecording: startRec, stopRecording: stopRec } = useRecording();
const {
projects,
selectedProjectId,
getProjects,
createProject,
updateProject,
deleteProject,
addSessionToProject,
getProjectSessions
} = useProjects();
const {
messages,
isStreaming,
isConvRunning,
enableStreaming,
currentSessionProject,
getSessionMessages: getSessionMsg,
sendMessage: sendMsg,
toggleStreaming
@@ -206,6 +293,14 @@ const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
// 输入状态
const prompt = ref('');
// 项目状态
const projectDialog = ref(false);
const editingProject = ref<Project | null>(null);
const projectSessions = ref<any[]>([]);
const currentProject = computed(() =>
projects.value.find(p => p.project_id === selectedProjectId.value)
);
// 引用消息状态
interface ReplyInfo {
messageId: number; // PlatformSessionHistoryMessage 的 id
@@ -304,6 +399,10 @@ function handleReplyWithText(replyData: any) {
async function handleSelectConversation(sessionIds: string[]) {
if (!sessionIds[0]) return;
// 退出项目视图
selectedProjectId.value = null;
projectSessions.value = [];
// 立即更新选中状态,避免需要点击两次
currSessionId.value = sessionIds[0];
selectedSessions.value = [sessionIds[0]];
@@ -340,6 +439,9 @@ function handleNewChat() {
newChat(closeMobileSidebar);
messages.value = [];
clearReply();
// 退出项目视图
selectedProjectId.value = null;
projectSessions.value = [];
}
async function handleDeleteConversation(sessionId: string) {
@@ -347,6 +449,53 @@ async function handleDeleteConversation(sessionId: string) {
messages.value = [];
}
async function handleSelectProject(projectId: string) {
selectedProjectId.value = projectId;
const sessions = await getProjectSessions(projectId);
projectSessions.value = sessions;
messages.value = [];
// 清空当前会话ID,准备在项目中创建新对话
currSessionId.value = '';
selectedSessions.value = [];
// 手机端关闭侧边栏
if (isMobile.value) {
closeMobileSidebar();
}
}
function showCreateProjectDialog() {
editingProject.value = null;
projectDialog.value = true;
}
function showEditProjectDialog(project: Project) {
editingProject.value = project;
projectDialog.value = true;
}
async function handleSaveProject(formData: ProjectFormData, projectId?: string) {
if (projectId) {
await updateProject(
projectId,
formData.title,
formData.emoji,
formData.description
);
} else {
await createProject(
formData.title,
formData.emoji,
formData.description
);
}
}
async function handleDeleteProject(projectId: string) {
await deleteProject(projectId);
}
async function handleStartRecording() {
await startRec();
}
@@ -373,7 +522,8 @@ async function handleSendMessage() {
return;
}
if (!currSessionId.value) {
const isCreatingNewSession = !currSessionId.value;
if (isCreatingNewSession) {
await newSession();
}
@@ -405,6 +555,14 @@ async function handleSendMessage() {
selectedModelName,
replyToSend
);
// 如果在项目视图中创建了新会话,自动添加到当前项目
if (isCreatingNewSession && selectedProjectId.value && currSessionId.value) {
await addSessionToProject(currSessionId.value, selectedProjectId.value);
// 刷新项目会话列表
const sessions = await getProjectSessions(selectedProjectId.value);
projectSessions.value = sessions;
}
}
// 路由变化监听
@@ -454,6 +612,7 @@ onMounted(() => {
checkMobile();
window.addEventListener('resize', checkMobile);
getSessions();
getProjects();
});
onBeforeUnmount(() => {
@@ -568,30 +727,39 @@ onBeforeUnmount(() => {
margin-left: 8px;
}
.welcome-container {
height: 100%;
.breadcrumb-container {
padding: 8px 16px;
border-bottom: 1px solid var(--v-theme-border);
flex-shrink: 0;
}
.breadcrumb-content {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
position: relative;
gap: 8px;
font-size: 14px;
}
.welcome-title {
font-size: 28px;
margin-bottom: 16px;
.breadcrumb-emoji {
font-size: 16px;
}
.loading-overlay-welcome {
display: flex;
justify-content: center;
align-items: center;
.breadcrumb-project {
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.bot-name {
font-weight: 700;
margin-left: 8px;
color: var(--v-theme-secondary);
.breadcrumb-project:hover {
opacity: 0.7;
}
.breadcrumb-separator {
opacity: 0.5;
}
.breadcrumb-session {
opacity: 0.7;
}
.fade-in {
+49 -33
View File
@@ -29,32 +29,62 @@
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 12px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 14px;">
<div style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px;">
<ConfigSelector
:session-id="sessionId || null"
:platform-id="sessionPlatformId"
:is-group="sessionIsGroup"
:initial-config-id="props.configId"
@config-changed="handleConfigChange"
/>
<!-- Settings Menu -->
<StyledMenu offset="8" location="top start" :close-on-content-click="false">
<template v-slot:activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps"
icon="mdi-plus"
variant="text"
color="deep-purple"
/>
</template>
<!-- Upload Files -->
<v-list-item
class="styled-menu-item"
rounded="md"
@click="triggerImageInput"
>
<template v-slot:prepend>
<v-icon icon="mdi-file-upload-outline" size="small"></v-icon>
</template>
<v-list-item-title>
{{ tm('input.upload') }}
</v-list-item-title>
</v-list-item>
<!-- Config Selector in Menu -->
<ConfigSelector
:session-id="sessionId || null"
:platform-id="sessionPlatformId"
:is-group="sessionIsGroup"
:initial-config-id="props.configId"
@config-changed="handleConfigChange"
/>
<!-- Streaming Toggle in Menu -->
<v-list-item
class="styled-menu-item"
rounded="md"
@click="$emit('toggleStreaming')"
>
<template v-slot:prepend>
<v-icon :icon="enableStreaming ? 'mdi-flash' : 'mdi-flash-off'" size="small"></v-icon>
</template>
<v-list-item-title>
{{ enableStreaming ? tm('streaming.enabled') : tm('streaming.disabled') }}
</v-list-item-title>
</v-list-item>
</StyledMenu>
<!-- Provider/Model Selector Menu -->
<ProviderModelMenu v-if="showProviderSelector" ref="providerModelMenuRef" />
<v-tooltip :text="enableStreaming ? tm('streaming.enabled') : tm('streaming.disabled')" location="top">
<template v-slot:activator="{ props }">
<v-chip v-bind="props" @click="$emit('toggleStreaming')" size="x-small" class="streaming-toggle-chip">
<v-icon start :icon="enableStreaming ? 'mdi-flash' : 'mdi-flash-off'" size="small"></v-icon>
{{ enableStreaming ? tm('streaming.on') : tm('streaming.off') }}
</v-chip>
</template>
</v-tooltip>
</div>
<div style="display: flex; justify-content: flex-end; margin-top: 8px; align-items: center;">
<input type="file" ref="imageInputRef" @change="handleFileSelect"
style="display: none" multiple />
<v-progress-circular v-if="disabled" indeterminate size="16" class="mr-1" width="1.5" />
<v-btn @click="triggerImageInput" icon="mdi-plus" variant="text" color="deep-purple"
class="add-btn" size="small" />
<v-btn @click="handleRecordClick"
:icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
:color="isRecording ? 'error' : 'deep-purple'" class="record-btn" size="small" />
@@ -99,6 +129,7 @@ import { useModuleI18n } from '@/i18n/composables';
import { useCustomizerStore } from '@/stores/customizer';
import ConfigSelector from './ConfigSelector.vue';
import ProviderModelMenu from './ProviderModelMenu.vue';
import StyledMenu from '@/components/shared/StyledMenu.vue';
import type { Session } from '@/composables/useSessions';
interface StagedFileInfo {
@@ -425,16 +456,6 @@ defineExpose({
opacity: 1;
}
.streaming-toggle-chip {
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.streaming-toggle-chip:hover {
opacity: 0.8;
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@@ -458,11 +479,6 @@ defineExpose({
.input-container {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
border-radius: 0 !important;
border-left: none !important;
border-right: none !important;
border-bottom: none !important;
}
}
</style>
@@ -1,21 +1,24 @@
<template>
<div>
<v-tooltip text="选择用于当前会话的配置文件" location="top">
<template #activator="{ props: tooltipProps }">
<v-chip
v-bind="tooltipProps"
class="text-none config-chip"
variant="tonal"
size="x-small"
rounded="lg"
@click="openDialog"
:disabled="loadingConfigs || saving"
>
<v-icon start size="14">mdi-cog</v-icon>
{{ selectedConfigLabel }}
</v-chip>
<v-list-item
class="styled-menu-item"
rounded="md"
@click="openDialog"
:disabled="loadingConfigs || saving"
>
<template v-slot:prepend>
<v-icon icon="mdi-cog-outline" size="small"></v-icon>
</template>
</v-tooltip>
<v-list-item-title>
{{ tm('config.title') }}
</v-list-item-title>
<v-list-item-subtitle class="text-caption">
{{ selectedConfigLabel }}
</v-list-item-subtitle>
<template v-slot:append>
<v-icon icon="mdi-chevron-right" size="small" class="text-medium-emphasis"></v-icon>
</template>
</v-list-item>
<v-dialog v-model="dialog" max-width="480">
<v-card>
@@ -73,6 +76,7 @@
import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { useToast } from '@/utils/toast';
import { useModuleI18n } from '@/i18n/composables';
interface ConfigInfo {
id: string;
@@ -100,6 +104,8 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{ 'config-changed': [ConfigChangedPayload] }>();
const { tm } = useModuleI18n('features/chat');
const configOptions = ref<ConfigInfo[]>([]);
const loadingConfigs = ref(false);
const dialog = ref(false);
@@ -301,11 +307,6 @@ onMounted(async () => {
</script>
<style scoped>
.config-chip {
cursor: pointer;
justify-content: flex-start;
}
.config-list {
max-height: 360px;
overflow-y: auto;
@@ -21,12 +21,22 @@
</div>
<div style="padding: 8px; opacity: 0.6;">
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId"
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId"
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
</div>
<!-- 项目列表组件 -->
<ProjectList
v-if="!sidebarCollapsed || isMobile"
:projects="projects"
@selectProject="$emit('selectProject', $event)"
@createProject="$emit('createProject')"
@editProject="$emit('editProject', $event)"
@deleteProject="$emit('deleteProject', $event)"
/>
<div style="overflow-y: auto; flex-grow: 1;"
v-if="!sidebarCollapsed || isMobile">
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
@@ -137,18 +147,24 @@ import type { Session } from '@/composables/useSessions';
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
import StyledMenu from '@/components/shared/StyledMenu.vue';
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
import ProjectList from '@/components/chat/ProjectList.vue';
import type { Project } from '@/components/chat/ProjectList.vue';
interface Props {
sessions: Session[];
selectedSessions: string[];
currSessionId: string;
selectedProjectId?: string | null;
isDark: boolean;
chatboxMode: boolean;
isMobile: boolean;
mobileMenuOpen: boolean;
projects?: Project[];
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
projects: () => []
});
const emit = defineEmits<{
newChat: [];
@@ -158,6 +174,10 @@ const emit = defineEmits<{
closeMobileSidebar: [];
toggleTheme: [];
toggleFullscreen: [];
selectProject: [projectId: string];
createProject: [];
editProject: [project: Project];
deleteProject: [projectId: string];
}>();
const { t } = useI18n();
@@ -0,0 +1,114 @@
<template>
<v-dialog v-model="isOpen" max-width="500" @update:model-value="handleDialogChange">
<v-card>
<v-card-title class="dialog-title">
{{ isEditing ? tm('project.edit') : tm('project.create') }}
</v-card-title>
<v-card-text>
<v-text-field v-model="form.emoji" :label="tm('project.emoji')" flat variant="solo-filled" hide-details class="mb-3" />
<v-text-field v-model="form.title" :label="tm('project.name')" flat variant="solo-filled" hide-details class="mb-3" autofocus
@keyup.enter="handleSave" />
<v-textarea v-model="form.description" :label="tm('project.description')" flat variant="solo-filled" hide-details rows="3" rounded="lg" />
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="handleCancel" color="grey-darken-1">{{ t('core.common.cancel') }}</v-btn>
<v-btn variant="text" @click="handleSave" color="primary" :disabled="!form.title.trim()">{{ t('core.common.save') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
export interface Project {
project_id: string;
title: string;
emoji?: string;
description?: string;
created_at: string;
updated_at: string;
}
export interface ProjectFormData {
emoji: string;
title: string;
description: string;
}
interface Props {
modelValue: boolean;
project?: Project | null;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
project: null
});
const emit = defineEmits<{
'update:modelValue': [value: boolean];
save: [formData: ProjectFormData, projectId?: string];
}>();
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
const isOpen = ref(props.modelValue);
const isEditing = ref(false);
const form = ref<ProjectFormData>({
emoji: '📁',
title: '',
description: ''
});
watch(() => props.modelValue, (newVal) => {
isOpen.value = newVal;
if (newVal) {
// 打开对话框时初始化表单
if (props.project) {
isEditing.value = true;
form.value = {
emoji: props.project.emoji || '📁',
title: props.project.title,
description: props.project.description || ''
};
} else {
isEditing.value = false;
form.value = {
emoji: '📁',
title: '',
description: ''
};
}
}
});
function handleDialogChange(value: boolean) {
emit('update:modelValue', value);
}
function handleCancel() {
isOpen.value = false;
emit('update:modelValue', false);
}
function handleSave() {
if (!form.value.title.trim()) {
return;
}
emit('save', { ...form.value }, props.project?.project_id);
isOpen.value = false;
emit('update:modelValue', false);
}
</script>
<style scoped>
.dialog-title {
font-size: 22px;
font-weight: 500;
}
</style>
@@ -0,0 +1,159 @@
<template>
<div>
<!-- 项目按钮 -->
<div style="padding: 0 8px 0px 8px; opacity: 0.6;">
<v-btn block variant="text" class="project-btn" @click="toggleExpanded" prepend-icon="mdi-folder-outline">
{{ tm('project.title') }}
<template v-slot:append>
<v-icon size="small">{{ expanded ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</template>
</v-btn>
</div>
<!-- 项目列表 -->
<v-expand-transition>
<div v-show="expanded" style="padding: 0 8px;">
<v-list density="compact" nav class="project-list" style="background-color: transparent;">
<v-list-item v-for="project in projects" :key="project.project_id"
@click="$emit('selectProject', project.project_id)" rounded="lg" class="project-item">
<template v-slot:prepend>
<span class="project-emoji">{{ project.emoji || '📁' }}</span>
</template>
<v-list-item-title class="project-title">{{ project.title }}</v-list-item-title>
<template v-slot:append>
<div class="project-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text" class="edit-project-btn"
@click.stop="$emit('editProject', project)" />
<v-btn icon="mdi-delete" size="x-small" variant="text" class="delete-project-btn"
color="error" @click.stop="handleDeleteProject(project)" />
</div>
</template>
</v-list-item>
<v-list-item @click="$emit('createProject')" class="create-project-item" rounded="lg">
<template v-slot:prepend>
<v-icon size="small">mdi-plus</v-icon>
</template>
<v-list-item-title style="font-size: 13px;">{{ tm('project.create') }}</v-list-item-title>
</v-list-item>
</v-list>
</div>
</v-expand-transition>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
export interface Project {
project_id: string;
title: string;
emoji?: string;
description?: string;
created_at: string;
updated_at: string;
}
interface Props {
projects: Project[];
initialExpanded?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
initialExpanded: false
});
const emit = defineEmits<{
selectProject: [projectId: string];
createProject: [];
editProject: [project: Project];
deleteProject: [projectId: string];
}>();
const { tm } = useModuleI18n('features/chat');
const expanded = ref(props.initialExpanded);
// 从 localStorage 读取项目展开状态
const savedProjectsExpandedState = localStorage.getItem('projectsExpanded');
if (savedProjectsExpandedState !== null) {
expanded.value = JSON.parse(savedProjectsExpandedState);
}
function toggleExpanded() {
expanded.value = !expanded.value;
localStorage.setItem('projectsExpanded', JSON.stringify(expanded.value));
}
function handleDeleteProject(project: Project) {
const message = tm('project.confirmDelete', { title: project.title });
if (window.confirm(message)) {
emit('deleteProject', project.project_id);
}
}
</script>
<style scoped>
.project-btn {
justify-content: flex-start;
background-color: transparent !important;
border-radius: 20px;
padding: 8px 16px !important;
text-transform: none;
}
.project-item {
border-radius: 16px !important;
padding: 4px 12px !important;
margin-bottom: 2px;
}
.project-item:hover {
background-color: rgba(103, 58, 183, 0.05);
}
.project-item:hover .project-actions {
opacity: 1;
visibility: visible;
}
.project-emoji {
font-size: 16px;
margin-right: 6px;
}
.project-title {
font-size: 13px;
font-weight: 500;
}
.project-actions {
display: flex;
gap: 2px;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
}
.edit-project-btn,
.delete-project-btn {
opacity: 0.7;
transition: opacity 0.2s ease;
}
.edit-project-btn:hover,
.delete-project-btn:hover {
opacity: 1;
}
.create-project-item {
border-radius: 16px !important;
padding: 4px 12px !important;
opacity: 0.7;
}
.create-project-item:hover {
background-color: rgba(103, 58, 183, 0.08);
opacity: 1;
}
</style>
@@ -0,0 +1,186 @@
<template>
<div class="project-sessions-container fade-in">
<div class="project-header">
<div class="project-header-info">
<span class="project-header-emoji">{{ project?.emoji || '📁' }}</span>
<h2 class="project-header-title">{{ project?.title }}</h2>
</div>
<p class="project-header-description" v-if="project?.description">
{{ project.description }}
</p>
</div>
<div class="project-input-slot">
<slot></slot>
</div>
<v-card flat class="project-sessions-list">
<v-list v-if="sessions.length > 0">
<v-list-item v-for="session in sessions" :key="session.session_id"
@click="$emit('selectSession', session.session_id)" class="project-session-item" rounded="lg">
<v-list-item-title>
{{ session.display_name || tm('conversation.newConversation') }}
</v-list-item-title>
<v-list-item-subtitle>
{{ formatDate(session.updated_at) }}
</v-list-item-subtitle>
<template v-slot:append>
<div class="session-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-session-btn"
@click.stop="$emit('editSessionTitle', session.session_id, session.display_name ?? '')" />
<v-btn icon="mdi-delete" size="x-small" variant="text"
class="delete-session-btn" color="error"
@click.stop="handleDeleteSession(session)" />
</div>
</template>
</v-list-item>
</v-list>
<div v-else class="no-sessions-in-project">
<v-icon icon="mdi-message-off-outline" size="large" color="grey-lighten-1"></v-icon>
<p>{{ tm('project.noSessions') }}</p>
</div>
</v-card>
</div>
</template>
<script setup lang="ts">
import { useModuleI18n } from '@/i18n/composables';
import type { Project } from '@/components/chat/ProjectList.vue';
interface Session {
session_id: string;
display_name?: string;
updated_at: string;
}
interface Props {
project?: Project | null;
sessions: Session[];
}
defineProps<Props>();
const emit = defineEmits<{
selectSession: [sessionId: string];
editSessionTitle: [sessionId: string, title: string];
deleteSession: [sessionId: string];
}>();
const { tm } = useModuleI18n('features/chat');
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleString();
}
function handleDeleteSession(session: Session) {
const sessionTitle = session.display_name || tm('conversation.newConversation');
const message = tm('conversation.confirmDelete', { name: sessionTitle });
if (window.confirm(message)) {
emit('deleteSession', session.session_id);
}
}
</script>
<style scoped>
.project-sessions-container {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 32px;
overflow-y: auto;
}
.project-header {
text-align: center;
margin-bottom: 32px;
max-width: 600px;
}
.project-header-info {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 12px;
}
.project-header-emoji {
font-size: 48px;
}
.project-header-title {
font-size: 32px;
font-weight: 600;
}
.project-header-description {
font-size: 14px;
color: var(--v-theme-secondaryText);
margin: 0;
}
.project-input-slot {
width: 100%;
max-width: 800px;
margin-bottom: 24px;
}
.project-sessions-list {
width: 100%;
max-width: 680px;
background-color: transparent !important;
}
.project-session-item {
margin-bottom: 8px;
border-radius: 12px !important;
cursor: pointer;
}
.project-session-item:hover {
background-color: rgba(103, 58, 183, 0.05);
}
.project-session-item:hover .session-actions {
opacity: 1;
visibility: visible;
}
.session-actions {
display: flex;
gap: 2px;
opacity: 1;
}
.no-sessions-in-project {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
opacity: 0.6;
}
.no-sessions-in-project p {
margin-top: 12px;
font-size: 14px;
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
@@ -1,7 +1,7 @@
<template>
<v-menu v-model="menuOpen" :close-on-content-click="false" location="top" @update:model-value="handleMenuToggle">
<template v-slot:activator="{ props: menuProps }">
<v-chip v-bind="menuProps" class="text-none provider-chip" variant="tonal" size="x-small">
<v-chip v-bind="menuProps" class="text-none provider-chip" variant="tonal" :size="chipSize">
<v-icon start size="14">mdi-creation</v-icon>
<span v-if="selectedProviderId">
{{ selectedProviderId }}
@@ -59,6 +59,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useDisplay } from 'vuetify';
import axios from 'axios';
interface ModelMetadata {
@@ -75,11 +76,15 @@ interface ProviderConfig {
enable?: boolean;
}
const { mobile } = useDisplay();
const providerConfigs = ref<ProviderConfig[]>([]);
const selectedProviderId = ref('');
const searchQuery = ref('');
const menuOpen = ref(false);
const chipSize = computed(() => mobile.value ? 'x-small' : 'small');
const filteredProviders = computed(() => {
if (!searchQuery.value) {
return providerConfigs.value;
@@ -0,0 +1,144 @@
<template>
<div class="welcome-container fade-in">
<div v-if="isLoading" class="loading-overlay-welcome">
<v-progress-circular
indeterminate
size="48"
width="4"
color="primary"
></v-progress-circular>
</div>
<template v-else>
<div class="welcome-content">
<div class="welcome-title">
<span class="bot-name-container">
<span class="bot-name-text">
Hello, I'm <span class="highlight-name">AstrBot</span>
</span>
<span class="bot-name-star"></span>
</span>
</div>
</div>
<div class="welcome-input">
<slot></slot>
</div>
</template>
</div>
</template>
<script setup lang="ts">
interface Props {
isLoading?: boolean;
}
withDefaults(defineProps<Props>(), {
isLoading: false
});
</script>
<style scoped>
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.welcome-container {
height: 100%;
width: 100%;
justify-content: center;
display: flex;
align-items: center;
flex-direction: column;
position: relative;
}
.welcome-content {
padding: 24px 0px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.welcome-title {
font-size: 28px;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.welcome-input {
width: 75%;
}
.loading-overlay-welcome {
display: flex;
justify-content: center;
align-items: center;
}
.bot-name-container {
display: flex;
align-items: center;
}
.highlight-name {
color: var(--v-theme-secondary);
font-weight: 700;
}
.bot-name-text {
overflow: hidden;
white-space: nowrap;
width: 0;
opacity: 0;
animation: revealText 1.2s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
animation-delay: 0.2s;
}
.bot-name-star {
margin-left: 0;
display: inline-block;
transform-origin: center;
animation: rotateStar 1.2s cubic-bezier(0.34, 1, 0.64, 1) forwards;
animation-delay: 0.2s;
padding-left: 4px;
}
@keyframes revealText {
from {
width: 0;
opacity: 0;
}
to {
width: 9.2em;
opacity: 1;
}
}
@keyframes rotateStar {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@media (max-width: 600px) {
.welcome-input {
width: 100%;
}
}
</style>
+7
View File
@@ -82,6 +82,9 @@ export function useMessages(
const activeSSECount = ref(0);
const enableStreaming = ref(true);
const attachmentCache = new Map<string, string>(); // attachment_id -> blob URL
// 当前会话的项目信息
const currentSessionProject = ref<{ project_id: string; title: string; emoji: string } | null>(null);
// 从 localStorage 读取流式响应开关状态
const savedStreamingState = localStorage.getItem('enableStreaming');
@@ -179,6 +182,9 @@ export function useMessages(
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;
// 保存项目信息(如果存在)
currentSessionProject.value = response.data.data.project || null;
if (isConvRunning.value) {
if (!isToastedRunningInfo.value) {
@@ -579,6 +585,7 @@ export function useMessages(
isStreaming,
isConvRunning,
enableStreaming,
currentSessionProject,
getSessionMessages,
sendMessage,
toggleStreaming,
+120
View File
@@ -0,0 +1,120 @@
import { ref } from 'vue';
import axios from 'axios';
import type { Project } from '@/components/chat/ProjectList.vue';
export function useProjects() {
const projects = ref<Project[]>([]);
const selectedProjectId = ref<string | null>(null);
async function getProjects() {
try {
const res = await axios.get('/api/chatui_project/list');
if (res.data.status === 'ok') {
projects.value = res.data.data || [];
}
} catch (error) {
console.error('Failed to fetch projects:', error);
}
}
async function createProject(title: string, emoji?: string, description?: string) {
try {
const res = await axios.post('/api/chatui_project/create', {
title,
emoji: emoji || '📁',
description
});
if (res.data.status === 'ok') {
await getProjects();
return res.data.data;
}
} catch (error) {
console.error('Failed to create project:', error);
}
}
async function updateProject(projectId: string, title?: string, emoji?: string, description?: string) {
try {
const res = await axios.post('/api/chatui_project/update', {
project_id: projectId,
title,
emoji,
description
});
if (res.data.status === 'ok') {
await getProjects();
}
} catch (error) {
console.error('Failed to update project:', error);
}
}
async function deleteProject(projectId: string) {
try {
const res = await axios.get('/api/chatui_project/delete', {
params: { project_id: projectId }
});
if (res.data.status === 'ok') {
await getProjects();
if (selectedProjectId.value === projectId) {
selectedProjectId.value = null;
}
}
} catch (error) {
console.error('Failed to delete project:', error);
}
}
async function addSessionToProject(sessionId: string, projectId: string) {
try {
const res = await axios.post('/api/chatui_project/add_session', {
session_id: sessionId,
project_id: projectId
});
return res.data.status === 'ok';
} catch (error) {
console.error('Failed to add session to project:', error);
return false;
}
}
async function removeSessionFromProject(sessionId: string) {
try {
const res = await axios.post('/api/chatui_project/remove_session', {
session_id: sessionId
});
return res.data.status === 'ok';
} catch (error) {
console.error('Failed to remove session from project:', error);
return false;
}
}
async function getProjectSessions(projectId: string) {
try {
const res = await axios.get('/api/chatui_project/get_sessions', {
params: { project_id: projectId }
});
if (res.data.status === 'ok') {
return res.data.data || [];
}
return [];
} catch (error) {
console.error('Failed to fetch project sessions:', error);
return [];
}
}
return {
projects,
selectedProjectId,
getProjects,
createProject,
updateProject,
deleteProject,
addSessionToProject,
removeSessionFromProject,
getProjectSessions
};
}
@@ -70,14 +70,25 @@
"disabled": "Streaming disabled",
"on": "Stream",
"off": "Normal"
},
"reasoning": {
}, "config": {
"title": "Config"
}, "reasoning": {
"thinking": "Thinking Process"
},
"reply": {
"replyTo": "Reply to",
"notFound": "Message not found"
},
"project": {
"title": "Projects",
"create": "Create Project",
"edit": "Edit Project",
"name": "Project Name",
"emoji": "Icon (Emoji)",
"description": "Description (Optional)",
"noSessions": "No conversations in this project",
"confirmDelete": "Are you sure you want to delete project \"{title}\"? Conversations in this project will not be deleted."
},
"time": {
"today": "Today",
"yesterday": "Yesterday"
@@ -71,6 +71,9 @@
"on": "流式",
"off": "普通"
},
"config": {
"title": "配置文件"
},
"reasoning": {
"thinking": "思考过程"
},
@@ -78,6 +81,16 @@
"replyTo": "引用",
"notFound": "无法定位消息"
},
"project": {
"title": "项目",
"create": "创建项目",
"edit": "编辑项目",
"name": "项目名称",
"emoji": "图标 (Emoji)",
"description": "项目描述(可选)",
"noSessions": "该项目暂无对话",
"confirmDelete": "确定要删除项目 \"{title}\" 吗?项目中的对话不会被删除。"
},
"time": {
"today": "今天",
"yesterday": "昨天"