mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-19 18:47:41 +08:00
refactor: message storage format of webchat, support reply and file message segment (#3845)
* refactor: message storage format of webchat * refactor: update image and record handling in webchat event processing * fix: thinking placeholder in webchat * feat: supports file upload in webchat * feat: supports to delete attachments when webchat session is deleted * perf: improve performance of file downloading * refactor: remove unused import in chat route * feat: add message timestamp formatting and localization support in chat * fix: handle missing filename in file upload for chat route * feat: enhance file handling in chat and webchat, supporting video uploads and improved attachment management * fix: update property name for embedded files in message handling * fix: compute variable errors after uninstalling plugins * feat: supported for reply message and standarlize the message param * fix: ensure message actions are displayed for the last message in the list
This commit is contained in:
@@ -173,7 +173,7 @@ class BaseDatabase(abc.ABC):
|
||||
content: dict,
|
||||
sender_id: str | None = None,
|
||||
sender_name: str | None = None,
|
||||
) -> None:
|
||||
) -> PlatformMessageHistory:
|
||||
"""Insert a new platform message history record."""
|
||||
...
|
||||
|
||||
@@ -198,6 +198,14 @@ class BaseDatabase(abc.ABC):
|
||||
"""Get platform message history for a specific user."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_platform_message_history_by_id(
|
||||
self,
|
||||
message_id: int,
|
||||
) -> PlatformMessageHistory | None:
|
||||
"""Get a platform message history record by its ID."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def insert_attachment(
|
||||
self,
|
||||
@@ -213,6 +221,27 @@ class BaseDatabase(abc.ABC):
|
||||
"""Get an attachment by its ID."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_attachments(self, attachment_ids: list[str]) -> list[Attachment]:
|
||||
"""Get multiple attachments by their IDs."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def delete_attachment(self, attachment_id: str) -> bool:
|
||||
"""Delete an attachment by its ID.
|
||||
|
||||
Returns True if the attachment was deleted, False if it was not found.
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def delete_attachments(self, attachment_ids: list[str]) -> int:
|
||||
"""Delete multiple attachments by their IDs.
|
||||
|
||||
Returns the number of attachments deleted.
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def insert_persona(
|
||||
self,
|
||||
|
||||
@@ -449,6 +449,18 @@ class SQLiteDatabase(BaseDatabase):
|
||||
result = await session.execute(query.offset(offset).limit(page_size))
|
||||
return result.scalars().all()
|
||||
|
||||
async def get_platform_message_history_by_id(
|
||||
self, message_id: int
|
||||
) -> PlatformMessageHistory | None:
|
||||
"""Get a platform message history record by its ID."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
query = select(PlatformMessageHistory).where(
|
||||
PlatformMessageHistory.id == message_id
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def insert_attachment(self, path, type, mime_type):
|
||||
"""Insert a new attachment record."""
|
||||
async with self.get_db() as session:
|
||||
@@ -470,6 +482,48 @@ class SQLiteDatabase(BaseDatabase):
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_attachments(self, attachment_ids: list[str]) -> list:
|
||||
"""Get multiple attachments by their IDs."""
|
||||
if not attachment_ids:
|
||||
return []
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
query = select(Attachment).where(
|
||||
Attachment.attachment_id.in_(attachment_ids)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def delete_attachment(self, attachment_id: str) -> bool:
|
||||
"""Delete an attachment by its ID.
|
||||
|
||||
Returns True if the attachment was deleted, False if it was not found.
|
||||
"""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
query = delete(Attachment).where(
|
||||
col(Attachment.attachment_id) == attachment_id
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return result.rowcount > 0
|
||||
|
||||
async def delete_attachments(self, attachment_ids: list[str]) -> int:
|
||||
"""Delete multiple attachments by their IDs.
|
||||
|
||||
Returns the number of attachments deleted.
|
||||
"""
|
||||
if not attachment_ids:
|
||||
return 0
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
query = delete(Attachment).where(
|
||||
col(Attachment.attachment_id).in_(attachment_ids)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return result.rowcount
|
||||
|
||||
async def insert_persona(
|
||||
self,
|
||||
persona_id,
|
||||
|
||||
@@ -6,7 +6,9 @@ from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.message.components import Image, Plain, Record
|
||||
from astrbot.core import db_helper
|
||||
from astrbot.core.db.po import PlatformMessageHistory
|
||||
from astrbot.core.message.components import File, Image, Plain, Record, Reply, Video
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.platform import (
|
||||
AstrBotMessage,
|
||||
@@ -96,6 +98,92 @@ class WebChatAdapter(Platform):
|
||||
await WebChatMessageEvent._send(message_chain, session.session_id)
|
||||
await super().send_by_session(session, message_chain)
|
||||
|
||||
async def _get_message_history(
|
||||
self, message_id: int
|
||||
) -> PlatformMessageHistory | None:
|
||||
return await db_helper.get_platform_message_history_by_id(message_id)
|
||||
|
||||
async def _parse_message_parts(
|
||||
self,
|
||||
message_parts: list,
|
||||
depth: int = 0,
|
||||
max_depth: int = 1,
|
||||
) -> tuple[list, list[str]]:
|
||||
"""解析消息段列表,返回消息组件列表和纯文本列表
|
||||
|
||||
Args:
|
||||
message_parts: 消息段列表
|
||||
depth: 当前递归深度
|
||||
max_depth: 最大递归深度(用于处理 reply)
|
||||
|
||||
Returns:
|
||||
tuple[list, list[str]]: (消息组件列表, 纯文本列表)
|
||||
"""
|
||||
components = []
|
||||
text_parts = []
|
||||
|
||||
for part in message_parts:
|
||||
part_type = part.get("type")
|
||||
if part_type == "plain":
|
||||
text = part.get("text", "")
|
||||
components.append(Plain(text))
|
||||
text_parts.append(text)
|
||||
elif part_type == "reply":
|
||||
message_id = part.get("message_id")
|
||||
reply_chain = []
|
||||
reply_message_str = ""
|
||||
sender_id = None
|
||||
sender_name = None
|
||||
|
||||
# recursively get the content of the referenced message
|
||||
if depth < max_depth and message_id:
|
||||
history = await self._get_message_history(message_id)
|
||||
if history and history.content:
|
||||
reply_parts = history.content.get("message", [])
|
||||
if isinstance(reply_parts, list):
|
||||
(
|
||||
reply_chain,
|
||||
reply_text_parts,
|
||||
) = await self._parse_message_parts(
|
||||
reply_parts,
|
||||
depth=depth + 1,
|
||||
max_depth=max_depth,
|
||||
)
|
||||
reply_message_str = "".join(reply_text_parts)
|
||||
sender_id = history.sender_id
|
||||
sender_name = history.sender_name
|
||||
|
||||
components.append(
|
||||
Reply(
|
||||
id=message_id,
|
||||
chain=reply_chain,
|
||||
message_str=reply_message_str,
|
||||
sender_id=sender_id,
|
||||
sender_nickname=sender_name,
|
||||
)
|
||||
)
|
||||
elif part_type == "image":
|
||||
path = part.get("path")
|
||||
if path:
|
||||
components.append(Image.fromFileSystem(path))
|
||||
elif part_type == "record":
|
||||
path = part.get("path")
|
||||
if path:
|
||||
components.append(Record.fromFileSystem(path))
|
||||
elif part_type == "file":
|
||||
path = part.get("path")
|
||||
if path:
|
||||
filename = part.get("filename") or (
|
||||
os.path.basename(path) if path else "file"
|
||||
)
|
||||
components.append(File(name=filename, file=path))
|
||||
elif part_type == "video":
|
||||
path = part.get("path")
|
||||
if path:
|
||||
components.append(Video.fromFileSystem(path))
|
||||
|
||||
return components, text_parts
|
||||
|
||||
async def convert_message(self, data: tuple) -> AstrBotMessage:
|
||||
username, cid, payload = data
|
||||
|
||||
@@ -108,36 +196,15 @@ class WebChatAdapter(Platform):
|
||||
abm.session_id = f"webchat!{username}!{cid}"
|
||||
|
||||
abm.message_id = str(uuid.uuid4())
|
||||
abm.message = []
|
||||
|
||||
if payload["message"]:
|
||||
abm.message.append(Plain(payload["message"]))
|
||||
if payload["image_url"]:
|
||||
if isinstance(payload["image_url"], list):
|
||||
for img in payload["image_url"]:
|
||||
abm.message.append(
|
||||
Image.fromFileSystem(os.path.join(self.imgs_dir, img)),
|
||||
)
|
||||
else:
|
||||
abm.message.append(
|
||||
Image.fromFileSystem(
|
||||
os.path.join(self.imgs_dir, payload["image_url"]),
|
||||
),
|
||||
)
|
||||
if payload["audio_url"]:
|
||||
if isinstance(payload["audio_url"], list):
|
||||
for audio in payload["audio_url"]:
|
||||
path = os.path.join(self.imgs_dir, audio)
|
||||
abm.message.append(Record(file=path, path=path))
|
||||
else:
|
||||
path = os.path.join(self.imgs_dir, payload["audio_url"])
|
||||
abm.message.append(Record(file=path, path=path))
|
||||
# 处理消息段列表
|
||||
message_parts = payload.get("message", [])
|
||||
abm.message, message_str_parts = await self._parse_message_parts(message_parts)
|
||||
|
||||
logger.debug(f"WebChatAdapter: {abm.message}")
|
||||
|
||||
message_str = payload["message"]
|
||||
abm.timestamp = int(time.time())
|
||||
abm.message_str = message_str
|
||||
abm.message_str = "".join(message_str_parts)
|
||||
abm.raw_message = data
|
||||
return abm
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import base64
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import Image, Plain, Record
|
||||
from astrbot.api.message_components import File, Image, Plain, Record
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
|
||||
from .webchat_queue_mgr import webchat_queue_mgr
|
||||
|
||||
@@ -19,7 +19,9 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
os.makedirs(imgs_dir, exist_ok=True)
|
||||
|
||||
@staticmethod
|
||||
async def _send(message: MessageChain, session_id: str, streaming: bool = False):
|
||||
async def _send(
|
||||
message: MessageChain | None, session_id: str, streaming: bool = False
|
||||
) -> str | None:
|
||||
cid = session_id.split("!")[-1]
|
||||
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
|
||||
if not message:
|
||||
@@ -30,7 +32,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
"streaming": False,
|
||||
}, # end means this request is finished
|
||||
)
|
||||
return ""
|
||||
return
|
||||
|
||||
data = ""
|
||||
for comp in message.chain:
|
||||
@@ -47,24 +49,11 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
elif isinstance(comp, Image):
|
||||
# save image to local
|
||||
filename = str(uuid.uuid4()) + ".jpg"
|
||||
filename = f"{str(uuid.uuid4())}.jpg"
|
||||
path = os.path.join(imgs_dir, filename)
|
||||
if comp.file and comp.file.startswith("file:///"):
|
||||
ph = comp.file[8:]
|
||||
with open(path, "wb") as f:
|
||||
with open(ph, "rb") as f2:
|
||||
f.write(f2.read())
|
||||
elif comp.file.startswith("base64://"):
|
||||
base64_str = comp.file[9:]
|
||||
image_data = base64.b64decode(base64_str)
|
||||
with open(path, "wb") as f:
|
||||
f.write(image_data)
|
||||
elif comp.file and comp.file.startswith("http"):
|
||||
await download_image_by_url(comp.file, path=path)
|
||||
else:
|
||||
with open(path, "wb") as f:
|
||||
with open(comp.file, "rb") as f2:
|
||||
f.write(f2.read())
|
||||
image_base64 = await comp.convert_to_base64()
|
||||
with open(path, "wb") as f:
|
||||
f.write(base64.b64decode(image_base64))
|
||||
data = f"[IMAGE]{filename}"
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
@@ -76,19 +65,11 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
elif isinstance(comp, Record):
|
||||
# save record to local
|
||||
filename = str(uuid.uuid4()) + ".wav"
|
||||
filename = f"{str(uuid.uuid4())}.wav"
|
||||
path = os.path.join(imgs_dir, filename)
|
||||
if comp.file and comp.file.startswith("file:///"):
|
||||
ph = comp.file[8:]
|
||||
with open(path, "wb") as f:
|
||||
with open(ph, "rb") as f2:
|
||||
f.write(f2.read())
|
||||
elif comp.file and comp.file.startswith("http"):
|
||||
await download_image_by_url(comp.file, path=path)
|
||||
else:
|
||||
with open(path, "wb") as f:
|
||||
with open(comp.file, "rb") as f2:
|
||||
f.write(f2.read())
|
||||
record_base64 = await comp.convert_to_base64()
|
||||
with open(path, "wb") as f:
|
||||
f.write(base64.b64decode(record_base64))
|
||||
data = f"[RECORD]{filename}"
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
@@ -98,6 +79,23 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
"streaming": streaming,
|
||||
},
|
||||
)
|
||||
elif isinstance(comp, File):
|
||||
# save file to local
|
||||
file_path = await comp.get_file()
|
||||
original_name = comp.name or os.path.basename(file_path)
|
||||
ext = os.path.splitext(original_name)[1] or ""
|
||||
filename = f"{uuid.uuid4()!s}{ext}"
|
||||
dest_path = os.path.join(imgs_dir, filename)
|
||||
shutil.copy2(file_path, dest_path)
|
||||
data = f"[FILE]{filename}|{original_name}"
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
"type": "file",
|
||||
"cid": cid,
|
||||
"data": data,
|
||||
"streaming": streaming,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.debug(f"webchat 忽略: {comp.type}")
|
||||
|
||||
@@ -131,6 +129,8 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
session_id=self.session_id,
|
||||
streaming=True,
|
||||
)
|
||||
if not r:
|
||||
continue
|
||||
if chain.type == "reasoning":
|
||||
reasoning_content += chain.get_plain_text()
|
||||
else:
|
||||
|
||||
@@ -10,12 +10,12 @@ class PlatformMessageHistoryManager:
|
||||
self,
|
||||
platform_id: str,
|
||||
user_id: str,
|
||||
content: list[dict], # TODO: parse from message chain
|
||||
content: dict, # TODO: parse from message chain
|
||||
sender_id: str | None = None,
|
||||
sender_name: str | None = None,
|
||||
):
|
||||
) -> PlatformMessageHistory:
|
||||
"""Insert a new platform message history record."""
|
||||
await self.db.insert_platform_message_history(
|
||||
return await self.db.insert_platform_message_history(
|
||||
platform_id=platform_id,
|
||||
user_id=user_id,
|
||||
content=content,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import asyncio
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from quart import Response as QuartResponse
|
||||
from quart import g, make_response, request
|
||||
from quart import g, make_response, request, send_file
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
@@ -44,7 +44,7 @@ class ChatRoute(Route):
|
||||
self.update_session_display_name,
|
||||
),
|
||||
"/chat/get_file": ("GET", self.get_file),
|
||||
"/chat/post_image": ("POST", self.post_image),
|
||||
"/chat/get_attachment": ("GET", self.get_attachment),
|
||||
"/chat/post_file": ("POST", self.post_file),
|
||||
}
|
||||
self.core_lifecycle = core_lifecycle
|
||||
@@ -73,52 +73,184 @@ class ChatRoute(Route):
|
||||
if not real_file_path.startswith(real_imgs_dir):
|
||||
return Response().error("Invalid file path").__dict__
|
||||
|
||||
with open(real_file_path, "rb") as f:
|
||||
filename_ext = os.path.splitext(filename)[1].lower()
|
||||
|
||||
if filename_ext == ".wav":
|
||||
return QuartResponse(f.read(), mimetype="audio/wav")
|
||||
if filename_ext[1:] in self.supported_imgs:
|
||||
return QuartResponse(f.read(), mimetype="image/jpeg")
|
||||
return QuartResponse(f.read())
|
||||
filename_ext = os.path.splitext(filename)[1].lower()
|
||||
if filename_ext == ".wav":
|
||||
return await send_file(real_file_path, mimetype="audio/wav")
|
||||
if filename_ext[1:] in self.supported_imgs:
|
||||
return await send_file(real_file_path, mimetype="image/jpeg")
|
||||
return await send_file(real_file_path)
|
||||
|
||||
except (FileNotFoundError, OSError):
|
||||
return Response().error("File access error").__dict__
|
||||
|
||||
async def post_image(self):
|
||||
post_data = await request.files
|
||||
if "file" not in post_data:
|
||||
return Response().error("Missing key: file").__dict__
|
||||
async def get_attachment(self):
|
||||
"""Get attachment file by attachment_id."""
|
||||
attachment_id = request.args.get("attachment_id")
|
||||
if not attachment_id:
|
||||
return Response().error("Missing key: attachment_id").__dict__
|
||||
|
||||
file = post_data["file"]
|
||||
filename = str(uuid.uuid4()) + ".jpg"
|
||||
path = os.path.join(self.imgs_dir, filename)
|
||||
await file.save(path)
|
||||
try:
|
||||
attachment = await self.db.get_attachment_by_id(attachment_id)
|
||||
if not attachment:
|
||||
return Response().error("Attachment not found").__dict__
|
||||
|
||||
return Response().ok(data={"filename": filename}).__dict__
|
||||
file_path = attachment.path
|
||||
real_file_path = os.path.realpath(file_path)
|
||||
|
||||
return await send_file(real_file_path, mimetype=attachment.mime_type)
|
||||
|
||||
except (FileNotFoundError, OSError):
|
||||
return Response().error("File access error").__dict__
|
||||
|
||||
async def post_file(self):
|
||||
"""Upload a file and create an attachment record, return attachment_id."""
|
||||
post_data = await request.files
|
||||
if "file" not in post_data:
|
||||
return Response().error("Missing key: file").__dict__
|
||||
|
||||
file = post_data["file"]
|
||||
filename = f"{uuid.uuid4()!s}"
|
||||
# 通过文件格式判断文件类型
|
||||
if file.content_type.startswith("audio"):
|
||||
filename += ".wav"
|
||||
filename = file.filename or f"{uuid.uuid4()!s}"
|
||||
content_type = file.content_type or "application/octet-stream"
|
||||
|
||||
# 根据 content_type 判断文件类型并添加扩展名
|
||||
if content_type.startswith("image"):
|
||||
attach_type = "image"
|
||||
elif content_type.startswith("audio"):
|
||||
attach_type = "record"
|
||||
elif content_type.startswith("video"):
|
||||
attach_type = "video"
|
||||
else:
|
||||
attach_type = "file"
|
||||
|
||||
path = os.path.join(self.imgs_dir, filename)
|
||||
await file.save(path)
|
||||
|
||||
return Response().ok(data={"filename": filename}).__dict__
|
||||
# 创建 attachment 记录
|
||||
attachment = await self.db.insert_attachment(
|
||||
path=path,
|
||||
type=attach_type,
|
||||
mime_type=content_type,
|
||||
)
|
||||
|
||||
if not attachment:
|
||||
return Response().error("Failed to create attachment").__dict__
|
||||
|
||||
filename = os.path.basename(attachment.path)
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
data={
|
||||
"attachment_id": attachment.attachment_id,
|
||||
"filename": filename,
|
||||
"type": attach_type,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
async def _build_user_message_parts(self, message: str | list) -> list[dict]:
|
||||
"""构建用户消息的部分列表
|
||||
|
||||
Args:
|
||||
message: 文本消息 (str) 或消息段列表 (list)
|
||||
"""
|
||||
parts = []
|
||||
|
||||
if isinstance(message, list):
|
||||
for part in message:
|
||||
part_type = part.get("type")
|
||||
if part_type == "plain":
|
||||
parts.append({"type": "plain", "text": part.get("text", "")})
|
||||
elif part_type == "reply":
|
||||
parts.append(
|
||||
{"type": "reply", "message_id": part.get("message_id")}
|
||||
)
|
||||
elif attachment_id := part.get("attachment_id"):
|
||||
attachment = await self.db.get_attachment_by_id(attachment_id)
|
||||
if attachment:
|
||||
parts.append(
|
||||
{
|
||||
"type": attachment.type,
|
||||
"attachment_id": attachment.attachment_id,
|
||||
"filename": os.path.basename(attachment.path),
|
||||
"path": attachment.path, # will be deleted
|
||||
}
|
||||
)
|
||||
return parts
|
||||
|
||||
if message:
|
||||
parts.append({"type": "plain", "text": message})
|
||||
|
||||
return parts
|
||||
|
||||
async def _create_attachment_from_file(
|
||||
self, filename: str, attach_type: str
|
||||
) -> dict | None:
|
||||
"""从本地文件创建 attachment 并返回消息部分
|
||||
|
||||
用于处理 bot 回复中的媒体文件
|
||||
|
||||
Args:
|
||||
filename: 存储的文件名
|
||||
attach_type: 附件类型 (image, record, file, video)
|
||||
"""
|
||||
file_path = os.path.join(self.imgs_dir, os.path.basename(filename))
|
||||
if not os.path.exists(file_path):
|
||||
return None
|
||||
|
||||
# guess mime type
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
if not mime_type:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
# insert attachment
|
||||
attachment = await self.db.insert_attachment(
|
||||
path=file_path,
|
||||
type=attach_type,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
if not attachment:
|
||||
return None
|
||||
|
||||
return {
|
||||
"type": attach_type,
|
||||
"attachment_id": attachment.attachment_id,
|
||||
"filename": os.path.basename(file_path),
|
||||
}
|
||||
|
||||
async def _save_bot_message(
|
||||
self,
|
||||
webchat_conv_id: str,
|
||||
text: str,
|
||||
media_parts: list,
|
||||
reasoning: str,
|
||||
):
|
||||
"""保存 bot 消息到历史记录,返回保存的记录"""
|
||||
bot_message_parts = []
|
||||
if text:
|
||||
bot_message_parts.append({"type": "plain", "text": text})
|
||||
bot_message_parts.extend(media_parts)
|
||||
|
||||
new_his = {"type": "bot", "message": bot_message_parts}
|
||||
if reasoning:
|
||||
new_his["reasoning"] = reasoning
|
||||
|
||||
record = await self.platform_history_mgr.insert(
|
||||
platform_id="webchat",
|
||||
user_id=webchat_conv_id,
|
||||
content=new_his,
|
||||
sender_id="bot",
|
||||
sender_name="bot",
|
||||
)
|
||||
return record
|
||||
|
||||
async def chat(self):
|
||||
username = g.get("username", "guest")
|
||||
|
||||
post_data = await request.json
|
||||
if "message" not in post_data and "image_url" not in post_data:
|
||||
return Response().error("Missing key: message or image_url").__dict__
|
||||
if "message" not in post_data and "files" not in post_data:
|
||||
return Response().error("Missing key: message or files").__dict__
|
||||
|
||||
if "session_id" not in post_data and "conversation_id" not in post_data:
|
||||
return (
|
||||
@@ -126,44 +258,40 @@ class ChatRoute(Route):
|
||||
)
|
||||
|
||||
message = post_data["message"]
|
||||
# conversation_id = post_data["conversation_id"]
|
||||
session_id = post_data.get("session_id", post_data.get("conversation_id"))
|
||||
image_url = post_data.get("image_url")
|
||||
audio_url = post_data.get("audio_url")
|
||||
selected_provider = post_data.get("selected_provider")
|
||||
selected_model = post_data.get("selected_model")
|
||||
enable_streaming = post_data.get("enable_streaming", True) # 默认为 True
|
||||
enable_streaming = post_data.get("enable_streaming", True)
|
||||
|
||||
if not message and not image_url and not audio_url:
|
||||
return (
|
||||
Response()
|
||||
.error("Message and image_url and audio_url are empty")
|
||||
.__dict__
|
||||
# 检查消息是否为空
|
||||
if isinstance(message, list):
|
||||
has_content = any(
|
||||
part.get("type") in ("plain", "image", "record", "file", "video")
|
||||
for part in message
|
||||
)
|
||||
if not has_content:
|
||||
return (
|
||||
Response()
|
||||
.error("Message content is empty (reply only is not allowed)")
|
||||
.__dict__
|
||||
)
|
||||
elif not message:
|
||||
return Response().error("Message are both empty").__dict__
|
||||
|
||||
if not session_id:
|
||||
return Response().error("session_id is empty").__dict__
|
||||
|
||||
# 追加用户消息
|
||||
webchat_conv_id = session_id
|
||||
|
||||
# 获取会话特定的队列
|
||||
back_queue = webchat_queue_mgr.get_or_create_back_queue(webchat_conv_id)
|
||||
|
||||
new_his = {"type": "user", "message": message}
|
||||
if image_url:
|
||||
new_his["image_url"] = image_url
|
||||
if audio_url:
|
||||
new_his["audio_url"] = audio_url
|
||||
await self.platform_history_mgr.insert(
|
||||
platform_id="webchat",
|
||||
user_id=webchat_conv_id,
|
||||
content=new_his,
|
||||
sender_id=username,
|
||||
sender_name=username,
|
||||
)
|
||||
# 构建用户消息段(包含 path 用于传递给 adapter)
|
||||
message_parts = await self._build_user_message_parts(message)
|
||||
|
||||
async def stream():
|
||||
client_disconnected = False
|
||||
accumulated_parts = []
|
||||
accumulated_text = ""
|
||||
accumulated_reasoning = ""
|
||||
|
||||
try:
|
||||
async with track_conversation(self.running_convs, webchat_conv_id):
|
||||
@@ -182,16 +310,17 @@ class ChatRoute(Route):
|
||||
continue
|
||||
|
||||
result_text = result["data"]
|
||||
type = result.get("type")
|
||||
msg_type = result.get("type")
|
||||
streaming = result.get("streaming", False)
|
||||
|
||||
# 发送 SSE 数据
|
||||
try:
|
||||
if not client_disconnected:
|
||||
yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
|
||||
except Exception as e:
|
||||
if not client_disconnected:
|
||||
logger.debug(
|
||||
f"[WebChat] 用户 {username} 断开聊天长连接。 {e}",
|
||||
f"[WebChat] 用户 {username} 断开聊天长连接。 {e}"
|
||||
)
|
||||
client_disconnected = True
|
||||
|
||||
@@ -202,24 +331,68 @@ class ChatRoute(Route):
|
||||
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
|
||||
client_disconnected = True
|
||||
|
||||
if type == "end":
|
||||
# 累积消息部分
|
||||
if msg_type == "plain":
|
||||
chain_type = result.get("chain_type", "normal")
|
||||
if chain_type == "reasoning":
|
||||
accumulated_reasoning += result_text
|
||||
else:
|
||||
accumulated_text += result_text
|
||||
elif msg_type == "image":
|
||||
filename = result_text.replace("[IMAGE]", "")
|
||||
part = await self._create_attachment_from_file(
|
||||
filename, "image"
|
||||
)
|
||||
if part:
|
||||
accumulated_parts.append(part)
|
||||
elif msg_type == "record":
|
||||
filename = result_text.replace("[RECORD]", "")
|
||||
part = await self._create_attachment_from_file(
|
||||
filename, "record"
|
||||
)
|
||||
if part:
|
||||
accumulated_parts.append(part)
|
||||
elif msg_type == "file":
|
||||
# 格式: [FILE]filename
|
||||
filename = result_text.replace("[FILE]", "")
|
||||
part = await self._create_attachment_from_file(
|
||||
filename, "file"
|
||||
)
|
||||
if part:
|
||||
accumulated_parts.append(part)
|
||||
|
||||
# 消息结束处理
|
||||
if msg_type == "end":
|
||||
break
|
||||
elif (
|
||||
(streaming and type == "complete")
|
||||
(streaming and msg_type == "complete")
|
||||
or not streaming
|
||||
or type == "break"
|
||||
or msg_type == "break"
|
||||
):
|
||||
# 追加机器人消息
|
||||
new_his = {"type": "bot", "message": result_text}
|
||||
if "reasoning" in result:
|
||||
new_his["reasoning"] = result["reasoning"]
|
||||
await self.platform_history_mgr.insert(
|
||||
platform_id="webchat",
|
||||
user_id=webchat_conv_id,
|
||||
content=new_his,
|
||||
sender_id="bot",
|
||||
sender_name="bot",
|
||||
saved_record = await self._save_bot_message(
|
||||
webchat_conv_id,
|
||||
accumulated_text,
|
||||
accumulated_parts,
|
||||
accumulated_reasoning,
|
||||
)
|
||||
# 发送保存的消息信息给前端
|
||||
if saved_record and not client_disconnected:
|
||||
saved_info = {
|
||||
"type": "message_saved",
|
||||
"data": {
|
||||
"id": saved_record.id,
|
||||
"created_at": saved_record.created_at.astimezone().isoformat(),
|
||||
},
|
||||
}
|
||||
try:
|
||||
yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n"
|
||||
except Exception:
|
||||
pass
|
||||
# 重置累积变量 (对于 break 后的下一段消息)
|
||||
if msg_type == "break":
|
||||
accumulated_parts = []
|
||||
accumulated_text = ""
|
||||
accumulated_reasoning = ""
|
||||
except BaseException as e:
|
||||
logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
|
||||
|
||||
@@ -230,9 +403,7 @@ class ChatRoute(Route):
|
||||
username,
|
||||
webchat_conv_id,
|
||||
{
|
||||
"message": message,
|
||||
"image_url": image_url, # list
|
||||
"audio_url": audio_url,
|
||||
"message": message_parts,
|
||||
"selected_provider": selected_provider,
|
||||
"selected_model": selected_model,
|
||||
"enable_streaming": enable_streaming,
|
||||
@@ -240,6 +411,19 @@ class ChatRoute(Route):
|
||||
),
|
||||
)
|
||||
|
||||
message_parts_for_storage = []
|
||||
for part in message_parts:
|
||||
part_copy = {k: v for k, v in part.items() if k != "path"}
|
||||
message_parts_for_storage.append(part_copy)
|
||||
|
||||
await self.platform_history_mgr.insert(
|
||||
platform_id="webchat",
|
||||
user_id=webchat_conv_id,
|
||||
content={"type": "user", "message": message_parts_for_storage},
|
||||
sender_id=username,
|
||||
sender_name=username,
|
||||
)
|
||||
|
||||
response = await make_response(
|
||||
stream(),
|
||||
{
|
||||
@@ -249,7 +433,7 @@ class ChatRoute(Route):
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
response.timeout = None # fix SSE auto disconnect issue
|
||||
response.timeout = None # fix SSE auto disconnect issue # pyright: ignore[reportAttributeAccessIssue]
|
||||
return response
|
||||
|
||||
async def delete_webchat_session(self):
|
||||
@@ -271,6 +455,17 @@ class ChatRoute(Route):
|
||||
unified_msg_origin = f"{session.platform_id}:{message_type}:{session.platform_id}!{username}!{session_id}"
|
||||
await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin)
|
||||
|
||||
# 获取消息历史中的所有附件 ID 并删除附件
|
||||
history_list = await self.platform_history_mgr.get(
|
||||
platform_id=session.platform_id,
|
||||
user_id=session_id,
|
||||
page=1,
|
||||
page_size=100000, # 获取足够多的记录
|
||||
)
|
||||
attachment_ids = self._extract_attachment_ids(history_list)
|
||||
if attachment_ids:
|
||||
await self._delete_attachments(attachment_ids)
|
||||
|
||||
# 删除消息历史
|
||||
await self.platform_history_mgr.delete(
|
||||
platform_id=session.platform_id,
|
||||
@@ -297,6 +492,41 @@ class ChatRoute(Route):
|
||||
|
||||
return Response().ok().__dict__
|
||||
|
||||
def _extract_attachment_ids(self, history_list) -> list[str]:
|
||||
"""从消息历史中提取所有 attachment_id"""
|
||||
attachment_ids = []
|
||||
for history in history_list:
|
||||
content = history.content
|
||||
if not content or "message" not in content:
|
||||
continue
|
||||
message_parts = content.get("message", [])
|
||||
for part in message_parts:
|
||||
if isinstance(part, dict) and "attachment_id" in part:
|
||||
attachment_ids.append(part["attachment_id"])
|
||||
return attachment_ids
|
||||
|
||||
async def _delete_attachments(self, attachment_ids: list[str]):
|
||||
"""删除附件(包括数据库记录和磁盘文件)"""
|
||||
try:
|
||||
attachments = await self.db.get_attachments(attachment_ids)
|
||||
for attachment in attachments:
|
||||
if not os.path.exists(attachment.path):
|
||||
continue
|
||||
try:
|
||||
os.remove(attachment.path)
|
||||
except OSError as e:
|
||||
logger.warning(
|
||||
f"Failed to delete attachment file {attachment.path}: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get attachments: {e}")
|
||||
|
||||
# 批量删除数据库记录
|
||||
try:
|
||||
await self.db.delete_attachments(attachment_ids)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete attachments: {e}")
|
||||
|
||||
async def new_session(self):
|
||||
"""Create a new Platform session (default: webchat)."""
|
||||
username = g.get("username", "guest")
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
|
||||
<MessageList v-if="messages && messages.length > 0" :messages="messages" :isDark="isDark"
|
||||
:isStreaming="isStreaming || isConvRunning" @openImagePreview="openImagePreview"
|
||||
@replyMessage="handleReplyMessage"
|
||||
ref="messageList" />
|
||||
<div class="welcome-container fade-in" v-else>
|
||||
<div class="welcome-title">
|
||||
@@ -84,19 +85,23 @@
|
||||
v-model:prompt="prompt"
|
||||
:stagedImagesUrl="stagedImagesUrl"
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:stagedFiles="stagedNonImageFiles"
|
||||
:disabled="isStreaming"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
@send="handleSendMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@removeImage="removeImage"
|
||||
@removeAudio="removeAudio"
|
||||
@removeFile="removeFile"
|
||||
@startRecording="handleStartRecording"
|
||||
@stopRecording="handleStopRecording"
|
||||
@pasteImage="handlePaste"
|
||||
@fileSelect="handleFileSelect"
|
||||
@clearReply="clearReply"
|
||||
ref="chatInputRef"
|
||||
/>
|
||||
</div>
|
||||
@@ -189,14 +194,17 @@ const {
|
||||
} = useSessions(props.chatboxMode);
|
||||
|
||||
const {
|
||||
stagedImagesName,
|
||||
stagedImagesUrl,
|
||||
stagedAudioUrl,
|
||||
stagedFiles,
|
||||
stagedNonImageFiles,
|
||||
getMediaFile,
|
||||
processAndUploadImage,
|
||||
processAndUploadFile,
|
||||
handlePaste,
|
||||
removeImage,
|
||||
removeAudio,
|
||||
removeFile,
|
||||
clearStaged,
|
||||
cleanupMediaCache
|
||||
} = useMediaHandling();
|
||||
@@ -220,6 +228,13 @@ const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
|
||||
// 输入状态
|
||||
const prompt = ref('');
|
||||
|
||||
// 引用消息状态
|
||||
interface ReplyInfo {
|
||||
messageId: number; // PlatformSessionHistoryMessage 的 id
|
||||
messageContent: string; // 用于显示的消息内容
|
||||
}
|
||||
const replyTo = ref<ReplyInfo | null>(null);
|
||||
|
||||
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
|
||||
|
||||
// 检测是否为手机端
|
||||
@@ -250,6 +265,41 @@ function openImagePreview(imageUrl: string) {
|
||||
imagePreviewDialog.value = true;
|
||||
}
|
||||
|
||||
function handleReplyMessage(msg: any, index: number) {
|
||||
// 从消息中获取 id (PlatformSessionHistoryMessage 的 id)
|
||||
const messageId = msg.id;
|
||||
if (!messageId) {
|
||||
console.warn('Message does not have an id');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取消息内容用于显示
|
||||
let messageContent = '';
|
||||
if (typeof msg.content.message === 'string') {
|
||||
messageContent = msg.content.message;
|
||||
} else if (Array.isArray(msg.content.message)) {
|
||||
// 从消息段数组中提取纯文本
|
||||
const textParts = msg.content.message
|
||||
.filter((part: any) => part.type === 'plain' && part.text)
|
||||
.map((part: any) => part.text);
|
||||
messageContent = textParts.join('');
|
||||
}
|
||||
|
||||
// 截断过长的内容
|
||||
if (messageContent.length > 100) {
|
||||
messageContent = messageContent.substring(0, 100) + '...';
|
||||
}
|
||||
|
||||
replyTo.value = {
|
||||
messageId,
|
||||
messageContent: messageContent || '[媒体内容]'
|
||||
};
|
||||
}
|
||||
|
||||
function clearReply() {
|
||||
replyTo.value = null;
|
||||
}
|
||||
|
||||
async function handleSelectConversation(sessionIds: string[]) {
|
||||
if (!sessionIds[0]) return;
|
||||
|
||||
@@ -265,6 +315,9 @@ async function handleSelectConversation(sessionIds: string[]) {
|
||||
closeMobileSidebar();
|
||||
}
|
||||
|
||||
// 清除引用状态
|
||||
clearReply();
|
||||
|
||||
currSessionId.value = sessionIds[0];
|
||||
selectedSessions.value = [sessionIds[0]];
|
||||
|
||||
@@ -278,6 +331,7 @@ async function handleSelectConversation(sessionIds: string[]) {
|
||||
function handleNewChat() {
|
||||
newChat(closeMobileSidebar);
|
||||
messages.value = [];
|
||||
clearReply();
|
||||
}
|
||||
|
||||
async function handleDeleteConversation(sessionId: string) {
|
||||
@@ -295,13 +349,19 @@ async function handleStopRecording() {
|
||||
}
|
||||
|
||||
async function handleFileSelect(files: FileList) {
|
||||
const imageTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
for (const file of files) {
|
||||
await processAndUploadImage(file);
|
||||
if (imageTypes.includes(file.type)) {
|
||||
await processAndUploadImage(file);
|
||||
} else {
|
||||
await processAndUploadFile(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendMessage() {
|
||||
if (!prompt.value.trim() && stagedImagesName.value.length === 0 && !stagedAudioUrl.value) {
|
||||
// 只有引用不能发送,必须有输入内容
|
||||
if (!prompt.value.trim() && stagedFiles.value.length === 0 && !stagedAudioUrl.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -310,12 +370,19 @@ async function handleSendMessage() {
|
||||
}
|
||||
|
||||
const promptToSend = prompt.value.trim();
|
||||
const imageNamesToSend = [...stagedImagesName.value];
|
||||
const audioNameToSend = stagedAudioUrl.value;
|
||||
const filesToSend = stagedFiles.value.map(f => ({
|
||||
attachment_id: f.attachment_id,
|
||||
url: f.url,
|
||||
original_name: f.original_name,
|
||||
type: f.type
|
||||
}));
|
||||
const replyToSend = replyTo.value ? { ...replyTo.value } : null;
|
||||
|
||||
// 清空输入和附件
|
||||
// 清空输入和附件和引用
|
||||
prompt.value = '';
|
||||
clearStaged();
|
||||
clearReply();
|
||||
|
||||
// 获取选择的提供商和模型
|
||||
const selection = chatInputRef.value?.getCurrentSelection();
|
||||
@@ -324,10 +391,11 @@ async function handleSendMessage() {
|
||||
|
||||
await sendMsg(
|
||||
promptToSend,
|
||||
imageNamesToSend,
|
||||
filesToSend,
|
||||
audioNameToSend,
|
||||
selectedProviderId,
|
||||
selectedModelName
|
||||
selectedModelName,
|
||||
replyToSend
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
<div class="input-area fade-in">
|
||||
<div class="input-container"
|
||||
style="width: 85%; max-width: 900px; margin: 0 auto; border: 1px solid #e0e0e0; border-radius: 24px;">
|
||||
<!-- 引用预览区 -->
|
||||
<div class="reply-preview" v-if="props.replyTo">
|
||||
<div class="reply-content">
|
||||
<v-icon size="small" class="reply-icon">mdi-reply</v-icon>
|
||||
"<span class="reply-text">{{ props.replyTo.messageContent }}</span>"
|
||||
</div>
|
||||
<v-btn @click="$emit('clearReply')" class="remove-reply-btn" icon="mdi-close" size="x-small" color="grey" variant="text" />
|
||||
</div>
|
||||
<textarea
|
||||
ref="inputField"
|
||||
v-model="localPrompt"
|
||||
@@ -30,7 +38,7 @@
|
||||
</v-tooltip>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 8px; align-items: center;">
|
||||
<input type="file" ref="imageInputRef" @change="handleFileSelect" accept="image/*"
|
||||
<input type="file" ref="imageInputRef" @change="handleFileSelect"
|
||||
style="display: none" multiple />
|
||||
<v-progress-circular v-if="disabled" indeterminate size="16" class="mr-1" width="1.5" />
|
||||
<v-btn @click="triggerImageInput" icon="mdi-plus" variant="text" color="deep-purple"
|
||||
@@ -45,8 +53,8 @@
|
||||
</div>
|
||||
|
||||
<!-- 附件预览区 -->
|
||||
<div class="attachments-preview" v-if="stagedImagesUrl.length > 0 || stagedAudioUrl">
|
||||
<div v-for="(img, index) in stagedImagesUrl" :key="index" class="image-preview">
|
||||
<div class="attachments-preview" v-if="stagedImagesUrl.length > 0 || stagedAudioUrl || (stagedFiles && stagedFiles.length > 0)">
|
||||
<div v-for="(img, index) in stagedImagesUrl" :key="'img-' + index" class="image-preview">
|
||||
<img :src="img" class="preview-image" />
|
||||
<v-btn @click="$emit('removeImage', index)" class="remove-attachment-btn" icon="mdi-close"
|
||||
size="small" color="error" variant="text" />
|
||||
@@ -60,6 +68,15 @@
|
||||
<v-btn @click="$emit('removeAudio')" class="remove-attachment-btn" icon="mdi-close" size="small"
|
||||
color="error" variant="text" />
|
||||
</div>
|
||||
|
||||
<div v-for="(file, index) in stagedFiles" :key="'file-' + index" class="file-preview">
|
||||
<v-chip color="blue-grey-lighten-4" class="file-chip">
|
||||
<v-icon start icon="mdi-file-document-outline" size="small"></v-icon>
|
||||
<span class="file-name-preview">{{ file.original_name }}</span>
|
||||
</v-chip>
|
||||
<v-btn @click="$emit('removeFile', index)" class="remove-attachment-btn" icon="mdi-close" size="small"
|
||||
color="error" variant="text" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -71,22 +88,39 @@ import ProviderModelSelector from './ProviderModelSelector.vue';
|
||||
import ConfigSelector from './ConfigSelector.vue';
|
||||
import type { Session } from '@/composables/useSessions';
|
||||
|
||||
interface StagedFileInfo {
|
||||
attachment_id: string;
|
||||
filename: string;
|
||||
original_name: string;
|
||||
url: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface ReplyInfo {
|
||||
messageId: number;
|
||||
messageContent: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
prompt: string;
|
||||
stagedImagesUrl: string[];
|
||||
stagedAudioUrl: string;
|
||||
stagedFiles?: StagedFileInfo[];
|
||||
disabled: boolean;
|
||||
enableStreaming: boolean;
|
||||
isRecording: boolean;
|
||||
sessionId?: string | null;
|
||||
currentSession?: Session | null;
|
||||
configId?: string | null;
|
||||
replyTo?: ReplyInfo | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
sessionId: null,
|
||||
currentSession: null,
|
||||
configId: null
|
||||
configId: null,
|
||||
stagedFiles: () => [],
|
||||
replyTo: null
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -95,10 +129,12 @@ const emit = defineEmits<{
|
||||
toggleStreaming: [];
|
||||
removeImage: [index: number];
|
||||
removeAudio: [];
|
||||
removeFile: [index: number];
|
||||
startRecording: [];
|
||||
stopRecording: [];
|
||||
pasteImage: [event: ClipboardEvent];
|
||||
fileSelect: [files: FileList];
|
||||
clearReply: [];
|
||||
}>();
|
||||
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
@@ -117,7 +153,7 @@ const sessionPlatformId = computed(() => props.currentSession?.platform_id || 'w
|
||||
const sessionIsGroup = computed(() => Boolean(props.currentSession?.is_group));
|
||||
|
||||
const canSend = computed(() => {
|
||||
return (props.prompt && props.prompt.trim()) || props.stagedImagesUrl.length > 0 || props.stagedAudioUrl;
|
||||
return (props.prompt && props.prompt.trim()) || props.stagedImagesUrl.length > 0 || props.stagedAudioUrl || (props.stagedFiles && props.stagedFiles.length > 0);
|
||||
});
|
||||
|
||||
// Ctrl+B 长按录音相关
|
||||
@@ -229,6 +265,46 @@ defineExpose({
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reply-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
margin: 8px 8px 0 8px;
|
||||
background-color: rgba(103, 58, 183, 0.06);
|
||||
border-radius: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.reply-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.reply-icon {
|
||||
color: var(--v-theme-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reply-text {
|
||||
font-size: 13px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.remove-reply-btn {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.attachments-preview {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -239,7 +315,8 @@ defineExpose({
|
||||
}
|
||||
|
||||
.image-preview,
|
||||
.audio-preview {
|
||||
.audio-preview,
|
||||
.file-preview {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
@@ -252,11 +329,19 @@ defineExpose({
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.audio-chip {
|
||||
.audio-chip,
|
||||
.file-chip {
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.file-name-preview {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.remove-attachment-btn {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
<div v-if="msg.content.type == 'user'" class="user-message">
|
||||
<div class="message-bubble user-bubble" :class="{ 'has-audio': msg.content.audio_url }"
|
||||
:style="{ backgroundColor: isDark ? '#2d2e30' : '#e7ebf4' }">
|
||||
<!-- 引用消息 -->
|
||||
<div v-if="msg.content.reply_to" class="reply-quote" @click="scrollToMessage(msg.content.reply_to.message_id)">
|
||||
<v-icon size="small" class="reply-quote-icon">mdi-reply</v-icon>
|
||||
<span class="reply-quote-text">{{ getReplyContent(msg.content.reply_to.message_id) }}</span>
|
||||
</div>
|
||||
<pre
|
||||
style="font-family: inherit; white-space: pre-wrap; word-wrap: break-word;">{{ msg.content.message }}</pre>
|
||||
|
||||
@@ -24,6 +29,22 @@
|
||||
{{ t('messages.errors.browser.audioNotSupported') }}
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<!-- 文件附件 -->
|
||||
<div class="file-attachments" v-if="msg.content.file_url && msg.content.file_url.length > 0">
|
||||
<div v-for="(file, fileIdx) in msg.content.file_url" :key="fileIdx" class="file-attachment">
|
||||
<a v-if="file.url" :href="file.url" :download="file.filename" class="file-link">
|
||||
<v-icon size="small" class="file-icon">mdi-file-document-outline</v-icon>
|
||||
<span class="file-name">{{ file.filename }}</span>
|
||||
</a>
|
||||
<a v-else @click="downloadFile(file)" class="file-link file-link-download">
|
||||
<v-icon size="small" class="file-icon">mdi-file-document-outline</v-icon>
|
||||
<span class="file-name">{{ file.filename }}</span>
|
||||
<v-icon v-if="downloadingFiles.has(file.attachment_id)" size="small" class="download-icon">mdi-loading mdi-spin</v-icon>
|
||||
<v-icon v-else size="small" class="download-icon">mdi-download</v-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,12 +98,33 @@
|
||||
{{ t('messages.errors.browser.audioNotSupported') }}
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<!-- Files -->
|
||||
<div class="embedded-files"
|
||||
v-if="msg.content.embedded_files && msg.content.embedded_files.length > 0">
|
||||
<div v-for="(file, fileIndex) in msg.content.embedded_files" :key="fileIndex"
|
||||
class="embedded-file">
|
||||
<a v-if="file.url" :href="file.url" :download="file.filename" class="file-link">
|
||||
<v-icon size="small" class="file-icon">mdi-file-document-outline</v-icon>
|
||||
<span class="file-name">{{ file.filename }}</span>
|
||||
</a>
|
||||
<a v-else @click="downloadFile(file)" class="file-link file-link-download">
|
||||
<v-icon size="small" class="file-icon">mdi-file-document-outline</v-icon>
|
||||
<span class="file-name">{{ file.filename }}</span>
|
||||
<v-icon v-if="downloadingFiles.has(file.attachment_id)" size="small" class="download-icon">mdi-loading mdi-spin</v-icon>
|
||||
<v-icon v-else size="small" class="download-icon">mdi-download</v-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="message-actions" v-if="!msg.content.isLoading">
|
||||
<v-btn :icon="getCopyIcon(index)" size="small" variant="text" class="copy-message-btn"
|
||||
<div class="message-actions" v-if="!msg.content.isLoading || index === messages.length - 1">
|
||||
<span class="message-time" v-if="msg.created_at">{{ formatMessageTime(msg.created_at) }}</span>
|
||||
<v-btn :icon="getCopyIcon(index)" size="x-small" variant="text" class="copy-message-btn"
|
||||
:class="{ 'copy-success': isCopySuccess(index) }"
|
||||
@click="copyBotMessage(msg.content.message, index)" :title="t('core.common.copy')" />
|
||||
<v-btn icon="mdi-reply-outline" size="x-small" variant="text" class="reply-message-btn"
|
||||
@click="$emit('replyMessage', msg, index)" :title="tm('actions.reply')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,6 +138,7 @@ import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import hljs from 'highlight.js';
|
||||
import 'highlight.js/styles/github.css';
|
||||
import axios from 'axios';
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
@@ -129,7 +172,7 @@ export default {
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['openImagePreview'],
|
||||
emits: ['openImagePreview', 'replyMessage'],
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
@@ -147,6 +190,7 @@ export default {
|
||||
scrollThreshold: 1,
|
||||
scrollTimer: null,
|
||||
expandedReasoning: new Set(), // Track which reasoning blocks are expanded
|
||||
downloadingFiles: new Set(), // Track which files are being downloaded
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
@@ -163,6 +207,45 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 获取被引用消息的内容
|
||||
getReplyContent(messageId) {
|
||||
const replyMsg = this.messages.find(m => m.id === messageId);
|
||||
if (!replyMsg) {
|
||||
return this.tm('reply.notFound');
|
||||
}
|
||||
let content = '';
|
||||
if (typeof replyMsg.content.message === 'string') {
|
||||
content = replyMsg.content.message;
|
||||
} else if (Array.isArray(replyMsg.content.message)) {
|
||||
const textParts = replyMsg.content.message
|
||||
.filter(part => part.type === 'plain' && part.text)
|
||||
.map(part => part.text);
|
||||
content = textParts.join('');
|
||||
}
|
||||
// 截断过长内容
|
||||
if (content.length > 50) {
|
||||
content = content.substring(0, 50) + '...';
|
||||
}
|
||||
return content || '[媒体内容]';
|
||||
},
|
||||
|
||||
// 滚动到指定消息
|
||||
scrollToMessage(messageId) {
|
||||
const msgIndex = this.messages.findIndex(m => m.id === messageId);
|
||||
if (msgIndex === -1) return;
|
||||
|
||||
const container = this.$refs.messageContainer;
|
||||
const messageItems = container?.querySelectorAll('.message-item');
|
||||
if (messageItems && messageItems[msgIndex]) {
|
||||
messageItems[msgIndex].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
// 高亮一下
|
||||
messageItems[msgIndex].classList.add('highlight-message');
|
||||
setTimeout(() => {
|
||||
messageItems[msgIndex].classList.remove('highlight-message');
|
||||
}, 2000);
|
||||
}
|
||||
},
|
||||
|
||||
// Toggle reasoning expansion state
|
||||
toggleReasoning(messageIndex) {
|
||||
if (this.expandedReasoning.has(messageIndex)) {
|
||||
@@ -179,6 +262,35 @@ export default {
|
||||
return this.expandedReasoning.has(messageIndex);
|
||||
},
|
||||
|
||||
// 下载文件
|
||||
async downloadFile(file) {
|
||||
if (!file.attachment_id) return;
|
||||
|
||||
// 标记为下载中
|
||||
this.downloadingFiles.add(file.attachment_id);
|
||||
this.downloadingFiles = new Set(this.downloadingFiles);
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/api/chat/get_attachment?attachment_id=${file.attachment_id}`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(response.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = file.filename || 'file';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
} catch (err) {
|
||||
console.error('Download file failed:', err);
|
||||
} finally {
|
||||
this.downloadingFiles.delete(file.attachment_id);
|
||||
this.downloadingFiles = new Set(this.downloadingFiles);
|
||||
}
|
||||
},
|
||||
|
||||
// 复制代码到剪贴板
|
||||
copyCodeToClipboard(code) {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
@@ -375,6 +487,37 @@ export default {
|
||||
clearTimeout(this.scrollTimer);
|
||||
this.scrollTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
// 格式化消息时间,支持别名显示
|
||||
formatMessageTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
|
||||
// 获取本地时间的日期部分
|
||||
const dateDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const todayDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterdayDay = new Date(todayDay);
|
||||
yesterdayDay.setDate(yesterdayDay.getDate() - 1);
|
||||
|
||||
// 格式化时间 HH:MM
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
const timeStr = `${hours}:${minutes}`;
|
||||
|
||||
// 判断是今天、昨天还是更早
|
||||
if (dateDay.getTime() === todayDay.getTime()) {
|
||||
return `${this.tm('time.today')} ${timeStr}`;
|
||||
} else if (dateDay.getTime() === yesterdayDay.getTime()) {
|
||||
return `${this.tm('time.yesterday')} ${timeStr}`;
|
||||
} else {
|
||||
// 更早的日期显示完整格式
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
return `${month}-${day} ${timeStr}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -413,7 +556,7 @@ export default {
|
||||
}
|
||||
|
||||
.message-item {
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 12px;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@@ -441,10 +584,23 @@ export default {
|
||||
|
||||
.message-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
margin-left: 8px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
/* 最后一条消息始终显示操作按钮 */
|
||||
.message-item:last-child .message-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bot-message:hover .message-actions {
|
||||
@@ -472,6 +628,62 @@ export default {
|
||||
background-color: rgba(76, 175, 80, 0.1);
|
||||
}
|
||||
|
||||
.reply-message-btn {
|
||||
opacity: 0.6;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--v-theme-secondary);
|
||||
}
|
||||
|
||||
.reply-message-btn:hover {
|
||||
opacity: 1;
|
||||
background-color: rgba(103, 58, 183, 0.1);
|
||||
}
|
||||
|
||||
/* 引用消息显示样式 */
|
||||
.reply-quote {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 8px;
|
||||
background-color: rgba(103, 58, 183, 0.08);
|
||||
border-left: 3px solid var(--v-theme-secondary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.reply-quote:hover {
|
||||
background-color: rgba(103, 58, 183, 0.15);
|
||||
}
|
||||
|
||||
.reply-quote-icon {
|
||||
color: var(--v-theme-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reply-quote-text {
|
||||
font-size: 13px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 消息高亮动画 */
|
||||
.highlight-message {
|
||||
animation: highlightPulse 2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes highlightPulse {
|
||||
0% {
|
||||
background-color: rgba(103, 58, 183, 0.3);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 2px 16px;
|
||||
border-radius: 12px;
|
||||
@@ -553,7 +765,6 @@ export default {
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
@@ -568,6 +779,71 @@ export default {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* 文件附件样式 */
|
||||
.file-attachments,
|
||||
.embedded-files {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.file-attachment,
|
||||
.embedded-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.2);
|
||||
border-radius: 8px;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.file-link-download {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.download-icon {
|
||||
margin-left: 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
flex-shrink: 0;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.file-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.v-theme--dark .file-link {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: var(--v-theme-secondary);
|
||||
}
|
||||
|
||||
.v-theme--dark .file-link:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.v-theme--dark .file-icon {
|
||||
color: var(--v-theme-secondary);
|
||||
}
|
||||
|
||||
/* 动画类 */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
|
||||
@@ -110,9 +110,9 @@ function getSessions() {
|
||||
}
|
||||
|
||||
const {
|
||||
stagedImagesName,
|
||||
stagedImagesUrl,
|
||||
stagedAudioUrl,
|
||||
stagedFiles,
|
||||
getMediaFile,
|
||||
processAndUploadImage,
|
||||
handlePaste,
|
||||
@@ -164,7 +164,7 @@ async function handleFileSelect(files: FileList) {
|
||||
}
|
||||
|
||||
async function handleSendMessage() {
|
||||
if (!prompt.value.trim() && stagedImagesName.value.length === 0 && !stagedAudioUrl.value) {
|
||||
if (!prompt.value.trim() && stagedFiles.value.length === 0 && !stagedAudioUrl.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -174,8 +174,13 @@ async function handleSendMessage() {
|
||||
}
|
||||
|
||||
const promptToSend = prompt.value.trim();
|
||||
const imageNamesToSend = [...stagedImagesName.value];
|
||||
const audioNameToSend = stagedAudioUrl.value;
|
||||
const filesToSend = stagedFiles.value.map(f => ({
|
||||
attachment_id: f.attachment_id,
|
||||
url: f.url,
|
||||
original_name: f.original_name,
|
||||
type: f.type
|
||||
}));
|
||||
|
||||
// 清空输入和附件
|
||||
prompt.value = '';
|
||||
@@ -188,7 +193,7 @@ async function handleSendMessage() {
|
||||
|
||||
await sendMsg(
|
||||
promptToSend,
|
||||
imageNamesToSend,
|
||||
filesToSend,
|
||||
audioNameToSend,
|
||||
selectedProviderId,
|
||||
selectedModelName
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface StagedFileInfo {
|
||||
attachment_id: string;
|
||||
filename: string;
|
||||
original_name: string;
|
||||
url: string; // blob URL for preview
|
||||
type: string; // image, record, file, video
|
||||
}
|
||||
|
||||
export function useMediaHandling() {
|
||||
const stagedImagesName = ref<string[]>([]);
|
||||
const stagedImagesUrl = ref<string[]>([]);
|
||||
const stagedAudioUrl = ref<string>('');
|
||||
const stagedFiles = ref<StagedFileInfo[]>([]);
|
||||
const mediaCache = ref<Record<string, string>>({});
|
||||
|
||||
async function getMediaFile(filename: string): Promise<string> {
|
||||
@@ -32,20 +39,49 @@ export function useMediaHandling() {
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/chat/post_image', formData, {
|
||||
const response = await axios.post('/api/chat/post_file', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
const img = response.data.data.filename;
|
||||
stagedImagesName.value.push(img);
|
||||
stagedImagesUrl.value.push(URL.createObjectURL(file));
|
||||
const { attachment_id, filename, type } = response.data.data;
|
||||
stagedFiles.value.push({
|
||||
attachment_id,
|
||||
filename,
|
||||
original_name: file.name,
|
||||
url: URL.createObjectURL(file),
|
||||
type
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error uploading image:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function processAndUploadFile(file: File) {
|
||||
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;
|
||||
stagedFiles.value.push({
|
||||
attachment_id,
|
||||
filename,
|
||||
original_name: file.name,
|
||||
url: URL.createObjectURL(file),
|
||||
type
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error uploading file:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaste(event: ClipboardEvent) {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return;
|
||||
@@ -61,23 +97,54 @@ export function useMediaHandling() {
|
||||
}
|
||||
|
||||
function removeImage(index: number) {
|
||||
const urlToRevoke = stagedImagesUrl.value[index];
|
||||
if (urlToRevoke && urlToRevoke.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(urlToRevoke);
|
||||
// 找到第 index 个图片类型的文件
|
||||
let imageCount = 0;
|
||||
for (let i = 0; i < stagedFiles.value.length; i++) {
|
||||
if (stagedFiles.value[i].type === 'image') {
|
||||
if (imageCount === index) {
|
||||
const fileToRemove = stagedFiles.value[i];
|
||||
if (fileToRemove.url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(fileToRemove.url);
|
||||
}
|
||||
stagedFiles.value.splice(i, 1);
|
||||
return;
|
||||
}
|
||||
imageCount++;
|
||||
}
|
||||
}
|
||||
|
||||
stagedImagesName.value.splice(index, 1);
|
||||
stagedImagesUrl.value.splice(index, 1);
|
||||
}
|
||||
|
||||
function removeAudio() {
|
||||
stagedAudioUrl.value = '';
|
||||
}
|
||||
|
||||
function removeFile(index: number) {
|
||||
// 找到第 index 个非图片类型的文件
|
||||
let fileCount = 0;
|
||||
for (let i = 0; i < stagedFiles.value.length; i++) {
|
||||
if (stagedFiles.value[i].type !== 'image') {
|
||||
if (fileCount === index) {
|
||||
const fileToRemove = stagedFiles.value[i];
|
||||
if (fileToRemove.url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(fileToRemove.url);
|
||||
}
|
||||
stagedFiles.value.splice(i, 1);
|
||||
return;
|
||||
}
|
||||
fileCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearStaged() {
|
||||
stagedImagesName.value = [];
|
||||
stagedImagesUrl.value = [];
|
||||
stagedAudioUrl.value = '';
|
||||
// 清理文件的 blob URLs
|
||||
stagedFiles.value.forEach(file => {
|
||||
if (file.url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(file.url);
|
||||
}
|
||||
});
|
||||
stagedFiles.value = [];
|
||||
}
|
||||
|
||||
function cleanupMediaCache() {
|
||||
@@ -89,15 +156,28 @@ export function useMediaHandling() {
|
||||
mediaCache.value = {};
|
||||
}
|
||||
|
||||
// 计算属性:获取图片的 URL 列表(用于预览)
|
||||
const stagedImagesUrl = computed(() =>
|
||||
stagedFiles.value.filter(f => f.type === 'image').map(f => f.url)
|
||||
);
|
||||
|
||||
// 计算属性:获取非图片文件列表
|
||||
const stagedNonImageFiles = computed(() =>
|
||||
stagedFiles.value.filter(f => f.type !== 'image')
|
||||
);
|
||||
|
||||
return {
|
||||
stagedImagesName,
|
||||
stagedImagesUrl,
|
||||
stagedAudioUrl,
|
||||
stagedFiles,
|
||||
stagedNonImageFiles,
|
||||
getMediaFile,
|
||||
processAndUploadImage,
|
||||
processAndUploadFile,
|
||||
handlePaste,
|
||||
removeImage,
|
||||
removeAudio,
|
||||
removeFile,
|
||||
clearStaged,
|
||||
cleanupMediaCache
|
||||
};
|
||||
|
||||
@@ -2,19 +2,52 @@ import { ref, reactive, type Ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useToast } from '@/utils/toast';
|
||||
|
||||
// 新格式消息部分的类型定义
|
||||
export interface MessagePart {
|
||||
type: 'plain' | 'image' | 'record' | 'file' | 'video' | 'reply';
|
||||
text?: string; // for plain
|
||||
attachment_id?: string; // for image, record, file, video
|
||||
filename?: string; // for file (filename from backend)
|
||||
message_id?: number; // for reply (PlatformSessionHistoryMessage.id)
|
||||
}
|
||||
|
||||
// 引用信息
|
||||
export interface ReplyInfo {
|
||||
messageId: number;
|
||||
messageContent: string;
|
||||
}
|
||||
|
||||
// 文件信息结构
|
||||
export interface FileInfo {
|
||||
url?: string; // blob URL (可选,点击时才加载)
|
||||
filename: string;
|
||||
attachment_id?: string; // 用于按需下载
|
||||
}
|
||||
|
||||
// 引用消息信息
|
||||
export interface ReplyTo {
|
||||
message_id: number;
|
||||
message_content?: string; // 被引用消息的内容(解析后填充)
|
||||
}
|
||||
|
||||
export interface MessageContent {
|
||||
type: string;
|
||||
message: string;
|
||||
message: string | MessagePart[]; // 支持旧格式(string)和新格式(MessagePart[])
|
||||
reasoning?: string;
|
||||
image_url?: string[];
|
||||
audio_url?: string;
|
||||
file_url?: FileInfo[];
|
||||
embedded_images?: string[];
|
||||
embedded_audio?: string;
|
||||
embedded_files?: FileInfo[];
|
||||
isLoading?: boolean;
|
||||
reply_to?: ReplyTo; // 引用的消息
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id?: number;
|
||||
content: MessageContent;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export function useMessages(
|
||||
@@ -29,6 +62,7 @@ export function useMessages(
|
||||
const isToastedRunningInfo = ref(false);
|
||||
const activeSSECount = ref(0);
|
||||
const enableStreaming = ref(true);
|
||||
const attachmentCache = new Map<string, string>(); // attachment_id -> blob URL
|
||||
|
||||
// 从 localStorage 读取流式响应开关状态
|
||||
const savedStreamingState = localStorage.getItem('enableStreaming');
|
||||
@@ -41,6 +75,72 @@ export function useMessages(
|
||||
localStorage.setItem('enableStreaming', JSON.stringify(enableStreaming.value));
|
||||
}
|
||||
|
||||
// 获取 attachment 文件并返回 blob URL
|
||||
async function getAttachment(attachmentId: string): Promise<string> {
|
||||
if (attachmentCache.has(attachmentId)) {
|
||||
return attachmentCache.get(attachmentId)!;
|
||||
}
|
||||
try {
|
||||
const response = await axios.get(`/api/chat/get_attachment?attachment_id=${attachmentId}`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
const blobUrl = URL.createObjectURL(response.data);
|
||||
attachmentCache.set(attachmentId, blobUrl);
|
||||
return blobUrl;
|
||||
} catch (err) {
|
||||
console.error('Failed to get attachment:', attachmentId, err);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// 解析新格式消息为旧格式兼容的结构 (用于显示)
|
||||
async function parseMessageContent(content: any): Promise<void> {
|
||||
const message = content.message;
|
||||
|
||||
// 如果 message 是数组 (新格式)
|
||||
if (Array.isArray(message)) {
|
||||
let textParts: string[] = [];
|
||||
let imageUrls: string[] = [];
|
||||
let audioUrl: string | undefined;
|
||||
let fileInfos: FileInfo[] = [];
|
||||
let replyTo: ReplyTo | undefined;
|
||||
|
||||
for (const part of message as MessagePart[]) {
|
||||
if (part.type === 'plain' && part.text) {
|
||||
textParts.push(part.text);
|
||||
} else if (part.type === 'image' && part.attachment_id) {
|
||||
const url = await getAttachment(part.attachment_id);
|
||||
if (url) imageUrls.push(url);
|
||||
} else if (part.type === 'record' && part.attachment_id) {
|
||||
audioUrl = await getAttachment(part.attachment_id);
|
||||
} else if (part.type === 'file' && part.attachment_id) {
|
||||
// file 类型不预加载,保留 attachment_id 以便点击时下载
|
||||
fileInfos.push({
|
||||
attachment_id: part.attachment_id,
|
||||
filename: part.filename || 'file'
|
||||
});
|
||||
} else if (part.type === 'reply' && part.message_id) {
|
||||
replyTo = { message_id: part.message_id };
|
||||
}
|
||||
// video 类型可以后续扩展
|
||||
}
|
||||
|
||||
// 转换为旧格式兼容的结构
|
||||
content.message = textParts.join('\n');
|
||||
content.reply_to = replyTo;
|
||||
if (content.type === 'user') {
|
||||
content.image_url = imageUrls.length > 0 ? imageUrls : undefined;
|
||||
content.audio_url = audioUrl;
|
||||
content.file_url = fileInfos.length > 0 ? fileInfos : undefined;
|
||||
} else {
|
||||
content.embedded_images = imageUrls.length > 0 ? imageUrls : undefined;
|
||||
content.embedded_audio = audioUrl;
|
||||
content.embedded_files = fileInfos.length > 0 ? fileInfos : undefined;
|
||||
}
|
||||
}
|
||||
// 如果 message 是字符串 (旧格式),保持原有处理逻辑
|
||||
}
|
||||
|
||||
async function getSessionMessages(sessionId: string, router: any) {
|
||||
if (!sessionId) return;
|
||||
|
||||
@@ -64,35 +164,45 @@ export function useMessages(
|
||||
// 处理历史消息中的媒体文件
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
let content = history[i].content;
|
||||
|
||||
if (content.message?.startsWith('[IMAGE]')) {
|
||||
let img = content.message.replace('[IMAGE]', '');
|
||||
const imageUrl = await getMediaFile(img);
|
||||
if (!content.embedded_images) {
|
||||
content.embedded_images = [];
|
||||
|
||||
// 首先尝试解析新格式消息
|
||||
await parseMessageContent(content);
|
||||
|
||||
// 以下是旧格式的兼容处理 (message 是字符串的情况)
|
||||
if (typeof content.message === 'string') {
|
||||
if (content.message?.startsWith('[IMAGE]')) {
|
||||
let img = content.message.replace('[IMAGE]', '');
|
||||
const imageUrl = await getMediaFile(img);
|
||||
if (!content.embedded_images) {
|
||||
content.embedded_images = [];
|
||||
}
|
||||
content.embedded_images.push(imageUrl);
|
||||
content.message = '';
|
||||
}
|
||||
|
||||
if (content.message?.startsWith('[RECORD]')) {
|
||||
let audio = content.message.replace('[RECORD]', '');
|
||||
const audioUrl = await getMediaFile(audio);
|
||||
content.embedded_audio = audioUrl;
|
||||
content.message = '';
|
||||
}
|
||||
content.embedded_images.push(imageUrl);
|
||||
content.message = '';
|
||||
}
|
||||
|
||||
if (content.message?.startsWith('[RECORD]')) {
|
||||
let audio = content.message.replace('[RECORD]', '');
|
||||
const audioUrl = await getMediaFile(audio);
|
||||
content.embedded_audio = audioUrl;
|
||||
content.message = '';
|
||||
}
|
||||
|
||||
// 旧格式中的 image_url 和 audio_url 字段处理
|
||||
if (content.image_url && content.image_url.length > 0) {
|
||||
for (let j = 0; j < content.image_url.length; j++) {
|
||||
content.image_url[j] = await getMediaFile(content.image_url[j]);
|
||||
// 检查是否已经是 blob URL (新格式解析后的结果)
|
||||
if (!content.image_url[j].startsWith('blob:')) {
|
||||
content.image_url[j] = await getMediaFile(content.image_url[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content.audio_url) {
|
||||
if (content.audio_url && !content.audio_url.startsWith('blob:')) {
|
||||
content.audio_url = await getMediaFile(content.audio_url);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
messages.value = history;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -101,37 +211,45 @@ export function useMessages(
|
||||
|
||||
async function sendMessage(
|
||||
prompt: string,
|
||||
imageNames: string[],
|
||||
stagedFiles: { attachment_id: string; url: string; original_name: string; type: string }[],
|
||||
audioName: string,
|
||||
selectedProviderId: string,
|
||||
selectedModelName: string
|
||||
selectedModelName: string,
|
||||
replyTo: ReplyInfo | null = null
|
||||
) {
|
||||
// Create user message
|
||||
const userMessage: MessageContent = {
|
||||
type: 'user',
|
||||
message: prompt,
|
||||
image_url: [],
|
||||
audio_url: undefined
|
||||
audio_url: undefined,
|
||||
file_url: [],
|
||||
reply_to: replyTo ? { message_id: replyTo.messageId } : undefined
|
||||
};
|
||||
|
||||
// Convert image filenames to blob URLs
|
||||
if (imageNames.length > 0) {
|
||||
const imagePromises = imageNames.map(name => {
|
||||
if (!name.startsWith('blob:')) {
|
||||
return getMediaFile(name);
|
||||
}
|
||||
return Promise.resolve(name);
|
||||
});
|
||||
userMessage.image_url = await Promise.all(imagePromises);
|
||||
// 分离图片和文件
|
||||
const imageFiles = stagedFiles.filter(f => f.type === 'image');
|
||||
const nonImageFiles = stagedFiles.filter(f => f.type !== 'image');
|
||||
|
||||
// 使用 attachment_id 获取图片内容(避免 blob URL 被 revoke 后 404)
|
||||
if (imageFiles.length > 0) {
|
||||
const imageUrls = await Promise.all(
|
||||
imageFiles.map(f => getAttachment(f.attachment_id))
|
||||
);
|
||||
userMessage.image_url = imageUrls.filter(url => url !== '');
|
||||
}
|
||||
|
||||
// Convert audio filename to blob URL
|
||||
// 使用 blob URL 作为音频预览(录音不走 attachment)
|
||||
if (audioName) {
|
||||
if (!audioName.startsWith('blob:')) {
|
||||
userMessage.audio_url = await getMediaFile(audioName);
|
||||
} else {
|
||||
userMessage.audio_url = audioName;
|
||||
}
|
||||
userMessage.audio_url = audioName;
|
||||
}
|
||||
|
||||
// 文件不预加载,只显示文件名和 attachment_id
|
||||
if (nonImageFiles.length > 0) {
|
||||
userMessage.file_url = nonImageFiles.map(f => ({
|
||||
filename: f.original_name,
|
||||
attachment_id: f.attachment_id
|
||||
}));
|
||||
}
|
||||
|
||||
messages.value.push({ content: userMessage });
|
||||
@@ -151,6 +269,46 @@ export function useMessages(
|
||||
isConvRunning.value = true;
|
||||
}
|
||||
|
||||
// 收集所有 attachment_id
|
||||
const files = stagedFiles.map(f => f.attachment_id);
|
||||
|
||||
// 构建 message 参数
|
||||
// 当 files 或 reply 存在时,message 是 list,否则是 str
|
||||
let messageToSend: string | MessagePart[];
|
||||
if (files.length > 0 || replyTo) {
|
||||
const parts: MessagePart[] = [];
|
||||
|
||||
// 添加引用消息段
|
||||
if (replyTo) {
|
||||
parts.push({
|
||||
type: 'reply',
|
||||
message_id: replyTo.messageId
|
||||
});
|
||||
}
|
||||
|
||||
// 添加纯文本消息段
|
||||
if (prompt) {
|
||||
parts.push({
|
||||
type: 'plain',
|
||||
text: prompt
|
||||
});
|
||||
}
|
||||
|
||||
// 添加文件消息段
|
||||
for (const f of stagedFiles) {
|
||||
const partType = f.type === 'image' ? 'image' :
|
||||
f.type === 'record' ? 'record' : 'file';
|
||||
parts.push({
|
||||
type: partType as 'image' | 'record' | 'file',
|
||||
attachment_id: f.attachment_id
|
||||
});
|
||||
}
|
||||
|
||||
messageToSend = parts;
|
||||
} else {
|
||||
messageToSend = prompt;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/chat/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -158,10 +316,8 @@ export function useMessages(
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: prompt,
|
||||
message: messageToSend,
|
||||
session_id: currSessionId.value,
|
||||
image_url: imageNames,
|
||||
audio_url: audioName ? [audioName] : [],
|
||||
selected_provider: selectedProviderId,
|
||||
selected_model: selectedModelName,
|
||||
enable_streaming: enableStreaming.value
|
||||
@@ -207,6 +363,11 @@ export function useMessages(
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastMsg = messages.value[messages.value.length - 1];
|
||||
if (lastMsg?.content?.isLoading) {
|
||||
messages.value.pop();
|
||||
}
|
||||
|
||||
if (chunk_json.type === 'error') {
|
||||
console.error('Error received:', chunk_json.data);
|
||||
continue;
|
||||
@@ -230,16 +391,26 @@ export function useMessages(
|
||||
embedded_audio: audioUrl
|
||||
};
|
||||
messages.value.push({ content: bot_resp });
|
||||
} else if (chunk_json.type === 'file') {
|
||||
// 格式: [FILE]filename|original_name
|
||||
let fileData = chunk_json.data.replace('[FILE]', '');
|
||||
let [filename, originalName] = fileData.includes('|')
|
||||
? fileData.split('|', 2)
|
||||
: [fileData, fileData];
|
||||
const fileUrl = await getMediaFile(filename);
|
||||
let bot_resp: MessageContent = {
|
||||
type: 'bot',
|
||||
message: '',
|
||||
embedded_files: [{
|
||||
url: fileUrl,
|
||||
filename: originalName
|
||||
}]
|
||||
};
|
||||
messages.value.push({ content: bot_resp });
|
||||
} else if (chunk_json.type === 'plain') {
|
||||
const chain_type = chunk_json.chain_type || 'normal';
|
||||
|
||||
|
||||
if (!in_streaming) {
|
||||
// 移除加载占位符
|
||||
const lastMsg = messages.value[messages.value.length - 1];
|
||||
if (lastMsg?.content?.isLoading) {
|
||||
messages.value.pop();
|
||||
}
|
||||
|
||||
message_obj = reactive({
|
||||
type: 'bot',
|
||||
message: chain_type === 'reasoning' ? '' : chunk_json.data,
|
||||
@@ -257,6 +428,13 @@ export function useMessages(
|
||||
}
|
||||
} else if (chunk_json.type === 'update_title') {
|
||||
updateSessionTitle(chunk_json.session_id, chunk_json.data);
|
||||
} else if (chunk_json.type === 'message_saved') {
|
||||
// 更新最后一条 bot 消息的 id 和 created_at
|
||||
const lastBotMsg = messages.value[messages.value.length - 1];
|
||||
if (lastBotMsg && lastBotMsg.content?.type === 'bot') {
|
||||
lastBotMsg.id = chunk_json.data.id;
|
||||
lastBotMsg.created_at = chunk_json.data.created_at;
|
||||
}
|
||||
}
|
||||
|
||||
if ((chunk_json.type === 'break' && chunk_json.streaming) || !chunk_json.streaming) {
|
||||
@@ -298,7 +476,8 @@ export function useMessages(
|
||||
enableStreaming,
|
||||
getSessionMessages,
|
||||
sendMessage,
|
||||
toggleStreaming
|
||||
toggleStreaming,
|
||||
getAttachment
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"deleteChat": "Delete this conversation",
|
||||
"editTitle": "Edit Title",
|
||||
"fullscreen": "Fullscreen Mode",
|
||||
"exitFullscreen": "Exit Fullscreen"
|
||||
"exitFullscreen": "Exit Fullscreen",
|
||||
"reply": "Reply"
|
||||
},
|
||||
"conversation": {
|
||||
"newConversation": "New Conversation",
|
||||
@@ -71,6 +72,14 @@
|
||||
"reasoning": {
|
||||
"thinking": "Thinking Process"
|
||||
},
|
||||
"reply": {
|
||||
"replyTo": "Reply to",
|
||||
"notFound": "Message not found"
|
||||
},
|
||||
"time": {
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday"
|
||||
},
|
||||
"connection": {
|
||||
"title": "Connection Status Notice",
|
||||
"message": "The system detected that the chat connection needs to be re-established.",
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"deleteChat": "删除此对话",
|
||||
"editTitle": "编辑标题",
|
||||
"fullscreen": "全屏模式",
|
||||
"exitFullscreen": "退出全屏"
|
||||
"exitFullscreen": "退出全屏",
|
||||
"reply": "引用回复"
|
||||
},
|
||||
"conversation": {
|
||||
"newConversation": "新的聊天",
|
||||
@@ -71,6 +72,14 @@
|
||||
"reasoning": {
|
||||
"thinking": "思考过程"
|
||||
},
|
||||
"reply": {
|
||||
"replyTo": "引用",
|
||||
"notFound": "无法定位消息"
|
||||
},
|
||||
"time": {
|
||||
"today": "今天",
|
||||
"yesterday": "昨天"
|
||||
},
|
||||
"connection": {
|
||||
"title": "连接状态提醒",
|
||||
"message": "系统检测到聊天连接需要重新建立。",
|
||||
|
||||
@@ -138,10 +138,11 @@ const pluginMarketHeaders = computed(() => [
|
||||
|
||||
// 过滤要显示的插件
|
||||
const filteredExtensions = computed(() => {
|
||||
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
|
||||
if (!showReserved.value) {
|
||||
return extension_data?.data?.filter(ext => !ext.reserved) || [];
|
||||
return data.filter(ext => !ext.reserved);
|
||||
}
|
||||
return extension_data.data || [];
|
||||
return data;
|
||||
});
|
||||
|
||||
// 通过搜索过滤插件
|
||||
@@ -280,7 +281,8 @@ const checkUpdate = () => {
|
||||
onlinePluginsNameMap.set(plugin.name, plugin);
|
||||
});
|
||||
|
||||
extension_data.data.forEach(extension => {
|
||||
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
|
||||
data.forEach(extension => {
|
||||
const repoKey = extension.repo?.toLowerCase();
|
||||
const onlinePlugin = repoKey ? onlinePluginsMap.get(repoKey) : null;
|
||||
const onlinePluginByName = onlinePluginsNameMap.get(extension.name);
|
||||
@@ -562,8 +564,9 @@ const trimExtensionName = () => {
|
||||
};
|
||||
|
||||
const checkAlreadyInstalled = () => {
|
||||
const installedRepos = new Set(extension_data.data.map(ext => ext.repo?.toLowerCase()));
|
||||
const installedNames = new Set(extension_data.data.map(ext => ext.name));
|
||||
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
|
||||
const installedRepos = new Set(data.map(ext => ext.repo?.toLowerCase()));
|
||||
const installedNames = new Set(data.map(ext => ext.name));
|
||||
|
||||
for (let i = 0; i < pluginMarketData.value.length; i++) {
|
||||
const plugin = pluginMarketData.value[i];
|
||||
|
||||
Reference in New Issue
Block a user