mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-01 18:20:16 +08:00
Compare commits
3 Commits
codex/add-
...
feat/works
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3362ff394f | ||
|
|
980aae1f8e | ||
|
|
a0b9ecbf92 |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
56
astrbot/core/platform/sources/webchat/group_bots.py
Normal file
56
astrbot/core/platform/sources/webchat/group_bots.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 逻辑)"""
|
||||
|
||||
BIN
dashboard/src/assets/images/chatui-group-default-avatar.png
Normal file
BIN
dashboard/src/assets/images/chatui-group-default-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
@@ -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";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
231
dashboard/src/components/chat/GroupList.vue
Normal file
231
dashboard/src/components/chat/GroupList.vue
Normal 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>
|
||||
272
dashboard/src/components/chat/GroupPanel.vue
Normal file
272
dashboard/src/components/chat/GroupPanel.vue
Normal 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>
|
||||
479
dashboard/src/components/chat/WorkspacePanel.vue
Normal file
479
dashboard/src/components/chat/WorkspacePanel.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "Вчера"
|
||||
|
||||
@@ -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": "昨天"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user