feat: chatui-project
This commit is contained in:
@@ -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."""
|
||||
...
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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__
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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');
|
||||
console.log('Fetched projects:', res);
|
||||
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": "昨天"
|
||||
|
||||
Reference in New Issue
Block a user