Compare commits

...

3 Commits

Author SHA1 Message Date
Soulter
3362ff394f Merge remote-tracking branch 'origin/master' into feat/workspace-panel-chatui 2026-04-23 13:45:35 +08:00
Soulter
980aae1f8e feat: group chat 2026-04-23 11:45:57 +08:00
Soulter
a0b9ecbf92 feat: workspace panel for chatui 2026-04-22 13:18:58 +08:00
30 changed files with 4863 additions and 200 deletions

View File

@@ -334,9 +334,15 @@ class LocalBooter(ComputerBooter):
)
async def download_file(self, remote_path: str, local_path: str) -> None:
raise NotImplementedError(
"LocalBooter does not support download_file operation. Use shell instead."
)
def _run() -> None:
source = os.path.abspath(remote_path)
destination = os.path.abspath(local_path)
if not os.path.isfile(source):
raise FileNotFoundError(source)
os.makedirs(os.path.dirname(destination), exist_ok=True)
shutil.copyfile(source, destination)
await asyncio.to_thread(_run)
async def available(self) -> bool:
return True

View File

@@ -24,6 +24,8 @@ from astrbot.core.db.po import (
ProviderStat,
SessionProjectRelation,
Stats,
WebChatGroup,
WebChatGroupBot,
WebChatThread,
)
@@ -316,6 +318,81 @@ class BaseDatabase(abc.ABC):
"""Delete side threads linked to parent message IDs."""
...
@abc.abstractmethod
async def create_webchat_group(
self,
session_id: str,
creator: str,
name: str,
avatar: str | None = None,
avatar_attachment_id: str | None = None,
description: str | None = None,
) -> WebChatGroup:
"""Create metadata for a ChatUI pseudo group."""
...
@abc.abstractmethod
async def get_webchat_group_by_session(
self, session_id: str
) -> WebChatGroup | None:
"""Get a ChatUI pseudo group by platform session ID."""
...
@abc.abstractmethod
async def delete_webchat_group_by_session(self, session_id: str) -> None:
"""Delete ChatUI pseudo group metadata without deleting bot resources."""
...
@abc.abstractmethod
async def create_webchat_group_bot(
self,
session_id: str,
name: str,
avatar: str | None = None,
avatar_attachment_id: str | None = None,
conf_id: str = "default",
bot_id: str | None = None,
platform_id: str | None = None,
) -> WebChatGroupBot:
"""Create a bot member in a ChatUI pseudo group."""
...
@abc.abstractmethod
async def get_webchat_group_bots(self, session_id: str) -> list[WebChatGroupBot]:
"""List bot members in a ChatUI pseudo group."""
...
@abc.abstractmethod
async def get_all_webchat_group_bots(self) -> list[WebChatGroupBot]:
"""List all ChatUI pseudo group bot resources."""
...
@abc.abstractmethod
async def get_webchat_group_bot(
self, session_id: str, bot_id: str
) -> WebChatGroupBot | None:
"""Get a bot member in a ChatUI pseudo group."""
...
@abc.abstractmethod
async def update_webchat_group_bot(
self,
session_id: str,
bot_id: str,
name: str | None = None,
avatar: str | None = None,
avatar_attachment_id: str | None = None,
conf_id: str | None = None,
platform_id: str | None = None,
) -> WebChatGroupBot | None:
"""Update a bot member in a ChatUI pseudo group."""
...
@abc.abstractmethod
async def delete_webchat_group_bot(self, session_id: str, bot_id: str) -> bool:
"""Delete a bot member in a ChatUI pseudo group."""
...
@abc.abstractmethod
async def insert_attachment(
self,

View File

@@ -277,6 +277,62 @@ class WebChatThread(TimestampMixin, SQLModel, table=True):
)
class WebChatGroup(TimestampMixin, SQLModel, table=True):
"""Metadata for a pseudo group chat in ChatUI."""
__tablename__: str = "webchat_groups"
id: int | None = Field(
primary_key=True,
sa_column_kwargs={"autoincrement": True},
default=None,
)
session_id: str = Field(nullable=False, unique=True, index=True)
creator: str = Field(nullable=False, index=True)
name: str = Field(default="Group Chat", max_length=255, nullable=False)
avatar: str | None = Field(default=None, max_length=1024)
avatar_attachment_id: str | None = Field(default=None, max_length=36)
description: str | None = Field(default=None, sa_type=Text)
__table_args__ = (
UniqueConstraint(
"session_id",
name="uix_webchat_group_session_id",
),
)
class WebChatGroupBot(TimestampMixin, SQLModel, table=True):
"""A bot member in a pseudo WebChat group."""
__tablename__: str = "webchat_group_bots"
id: int | None = Field(
primary_key=True,
sa_column_kwargs={"autoincrement": True},
default=None,
)
bot_id: str = Field(
max_length=8,
nullable=False,
unique=True,
default_factory=lambda: uuid.uuid4().hex[:8],
)
session_id: str = Field(nullable=False, index=True)
name: str = Field(nullable=False, max_length=255)
avatar: str | None = Field(default=None, max_length=1024)
avatar_attachment_id: str | None = Field(default=None, max_length=36)
conf_id: str = Field(default="default", nullable=False, max_length=255)
platform_id: str | None = Field(default=None, max_length=255, index=True)
__table_args__ = (
UniqueConstraint(
"bot_id",
name="uix_webchat_group_bot_id",
),
)
class PlatformSession(TimestampMixin, SQLModel, table=True):
"""Platform session table for managing user sessions across different platforms.

View File

@@ -1,6 +1,7 @@
import asyncio
import threading
import typing as T
import uuid
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta, timezone
@@ -26,6 +27,8 @@ from astrbot.core.db.po import (
ProviderStat,
SessionProjectRelation,
SQLModel,
WebChatGroup,
WebChatGroupBot,
WebChatThread,
)
from astrbot.core.db.po import (
@@ -62,6 +65,7 @@ class SQLiteDatabase(BaseDatabase):
await self._ensure_persona_skills_column(conn)
await self._ensure_persona_custom_error_message_column(conn)
await self._ensure_platform_message_history_checkpoint_column(conn)
await self._ensure_webchat_group_avatar_attachment_columns(conn)
await conn.commit()
async def _ensure_persona_folder_columns(self, conn) -> None:
@@ -126,6 +130,33 @@ class SQLiteDatabase(BaseDatabase):
)
)
async def _ensure_webchat_group_avatar_attachment_columns(self, conn) -> None:
"""Ensure ChatUI group tables have newly added columns."""
for table_name in ("webchat_groups", "webchat_group_bots"):
result = await conn.execute(text(f"PRAGMA table_info({table_name})"))
columns = {row[1] for row in result.fetchall()}
if columns and "avatar_attachment_id" not in columns:
await conn.execute(
text(
f"ALTER TABLE {table_name} "
"ADD COLUMN avatar_attachment_id VARCHAR(36) DEFAULT NULL"
)
)
if table_name == "webchat_group_bots" and "platform_id" not in columns:
await conn.execute(
text(
"ALTER TABLE webchat_group_bots "
"ADD COLUMN platform_id VARCHAR(255) DEFAULT NULL"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS "
"ix_webchat_group_bots_platform_id "
"ON webchat_group_bots (platform_id)"
)
)
# ====
# Platform Statistics
# ====
@@ -757,6 +788,163 @@ class SQLiteDatabase(BaseDatabase):
)
return thread_ids
async def create_webchat_group(
self,
session_id: str,
creator: str,
name: str,
avatar: str | None = None,
avatar_attachment_id: str | None = None,
description: str | None = None,
) -> WebChatGroup:
"""Create metadata for a ChatUI pseudo group."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
group = WebChatGroup(
session_id=session_id,
creator=creator,
name=name,
avatar=avatar,
avatar_attachment_id=avatar_attachment_id,
description=description,
)
session.add(group)
await session.flush()
await session.refresh(group)
return group
async def get_webchat_group_by_session(
self, session_id: str
) -> WebChatGroup | None:
"""Get a ChatUI pseudo group by platform session ID."""
async with self.get_db() as session:
session: AsyncSession
result = await session.execute(
select(WebChatGroup).where(WebChatGroup.session_id == session_id)
)
return result.scalar_one_or_none()
async def delete_webchat_group_by_session(self, session_id: str) -> None:
"""Delete ChatUI pseudo group metadata without deleting bot resources."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
await session.execute(
delete(WebChatGroup).where(WebChatGroup.session_id == session_id)
)
async def create_webchat_group_bot(
self,
session_id: str,
name: str,
avatar: str | None = None,
avatar_attachment_id: str | None = None,
conf_id: str = "default",
bot_id: str | None = None,
platform_id: str | None = None,
) -> WebChatGroupBot:
"""Create a bot member in a ChatUI pseudo group."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
bot = WebChatGroupBot(
session_id=session_id,
bot_id=bot_id or uuid.uuid4().hex[:8],
name=name,
avatar=avatar,
avatar_attachment_id=avatar_attachment_id,
conf_id=conf_id,
platform_id=platform_id,
)
session.add(bot)
await session.flush()
await session.refresh(bot)
return bot
async def get_webchat_group_bots(self, session_id: str) -> list[WebChatGroupBot]:
"""List bot members in a ChatUI pseudo group."""
async with self.get_db() as session:
session: AsyncSession
result = await session.execute(
select(WebChatGroupBot)
.where(WebChatGroupBot.session_id == session_id)
.order_by(WebChatGroupBot.created_at)
)
return list(result.scalars().all())
async def get_all_webchat_group_bots(self) -> list[WebChatGroupBot]:
"""List all ChatUI pseudo group bot resources."""
async with self.get_db() as session:
session: AsyncSession
result = await session.execute(
select(WebChatGroupBot).order_by(WebChatGroupBot.created_at)
)
return list(result.scalars().all())
async def get_webchat_group_bot(
self, session_id: str, bot_id: str
) -> WebChatGroupBot | None:
"""Get a bot member in a ChatUI pseudo group."""
async with self.get_db() as session:
session: AsyncSession
result = await session.execute(
select(WebChatGroupBot).where(
WebChatGroupBot.session_id == session_id,
WebChatGroupBot.bot_id == bot_id,
)
)
return result.scalar_one_or_none()
async def update_webchat_group_bot(
self,
session_id: str,
bot_id: str,
name: str | None = None,
avatar: str | None = None,
avatar_attachment_id: str | None = None,
conf_id: str | None = None,
platform_id: str | None = None,
) -> WebChatGroupBot | None:
"""Update a bot member in a ChatUI pseudo group."""
values: dict[str, T.Any] = {"updated_at": datetime.now(timezone.utc)}
if name is not None:
values["name"] = name
if avatar is not None:
values["avatar"] = avatar
if avatar_attachment_id is not None:
values["avatar_attachment_id"] = avatar_attachment_id
if conf_id is not None:
values["conf_id"] = conf_id
if platform_id is not None:
values["platform_id"] = platform_id
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
await session.execute(
update(WebChatGroupBot)
.where(
WebChatGroupBot.session_id == session_id,
WebChatGroupBot.bot_id == bot_id,
)
.values(**values)
)
return await self.get_webchat_group_bot(session_id, bot_id)
async def delete_webchat_group_bot(self, session_id: str, bot_id: str) -> bool:
"""Delete a bot member in a ChatUI pseudo group."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
result = await session.execute(
delete(WebChatGroupBot).where(
WebChatGroupBot.session_id == session_id,
WebChatGroupBot.bot_id == bot_id,
)
)
return bool(result.rowcount)
async def insert_attachment(self, path, type, mime_type):
"""Insert a new attachment record."""
async with self.get_db() as session:

View File

@@ -227,6 +227,30 @@ class PlatformManager:
except Exception:
logger.error(traceback.format_exc())
def get_platform_config(self, platform_id: str) -> dict | None:
for platform_config in self.platforms_config:
if platform_config.get("id") == platform_id:
return platform_config
return None
async def ensure_platform(self, platform_config: dict) -> None:
platform_id = platform_config.get("id")
if not platform_id:
raise ValueError("platform id is required")
existing_config = self.get_platform_config(platform_id)
if existing_config is None:
self.platforms_config.append(platform_config)
existing_config = platform_config
self.astrbot_config.save_config()
elif not existing_config.get("enable", True):
existing_config["enable"] = True
self.astrbot_config.save_config()
if platform_id in self._inst_map:
return
await self.load_platform(existing_config)
async def _task_wrapper(
self, task: asyncio.Task, platform: Platform | None = None
) -> None:

View File

@@ -0,0 +1,56 @@
def serialize_group(group) -> dict | None:
if not group:
return None
return {
"session_id": group.session_id,
"creator": group.creator,
"name": group.name,
"avatar": group.avatar or "",
"avatar_attachment_id": group.avatar_attachment_id or "",
"description": group.description or "",
}
def serialize_group_bot(bot) -> dict:
return {
"bot_id": bot.bot_id,
"session_id": bot.session_id,
"name": bot.name,
"avatar": bot.avatar or "",
"avatar_attachment_id": bot.avatar_attachment_id or "",
"conf_id": bot.conf_id,
"platform_id": bot.platform_id or f"webchat-{bot.bot_id}",
}
def resolve_mentioned_bots(message_parts: list[dict], bots: list[dict]) -> list[dict]:
bot_ids = {bot["bot_id"]: bot for bot in bots}
bot_names = {bot["name"].casefold(): bot for bot in bots}
resolved: dict[str, dict] = {}
for part in message_parts:
if not isinstance(part, dict) or part.get("type") != "at":
continue
target = str(part.get("target") or part.get("bot_id") or "").strip()
name = str(part.get("name") or "").strip().casefold()
if target in bot_ids:
resolved[target] = bot_ids[target]
if name in bot_names:
bot = bot_names[name]
resolved[bot["bot_id"]] = bot
text = "\n".join(
str(part.get("text") or "")
for part in message_parts
if isinstance(part, dict) and part.get("type") == "plain"
)
folded_text = text.casefold()
for bot in bots:
if f"@{bot['name'].casefold()}" in folded_text:
resolved[bot["bot_id"]] = bot
return list(resolved.values())
def resolve_mentioned_bot(message_parts: list[dict], bots: list[dict]) -> dict | None:
mentioned_bots = resolve_mentioned_bots(message_parts, bots)
return mentioned_bots[0] if mentioned_bots else None

View File

@@ -8,6 +8,7 @@ from typing import Any
from astrbot.core.db.po import Attachment
from astrbot.core.message.components import (
At,
File,
Image,
Json,
@@ -28,14 +29,41 @@ ReplyHistoryGetter = Callable[
MEDIA_PART_TYPES = {"image", "record", "file", "video"}
def merge_adjacent_plain_parts(message_parts: list[dict]) -> list[dict]:
"""Merge neighboring plain parts so streamed chunks become normal text."""
merged: list[dict] = []
for part in message_parts:
if not isinstance(part, dict):
continue
if part.get("type") != "plain":
merged.append(part)
continue
text = str(part.get("text") or "")
if not text:
continue
if merged and merged[-1].get("type") == "plain":
merged[-1]["text"] = f"{merged[-1].get('text', '')}{text}"
else:
merged.append({**part, "text": text})
return merged
def strip_message_parts_path_fields(message_parts: list[dict]) -> list[dict]:
return [{k: v for k, v in part.items() if k != "path"} for part in message_parts]
return merge_adjacent_plain_parts(
[{k: v for k, v in part.items() if k != "path"} for part in message_parts]
)
def webchat_message_parts_have_content(message_parts: list[dict]) -> bool:
return any(
part.get("type") in ("plain", "image", "record", "file", "video")
and (part.get("text") or part.get("attachment_id") or part.get("filename"))
(
part.get("type") in ("plain", "image", "record", "file", "video")
and (part.get("text") or part.get("attachment_id") or part.get("filename"))
)
or (
part.get("type") == "at"
and (part.get("target") or part.get("bot_id") or part.get("name"))
)
for part in message_parts
)
@@ -61,7 +89,9 @@ async def parse_webchat_message_parts(
text_parts: list[str] = []
has_content = False
for part in message_parts:
for part in merge_adjacent_plain_parts(
[part for part in message_parts if isinstance(part, dict)]
):
if not isinstance(part, dict):
if strict:
raise ValueError("message part must be an object")
@@ -77,6 +107,14 @@ async def parse_webchat_message_parts(
has_content = True
continue
if part_type == "at":
target = str(part.get("target") or part.get("bot_id") or "").strip()
name = str(part.get("name") or "").strip()
if target or name:
components.append(At(qq=target or name, name=name))
has_content = True
continue
if part_type == "reply":
message_id = part.get("message_id")
if message_id is None:
@@ -190,6 +228,19 @@ async def build_webchat_message_parts(
message_parts.append({"type": "plain", "text": text})
continue
if part_type == "at":
target = str(part.get("target") or part.get("bot_id") or "").strip()
name = str(part.get("name") or "").strip()
if target or name:
message_parts.append(
{
"type": "at",
"target": target,
"name": name,
}
)
continue
if part_type == "reply":
message_id = part.get("message_id")
if message_id is None:
@@ -232,7 +283,7 @@ async def build_webchat_message_parts(
}
)
return message_parts
return merge_adjacent_plain_parts(message_parts)
def webchat_message_parts_to_message_chain(
@@ -243,7 +294,7 @@ def webchat_message_parts_to_message_chain(
components = []
has_content = False
for part in message_parts:
for part in merge_adjacent_plain_parts(message_parts):
if not isinstance(part, dict):
if strict:
raise ValueError("message part must be an object")
@@ -257,6 +308,13 @@ def webchat_message_parts_to_message_chain(
has_content = True
continue
if part_type == "at":
target = str(part.get("target") or part.get("bot_id") or "").strip()
name = str(part.get("name") or "").strip()
if target or name:
components.append(At(qq=target or name, name=name))
continue
if part_type == "reply":
message_id = part.get("message_id")
if message_id is None:
@@ -381,6 +439,13 @@ async def message_chain_to_storage_message_parts(
)
continue
if isinstance(comp, At):
target = str(comp.qq or "").strip()
name = str(comp.name or "").strip()
if target or name:
parts.append({"type": "at", "target": target, "name": name})
continue
if isinstance(comp, Image):
file_path = await comp.convert_to_file_path()
attachment_part = await _copy_file_to_attachment_part(
@@ -430,7 +495,7 @@ async def message_chain_to_storage_message_parts(
parts.append(attachment_part)
continue
return parts
return merge_adjacent_plain_parts(parts)
async def _copy_file_to_attachment_part(

View File

@@ -11,6 +11,7 @@ from astrbot.core.db.po import PlatformMessageHistory
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.platform import (
AstrBotMessage,
Group,
MessageMember,
MessageType,
Platform,
@@ -31,30 +32,38 @@ from .webchat_queue_mgr import WebChatQueueMgr, webchat_queue_mgr
def _extract_conversation_id(session_id: str) -> str:
"""Extract raw webchat conversation id from event/session id."""
if session_id.startswith("webchat!"):
parts = session_id.split("!", 2)
if len(parts) == 3:
parts = session_id.split("!")
if len(parts) >= 3:
return parts[2]
return session_id
def _bot_id_from_platform_id(platform_id: str) -> str:
if platform_id.startswith("webchat-"):
return platform_id.removeprefix("webchat-")
return ""
class QueueListener:
def __init__(
self,
webchat_queue_mgr: WebChatQueueMgr,
platform_id: str,
callback: Callable,
stop_event: asyncio.Event,
) -> None:
self.webchat_queue_mgr = webchat_queue_mgr
self.platform_id = platform_id
self.callback = callback
self.stop_event = stop_event
async def run(self) -> None:
"""Register callback and keep adapter task alive."""
self.webchat_queue_mgr.set_listener(self.callback)
self.webchat_queue_mgr.set_listener(self.platform_id, self.callback)
try:
await self.stop_event.wait()
finally:
await self.webchat_queue_mgr.clear_listener()
await self.webchat_queue_mgr.clear_listener(self.platform_id)
@register_platform_adapter("webchat", "webchat")
@@ -73,10 +82,11 @@ class WebChatAdapter(Platform):
os.makedirs(self.imgs_dir, exist_ok=True)
self.attachments_dir.mkdir(parents=True, exist_ok=True)
platform_id = platform_config.get("id") or "webchat"
self.metadata = PlatformMetadata(
name="webchat",
description="webchat",
id="webchat",
id=platform_id,
support_proactive_message=True,
)
self._shutdown_event = asyncio.Event()
@@ -91,10 +101,23 @@ class WebChatAdapter(Platform):
active_request_ids = self._webchat_queue_mgr.list_back_request_ids(
conversation_id
)
sender_id = (
_bot_id_from_platform_id(self.metadata.id)
if session.message_type == MessageType.GROUP_MESSAGE
else None
)
if sender_id:
sender_request_ids = self._webchat_queue_mgr.list_back_request_ids(
conversation_id,
sender_id=sender_id,
)
if sender_request_ids:
active_request_ids = sender_request_ids
stream_request_ids = [
req_id for req_id in active_request_ids if not req_id.startswith("ws_sub_")
]
target_request_ids = stream_request_ids or active_request_ids
is_group_message = session.message_type == MessageType.GROUP_MESSAGE
if not target_request_ids:
# No active streams to consume this proactive message.
@@ -114,8 +137,10 @@ class WebChatAdapter(Platform):
request_id,
message_chain,
session.session_id,
streaming=True,
streaming=not is_group_message,
emit_complete=True,
sender_id=sender_id,
compact_chain=is_group_message,
)
# If only passive subscription queues exist for this conversation,
@@ -146,7 +171,7 @@ class WebChatAdapter(Platform):
return
await db_helper.insert_platform_message_history(
platform_id="webchat",
platform_id=self.metadata.id,
user_id=conversation_id,
content={"type": "bot", "message": message_parts},
sender_id="bot",
@@ -202,14 +227,29 @@ class WebChatAdapter(Platform):
async def convert_message(self, data: tuple) -> AstrBotMessage:
username, cid, payload = data
sender_id = str(payload.get("sender_id") or username)
sender_name = str(payload.get("sender_name") or sender_id)
is_group = bool(payload.get("is_group"))
target_bot_id = str(payload.get("target_bot_id") or "").strip()
session_creator = str(payload.get("session_creator") or username)
abm = AstrBotMessage()
abm.self_id = "webchat"
abm.sender = MessageMember(username, username)
abm.self_id = (
target_bot_id
or (_bot_id_from_platform_id(self.metadata.id) if is_group else "")
or "webchat"
)
abm.sender = MessageMember(sender_id, sender_name)
abm.type = MessageType.FRIEND_MESSAGE
abm.type = MessageType.GROUP_MESSAGE if is_group else MessageType.FRIEND_MESSAGE
abm.session_id = f"webchat!{username}!{cid}"
if is_group:
abm.group = Group(
group_id=cid, group_name=str(payload.get("group_name") or cid)
)
abm.session_id = f"webchat!{session_creator}!{cid}"
else:
abm.session_id = f"webchat!{username}!{cid}"
abm.message_id = payload.get("message_id")
@@ -229,7 +269,12 @@ class WebChatAdapter(Platform):
abm = await self.convert_message(data)
await self.handle_msg(abm)
bot = QueueListener(self._webchat_queue_mgr, callback, self._shutdown_event)
bot = QueueListener(
self._webchat_queue_mgr,
self.metadata.id,
callback,
self._shutdown_event,
)
return bot.run()
def meta(self) -> PlatformMetadata:
@@ -246,6 +291,8 @@ class WebChatAdapter(Platform):
_, _, payload = message.raw_message # type: ignore
message_event.set_extra("selected_provider", payload.get("selected_provider"))
message_event.set_extra("selected_model", payload.get("selected_model"))
message_event.set_extra("target_bot_id", payload.get("target_bot_id"))
message_event.set_extra("target_bot_name", payload.get("target_bot_name"))
message_event.set_extra(
"enable_streaming", payload.get("enable_streaming", True)
)
@@ -255,6 +302,9 @@ class WebChatAdapter(Platform):
"thread_selected_text", payload.get("thread_selected_text")
)
if message.type == MessageType.GROUP_MESSAGE:
await asyncio.sleep(0.5)
self.commit_event(message_event)
async def terminate(self) -> None:

View File

@@ -6,9 +6,12 @@ import uuid
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import File, Image, Json, Plain, Record
from astrbot.api.message_components import At, File, Image, Json, Plain, Record
from astrbot.core import db_helper
from astrbot.core.platform.message_type import MessageType
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from .message_parts_helper import message_chain_to_storage_message_parts
from .webchat_queue_mgr import webchat_queue_mgr
attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
@@ -17,12 +20,18 @@ attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
def _extract_conversation_id(session_id: str) -> str:
"""Extract raw webchat conversation id from event/session id."""
if session_id.startswith("webchat!"):
parts = session_id.split("!", 2)
if len(parts) == 3:
parts = session_id.split("!")
if len(parts) >= 3:
return parts[2]
return session_id
def _sender_payload(sender_id: str | None) -> dict:
if not sender_id or sender_id == "webchat":
return {}
return {"sender_id": sender_id}
class WebChatMessageEvent(AstrMessageEvent):
def __init__(self, message_str, message_obj, platform_meta, session_id) -> None:
super().__init__(message_str, message_obj, platform_meta, session_id)
@@ -35,6 +44,8 @@ class WebChatMessageEvent(AstrMessageEvent):
session_id: str,
streaming: bool = False,
emit_complete: bool = False,
sender_id: str | None = None,
compact_chain: bool = False,
) -> str | None:
request_id = str(message_id)
conversation_id = _extract_conversation_id(session_id)
@@ -49,11 +60,47 @@ class WebChatMessageEvent(AstrMessageEvent):
"data": "",
"streaming": False,
"message_id": message_id,
**_sender_payload(sender_id),
}, # end means this request is finished
)
return
if compact_chain:
message_parts = await message_chain_to_storage_message_parts(
message,
insert_attachment=db_helper.insert_attachment,
attachments_dir=attachments_dir,
)
data = "".join(
str(part.get("text") or "")
for part in message_parts
if part.get("type") == "plain"
)
await web_chat_back_queue.put(
{
"type": "chain",
"data": message_parts,
"streaming": False,
"chain_type": message.type,
"message_id": message_id,
**_sender_payload(sender_id),
},
)
if emit_complete:
await web_chat_back_queue.put(
{
"type": "complete",
"data": data,
"streaming": False,
"chain_type": message.type,
"message_id": message_id,
**_sender_payload(sender_id),
},
)
return data
data = ""
sender_payload = _sender_payload(sender_id)
for comp in message.chain:
if isinstance(comp, Plain):
data = comp.text
@@ -64,6 +111,7 @@ class WebChatMessageEvent(AstrMessageEvent):
"streaming": streaming,
"chain_type": message.type,
"message_id": message_id,
**sender_payload,
},
)
elif isinstance(comp, Json):
@@ -74,6 +122,20 @@ class WebChatMessageEvent(AstrMessageEvent):
"streaming": streaming,
"chain_type": message.type,
"message_id": message_id,
**sender_payload,
},
)
elif isinstance(comp, At):
await web_chat_back_queue.put(
{
"type": "at",
"data": {
"target": str(comp.qq or ""),
"name": str(comp.name or ""),
},
"streaming": streaming,
"message_id": message_id,
**sender_payload,
},
)
elif isinstance(comp, Image):
@@ -90,6 +152,7 @@ class WebChatMessageEvent(AstrMessageEvent):
"data": data,
"streaming": streaming,
"message_id": message_id,
**sender_payload,
},
)
elif isinstance(comp, Record):
@@ -106,6 +169,7 @@ class WebChatMessageEvent(AstrMessageEvent):
"data": data,
"streaming": streaming,
"message_id": message_id,
**sender_payload,
},
)
elif isinstance(comp, File):
@@ -123,6 +187,7 @@ class WebChatMessageEvent(AstrMessageEvent):
"data": data,
"streaming": streaming,
"message_id": message_id,
**sender_payload,
},
)
else:
@@ -136,6 +201,7 @@ class WebChatMessageEvent(AstrMessageEvent):
"streaming": streaming,
"chain_type": message.type,
"message_id": message_id,
**sender_payload,
},
)
@@ -143,9 +209,65 @@ class WebChatMessageEvent(AstrMessageEvent):
async def send(self, message: MessageChain | None) -> None:
message_id = self.message_obj.message_id
await WebChatMessageEvent._send(message_id, message, session_id=self.session_id)
await WebChatMessageEvent._send(
message_id,
message,
session_id=self.session_id,
sender_id=self.message_obj.self_id,
compact_chain=self.message_obj.type == MessageType.GROUP_MESSAGE,
)
await super().send(MessageChain([]))
async def send_typing(self) -> None:
message_id = self.message_obj.message_id
conversation_id = _extract_conversation_id(self.session_id)
_, _, payload = self.message_obj.raw_message # type: ignore
sender_id = str(
self.message_obj.self_id or payload.get("target_bot_id") or "bot"
)
sender_name = str(payload.get("target_bot_name") or sender_id)
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(
str(message_id),
conversation_id,
)
await web_chat_back_queue.put(
{
"type": "typing",
"data": {
"typing": True,
"sender_id": sender_id,
"sender_name": sender_name,
},
"streaming": False,
"message_id": message_id,
}
)
async def stop_typing(self) -> None:
message_id = self.message_obj.message_id
conversation_id = _extract_conversation_id(self.session_id)
_, _, payload = self.message_obj.raw_message # type: ignore
sender_id = str(
self.message_obj.self_id or payload.get("target_bot_id") or "bot"
)
sender_name = str(payload.get("target_bot_name") or sender_id)
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(
str(message_id),
conversation_id,
)
await web_chat_back_queue.put(
{
"type": "typing",
"data": {
"typing": False,
"sender_id": sender_id,
"sender_name": sender_name,
},
"streaming": False,
"message_id": message_id,
}
)
async def send_streaming(self, generator, use_fallback: bool = False) -> None:
final_data = ""
reasoning_content = ""
@@ -156,6 +278,9 @@ class WebChatMessageEvent(AstrMessageEvent):
request_id,
conversation_id,
)
_, _, payload = self.message_obj.raw_message # type: ignore
group_final_only = bool(payload.get("is_group"))
group_final_message = MessageChain()
async for chain in generator:
# 处理音频流Live Mode
if chain.type == "audio_chunk":
@@ -193,26 +318,57 @@ class WebChatMessageEvent(AstrMessageEvent):
# final_data = ""
# continue
if group_final_only and chain.type == "reasoning":
continue
if group_final_only:
group_final_message.chain.extend(chain.chain)
group_final_message.type = chain.type
continue
r = await WebChatMessageEvent._send(
message_id=message_id,
message=chain,
session_id=self.session_id,
streaming=True,
streaming=not group_final_only,
emit_complete=group_final_only,
sender_id=self.message_obj.self_id,
compact_chain=group_final_only,
)
if not r:
continue
if chain.type == "reasoning":
if not group_final_only and chain.type == "reasoning":
reasoning_content += chain.get_plain_text()
else:
elif not group_final_only:
final_data += r
if group_final_only:
await WebChatMessageEvent._send(
message_id=message_id,
message=group_final_message,
session_id=self.session_id,
streaming=False,
emit_complete=True,
sender_id=self.message_obj.self_id,
compact_chain=True,
)
else:
await web_chat_back_queue.put(
{
"type": "complete", # complete means we return the final result
"data": final_data,
"reasoning": reasoning_content,
"streaming": True,
"message_id": message_id,
},
)
await web_chat_back_queue.put(
{
"type": "complete", # complete means we return the final result
"data": final_data,
"reasoning": reasoning_content,
"streaming": True,
"type": "end",
"data": "",
"streaming": False,
"message_id": message_id,
**_sender_payload(self.message_obj.self_id),
},
)
await super().send_streaming(generator, use_fallback)

View File

@@ -12,9 +12,10 @@ class WebChatQueueMgr:
"""Request ID to asyncio.Queue mapping for responses"""
self._conversation_back_requests: dict[str, set[str]] = {}
self._request_conversation: dict[str, str] = {}
self._request_sender: dict[str, str] = {}
self._queue_close_events: dict[str, asyncio.Event] = {}
self._listener_tasks: dict[str, asyncio.Task] = {}
self._listener_callback: Callable[[tuple], Awaitable[None]] | None = None
self._listener_callbacks: dict[str, Callable[[tuple], Awaitable[None]]] = {}
self.queue_maxsize = queue_maxsize
self.back_queue_maxsize = back_queue_maxsize
@@ -43,9 +44,24 @@ class WebChatQueueMgr:
self._conversation_back_requests[conversation_id].add(request_id)
return self.back_queues[request_id]
def bind_back_queue(
self,
request_id: str,
queue: asyncio.Queue,
conversation_id: str | None = None,
) -> None:
"""Bind a request ID to an existing back queue."""
self.back_queues[request_id] = queue
if conversation_id:
self._request_conversation[request_id] = conversation_id
if conversation_id not in self._conversation_back_requests:
self._conversation_back_requests[conversation_id] = set()
self._conversation_back_requests[conversation_id].add(request_id)
def remove_back_queue(self, request_id: str):
"""Remove back queue for the given request ID"""
self.back_queues.pop(request_id, None)
self._request_sender.pop(request_id, None)
conversation_id = self._request_conversation.pop(request_id, None)
if conversation_id:
request_ids = self._conversation_back_requests.get(conversation_id)
@@ -75,9 +91,25 @@ class WebChatQueueMgr:
if task is not None:
task.cancel()
def list_back_request_ids(self, conversation_id: str) -> list[str]:
def bind_request_sender(self, request_id: str, sender_id: str | None) -> None:
"""Bind a request ID to the bot that will answer it."""
if sender_id:
self._request_sender[request_id] = sender_id
def list_back_request_ids(
self,
conversation_id: str,
sender_id: str | None = None,
) -> list[str]:
"""List active back-queue request IDs for a conversation."""
return list(self._conversation_back_requests.get(conversation_id, set()))
request_ids = list(self._conversation_back_requests.get(conversation_id, set()))
if sender_id is None:
return request_ids
return [
request_id
for request_id in request_ids
if self._request_sender.get(request_id) == sender_id
]
def has_queue(self, conversation_id: str) -> bool:
"""Check if a queue exists for the given conversation ID"""
@@ -85,14 +117,18 @@ class WebChatQueueMgr:
def set_listener(
self,
platform_id: str,
callback: Callable[[tuple], Awaitable[None]],
):
self._listener_callback = callback
self._listener_callbacks[platform_id] = callback
for conversation_id in list(self.queues.keys()):
self._start_listener_if_needed(conversation_id)
async def clear_listener(self) -> None:
self._listener_callback = None
async def clear_listener(self, platform_id: str) -> None:
self._listener_callbacks.pop(platform_id, None)
if self._listener_callbacks:
return
for close_event in list(self._queue_close_events.values()):
close_event.set()
self._queue_close_events.clear()
@@ -105,7 +141,7 @@ class WebChatQueueMgr:
self._listener_tasks.clear()
def _start_listener_if_needed(self, conversation_id: str):
if self._listener_callback is None:
if not self._listener_callbacks:
return
if conversation_id in self._listener_tasks:
task = self._listener_tasks[conversation_id]
@@ -144,10 +180,23 @@ class WebChatQueueMgr:
if close_task in done:
break
data = get_task.result()
if self._listener_callback is None:
if not self._listener_callbacks:
continue
if self._is_adapter_broadcast(data):
await self._broadcast_to_adapters(data)
continue
target_platform_id = self._target_platform_id(data)
callback = self._listener_callbacks.get(target_platform_id)
if callback is None:
callback = self._listener_callbacks.get("webchat")
if callback is None:
logger.warning(
"No webchat listener for target platform: %s",
target_platform_id,
)
continue
try:
await self._listener_callback(data)
await callback(data)
except Exception as e:
logger.error(
f"Error processing message from conversation {conversation_id}: {e}"
@@ -160,5 +209,61 @@ class WebChatQueueMgr:
if not close_task.done():
close_task.cancel()
@staticmethod
def _target_platform_id(data: tuple) -> str:
try:
_, _, payload = data
if isinstance(payload, dict):
return str(payload.get("target_platform_id") or "webchat")
except Exception:
pass
return "webchat"
@staticmethod
def _is_adapter_broadcast(data: tuple) -> bool:
try:
_, _, payload = data
return isinstance(payload, dict) and bool(payload.get("broadcast_adapters"))
except Exception:
return False
async def _broadcast_to_adapters(self, data: tuple) -> None:
try:
username, conversation_id, payload = data
except Exception:
return
if not isinstance(payload, dict):
return
platform_ids = payload.get("broadcast_platform_ids")
if isinstance(platform_ids, list):
target_platform_ids = [str(pid) for pid in platform_ids if pid]
else:
source_platform_id = str(payload.get("source_platform_id") or "")
target_platform_ids = [
platform_id
for platform_id in self._listener_callbacks
if platform_id != "webchat" and platform_id != source_platform_id
]
for platform_id in target_platform_ids:
callback = self._listener_callbacks.get(platform_id)
if callback is None:
logger.warning(
"No webchat listener for broadcast platform: %s",
platform_id,
)
continue
broadcast_payload = dict(payload)
broadcast_payload["receiver_platform_id"] = platform_id
broadcast_payload.pop("target_platform_id", None)
broadcast_payload.pop("target_bot_id", None)
try:
await callback((username, conversation_id, broadcast_payload))
except Exception as e:
logger.error(
f"Error broadcasting message to platform {platform_id}: {e}",
)
webchat_queue_mgr = WebChatQueueMgr()

View File

@@ -5,6 +5,7 @@ import re
import uuid
from contextlib import asynccontextmanager
from copy import deepcopy
from pathlib import Path
from typing import cast
from quart import Response as QuartResponse
@@ -12,9 +13,14 @@ from quart import g, make_response, request, send_file
from astrbot.core import logger, sp
from astrbot.core.agent.message import get_checkpoint_id, is_checkpoint_message
from astrbot.core.computer.computer_client import get_local_booter
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
from astrbot.core.platform.message_type import MessageType
from astrbot.core.platform.sources.webchat.group_bots import (
serialize_group,
serialize_group_bot,
)
from astrbot.core.platform.sources.webchat.message_parts_helper import (
build_webchat_message_parts,
create_attachment_part_from_existing_file,
@@ -22,6 +28,7 @@ from astrbot.core.platform.sources.webchat.message_parts_helper import (
webchat_message_parts_have_content,
)
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
from astrbot.core.tools.computer_tools.util import workspace_root
from astrbot.core.utils.active_event_registry import active_event_registry
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.datetime_utils import to_utc_isoformat
@@ -30,6 +37,34 @@ from .route import Response, Route, RouteContext
# SSE heartbeat message to keep the connection alive during long-running operations
SSE_HEARTBEAT = ": heartbeat\n\n"
WORKSPACE_TEXT_EXTENSIONS = {
".css",
".csv",
".html",
".js",
".json",
".jsonl",
".jsx",
".log",
".markdown",
".md",
".py",
".rst",
".sass",
".scss",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".vue",
".xml",
".yaml",
".yml",
}
WORKSPACE_MAX_PREVIEW_BYTES = 1024 * 1024
WORKSPACE_MAX_LIST_ITEMS = 500
@asynccontextmanager
@@ -78,6 +113,11 @@ class ChatRoute(Route):
"POST",
self.update_session_display_name,
),
"/chat/group/bots": ("GET", self.get_group_bots),
"/chat/group/bot/platforms": ("GET", self.get_group_bot_platforms),
"/chat/group/bot/create": ("POST", self.create_group_bot),
"/chat/group/bot/update": ("POST", self.update_group_bot),
"/chat/group/bot/delete": ("POST", self.delete_group_bot),
"/chat/message/edit": ("POST", self.update_message),
"/chat/message/regenerate": ("POST", self.regenerate_message),
"/chat/thread/create": ("POST", self.create_thread),
@@ -87,6 +127,8 @@ class ChatRoute(Route):
"/chat/get_file": ("GET", self.get_file),
"/chat/get_attachment": ("GET", self.get_attachment),
"/chat/post_file": ("POST", self.post_file),
"/chat/workspace/list_files": ("GET", self.list_workspace_files),
"/chat/workspace/download_file": ("GET", self.download_workspace_file),
}
self.core_lifecycle = core_lifecycle
self.register_routes()
@@ -160,7 +202,8 @@ class ChatRoute(Route):
return Response().error("Missing key: file").__dict__
file = post_data["file"]
filename = file.filename or f"{uuid.uuid4()!s}"
original_filename = file.filename or "upload"
filename = f"{uuid.uuid4().hex}_{os.path.basename(original_filename)}"
content_type = file.content_type or "application/octet-stream"
# 根据 content_type 判断文件类型并添加扩展名
@@ -200,6 +243,120 @@ class ChatRoute(Route):
.__dict__
)
async def list_workspace_files(self):
"""List files in the local computer workspace for a webchat session."""
username = g.get("username", "guest")
session_id = request.args.get("session_id")
if not session_id:
return Response().error("Missing key: session_id").__dict__
try:
session = await self._get_owned_webchat_session(session_id, username)
root = workspace_root(self._build_webchat_unified_msg_origin(session))
root.mkdir(parents=True, exist_ok=True)
target = self._resolve_workspace_path(root, request.args.get("path"))
if not target.exists():
return Response().error("Workspace path not found").__dict__
if not target.is_dir():
return Response().error("Workspace path is not a directory").__dict__
result = await get_local_booter().fs.list_dir(
str(target), show_hidden=False
)
if not result.get("success", False):
return Response().error("Failed to list workspace files").__dict__
entries: list[dict] = []
for name in result.get("entries", [])[:WORKSPACE_MAX_LIST_ITEMS]:
if not isinstance(name, str):
continue
entry = target / name
if not entry.exists():
continue
resolved_entry = entry.resolve(strict=False)
if resolved_entry != root and not resolved_entry.is_relative_to(root):
continue
entries.append(self._serialize_workspace_entry(root, entry))
entries.sort(key=lambda item: (item["type"] != "directory", item["name"]))
return (
Response()
.ok(
data={
"path": self._workspace_relative_path(root, target),
"entries": entries,
"truncated": len(result.get("entries", []))
> WORKSPACE_MAX_LIST_ITEMS,
}
)
.__dict__
)
except PermissionError as exc:
return Response().error(str(exc)).__dict__
except ValueError as exc:
return Response().error(str(exc)).__dict__
except OSError as exc:
logger.error(f"Error listing workspace files: {exc}")
return Response().error("File access error").__dict__
async def download_workspace_file(self):
"""Read a text file from the local computer workspace for preview."""
username = g.get("username", "guest")
session_id = request.args.get("session_id")
raw_path = request.args.get("path")
if not session_id:
return Response().error("Missing key: session_id").__dict__
if not raw_path:
return Response().error("Missing key: path").__dict__
try:
session = await self._get_owned_webchat_session(session_id, username)
root = workspace_root(self._build_webchat_unified_msg_origin(session))
target = self._resolve_workspace_path(root, raw_path)
if not target.exists():
return Response().error("Workspace file not found").__dict__
if not target.is_file():
return Response().error("Workspace path is not a file").__dict__
if target.suffix.lower() not in WORKSPACE_TEXT_EXTENSIONS:
return Response().error("This file type cannot be previewed").__dict__
stat = target.stat()
if stat.st_size > WORKSPACE_MAX_PREVIEW_BYTES:
return (
Response()
.error(
"File is too large to preview. "
f"Maximum size is {WORKSPACE_MAX_PREVIEW_BYTES} bytes."
)
.__dict__
)
result = await get_local_booter().fs.read_file(str(target))
if not result.get("success", False):
return Response().error("Failed to read workspace file").__dict__
return (
Response()
.ok(
data={
"name": target.name,
"path": self._workspace_relative_path(root, target),
"content": result.get("content", ""),
"size": stat.st_size,
"modified_at": stat.st_mtime,
"extension": target.suffix.lower(),
}
)
.__dict__
)
except PermissionError as exc:
return Response().error(str(exc)).__dict__
except ValueError as exc:
return Response().error(str(exc)).__dict__
except OSError as exc:
logger.error(f"Error reading workspace file: {exc}")
return Response().error("File access error").__dict__
async def _build_user_message_parts(self, message: str | list) -> list[dict]:
"""构建用户消息的部分列表。"""
return await build_webchat_message_parts(
@@ -325,6 +482,54 @@ class ChatRoute(Route):
f"{session.platform_id}!{session.creator}!{session.session_id}"
)
async def _get_owned_webchat_session(self, session_id: str, username: str):
session = await self.db.get_platform_session_by_id(session_id)
if not session:
raise ValueError(f"Session {session_id} not found")
if session.creator != username:
raise PermissionError("Permission denied")
return session
def _resolve_workspace_path(self, root: Path, raw_path: str | None) -> Path:
normalized = (raw_path or "").strip().replace("\\", "/")
if normalized in {"", ".", "/"}:
candidate = root
else:
relative_path = Path(normalized)
if relative_path.is_absolute():
raise ValueError("Invalid workspace path")
candidate = root / relative_path
resolved_root = root.resolve(strict=False)
resolved_candidate = candidate.resolve(strict=False)
if (
resolved_candidate != resolved_root
and not resolved_candidate.is_relative_to(resolved_root)
):
raise ValueError("Invalid workspace path")
return resolved_candidate
def _workspace_relative_path(self, root: Path, path: Path) -> str:
resolved_root = root.resolve(strict=False)
resolved_path = path.resolve(strict=False)
if resolved_path == resolved_root:
return ""
return resolved_path.relative_to(resolved_root).as_posix()
def _serialize_workspace_entry(self, root: Path, entry: Path) -> dict:
stat = entry.stat()
is_dir = entry.is_dir()
suffix = entry.suffix.lower()
return {
"name": entry.name,
"path": self._workspace_relative_path(root, entry),
"type": "directory" if is_dir else "file",
"size": stat.st_size if not is_dir else None,
"modified_at": stat.st_mtime,
"extension": suffix,
"previewable": (not is_dir) and suffix in WORKSPACE_TEXT_EXTENSIONS,
}
def _build_thread_unified_msg_origin(self, creator: str, thread_id: str) -> str:
return (
f"webchat:{MessageType.FRIEND_MESSAGE.value}:webchat!{creator}!{thread_id}"
@@ -892,6 +1097,14 @@ class ChatRoute(Route):
if attachment_ids:
await self._delete_attachments(attachment_ids)
if session.is_group:
group_resource_attachment_ids = []
group = await self.db.get_webchat_group_by_session(session_id)
if group and group.avatar_attachment_id:
group_resource_attachment_ids.append(group.avatar_attachment_id)
if group_resource_attachment_ids:
await self._delete_attachments(group_resource_attachment_ids)
# 删除消息历史
await self.platform_history_mgr.delete(
platform_id=session.platform_id,
@@ -914,6 +1127,8 @@ class ChatRoute(Route):
# 清理队列(仅对 webchat
if session.platform_id == "webchat":
webchat_queue_mgr.remove_queues(session_id)
if session.is_group:
await self.db.delete_webchat_group_by_session(session_id)
# 删除会话
await self.db.delete_platform_session(session_id)
@@ -1023,13 +1238,30 @@ class ChatRoute(Route):
# 获取可选的 platform_id 参数,默认为 webchat
platform_id = request.args.get("platform_id", "webchat")
is_group = (
1 if request.args.get("is_group", "0").lower() in {"1", "true"} else 0
)
display_name = request.args.get("display_name")
group_avatar = request.args.get("avatar", "")
group_avatar_attachment_id = request.args.get("avatar_attachment_id") or None
group_description = request.args.get("description", "")
# 创建新会话
session = await self.db.create_platform_session(
creator=username,
platform_id=platform_id,
is_group=0,
display_name=display_name,
is_group=is_group,
)
if is_group:
await self.db.create_webchat_group(
session_id=session.session_id,
creator=username,
name=(display_name or "Group Chat"),
avatar=group_avatar,
avatar_attachment_id=group_avatar_attachment_id,
description=group_description,
)
return (
Response()
@@ -1037,11 +1269,205 @@ class ChatRoute(Route):
data={
"session_id": session.session_id,
"platform_id": session.platform_id,
"is_group": session.is_group,
}
)
.__dict__
)
async def _get_owned_group_session(self, session_id: str, username: str):
session = await self._get_owned_webchat_session(session_id, username)
if not session.is_group:
raise ValueError("Session is not a group chat")
return session
async def get_group_bots(self):
session_id = request.args.get("session_id")
if not session_id:
return Response().error("Missing key: session_id").__dict__
username = g.get("username", "guest")
try:
await self._get_owned_group_session(session_id, username)
except PermissionError as exc:
return Response().error(str(exc)).__dict__
except ValueError as exc:
return Response().error(str(exc)).__dict__
bots = await self.db.get_webchat_group_bots(session_id)
return (
Response()
.ok(data={"bots": [serialize_group_bot(bot) for bot in bots]})
.__dict__
)
async def get_group_bot_platforms(self):
bot_by_platform_id = {
bot.platform_id or f"webchat-{bot.bot_id}": bot
for bot in await self.db.get_all_webchat_group_bots()
}
platforms = []
for platform_config in self.core_lifecycle.astrbot_config.get("platform", []):
if not isinstance(platform_config, dict):
continue
if platform_config.get("type") != "webchat":
continue
platform_id = str(platform_config.get("id") or "")
if not platform_id or platform_id == "webchat":
continue
bot = bot_by_platform_id.get(platform_id)
if bot:
platforms.append(serialize_group_bot(bot))
continue
platforms.append(
{
"id": platform_id,
"name": platform_config.get("name") or platform_id,
"avatar": "",
"avatar_attachment_id": "",
"bot_id": platform_id.removeprefix("webchat-")[:8]
if platform_id.startswith("webchat-")
else "",
"conf_id": "default",
"platform_id": platform_id,
"enable": bool(platform_config.get("enable", True)),
}
)
return Response().ok(data={"platforms": platforms}).__dict__
async def create_group_bot(self):
post_data = await request.json
if post_data is None:
return Response().error("Missing JSON body").__dict__
session_id = post_data.get("session_id")
name = str(post_data.get("name") or "").strip()
if not session_id:
return Response().error("Missing key: session_id").__dict__
if not name:
return Response().error("Missing key: name").__dict__
username = g.get("username", "guest")
try:
await self._get_owned_group_session(session_id, username)
except PermissionError as exc:
return Response().error(str(exc)).__dict__
except ValueError as exc:
return Response().error(str(exc)).__dict__
bot_id = uuid.uuid4().hex[:8]
platform_id = str(post_data.get("platform_id") or "").strip()
if platform_id:
if platform_id == "webchat":
return (
Response()
.error("Default webchat cannot be used as a group bot")
.__dict__
)
platform_config = self.core_lifecycle.platform_manager.get_platform_config(
platform_id
)
if not platform_config or platform_config.get("type") != "webchat":
return Response().error("WebChat platform not found").__dict__
if platform_id.startswith("webchat-") and len(platform_id) > 8:
bot_id = platform_id.removeprefix("webchat-")[:8]
else:
platform_id = f"webchat-{bot_id}"
await self._ensure_group_bot_platform(platform_id)
bot = await self.db.create_webchat_group_bot(
session_id=session_id,
bot_id=bot_id,
name=name,
avatar=str(post_data.get("avatar") or ""),
avatar_attachment_id=post_data.get("avatar_attachment_id"),
conf_id=str(post_data.get("conf_id") or "default"),
platform_id=platform_id,
)
await self.umop_config_router.update_route(
self._build_platform_wide_umop(platform_id),
bot.conf_id,
)
return Response().ok(data={"bot": serialize_group_bot(bot)}).__dict__
async def update_group_bot(self):
post_data = await request.json
if post_data is None:
return Response().error("Missing JSON body").__dict__
session_id = post_data.get("session_id")
bot_id = str(post_data.get("bot_id") or "").strip()
if not session_id:
return Response().error("Missing key: session_id").__dict__
if not bot_id:
return Response().error("Missing key: bot_id").__dict__
username = g.get("username", "guest")
try:
await self._get_owned_group_session(session_id, username)
except PermissionError as exc:
return Response().error(str(exc)).__dict__
except ValueError as exc:
return Response().error(str(exc)).__dict__
bot = await self.db.update_webchat_group_bot(
session_id=session_id,
bot_id=bot_id,
name=post_data.get("name"),
avatar=post_data.get("avatar"),
avatar_attachment_id=post_data.get("avatar_attachment_id"),
conf_id=post_data.get("conf_id"),
platform_id=post_data.get("platform_id"),
)
if not bot:
return Response().error("Bot not found").__dict__
if bot.platform_id:
await self._ensure_group_bot_platform(bot.platform_id)
await self.umop_config_router.update_route(
self._build_platform_wide_umop(bot.platform_id),
bot.conf_id,
)
return Response().ok(data={"bot": serialize_group_bot(bot)}).__dict__
async def delete_group_bot(self):
post_data = await request.json
if post_data is None:
return Response().error("Missing JSON body").__dict__
session_id = post_data.get("session_id")
bot_id = str(post_data.get("bot_id") or "").strip()
if not session_id:
return Response().error("Missing key: session_id").__dict__
if not bot_id:
return Response().error("Missing key: bot_id").__dict__
username = g.get("username", "guest")
try:
await self._get_owned_group_session(session_id, username)
except PermissionError as exc:
return Response().error(str(exc)).__dict__
except ValueError as exc:
return Response().error(str(exc)).__dict__
bot = await self.db.get_webchat_group_bot(session_id, bot_id)
deleted = await self.db.delete_webchat_group_bot(session_id, bot_id)
if not deleted:
return Response().error("Bot not found").__dict__
if bot and bot.avatar_attachment_id:
await self._delete_attachments([bot.avatar_attachment_id])
return Response().ok(data={"bot_id": bot_id}).__dict__
async def _ensure_group_bot_platform(self, platform_id: str) -> None:
await self.core_lifecycle.platform_manager.ensure_platform(
{
"id": platform_id,
"type": "webchat",
"enable": True,
}
)
@staticmethod
def _build_platform_wide_umop(platform_id: str) -> str:
return f"{platform_id}::"
async def get_sessions(self):
"""Get all Platform sessions for the current user."""
username = g.get("username", "guest")
@@ -1110,7 +1536,18 @@ class ChatRoute(Route):
"history": history_res,
"threads": [self._serialize_thread(thread) for thread in threads],
"is_running": self.running_convs.get(session_id, False),
"session": {
"session_id": session.session_id if session else session_id,
"platform_id": platform_id,
"is_group": session.is_group if session else 0,
"display_name": session.display_name if session else None,
},
}
if session and session.is_group:
group = await self.db.get_webchat_group_by_session(session_id)
bots = await self.db.get_webchat_group_bots(session_id)
response_data["group"] = serialize_group(group)
response_data["group_bots"] = [serialize_group_bot(bot) for bot in bots]
# 如果会话属于项目,添加项目信息
if project_info:
@@ -1312,6 +1749,8 @@ class ChatRoute(Route):
return Response().error(f"Session {session_id} not found").__dict__
if session.creator != username:
return Response().error("Permission denied").__dict__
if session.is_group:
return Response().error("Group chat messages cannot be edited").__dict__
record = await self.db.get_platform_message_history_by_id(message_id)
if not record:
@@ -1420,6 +1859,10 @@ class ChatRoute(Route):
return Response().error(f"Session {session_id} not found").__dict__
if session.creator != username:
return Response().error("Permission denied").__dict__
if session.is_group:
return (
Response().error("Group chat messages cannot be regenerated").__dict__
)
target_record = await self.db.get_platform_message_history_by_id(message_id)
if not target_record:

View File

@@ -13,9 +13,14 @@ from quart import websocket
from astrbot import logger
from astrbot.core import sp
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.platform.sources.webchat.group_bots import (
resolve_mentioned_bots,
serialize_group_bot,
)
from astrbot.core.platform.sources.webchat.message_parts_helper import (
build_webchat_message_parts,
create_attachment_part_from_existing_file,
merge_adjacent_plain_parts,
strip_message_parts_path_fields,
webchat_message_parts_have_content,
)
@@ -256,6 +261,8 @@ class LiveChatRoute(Route):
agent_stats: dict,
refs: dict,
llm_checkpoint_id: str | None = None,
sender_id: str = "bot",
sender_name: str = "bot",
):
"""保存 bot 消息到历史记录。"""
bot_message_parts = []
@@ -275,11 +282,82 @@ class LiveChatRoute(Route):
platform_id="webchat",
user_id=webchat_conv_id,
content=new_his,
sender_id="bot",
sender_name="bot",
sender_id=sender_id,
sender_name=sender_name,
llm_checkpoint_id=llm_checkpoint_id,
)
async def _ensure_group_bot_platform(self, bot: dict) -> None:
platform_id = bot.get("platform_id")
if not platform_id:
return
await self.core_lifecycle.platform_manager.ensure_platform(
{
"id": platform_id,
"type": "webchat",
"enable": True,
}
)
await self.core_lifecycle.umop_config_router.update_route(
f"{platform_id}::",
str(bot.get("conf_id") or "default"),
)
async def _broadcast_group_bot_message(
self,
*,
chat_queue: asyncio.Queue,
back_queue: asyncio.Queue,
session: LiveChatSession,
session_id: str,
platform_session: Any,
message_parts: list[dict],
sender_id: str,
sender_name: str,
target_bots: list[dict],
active_message_ids: set[str],
) -> int:
if not target_bots or not webchat_message_parts_have_content(message_parts):
return 0
platform_ids = [
str(bot.get("platform_id") or "")
for bot in target_bots
if bot.get("platform_id")
]
if not platform_ids:
return 0
broadcast_message_id = str(uuid.uuid4())
active_message_ids.add(broadcast_message_id)
webchat_queue_mgr.bind_back_queue(
broadcast_message_id,
back_queue,
session_id,
)
for target_bot in target_bots:
await self._ensure_group_bot_platform(target_bot)
await chat_queue.put(
(
session.username,
session_id,
{
"message": message_parts,
"message_id": broadcast_message_id,
"is_group": True,
"session_creator": platform_session.creator,
"sender_id": sender_id,
"sender_name": sender_name,
"group_name": platform_session.display_name,
"broadcast_adapters": True,
"broadcast_platform_ids": platform_ids,
},
),
)
return len(platform_ids)
async def _send_chat_payload(self, session: LiveChatSession, payload: dict) -> None:
async with session.ws_send_lock:
await websocket.send_json(payload)
@@ -422,6 +500,8 @@ class LiveChatRoute(Route):
persona_prompt = message.get("persona_prompt")
show_reasoning = message.get("show_reasoning")
enable_streaming = message.get("enable_streaming", True)
sender_id = str(message.get("sender_id") or session.username)
sender_name = str(message.get("sender_name") or sender_id)
if not isinstance(payload, list):
await self._send_chat_payload(
@@ -449,41 +529,114 @@ class LiveChatRoute(Route):
)
return
platform_session = await self.db.get_platform_session_by_id(session_id)
if not platform_session:
await self._send_chat_payload(
session,
{
"ct": "chat",
"t": "error",
"data": f"Session {session_id} not found",
"code": "SESSION_NOT_FOUND",
},
)
return
if platform_session.creator != session.username:
await self._send_chat_payload(
session,
{
"ct": "chat",
"t": "error",
"data": "Permission denied",
"code": "PERMISSION_DENIED",
},
)
return
target_bots: list[dict | None] = [None]
group_bots_by_id: dict[str, dict] = {}
if platform_session.is_group:
bots = [
serialize_group_bot(bot)
for bot in await self.db.get_webchat_group_bots(session_id)
]
group_bots_by_id = {bot["bot_id"]: bot for bot in bots}
resolved_bots: dict[str, dict] = {}
target_bot_id = str(message.get("target_bot_id") or "").strip()
if target_bot_id and target_bot_id in group_bots_by_id:
resolved_bots[target_bot_id] = group_bots_by_id[target_bot_id]
for bot in resolve_mentioned_bots(message_parts, bots):
resolved_bots[bot["bot_id"]] = bot
target_bots = list(resolved_bots.values()) or bots or [None]
await self._ensure_chat_subscription(session, session_id)
session.is_processing = True
session.should_interrupt = False
back_queue = webchat_queue_mgr.get_or_create_back_queue(message_id, session_id)
active_message_ids = {message_id}
llm_checkpoint_id = str(uuid.uuid4())
try:
chat_queue = webchat_queue_mgr.get_or_create_queue(session_id)
await chat_queue.put(
(
session.username,
session_id,
{
"message": message_parts,
"selected_provider": selected_provider,
"selected_model": selected_model,
"selected_stt_provider": selected_stt_provider,
"selected_tts_provider": selected_tts_provider,
"persona_prompt": persona_prompt,
"show_reasoning": show_reasoning,
"enable_streaming": enable_streaming,
"message_id": message_id,
"llm_checkpoint_id": llm_checkpoint_id,
},
),
)
for target_bot in target_bots:
if target_bot:
await self._ensure_group_bot_platform(target_bot)
target_message_id = (
str(uuid.uuid4()) if platform_session.is_group else message_id
)
if target_message_id != message_id:
active_message_ids.add(target_message_id)
webchat_queue_mgr.bind_back_queue(
target_message_id,
back_queue,
session_id,
)
if target_bot:
webchat_queue_mgr.bind_request_sender(
target_message_id,
target_bot.get("bot_id"),
)
await chat_queue.put(
(
session.username,
session_id,
{
"message": message_parts,
"selected_provider": selected_provider,
"selected_model": selected_model,
"selected_stt_provider": selected_stt_provider,
"selected_tts_provider": selected_tts_provider,
"persona_prompt": persona_prompt,
"show_reasoning": show_reasoning,
"enable_streaming": enable_streaming,
"message_id": target_message_id,
"llm_checkpoint_id": llm_checkpoint_id,
"is_group": bool(platform_session.is_group),
"session_creator": platform_session.creator,
"sender_id": sender_id,
"sender_name": sender_name,
"group_name": platform_session.display_name,
"target_bot_id": (
target_bot.get("bot_id") if target_bot else None
),
"target_platform_id": (
target_bot.get("platform_id") if target_bot else None
),
"target_bot_name": (
target_bot.get("name") if target_bot else None
),
},
),
)
message_parts_for_storage = strip_message_parts_path_fields(message_parts)
saved_user_record = await self.platform_history_mgr.insert(
platform_id="webchat",
user_id=session_id,
content={"type": "user", "message": message_parts_for_storage},
sender_id=session.username,
sender_name=session.username,
sender_id=sender_id,
sender_name=sender_name,
llm_checkpoint_id=llm_checkpoint_id,
)
await self._send_chat_payload(
@@ -505,6 +658,9 @@ class LiveChatRoute(Route):
tool_calls = {}
agent_stats = {}
refs = {}
last_saved_bot_record = None
completed_targets = 0
expected_targets = max(1, len(target_bots))
while True:
if session.should_interrupt:
@@ -518,58 +674,126 @@ class LiveChatRoute(Route):
if not result:
continue
if result.get("message_id") and result.get("message_id") != message_id:
result_message_id = result.get("message_id")
if result_message_id and result_message_id not in active_message_ids:
continue
result_text = result.get("data", "")
msg_type = result.get("type")
streaming = result.get("streaming", False)
chain_type = result.get("chain_type")
result_sender_id = str(result.get("sender_id") or "").strip()
if not result_sender_id:
result_sender_id = "bot"
result_sender_name = group_bots_by_id.get(result_sender_id, {}).get(
"name", result_sender_id
)
result_sender_is_group_bot = result_sender_id in group_bots_by_id
if (
not platform_session.is_group
and msg_type in ("complete", "break")
and isinstance(result.get("reasoning"), str)
and result["reasoning"]
):
accumulated_reasoning += result["reasoning"]
if platform_session.is_group and chain_type == "reasoning":
continue
if platform_session.is_group and chain_type == "tool_call":
continue
if msg_type == "typing":
await self._send_chat_payload(session, {"ct": "chat", **result})
continue
if chain_type == "agent_stats":
try:
parsed_agent_stats = json.loads(result_text)
agent_stats = parsed_agent_stats
if (
last_saved_bot_record
and not accumulated_parts
and not accumulated_text
and not accumulated_reasoning
):
updated_content = dict(last_saved_bot_record.content or {})
updated_content["agent_stats"] = parsed_agent_stats
await self.platform_history_mgr.update(
last_saved_bot_record.id,
content=updated_content,
)
last_saved_bot_record.content = updated_content
else:
agent_stats = parsed_agent_stats
await self._send_chat_payload(
session,
{
"ct": "chat",
"type": "agent_stats",
"data": parsed_agent_stats,
"sender_id": result_sender_id,
"sender_name": result_sender_name,
},
)
except Exception:
pass
continue
outgoing = {"ct": "chat", **result}
await self._send_chat_payload(session, outgoing)
should_forward = not (
platform_session.is_group
and (
msg_type in ("end", "complete", "break")
or (
msg_type == "plain"
and chain_type in ("tool_call", "tool_call_result")
)
or (msg_type == "chain" and chain_type == "tool_call")
)
)
if should_forward:
outgoing = {
"ct": "chat",
**result,
"sender_id": result_sender_id,
"sender_name": result_sender_name,
}
await self._send_chat_payload(session, outgoing)
if msg_type == "plain":
if chain_type == "tool_call":
try:
tool_call = json.loads(result_text)
tool_calls[tool_call.get("id")] = tool_call
if accumulated_text:
accumulated_parts.append(
{"type": "plain", "text": accumulated_text}
)
accumulated_text = ""
tool_calls[(result_sender_id, tool_call.get("id"))] = (
tool_call
)
except Exception:
pass
elif chain_type == "tool_call_result":
try:
tcr = json.loads(result_text)
tc_id = tcr.get("id")
if tc_id in tool_calls:
tool_calls[tc_id]["result"] = tcr.get("result")
tool_calls[tc_id]["finished_ts"] = tcr.get("ts")
accumulated_parts.append(
tool_key = (result_sender_id, tc_id)
tool_call = dict(tool_calls.get(tool_key, {"id": tc_id}))
tool_call["id"] = tool_call.get("id") or tc_id
tool_call["result"] = tcr.get("result")
tool_call["finished_ts"] = tcr.get("ts")
accumulated_parts.append(
{"type": "tool_call", "tool_calls": [tool_call]}
)
if platform_session.is_group:
await self._send_chat_payload(
session,
{
"type": "tool_call",
"tool_calls": [tool_calls[tc_id]],
}
"ct": "chat",
"type": "plain",
"chain_type": "tool_call_result",
"data": json.dumps(
tool_call, ensure_ascii=False
),
"message_id": result_message_id or message_id,
"streaming": False,
"sender_id": result_sender_id,
"sender_name": result_sender_name,
},
)
tool_calls.pop(tc_id, None)
if tool_key in tool_calls:
tool_calls.pop(tool_key, None)
except Exception:
pass
elif chain_type == "reasoning":
@@ -598,25 +822,39 @@ class LiveChatRoute(Route):
part = await self._create_attachment_from_file(filename, "video")
if part:
accumulated_parts.append(part)
elif msg_type == "at":
at_payload = result_text if isinstance(result_text, dict) else {}
accumulated_parts.append(
{
"type": "at",
"target": str(
at_payload.get("target")
or at_payload.get("bot_id")
or ""
),
"name": str(at_payload.get("name") or ""),
}
)
elif msg_type == "chain":
chain_parts = result_text if isinstance(result_text, list) else []
accumulated_parts.extend(
part for part in chain_parts if isinstance(part, dict)
)
should_save = False
has_bot_content = bool(
accumulated_parts
or accumulated_text
or accumulated_reasoning
or refs
)
if msg_type == "end":
should_save = bool(
accumulated_parts
or accumulated_text
or accumulated_reasoning
or refs
or agent_stats
)
should_save = has_bot_content
elif (streaming and msg_type == "complete") or not streaming:
if chain_type not in (
"tool_call",
"tool_call_result",
"agent_stats",
):
should_save = True
should_save = has_bot_content
if should_save:
accumulated_parts = merge_adjacent_plain_parts(accumulated_parts)
try:
refs = self._extract_web_search_refs(
accumulated_text,
@@ -636,23 +874,62 @@ class LiveChatRoute(Route):
agent_stats,
refs,
llm_checkpoint_id,
sender_id=result_sender_id,
sender_name=result_sender_name,
)
if saved_record:
last_saved_bot_record = saved_record
await self._send_chat_payload(
session,
{
"ct": "chat",
"type": "message_saved",
"message_id": result_message_id or message_id,
"data": {
"id": saved_record.id,
"created_at": to_utc_isoformat(
saved_record.created_at
),
"llm_checkpoint_id": llm_checkpoint_id,
"sender_id": saved_record.sender_id,
"sender_name": saved_record.sender_name,
},
},
)
if platform_session.is_group and result_sender_is_group_bot:
broadcast_source_parts = [
part
for part in accumulated_parts
if isinstance(part, dict)
and part.get("type")
in ("plain", "at", "image", "record", "file", "video")
]
if accumulated_text:
broadcast_source_parts.append(
{"type": "plain", "text": accumulated_text}
)
broadcast_parts = await self._build_chat_message_parts(
broadcast_source_parts
)
broadcast_targets = [
bot
for bot_id, bot in group_bots_by_id.items()
if bot_id != result_sender_id
]
expected_targets += await self._broadcast_group_bot_message(
chat_queue=chat_queue,
back_queue=back_queue,
session=session,
session_id=session_id,
platform_session=platform_session,
message_parts=broadcast_parts,
sender_id=result_sender_id,
sender_name=result_sender_name,
target_bots=broadcast_targets,
active_message_ids=active_message_ids,
)
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
@@ -660,6 +937,9 @@ class LiveChatRoute(Route):
refs = {}
if msg_type == "end":
completed_targets += 1
if completed_targets < expected_targets:
continue
break
except Exception as e:
@@ -675,7 +955,8 @@ class LiveChatRoute(Route):
)
finally:
session.is_processing = False
webchat_queue_mgr.remove_back_queue(message_id)
for active_message_id in list(active_message_ids):
webchat_queue_mgr.remove_back_queue(active_message_id)
async def _build_chat_message_parts(self, message: list[dict]) -> list[dict]:
"""构建 chat websocket 用户消息段(复用 webchat 逻辑)"""

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 247 icons */
/* Auto-generated MDI subset 258 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -236,6 +236,10 @@
content: "\F0167";
}
.mdi-code-braces::before {
content: "\F0169";
}
.mdi-code-json::before {
content: "\F0626";
}
@@ -396,6 +400,10 @@
content: "\F022E";
}
.mdi-file-delimited-outline::before {
content: "\F0EA5";
}
.mdi-file-document::before {
content: "\F0219";
}
@@ -416,6 +424,10 @@
content: "\F021C";
}
.mdi-file-music-outline::before {
content: "\F0E2A";
}
.mdi-file-outline::before {
content: "\F0224";
}
@@ -436,6 +448,10 @@
content: "\F0A4D";
}
.mdi-file-video-outline::before {
content: "\F0E2C";
}
.mdi-file-word-box::before {
content: "\F022D";
}
@@ -484,6 +500,10 @@
content: "\F0279";
}
.mdi-forum::before {
content: "\F028C";
}
.mdi-frequently-asked-questions::before {
content: "\F0EB4";
}
@@ -564,10 +584,38 @@
content: "\F0315";
}
.mdi-language-css3::before {
content: "\F031C";
}
.mdi-language-html5::before {
content: "\F031D";
}
.mdi-language-java::before {
content: "\F0B37";
}
.mdi-language-javascript::before {
content: "\F031E";
}
.mdi-language-markdown::before {
content: "\F0354";
}
.mdi-language-markdown-outline::before {
content: "\F0F5B";
}
.mdi-language-python::before {
content: "\F0320";
}
.mdi-language-typescript::before {
content: "\F06E6";
}
.mdi-layers-outline::before {
content: "\F09FE";
}
@@ -812,10 +860,6 @@
content: "\F167A";
}
.mdi-send::before {
content: "\F048A";
}
.mdi-server::before {
content: "\F048B";
}

File diff suppressed because it is too large Load Diff

View File

@@ -108,33 +108,54 @@
</div>
</transition>
<textarea
ref="inputField"
v-model="localPrompt"
@keydown="handleKeyDown"
:disabled="disabled"
placeholder="Ask AstrBot..."
class="chat-textarea"
autocomplete="off"
autocorrect="off"
autocapitalize="sentences"
spellcheck="false"
style="
width: 100%;
resize: none;
outline: none;
border: 1px solid var(--v-theme-border);
border-radius: 12px;
padding: 12px 18px;
min-height: 34px;
max-height: 200px;
overflow-y: auto;
font-family: inherit;
font-size: 16px;
background-color: var(--v-theme-surface);
transition: height 0.16s ease;
"
></textarea>
<div class="textarea-shell">
<div
ref="highlightLayer"
class="textarea-highlight"
:class="{ 'is-composing': isComposing }"
aria-hidden="true"
>
<template v-for="(segment, index) in highlightedPrompt" :key="index">
<span :class="{ 'textarea-mention': segment.mention }">{{
segment.text
}}</span>
</template>
</div>
<textarea
ref="inputField"
v-model="localPrompt"
:class="{ 'is-composing': isComposing }"
@input="handlePromptInput"
@scroll="syncHighlightScroll"
@keydown="handleKeyDown"
@compositionstart="isComposing = true"
@compositionend="handleCompositionEnd"
:disabled="disabled"
placeholder="Ask AstrBot..."
class="chat-textarea"
autocomplete="off"
autocorrect="off"
autocapitalize="sentences"
spellcheck="false"
></textarea>
</div>
<div v-if="mentionMenuOpen" class="mention-menu">
<button
v-for="(bot, index) in filteredMentionBots"
:key="bot.bot_id"
class="mention-option"
:class="{ active: index === activeMentionIndex }"
type="button"
@mousedown.prevent="selectMentionBot(bot)"
>
<v-avatar size="24" rounded="lg">
<img v-if="bot.avatar" :src="bot.avatar" alt="" />
<span v-else>{{ bot.name.slice(0, 1).toUpperCase() }}</span>
</v-avatar>
<span class="mention-option-name">{{ bot.name }}</span>
<v-chip size="x-small" variant="tonal">Bot</v-chip>
</button>
</div>
<div
style="
display: flex;
@@ -214,7 +235,7 @@
<!-- Provider/Model Selector Menu -->
<ProviderModelMenu
v-if="showProviderSelector"
v-if="showProviderSelector && !sessionIsGroup"
ref="providerModelMenuRef"
/>
</div>
@@ -271,7 +292,7 @@
</v-btn>
<v-btn
icon
v-if="isRunning && !canSend"
v-if="showStopButton"
@click="$emit('stop')"
variant="tonal"
class="send-btn input-action-btn"
@@ -325,6 +346,13 @@ interface ReplyInfo {
selectedText?: string;
}
interface MentionableBot {
bot_id: string;
name: string;
avatar?: string;
conf_id?: string;
}
interface Props {
prompt: string;
stagedImagesUrl: string[];
@@ -339,6 +367,7 @@ interface Props {
configId?: string | null;
replyTo?: ReplyInfo | null;
sendShortcut?: "enter" | "shift_enter";
mentionableBots?: MentionableBot[];
}
const props = withDefaults(defineProps<Props>(), {
@@ -348,6 +377,7 @@ const props = withDefaults(defineProps<Props>(), {
stagedFiles: () => [],
replyTo: null,
sendShortcut: "shift_enter",
mentionableBots: () => [],
});
const emit = defineEmits<{
@@ -372,6 +402,7 @@ const isDark = computed(
);
const inputField = ref<HTMLTextAreaElement | null>(null);
const highlightLayer = ref<HTMLDivElement | null>(null);
const imageInputRef = ref<HTMLInputElement | null>(null);
const providerModelMenuRef = ref<InstanceType<typeof ProviderModelMenu> | null>(
null,
@@ -379,6 +410,12 @@ const providerModelMenuRef = ref<InstanceType<typeof ProviderModelMenu> | null>(
const showProviderSelector = ref(true);
const isReplyClosing = ref(false);
const isDragging = ref(false);
const selectedMentions = ref<MentionableBot[]>([]);
const mentionMenuOpen = ref(false);
const mentionQuery = ref("");
const mentionStartIndex = ref(-1);
const activeMentionIndex = ref(0);
const isComposing = ref(false);
let dragLeaveTimeout: number | null = null;
const localPrompt = computed({
@@ -400,6 +437,53 @@ const canSend = computed(() => {
);
});
const highlightedPrompt = computed(() => {
const text = localPrompt.value;
if (!text) return [];
const mentionNames = props.mentionableBots
.map((bot) => bot.name)
.filter(Boolean)
.sort((a, b) => b.length - a.length);
if (!mentionNames.length) return [{ text, mention: false }];
const pattern = new RegExp(
`(^|\\s)@(${mentionNames.map(escapeRegExp).join("|")})(?=\\s|$)`,
"gi",
);
const segments: Array<{ text: string; mention: boolean }> = [];
let cursor = 0;
for (const match of text.matchAll(pattern)) {
const index = match.index ?? 0;
const prefix = match[1] || "";
const mentionStart = index + prefix.length;
if (mentionStart > cursor) {
segments.push({ text: text.slice(cursor, mentionStart), mention: false });
}
segments.push({ text: `@${match[2]}`, mention: true });
cursor = mentionStart + match[2].length + 1;
}
if (cursor < text.length) {
segments.push({ text: text.slice(cursor), mention: false });
}
return segments;
});
const filteredMentionBots = computed(() => {
const query = mentionQuery.value.toLowerCase();
return props.mentionableBots
.filter(
(bot) =>
!selectedMentions.value.some((item) => item.bot_id === bot.bot_id),
)
.filter((bot) => bot.name.toLowerCase().includes(query))
.slice(0, 6);
});
const isGroupInput = computed(() => props.mentionableBots.length > 0);
const showStopButton = computed(
() => props.isRunning && !canSend.value && !isGroupInput.value,
);
const hasStagedAttachments = computed(() => {
return (
props.stagedImagesUrl.length > 0 ||
@@ -496,7 +580,51 @@ watch(localPrompt, () => {
nextTick(autoResize);
});
watch(
() => props.mentionableBots,
(bots) => {
if (!bots.length) {
selectedMentions.value = [];
closeMentionMenu();
return;
}
const botIds = new Set(bots.map((bot) => bot.bot_id));
selectedMentions.value = selectedMentions.value.filter((bot) =>
botIds.has(bot.bot_id),
);
},
);
function handleKeyDown(e: KeyboardEvent) {
if (mentionMenuOpen.value) {
if (e.key === "ArrowDown") {
e.preventDefault();
activeMentionIndex.value =
(activeMentionIndex.value + 1) %
Math.max(filteredMentionBots.value.length, 1);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
const length = Math.max(filteredMentionBots.value.length, 1);
activeMentionIndex.value = (activeMentionIndex.value - 1 + length) % length;
return;
}
if (e.key === "Enter" || e.key === "Tab") {
const bot = filteredMentionBots.value[activeMentionIndex.value];
if (bot) {
e.preventDefault();
selectMentionBot(bot);
return;
}
}
if (e.key === "Escape") {
e.preventDefault();
closeMentionMenu();
return;
}
}
const isEnter = e.key === "Enter";
if (!isEnter) {
// Ctrl+B 录音
@@ -533,6 +661,78 @@ function handleKeyDown(e: KeyboardEvent) {
}
}
function handlePromptInput() {
if (isComposing.value) return;
syncSelectedMentionsFromText();
updateMentionMenu();
}
function handleCompositionEnd() {
isComposing.value = false;
handlePromptInput();
}
function syncSelectedMentionsFromText() {
const text = localPrompt.value;
selectedMentions.value = selectedMentions.value.filter((bot) =>
new RegExp(`(^|\\s)@${escapeRegExp(bot.name)}(?=\\s|$)`, "i").test(text),
);
}
function updateMentionMenu() {
const el = inputField.value;
if (!el || !props.mentionableBots.length) {
closeMentionMenu();
return;
}
const caret = el.selectionStart ?? localPrompt.value.length;
const beforeCaret = localPrompt.value.slice(0, caret);
const match = /(^|\s)@([^\s@]*)$/.exec(beforeCaret);
if (!match) {
closeMentionMenu();
return;
}
mentionStartIndex.value = beforeCaret.length - match[2].length - 1;
mentionQuery.value = match[2];
activeMentionIndex.value = 0;
mentionMenuOpen.value = filteredMentionBots.value.length > 0;
}
function selectMentionBot(bot: MentionableBot) {
if (!selectedMentions.value.some((item) => item.bot_id === bot.bot_id)) {
selectedMentions.value.push(bot);
}
const el = inputField.value;
const start = mentionStartIndex.value;
if (el && start >= 0) {
const caret = el.selectionStart ?? localPrompt.value.length;
const before = localPrompt.value.slice(0, start);
const after = localPrompt.value.slice(caret);
const needsLeadingSpace = before.length > 0 && !/\s$/.test(before);
const nextText = `${before}${needsLeadingSpace ? " " : ""}@${bot.name} ${after.replace(
/^\s+/,
"",
)}`;
localPrompt.value = nextText;
nextTick(() => {
const nextCaret =
before.length + (needsLeadingSpace ? 1 : 0) + bot.name.length + 2;
el.focus();
el.setSelectionRange(nextCaret, nextCaret);
autoResize();
syncHighlightScroll();
});
}
closeMentionMenu();
}
function closeMentionMenu() {
mentionMenuOpen.value = false;
mentionQuery.value = "";
mentionStartIndex.value = -1;
activeMentionIndex.value = 0;
}
function handleKeyUp(e: KeyboardEvent) {
if (e.keyCode === 66) {
ctrlKeyDown.value = false;
@@ -548,6 +748,18 @@ function handleKeyUp(e: KeyboardEvent) {
}
}
function syncHighlightScroll() {
const input = inputField.value;
const layer = highlightLayer.value;
if (!input || !layer) return;
layer.scrollTop = input.scrollTop;
layer.scrollLeft = input.scrollLeft;
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function handlePaste(e: ClipboardEvent) {
emit("pasteImage", e);
}
@@ -618,6 +830,34 @@ function getCurrentSelection() {
return providerModelMenuRef.value?.getCurrentSelection();
}
function consumeMentions() {
const mentions = mentionBotsFromText(localPrompt.value);
selectedMentions.value = [];
closeMentionMenu();
return mentions;
}
function mentionBotsFromText(text: string) {
const found = new Map<string, MentionableBot>();
for (const bot of props.mentionableBots) {
const pattern = new RegExp(
`(^|\\s)@${escapeRegExp(bot.name)}(?=\\s|$)`,
"gi",
);
if (pattern.test(text)) {
found.set(bot.bot_id, bot);
}
}
for (const bot of selectedMentions.value) {
if (
new RegExp(`(^|\\s)@${escapeRegExp(bot.name)}(?=\\s|$)`, "i").test(text)
) {
found.set(bot.bot_id, bot);
}
}
return Array.from(found.values());
}
function focusInput() {
if (!inputField.value) return;
inputField.value.focus();
@@ -639,6 +879,7 @@ onBeforeUnmount(() => {
defineExpose({
getCurrentSelection,
consumeMentions,
focusInput,
});
</script>
@@ -706,6 +947,122 @@ defineExpose({
background: rgba(var(--v-theme-on-surface), 0.04) !important;
}
.textarea-shell {
position: relative;
width: 100%;
border: 1px solid var(--v-theme-border);
border-radius: 12px;
background-color: var(--v-theme-surface);
}
.chat-textarea,
.textarea-highlight {
box-sizing: border-box;
width: 100%;
min-height: 34px;
max-height: 200px;
padding: 12px 18px;
border: 0;
border-radius: 12px;
font-family: inherit;
font-size: 16px;
line-height: normal;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.chat-textarea {
position: relative;
z-index: 1;
display: block;
resize: none;
outline: none;
overflow-y: auto;
background: transparent;
color: transparent;
caret-color: rgb(var(--v-theme-on-surface));
transition: height 0.16s ease;
}
.chat-textarea::placeholder {
color: rgba(var(--v-theme-on-surface), 0.42);
opacity: 1;
}
.chat-textarea.is-composing {
color: rgb(var(--v-theme-on-surface));
}
.textarea-highlight {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
color: rgb(var(--v-theme-on-surface));
}
.textarea-highlight.is-composing {
opacity: 0;
}
.textarea-mention {
color: rgb(var(--v-theme-primary));
font-weight: 500;
}
.mention-menu {
position: absolute;
left: 18px;
bottom: calc(100% - 8px);
z-index: 12;
display: grid;
gap: 4px;
width: min(280px, calc(100% - 36px));
max-height: 220px;
padding: 6px;
overflow-y: auto;
border: 1px solid rgba(var(--v-border-color), 0.16);
border-radius: 10px;
background: rgb(var(--v-theme-surface));
}
.mention-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 38px;
padding: 7px 8px;
border: 0;
border-radius: 8px;
background: transparent;
color: rgb(var(--v-theme-on-surface));
cursor: pointer;
text-align: left;
}
.mention-option:hover,
.mention-option.active {
background: rgba(var(--v-theme-primary), 0.1);
}
.mention-option img {
width: 100%;
height: 100%;
object-fit: cover;
}
.mention-option-name {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 500;
}
.input-area.is-dark .input-neutral-btn {
color: rgba(255, 255, 255, 0.78) !important;
}
@@ -1016,7 +1373,8 @@ defineExpose({
}
.input-area textarea,
.chat-textarea {
.chat-textarea,
.textarea-highlight {
min-height: 28px !important;
max-height: 140px !important;
font-size: 16px !important;

View File

@@ -2,27 +2,60 @@
<div
ref="listRoot"
class="chat-message-list"
:class="[`variant-${variant}`, { 'is-dark': isDark }]"
:class="[
`variant-${variant}`,
{ 'is-dark': isDark, 'is-group-chat': useDefaultBotAvatar },
]"
>
<div class="messages-list">
<div
v-for="(msg, msgIndex) in messages"
:key="msg.id || `${msgIndex}-${msg.created_at || ''}`"
v-show="shouldRenderMessage(msg)"
class="message-row"
:class="isUserMessage(msg) ? 'from-user' : 'from-bot'"
:class="[
isUserMessage(msg) ? 'from-user' : 'from-bot',
{ 'is-continuation': isContinuationMessage(msg, msgIndex) },
]"
>
<v-avatar v-if="!isUserMessage(msg)" class="bot-avatar" :size="avatarSize">
<v-avatar
v-if="!isUserMessage(msg) && !isContinuationMessage(msg, msgIndex)"
class="bot-avatar"
:size="avatarSize"
>
<v-progress-circular
v-if="isMessageStreaming(msg, msgIndex)"
v-if="!useDefaultBotAvatar && isMessageStreaming(msg, msgIndex)"
class="bot-streaming-spinner"
indeterminate
size="22"
width="2"
/>
<img v-else-if="botAvatar(msg)" :src="botAvatar(msg)" alt="" />
<img v-else-if="useDefaultBotAvatar" :src="defaultBotAvatar" alt="" />
<span v-else class="bot-avatar-symbol" aria-hidden="true"></span>
</v-avatar>
<div
v-else-if="!isUserMessage(msg)"
class="bot-avatar-spacer"
:style="{ width: `${avatarSize}px` }"
></div>
<div class="message-stack">
<div
v-if="showSenderName(msg) && !isContinuationMessage(msg, msgIndex)"
class="message-sender-name"
>
<span>{{ msg.sender_name || msg.sender_id }}</span>
<span v-if="isGroupChat && msg.created_at" class="group-message-time">
{{ formatTime(msg.created_at) }}
</span>
</div>
<div
v-if="isContinuationMessage(msg, msgIndex) && msg.created_at"
class="group-continuation-time"
>
{{ formatTime(msg.created_at) }}
</div>
<div
v-if="isUserMessage(msg) && userAttachmentParts(msg).length"
class="sent-attachments"
@@ -149,6 +182,10 @@
{{ part.text || "" }}
</div>
<span v-else-if="part.type === 'at'" class="mention-part">
@{{ mentionName(part) }}
</span>
<div
v-else-if="part.type === 'plain' && messageThreads(msg).length"
class="threaded-message-content"
@@ -381,6 +418,7 @@ import ActionRef from "@/components/chat/message_list_comps/ActionRef.vue";
import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue";
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import defaultBotAvatar from "@/assets/images/chatui-group-default-avatar.png";
import type {
ChatContent,
ChatRecord,
@@ -403,6 +441,10 @@ const props = withDefaults(
editingMessageId?: string | number | null;
editDraft?: string;
savingEdit?: boolean;
showSenderNames?: boolean;
useDefaultBotAvatar?: boolean;
botAvatars?: Record<string, string>;
hideLoadingBotMessage?: boolean;
}>(),
{
isDark: false,
@@ -416,6 +458,10 @@ const props = withDefaults(
editingMessageId: null,
editDraft: "",
savingEdit: false,
showSenderNames: false,
useDefaultBotAvatar: false,
botAvatars: () => ({}),
hideLoadingBotMessage: false,
},
);
@@ -448,12 +494,85 @@ const imagePreview = reactive({ visible: false, url: "" });
const refsSidebarOpen = ref(false);
const selectedRefs = ref<Record<string, unknown> | null>(null);
const listRoot = ref<HTMLElement | null>(null);
const avatarSize = computed(() => (props.variant === "thread" ? 36 : 56));
const isGroupChat = computed(() => props.useDefaultBotAvatar);
const avatarSize = computed(() => {
if (props.variant === "thread") return 36;
return props.useDefaultBotAvatar ? 40 : 56;
});
function isUserMessage(message: ChatRecord) {
return messageContent(message).type === "user";
}
function shouldRenderMessage(message: ChatRecord) {
return !(
props.hideLoadingBotMessage &&
!isUserMessage(message) &&
messageContent(message).isLoading &&
!hasNonReasoningContent(message)
);
}
function isContinuationMessage(message: ChatRecord, index: number) {
if (!isGroupChat.value) return false;
if (isUserMessage(message)) return false;
const sender = senderKey(message);
if (!sender) return false;
const previous = previousVisibleBotMessage(index);
if (!previous || senderKey(previous.message) !== sender) return false;
const groupStart = groupStartMessage(previous.index);
return isWithinGroupWindow(groupStart, message);
}
function senderKey(message: ChatRecord) {
return message.sender_id || message.sender_name || "";
}
function previousVisibleBotMessage(index: number) {
for (let prevIndex = index - 1; prevIndex >= 0; prevIndex -= 1) {
const previous = props.messages[prevIndex];
if (!previous || !shouldRenderMessage(previous)) continue;
if (isUserMessage(previous)) return null;
return { message: previous, index: prevIndex };
}
return null;
}
function groupStartMessage(index: number): ChatRecord {
const message = props.messages[index];
const previous = previousVisibleBotMessage(index);
if (
previous &&
senderKey(previous.message) === senderKey(message) &&
isContinuationMessage(message, index)
) {
return groupStartMessage(previous.index);
}
return message;
}
function isWithinGroupWindow(first: ChatRecord, message: ChatRecord) {
if (!first.created_at || !message.created_at) return true;
const firstTime = new Date(first.created_at).getTime();
const messageTime = new Date(message.created_at).getTime();
if (Number.isNaN(firstTime) || Number.isNaN(messageTime)) return true;
return messageTime - firstTime <= 5 * 60 * 1000;
}
function showSenderName(message: ChatRecord) {
if (!props.showSenderNames) return false;
if (isUserMessage(message)) return false;
const sender = message.sender_name || message.sender_id;
if (!sender) return false;
if (sender === "bot") return false;
return true;
}
function botAvatar(message: ChatRecord) {
const senderId = message.sender_id || "";
return senderId ? props.botAvatars[senderId] || "" : "";
}
function messageContent(message: ChatRecord): ChatContent {
return message.content || { type: "bot", message: [] };
}
@@ -482,6 +601,10 @@ function hasImageOnlyAttachments(message: ChatRecord) {
);
}
function mentionName(part: MessagePart) {
return part.name || part.target || part.bot_id || "";
}
function bubbleParts(message: ChatRecord) {
if (!isUserMessage(message)) return messageParts(message);
return messageParts(message).filter((part) => !isAttachmentPart(part));
@@ -547,6 +670,7 @@ function canRegenerateMessage(message: ChatRecord, messageIndex: number) {
}
function showMessageMeta(message: ChatRecord, messageIndex: number) {
if (isGroupChat.value) return false;
return (
!messageContent(message).isLoading &&
!isMessageStreaming(message, messageIndex)
@@ -890,11 +1014,22 @@ function formatDuration(seconds: number) {
max-width: 100%;
}
.is-group-chat .message-row {
margin-right: -8px;
margin-left: -8px;
padding: 2px 8px;
}
.is-group-chat .message-row.is-continuation {
margin-top: -14px;
}
.message-row.from-user {
justify-content: flex-end;
}
.message-stack {
position: relative;
display: flex;
flex-direction: column;
max-width: min(760px, 82%);
@@ -905,6 +1040,44 @@ function formatDuration(seconds: number) {
max-width: 60%;
}
.message-sender-name {
max-width: 100%;
color: rgba(var(--v-theme-on-surface), 0.5);
font-size: 12px;
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.is-group-chat .message-sender-name {
display: flex;
align-items: baseline;
gap: 8px;
}
.group-message-time,
.group-continuation-time {
color: rgba(var(--v-theme-on-surface), 0.38);
font-size: 11px;
font-weight: 400;
line-height: 16px;
}
.group-continuation-time {
position: absolute;
left: -52px;
top: 8px;
width: 40px;
text-align: right;
opacity: 0;
pointer-events: none;
}
.message-row.is-continuation:hover .group-continuation-time {
opacity: 1;
}
.sent-attachments {
display: flex;
max-width: 100%;
@@ -1000,6 +1173,16 @@ function formatDuration(seconds: number) {
user-select: none;
}
.bot-avatar-spacer {
flex: 0 0 auto;
}
.bot-avatar img {
width: 100%;
height: 100%;
object-fit: contain;
}
.bot-streaming-spinner {
margin-top: -4px;
}
@@ -1008,7 +1191,7 @@ function formatDuration(seconds: number) {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 30px;
font-size: 26px;
margin-top: -2px;
line-height: 0;
pointer-events: none;
@@ -1036,10 +1219,26 @@ function formatDuration(seconds: number) {
padding-left: 0;
}
.is-group-chat .message-bubble.bot {
padding-top: 2px;
padding-bottom: 2px;
}
.plain-content {
display: inline;
white-space: pre-wrap;
}
.mention-part {
display: inline;
width: auto;
max-width: 100%;
margin-right: 4px;
color: rgb(var(--v-theme-primary));
font-weight: 500;
overflow-wrap: anywhere;
}
.inline-message-editor {
display: flex;
flex-direction: column;
@@ -1087,6 +1286,10 @@ function formatDuration(seconds: number) {
margin: 0.25rem 0;
}
.chat-message-list :deep(.hr-node) {
margin: 24px 0;
}
.unknown-part {
max-width: 100%;
overflow-x: auto;
@@ -1280,7 +1483,7 @@ function formatDuration(seconds: number) {
}
.variant-thread .bot-avatar-symbol {
font-size: 24px;
font-size: 22px;
}
.variant-thread .from-bot .bot-avatar {

View File

@@ -0,0 +1,231 @@
<template>
<div class="group-list-shell">
<div class="group-button-wrap">
<v-btn block variant="text" class="group-btn" @click="toggleExpanded">
<v-icon size="20" class="group-action-icon mr-2">
mdi-forum
</v-icon>
<span class="group-btn-title">{{ tm("group.title") }}</span>
<v-spacer />
<v-icon size="18" class="group-toggle-icon">
{{ expanded ? "mdi-chevron-up" : "mdi-chevron-down" }}
</v-icon>
</v-btn>
</div>
<v-expand-transition>
<div v-show="expanded" class="group-list-wrap">
<button
class="group-row create-group-item"
type="button"
@click="$emit('createGroup')"
>
<span class="group-icon">
<v-icon size="18">mdi-plus</v-icon>
</span>
<span class="group-title">{{ tm("group.create") }}</span>
</button>
<button
v-for="session in sessions"
:key="session.session_id"
class="group-row group-item"
:class="{ active: selectedSessionId === session.session_id }"
type="button"
@click="$emit('selectSession', session.session_id)"
>
<span class="group-icon">
<img :src="defaultGroupAvatar" alt="" />
</span>
<span class="group-title">{{ sessionTitle(session) }}</span>
<span class="group-actions">
<v-btn
icon="mdi-pencil"
size="x-small"
variant="text"
class="edit-group-btn"
@click.stop="$emit('editSessionTitle', session)"
/>
<v-btn
icon="mdi-delete"
size="x-small"
variant="text"
class="delete-group-btn"
color="error"
@click.stop="handleDeleteSession(session)"
/>
</span>
</button>
</div>
</v-expand-transition>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useModuleI18n } from "@/i18n/composables";
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
import type { Session } from "@/composables/useSessions";
import defaultGroupAvatar from "@/assets/images/chatui-group-default-avatar.png";
const props = withDefaults(
defineProps<{
sessions: Session[];
selectedSessionId?: string | null;
initialExpanded?: boolean;
}>(),
{
selectedSessionId: null,
initialExpanded: false,
},
);
const emit = defineEmits<{
createGroup: [];
selectSession: [sessionId: string];
editSessionTitle: [session: Session];
deleteSession: [session: Session];
}>();
const { tm } = useModuleI18n("features/chat");
const confirmDialog = useConfirmDialog();
const expanded = ref(props.initialExpanded);
const savedGroupsExpandedState = localStorage.getItem("groupsExpanded");
if (savedGroupsExpandedState !== null) {
expanded.value = JSON.parse(savedGroupsExpandedState);
}
function toggleExpanded() {
expanded.value = !expanded.value;
localStorage.setItem("groupsExpanded", JSON.stringify(expanded.value));
}
function sessionTitle(session: Session) {
return session.display_name?.trim() || tm("group.defaultName");
}
async function handleDeleteSession(session: Session) {
const message = tm("conversation.confirmDelete", {
name: sessionTitle(session),
});
if (await askForConfirmation(message, confirmDialog)) {
emit("deleteSession", session);
}
}
</script>
<style scoped>
.group-list-shell {
margin-top: 6px;
}
.group-button-wrap {
opacity: 0.6;
}
.group-btn {
justify-content: flex-start;
background-color: transparent !important;
border-radius: 8px;
padding: 8px 12px !important;
text-transform: none;
font-weight: 500;
}
.group-action-icon {
color: currentcolor;
}
.group-btn-title {
min-width: 0;
}
.group-toggle-icon {
margin-left: 10px;
}
.group-list-wrap {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 8px;
}
.group-row {
width: 100%;
min-height: 38px;
border: 0;
border-radius: 8px;
background: transparent;
color: inherit;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
text-align: left;
}
.group-row:hover,
.group-row.active {
background: var(--chat-session-active-bg);
}
.group-item:hover .group-actions {
opacity: 1;
visibility: visible;
}
.group-icon {
width: 20px;
flex: 0 0 20px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.group-icon img {
width: 20px;
height: 20px;
border-radius: 6px;
object-fit: cover;
}
.group-title {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 500;
}
.group-actions {
display: flex;
gap: 2px;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
}
.edit-group-btn,
.delete-group-btn {
opacity: 0.7;
transition: opacity 0.2s ease;
}
.edit-group-btn:hover,
.delete-group-btn:hover {
opacity: 1;
}
.create-group-item {
opacity: 0.7;
}
.create-group-item:hover {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,272 @@
<template>
<transition name="slide-left">
<aside v-if="modelValue" class="group-panel">
<div class="group-panel-header">
<div class="group-panel-title">{{ tm("group.panelTitle") }}</div>
<v-btn icon="mdi-close" size="small" variant="text" @click="close" />
</div>
<section class="group-profile">
<v-avatar class="group-avatar" size="86" rounded="lg">
<img :src="groupAvatar" alt="" />
</v-avatar>
<div class="group-name">{{ groupName }}</div>
<p v-if="groupDescription" class="group-description">
{{ groupDescription }}
</p>
</section>
<section class="group-bots-section">
<div class="group-bots-header">
<div class="group-bots-title">{{ tm("group.members") }}</div>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-robot-outline"
@click="$emit('addBot')"
>
{{ tm("group.addBot") }}
</v-btn>
</div>
<div v-if="!bots.length" class="group-empty">
{{ tm("group.addBotHint") }}
</div>
<div v-else class="group-bot-list">
<div v-for="bot in bots" :key="bot.bot_id" class="group-bot-row">
<v-avatar size="34" rounded="lg">
<img :src="bot.avatar || defaultGroupAvatar" alt="" />
</v-avatar>
<div class="group-bot-meta">
<div class="group-bot-name-line">
<div class="group-bot-name">@{{ bot.name }}</div>
<v-chip class="group-bot-chip" size="x-small" variant="tonal">
{{ tm("group.botBadge") }}
</v-chip>
</div>
<div class="group-bot-config">{{ bot.conf_id }}</div>
</div>
<v-btn
icon="mdi-delete-outline"
size="x-small"
variant="text"
color="error"
@click="$emit('deleteBot', bot)"
/>
</div>
</div>
</section>
</aside>
</transition>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useModuleI18n } from "@/i18n/composables";
import type { GroupBot, GroupProfile } from "@/composables/useMessages";
import defaultGroupAvatar from "@/assets/images/chatui-group-default-avatar.png";
const props = defineProps<{
modelValue: boolean;
group: GroupProfile | null;
bots: GroupBot[];
fallbackName: string;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
addBot: [];
deleteBot: [bot: GroupBot];
}>();
const { tm } = useModuleI18n("features/chat");
const groupName = computed(() => props.group?.name || props.fallbackName);
const groupAvatar = computed(() => props.group?.avatar || defaultGroupAvatar);
const groupDescription = computed(() => props.group?.description || "");
function close() {
emit("update:modelValue", false);
}
</script>
<style scoped>
.group-panel {
width: 360px;
height: 100%;
display: flex;
flex-direction: column;
flex-shrink: 0;
border-left: 1px solid rgba(var(--v-border-color), 0.16);
background: rgb(var(--v-theme-surface));
color: rgb(var(--v-theme-on-surface));
}
.slide-left-enter-active,
.slide-left-leave-active {
transition: all 0.3s ease;
}
.slide-left-enter-from,
.slide-left-leave-to {
transform: translateX(100%);
opacity: 0;
}
.group-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: 14px 16px 8px;
}
.group-panel-title,
.group-bots-title {
color: rgb(var(--v-theme-on-surface));
font-size: 16px;
font-weight: 600;
line-height: 1.4;
}
.group-bots-title {
font-size: 14px;
}
.group-profile {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 22px 22px;
text-align: center;
}
.group-avatar img {
width: 100%;
height: 100%;
object-fit: contain;
}
.group-name {
max-width: 100%;
margin-top: 12px;
font-size: 18px;
font-weight: 800;
overflow-wrap: anywhere;
}
.group-description {
margin: 8px 0 0;
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 13px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.group-bots-section {
flex: 1;
min-height: 0;
padding: 0 16px 18px;
overflow-y: auto;
}
.group-bots-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
}
.group-empty {
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 13px;
}
.group-bot-list {
display: grid;
gap: 8px;
}
.group-bot-row {
display: flex;
align-items: center;
gap: 10px;
min-height: 50px;
padding: 8px 10px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 8px;
}
.group-bot-row img {
width: 100%;
height: 100%;
object-fit: contain;
}
.group-bot-meta {
min-width: 0;
flex: 1;
}
.group-bot-name-line,
.group-bot-config {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-bot-name-line {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.group-bot-name {
min-width: 0;
font-size: 14px;
font-weight: 650;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-bot-chip {
flex-shrink: 0;
height: 18px;
font-size: 11px;
font-weight: 500;
}
.group-bot-config {
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 12px;
}
@media (max-width: 760px) {
.group-panel {
position: fixed;
inset: 0;
z-index: 1300;
width: 100vw;
height: 100dvh;
border-left: 0;
}
.group-panel-header {
min-height: 52px;
padding: calc(10px + env(safe-area-inset-top)) 12px 8px;
border-bottom: 1px solid rgba(var(--v-border-color), 0.12);
}
.group-profile {
padding: 22px 18px 20px;
}
.group-bots-section {
padding: 0 12px calc(14px + env(safe-area-inset-bottom));
}
}
</style>

View File

@@ -0,0 +1,479 @@
<template>
<transition name="slide-left">
<aside v-if="modelValue" class="workspace-panel">
<div class="workspace-panel-header">
<div class="workspace-panel-title">{{ tm("workspace.title") }}</div>
<div class="workspace-panel-actions">
<v-btn
icon="mdi-refresh"
size="small"
variant="text"
:title="tm('workspace.refresh')"
:loading="loading"
@click="loadFiles(currentPath)"
/>
<v-btn icon="mdi-close" size="small" variant="text" @click="close" />
</div>
</div>
<div v-if="breadcrumbs.length" class="workspace-breadcrumb">
<template v-for="crumb in breadcrumbs" :key="crumb.path">
<v-icon v-if="crumb.path !== breadcrumbs[0].path" size="14">
mdi-chevron-right
</v-icon>
<button
type="button"
class="breadcrumb-item"
@click="loadFiles(crumb.path)"
>
{{ crumb.name }}
</button>
</template>
</div>
<div class="workspace-body">
<div class="workspace-content">
<div v-if="loading" class="workspace-state">
<v-progress-circular indeterminate size="24" width="2" />
</div>
<div v-else-if="error" class="workspace-state workspace-error">
{{ error }}
</div>
<div v-else-if="!entries.length" class="workspace-state">
{{ tm("workspace.empty") }}
</div>
<div v-else class="workspace-list">
<button
v-if="currentPath"
type="button"
class="workspace-row"
@click="loadFiles(parentPath)"
>
<v-icon size="18">mdi-arrow-up</v-icon>
<span class="workspace-name">..</span>
</button>
<button
v-for="entry in entries"
:key="entry.path"
type="button"
class="workspace-row"
:class="{ active: selectedFile?.path === entry.path }"
:disabled="entry.type === 'file' && !entry.previewable"
@click="openEntry(entry)"
>
<v-icon size="18">
{{
entry.type === "directory"
? "mdi-folder-outline"
: "mdi-file-document-outline"
}}
</v-icon>
<span class="workspace-name">{{ entry.name }}</span>
<span v-if="entry.type === 'file'" class="workspace-size">
{{ formatSize(entry.size) }}
</span>
</button>
</div>
</div>
<section class="workspace-preview">
<div class="preview-header">
<div class="preview-title">
{{ selectedFile?.name || tm("workspace.preview") }}
</div>
<v-progress-circular
v-if="previewLoading"
indeterminate
size="18"
width="2"
/>
</div>
<pre v-if="previewContent" class="preview-body">{{ previewContent }}</pre>
<div v-else class="preview-empty">
{{ tm("workspace.selectFile") }}
</div>
</section>
</div>
</aside>
</transition>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import axios from "axios";
import { useModuleI18n } from "@/i18n/composables";
type WorkspaceEntry = {
name: string;
path: string;
type: "directory" | "file";
size: number | null;
previewable: boolean;
};
const props = defineProps<{
modelValue: boolean;
sessionId: string | null;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
}>();
const { tm } = useModuleI18n("features/chat");
const currentPath = ref("");
const entries = ref<WorkspaceEntry[]>([]);
const loading = ref(false);
const error = ref("");
const selectedFile = ref<WorkspaceEntry | null>(null);
const previewContent = ref("");
const previewLoading = ref(false);
const breadcrumbs = computed(() => {
if (!currentPath.value) return [];
const parts = currentPath.value.split("/").filter(Boolean);
return parts.map((name, index) => ({
name,
path: parts.slice(0, index + 1).join("/"),
}));
});
const parentPath = computed(() => {
const parts = currentPath.value.split("/").filter(Boolean);
parts.pop();
return parts.join("/");
});
watch(
() => [props.modelValue, props.sessionId],
([open, sessionId]) => {
if (open && sessionId) {
currentPath.value = "";
selectedFile.value = null;
previewContent.value = "";
loadFiles(currentPath.value);
}
},
{ immediate: true },
);
function close() {
emit("update:modelValue", false);
}
async function loadFiles(path: string) {
if (!props.sessionId) {
entries.value = [];
error.value = tm("workspace.noSession");
return;
}
loading.value = true;
error.value = "";
try {
const response = await axios.get("/api/chat/workspace/list_files", {
params: {
session_id: props.sessionId,
path,
},
});
if (response.data?.status !== "ok") {
throw new Error(response.data?.message || tm("workspace.loadFailed"));
}
currentPath.value = response.data.data?.path || "";
entries.value = response.data.data?.entries || [];
} catch (err) {
error.value = axios.isAxiosError(err)
? err.response?.data?.message || err.message
: String((err as Error)?.message || err);
entries.value = [];
} finally {
loading.value = false;
}
}
async function openEntry(entry: WorkspaceEntry) {
if (entry.type === "directory") {
await loadFiles(entry.path);
return;
}
if (entry.previewable) {
await previewFile(entry);
}
}
async function previewFile(entry: WorkspaceEntry) {
if (!props.sessionId) return;
selectedFile.value = entry;
previewLoading.value = true;
previewContent.value = "";
try {
const response = await axios.get("/api/chat/workspace/download_file", {
params: {
session_id: props.sessionId,
path: entry.path,
},
});
if (response.data?.status !== "ok") {
throw new Error(response.data?.message || tm("workspace.previewFailed"));
}
previewContent.value = response.data.data?.content || "";
} catch (err) {
previewContent.value = axios.isAxiosError(err)
? err.response?.data?.message || err.message
: String((err as Error)?.message || err);
} finally {
previewLoading.value = false;
}
}
function formatSize(size: number | null) {
if (size == null) return "";
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / 1024 / 1024).toFixed(1)} MB`;
}
</script>
<style scoped>
.workspace-panel {
width: min(480px, 56vw);
height: 100%;
border-left: 1px solid rgba(var(--v-theme-on-surface), 0.1);
background: rgb(var(--v-theme-surface));
color: rgb(var(--v-theme-on-surface));
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.slide-left-enter-active,
.slide-left-leave-active {
transition: all 0.2s ease;
}
.slide-left-enter-from,
.slide-left-leave-to {
transform: translateX(100%);
opacity: 0;
}
.workspace-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 8px;
}
.workspace-panel-title {
font-size: 16px;
font-weight: 600;
line-height: 1.4;
}
.workspace-panel-actions {
display: flex;
align-items: center;
gap: 4px;
}
.workspace-breadcrumb {
display: flex;
align-items: center;
gap: 4px;
min-height: 34px;
padding: 0 16px 8px;
overflow-x: auto;
color: rgba(var(--v-theme-on-surface), 0.62);
white-space: nowrap;
}
.breadcrumb-item {
border: 0;
padding: 3px 4px;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.breadcrumb-item:hover {
color: rgb(var(--v-theme-on-surface));
}
.workspace-body {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(170px, 38%) minmax(260px, 1fr);
border-top: 1px solid rgba(var(--v-border-color), 0.1);
}
.workspace-content {
min-height: 0;
overflow-y: auto;
padding: 10px;
border-right: 1px solid rgba(var(--v-border-color), 0.14);
}
.workspace-state {
min-height: 110px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 13px;
text-align: center;
}
.workspace-error {
color: rgb(var(--v-theme-error));
}
.workspace-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.workspace-row {
width: 100%;
min-height: 36px;
border: 0;
border-radius: 8px;
padding: 0 8px;
display: flex;
align-items: center;
gap: 8px;
background: transparent;
color: inherit;
cursor: pointer;
text-align: left;
}
.workspace-row:hover,
.workspace-row.active {
background: rgba(var(--v-theme-on-surface), 0.06);
}
.workspace-row:disabled {
cursor: default;
opacity: 0.5;
}
.workspace-row:disabled:hover {
background: transparent;
}
.workspace-name {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.workspace-size {
flex-shrink: 0;
color: rgba(var(--v-theme-on-surface), 0.56);
font-size: 12px;
}
.workspace-preview {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.preview-header {
min-height: 42px;
padding: 10px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.preview-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 600;
}
.preview-body {
flex: 1;
min-height: 0;
margin: 0;
padding: 0 12px 12px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
color: rgba(var(--v-theme-on-surface), 0.84);
font-size: 12px;
line-height: 1.55;
}
.preview-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
color: rgba(var(--v-theme-on-surface), 0.58);
font-size: 13px;
text-align: center;
}
@media (max-width: 760px) {
.workspace-panel {
position: fixed;
inset: 0;
z-index: 1300;
width: 100vw;
height: 100dvh;
border-left: 0;
}
.workspace-row {
margin-top: 8px;
}
.workspace-panel-header {
min-height: 52px;
padding: calc(10px + env(safe-area-inset-top)) 12px 8px;
border-bottom: 1px solid rgba(var(--v-border-color), 0.12);
}
.workspace-breadcrumb {
padding: 8px 12px;
}
.workspace-body {
display: flex;
flex-direction: column;
border-top: 0;
gap: 8px;
}
.workspace-content {
flex: 1 1 0;
padding: 0 8px;
border-right: 0;
}
.workspace-preview {
flex: 0 0 42%;
min-height: 180px;
border-top: 1px solid rgba(var(--v-border-color), 0.14);
margin-top: 0;
}
}
</style>

View File

@@ -56,22 +56,60 @@ export function useMediaHandling() {
}
}
async function compressImageFile(file: File, options: { maxEdge?: number; quality?: number } = {}) {
if (!file.type.startsWith('image/') || file.type === 'image/gif') {
return file;
}
const { maxEdge = 512, quality = 0.82 } = options;
const image = await createImageBitmap(file);
const scale = Math.min(1, maxEdge / Math.max(image.width, image.height));
const width = Math.max(1, Math.round(image.width * scale));
const height = Math.max(1, Math.round(image.height * scale));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return file;
ctx.drawImage(image, 0, 0, width, height);
image.close();
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/jpeg', quality);
});
if (!blob) return file;
const basename = file.name.replace(/\.[^.]+$/, '') || 'image';
return new File([blob], `${basename}.jpg`, {
type: 'image/jpeg',
lastModified: Date.now(),
});
}
async function uploadFile(file: File) {
const formData = new FormData();
formData.append('file', file);
const response = await axios.post('/api/chat/post_file', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return response.data.data as {
attachment_id: string;
filename: string;
type: string;
};
}
async function uploadStagedFile(file: File) {
const signature = await getFileSignature(file);
if (isDuplicateFile(signature)) return;
pendingFileSignatures.add(signature);
const formData = new FormData();
formData.append('file', file);
try {
const response = await axios.post('/api/chat/post_file', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
const { attachment_id, filename, type } = response.data.data;
const { attachment_id, filename, type } = await uploadFile(file);
stagedFiles.value.push({
attachment_id,
filename,
@@ -88,13 +126,22 @@ export function useMediaHandling() {
}
async function processAndUploadImage(file: File) {
await uploadStagedFile(file);
await uploadStagedFile(await compressImageFile(file));
}
async function processAndUploadFile(file: File) {
await uploadStagedFile(file);
}
async function uploadImageAttachment(file: File) {
const compressed = await compressImageFile(file);
const uploaded = await uploadFile(compressed);
return {
...uploaded,
url: URL.createObjectURL(compressed),
};
}
async function handlePaste(event: ClipboardEvent) {
const items = event.clipboardData?.items;
if (!items) return;
@@ -188,6 +235,8 @@ export function useMediaHandling() {
stagedFiles,
stagedNonImageFiles,
getMediaFile,
compressImageFile,
uploadImageAttachment,
processAndUploadImage,
processAndUploadFile,
handlePaste,

View File

@@ -6,6 +6,9 @@ export type TransportMode = "sse" | "websocket";
export interface MessagePart {
type: string;
text?: string;
target?: string;
bot_id?: string;
name?: string;
message_id?: string | number;
selected_text?: string;
embedded_url?: string;
@@ -61,6 +64,29 @@ export interface ChatSessionProject {
emoji?: string;
}
export interface GroupBot {
bot_id: string;
session_id?: string;
name: string;
avatar?: string;
avatar_attachment_id?: string;
conf_id: string;
platform_id?: string;
}
export interface GroupProfile {
session_id: string;
name: string;
avatar?: string;
avatar_attachment_id?: string;
description?: string;
}
export interface TypingState {
sender_id: string;
sender_name: string;
}
interface ActiveConnection {
sessionId: string;
messageId: string;
@@ -77,8 +103,12 @@ interface SendMessageStreamOptions {
enableStreaming?: boolean;
selectedProvider?: string;
selectedModel?: string;
targetBotId?: string;
senderId?: string;
senderName?: string;
usePersistentWebSocket?: boolean;
userRecord?: ChatRecord;
botRecord: ChatRecord;
botRecord?: ChatRecord;
skipUserHistory?: boolean;
llmCheckpointId?: string | null;
}
@@ -95,6 +125,25 @@ interface CreateLocalExchangeOptions {
sessionId: string;
messageId: string;
parts: MessagePart[];
includeBot?: boolean;
}
interface ActiveBotRecordState {
current?: ChatRecord;
startNewAfterSave: boolean;
}
interface PendingChatRequest {
botState: ActiveBotRecordState;
userRecord?: ChatRecord;
}
interface PersistentChatConnection {
sessionId: string;
ws: WebSocket;
openPromise: Promise<void>;
pending: Record<string, PendingChatRequest>;
subscriptionBotStates: Record<string, ActiveBotRecordState>;
}
interface UseMessagesOptions {
@@ -113,6 +162,12 @@ export function useMessages(options: UseMessagesOptions) {
const sessionProjects = reactive<Record<string, ChatSessionProject | null>>(
{},
);
const groupBotsBySession = reactive<Record<string, GroupBot[]>>({});
const groupProfilesBySession = reactive<Record<string, GroupProfile | null>>({});
const typingBySession = reactive<Record<string, TypingState[]>>({});
const persistentChatConnections = reactive<Record<string, PersistentChatConnection>>(
{},
);
const activeMessages = computed(() =>
options.currentSessionId.value
@@ -132,6 +187,24 @@ export function useMessages(options: UseMessagesOptions) {
return Boolean(activeConnections[sessionId]);
}
function setTypingState(sessionId: string, data: unknown) {
const payload = data && typeof data === "object" ? data as Record<string, unknown> : {};
const senderId = String(payload.sender_id || "");
const senderName = String(payload.sender_name || senderId);
if (!senderId) return;
const current = typingBySession[sessionId] || [];
if (payload.typing) {
typingBySession[sessionId] = [
...current.filter((item) => item.sender_id !== senderId),
{ sender_id: senderId, sender_name: senderName },
];
return;
}
typingBySession[sessionId] = current.filter(
(item) => item.sender_id !== senderId,
);
}
function isUserMessage(msg: ChatRecord) {
return messageContent(msg).type === "user";
}
@@ -212,6 +285,8 @@ export function useMessages(options: UseMessagesOptions) {
await resolveRecordMedia(records);
messagesBySession[sessionId] = records;
sessionProjects[sessionId] = normalizeSessionProject(payload.project);
groupBotsBySession[sessionId] = normalizeGroupBots(payload.group_bots);
groupProfilesBySession[sessionId] = normalizeGroupProfile(payload.group);
loadedSessions[sessionId] = true;
} catch (error) {
console.error("Failed to load session messages:", error);
@@ -225,6 +300,7 @@ export function useMessages(options: UseMessagesOptions) {
sessionId,
messageId,
parts,
includeBot = true,
}: CreateLocalExchangeOptions) {
loadedSessions[sessionId] = true;
messagesBySession[sessionId] = messagesBySession[sessionId] || [];
@@ -238,23 +314,32 @@ export function useMessages(options: UseMessagesOptions) {
},
};
const botRecord: ChatRecord = {
id: `local-bot-${messageId}`,
created_at: new Date().toISOString(),
content: {
type: "bot",
message: [{ type: "plain", text: "" }],
reasoning: "",
isLoading: true,
},
};
let botRecord: ChatRecord | undefined;
if (includeBot) {
botRecord = {
id: `local-bot-${messageId}`,
created_at: new Date().toISOString(),
content: {
type: "bot",
message: [{ type: "plain", text: "" }],
reasoning: "",
isLoading: true,
},
};
}
messagesBySession[sessionId].push(userRecord, botRecord);
messagesBySession[sessionId].push(
...([userRecord, botRecord].filter(Boolean) as ChatRecord[]),
);
const sessionMessages = messagesBySession[sessionId];
return {
userRecord: sessionMessages[sessionMessages.length - 2],
botRecord: sessionMessages[sessionMessages.length - 1],
userRecord: includeBot
? sessionMessages[sessionMessages.length - 2]
: sessionMessages[sessionMessages.length - 1],
botRecord: includeBot
? sessionMessages[sessionMessages.length - 1]
: undefined,
};
}
@@ -266,12 +351,34 @@ export function useMessages(options: UseMessagesOptions) {
enableStreaming = true,
selectedProvider = "",
selectedModel = "",
targetBotId = "",
senderId = "",
senderName = "",
usePersistentWebSocket = false,
botRecord,
userRecord,
skipUserHistory = false,
llmCheckpointId = null,
}: SendMessageStreamOptions) {
if (transport === "websocket") {
if (usePersistentWebSocket) {
const connection = ensurePersistentChatConnection(sessionId);
sendPersistentChatMessage(
connection,
sessionId,
messageId,
parts,
botRecord,
userRecord,
enableStreaming,
selectedProvider,
selectedModel,
targetBotId,
senderId,
senderName,
);
return;
}
startWebSocketStream(
sessionId,
messageId,
@@ -281,6 +388,9 @@ export function useMessages(options: UseMessagesOptions) {
enableStreaming,
selectedProvider,
selectedModel,
targetBotId,
senderId,
senderName,
);
return;
}
@@ -293,6 +403,9 @@ export function useMessages(options: UseMessagesOptions) {
enableStreaming,
selectedProvider,
selectedModel,
targetBotId,
senderId,
senderName,
skipUserHistory,
llmCheckpointId,
);
@@ -368,6 +481,9 @@ export function useMessages(options: UseMessagesOptions) {
enableStreaming,
selectedProvider,
selectedModel,
"",
"",
"",
true,
sourceRecord.llm_checkpoint_id || null,
);
@@ -422,8 +538,9 @@ export function useMessages(options: UseMessagesOptions) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.message || "Regenerate failed.");
}
const botState = createBotRecordState(botRecord);
await readSseStream(response.body, (payload) => {
processStreamPayload(botRecord, payload);
processStreamPayload(botState, payload, undefined, sessionId);
options.onStreamUpdate?.(sessionId);
});
} catch (error) {
@@ -447,6 +564,76 @@ export function useMessages(options: UseMessagesOptions) {
connection.abort?.abort();
connection.ws?.close();
});
Object.values(persistentChatConnections).forEach((connection) => {
connection.ws.close();
});
}
function closePersistentChatConnections(exceptSessionId = "") {
Object.entries(persistentChatConnections).forEach(([sessionId, connection]) => {
if (sessionId === exceptSessionId) return;
connection.ws.close();
delete persistentChatConnections[sessionId];
});
}
function ensurePersistentChatConnection(sessionId: string) {
const existing = persistentChatConnections[sessionId];
if (
existing &&
(existing.ws.readyState === WebSocket.CONNECTING ||
existing.ws.readyState === WebSocket.OPEN)
) {
return existing;
}
const token = encodeURIComponent(localStorage.getItem("token") || "");
const ws = createUnifiedChatWebSocket(token);
let didOpen = false;
let resolveOpen: () => void = () => {};
let rejectOpen: (error: Event) => void = () => {};
const openPromise = new Promise<void>((resolve, reject) => {
resolveOpen = resolve;
rejectOpen = reject;
});
const connection: PersistentChatConnection = {
sessionId,
ws,
openPromise,
pending: {},
subscriptionBotStates: {},
};
persistentChatConnections[sessionId] = connection;
ws.onopen = () => {
didOpen = true;
ws.send(JSON.stringify({ ct: "chat", t: "bind", session_id: sessionId }));
resolveOpen();
};
ws.onmessage = (event) => {
handlePersistentChatPayload(connection, event.data);
};
ws.onerror = (event) => {
rejectOpen(event);
Object.values(connection.pending).forEach((pending) => {
if (pending.botState.current) {
appendPlain(pending.botState.current, "\n\nWebSocket connection failed.");
}
});
};
ws.onclose = async () => {
if (!didOpen) rejectOpen(new Event("close"));
if (persistentChatConnections[sessionId] === connection) {
delete persistentChatConnections[sessionId];
}
Object.keys(connection.pending).forEach((messageId) => {
delete activeConnections[sessionId];
delete connection.pending[messageId];
});
await options.onSessionsChanged?.();
};
return connection;
}
function normalizeHistoryRecord(record: any): ChatRecord {
@@ -495,11 +682,14 @@ export function useMessages(options: UseMessagesOptions) {
sessionId: string,
messageId: string,
parts: MessagePart[],
botRecord: ChatRecord,
botRecord: ChatRecord | undefined,
userRecord: ChatRecord | undefined,
enableStreaming: boolean,
selectedProvider: string,
selectedModel: string,
targetBotId: string,
senderId: string,
senderName: string,
skipUserHistory = false,
llmCheckpointId: string | null = null,
) {
@@ -523,6 +713,9 @@ export function useMessages(options: UseMessagesOptions) {
enable_streaming: enableStreaming,
selected_provider: selectedProvider,
selected_model: selectedModel,
target_bot_id: targetBotId || undefined,
sender_id: senderId || undefined,
sender_name: senderName || undefined,
_skip_user_history: skipUserHistory,
_llm_checkpoint_id: llmCheckpointId || undefined,
}),
@@ -532,14 +725,17 @@ export function useMessages(options: UseMessagesOptions) {
if (!response.ok || !response.body) {
throw new Error(`SSE connection failed: ${response.status}`);
}
const botState = createBotRecordState(botRecord);
await readSseStream(response.body, (payload) => {
processStreamPayload(botRecord, payload, userRecord);
processStreamPayload(botState, payload, userRecord, sessionId);
options.onStreamUpdate?.(sessionId);
});
})
.catch((error) => {
if (abort.signal.aborted) return;
appendPlain(botRecord, `\n\n${String(error?.message || error)}`);
if (botRecord) {
appendPlain(botRecord, `\n\n${String(error?.message || error)}`);
}
console.error("SSE chat failed:", error);
})
.finally(async () => {
@@ -552,17 +748,17 @@ export function useMessages(options: UseMessagesOptions) {
sessionId: string,
messageId: string,
parts: MessagePart[],
botRecord: ChatRecord,
botRecord: ChatRecord | undefined,
userRecord: ChatRecord | undefined,
enableStreaming: boolean,
selectedProvider: string,
selectedModel: string,
targetBotId: string,
senderId: string,
senderName: string,
) {
const token = encodeURIComponent(localStorage.getItem("token") || "");
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(
`${protocol}//${window.location.host}/api/unified_chat/ws?token=${token}`,
);
const ws = createUnifiedChatWebSocket(token);
activeConnections[sessionId] = {
sessionId,
@@ -582,13 +778,17 @@ export function useMessages(options: UseMessagesOptions) {
enable_streaming: enableStreaming,
selected_provider: selectedProvider,
selected_model: selectedModel,
target_bot_id: targetBotId || undefined,
sender_id: senderId || undefined,
sender_name: senderName || undefined,
}),
);
};
const botState = createBotRecordState(botRecord);
ws.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
processStreamPayload(botRecord, payload, userRecord);
processStreamPayload(botState, payload, userRecord, sessionId);
options.onStreamUpdate?.(sessionId);
if (payload.type === "end" || payload.t === "end") {
ws.close();
@@ -598,7 +798,9 @@ export function useMessages(options: UseMessagesOptions) {
}
};
ws.onerror = () => {
appendPlain(botRecord, "\n\nWebSocket connection failed.");
if (botRecord) {
appendPlain(botRecord, "\n\nWebSocket connection failed.");
}
};
ws.onclose = async () => {
delete activeConnections[sessionId];
@@ -606,10 +808,154 @@ export function useMessages(options: UseMessagesOptions) {
};
}
function sendPersistentChatMessage(
connection: PersistentChatConnection,
sessionId: string,
messageId: string,
parts: MessagePart[],
botRecord: ChatRecord | undefined,
userRecord: ChatRecord | undefined,
enableStreaming: boolean,
selectedProvider: string,
selectedModel: string,
targetBotId: string,
senderId: string,
senderName: string,
) {
connection.pending[messageId] = {
botState: createBotRecordState(botRecord),
userRecord,
};
activeConnections[sessionId] = {
sessionId,
messageId,
transport: "websocket",
};
connection.openPromise
.then(() => {
if (connection.ws.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket connection is not open.");
}
connection.ws.send(
JSON.stringify({
ct: "chat",
t: "send",
session_id: sessionId,
message_id: messageId,
message: parts.map(partToPayload),
enable_streaming: enableStreaming,
selected_provider: selectedProvider,
selected_model: selectedModel,
target_bot_id: targetBotId || undefined,
sender_id: senderId || undefined,
sender_name: senderName || undefined,
}),
);
})
.catch((error) => {
delete activeConnections[sessionId];
delete connection.pending[messageId];
if (botRecord) {
appendPlain(botRecord, `\n\n${String(error?.message || error)}`);
}
console.error("Persistent WebSocket chat failed:", error);
});
}
function handlePersistentChatPayload(
connection: PersistentChatConnection,
rawPayload: string,
) {
let payload: any;
try {
payload = JSON.parse(rawPayload);
} catch (error) {
console.error("Failed to parse WebSocket payload:", error);
return;
}
const normalized =
payload?.ct === "chat"
? { ...payload, type: payload.type || payload.t }
: payload;
const msgType = normalized?.type || normalized?.t;
if (msgType === "session_bound") return;
const messageId = String(normalized?.message_id || "");
const pending = messageId ? connection.pending[messageId] : undefined;
if (pending) {
if (
isGroupChatSession(connection.sessionId) &&
msgType !== "user_message_saved"
) {
processSubscriptionPayload(connection, normalized, msgType);
options.onStreamUpdate?.(connection.sessionId);
return;
}
processStreamPayload(
pending.botState,
normalized,
pending.userRecord,
connection.sessionId,
);
if (msgType === "end") {
delete activeConnections[connection.sessionId];
delete connection.pending[messageId];
void options.onSessionsChanged?.();
}
options.onStreamUpdate?.(connection.sessionId);
return;
}
processSubscriptionPayload(connection, normalized, msgType);
options.onStreamUpdate?.(connection.sessionId);
}
function processSubscriptionPayload(
connection: PersistentChatConnection,
normalized: any,
msgType: string | undefined,
) {
const subscriptionStateKey = getSubscriptionStateKey(normalized);
const subscriptionState =
connection.subscriptionBotStates[subscriptionStateKey] ||
createBotRecordState(undefined);
connection.subscriptionBotStates[subscriptionStateKey] = subscriptionState;
ensureSubscriptionBotRecord(subscriptionState, connection.sessionId, normalized);
processStreamPayload(
subscriptionState,
normalized,
undefined,
connection.sessionId,
);
if (msgType === "complete" || msgType === "end") {
delete connection.subscriptionBotStates[subscriptionStateKey];
void options.onSessionsChanged?.();
}
}
function getSubscriptionStateKey(payload: any) {
const messageId = String(payload?.message_id || "");
if (messageId) return messageId;
const senderId = String(payload?.sender_id || payload?.data?.sender_id || "");
return senderId ? `sender:${senderId}` : "__default__";
}
function ensureSubscriptionBotRecord(
state: ActiveBotRecordState,
sessionId: string,
payload: any,
) {
if (state.current || !startsNewBotRecord(payload)) return;
state.current = appendFollowupBotRecord(sessionId);
}
function processStreamPayload(
botRecord: ChatRecord,
botState: ActiveBotRecordState,
payload: any,
userRecord?: ChatRecord,
sessionId = options.currentSessionId.value,
) {
const normalized =
payload?.ct === "chat"
@@ -620,6 +966,10 @@ export function useMessages(options: UseMessagesOptions) {
const data = normalized?.data ?? "";
if (msgType === "session_id" || msgType === "session_bound") return;
if (msgType === "typing") {
setTypingState(sessionId, data);
return;
}
if (msgType === "user_message_saved") {
if (userRecord) {
userRecord.id = data?.id || userRecord.id;
@@ -629,15 +979,33 @@ export function useMessages(options: UseMessagesOptions) {
}
return;
}
const botRecord = getCurrentBotRecord(botState, sessionId, normalized);
if (!botRecord) return;
if (normalized.sender_id) {
botRecord.sender_id = String(normalized.sender_id);
}
if (normalized.sender_name) {
botRecord.sender_name = String(normalized.sender_name);
}
if (msgType === "message_saved") {
if (data?.sender_id) {
setTypingState(sessionId, {
typing: false,
sender_id: data.sender_id,
sender_name: data.sender_name,
});
}
markMessageStarted(botRecord);
botRecord.id = data?.id || botRecord.id;
botRecord.created_at = data?.created_at || botRecord.created_at;
botRecord.llm_checkpoint_id =
data?.llm_checkpoint_id || botRecord.llm_checkpoint_id;
botRecord.sender_id = data?.sender_id || botRecord.sender_id;
botRecord.sender_name = data?.sender_name || botRecord.sender_name;
if (data?.refs) {
messageContent(botRecord).refs = data.refs;
}
botState.startNewAfterSave = true;
return;
}
if (msgType === "agent_stats" || chainType === "agent_stats") {
@@ -652,6 +1020,9 @@ export function useMessages(options: UseMessagesOptions) {
}
if (msgType === "complete" || msgType === "break") {
markMessageStarted(botRecord);
if (typeof normalized.reasoning === "string" && normalized.reasoning) {
messageContent(botRecord).reasoning = normalized.reasoning;
}
const finalText = payloadText(data);
if (finalText && !hasPlainText(botRecord)) {
appendPlain(botRecord, finalText, false);
@@ -659,19 +1030,33 @@ export function useMessages(options: UseMessagesOptions) {
return;
}
if (msgType === "end") {
if (botRecord.sender_id) {
setTypingState(sessionId, {
typing: false,
sender_id: botRecord.sender_id,
sender_name: botRecord.sender_name,
});
}
markMessageStarted(botRecord);
return;
}
if (msgType === "plain") {
markMessageStarted(botRecord);
if (botRecord.sender_id) {
setTypingState(sessionId, {
typing: false,
sender_id: botRecord.sender_id,
sender_name: botRecord.sender_name,
});
}
if (chainType === "reasoning") {
messageContent(botRecord).reasoning = `${
messageContent(botRecord).reasoning || ""
}${payloadText(data)}`;
return;
}
if (chainType === "tool_call") {
if (chainType === "tool_call" && !isGroupChatSession(sessionId)) {
upsertToolCall(botRecord, parseJsonSafe(data));
return;
}
@@ -679,7 +1064,30 @@ export function useMessages(options: UseMessagesOptions) {
finishToolCall(botRecord, parseJsonSafe(data));
return;
}
appendPlain(botRecord, payloadText(data), normalized.streaming !== false);
appendPlain(
botRecord,
payloadText(data),
isGroupChatSession(sessionId) || normalized.streaming !== false,
);
return;
}
if (msgType === "chain") {
markMessageStarted(botRecord);
const parts = Array.isArray(data) ? data : [];
for (const part of parts) {
if (!part || typeof part !== "object") continue;
if (isGroupChatSession(sessionId) && part.type === "tool_call") continue;
if (part.type === "plain") {
appendPlain(
botRecord,
String(part.text || ""),
isGroupChatSession(sessionId) || normalized.streaming !== false,
);
} else {
messageContent(botRecord).message.push({ ...part });
}
}
return;
}
@@ -700,6 +1108,68 @@ export function useMessages(options: UseMessagesOptions) {
messageContent(botRecord).message.push(mediaPart);
}
}
if (msgType === "at") {
markMessageStarted(botRecord);
const mention = parseJsonSafe(data);
if (!mention || typeof mention !== "object") return;
const target = String(
mention.target || mention.bot_id || mention.qq || "",
);
const name = String(mention.name || "");
if (!target && !name) return;
messageContent(botRecord).message.push({ type: "at", target, name });
}
}
function createBotRecordState(
botRecord: ChatRecord | undefined,
): ActiveBotRecordState {
return { current: botRecord, startNewAfterSave: false };
}
function getCurrentBotRecord(
state: ActiveBotRecordState,
sessionId: string,
payload: any,
) {
if (state.startNewAfterSave && startsNewBotRecord(payload)) {
state.current = appendFollowupBotRecord(sessionId, state.current);
state.startNewAfterSave = false;
}
return state.current;
}
function startsNewBotRecord(payload: any) {
const msgType = payload?.type || payload?.t;
if (msgType === "plain") return true;
if (msgType === "chain") return true;
if (msgType === "message_saved") return true;
if (msgType === "at") return true;
if (msgType === "error") return true;
return ["image", "record", "file", "video"].includes(msgType);
}
function isGroupChatSession(sessionId: string) {
return Boolean(groupProfilesBySession[sessionId]);
}
function appendFollowupBotRecord(sessionId: string, previous?: ChatRecord) {
messagesBySession[sessionId] = messagesBySession[sessionId] || [];
const record: ChatRecord = {
id: `local-bot-${crypto.randomUUID?.() || Date.now()}`,
created_at: new Date().toISOString(),
sender_id: previous?.sender_id,
sender_name: previous?.sender_name,
content: {
type: "bot",
message: [{ type: "plain", text: "" }],
reasoning: "",
isLoading: true,
},
};
messagesBySession[sessionId].push(record);
return messagesBySession[sessionId][messagesBySession[sessionId].length - 1];
}
return {
@@ -708,6 +1178,9 @@ export function useMessages(options: UseMessagesOptions) {
messagesBySession,
loadedSessions,
sessionProjects,
groupBotsBySession,
groupProfilesBySession,
typingBySession,
activeMessages,
isSessionRunning,
isUserMessage,
@@ -715,6 +1188,8 @@ export function useMessages(options: UseMessagesOptions) {
messageContent,
messageParts,
loadSessionMessages,
ensurePersistentChatConnection,
closePersistentChatConnections,
createLocalExchange,
sendMessageStream,
editMessage,
@@ -756,6 +1231,13 @@ function stripUploadOnlyFields(part: MessagePart): MessagePart {
return copied;
}
function createUnifiedChatWebSocket(token: string) {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return new WebSocket(
`${protocol}//${window.location.host}/api/unified_chat/ws?token=${token}`,
);
}
function normalizeSessionProject(value: unknown): ChatSessionProject | null {
if (!value || typeof value !== "object") return null;
const project = value as Record<string, unknown>;
@@ -775,6 +1257,13 @@ function normalizeSessionProject(value: unknown): ChatSessionProject | null {
function partToPayload(part: MessagePart) {
if (part.type === "plain") return { type: "plain", text: part.text || "" };
if (part.type === "at") {
return {
type: "at",
target: part.target || part.bot_id || "",
name: part.name || "",
};
}
if (part.type === "reply") {
return {
type: "reply",
@@ -789,6 +1278,45 @@ function partToPayload(part: MessagePart) {
};
}
function normalizeGroupBots(value: unknown): GroupBot[] {
if (!Array.isArray(value)) return [];
const bots: GroupBot[] = [];
for (const item of value) {
if (!item || typeof item !== "object") continue;
const bot = item as Record<string, unknown>;
if (typeof bot.bot_id !== "string" || typeof bot.name !== "string") {
continue;
}
bots.push({
bot_id: bot.bot_id,
name: bot.name,
avatar: typeof bot.avatar === "string" ? bot.avatar : "",
avatar_attachment_id:
typeof bot.avatar_attachment_id === "string" ? bot.avatar_attachment_id : "",
conf_id: typeof bot.conf_id === "string" ? bot.conf_id : "default",
platform_id: typeof bot.platform_id === "string" ? bot.platform_id : "",
});
}
return bots;
}
function normalizeGroupProfile(value: unknown): GroupProfile | null {
if (!value || typeof value !== "object") return null;
const group = value as Record<string, unknown>;
if (typeof group.session_id !== "string" || typeof group.name !== "string") {
return null;
}
return {
session_id: group.session_id,
name: group.name,
avatar: typeof group.avatar === "string" ? group.avatar : "",
avatar_attachment_id:
typeof group.avatar_attachment_id === "string" ? group.avatar_attachment_id : "",
description:
typeof group.description === "string" ? group.description : "",
};
}
async function readSseStream(
body: ReadableStream<Uint8Array>,
onPayload: (payload: any) => void,
@@ -846,10 +1374,20 @@ function finishToolCall(record: ChatRecord, result: any) {
const tool = part.tool_calls.find((item) => item.id === targetId);
if (tool) {
tool.result = result.result;
tool.finished_ts = result.ts || Date.now() / 1000;
tool.finished_ts = result.finished_ts || result.ts || Date.now() / 1000;
return;
}
}
record.content.message.push({
type: "tool_call",
tool_calls: [
{
...result,
result: result.result,
finished_ts: result.finished_ts || result.ts || Date.now() / 1000,
},
],
});
}
function markMessageStarted(record: ChatRecord) {

View File

@@ -47,16 +47,25 @@ export function useSessions(chatboxMode: boolean = false) {
}
}
async function newSession() {
async function newSession(options: { isGroup?: boolean; displayName?: string; avatar?: string; avatarAttachmentId?: string; description?: string } = {}) {
try {
const selectedConfigId = getStoredSelectedChatConfigId();
const response = await axios.get('/api/chat/new_session');
const response = await axios.get('/api/chat/new_session', {
params: {
is_group: options.isGroup ? 1 : 0,
display_name: options.displayName || undefined,
avatar: options.avatar || undefined,
avatar_attachment_id: options.avatarAttachmentId || undefined,
description: options.description || undefined,
}
});
const sessionId = response.data.data.session_id;
const platformId = response.data.data.platform_id;
const isGroup = Number(response.data.data.is_group || 0) === 1;
currSessionId.value = sessionId;
if (selectedConfigId && selectedConfigId !== 'default' && platformId === 'webchat') {
if (selectedConfigId && selectedConfigId !== 'default' && platformId === 'webchat' && !isGroup) {
try {
const umoDetails = buildWebchatUmoDetails(sessionId, false);
await axios.post('/api/config/umo_abconf_route/update', {

View File

@@ -72,6 +72,18 @@
"confirmDelete": "Delete this thread? This action cannot be undone.",
"placeholder": "Ask about this excerpt..."
},
"workspace": {
"title": "Workspace",
"open": "Open workspace",
"root": "Root",
"refresh": "Refresh",
"empty": "No files",
"preview": "Preview",
"selectFile": "Select a text file to preview",
"noSession": "Open a session first",
"loadFailed": "Failed to load workspace",
"previewFailed": "Failed to read file"
},
"conversation": {
"newConversation": "New Conversation",
"noHistory": "No conversation history",
@@ -129,6 +141,33 @@
"noSessions": "No conversations in this project",
"confirmDelete": "Are you sure you want to delete project \"{title}\"? Conversations in this project will not be deleted."
},
"group": {
"title": "Groups",
"create": "Create Group",
"defaultName": "Group Chat",
"name": "Group Name",
"description": "Group Description",
"panelTitle": "Group Info",
"bots": "Bots",
"members": "Members",
"botBadge": "Bot",
"addBot": "Add Bot",
"addBotHint": "Add a bot, then mention it with @name.",
"availableBots": "Existing Bots",
"addExisting": "Add Existing",
"createNewBot": "New Bot",
"noExistingBots": "No existing bots",
"deleteBot": "Delete Bot",
"added": "Added",
"typing": "{name} is typing",
"botName": "Name",
"platform": "Platform adapter",
"createPlatform": "Create a new adapter",
"config": "Config",
"avatar": "Avatar URL",
"uploadAvatar": "Upload Avatar",
"mentionRequired": "Mention a bot in this group chat first."
},
"time": {
"today": "Today",
"yesterday": "Yesterday"

View File

@@ -129,6 +129,33 @@
"noSessions": "В этом проекте пока нет диалогов",
"confirmDelete": "Вы уверены, что хотите удалить проект «{title}»? Диалоги внутри проекта не будут удалены."
},
"group": {
"title": "Группы",
"create": "Создать группу",
"defaultName": "Групповой чат",
"name": "Название группы",
"description": "Описание группы",
"panelTitle": "Информация о группе",
"bots": "Боты",
"members": "Участники",
"botBadge": "Бот",
"addBot": "Добавить бота",
"addBotHint": "Добавьте бота, затем упомяните его через @имя.",
"availableBots": "Существующие боты",
"addExisting": "Добавить существующего",
"createNewBot": "Новый бот",
"noExistingBots": "Нет существующих ботов",
"deleteBot": "Удалить бота",
"added": "Добавлен",
"typing": "{name} печатает",
"botName": "Имя",
"platform": "Адаптер платформы",
"createPlatform": "Создать новый адаптер",
"config": "Конфигурация",
"avatar": "URL аватара",
"uploadAvatar": "Загрузить аватар",
"mentionRequired": "Сначала упомяните бота в этом групповом чате."
},
"time": {
"today": "Сегодня",
"yesterday": "Вчера"

View File

@@ -72,6 +72,18 @@
"confirmDelete": "确定要删除这个分支吗?此操作无法撤销。",
"placeholder": "继续追问这段内容..."
},
"workspace": {
"title": "工作区",
"open": "打开工作区",
"root": "根目录",
"refresh": "刷新",
"empty": "暂无文件",
"preview": "预览",
"selectFile": "选择一个文本文件进行预览",
"noSession": "请先打开一个会话",
"loadFailed": "加载工作区失败",
"previewFailed": "读取文件失败"
},
"conversation": {
"newConversation": "新的聊天",
"noHistory": "暂无对话历史",
@@ -129,6 +141,33 @@
"noSessions": "该项目暂无对话",
"confirmDelete": "确定要删除项目 \"{title}\" 吗?项目中的对话不会被删除。"
},
"group": {
"title": "群聊",
"create": "创建群聊",
"defaultName": "群聊",
"name": "群名",
"description": "群介绍",
"panelTitle": "群信息",
"bots": "Bot 列表",
"members": "群成员",
"botBadge": "Bot",
"addBot": "添加 Bot",
"addBotHint": "添加 Bot 后,使用 @名称 触发回复。",
"availableBots": "已有 Bot",
"addExisting": "添加已有",
"createNewBot": "新建 Bot",
"noExistingBots": "暂无已有 Bot",
"deleteBot": "删除 Bot",
"added": "已添加",
"typing": "{name} 正在输入",
"botName": "名称",
"platform": "平台适配器",
"createPlatform": "自动创建新适配器",
"config": "配置文件",
"avatar": "头像 URL",
"uploadAvatar": "上传头像",
"mentionRequired": "请先在群聊中 @ 一个 Bot。"
},
"time": {
"today": "今天",
"yesterday": "昨天"

View File

@@ -1,8 +1,9 @@
import asyncio
from pathlib import Path
import pytest
from astrbot.dashboard.routes.chat import _poll_webchat_stream_result
from astrbot.dashboard.routes.chat import ChatRoute, _poll_webchat_stream_result
class _QueueThatRaises:
@@ -54,3 +55,26 @@ async def test_poll_webchat_stream_result_returns_queue_payload():
assert result == payload
assert should_break is False
def test_resolve_workspace_path_blocks_traversal(tmp_path: Path):
route = ChatRoute.__new__(ChatRoute)
root = tmp_path / "workspace"
root.mkdir()
with pytest.raises(ValueError):
route._resolve_workspace_path(root, "../secret.txt")
def test_serialize_workspace_entry_marks_text_previewable(tmp_path: Path):
route = ChatRoute.__new__(ChatRoute)
root = tmp_path / "workspace"
root.mkdir()
file_path = root / "notes.md"
file_path.write_text("# notes", encoding="utf-8")
payload = route._serialize_workspace_entry(root, file_path)
assert payload["path"] == "notes.md"
assert payload["type"] == "file"
assert payload["previewable"] is True

View File

@@ -77,14 +77,16 @@ class TestLocalBooterUploadDownload:
)
@pytest.mark.asyncio
async def test_download_file_not_supported(self):
"""Test LocalBooter download_file raises NotImplementedError."""
async def test_download_file_copies_local_file(self, tmp_path):
"""Test LocalBooter download_file copies a local file."""
booter = LocalBooter()
with pytest.raises(NotImplementedError) as exc_info:
await booter.download_file("remote_path", "local_path")
assert "LocalBooter does not support download_file operation" in str(
exc_info.value
)
source = tmp_path / "source.txt"
target = tmp_path / "nested" / "target.txt"
source.write_text("content", encoding="utf-8")
await booter.download_file(str(source), str(target))
assert target.read_text(encoding="utf-8") == "content"
class TestSecurityRestrictions: