Compare commits
12 Commits
feat/file-
...
refactor/w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1742bc23c1 | ||
|
|
9e5c48c70d | ||
|
|
2e8de16535 | ||
|
|
7a66911988 | ||
|
|
9221741cb2 | ||
|
|
e7a1e17d7f | ||
|
|
0599465a60 | ||
|
|
e103414114 | ||
|
|
e3e252ef69 | ||
|
|
77f996f137 | ||
|
|
839344bcd4 | ||
|
|
c98dea7e4b |
@@ -213,6 +213,27 @@ class BaseDatabase(abc.ABC):
|
|||||||
"""Get an attachment by its ID."""
|
"""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
|
@abc.abstractmethod
|
||||||
async def insert_persona(
|
async def insert_persona(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -470,6 +470,48 @@ class SQLiteDatabase(BaseDatabase):
|
|||||||
result = await session.execute(query)
|
result = await session.execute(query)
|
||||||
return result.scalar_one_or_none()
|
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(
|
||||||
|
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(
|
||||||
|
Attachment.attachment_id.in_(attachment_ids)
|
||||||
|
)
|
||||||
|
result = await session.execute(query)
|
||||||
|
return result.rowcount
|
||||||
|
|
||||||
async def insert_persona(
|
async def insert_persona(
|
||||||
self,
|
self,
|
||||||
persona_id,
|
persona_id,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from astrbot import logger
|
from astrbot import logger
|
||||||
from astrbot.core.message.components import Image, Plain, Record
|
from astrbot.core.message.components import File, Image, Plain, Record, Video
|
||||||
from astrbot.core.message.message_event_result import MessageChain
|
from astrbot.core.message.message_event_result import MessageChain
|
||||||
from astrbot.core.platform import (
|
from astrbot.core.platform import (
|
||||||
AstrBotMessage,
|
AstrBotMessage,
|
||||||
@@ -112,26 +112,19 @@ class WebChatAdapter(Platform):
|
|||||||
|
|
||||||
if payload["message"]:
|
if payload["message"]:
|
||||||
abm.message.append(Plain(payload["message"]))
|
abm.message.append(Plain(payload["message"]))
|
||||||
if payload["image_url"]:
|
|
||||||
if isinstance(payload["image_url"], list):
|
# 处理 files
|
||||||
for img in payload["image_url"]:
|
files_info = payload.get("files", [])
|
||||||
abm.message.append(
|
for file_info in files_info:
|
||||||
Image.fromFileSystem(os.path.join(self.imgs_dir, img)),
|
if file_info["type"] == "image":
|
||||||
)
|
abm.message.append(Image.fromFileSystem(file_info["path"]))
|
||||||
else:
|
elif file_info["type"] == "record":
|
||||||
abm.message.append(
|
abm.message.append(Record.fromFileSystem(file_info["path"]))
|
||||||
Image.fromFileSystem(
|
elif file_info["type"] == "file":
|
||||||
os.path.join(self.imgs_dir, payload["image_url"]),
|
filename = os.path.basename(file_info["path"])
|
||||||
),
|
abm.message.append(File(name=filename, file=file_info["path"]))
|
||||||
)
|
elif file_info["type"] == "video":
|
||||||
if payload["audio_url"]:
|
abm.message.append(Video.fromFileSystem(file_info["path"]))
|
||||||
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))
|
|
||||||
|
|
||||||
logger.debug(f"WebChatAdapter: {abm.message}")
|
logger.debug(f"WebChatAdapter: {abm.message}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import base64
|
import base64
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from astrbot.api import logger
|
from astrbot.api import logger
|
||||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
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.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
|
from .webchat_queue_mgr import webchat_queue_mgr
|
||||||
|
|
||||||
@@ -19,7 +19,9 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
os.makedirs(imgs_dir, exist_ok=True)
|
os.makedirs(imgs_dir, exist_ok=True)
|
||||||
|
|
||||||
@staticmethod
|
@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]
|
cid = session_id.split("!")[-1]
|
||||||
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
|
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
|
||||||
if not message:
|
if not message:
|
||||||
@@ -30,7 +32,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
"streaming": False,
|
"streaming": False,
|
||||||
}, # end means this request is finished
|
}, # end means this request is finished
|
||||||
)
|
)
|
||||||
return ""
|
return
|
||||||
|
|
||||||
data = ""
|
data = ""
|
||||||
for comp in message.chain:
|
for comp in message.chain:
|
||||||
@@ -47,24 +49,11 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
)
|
)
|
||||||
elif isinstance(comp, Image):
|
elif isinstance(comp, Image):
|
||||||
# save image to local
|
# save image to local
|
||||||
filename = str(uuid.uuid4()) + ".jpg"
|
filename = f"{str(uuid.uuid4())}.jpg"
|
||||||
path = os.path.join(imgs_dir, filename)
|
path = os.path.join(imgs_dir, filename)
|
||||||
if comp.file and comp.file.startswith("file:///"):
|
image_base64 = await comp.convert_to_base64()
|
||||||
ph = comp.file[8:]
|
with open(path, "wb") as f:
|
||||||
with open(path, "wb") as f:
|
f.write(base64.b64decode(image_base64))
|
||||||
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())
|
|
||||||
data = f"[IMAGE]{filename}"
|
data = f"[IMAGE]{filename}"
|
||||||
await web_chat_back_queue.put(
|
await web_chat_back_queue.put(
|
||||||
{
|
{
|
||||||
@@ -76,19 +65,11 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
)
|
)
|
||||||
elif isinstance(comp, Record):
|
elif isinstance(comp, Record):
|
||||||
# save record to local
|
# save record to local
|
||||||
filename = str(uuid.uuid4()) + ".wav"
|
filename = f"{str(uuid.uuid4())}.wav"
|
||||||
path = os.path.join(imgs_dir, filename)
|
path = os.path.join(imgs_dir, filename)
|
||||||
if comp.file and comp.file.startswith("file:///"):
|
record_base64 = await comp.convert_to_base64()
|
||||||
ph = comp.file[8:]
|
with open(path, "wb") as f:
|
||||||
with open(path, "wb") as f:
|
f.write(base64.b64decode(record_base64))
|
||||||
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())
|
|
||||||
data = f"[RECORD]{filename}"
|
data = f"[RECORD]{filename}"
|
||||||
await web_chat_back_queue.put(
|
await web_chat_back_queue.put(
|
||||||
{
|
{
|
||||||
@@ -98,6 +79,23 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
"streaming": streaming,
|
"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:
|
else:
|
||||||
logger.debug(f"webchat 忽略: {comp.type}")
|
logger.debug(f"webchat 忽略: {comp.type}")
|
||||||
|
|
||||||
@@ -131,6 +129,8 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
session_id=self.session_id,
|
session_id=self.session_id,
|
||||||
streaming=True,
|
streaming=True,
|
||||||
)
|
)
|
||||||
|
if not r:
|
||||||
|
continue
|
||||||
if chain.type == "reasoning":
|
if chain.type == "reasoning":
|
||||||
reasoning_content += chain.get_plain_text()
|
reasoning_content += chain.get_plain_text()
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class PlatformMessageHistoryManager:
|
|||||||
self,
|
self,
|
||||||
platform_id: str,
|
platform_id: str,
|
||||||
user_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_id: str | None = None,
|
||||||
sender_name: str | None = None,
|
sender_name: str | None = None,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from quart import Response as QuartResponse
|
from quart import g, make_response, request, send_file
|
||||||
from quart import g, make_response, request
|
|
||||||
|
|
||||||
from astrbot.core import logger
|
from astrbot.core import logger
|
||||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||||
from astrbot.core.db import BaseDatabase
|
from astrbot.core.db import BaseDatabase
|
||||||
|
from astrbot.core.db.po import Attachment
|
||||||
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
|
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
|
||||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ class ChatRoute(Route):
|
|||||||
self.update_session_display_name,
|
self.update_session_display_name,
|
||||||
),
|
),
|
||||||
"/chat/get_file": ("GET", self.get_file),
|
"/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),
|
"/chat/post_file": ("POST", self.post_file),
|
||||||
}
|
}
|
||||||
self.core_lifecycle = core_lifecycle
|
self.core_lifecycle = core_lifecycle
|
||||||
@@ -73,52 +74,176 @@ class ChatRoute(Route):
|
|||||||
if not real_file_path.startswith(real_imgs_dir):
|
if not real_file_path.startswith(real_imgs_dir):
|
||||||
return Response().error("Invalid file path").__dict__
|
return Response().error("Invalid file path").__dict__
|
||||||
|
|
||||||
with open(real_file_path, "rb") as f:
|
filename_ext = os.path.splitext(filename)[1].lower()
|
||||||
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 == ".wav":
|
if filename_ext[1:] in self.supported_imgs:
|
||||||
return QuartResponse(f.read(), mimetype="audio/wav")
|
return await send_file(real_file_path, mimetype="image/jpeg")
|
||||||
if filename_ext[1:] in self.supported_imgs:
|
return await send_file(real_file_path)
|
||||||
return QuartResponse(f.read(), mimetype="image/jpeg")
|
|
||||||
return QuartResponse(f.read())
|
|
||||||
|
|
||||||
except (FileNotFoundError, OSError):
|
except (FileNotFoundError, OSError):
|
||||||
return Response().error("File access error").__dict__
|
return Response().error("File access error").__dict__
|
||||||
|
|
||||||
async def post_image(self):
|
async def get_attachment(self):
|
||||||
post_data = await request.files
|
"""Get attachment file by attachment_id."""
|
||||||
if "file" not in post_data:
|
attachment_id = request.args.get("attachment_id")
|
||||||
return Response().error("Missing key: file").__dict__
|
if not attachment_id:
|
||||||
|
return Response().error("Missing key: attachment_id").__dict__
|
||||||
|
|
||||||
file = post_data["file"]
|
try:
|
||||||
filename = str(uuid.uuid4()) + ".jpg"
|
attachment = await self.db.get_attachment_by_id(attachment_id)
|
||||||
path = os.path.join(self.imgs_dir, filename)
|
if not attachment:
|
||||||
await file.save(path)
|
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):
|
async def post_file(self):
|
||||||
|
"""Upload a file and create an attachment record, return attachment_id."""
|
||||||
post_data = await request.files
|
post_data = await request.files
|
||||||
if "file" not in post_data:
|
if "file" not in post_data:
|
||||||
return Response().error("Missing key: file").__dict__
|
return Response().error("Missing key: file").__dict__
|
||||||
|
|
||||||
file = post_data["file"]
|
file = post_data["file"]
|
||||||
filename = f"{uuid.uuid4()!s}"
|
filename = file.filename or f"{uuid.uuid4()!s}"
|
||||||
# 通过文件格式判断文件类型
|
content_type = file.content_type or "application/octet-stream"
|
||||||
if file.content_type.startswith("audio"):
|
|
||||||
filename += ".wav"
|
# 根据 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)
|
path = os.path.join(self.imgs_dir, filename)
|
||||||
await file.save(path)
|
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,
|
||||||
|
attachments: list[Attachment],
|
||||||
|
) -> list:
|
||||||
|
"""构建用户消息的部分列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: 文本消息
|
||||||
|
files: attachment_id 列表
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
if message:
|
||||||
|
parts.append({"type": "plain", "text": message})
|
||||||
|
|
||||||
|
if attachments:
|
||||||
|
for attachment in attachments:
|
||||||
|
parts.append(
|
||||||
|
{
|
||||||
|
"type": attachment.type,
|
||||||
|
"attachment_id": attachment.attachment_id,
|
||||||
|
"filename": os.path.basename(attachment.path),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
await self.platform_history_mgr.insert(
|
||||||
|
platform_id="webchat",
|
||||||
|
user_id=webchat_conv_id,
|
||||||
|
content=new_his,
|
||||||
|
sender_id="bot",
|
||||||
|
sender_name="bot",
|
||||||
|
)
|
||||||
|
|
||||||
async def chat(self):
|
async def chat(self):
|
||||||
username = g.get("username", "guest")
|
username = g.get("username", "guest")
|
||||||
|
|
||||||
post_data = await request.json
|
post_data = await request.json
|
||||||
if "message" not in post_data and "image_url" not in post_data:
|
if "message" not in post_data and "files" not in post_data:
|
||||||
return Response().error("Missing key: message or image_url").__dict__
|
return Response().error("Missing key: message or files").__dict__
|
||||||
|
|
||||||
if "session_id" not in post_data and "conversation_id" not in post_data:
|
if "session_id" not in post_data and "conversation_id" not in post_data:
|
||||||
return (
|
return (
|
||||||
@@ -126,44 +251,44 @@ class ChatRoute(Route):
|
|||||||
)
|
)
|
||||||
|
|
||||||
message = post_data["message"]
|
message = post_data["message"]
|
||||||
# conversation_id = post_data["conversation_id"]
|
|
||||||
session_id = post_data.get("session_id", post_data.get("conversation_id"))
|
session_id = post_data.get("session_id", post_data.get("conversation_id"))
|
||||||
image_url = post_data.get("image_url")
|
files = post_data.get("files") # list of attachment_id
|
||||||
audio_url = post_data.get("audio_url")
|
|
||||||
selected_provider = post_data.get("selected_provider")
|
selected_provider = post_data.get("selected_provider")
|
||||||
selected_model = post_data.get("selected_model")
|
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:
|
if not message and not files:
|
||||||
return (
|
return Response().error("Message and files are both empty").__dict__
|
||||||
Response()
|
|
||||||
.error("Message and image_url and audio_url are empty")
|
|
||||||
.__dict__
|
|
||||||
)
|
|
||||||
if not session_id:
|
if not session_id:
|
||||||
return Response().error("session_id is empty").__dict__
|
return Response().error("session_id is empty").__dict__
|
||||||
|
|
||||||
# 追加用户消息
|
|
||||||
webchat_conv_id = session_id
|
webchat_conv_id = session_id
|
||||||
|
|
||||||
# 获取会话特定的队列
|
|
||||||
back_queue = webchat_queue_mgr.get_or_create_back_queue(webchat_conv_id)
|
back_queue = webchat_queue_mgr.get_or_create_back_queue(webchat_conv_id)
|
||||||
|
|
||||||
new_his = {"type": "user", "message": message}
|
# 构建并保存用户消息
|
||||||
if image_url:
|
attachments = await self.db.get_attachments(files)
|
||||||
new_his["image_url"] = image_url
|
message_parts = await self._build_user_message_parts(message, attachments)
|
||||||
if audio_url:
|
files_info = [
|
||||||
new_his["audio_url"] = audio_url
|
{
|
||||||
|
"type": attachment.type,
|
||||||
|
"path": attachment.path,
|
||||||
|
}
|
||||||
|
for attachment in attachments
|
||||||
|
]
|
||||||
|
|
||||||
await self.platform_history_mgr.insert(
|
await self.platform_history_mgr.insert(
|
||||||
platform_id="webchat",
|
platform_id="webchat",
|
||||||
user_id=webchat_conv_id,
|
user_id=webchat_conv_id,
|
||||||
content=new_his,
|
content={"type": "user", "message": message_parts},
|
||||||
sender_id=username,
|
sender_id=username,
|
||||||
sender_name=username,
|
sender_name=username,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def stream():
|
async def stream():
|
||||||
client_disconnected = False
|
client_disconnected = False
|
||||||
|
accumulated_parts = []
|
||||||
|
accumulated_text = ""
|
||||||
|
accumulated_reasoning = ""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with track_conversation(self.running_convs, webchat_conv_id):
|
async with track_conversation(self.running_convs, webchat_conv_id):
|
||||||
@@ -182,16 +307,17 @@ class ChatRoute(Route):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
result_text = result["data"]
|
result_text = result["data"]
|
||||||
type = result.get("type")
|
msg_type = result.get("type")
|
||||||
streaming = result.get("streaming", False)
|
streaming = result.get("streaming", False)
|
||||||
|
|
||||||
|
# 发送 SSE 数据
|
||||||
try:
|
try:
|
||||||
if not client_disconnected:
|
if not client_disconnected:
|
||||||
yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if not client_disconnected:
|
if not client_disconnected:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[WebChat] 用户 {username} 断开聊天长连接。 {e}",
|
f"[WebChat] 用户 {username} 断开聊天长连接。 {e}"
|
||||||
)
|
)
|
||||||
client_disconnected = True
|
client_disconnected = True
|
||||||
|
|
||||||
@@ -202,24 +328,55 @@ class ChatRoute(Route):
|
|||||||
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
|
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
|
||||||
client_disconnected = True
|
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
|
break
|
||||||
elif (
|
elif (
|
||||||
(streaming and type == "complete")
|
(streaming and msg_type == "complete")
|
||||||
or not streaming
|
or not streaming
|
||||||
or type == "break"
|
or msg_type == "break"
|
||||||
):
|
):
|
||||||
# 追加机器人消息
|
await self._save_bot_message(
|
||||||
new_his = {"type": "bot", "message": result_text}
|
webchat_conv_id,
|
||||||
if "reasoning" in result:
|
accumulated_text,
|
||||||
new_his["reasoning"] = result["reasoning"]
|
accumulated_parts,
|
||||||
await self.platform_history_mgr.insert(
|
accumulated_reasoning,
|
||||||
platform_id="webchat",
|
|
||||||
user_id=webchat_conv_id,
|
|
||||||
content=new_his,
|
|
||||||
sender_id="bot",
|
|
||||||
sender_name="bot",
|
|
||||||
)
|
)
|
||||||
|
# 重置累积变量 (对于 break 后的下一段消息)
|
||||||
|
if msg_type == "break":
|
||||||
|
accumulated_parts = []
|
||||||
|
accumulated_text = ""
|
||||||
|
accumulated_reasoning = ""
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
|
logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
|
||||||
|
|
||||||
@@ -231,8 +388,7 @@ class ChatRoute(Route):
|
|||||||
webchat_conv_id,
|
webchat_conv_id,
|
||||||
{
|
{
|
||||||
"message": message,
|
"message": message,
|
||||||
"image_url": image_url, # list
|
"files": files_info,
|
||||||
"audio_url": audio_url,
|
|
||||||
"selected_provider": selected_provider,
|
"selected_provider": selected_provider,
|
||||||
"selected_model": selected_model,
|
"selected_model": selected_model,
|
||||||
"enable_streaming": enable_streaming,
|
"enable_streaming": enable_streaming,
|
||||||
@@ -249,7 +405,7 @@ class ChatRoute(Route):
|
|||||||
"Connection": "keep-alive",
|
"Connection": "keep-alive",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response.timeout = None # fix SSE auto disconnect issue
|
response.timeout = None # fix SSE auto disconnect issue # pyright: ignore[reportAttributeAccessIssue]
|
||||||
return response
|
return response
|
||||||
|
|
||||||
async def delete_webchat_session(self):
|
async def delete_webchat_session(self):
|
||||||
@@ -271,6 +427,17 @@ class ChatRoute(Route):
|
|||||||
unified_msg_origin = f"{session.platform_id}:{message_type}:{session.platform_id}!{username}!{session_id}"
|
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)
|
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(
|
await self.platform_history_mgr.delete(
|
||||||
platform_id=session.platform_id,
|
platform_id=session.platform_id,
|
||||||
@@ -297,6 +464,41 @@ class ChatRoute(Route):
|
|||||||
|
|
||||||
return Response().ok().__dict__
|
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):
|
async def new_session(self):
|
||||||
"""Create a new Platform session (default: webchat)."""
|
"""Create a new Platform session (default: webchat)."""
|
||||||
username = g.get("username", "guest")
|
username = g.get("username", "guest")
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
v-model:prompt="prompt"
|
v-model:prompt="prompt"
|
||||||
:stagedImagesUrl="stagedImagesUrl"
|
:stagedImagesUrl="stagedImagesUrl"
|
||||||
:stagedAudioUrl="stagedAudioUrl"
|
:stagedAudioUrl="stagedAudioUrl"
|
||||||
|
:stagedFiles="stagedNonImageFiles"
|
||||||
:disabled="isStreaming"
|
:disabled="isStreaming"
|
||||||
:enableStreaming="enableStreaming"
|
:enableStreaming="enableStreaming"
|
||||||
:isRecording="isRecording"
|
:isRecording="isRecording"
|
||||||
@@ -93,6 +94,7 @@
|
|||||||
@toggleStreaming="toggleStreaming"
|
@toggleStreaming="toggleStreaming"
|
||||||
@removeImage="removeImage"
|
@removeImage="removeImage"
|
||||||
@removeAudio="removeAudio"
|
@removeAudio="removeAudio"
|
||||||
|
@removeFile="removeFile"
|
||||||
@startRecording="handleStartRecording"
|
@startRecording="handleStartRecording"
|
||||||
@stopRecording="handleStopRecording"
|
@stopRecording="handleStopRecording"
|
||||||
@pasteImage="handlePaste"
|
@pasteImage="handlePaste"
|
||||||
@@ -189,14 +191,17 @@ const {
|
|||||||
} = useSessions(props.chatboxMode);
|
} = useSessions(props.chatboxMode);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
stagedImagesName,
|
|
||||||
stagedImagesUrl,
|
stagedImagesUrl,
|
||||||
stagedAudioUrl,
|
stagedAudioUrl,
|
||||||
|
stagedFiles,
|
||||||
|
stagedNonImageFiles,
|
||||||
getMediaFile,
|
getMediaFile,
|
||||||
processAndUploadImage,
|
processAndUploadImage,
|
||||||
|
processAndUploadFile,
|
||||||
handlePaste,
|
handlePaste,
|
||||||
removeImage,
|
removeImage,
|
||||||
removeAudio,
|
removeAudio,
|
||||||
|
removeFile,
|
||||||
clearStaged,
|
clearStaged,
|
||||||
cleanupMediaCache
|
cleanupMediaCache
|
||||||
} = useMediaHandling();
|
} = useMediaHandling();
|
||||||
@@ -295,13 +300,18 @@ async function handleStopRecording() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleFileSelect(files: FileList) {
|
async function handleFileSelect(files: FileList) {
|
||||||
|
const imageTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
await processAndUploadImage(file);
|
if (imageTypes.includes(file.type)) {
|
||||||
|
await processAndUploadImage(file);
|
||||||
|
} else {
|
||||||
|
await processAndUploadFile(file);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSendMessage() {
|
async function handleSendMessage() {
|
||||||
if (!prompt.value.trim() && stagedImagesName.value.length === 0 && !stagedAudioUrl.value) {
|
if (!prompt.value.trim() && stagedFiles.value.length === 0 && !stagedAudioUrl.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,8 +320,13 @@ async function handleSendMessage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const promptToSend = prompt.value.trim();
|
const promptToSend = prompt.value.trim();
|
||||||
const imageNamesToSend = [...stagedImagesName.value];
|
|
||||||
const audioNameToSend = stagedAudioUrl.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 = '';
|
prompt.value = '';
|
||||||
@@ -324,7 +339,7 @@ async function handleSendMessage() {
|
|||||||
|
|
||||||
await sendMsg(
|
await sendMsg(
|
||||||
promptToSend,
|
promptToSend,
|
||||||
imageNamesToSend,
|
filesToSend,
|
||||||
audioNameToSend,
|
audioNameToSend,
|
||||||
selectedProviderId,
|
selectedProviderId,
|
||||||
selectedModelName
|
selectedModelName
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; justify-content: flex-end; margin-top: 8px; align-items: center;">
|
<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 />
|
style="display: none" multiple />
|
||||||
<v-progress-circular v-if="disabled" indeterminate size="16" class="mr-1" width="1.5" />
|
<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"
|
<v-btn @click="triggerImageInput" icon="mdi-plus" variant="text" color="deep-purple"
|
||||||
@@ -45,8 +45,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 附件预览区 -->
|
<!-- 附件预览区 -->
|
||||||
<div class="attachments-preview" v-if="stagedImagesUrl.length > 0 || stagedAudioUrl">
|
<div class="attachments-preview" v-if="stagedImagesUrl.length > 0 || stagedAudioUrl || (stagedFiles && stagedFiles.length > 0)">
|
||||||
<div v-for="(img, index) in stagedImagesUrl" :key="index" class="image-preview">
|
<div v-for="(img, index) in stagedImagesUrl" :key="'img-' + index" class="image-preview">
|
||||||
<img :src="img" class="preview-image" />
|
<img :src="img" class="preview-image" />
|
||||||
<v-btn @click="$emit('removeImage', index)" class="remove-attachment-btn" icon="mdi-close"
|
<v-btn @click="$emit('removeImage', index)" class="remove-attachment-btn" icon="mdi-close"
|
||||||
size="small" color="error" variant="text" />
|
size="small" color="error" variant="text" />
|
||||||
@@ -60,6 +60,15 @@
|
|||||||
<v-btn @click="$emit('removeAudio')" class="remove-attachment-btn" icon="mdi-close" size="small"
|
<v-btn @click="$emit('removeAudio')" class="remove-attachment-btn" icon="mdi-close" size="small"
|
||||||
color="error" variant="text" />
|
color="error" variant="text" />
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -71,10 +80,19 @@ import ProviderModelSelector from './ProviderModelSelector.vue';
|
|||||||
import ConfigSelector from './ConfigSelector.vue';
|
import ConfigSelector from './ConfigSelector.vue';
|
||||||
import type { Session } from '@/composables/useSessions';
|
import type { Session } from '@/composables/useSessions';
|
||||||
|
|
||||||
|
interface StagedFileInfo {
|
||||||
|
attachment_id: string;
|
||||||
|
filename: string;
|
||||||
|
original_name: string;
|
||||||
|
url: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
stagedImagesUrl: string[];
|
stagedImagesUrl: string[];
|
||||||
stagedAudioUrl: string;
|
stagedAudioUrl: string;
|
||||||
|
stagedFiles?: StagedFileInfo[];
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
enableStreaming: boolean;
|
enableStreaming: boolean;
|
||||||
isRecording: boolean;
|
isRecording: boolean;
|
||||||
@@ -86,7 +104,8 @@ interface Props {
|
|||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
currentSession: null,
|
currentSession: null,
|
||||||
configId: null
|
configId: null,
|
||||||
|
stagedFiles: () => []
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -95,6 +114,7 @@ const emit = defineEmits<{
|
|||||||
toggleStreaming: [];
|
toggleStreaming: [];
|
||||||
removeImage: [index: number];
|
removeImage: [index: number];
|
||||||
removeAudio: [];
|
removeAudio: [];
|
||||||
|
removeFile: [index: number];
|
||||||
startRecording: [];
|
startRecording: [];
|
||||||
stopRecording: [];
|
stopRecording: [];
|
||||||
pasteImage: [event: ClipboardEvent];
|
pasteImage: [event: ClipboardEvent];
|
||||||
@@ -117,7 +137,7 @@ const sessionPlatformId = computed(() => props.currentSession?.platform_id || 'w
|
|||||||
const sessionIsGroup = computed(() => Boolean(props.currentSession?.is_group));
|
const sessionIsGroup = computed(() => Boolean(props.currentSession?.is_group));
|
||||||
|
|
||||||
const canSend = computed(() => {
|
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 长按录音相关
|
// Ctrl+B 长按录音相关
|
||||||
@@ -239,7 +259,8 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.image-preview,
|
.image-preview,
|
||||||
.audio-preview {
|
.audio-preview,
|
||||||
|
.file-preview {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
@@ -252,11 +273,19 @@ defineExpose({
|
|||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-chip {
|
.audio-chip,
|
||||||
|
.file-chip {
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-name-preview {
|
||||||
|
max-width: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.remove-attachment-btn {
|
.remove-attachment-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -8px;
|
top: -8px;
|
||||||
|
|||||||
@@ -24,6 +24,22 @@
|
|||||||
{{ t('messages.errors.browser.audioNotSupported') }}
|
{{ t('messages.errors.browser.audioNotSupported') }}
|
||||||
</audio>
|
</audio>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,10 +93,29 @@
|
|||||||
{{ t('messages.errors.browser.audioNotSupported') }}
|
{{ t('messages.errors.browser.audioNotSupported') }}
|
||||||
</audio>
|
</audio>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-actions" v-if="!msg.content.isLoading">
|
<div class="message-actions" v-if="!msg.content.isLoading">
|
||||||
<v-btn :icon="getCopyIcon(index)" size="small" variant="text" class="copy-message-btn"
|
<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) }"
|
:class="{ 'copy-success': isCopySuccess(index) }"
|
||||||
@click="copyBotMessage(msg.content.message, index)" :title="t('core.common.copy')" />
|
@click="copyBotMessage(msg.content.message, index)" :title="t('core.common.copy')" />
|
||||||
</div>
|
</div>
|
||||||
@@ -96,6 +131,7 @@ import { useI18n, useModuleI18n } from '@/i18n/composables';
|
|||||||
import MarkdownIt from 'markdown-it';
|
import MarkdownIt from 'markdown-it';
|
||||||
import hljs from 'highlight.js';
|
import hljs from 'highlight.js';
|
||||||
import 'highlight.js/styles/github.css';
|
import 'highlight.js/styles/github.css';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
const md = new MarkdownIt({
|
const md = new MarkdownIt({
|
||||||
html: false,
|
html: false,
|
||||||
@@ -147,6 +183,7 @@ export default {
|
|||||||
scrollThreshold: 1,
|
scrollThreshold: 1,
|
||||||
scrollTimer: null,
|
scrollTimer: null,
|
||||||
expandedReasoning: new Set(), // Track which reasoning blocks are expanded
|
expandedReasoning: new Set(), // Track which reasoning blocks are expanded
|
||||||
|
downloadingFiles: new Set(), // Track which files are being downloaded
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -179,6 +216,35 @@ export default {
|
|||||||
return this.expandedReasoning.has(messageIndex);
|
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) {
|
copyCodeToClipboard(code) {
|
||||||
navigator.clipboard.writeText(code).then(() => {
|
navigator.clipboard.writeText(code).then(() => {
|
||||||
@@ -375,6 +441,37 @@ export default {
|
|||||||
clearTimeout(this.scrollTimer);
|
clearTimeout(this.scrollTimer);
|
||||||
this.scrollTimer = null;
|
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 +510,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-item {
|
.message-item {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 12px;
|
||||||
animation: fadeIn 0.3s ease-out;
|
animation: fadeIn 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,10 +538,18 @@ export default {
|
|||||||
|
|
||||||
.message-actions {
|
.message-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
margin-left: 8px;
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--v-theme-secondaryText);
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bot-message:hover .message-actions {
|
.bot-message:hover .message-actions {
|
||||||
@@ -553,7 +658,6 @@ export default {
|
|||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
@@ -568,6 +672,71 @@ export default {
|
|||||||
max-width: 300px;
|
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 {
|
.fade-in {
|
||||||
animation: fadeIn 0.3s ease-in-out;
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
|
|||||||
@@ -110,9 +110,9 @@ function getSessions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
stagedImagesName,
|
|
||||||
stagedImagesUrl,
|
stagedImagesUrl,
|
||||||
stagedAudioUrl,
|
stagedAudioUrl,
|
||||||
|
stagedFiles,
|
||||||
getMediaFile,
|
getMediaFile,
|
||||||
processAndUploadImage,
|
processAndUploadImage,
|
||||||
handlePaste,
|
handlePaste,
|
||||||
@@ -164,7 +164,7 @@ async function handleFileSelect(files: FileList) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSendMessage() {
|
async function handleSendMessage() {
|
||||||
if (!prompt.value.trim() && stagedImagesName.value.length === 0 && !stagedAudioUrl.value) {
|
if (!prompt.value.trim() && stagedFiles.value.length === 0 && !stagedAudioUrl.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,8 +174,13 @@ async function handleSendMessage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const promptToSend = prompt.value.trim();
|
const promptToSend = prompt.value.trim();
|
||||||
const imageNamesToSend = [...stagedImagesName.value];
|
|
||||||
const audioNameToSend = stagedAudioUrl.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 = '';
|
prompt.value = '';
|
||||||
@@ -188,7 +193,7 @@ async function handleSendMessage() {
|
|||||||
|
|
||||||
await sendMsg(
|
await sendMsg(
|
||||||
promptToSend,
|
promptToSend,
|
||||||
imageNamesToSend,
|
filesToSend,
|
||||||
audioNameToSend,
|
audioNameToSend,
|
||||||
selectedProviderId,
|
selectedProviderId,
|
||||||
selectedModelName
|
selectedModelName
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { ref } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import axios from 'axios';
|
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() {
|
export function useMediaHandling() {
|
||||||
const stagedImagesName = ref<string[]>([]);
|
|
||||||
const stagedImagesUrl = ref<string[]>([]);
|
|
||||||
const stagedAudioUrl = ref<string>('');
|
const stagedAudioUrl = ref<string>('');
|
||||||
|
const stagedFiles = ref<StagedFileInfo[]>([]);
|
||||||
const mediaCache = ref<Record<string, string>>({});
|
const mediaCache = ref<Record<string, string>>({});
|
||||||
|
|
||||||
async function getMediaFile(filename: string): Promise<string> {
|
async function getMediaFile(filename: string): Promise<string> {
|
||||||
@@ -32,20 +39,49 @@ export function useMediaHandling() {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/api/chat/post_image', formData, {
|
const response = await axios.post('/api/chat/post_file', formData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data'
|
'Content-Type': 'multipart/form-data'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const img = response.data.data.filename;
|
const { attachment_id, filename, type } = response.data.data;
|
||||||
stagedImagesName.value.push(img);
|
stagedFiles.value.push({
|
||||||
stagedImagesUrl.value.push(URL.createObjectURL(file));
|
attachment_id,
|
||||||
|
filename,
|
||||||
|
original_name: file.name,
|
||||||
|
url: URL.createObjectURL(file),
|
||||||
|
type
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error uploading image:', 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) {
|
async function handlePaste(event: ClipboardEvent) {
|
||||||
const items = event.clipboardData?.items;
|
const items = event.clipboardData?.items;
|
||||||
if (!items) return;
|
if (!items) return;
|
||||||
@@ -61,23 +97,54 @@ export function useMediaHandling() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeImage(index: number) {
|
function removeImage(index: number) {
|
||||||
const urlToRevoke = stagedImagesUrl.value[index];
|
// 找到第 index 个图片类型的文件
|
||||||
if (urlToRevoke && urlToRevoke.startsWith('blob:')) {
|
let imageCount = 0;
|
||||||
URL.revokeObjectURL(urlToRevoke);
|
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() {
|
function removeAudio() {
|
||||||
stagedAudioUrl.value = '';
|
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() {
|
function clearStaged() {
|
||||||
stagedImagesName.value = [];
|
|
||||||
stagedImagesUrl.value = [];
|
|
||||||
stagedAudioUrl.value = '';
|
stagedAudioUrl.value = '';
|
||||||
|
// 清理文件的 blob URLs
|
||||||
|
stagedFiles.value.forEach(file => {
|
||||||
|
if (file.url.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(file.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stagedFiles.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupMediaCache() {
|
function cleanupMediaCache() {
|
||||||
@@ -89,15 +156,28 @@ export function useMediaHandling() {
|
|||||||
mediaCache.value = {};
|
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 {
|
return {
|
||||||
stagedImagesName,
|
|
||||||
stagedImagesUrl,
|
stagedImagesUrl,
|
||||||
stagedAudioUrl,
|
stagedAudioUrl,
|
||||||
|
stagedFiles,
|
||||||
|
stagedNonImageFiles,
|
||||||
getMediaFile,
|
getMediaFile,
|
||||||
processAndUploadImage,
|
processAndUploadImage,
|
||||||
|
processAndUploadFile,
|
||||||
handlePaste,
|
handlePaste,
|
||||||
removeImage,
|
removeImage,
|
||||||
removeAudio,
|
removeAudio,
|
||||||
|
removeFile,
|
||||||
clearStaged,
|
clearStaged,
|
||||||
cleanupMediaCache
|
cleanupMediaCache
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,19 +2,37 @@ import { ref, reactive, type Ref } from 'vue';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useToast } from '@/utils/toast';
|
import { useToast } from '@/utils/toast';
|
||||||
|
|
||||||
|
// 新格式消息部分的类型定义
|
||||||
|
export interface MessagePart {
|
||||||
|
type: 'plain' | 'image' | 'record' | 'file' | 'video';
|
||||||
|
text?: string; // for plain
|
||||||
|
attachment_id?: string; // for image, record, file, video
|
||||||
|
filename?: string; // for file (filename from backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件信息结构
|
||||||
|
export interface FileInfo {
|
||||||
|
url?: string; // blob URL (可选,点击时才加载)
|
||||||
|
filename: string;
|
||||||
|
attachment_id?: string; // 用于按需下载
|
||||||
|
}
|
||||||
|
|
||||||
export interface MessageContent {
|
export interface MessageContent {
|
||||||
type: string;
|
type: string;
|
||||||
message: string;
|
message: string | MessagePart[]; // 支持旧格式(string)和新格式(MessagePart[])
|
||||||
reasoning?: string;
|
reasoning?: string;
|
||||||
image_url?: string[];
|
image_url?: string[];
|
||||||
audio_url?: string;
|
audio_url?: string;
|
||||||
|
file_url?: FileInfo[];
|
||||||
embedded_images?: string[];
|
embedded_images?: string[];
|
||||||
embedded_audio?: string;
|
embedded_audio?: string;
|
||||||
|
embedded_files?: FileInfo[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
content: MessageContent;
|
content: MessageContent;
|
||||||
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMessages(
|
export function useMessages(
|
||||||
@@ -29,6 +47,7 @@ export function useMessages(
|
|||||||
const isToastedRunningInfo = ref(false);
|
const isToastedRunningInfo = ref(false);
|
||||||
const activeSSECount = ref(0);
|
const activeSSECount = ref(0);
|
||||||
const enableStreaming = ref(true);
|
const enableStreaming = ref(true);
|
||||||
|
const attachmentCache = new Map<string, string>(); // attachment_id -> blob URL
|
||||||
|
|
||||||
// 从 localStorage 读取流式响应开关状态
|
// 从 localStorage 读取流式响应开关状态
|
||||||
const savedStreamingState = localStorage.getItem('enableStreaming');
|
const savedStreamingState = localStorage.getItem('enableStreaming');
|
||||||
@@ -41,6 +60,68 @@ export function useMessages(
|
|||||||
localStorage.setItem('enableStreaming', JSON.stringify(enableStreaming.value));
|
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[] = [];
|
||||||
|
|
||||||
|
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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// video 类型可以后续扩展
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为旧格式兼容的结构
|
||||||
|
content.message = textParts.join('\n');
|
||||||
|
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) {
|
async function getSessionMessages(sessionId: string, router: any) {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
@@ -64,35 +145,45 @@ export function useMessages(
|
|||||||
// 处理历史消息中的媒体文件
|
// 处理历史消息中的媒体文件
|
||||||
for (let i = 0; i < history.length; i++) {
|
for (let i = 0; i < history.length; i++) {
|
||||||
let content = history[i].content;
|
let content = history[i].content;
|
||||||
|
|
||||||
if (content.message?.startsWith('[IMAGE]')) {
|
// 首先尝试解析新格式消息
|
||||||
let img = content.message.replace('[IMAGE]', '');
|
await parseMessageContent(content);
|
||||||
const imageUrl = await getMediaFile(img);
|
|
||||||
if (!content.embedded_images) {
|
// 以下是旧格式的兼容处理 (message 是字符串的情况)
|
||||||
content.embedded_images = [];
|
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) {
|
if (content.image_url && content.image_url.length > 0) {
|
||||||
for (let j = 0; j < content.image_url.length; j++) {
|
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);
|
content.audio_url = await getMediaFile(content.audio_url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.value = history;
|
messages.value = history;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -101,7 +192,7 @@ export function useMessages(
|
|||||||
|
|
||||||
async function sendMessage(
|
async function sendMessage(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
imageNames: string[],
|
stagedFiles: { attachment_id: string; url: string; original_name: string; type: string }[],
|
||||||
audioName: string,
|
audioName: string,
|
||||||
selectedProviderId: string,
|
selectedProviderId: string,
|
||||||
selectedModelName: string
|
selectedModelName: string
|
||||||
@@ -111,27 +202,33 @@ export function useMessages(
|
|||||||
type: 'user',
|
type: 'user',
|
||||||
message: prompt,
|
message: prompt,
|
||||||
image_url: [],
|
image_url: [],
|
||||||
audio_url: undefined
|
audio_url: undefined,
|
||||||
|
file_url: []
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert image filenames to blob URLs
|
// 分离图片和文件
|
||||||
if (imageNames.length > 0) {
|
const imageFiles = stagedFiles.filter(f => f.type === 'image');
|
||||||
const imagePromises = imageNames.map(name => {
|
const nonImageFiles = stagedFiles.filter(f => f.type !== 'image');
|
||||||
if (!name.startsWith('blob:')) {
|
|
||||||
return getMediaFile(name);
|
// 使用 attachment_id 获取图片内容(避免 blob URL 被 revoke 后 404)
|
||||||
}
|
if (imageFiles.length > 0) {
|
||||||
return Promise.resolve(name);
|
const imageUrls = await Promise.all(
|
||||||
});
|
imageFiles.map(f => getAttachment(f.attachment_id))
|
||||||
userMessage.image_url = await Promise.all(imagePromises);
|
);
|
||||||
|
userMessage.image_url = imageUrls.filter(url => url !== '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert audio filename to blob URL
|
// 使用 blob URL 作为音频预览(录音不走 attachment)
|
||||||
if (audioName) {
|
if (audioName) {
|
||||||
if (!audioName.startsWith('blob:')) {
|
userMessage.audio_url = audioName;
|
||||||
userMessage.audio_url = await getMediaFile(audioName);
|
}
|
||||||
} else {
|
|
||||||
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 });
|
messages.value.push({ content: userMessage });
|
||||||
@@ -151,6 +248,9 @@ export function useMessages(
|
|||||||
isConvRunning.value = true;
|
isConvRunning.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 收集所有 attachment_id
|
||||||
|
const files = stagedFiles.map(f => f.attachment_id);
|
||||||
|
|
||||||
const response = await fetch('/api/chat/send', {
|
const response = await fetch('/api/chat/send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -160,8 +260,7 @@ export function useMessages(
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message: prompt,
|
message: prompt,
|
||||||
session_id: currSessionId.value,
|
session_id: currSessionId.value,
|
||||||
image_url: imageNames,
|
files: files,
|
||||||
audio_url: audioName ? [audioName] : [],
|
|
||||||
selected_provider: selectedProviderId,
|
selected_provider: selectedProviderId,
|
||||||
selected_model: selectedModelName,
|
selected_model: selectedModelName,
|
||||||
enable_streaming: enableStreaming.value
|
enable_streaming: enableStreaming.value
|
||||||
@@ -207,6 +306,11 @@ export function useMessages(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lastMsg = messages.value[messages.value.length - 1];
|
||||||
|
if (lastMsg?.content?.isLoading) {
|
||||||
|
messages.value.pop();
|
||||||
|
}
|
||||||
|
|
||||||
if (chunk_json.type === 'error') {
|
if (chunk_json.type === 'error') {
|
||||||
console.error('Error received:', chunk_json.data);
|
console.error('Error received:', chunk_json.data);
|
||||||
continue;
|
continue;
|
||||||
@@ -230,16 +334,26 @@ export function useMessages(
|
|||||||
embedded_audio: audioUrl
|
embedded_audio: audioUrl
|
||||||
};
|
};
|
||||||
messages.value.push({ content: bot_resp });
|
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') {
|
} else if (chunk_json.type === 'plain') {
|
||||||
const chain_type = chunk_json.chain_type || 'normal';
|
const chain_type = chunk_json.chain_type || 'normal';
|
||||||
|
|
||||||
if (!in_streaming) {
|
if (!in_streaming) {
|
||||||
// 移除加载占位符
|
|
||||||
const lastMsg = messages.value[messages.value.length - 1];
|
|
||||||
if (lastMsg?.content?.isLoading) {
|
|
||||||
messages.value.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
message_obj = reactive({
|
message_obj = reactive({
|
||||||
type: 'bot',
|
type: 'bot',
|
||||||
message: chain_type === 'reasoning' ? '' : chunk_json.data,
|
message: chain_type === 'reasoning' ? '' : chunk_json.data,
|
||||||
@@ -298,7 +412,8 @@ export function useMessages(
|
|||||||
enableStreaming,
|
enableStreaming,
|
||||||
getSessionMessages,
|
getSessionMessages,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
toggleStreaming
|
toggleStreaming,
|
||||||
|
getAttachment
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,10 @@
|
|||||||
"reasoning": {
|
"reasoning": {
|
||||||
"thinking": "Thinking Process"
|
"thinking": "Thinking Process"
|
||||||
},
|
},
|
||||||
|
"time": {
|
||||||
|
"today": "Today",
|
||||||
|
"yesterday": "Yesterday"
|
||||||
|
},
|
||||||
"connection": {
|
"connection": {
|
||||||
"title": "Connection Status Notice",
|
"title": "Connection Status Notice",
|
||||||
"message": "The system detected that the chat connection needs to be re-established.",
|
"message": "The system detected that the chat connection needs to be re-established.",
|
||||||
|
|||||||
@@ -71,6 +71,10 @@
|
|||||||
"reasoning": {
|
"reasoning": {
|
||||||
"thinking": "思考过程"
|
"thinking": "思考过程"
|
||||||
},
|
},
|
||||||
|
"time": {
|
||||||
|
"today": "今天",
|
||||||
|
"yesterday": "昨天"
|
||||||
|
},
|
||||||
"connection": {
|
"connection": {
|
||||||
"title": "连接状态提醒",
|
"title": "连接状态提醒",
|
||||||
"message": "系统检测到聊天连接需要重新建立。",
|
"message": "系统检测到聊天连接需要重新建立。",
|
||||||
|
|||||||
@@ -137,10 +137,11 @@ const pluginMarketHeaders = computed(() => [
|
|||||||
|
|
||||||
// 过滤要显示的插件
|
// 过滤要显示的插件
|
||||||
const filteredExtensions = computed(() => {
|
const filteredExtensions = computed(() => {
|
||||||
|
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
|
||||||
if (!showReserved.value) {
|
if (!showReserved.value) {
|
||||||
return extension_data?.data?.filter(ext => !ext.reserved) || [];
|
return data.filter(ext => !ext.reserved);
|
||||||
}
|
}
|
||||||
return extension_data.data || [];
|
return data;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 通过搜索过滤插件
|
// 通过搜索过滤插件
|
||||||
@@ -275,7 +276,8 @@ const checkUpdate = () => {
|
|||||||
onlinePluginsNameMap.set(plugin.name, plugin);
|
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 repoKey = extension.repo?.toLowerCase();
|
||||||
const onlinePlugin = repoKey ? onlinePluginsMap.get(repoKey) : null;
|
const onlinePlugin = repoKey ? onlinePluginsMap.get(repoKey) : null;
|
||||||
const onlinePluginByName = onlinePluginsNameMap.get(extension.name);
|
const onlinePluginByName = onlinePluginsNameMap.get(extension.name);
|
||||||
@@ -507,8 +509,9 @@ const trimExtensionName = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const checkAlreadyInstalled = () => {
|
const checkAlreadyInstalled = () => {
|
||||||
const installedRepos = new Set(extension_data.data.map(ext => ext.repo?.toLowerCase()));
|
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
|
||||||
const installedNames = new Set(extension_data.data.map(ext => ext.name));
|
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++) {
|
for (let i = 0; i < pluginMarketData.value.length; i++) {
|
||||||
const plugin = pluginMarketData.value[i];
|
const plugin = pluginMarketData.value[i];
|
||||||
|
|||||||
Reference in New Issue
Block a user