mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-05 20:30:14 +08:00
Compare commits
8 Commits
feat/chatu
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25cbd41e08 | ||
|
|
3b8caf37e6 | ||
|
|
85b653b6f0 | ||
|
|
c9eed7b65e | ||
|
|
cc0b347508 | ||
|
|
30426c4f67 | ||
|
|
041fba4df4 | ||
|
|
b43cc6dee0 |
@@ -47,11 +47,12 @@ ruff check .
|
||||
2. Do not add any report files such as xxx_SUMMARY.md.
|
||||
3. After finishing, use `ruff format .` and `ruff check .` to format and check the code.
|
||||
4. When committing, ensure to use conventional commits messages, such as `feat: add new agent for data analysis` or `fix: resolve bug in provider manager`.
|
||||
5. Use English for all new comments.
|
||||
5. Use **English** for all comments and logs.
|
||||
6. For path handling, use `pathlib.Path` instead of string paths, and use `astrbot.core.utils.path_utils` to get the AstrBot data and temp directory.
|
||||
7. When backend API routes, request/response schemas, or OpenAPI definitions change, regenerate the frontend API client by running `cd dashboard && pnpm generate:api`.
|
||||
8. When updating the project version, keep `[project].version` in `pyproject.toml` and `__version__` in `astrbot/__init__.py` in sync. `VERSION` in `astrbot/core/config/default.py` should derive from `astrbot.__version__` instead of hardcoding a separate version string.
|
||||
9. When designing WebUI dialogs, use `text-h3 pa-4 pb-0 pl-6` as the base class for dialog titles, and use `variant="text"` or `variant="tonal"` for dialog buttons.
|
||||
10. Consider cross-platform compatibility (e.g., Windows, macOS, and Linux, as well as Arm64 and x86 CPU architectures) and compatibility with Python 3.10+.
|
||||
|
||||
### KISS and First Principles
|
||||
|
||||
|
||||
@@ -145,6 +145,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
REPEATED_TOOL_NOTICE_L1_THRESHOLD = 3
|
||||
REPEATED_TOOL_NOTICE_L2_THRESHOLD = 4
|
||||
REPEATED_TOOL_NOTICE_L3_THRESHOLD = 5
|
||||
MALFORMED_TOOL_NAME_PLACEHOLDER = "__malformed_tool_name__"
|
||||
REPEATED_TOOL_NOTICE_L1_TEMPLATE = (
|
||||
"\n\n[SYSTEM NOTICE] By the way, you have executed the same tool "
|
||||
"`{tool_name}` {streak} times consecutively. Double-check whether another "
|
||||
@@ -532,6 +533,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
)
|
||||
break
|
||||
|
||||
self._sanitize_malformed_tool_calls(resp)
|
||||
yield resp
|
||||
return
|
||||
|
||||
@@ -689,6 +691,22 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
streak=streak,
|
||||
)
|
||||
|
||||
def _sanitize_malformed_tool_calls(
|
||||
self,
|
||||
llm_resp: LLMResponse,
|
||||
) -> None:
|
||||
"""Normalize malformed tool call names.
|
||||
|
||||
Args:
|
||||
llm_resp: The LLM response whose tool call lists should be sanitized.
|
||||
"""
|
||||
llm_resp.tools_call_name = [
|
||||
self.MALFORMED_TOOL_NAME_PLACEHOLDER
|
||||
if tool_name is None or tool_name.strip() == ""
|
||||
else tool_name
|
||||
for tool_name in llm_resp.tools_call_name
|
||||
]
|
||||
|
||||
@override
|
||||
async def step(self):
|
||||
"""Process a single step of the agent.
|
||||
@@ -1312,6 +1330,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
)
|
||||
if requery_resp:
|
||||
llm_resp = requery_resp
|
||||
self._sanitize_malformed_tool_calls(llm_resp)
|
||||
|
||||
# If the re-query still returns no tool calls, and also does not have a meaningful assistant reply,
|
||||
# we consider it as a failure of the LLM to follow the tool-use instruction,
|
||||
@@ -1339,6 +1358,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
)
|
||||
if repair_resp:
|
||||
llm_resp = repair_resp
|
||||
self._sanitize_malformed_tool_calls(llm_resp)
|
||||
|
||||
return llm_resp, subset
|
||||
|
||||
|
||||
@@ -54,17 +54,9 @@ CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT = (
|
||||
"move toward structure, insight, or guidance.\n"
|
||||
"You listen more than you speak, respect uncertainty, avoid forcing quick conclusions or grand narratives, "
|
||||
"and prefer clear, restrained language over unnecessary emotional embellishment. At your core, you value "
|
||||
"empathy, clarity, autonomy, and meaning, favoring steady, sustainable progress over judgment or dramatic leaps. "
|
||||
"empathy, clarity, autonomy, and meaning, favoring steady, sustainable progress over judgment or dramatic leaps."
|
||||
'When you answered, you need to add a follow up question / summarization but do not add "Follow up" words. '
|
||||
"Such as, user asked you to generate codes, you can add: Do you need me to run these codes for you?"
|
||||
"\n\n[ChatUI HTML GenUI]\n"
|
||||
"When the user asks you to create, prototype, preview, or modify a visual HTML UI, "
|
||||
"output the runnable HTML inside exactly one `<html-genui>...</html-genui>` block. "
|
||||
'You may add a short optional title on the opening tag, for example `<html-genui title="Dashboard mockup">`. '
|
||||
"Do not wrap the block in Markdown code fences. Put complete, self-contained HTML/CSS/JavaScript inside the tag, "
|
||||
"including `<style>` and `<script>` when needed. Prefer responsive layouts that fit a chat iframe. "
|
||||
"For revisions, output the full updated `<html-genui>` block instead of a diff. "
|
||||
"Only use this block when an HTML UI preview is useful; otherwise answer normally."
|
||||
)
|
||||
|
||||
LIVE_MODE_SYSTEM_PROMPT = (
|
||||
|
||||
@@ -1593,7 +1593,7 @@ CONFIG_METADATA_2 = {
|
||||
"enable": False,
|
||||
"api_key": "",
|
||||
"api_base": "https://api.xiaomimimo.com/v1",
|
||||
"model": "mimo-v2-omni",
|
||||
"model": "mimo-v2.5-asr",
|
||||
"timeout": "20",
|
||||
"proxy": "",
|
||||
},
|
||||
|
||||
@@ -30,6 +30,10 @@ from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
||||
from astrbot.core.utils.media_utils import MediaResolver, file_uri_to_path, is_file_uri
|
||||
|
||||
|
||||
class APIReturnNoneError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _patch_qq_botpy_formdata() -> None:
|
||||
"""Patch qq-botpy for aiohttp>=3.12 compatibility.
|
||||
|
||||
@@ -49,21 +53,25 @@ def _patch_qq_botpy_formdata() -> None:
|
||||
|
||||
_patch_qq_botpy_formdata()
|
||||
|
||||
# Retry decorator for QQ Official API transient errors (HTTP 500/504)
|
||||
_qqofficial_retry = retry(
|
||||
retry=retry_if_exception_type(
|
||||
(
|
||||
botpy.errors.ServerError,
|
||||
botpy.errors.SequenceNumberError,
|
||||
OSError,
|
||||
asyncio.TimeoutError,
|
||||
)
|
||||
),
|
||||
stop=stop_after_attempt(5),
|
||||
wait=wait_exponential(multiplier=2, min=2, max=30),
|
||||
before_sleep=before_sleep_log(logger, logging.WARNING),
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
def _qqofficial_retry(max_attempts: int = 5):
|
||||
"""Retry decorator for QQ Official API transient errors (HTTP 500/504)"""
|
||||
return retry(
|
||||
retry=retry_if_exception_type(
|
||||
(
|
||||
botpy.errors.ServerError,
|
||||
botpy.errors.SequenceNumberError,
|
||||
OSError,
|
||||
asyncio.TimeoutError,
|
||||
APIReturnNoneError,
|
||||
)
|
||||
),
|
||||
stop=stop_after_attempt(max_attempts),
|
||||
wait=wait_exponential(multiplier=2, min=2, max=30),
|
||||
before_sleep=before_sleep_log(logger, logging.WARNING),
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
|
||||
_QQOFFICIAL_SEND_API_ERRORS = (
|
||||
botpy.errors.ForbiddenError,
|
||||
@@ -558,14 +566,13 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
"srv_send_msg": False,
|
||||
}
|
||||
|
||||
@_qqofficial_retry
|
||||
@_qqofficial_retry()
|
||||
async def _do_upload():
|
||||
if "openid" in kwargs:
|
||||
payload["openid"] = kwargs["openid"]
|
||||
route = Route(
|
||||
"POST", "/v2/users/{openid}/files", openid=kwargs["openid"]
|
||||
)
|
||||
return await self.bot.api._http.request(route, json=payload)
|
||||
elif "group_openid" in kwargs:
|
||||
payload["group_openid"] = kwargs["group_openid"]
|
||||
route = Route(
|
||||
@@ -573,11 +580,20 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
"/v2/groups/{group_openid}/files",
|
||||
group_openid=kwargs["group_openid"],
|
||||
)
|
||||
return await self.bot.api._http.request(route, json=payload)
|
||||
else:
|
||||
raise ValueError("Invalid upload parameters")
|
||||
|
||||
result = await _do_upload()
|
||||
result = await self.bot.api._http.request(route, json=payload)
|
||||
if result is None:
|
||||
err_msg = "上传图片API返回None,触发重试"
|
||||
raise APIReturnNoneError(err_msg)
|
||||
return result
|
||||
|
||||
try:
|
||||
result = await _do_upload()
|
||||
except APIReturnNoneError:
|
||||
logger.warning(f"上传图片API返回None,共尝试5次后放弃: {payload}")
|
||||
raise
|
||||
|
||||
if not isinstance(result, dict):
|
||||
raise RuntimeError(
|
||||
@@ -629,9 +645,13 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
else:
|
||||
return None
|
||||
|
||||
@_qqofficial_retry
|
||||
@_qqofficial_retry()
|
||||
async def _do_upload():
|
||||
return await self.bot.api._http.request(route, json=payload)
|
||||
result = await self.bot.api._http.request(route, json=payload)
|
||||
if result is None:
|
||||
err_msg = "上传文件API返回None,触发重试"
|
||||
raise APIReturnNoneError(err_msg)
|
||||
return result
|
||||
|
||||
try:
|
||||
result = await _do_upload()
|
||||
@@ -646,6 +666,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
file_info=result["file_info"],
|
||||
ttl=result.get("ttl", 0),
|
||||
)
|
||||
except APIReturnNoneError:
|
||||
logger.warning(f"上传文件API返回None,共尝试5次后放弃: {file_source}")
|
||||
except (botpy.errors.ServerError, botpy.errors.SequenceNumberError):
|
||||
logger.error(f"上传媒体文件失败,共尝试5次后放弃: {file_source}")
|
||||
except Exception as e:
|
||||
@@ -680,11 +702,26 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
stream_data.pop("id", None)
|
||||
payload["stream"] = stream_data
|
||||
route = Route("POST", "/v2/users/{openid}/messages", openid=openid)
|
||||
result = await self.bot.api._http.request(route, json=payload)
|
||||
|
||||
if result is None:
|
||||
logger.warning("[QQOfficial] post_c2c_message: API 返回 None,跳过本次发送")
|
||||
retry_times = 3
|
||||
|
||||
@_qqofficial_retry(retry_times)
|
||||
async def _do_request():
|
||||
result = await self.bot.api._http.request(route, json=payload)
|
||||
if result is None:
|
||||
err_msg = "发送消息API返回None,触发重试"
|
||||
raise APIReturnNoneError(err_msg)
|
||||
return result
|
||||
|
||||
result = None
|
||||
try:
|
||||
result = await _do_request()
|
||||
except APIReturnNoneError:
|
||||
logger.warning(
|
||||
f"[QQOfficial] post_c2c_message: 发送消息失败,API 返回 None,共尝试{retry_times}次后放弃"
|
||||
)
|
||||
return None
|
||||
|
||||
if not isinstance(result, dict):
|
||||
logger.error(f"[QQOfficial] post_c2c_message: 响应不是 dict: {result}")
|
||||
return None
|
||||
|
||||
@@ -3,7 +3,7 @@ import mimetypes
|
||||
import shutil
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from astrbot.core.db.po import Attachment
|
||||
@@ -29,6 +29,23 @@ ReplyHistoryGetter = Callable[
|
||||
MEDIA_PART_TYPES = {"image", "record", "file", "video"}
|
||||
|
||||
|
||||
def _safe_display_filename(filename: str | None) -> str:
|
||||
"""Return a safe basename for display-only filenames.
|
||||
|
||||
Args:
|
||||
filename: Candidate filename from a message payload or component.
|
||||
|
||||
Returns:
|
||||
Sanitized basename, or an empty string when the value is unusable.
|
||||
"""
|
||||
if not filename:
|
||||
return ""
|
||||
basename = (
|
||||
PurePosixPath(str(filename).replace("\\", "/")).name.replace("\x00", "").strip()
|
||||
)
|
||||
return "" if basename in {"", ".", ".."} else basename
|
||||
|
||||
|
||||
def strip_message_parts_path_fields(message_parts: list[dict]) -> list[dict]:
|
||||
return [{k: v for k, v in part.items() if k != "path"} for part in message_parts]
|
||||
|
||||
@@ -229,14 +246,19 @@ async def build_webchat_message_parts(
|
||||
continue
|
||||
|
||||
attachment_path = Path(attachment.path)
|
||||
display_name = (
|
||||
_safe_display_filename(part.get("filename")) or attachment_path.name
|
||||
)
|
||||
message_parts.append(
|
||||
{
|
||||
"type": attachment.type,
|
||||
"attachment_id": attachment.attachment_id,
|
||||
"filename": attachment_path.name,
|
||||
"filename": display_name,
|
||||
"path": str(attachment_path),
|
||||
}
|
||||
)
|
||||
if display_name != attachment_path.name:
|
||||
message_parts[-1]["stored_filename"] = attachment_path.name
|
||||
|
||||
return message_parts
|
||||
|
||||
@@ -340,6 +362,7 @@ async def create_attachment_part_from_existing_file(
|
||||
insert_attachment: AttachmentInserter,
|
||||
attachments_dir: str | Path,
|
||||
fallback_dirs: Sequence[str | Path] = (),
|
||||
display_name: str | None = None,
|
||||
) -> dict | None:
|
||||
basename = Path(filename).name
|
||||
candidate_paths = [Path(attachments_dir) / basename]
|
||||
@@ -358,11 +381,15 @@ async def create_attachment_part_from_existing_file(
|
||||
if not attachment:
|
||||
return None
|
||||
|
||||
return {
|
||||
safe_display_name = _safe_display_filename(display_name)
|
||||
part = {
|
||||
"type": attach_type,
|
||||
"attachment_id": attachment.attachment_id,
|
||||
"filename": file_path.name,
|
||||
"filename": safe_display_name or file_path.name,
|
||||
}
|
||||
if part["filename"] != file_path.name:
|
||||
part["stored_filename"] = file_path.name
|
||||
return part
|
||||
|
||||
|
||||
async def message_chain_to_storage_message_parts(
|
||||
@@ -464,8 +491,11 @@ async def _copy_file_to_attachment_part(
|
||||
if not attachment:
|
||||
return None
|
||||
|
||||
return {
|
||||
part = {
|
||||
"type": attach_type,
|
||||
"attachment_id": attachment.attachment_id,
|
||||
"filename": display_name or src_path.name,
|
||||
"filename": _safe_display_filename(display_name) or src_path.name,
|
||||
}
|
||||
if part["filename"] != target_path.name:
|
||||
part["stored_filename"] = target_path.name
|
||||
return part
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
@@ -122,12 +122,19 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
elif isinstance(comp, File):
|
||||
# save file to local
|
||||
file_path = await comp.get_file()
|
||||
original_name = comp.name or os.path.basename(file_path)
|
||||
raw_original_name = comp.name or os.path.basename(file_path)
|
||||
original_name = (
|
||||
PurePosixPath(str(raw_original_name).replace("\\", "/"))
|
||||
.name.replace("\x00", "")
|
||||
.strip()
|
||||
)
|
||||
if original_name in {"", ".", ".."}:
|
||||
original_name = os.path.basename(file_path) or "file"
|
||||
ext = os.path.splitext(original_name)[1] or ""
|
||||
filename = f"{uuid.uuid4()!s}{ext}"
|
||||
dest_path = os.path.join(attachments_dir, filename)
|
||||
shutil.copy2(file_path, dest_path)
|
||||
data = f"[FILE]{filename}"
|
||||
data = f"[FILE]{filename}|{original_name}"
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
"type": "file",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
@@ -10,7 +11,16 @@ DEFAULT_MIMO_API_BASE = "https://api.xiaomimimo.com/v1"
|
||||
DEFAULT_MIMO_TTS_MODEL = "mimo-v2-tts"
|
||||
DEFAULT_MIMO_TTS_VOICE = "mimo_default"
|
||||
DEFAULT_MIMO_TTS_SEED_TEXT = "Hello, MiMo, have you had lunch?"
|
||||
DEFAULT_MIMO_STT_MODEL = "mimo-v2-omni"
|
||||
# The MiMo-V2 series went offline on 2026-06-30; mimo-v2.5-asr is the
|
||||
# dedicated speech recognition model per the official model lineup.
|
||||
DEFAULT_MIMO_STT_MODEL = "mimo-v2.5-asr"
|
||||
DEFAULT_MIMO_STT_SYSTEM_PROMPT = (
|
||||
"You are a speech transcription assistant. "
|
||||
"Transcribe the spoken content from the audio exactly and return only the transcription text."
|
||||
)
|
||||
DEFAULT_MIMO_STT_USER_PROMPT = (
|
||||
"Please transcribe the content of the audio and return only the transcription text."
|
||||
)
|
||||
|
||||
|
||||
class MiMoAPIError(Exception):
|
||||
@@ -67,9 +77,50 @@ async def prepare_audio_input(audio_source: str) -> tuple[str, list[Path]]:
|
||||
)
|
||||
if audio_data is None:
|
||||
raise ValueError(f"Invalid audio data: {describe_media_ref(audio_source)}")
|
||||
_validate_wav_payload(audio_data.base64_data, audio_source)
|
||||
return audio_data.to_data_url(), []
|
||||
|
||||
|
||||
def _decode_base64_header(base64_data: str) -> bytes:
|
||||
chunk = "".join(base64_data[:64].split())
|
||||
padding = len(chunk) % 4
|
||||
if padding:
|
||||
chunk += "=" * (4 - padding)
|
||||
return base64.b64decode(chunk)
|
||||
|
||||
|
||||
def _validate_wav_payload(base64_data: str, audio_source: str) -> None:
|
||||
"""Reject audio payloads whose bytes are not RIFF/WAVE.
|
||||
|
||||
MiMo only accepts wav/mp3 audio. When a platform voice file (e.g. Tencent
|
||||
SILK from QQ) slips through the WAV conversion chain unchanged, the API
|
||||
replies with an opaque HTTP 400, so fail locally with the real reason.
|
||||
|
||||
Args:
|
||||
base64_data: Base64-encoded audio payload about to be sent.
|
||||
audio_source: Original media reference, used in error messages.
|
||||
|
||||
Raises:
|
||||
MiMoAPIError: Raised when the payload is not valid WAV data.
|
||||
"""
|
||||
try:
|
||||
header = _decode_base64_header(base64_data)
|
||||
except Exception:
|
||||
header = b""
|
||||
if len(header) >= 12 and header[:4] == b"RIFF" and header[8:12] == b"WAVE":
|
||||
return
|
||||
if header.startswith((b"#!SILK_V3", b"\x02#!SILK_V3")):
|
||||
raise MiMoAPIError(
|
||||
"Audio for MiMo STT is still Tencent SILK data after WAV conversion; "
|
||||
"check that the silk-python package is installed and working: "
|
||||
f"{describe_media_ref(audio_source)}"
|
||||
)
|
||||
raise MiMoAPIError(
|
||||
"Audio for MiMo STT could not be converted to WAV "
|
||||
f"(unrecognized audio bytes): {describe_media_ref(audio_source)}"
|
||||
)
|
||||
|
||||
|
||||
def cleanup_files(paths: list[Path]) -> None:
|
||||
for path in paths:
|
||||
try:
|
||||
|
||||
@@ -4,6 +4,8 @@ from ..register import register_provider_adapter
|
||||
from .mimo_api_common import (
|
||||
DEFAULT_MIMO_API_BASE,
|
||||
DEFAULT_MIMO_STT_MODEL,
|
||||
DEFAULT_MIMO_STT_SYSTEM_PROMPT,
|
||||
DEFAULT_MIMO_STT_USER_PROMPT,
|
||||
MiMoAPIError,
|
||||
build_api_url,
|
||||
build_headers,
|
||||
@@ -33,23 +35,49 @@ class ProviderMiMoSTTAPI(STTProvider):
|
||||
self.set_model(provider_config.get("model", DEFAULT_MIMO_STT_MODEL))
|
||||
self.client = create_http_client(self.timeout, self.proxy)
|
||||
|
||||
def _is_asr_model(self) -> bool:
|
||||
return "asr" in (self.model_name or "").lower()
|
||||
|
||||
def _build_messages(self, audio_data_url: str) -> list[dict]:
|
||||
audio_content = {
|
||||
"type": "input_audio",
|
||||
"input_audio": {
|
||||
"data": audio_data_url,
|
||||
},
|
||||
}
|
||||
if self._is_asr_model():
|
||||
# Dedicated ASR models (speech-recognition docs) take bare audio.
|
||||
return [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [audio_content],
|
||||
},
|
||||
]
|
||||
# Multimodal models such as mimo-v2.5 (audio-understanding docs)
|
||||
# require a text instruction alongside the audio, otherwise the API
|
||||
# rejects the request.
|
||||
return [
|
||||
{
|
||||
"role": "system",
|
||||
"content": DEFAULT_MIMO_STT_SYSTEM_PROMPT,
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
audio_content,
|
||||
{
|
||||
"type": "text",
|
||||
"text": DEFAULT_MIMO_STT_USER_PROMPT,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
async def get_text(self, audio_url: str) -> str:
|
||||
audio_data_url, cleanup_paths = await prepare_audio_input(audio_url)
|
||||
payload = {
|
||||
"model": self.model_name,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": {
|
||||
"data": audio_data_url,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"messages": self._build_messages(audio_data_url),
|
||||
"max_completion_tokens": 1024,
|
||||
}
|
||||
|
||||
|
||||
@@ -501,7 +501,7 @@ class ChatService:
|
||||
)
|
||||
|
||||
async def create_attachment_from_file(
|
||||
self, filename: str, attach_type: str
|
||||
self, filename: str, attach_type: str, display_name: str | None = None
|
||||
) -> dict | None:
|
||||
return await create_attachment_part_from_existing_file(
|
||||
filename,
|
||||
@@ -509,6 +509,7 @@ class ChatService:
|
||||
insert_attachment=self.db.insert_attachment,
|
||||
attachments_dir=self.attachments_dir,
|
||||
fallback_dirs=[self.webchat_img_dir],
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
async def resolve_webchat_file(
|
||||
@@ -897,9 +898,14 @@ class ChatService:
|
||||
):
|
||||
yield attachment_saved_event
|
||||
elif msg_type == "file":
|
||||
filename = result_text.replace("[FILE]", "")
|
||||
filename = result_text.replace("[FILE]", "", 1)
|
||||
display_name = None
|
||||
if "|" in filename:
|
||||
filename, display_name = filename.split("|", 1)
|
||||
part = await self.create_attachment_from_file(
|
||||
filename, "file"
|
||||
filename,
|
||||
"file",
|
||||
display_name=display_name,
|
||||
)
|
||||
message_accumulator.add_attachment(part)
|
||||
if attachment_saved_event := build_attachment_saved_event(
|
||||
@@ -1190,7 +1196,11 @@ class ChatService:
|
||||
|
||||
async def get_session(self, username: str, session_id: str) -> dict:
|
||||
session = await self.db.get_platform_session_by_id(session_id)
|
||||
platform_id = session.platform_id if session else "webchat"
|
||||
if not session:
|
||||
raise ChatServiceError(f"Session {session_id} not found")
|
||||
if session.creator != username:
|
||||
raise ChatServiceError("Permission denied")
|
||||
platform_id = session.platform_id
|
||||
|
||||
project_info = await self.db.get_project_by_session(
|
||||
session_id=session_id, creator=username
|
||||
|
||||
@@ -205,7 +205,7 @@ class LiveChatService:
|
||||
logger.info(f"[Live Chat] WebSocket 连接关闭: {username}")
|
||||
|
||||
async def create_attachment_from_file(
|
||||
self, filename: str, attach_type: str
|
||||
self, filename: str, attach_type: str, display_name: str | None = None
|
||||
) -> dict | None:
|
||||
return await create_attachment_part_from_existing_file(
|
||||
filename,
|
||||
@@ -213,6 +213,7 @@ class LiveChatService:
|
||||
insert_attachment=self.db.insert_attachment,
|
||||
attachments_dir=self.attachments_dir,
|
||||
fallback_dirs=[self.webchat_img_dir],
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -650,13 +651,27 @@ class LiveChatService:
|
||||
message_accumulator.add_attachment(part)
|
||||
await send_attachment_saved_event(part)
|
||||
elif result_type == "file":
|
||||
filename = str(result_text).replace("[FILE]", "").split("|", 1)[0]
|
||||
part = await self.create_attachment_from_file(filename, "file")
|
||||
filename = str(result_text).replace("[FILE]", "", 1)
|
||||
display_name = None
|
||||
if "|" in filename:
|
||||
filename, display_name = filename.split("|", 1)
|
||||
part = await self.create_attachment_from_file(
|
||||
filename,
|
||||
"file",
|
||||
display_name=display_name,
|
||||
)
|
||||
message_accumulator.add_attachment(part)
|
||||
await send_attachment_saved_event(part)
|
||||
elif result_type == "video":
|
||||
filename = str(result_text).replace("[VIDEO]", "").split("|", 1)[0]
|
||||
part = await self.create_attachment_from_file(filename, "video")
|
||||
filename = str(result_text).replace("[VIDEO]", "", 1)
|
||||
display_name = None
|
||||
if "|" in filename:
|
||||
filename, display_name = filename.split("|", 1)
|
||||
part = await self.create_attachment_from_file(
|
||||
filename,
|
||||
"video",
|
||||
display_name=display_name,
|
||||
)
|
||||
message_accumulator.add_attachment(part)
|
||||
await send_attachment_saved_event(part)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import tempfile
|
||||
import traceback
|
||||
import uuid
|
||||
import zipfile
|
||||
@@ -22,7 +23,7 @@ from astrbot.core.desktop_runtime import (
|
||||
from astrbot.core.updator import AstrBotUpdator
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_data_path,
|
||||
get_astrbot_system_tmp_path,
|
||||
get_astrbot_temp_path,
|
||||
)
|
||||
from astrbot.core.utils.io import (
|
||||
download_dashboard as _download_dashboard,
|
||||
@@ -206,158 +207,168 @@ class UpdateService:
|
||||
reboot: Whether to restart AstrBot after applying files.
|
||||
proxy: Optional GitHub proxy URL.
|
||||
"""
|
||||
update_temp_dir = Path(get_astrbot_system_tmp_path()) / "updates"
|
||||
update_temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
update_token = uuid.uuid4().hex
|
||||
dashboard_zip_path = update_temp_dir / f"{update_token}-dashboard.zip"
|
||||
core_zip_path = update_temp_dir / f"{update_token}-core.zip"
|
||||
update_temp_parent = Path(get_astrbot_temp_path()) / "updates"
|
||||
try:
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"dashboard",
|
||||
"running",
|
||||
"正在下载 WebUI...",
|
||||
0,
|
||||
)
|
||||
await self.download_dashboard(
|
||||
path=str(dashboard_zip_path),
|
||||
latest=latest,
|
||||
version=version,
|
||||
proxy=proxy or "",
|
||||
progress_callback=self._make_progress_callback(
|
||||
if update_temp_parent.is_symlink():
|
||||
update_temp_parent.unlink()
|
||||
update_temp_parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
update_temp_parent.chmod(0o700)
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="project-update-",
|
||||
dir=update_temp_parent,
|
||||
) as update_temp_dir_name:
|
||||
update_temp_dir = Path(update_temp_dir_name)
|
||||
update_token = uuid.uuid4().hex
|
||||
dashboard_zip_path = update_temp_dir / f"{update_token}-dashboard.zip"
|
||||
core_zip_path = update_temp_dir / f"{update_token}-core.zip"
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"dashboard",
|
||||
"running",
|
||||
"正在下载 WebUI...",
|
||||
0,
|
||||
45,
|
||||
),
|
||||
extract=False,
|
||||
)
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"dashboard",
|
||||
"done",
|
||||
"WebUI 下载完成。",
|
||||
45,
|
||||
)
|
||||
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"core",
|
||||
"running",
|
||||
"正在下载 AstrBot 项目代码...",
|
||||
45,
|
||||
)
|
||||
core_zip_path = Path(
|
||||
await self.astrbot_updator.download_update_package(
|
||||
)
|
||||
await self.download_dashboard(
|
||||
path=str(dashboard_zip_path),
|
||||
latest=latest,
|
||||
version=version,
|
||||
proxy=proxy or "",
|
||||
path=core_zip_path,
|
||||
progress_callback=self._make_progress_callback(
|
||||
progress_id,
|
||||
"core",
|
||||
45,
|
||||
"dashboard",
|
||||
0,
|
||||
45,
|
||||
),
|
||||
extract=False,
|
||||
)
|
||||
)
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"core",
|
||||
"done",
|
||||
"项目代码下载完成。",
|
||||
90,
|
||||
)
|
||||
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"verify",
|
||||
"running",
|
||||
"下载完成,正在校验更新包...",
|
||||
90,
|
||||
)
|
||||
|
||||
def _verify_update_packages() -> None:
|
||||
for zip_path in (dashboard_zip_path, core_zip_path):
|
||||
with zipfile.ZipFile(zip_path, "r") as archive:
|
||||
corrupt_member = archive.testzip()
|
||||
if corrupt_member:
|
||||
raise UpdateServiceError(f"更新包校验失败: {corrupt_member}")
|
||||
|
||||
await asyncio.to_thread(_verify_update_packages)
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"verify",
|
||||
"done",
|
||||
"更新包校验完成。",
|
||||
91,
|
||||
)
|
||||
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"apply",
|
||||
"running",
|
||||
"下载完成,正在应用更新...",
|
||||
91,
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
self.astrbot_updator.apply_update_package,
|
||||
core_zip_path,
|
||||
)
|
||||
await self.extract_dashboard(
|
||||
dashboard_zip_path,
|
||||
Path(get_astrbot_data_path()),
|
||||
)
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"apply",
|
||||
"done",
|
||||
"更新文件应用完成。",
|
||||
92,
|
||||
)
|
||||
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"dependencies",
|
||||
"running",
|
||||
"正在更新依赖...",
|
||||
92,
|
||||
)
|
||||
logger.info("更新依赖中...")
|
||||
try:
|
||||
await self.pip_install(requirements_path="requirements.txt")
|
||||
except Exception as exc:
|
||||
logger.error(f"更新依赖失败: {exc}")
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"dependencies",
|
||||
"done",
|
||||
"依赖更新完成。",
|
||||
96,
|
||||
)
|
||||
|
||||
if reboot:
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"restart",
|
||||
"running",
|
||||
"更新成功,正在准备重启...",
|
||||
98,
|
||||
"dashboard",
|
||||
"done",
|
||||
"WebUI 下载完成。",
|
||||
45,
|
||||
)
|
||||
await self.core_lifecycle.restart()
|
||||
message = "更新成功,AstrBot 将在 2 秒内全量重启以应用新的代码。"
|
||||
else:
|
||||
message = "更新成功,AstrBot 将在下次启动时应用新的代码。"
|
||||
|
||||
self.update_progress[progress_id].update(
|
||||
{
|
||||
"status": "success",
|
||||
"stage": "done",
|
||||
"message": message,
|
||||
"overall_percent": 100,
|
||||
},
|
||||
)
|
||||
logger.info(message)
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"core",
|
||||
"running",
|
||||
"正在下载 AstrBot 项目代码...",
|
||||
45,
|
||||
)
|
||||
core_zip_path = Path(
|
||||
await self.astrbot_updator.download_update_package(
|
||||
latest=latest,
|
||||
version=version,
|
||||
proxy=proxy or "",
|
||||
path=core_zip_path,
|
||||
progress_callback=self._make_progress_callback(
|
||||
progress_id,
|
||||
"core",
|
||||
45,
|
||||
45,
|
||||
),
|
||||
)
|
||||
)
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"core",
|
||||
"done",
|
||||
"项目代码下载完成。",
|
||||
90,
|
||||
)
|
||||
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"verify",
|
||||
"running",
|
||||
"下载完成,正在校验更新包...",
|
||||
90,
|
||||
)
|
||||
|
||||
def _verify_update_packages() -> None:
|
||||
for zip_path in (dashboard_zip_path, core_zip_path):
|
||||
with zipfile.ZipFile(zip_path, "r") as archive:
|
||||
corrupt_member = archive.testzip()
|
||||
if corrupt_member:
|
||||
raise UpdateServiceError(
|
||||
f"更新包校验失败: {corrupt_member}"
|
||||
)
|
||||
|
||||
await asyncio.to_thread(_verify_update_packages)
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"verify",
|
||||
"done",
|
||||
"更新包校验完成。",
|
||||
91,
|
||||
)
|
||||
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"apply",
|
||||
"running",
|
||||
"下载完成,正在应用更新...",
|
||||
91,
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
self.astrbot_updator.apply_update_package,
|
||||
core_zip_path,
|
||||
)
|
||||
await self.extract_dashboard(
|
||||
dashboard_zip_path,
|
||||
Path(get_astrbot_data_path()),
|
||||
)
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"apply",
|
||||
"done",
|
||||
"更新文件应用完成。",
|
||||
92,
|
||||
)
|
||||
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"dependencies",
|
||||
"running",
|
||||
"正在更新依赖...",
|
||||
92,
|
||||
)
|
||||
logger.info("更新依赖中...")
|
||||
try:
|
||||
await self.pip_install(requirements_path="requirements.txt")
|
||||
except Exception as exc:
|
||||
logger.error(f"更新依赖失败: {exc}")
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"dependencies",
|
||||
"done",
|
||||
"依赖更新完成。",
|
||||
96,
|
||||
)
|
||||
|
||||
if reboot:
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"restart",
|
||||
"running",
|
||||
"更新成功,正在准备重启...",
|
||||
98,
|
||||
)
|
||||
await self.core_lifecycle.restart()
|
||||
message = "更新成功,AstrBot 将在 2 秒内全量重启以应用新的代码。"
|
||||
else:
|
||||
message = "更新成功,AstrBot 将在下次启动时应用新的代码。"
|
||||
|
||||
self.update_progress[progress_id].update(
|
||||
{
|
||||
"status": "success",
|
||||
"stage": "done",
|
||||
"message": message,
|
||||
"overall_percent": 100,
|
||||
},
|
||||
)
|
||||
logger.info(message)
|
||||
except asyncio.CancelledError:
|
||||
self.update_progress[progress_id].update(
|
||||
{
|
||||
@@ -376,13 +387,6 @@ class UpdateService:
|
||||
)
|
||||
logger.error(f"/api/update_project: {traceback.format_exc()}")
|
||||
logger.debug(f"Update task failed: {exc!s}")
|
||||
finally:
|
||||
for zip_path in (dashboard_zip_path, core_zip_path):
|
||||
try:
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
except Exception as cleanup_exc:
|
||||
logger.warning(f"清理更新临时文件失败: {zip_path}, {cleanup_exc}")
|
||||
|
||||
async def update_dashboard(self) -> UpdateServiceResult:
|
||||
try:
|
||||
|
||||
@@ -329,6 +329,7 @@ export type MessagePart = {
|
||||
attachment_id?: string;
|
||||
url?: string;
|
||||
filename?: string;
|
||||
stored_filename?: string;
|
||||
mime_type?: string;
|
||||
[key: string]: unknown | string;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Auto-generated MDI subset – 278 icons */
|
||||
/* Auto-generated MDI subset – 273 icons */
|
||||
/* Do not edit manually. Run: pnpm run subset-icons */
|
||||
|
||||
@font-face {
|
||||
@@ -256,10 +256,6 @@
|
||||
content: "\F0167";
|
||||
}
|
||||
|
||||
.mdi-code-braces::before {
|
||||
content: "\F0169";
|
||||
}
|
||||
|
||||
.mdi-code-json::before {
|
||||
content: "\F0626";
|
||||
}
|
||||
@@ -452,6 +448,10 @@
|
||||
content: "\F021C";
|
||||
}
|
||||
|
||||
.mdi-file-image::before {
|
||||
content: "\F021F";
|
||||
}
|
||||
|
||||
.mdi-file-music-outline::before {
|
||||
content: "\F0E2A";
|
||||
}
|
||||
@@ -624,22 +624,6 @@
|
||||
content: "\F0318";
|
||||
}
|
||||
|
||||
.mdi-language-css3::before {
|
||||
content: "\F031C";
|
||||
}
|
||||
|
||||
.mdi-language-html5::before {
|
||||
content: "\F031D";
|
||||
}
|
||||
|
||||
.mdi-language-java::before {
|
||||
content: "\F0B37";
|
||||
}
|
||||
|
||||
.mdi-language-javascript::before {
|
||||
content: "\F031E";
|
||||
}
|
||||
|
||||
.mdi-language-markdown::before {
|
||||
content: "\F0354";
|
||||
}
|
||||
@@ -648,14 +632,6 @@
|
||||
content: "\F0F5B";
|
||||
}
|
||||
|
||||
.mdi-language-python::before {
|
||||
content: "\F0320";
|
||||
}
|
||||
|
||||
.mdi-language-typescript::before {
|
||||
content: "\F06E6";
|
||||
}
|
||||
|
||||
.mdi-layers-outline::before {
|
||||
content: "\F09FE";
|
||||
}
|
||||
@@ -1012,6 +988,10 @@
|
||||
content: "\F060D";
|
||||
}
|
||||
|
||||
.mdi-svg::before {
|
||||
content: "\F0721";
|
||||
}
|
||||
|
||||
.mdi-sync::before {
|
||||
content: "\F04E6";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -92,7 +92,7 @@
|
||||
>
|
||||
<div
|
||||
class="attachment-icon"
|
||||
:style="{ color: filePresentation(file).color }"
|
||||
:style="{ '--attachment-color': filePresentation(file).color }"
|
||||
>
|
||||
<v-icon :icon="filePresentation(file).icon" size="24"></v-icon>
|
||||
<span class="attachment-ext">{{
|
||||
@@ -315,6 +315,7 @@ import ConfigSelector from "./ConfigSelector.vue";
|
||||
import ProviderModelMenu from "./ProviderModelMenu.vue";
|
||||
import StyledMenu from "@/components/shared/StyledMenu.vue";
|
||||
import CommandSuggestion from "./CommandSuggestion.vue";
|
||||
import { attachmentPresentation } from "./attachmentPresentation";
|
||||
import type { Session } from "@/composables/useSessions";
|
||||
import type { SuggestionCommand } from "./CommandSuggestion.vue";
|
||||
|
||||
@@ -546,62 +547,8 @@ const hasStagedAttachments = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const fileTypeStyles: Record<
|
||||
string,
|
||||
{ color: string; icon: string; label: string }
|
||||
> = {
|
||||
pdf: { color: "#d32f2f", icon: "mdi-file-pdf-box", label: "PDF" },
|
||||
txt: { color: "#1976d2", icon: "mdi-file-document-outline", label: "TXT" },
|
||||
md: { color: "#1976d2", icon: "mdi-language-markdown-outline", label: "MD" },
|
||||
markdown: {
|
||||
color: "#1976d2",
|
||||
icon: "mdi-language-markdown-outline",
|
||||
label: "MD",
|
||||
},
|
||||
doc: { color: "#2b579a", icon: "mdi-file-word-box", label: "DOC" },
|
||||
docx: { color: "#2b579a", icon: "mdi-file-word-box", label: "DOCX" },
|
||||
xls: { color: "#217346", icon: "mdi-file-excel-box", label: "XLS" },
|
||||
xlsx: { color: "#217346", icon: "mdi-file-excel-box", label: "XLSX" },
|
||||
csv: { color: "#217346", icon: "mdi-file-delimited-outline", label: "CSV" },
|
||||
ppt: { color: "#d24726", icon: "mdi-file-powerpoint-box", label: "PPT" },
|
||||
pptx: { color: "#d24726", icon: "mdi-file-powerpoint-box", label: "PPTX" },
|
||||
zip: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "ZIP" },
|
||||
rar: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "RAR" },
|
||||
"7z": { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "7Z" },
|
||||
tar: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "TAR" },
|
||||
gz: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "GZ" },
|
||||
json: { color: "#6a1b9a", icon: "mdi-code-json", label: "JSON" },
|
||||
yaml: { color: "#6a1b9a", icon: "mdi-code-braces", label: "YAML" },
|
||||
yml: { color: "#6a1b9a", icon: "mdi-code-braces", label: "YML" },
|
||||
js: { color: "#b8860b", icon: "mdi-language-javascript", label: "JS" },
|
||||
ts: { color: "#3178c6", icon: "mdi-language-typescript", label: "TS" },
|
||||
html: { color: "#e34c26", icon: "mdi-language-html5", label: "HTML" },
|
||||
css: { color: "#264de4", icon: "mdi-language-css3", label: "CSS" },
|
||||
py: { color: "#3776ab", icon: "mdi-language-python", label: "PY" },
|
||||
java: { color: "#b07219", icon: "mdi-language-java", label: "JAVA" },
|
||||
mp3: { color: "#00897b", icon: "mdi-file-music-outline", label: "MP3" },
|
||||
wav: { color: "#00897b", icon: "mdi-file-music-outline", label: "WAV" },
|
||||
flac: { color: "#00897b", icon: "mdi-file-music-outline", label: "FLAC" },
|
||||
mp4: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "MP4" },
|
||||
mov: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "MOV" },
|
||||
webm: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "WEBM" },
|
||||
};
|
||||
|
||||
function fileExtension(file: StagedFileInfo) {
|
||||
const name = file.original_name || file.filename || "";
|
||||
const extension = name.split(".").pop()?.toLowerCase() || "";
|
||||
return extension === name.toLowerCase() ? "" : extension;
|
||||
}
|
||||
|
||||
function filePresentation(file: StagedFileInfo) {
|
||||
const extension = fileExtension(file);
|
||||
return (
|
||||
fileTypeStyles[extension] || {
|
||||
color: "#607d8b",
|
||||
icon: "mdi-file-document-outline",
|
||||
label: extension ? extension.slice(0, 4).toUpperCase() : "FILE",
|
||||
}
|
||||
);
|
||||
return attachmentPresentation(file);
|
||||
}
|
||||
|
||||
// Ctrl+B 长按录音相关
|
||||
@@ -1001,7 +948,7 @@ defineExpose({
|
||||
|
||||
<style scoped>
|
||||
.input-area {
|
||||
padding: 0 16px;
|
||||
padding: 12px 16px 0;
|
||||
background-color: transparent;
|
||||
position: relative;
|
||||
border-top: 1px solid var(--v-theme-border);
|
||||
@@ -1418,6 +1365,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.attachment-card {
|
||||
--attachment-color: #607d8b;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1430,9 +1378,18 @@ defineExpose({
|
||||
padding: 7px 32px 7px 10px;
|
||||
overflow: hidden;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
background: rgba(var(--v-theme-on-surface), 0.035);
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 14px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.055);
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.file-preview {
|
||||
background: rgba(var(--v-theme-on-surface), 0.055);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--attachment-color) 14%, transparent),
|
||||
rgba(var(--v-theme-on-surface), 0.055) 62%
|
||||
);
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
@@ -1446,7 +1403,7 @@ defineExpose({
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 13px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.attachment-icon {
|
||||
@@ -1457,6 +1414,7 @@ defineExpose({
|
||||
gap: 1px;
|
||||
flex-shrink: 0;
|
||||
min-width: 34px;
|
||||
color: var(--attachment-color);
|
||||
}
|
||||
|
||||
.attachment-icon--audio {
|
||||
@@ -1471,6 +1429,7 @@ defineExpose({
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 12px;
|
||||
color: var(--attachment-color);
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
|
||||
@@ -44,9 +44,15 @@
|
||||
<div v-else class="sent-attachment-card sent-file-card">
|
||||
<div
|
||||
class="sent-attachment-icon"
|
||||
:style="{ color: attachmentPresentation(part).color }"
|
||||
:style="{
|
||||
'--attachment-color': attachmentPresentation(part).color,
|
||||
}"
|
||||
>
|
||||
<v-icon :icon="attachmentPresentation(part).icon" size="24" />
|
||||
<v-icon
|
||||
class="sent-attachment-icon-symbol"
|
||||
:icon="attachmentPresentation(part).icon"
|
||||
size="24"
|
||||
/>
|
||||
<span class="sent-attachment-ext">
|
||||
{{ attachmentPresentation(part).label }}
|
||||
</span>
|
||||
@@ -61,7 +67,10 @@
|
||||
variant="text"
|
||||
:loading="
|
||||
downloadingFiles.has(
|
||||
part.attachment_id || part.filename || '',
|
||||
part.attachment_id ||
|
||||
part.stored_filename ||
|
||||
part.filename ||
|
||||
'',
|
||||
)
|
||||
"
|
||||
@click="downloadPart(part)"
|
||||
@@ -205,16 +214,37 @@
|
||||
:src="partUrl(part)"
|
||||
/>
|
||||
|
||||
<div v-else-if="part.type === 'file'" class="file-part">
|
||||
<v-icon size="20">mdi-file-document-outline</v-icon>
|
||||
<span>{{ part.filename || "file" }}</span>
|
||||
<div
|
||||
v-else-if="part.type === 'file'"
|
||||
class="file-part"
|
||||
:style="{
|
||||
'--attachment-color': attachmentPresentation(part).color,
|
||||
}"
|
||||
>
|
||||
<v-icon
|
||||
class="file-part-icon"
|
||||
:icon="attachmentPresentation(part).icon"
|
||||
size="24"
|
||||
/>
|
||||
<div class="file-part-meta">
|
||||
<span class="file-part-name">
|
||||
{{ attachmentName(part) }}
|
||||
</span>
|
||||
<span class="file-part-kind">
|
||||
{{ attachmentPresentation(part).label }}
|
||||
</span>
|
||||
</div>
|
||||
<v-btn
|
||||
class="file-part-action"
|
||||
icon="mdi-download"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
:loading="
|
||||
downloadingFiles.has(
|
||||
part.attachment_id || part.filename || '',
|
||||
part.attachment_id ||
|
||||
part.stored_filename ||
|
||||
part.filename ||
|
||||
'',
|
||||
)
|
||||
"
|
||||
@click="downloadPart(part)"
|
||||
@@ -390,6 +420,8 @@
|
||||
import { computed, nextTick, reactive, ref } from "vue";
|
||||
import axios from "axios";
|
||||
import { fileApi } from "@/api/v1";
|
||||
import { setCustomComponents } from "markstream-vue";
|
||||
import "markstream-vue/index.css";
|
||||
import RegenerateMenu, {
|
||||
type RegenerateModelSelection,
|
||||
} from "@/components/chat/RegenerateMenu.vue";
|
||||
@@ -399,13 +431,16 @@ import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue"
|
||||
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
|
||||
import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue";
|
||||
import RefsSidebar from "@/components/chat/message_list_comps/RefsSidebar.vue";
|
||||
import RefNode from "@/components/chat/message_list_comps/RefNode.vue";
|
||||
import ThreadNode from "@/components/chat/message_list_comps/ThreadNode.vue";
|
||||
import ActionRef from "@/components/chat/message_list_comps/ActionRef.vue";
|
||||
import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue";
|
||||
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
|
||||
import StyledMenu from "@/components/shared/StyledMenu.vue";
|
||||
import {
|
||||
CHAT_MARKDOWN_CUSTOM_TAGS,
|
||||
registerChatMarkdownComponents,
|
||||
} from "@/components/chat/chatMarkdownComponents";
|
||||
attachmentName,
|
||||
attachmentPresentation,
|
||||
} from "@/components/chat/attachmentPresentation";
|
||||
import {
|
||||
displayParts as displayMessageParts,
|
||||
messageBlocks as buildMessageBlocks,
|
||||
@@ -466,11 +501,15 @@ const emit = defineEmits<{
|
||||
openRefs: [refs: unknown];
|
||||
}>();
|
||||
|
||||
registerChatMarkdownComponents();
|
||||
setCustomComponents("chat-message", {
|
||||
ref: RefNode,
|
||||
thread: ThreadNode,
|
||||
code_block: ThemeAwareMarkdownCodeBlock,
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n("features/chat");
|
||||
const customMarkdownTags = CHAT_MARKDOWN_CUSTOM_TAGS;
|
||||
const customMarkdownTags = ["ref"];
|
||||
const downloadingFiles = ref(new Set<string>());
|
||||
const imagePreview = reactive({ visible: false, url: "" });
|
||||
const refsSidebarOpen = ref(false);
|
||||
@@ -599,74 +638,6 @@ function hasFollowingContentBlock(message: ChatRecord, blockIndex: number) {
|
||||
.some((block) => block.kind === "content");
|
||||
}
|
||||
|
||||
const attachmentTypeStyles: Record<
|
||||
string,
|
||||
{ color: string; icon: string; label: string }
|
||||
> = {
|
||||
pdf: { color: "#d32f2f", icon: "mdi-file-pdf-box", label: "PDF" },
|
||||
txt: { color: "#1976d2", icon: "mdi-file-document-outline", label: "TXT" },
|
||||
md: { color: "#1976d2", icon: "mdi-language-markdown-outline", label: "MD" },
|
||||
markdown: {
|
||||
color: "#1976d2",
|
||||
icon: "mdi-language-markdown-outline",
|
||||
label: "MD",
|
||||
},
|
||||
doc: { color: "#2b579a", icon: "mdi-file-word-box", label: "DOC" },
|
||||
docx: { color: "#2b579a", icon: "mdi-file-word-box", label: "DOCX" },
|
||||
xls: { color: "#217346", icon: "mdi-file-excel-box", label: "XLS" },
|
||||
xlsx: { color: "#217346", icon: "mdi-file-excel-box", label: "XLSX" },
|
||||
csv: { color: "#217346", icon: "mdi-file-delimited-outline", label: "CSV" },
|
||||
ppt: { color: "#d24726", icon: "mdi-file-powerpoint-box", label: "PPT" },
|
||||
pptx: { color: "#d24726", icon: "mdi-file-powerpoint-box", label: "PPTX" },
|
||||
zip: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "ZIP" },
|
||||
rar: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "RAR" },
|
||||
"7z": { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "7Z" },
|
||||
tar: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "TAR" },
|
||||
gz: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "GZ" },
|
||||
json: { color: "#6a1b9a", icon: "mdi-code-json", label: "JSON" },
|
||||
yaml: { color: "#6a1b9a", icon: "mdi-code-braces", label: "YAML" },
|
||||
yml: { color: "#6a1b9a", icon: "mdi-code-braces", label: "YML" },
|
||||
js: { color: "#b8860b", icon: "mdi-language-javascript", label: "JS" },
|
||||
ts: { color: "#3178c6", icon: "mdi-language-typescript", label: "TS" },
|
||||
html: { color: "#e34c26", icon: "mdi-language-html5", label: "HTML" },
|
||||
css: { color: "#264de4", icon: "mdi-language-css3", label: "CSS" },
|
||||
py: { color: "#3776ab", icon: "mdi-language-python", label: "PY" },
|
||||
java: { color: "#b07219", icon: "mdi-language-java", label: "JAVA" },
|
||||
mp3: { color: "#00897b", icon: "mdi-file-music-outline", label: "MP3" },
|
||||
wav: { color: "#00897b", icon: "mdi-file-music-outline", label: "WAV" },
|
||||
flac: { color: "#00897b", icon: "mdi-file-music-outline", label: "FLAC" },
|
||||
mp4: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "MP4" },
|
||||
mov: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "MOV" },
|
||||
webm: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "WEBM" },
|
||||
};
|
||||
|
||||
function attachmentName(part: MessagePart) {
|
||||
return part.embedded_file?.filename || part.filename || part.type || "file";
|
||||
}
|
||||
|
||||
function attachmentExtension(part: MessagePart) {
|
||||
const name = attachmentName(part);
|
||||
const extension = name.split(".").pop()?.toLowerCase() || "";
|
||||
return extension === name.toLowerCase() ? "" : extension;
|
||||
}
|
||||
|
||||
function attachmentPresentation(part: MessagePart) {
|
||||
if (part.type === "record") {
|
||||
return { color: "#00897b", icon: "mdi-microphone", label: "AUDIO" };
|
||||
}
|
||||
if (part.type === "video") {
|
||||
return { color: "#5e35b1", icon: "mdi-file-video-outline", label: "VIDEO" };
|
||||
}
|
||||
const extension = attachmentExtension(part);
|
||||
return (
|
||||
attachmentTypeStyles[extension] || {
|
||||
color: "#607d8b",
|
||||
icon: "mdi-file-document-outline",
|
||||
label: extension ? extension.slice(0, 4).toUpperCase() : "FILE",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function handleMouseUp(event: MouseEvent, message: ChatRecord) {
|
||||
if (props.enableThreadSelection && !isUserMessage(message)) {
|
||||
emit("selectBotText", event, message);
|
||||
@@ -691,8 +662,9 @@ function partUrl(part: MessagePart) {
|
||||
if (part.attachment_id) {
|
||||
return fileApi.contentUrl(part.attachment_id);
|
||||
}
|
||||
if (part.filename) {
|
||||
return fileApi.byNameUrl(part.filename);
|
||||
const lookupFilename = part.stored_filename || part.filename;
|
||||
if (lookupFilename) {
|
||||
return fileApi.byNameUrl(lookupFilename);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -819,7 +791,7 @@ async function copyMessage(message: ChatRecord) {
|
||||
}
|
||||
|
||||
async function downloadPart(part: MessagePart) {
|
||||
const key = part.attachment_id || part.filename || "";
|
||||
const key = part.attachment_id || part.stored_filename || part.filename || "";
|
||||
if (!key) return;
|
||||
downloadingFiles.value = new Set(downloadingFiles.value).add(key);
|
||||
try {
|
||||
@@ -957,17 +929,18 @@ function formatDuration(seconds: number) {
|
||||
}
|
||||
|
||||
.sent-attachment-card {
|
||||
--attachment-color: #607d8b;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
height: 64px;
|
||||
height: 60px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: 12px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.055);
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
|
||||
@@ -981,7 +954,7 @@ function formatDuration(seconds: number) {
|
||||
.sent-image-card img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 11px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@@ -1000,18 +973,29 @@ function formatDuration(seconds: number) {
|
||||
}
|
||||
|
||||
.sent-file-card {
|
||||
width: 220px;
|
||||
width: 236px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.055);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--attachment-color) 14%, transparent),
|
||||
rgba(var(--v-theme-on-surface), 0.055) 62%
|
||||
);
|
||||
}
|
||||
|
||||
.sent-attachment-icon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
min-width: 34px;
|
||||
min-width: 36px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
color: var(--attachment-color);
|
||||
}
|
||||
|
||||
.sent-attachment-icon-symbol {
|
||||
color: var(--attachment-color);
|
||||
}
|
||||
|
||||
.sent-attachment-ext {
|
||||
@@ -1022,6 +1006,7 @@ function formatDuration(seconds: number) {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 12px;
|
||||
color: var(--attachment-color);
|
||||
}
|
||||
|
||||
.sent-attachment-name {
|
||||
@@ -1182,21 +1167,61 @@ function formatDuration(seconds: number) {
|
||||
}
|
||||
|
||||
.file-part {
|
||||
display: flex;
|
||||
--attachment-color: #607d8b;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
width: min(420px, 100%);
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--chat-border);
|
||||
padding: 9px 8px 9px 10px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.055);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--attachment-color) 13%, transparent),
|
||||
rgba(var(--v-theme-on-surface), 0.055) 58%
|
||||
);
|
||||
}
|
||||
|
||||
.file-part span {
|
||||
.file-part-icon {
|
||||
color: var(--attachment-color);
|
||||
}
|
||||
|
||||
.file-part-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.file-part-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.file-part-kind {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--attachment-color);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.file-part-action {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.file-part:hover .file-part-action {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tool-call-block {
|
||||
|
||||
@@ -106,16 +106,37 @@
|
||||
:src="partUrl(part)"
|
||||
/>
|
||||
|
||||
<div v-else-if="part.type === 'file'" class="file-part">
|
||||
<v-icon size="20">mdi-file-document-outline</v-icon>
|
||||
<span>{{ part.filename || "file" }}</span>
|
||||
<div
|
||||
v-else-if="part.type === 'file'"
|
||||
class="file-part"
|
||||
:style="{
|
||||
'--attachment-color': attachmentPresentation(part).color,
|
||||
}"
|
||||
>
|
||||
<v-icon
|
||||
class="file-part-icon"
|
||||
:icon="attachmentPresentation(part).icon"
|
||||
size="24"
|
||||
/>
|
||||
<div class="file-part-meta">
|
||||
<span class="file-part-name">
|
||||
{{ attachmentName(part) }}
|
||||
</span>
|
||||
<span class="file-part-kind">
|
||||
{{ attachmentPresentation(part).label }}
|
||||
</span>
|
||||
</div>
|
||||
<v-btn
|
||||
class="file-part-action"
|
||||
icon="mdi-download"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
:loading="
|
||||
downloadingFiles.has(
|
||||
part.attachment_id || part.filename || '',
|
||||
part.attachment_id ||
|
||||
part.stored_filename ||
|
||||
part.filename ||
|
||||
'',
|
||||
)
|
||||
"
|
||||
@click="downloadPart(part)"
|
||||
@@ -256,18 +277,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref } from "vue";
|
||||
import axios from "axios";
|
||||
import {
|
||||
CHAT_MARKDOWN_CUSTOM_TAGS,
|
||||
registerChatMarkdownComponents,
|
||||
} from "@/components/chat/chatMarkdownComponents";
|
||||
import { fileApi } from "@/api/v1";
|
||||
import { setCustomComponents } from "markstream-vue";
|
||||
import "markstream-vue/index.css";
|
||||
import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue";
|
||||
import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue";
|
||||
import ReasoningBlock from "@/components/chat/message_list_comps/ReasoningBlock.vue";
|
||||
import RefNode from "@/components/chat/message_list_comps/RefNode.vue";
|
||||
import RefsSidebar from "@/components/chat/message_list_comps/RefsSidebar.vue";
|
||||
import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue";
|
||||
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
|
||||
import ActionRef from "@/components/chat/message_list_comps/ActionRef.vue";
|
||||
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
|
||||
import {
|
||||
attachmentName,
|
||||
attachmentPresentation,
|
||||
} from "@/components/chat/attachmentPresentation";
|
||||
import {
|
||||
displayParts as displayMessageParts,
|
||||
messageBlocks as buildMessageBlocks,
|
||||
@@ -295,10 +320,13 @@ const props = withDefaults(
|
||||
},
|
||||
);
|
||||
|
||||
registerChatMarkdownComponents();
|
||||
setCustomComponents("chat-message", {
|
||||
ref: RefNode,
|
||||
code_block: ThemeAwareMarkdownCodeBlock,
|
||||
});
|
||||
|
||||
const { tm } = useModuleI18n("features/chat");
|
||||
const customMarkdownTags = CHAT_MARKDOWN_CUSTOM_TAGS;
|
||||
const customMarkdownTags = ["ref"];
|
||||
const downloadingFiles = ref(new Set<string>());
|
||||
const messageListRoot = ref<HTMLElement | null>(null);
|
||||
const imagePreview = reactive({ visible: false, url: "" });
|
||||
@@ -347,8 +375,9 @@ function partUrl(part: MessagePart) {
|
||||
if (part.attachment_id) {
|
||||
return fileApi.contentUrl(part.attachment_id);
|
||||
}
|
||||
if (part.filename) {
|
||||
return fileApi.byNameUrl(part.filename);
|
||||
const lookupFilename = part.stored_filename || part.filename;
|
||||
if (lookupFilename) {
|
||||
return fileApi.byNameUrl(lookupFilename);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -481,7 +510,7 @@ async function copyMessage(message: ChatRecord) {
|
||||
}
|
||||
|
||||
async function downloadPart(part: MessagePart) {
|
||||
const key = part.attachment_id || part.filename || "";
|
||||
const key = part.attachment_id || part.stored_filename || part.filename || "";
|
||||
if (!key) return;
|
||||
downloadingFiles.value = new Set(downloadingFiles.value).add(key);
|
||||
try {
|
||||
@@ -709,21 +738,61 @@ function formatDuration(seconds: number) {
|
||||
}
|
||||
|
||||
.file-part {
|
||||
display: flex;
|
||||
--attachment-color: #607d8b;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
width: min(420px, 100%);
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--chat-border);
|
||||
padding: 9px 8px 9px 10px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.055);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--attachment-color) 13%, transparent),
|
||||
rgba(var(--v-theme-on-surface), 0.055) 58%
|
||||
);
|
||||
}
|
||||
|
||||
.file-part span {
|
||||
.file-part-icon {
|
||||
color: var(--attachment-color);
|
||||
}
|
||||
|
||||
.file-part-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.file-part-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.file-part-kind {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--attachment-color);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.file-part-action {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.file-part:hover .file-part-action {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tool-call-block {
|
||||
|
||||
@@ -85,9 +85,27 @@
|
||||
:src="partUrl(part)"
|
||||
/>
|
||||
|
||||
<div v-else-if="part.type === 'file'" class="file-part">
|
||||
<v-icon size="20">mdi-file-document-outline</v-icon>
|
||||
<span>{{ part.filename || "file" }}</span>
|
||||
<div
|
||||
v-else-if="part.type === 'file'"
|
||||
class="file-part"
|
||||
:style="{
|
||||
'--attachment-color':
|
||||
attachmentPresentation(part).color,
|
||||
}"
|
||||
>
|
||||
<v-icon
|
||||
class="file-part-icon"
|
||||
:icon="attachmentPresentation(part).icon"
|
||||
size="24"
|
||||
/>
|
||||
<div class="file-part-meta">
|
||||
<span class="file-part-name">
|
||||
{{ attachmentName(part) }}
|
||||
</span>
|
||||
<span class="file-part-kind">
|
||||
{{ attachmentPresentation(part).label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -183,18 +201,21 @@ import {
|
||||
reactive,
|
||||
ref,
|
||||
} from "vue";
|
||||
import axios from "axios";
|
||||
import { chatApi, configRouteApi, fileApi } from "@/api/v1";
|
||||
import { setCustomComponents } from "markstream-vue";
|
||||
import "markstream-vue/index.css";
|
||||
import ChatInput from "@/components/chat/ChatInput.vue";
|
||||
import {
|
||||
CHAT_MARKDOWN_CUSTOM_TAGS,
|
||||
registerChatMarkdownComponents,
|
||||
} from "@/components/chat/chatMarkdownComponents";
|
||||
import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue";
|
||||
import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue";
|
||||
import ReasoningBlock from "@/components/chat/message_list_comps/ReasoningBlock.vue";
|
||||
import RefNode from "@/components/chat/message_list_comps/RefNode.vue";
|
||||
import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue";
|
||||
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
|
||||
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
|
||||
import {
|
||||
attachmentName,
|
||||
attachmentPresentation,
|
||||
} from "@/components/chat/attachmentPresentation";
|
||||
import { useMediaHandling } from "@/composables/useMediaHandling";
|
||||
import {
|
||||
displayParts as displayMessageParts,
|
||||
@@ -214,7 +235,10 @@ const props = withDefaults(defineProps<{ configId?: string | null }>(), {
|
||||
configId: "default",
|
||||
});
|
||||
|
||||
registerChatMarkdownComponents();
|
||||
setCustomComponents("chat-message", {
|
||||
ref: RefNode,
|
||||
code_block: ThemeAwareMarkdownCodeBlock,
|
||||
});
|
||||
|
||||
const { tm } = useModuleI18n("features/chat");
|
||||
const customizer = useCustomizerStore();
|
||||
@@ -229,7 +253,7 @@ const inputRef = ref<InstanceType<typeof ChatInput> | null>(null);
|
||||
const imagePreview = reactive({ visible: false, url: "" });
|
||||
|
||||
const isDark = computed(() => customizer.uiTheme === "PurpleThemeDark");
|
||||
const customMarkdownTags = CHAT_MARKDOWN_CUSTOM_TAGS;
|
||||
const customMarkdownTags = ["ref"];
|
||||
|
||||
const {
|
||||
stagedFiles,
|
||||
@@ -409,7 +433,8 @@ function partUrl(part: MessagePart) {
|
||||
if (part.embedded_url) return part.embedded_url;
|
||||
if (part.embedded_file?.url) return part.embedded_file.url;
|
||||
if (part.attachment_id) return fileApi.contentUrl(part.attachment_id);
|
||||
if (part.filename) return fileApi.byNameUrl(part.filename);
|
||||
const lookupFilename = part.stored_filename || part.filename;
|
||||
if (lookupFilename) return fileApi.byNameUrl(lookupFilename);
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -573,10 +598,51 @@ function closeImage() {
|
||||
}
|
||||
|
||||
.file-part {
|
||||
display: flex;
|
||||
--attachment-color: #607d8b;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
width: min(420px, 100%);
|
||||
margin-top: 8px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.055);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--attachment-color) 13%, transparent),
|
||||
rgba(var(--v-theme-on-surface), 0.055) 58%
|
||||
);
|
||||
}
|
||||
|
||||
.file-part-icon {
|
||||
color: var(--attachment-color);
|
||||
}
|
||||
|
||||
.file-part-meta {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.file-part-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.file-part-kind {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--attachment-color);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.tool-call-block {
|
||||
@@ -609,6 +675,22 @@ function closeImage() {
|
||||
background: rgb(var(--v-theme-background));
|
||||
}
|
||||
|
||||
.standalone-composer::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -32px;
|
||||
height: 32px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--v-theme-background), 0),
|
||||
rgb(var(--v-theme-background))
|
||||
);
|
||||
}
|
||||
|
||||
.standalone-composer :deep(.input-area) {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
payloadText,
|
||||
upsertToolCall,
|
||||
type ChatRecord,
|
||||
type MessagePart,
|
||||
type ChatThread,
|
||||
} from "@/composables/useMessages";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
@@ -305,13 +306,22 @@ function processPayload(botRecord: ChatRecord, userRecord: ChatRecord, payload:
|
||||
|
||||
if (["image", "record", "file", "video"].includes(type)) {
|
||||
markMessageStarted(botRecord);
|
||||
const filename = String(data)
|
||||
const rawFilename = String(data)
|
||||
.replace("[IMAGE]", "")
|
||||
.replace("[RECORD]", "")
|
||||
.replace("[FILE]", "")
|
||||
.replace("[VIDEO]", "")
|
||||
.split("|", 1)[0];
|
||||
botRecord.content.message.push({ type, filename });
|
||||
.replace("[VIDEO]", "");
|
||||
const separatorIndex = rawFilename.indexOf("|");
|
||||
const storedFilename =
|
||||
separatorIndex >= 0 ? rawFilename.slice(0, separatorIndex) : rawFilename;
|
||||
const displayFilename =
|
||||
separatorIndex >= 0 ? rawFilename.slice(separatorIndex + 1) : storedFilename;
|
||||
const filename = displayFilename || storedFilename;
|
||||
const mediaPart: MessagePart = { type, filename };
|
||||
if (storedFilename && storedFilename !== filename) {
|
||||
mediaPart.stored_filename = storedFilename;
|
||||
}
|
||||
botRecord.content.message.push(mediaPart);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
130
dashboard/src/components/chat/attachmentPresentation.ts
Normal file
130
dashboard/src/components/chat/attachmentPresentation.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
export interface AttachmentPresentationInput {
|
||||
type?: string;
|
||||
filename?: string;
|
||||
original_name?: string;
|
||||
embedded_file?: {
|
||||
filename?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AttachmentPresentation {
|
||||
color: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const fileTypeStyles: Record<string, AttachmentPresentation> = {
|
||||
pdf: { color: "#c43b3b", icon: "mdi-file-pdf-box", label: "PDF" },
|
||||
doc: { color: "#2b579a", icon: "mdi-file-word-box", label: "WORD" },
|
||||
docx: { color: "#2b579a", icon: "mdi-file-word-box", label: "WORD" },
|
||||
xls: { color: "#217346", icon: "mdi-file-excel-box", label: "XLS" },
|
||||
xlsx: { color: "#217346", icon: "mdi-file-excel-box", label: "XLSX" },
|
||||
csv: { color: "#217346", icon: "mdi-file-delimited-outline", label: "CSV" },
|
||||
ppt: { color: "#d24726", icon: "mdi-file-powerpoint-box", label: "PPT" },
|
||||
pptx: { color: "#d24726", icon: "mdi-file-powerpoint-box", label: "PPT" },
|
||||
jpg: { color: "#c1467a", icon: "mdi-file-image", label: "IMAGE" },
|
||||
jpeg: { color: "#c1467a", icon: "mdi-file-image", label: "IMAGE" },
|
||||
png: { color: "#c1467a", icon: "mdi-file-image", label: "IMAGE" },
|
||||
gif: { color: "#c1467a", icon: "mdi-file-image", label: "IMAGE" },
|
||||
webp: { color: "#c1467a", icon: "mdi-file-image", label: "IMAGE" },
|
||||
svg: { color: "#c1467a", icon: "mdi-svg", label: "SVG" },
|
||||
heic: { color: "#c1467a", icon: "mdi-file-image", label: "IMAGE" },
|
||||
bmp: { color: "#c1467a", icon: "mdi-file-image", label: "IMAGE" },
|
||||
mp3: { color: "#00897b", icon: "mdi-file-music-outline", label: "AUDIO" },
|
||||
wav: { color: "#00897b", icon: "mdi-file-music-outline", label: "AUDIO" },
|
||||
flac: { color: "#00897b", icon: "mdi-file-music-outline", label: "AUDIO" },
|
||||
m4a: { color: "#00897b", icon: "mdi-file-music-outline", label: "AUDIO" },
|
||||
ogg: { color: "#00897b", icon: "mdi-file-music-outline", label: "AUDIO" },
|
||||
mp4: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "VIDEO" },
|
||||
mov: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "VIDEO" },
|
||||
webm: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "VIDEO" },
|
||||
zip: { color: "#8a6f00", icon: "mdi-folder-zip-outline", label: "ZIP" },
|
||||
rar: { color: "#8a6f00", icon: "mdi-folder-zip-outline", label: "RAR" },
|
||||
"7z": { color: "#8a6f00", icon: "mdi-folder-zip-outline", label: "7Z" },
|
||||
tar: { color: "#8a6f00", icon: "mdi-folder-zip-outline", label: "TAR" },
|
||||
gz: { color: "#8a6f00", icon: "mdi-folder-zip-outline", label: "GZ" },
|
||||
txt: { color: "#607d8b", icon: "mdi-file-document-outline", label: "TXT" },
|
||||
md: { color: "#607d8b", icon: "mdi-language-markdown-outline", label: "MD" },
|
||||
markdown: {
|
||||
color: "#607d8b",
|
||||
icon: "mdi-language-markdown-outline",
|
||||
label: "MD",
|
||||
},
|
||||
};
|
||||
|
||||
const codeFileTypes = new Set([
|
||||
"c",
|
||||
"cc",
|
||||
"cpp",
|
||||
"cs",
|
||||
"css",
|
||||
"go",
|
||||
"h",
|
||||
"hpp",
|
||||
"html",
|
||||
"java",
|
||||
"js",
|
||||
"json",
|
||||
"jsx",
|
||||
"kt",
|
||||
"php",
|
||||
"py",
|
||||
"rb",
|
||||
"rs",
|
||||
"scss",
|
||||
"sh",
|
||||
"sql",
|
||||
"swift",
|
||||
"ts",
|
||||
"tsx",
|
||||
"vue",
|
||||
"xml",
|
||||
"yaml",
|
||||
"yml",
|
||||
]);
|
||||
|
||||
export function attachmentName(part: AttachmentPresentationInput) {
|
||||
return (
|
||||
part.embedded_file?.filename ||
|
||||
part.original_name ||
|
||||
part.filename ||
|
||||
part.type ||
|
||||
"file"
|
||||
);
|
||||
}
|
||||
|
||||
export function attachmentExtension(part: AttachmentPresentationInput) {
|
||||
const name = attachmentName(part);
|
||||
const extension = name.split(".").pop()?.toLowerCase() || "";
|
||||
return extension === name.toLowerCase() ? "" : extension;
|
||||
}
|
||||
|
||||
export function attachmentPresentation(
|
||||
part: AttachmentPresentationInput,
|
||||
): AttachmentPresentation {
|
||||
if (part.type === "image") {
|
||||
return { color: "#c1467a", icon: "mdi-file-image", label: "IMAGE" };
|
||||
}
|
||||
if (part.type === "record") {
|
||||
return { color: "#00897b", icon: "mdi-file-music-outline", label: "AUDIO" };
|
||||
}
|
||||
if (part.type === "video") {
|
||||
return { color: "#5e35b1", icon: "mdi-file-video-outline", label: "VIDEO" };
|
||||
}
|
||||
|
||||
const extension = attachmentExtension(part);
|
||||
if (codeFileTypes.has(extension)) {
|
||||
return {
|
||||
color: "#6a4fb3",
|
||||
icon: extension === "json" ? "mdi-code-json" : "mdi-file-code-outline",
|
||||
label: extension ? extension.slice(0, 4).toUpperCase() : "CODE",
|
||||
};
|
||||
}
|
||||
return (
|
||||
fileTypeStyles[extension] || {
|
||||
color: "#607d8b",
|
||||
icon: "mdi-file-document-outline",
|
||||
label: extension ? extension.slice(0, 4).toUpperCase() : "FILE",
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { setCustomComponents } from "markstream-vue";
|
||||
import "markstream-vue/index.css";
|
||||
import HtmlGenUiNode from "@/components/chat/message_list_comps/HtmlGenUiNode.vue";
|
||||
import RefNode from "@/components/chat/message_list_comps/RefNode.vue";
|
||||
import ThreadNode from "@/components/chat/message_list_comps/ThreadNode.vue";
|
||||
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
|
||||
|
||||
export const CHAT_MARKDOWN_CUSTOM_TAGS: string[] = ["ref", "html-genui"];
|
||||
|
||||
export function registerChatMarkdownComponents() {
|
||||
setCustomComponents("chat-message", {
|
||||
ref: RefNode,
|
||||
thread: ThreadNode,
|
||||
"html-genui": HtmlGenUiNode,
|
||||
code_block: ThemeAwareMarkdownCodeBlock,
|
||||
});
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
<template>
|
||||
<div class="html-genui-node" :class="{ 'is-dark': isDark, 'is-loading': isLoading }">
|
||||
<div class="html-genui-header">
|
||||
<div class="html-genui-title">{{ panelTitle }}</div>
|
||||
<div class="html-genui-toggle" role="tablist" aria-label="HTML GenUI view">
|
||||
<button
|
||||
class="html-genui-toggle-button"
|
||||
:class="{ active: viewMode === 'preview' }"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="viewMode === 'preview'"
|
||||
@click="viewMode = 'preview'"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
class="html-genui-toggle-button"
|
||||
:class="{ active: viewMode === 'source' }"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="viewMode === 'source'"
|
||||
@click="viewMode = 'source'"
|
||||
>
|
||||
Source
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
v-if="viewMode === 'preview'"
|
||||
class="html-genui-frame"
|
||||
:srcdoc="renderedSrcdoc"
|
||||
:sandbox="sandboxPolicy"
|
||||
title="Generated HTML UI preview"
|
||||
loading="lazy"
|
||||
></iframe>
|
||||
<pre v-else class="html-genui-source"><code>{{ htmlContent }}</code></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
|
||||
const RENDER_THROTTLE_MS = 500;
|
||||
const sandboxPolicy =
|
||||
"allow-forms allow-modals allow-pointer-lock allow-popups allow-scripts";
|
||||
|
||||
const props = defineProps<{
|
||||
node?: {
|
||||
attrs?: Array<[string, string]>;
|
||||
content?: string;
|
||||
raw?: string;
|
||||
loading?: boolean;
|
||||
} | null;
|
||||
loading?: boolean;
|
||||
isDark?: boolean;
|
||||
title?: string;
|
||||
}>();
|
||||
|
||||
const renderedSrcdoc = ref("");
|
||||
const viewMode = ref<"preview" | "source">("preview");
|
||||
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastRenderAt = 0;
|
||||
|
||||
const htmlContent = computed(() =>
|
||||
stripHtmlGenUiWrapper(String(props.node?.content || props.node?.raw || "")),
|
||||
);
|
||||
const isLoading = computed(() => Boolean(props.loading || props.node?.loading));
|
||||
const isDark = computed(() => Boolean(props.isDark));
|
||||
const panelTitle = computed(
|
||||
() => props.title?.trim() || attrValue("title") || "HTML UI",
|
||||
);
|
||||
|
||||
watch(
|
||||
[htmlContent, isLoading, isDark],
|
||||
() => {
|
||||
scheduleRender(!isLoading.value);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer);
|
||||
pendingTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
function scheduleRender(force = false) {
|
||||
if (force) {
|
||||
renderNow();
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - lastRenderAt;
|
||||
if (elapsed >= RENDER_THROTTLE_MS) {
|
||||
renderNow();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pendingTimer) {
|
||||
pendingTimer = setTimeout(renderNow, RENDER_THROTTLE_MS - elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
function renderNow() {
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer);
|
||||
pendingTimer = null;
|
||||
}
|
||||
lastRenderAt = Date.now();
|
||||
renderedSrcdoc.value = buildSrcdoc(htmlContent.value, isDark.value);
|
||||
}
|
||||
|
||||
function stripHtmlGenUiWrapper(value: string) {
|
||||
return value
|
||||
.replace(/^\s*<html-genui\b[^>]*>/i, "")
|
||||
.replace(/<\/html-genui>\s*$/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function attrValue(name: string) {
|
||||
const attr = props.node?.attrs?.find(
|
||||
([key]) => key.toLowerCase() === name.toLowerCase(),
|
||||
);
|
||||
return attr?.[1]?.trim() || "";
|
||||
}
|
||||
|
||||
function buildSrcdoc(content: string, dark: boolean) {
|
||||
const headExtras = buildHeadExtras(dark);
|
||||
if (/<html[\s>]/i.test(content)) {
|
||||
return injectHeadExtras(content, headExtras);
|
||||
}
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>${headExtras}</head>
|
||||
<body>${content}</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function injectHeadExtras(html: string, headExtras: string) {
|
||||
if (/<head[\s>]/i.test(html)) {
|
||||
return html.replace(/<head([^>]*)>/i, `<head$1>${headExtras}`);
|
||||
}
|
||||
if (/<html[\s>]/i.test(html)) {
|
||||
return html.replace(/<html([^>]*)>/i, `<html$1><head>${headExtras}</head>`);
|
||||
}
|
||||
return `<!doctype html><html><head>${headExtras}</head><body>${html}</body></html>`;
|
||||
}
|
||||
|
||||
function buildHeadExtras(dark: boolean) {
|
||||
const bg = dark ? "#111827" : "#ffffff";
|
||||
const fg = dark ? "#f9fafb" : "#111827";
|
||||
return `<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<base target="_blank">
|
||||
<style>
|
||||
:root { color-scheme: ${dark ? "dark" : "light"}; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { min-height: 100%; margin: 0; }
|
||||
body {
|
||||
background: ${bg};
|
||||
color: ${fg};
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
img, video, canvas, svg { max-width: 100%; }
|
||||
</style>`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.html-genui-node {
|
||||
width: 100%;
|
||||
margin: 12px 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(128, 128, 128, 0.24);
|
||||
border-radius: 8px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
.html-genui-node.is-dark {
|
||||
border-color: rgba(160, 160, 160, 0.28);
|
||||
}
|
||||
|
||||
.html-genui-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 42px;
|
||||
padding: 8px 10px 8px 12px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.2);
|
||||
background: rgba(128, 128, 128, 0.04);
|
||||
}
|
||||
|
||||
.html-genui-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: rgba(var(--v-theme-on-surface), 0.84);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.html-genui-toggle {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(128, 128, 128, 0.22);
|
||||
border-radius: 6px;
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
}
|
||||
|
||||
.html-genui-toggle-button {
|
||||
min-width: 64px;
|
||||
border: 0;
|
||||
border-right: 1px solid rgba(128, 128, 128, 0.2);
|
||||
padding: 4px 10px;
|
||||
background: transparent;
|
||||
color: rgba(var(--v-theme-on-surface), 0.68);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.html-genui-toggle-button:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.html-genui-toggle-button:focus-visible {
|
||||
outline: 2px solid rgba(128, 128, 128, 0.36);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.html-genui-toggle-button:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.html-genui-toggle-button.active {
|
||||
background: rgba(128, 128, 128, 0.16);
|
||||
color: rgba(var(--v-theme-on-surface), 0.92);
|
||||
}
|
||||
|
||||
.html-genui-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: clamp(280px, 52vh, 620px);
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.html-genui-node.is-loading .html-genui-frame {
|
||||
opacity: 0.96;
|
||||
}
|
||||
|
||||
.html-genui-source {
|
||||
height: clamp(280px, 52vh, 620px);
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 14px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.035);
|
||||
color: rgba(var(--v-theme-on-surface), 0.86);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
tab-size: 2;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@ export interface MessagePart {
|
||||
embedded_file?: { url?: string; filename?: string; attachment_id?: string };
|
||||
attachment_id?: string;
|
||||
filename?: string;
|
||||
stored_filename?: string;
|
||||
tool_calls?: ToolCall[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -174,20 +175,23 @@ export function useMessages(options: UseMessagesOptions) {
|
||||
if (part.embedded_url) return;
|
||||
let url: string;
|
||||
let cacheKey: string;
|
||||
const storedFilename =
|
||||
typeof part.stored_filename === "string" ? part.stored_filename : "";
|
||||
const lookupFilename = storedFilename || part.filename || "";
|
||||
if (part.attachment_id) {
|
||||
cacheKey = `att:${part.attachment_id}`;
|
||||
url = fileApi.contentUrl(part.attachment_id);
|
||||
} else if (part.filename) {
|
||||
cacheKey = `file:${part.filename}`;
|
||||
} else if (lookupFilename) {
|
||||
cacheKey = `file:${lookupFilename}`;
|
||||
url = "";
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
let promise = attachmentBlobCache.get(cacheKey);
|
||||
if (!promise) {
|
||||
if (part.filename) {
|
||||
if (!part.attachment_id && lookupFilename) {
|
||||
promise = fileApi
|
||||
.getByName(part.filename)
|
||||
.getByName(lookupFilename)
|
||||
.then((resp) => URL.createObjectURL(resp.data));
|
||||
} else {
|
||||
promise = fetchWithAuth(url).then(async (resp) => {
|
||||
@@ -210,7 +214,11 @@ export function useMessages(options: UseMessagesOptions) {
|
||||
const tasks: Promise<void>[] = [];
|
||||
for (const record of records) {
|
||||
for (const part of record.content?.message || []) {
|
||||
if (mediaTypes.includes(part.type) && !part.embedded_url && (part.attachment_id || part.filename)) {
|
||||
if (
|
||||
mediaTypes.includes(part.type) &&
|
||||
!part.embedded_url &&
|
||||
(part.attachment_id || part.stored_filename || part.filename)
|
||||
) {
|
||||
tasks.push(resolvePartMedia(part));
|
||||
}
|
||||
}
|
||||
@@ -796,13 +804,21 @@ export function useMessages(options: UseMessagesOptions) {
|
||||
|
||||
if (["image", "record", "file", "video"].includes(msgType)) {
|
||||
markMessageStarted(botRecord);
|
||||
const filename = String(data)
|
||||
const rawFilename = String(data)
|
||||
.replace("[IMAGE]", "")
|
||||
.replace("[RECORD]", "")
|
||||
.replace("[FILE]", "")
|
||||
.replace("[VIDEO]", "")
|
||||
.split("|", 1)[0];
|
||||
.replace("[VIDEO]", "");
|
||||
const separatorIndex = rawFilename.indexOf("|");
|
||||
const storedFilename =
|
||||
separatorIndex >= 0 ? rawFilename.slice(0, separatorIndex) : rawFilename;
|
||||
const displayFilename =
|
||||
separatorIndex >= 0 ? rawFilename.slice(separatorIndex + 1) : storedFilename;
|
||||
const filename = displayFilename || storedFilename;
|
||||
const mediaPart: MessagePart = { type: msgType, filename };
|
||||
if (storedFilename && storedFilename !== filename) {
|
||||
mediaPart.stored_filename = storedFilename;
|
||||
}
|
||||
if (msgType !== "file") {
|
||||
resolvePartMedia(mediaPart).then(() => {
|
||||
messageContent(botRecord).message.push(mediaPart);
|
||||
|
||||
@@ -5455,6 +5455,8 @@ components:
|
||||
type: string
|
||||
filename:
|
||||
type: string
|
||||
stored_filename:
|
||||
type: string
|
||||
mime_type:
|
||||
type: string
|
||||
additionalProperties: true
|
||||
|
||||
@@ -2217,6 +2217,51 @@ async def test_batch_delete_sessions_uses_batch_lookup(
|
||||
assert called["batch_lookup_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"path_template",
|
||||
[
|
||||
"/api/chat/get_session?session_id={session_id}",
|
||||
"/api/v1/chat/sessions/{session_id}",
|
||||
],
|
||||
)
|
||||
async def test_get_chat_session_rejects_session_owned_by_another_user(
|
||||
app: FastAPIAppAdapter,
|
||||
authenticated_header: dict,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
path_template: str,
|
||||
):
|
||||
test_client = app.test_client()
|
||||
session_id = f"foreign_get_session_{uuid.uuid4().hex[:8]}"
|
||||
await core_lifecycle_td.db.create_platform_session(
|
||||
creator="not_dashboard_user",
|
||||
platform_id="webchat",
|
||||
session_id=session_id,
|
||||
display_name="Foreign Session",
|
||||
is_group=0,
|
||||
)
|
||||
await core_lifecycle_td.platform_message_history_manager.insert(
|
||||
platform_id="webchat",
|
||||
user_id=session_id,
|
||||
content={
|
||||
"type": "user",
|
||||
"message": [{"type": "text", "text": "foreign session secret"}],
|
||||
},
|
||||
sender_id="not_dashboard_user",
|
||||
sender_name="not_dashboard_user",
|
||||
)
|
||||
|
||||
response = await test_client.get(
|
||||
path_template.format(session_id=session_id),
|
||||
headers=authenticated_header,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "error"
|
||||
assert data["message"] == "Permission denied"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugins(
|
||||
app: FastAPIAppAdapter,
|
||||
|
||||
@@ -592,6 +592,13 @@ def test_file_uri_to_path_supports_standard_and_legacy_posix_file_uris(tmp_path)
|
||||
assert media_utils.file_uri_to_path(legacy_file_uri) == str(media_path)
|
||||
|
||||
|
||||
def test_file_uri_to_path_preserves_posix_root_for_container_paths():
|
||||
if os.name != "nt":
|
||||
assert media_utils.file_uri_to_path("file:///AstrBot/data/cache/image.png") == (
|
||||
"/AstrBot/data/cache/image.png"
|
||||
)
|
||||
|
||||
|
||||
def test_from_file_system_uses_pathlib_file_uri(tmp_path):
|
||||
media_path = tmp_path / "media file.bin"
|
||||
media_path.write_bytes(b"media")
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import asyncio
|
||||
import base64
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.provider.sources.mimo_api_common import (
|
||||
MiMoAPIError,
|
||||
_validate_wav_payload,
|
||||
build_headers,
|
||||
prepare_audio_input,
|
||||
)
|
||||
from astrbot.core.provider.sources.mimo_stt_api_source import ProviderMiMoSTTAPI
|
||||
from astrbot.core.provider.sources.mimo_tts_api_source import ProviderMiMoTTSAPI
|
||||
|
||||
MIMO_STT_TEST_AUDIO_DATA_URL = "data:audio/wav;base64,ZmFrZQ=="
|
||||
MIMO_STT_TEST_WAV_HEADER = b"RIFF\x24\x08\x00\x00WAVEfmt "
|
||||
MIMO_STT_TEST_AUDIO_BASE64 = base64.b64encode(MIMO_STT_TEST_WAV_HEADER).decode()
|
||||
MIMO_STT_TEST_AUDIO_DATA_URL = f"data:audio/wav;base64,{MIMO_STT_TEST_AUDIO_BASE64}"
|
||||
|
||||
|
||||
def _make_tts_provider(overrides: dict | None = None) -> ProviderMiMoTTSAPI:
|
||||
@@ -33,7 +37,7 @@ def _make_stt_provider(overrides: dict | None = None) -> ProviderMiMoSTTAPI:
|
||||
provider_config = {
|
||||
"id": "test-mimo-stt",
|
||||
"type": "mimo_stt_api",
|
||||
"model": "mimo-v2-omni",
|
||||
"model": "mimo-v2.5-asr",
|
||||
"api_key": "test-key",
|
||||
}
|
||||
if overrides:
|
||||
@@ -196,7 +200,8 @@ async def test_mimo_tts_get_audio_handles_empty_choices():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mimo_stt_payload_includes_audio_only(monkeypatch):
|
||||
async def test_mimo_stt_asr_model_payload_includes_audio_only(monkeypatch):
|
||||
"""专用 ASR 模型按官方语音识别文档只传 input_audio,不带任何提示词。"""
|
||||
provider = _make_stt_provider(
|
||||
{
|
||||
"mimo-stt-system-prompt": "system prompt",
|
||||
@@ -248,10 +253,91 @@ async def test_mimo_stt_payload_includes_audio_only(monkeypatch):
|
||||
]
|
||||
|
||||
|
||||
def test_mimo_stt_default_model_is_v25_asr():
|
||||
"""mimo-v2-omni 已于 2026-06-30 下线,默认模型应为 mimo-v2.5-asr。"""
|
||||
provider = ProviderMiMoSTTAPI(
|
||||
provider_config={
|
||||
"id": "test-mimo-stt",
|
||||
"type": "mimo_stt_api",
|
||||
"api_key": "test-key",
|
||||
},
|
||||
provider_settings={},
|
||||
)
|
||||
try:
|
||||
assert provider.model_name == "mimo-v2.5-asr"
|
||||
finally:
|
||||
asyncio.run(provider.terminate())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mimo_stt_multimodal_model_payload_includes_transcription_prompts(
|
||||
monkeypatch,
|
||||
):
|
||||
"""非 ASR 模型(如 mimo-v2.5)按官方音频理解文档要求携带 system 与 text 指令。"""
|
||||
provider = _make_stt_provider({"model": "mimo-v2.5"})
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_prepare_audio_input(_audio_source: str):
|
||||
return MIMO_STT_TEST_AUDIO_DATA_URL, []
|
||||
|
||||
class _Response:
|
||||
status_code = 200
|
||||
text = '{"choices":[{"message":{"content":"transcribed text"}}]}'
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {"choices": [{"message": {"content": "transcribed text"}}]}
|
||||
|
||||
async def fake_post(_url, headers=None, json=None):
|
||||
captured["json"] = json
|
||||
return _Response()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.provider.sources.mimo_stt_api_source.prepare_audio_input",
|
||||
fake_prepare_audio_input,
|
||||
)
|
||||
provider.client = SimpleNamespace(post=fake_post)
|
||||
|
||||
result = await provider.get_text("/tmp/test.wav")
|
||||
|
||||
assert result == "transcribed text"
|
||||
assert captured["json"]["messages"] == [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are a speech transcription assistant. "
|
||||
"Transcribe the spoken content from the audio exactly "
|
||||
"and return only the transcription text."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": {
|
||||
"data": MIMO_STT_TEST_AUDIO_DATA_URL,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": (
|
||||
"Please transcribe the content of the audio "
|
||||
"and return only the transcription text."
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mimo_stt_prepare_audio_input_returns_data_url(monkeypatch):
|
||||
class _ResolvedAudio:
|
||||
base64_data = "ZmFrZQ=="
|
||||
base64_data = MIMO_STT_TEST_AUDIO_BASE64
|
||||
mime_type = "audio/wav"
|
||||
format = "wav"
|
||||
|
||||
@@ -284,6 +370,41 @@ async def test_mimo_stt_prepare_audio_input_returns_data_url(monkeypatch):
|
||||
assert cleanup_paths == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mimo_stt_prepare_audio_input_rejects_non_wav_payload(monkeypatch):
|
||||
"""上游 SILK→WAV 转换静默失败时应本地报错,而不是把坏字节发给 API(#9113)。"""
|
||||
silk_base64 = base64.b64encode(b"\x02#!SILK_V3" + b"\x00" * 16).decode()
|
||||
|
||||
class _ResolvedAudio:
|
||||
base64_data = silk_base64
|
||||
mime_type = "audio/wav"
|
||||
format = "wav"
|
||||
|
||||
def to_data_url(self):
|
||||
return f"data:audio/wav;base64,{silk_base64}"
|
||||
|
||||
class _Resolver:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
async def to_base64_data(self, **_kwargs):
|
||||
return _ResolvedAudio()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.provider.sources.mimo_api_common.MediaResolver",
|
||||
_Resolver,
|
||||
)
|
||||
|
||||
with pytest.raises(MiMoAPIError, match="SILK"):
|
||||
await prepare_audio_input("/tmp/test.wav")
|
||||
|
||||
|
||||
def test_mimo_stt_wav_validation_accepts_unpadded_base64_header():
|
||||
wav_base64 = base64.b64encode(MIMO_STT_TEST_WAV_HEADER).decode().rstrip("=")
|
||||
|
||||
_validate_wav_payload(wav_base64, "/tmp/test.wav")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mimo_stt_get_text_uses_reasoning_content(monkeypatch):
|
||||
provider = _make_stt_provider()
|
||||
|
||||
110
tests/unit/test_webchat_message_parts.py
Normal file
110
tests/unit/test_webchat_message_parts.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import File
|
||||
from astrbot.core.platform.sources.webchat import webchat_event
|
||||
from astrbot.core.platform.sources.webchat.message_parts_helper import (
|
||||
build_webchat_message_parts,
|
||||
create_attachment_part_from_existing_file,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webchat_file_send_keeps_original_filename(tmp_path, monkeypatch):
|
||||
"""WebChat file payloads should carry both stored and display filenames."""
|
||||
queue = asyncio.Queue()
|
||||
attachments_dir = tmp_path / "attachments"
|
||||
attachments_dir.mkdir()
|
||||
source_file = tmp_path / "source.txt"
|
||||
source_file.write_text("hello", encoding="utf-8")
|
||||
monkeypatch.setattr(webchat_event, "attachments_dir", str(attachments_dir))
|
||||
monkeypatch.setattr(
|
||||
webchat_event.webchat_queue_mgr,
|
||||
"get_or_create_back_queue",
|
||||
lambda *_args: queue,
|
||||
)
|
||||
|
||||
await webchat_event.WebChatMessageEvent._send(
|
||||
"message-1",
|
||||
MessageChain([File(name="report.txt", file=str(source_file))]),
|
||||
"webchat!user!conversation-1",
|
||||
)
|
||||
|
||||
payload = await queue.get()
|
||||
stored_name, display_name = payload["data"].removeprefix("[FILE]").split("|", 1)
|
||||
|
||||
assert payload["type"] == "file"
|
||||
assert display_name == "report.txt"
|
||||
assert stored_name != display_name
|
||||
assert (attachments_dir / stored_name).exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attachment_part_uses_display_filename_with_stored_filename(tmp_path):
|
||||
"""Attachment parts should show the display name while keeping the stored name."""
|
||||
stored_file = tmp_path / "uuid.txt"
|
||||
stored_file.write_text("payload", encoding="utf-8")
|
||||
|
||||
async def insert_attachment(path, type, mime_type):
|
||||
return SimpleNamespace(
|
||||
attachment_id="attachment-1",
|
||||
path=path,
|
||||
type=type,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
|
||||
part = await create_attachment_part_from_existing_file(
|
||||
stored_file.name,
|
||||
attach_type="file",
|
||||
insert_attachment=insert_attachment,
|
||||
attachments_dir=tmp_path,
|
||||
display_name="../nested/report.txt",
|
||||
)
|
||||
|
||||
assert part == {
|
||||
"type": "file",
|
||||
"attachment_id": "attachment-1",
|
||||
"filename": "report.txt",
|
||||
"stored_filename": "uuid.txt",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_webchat_message_parts_preserves_payload_filename(tmp_path):
|
||||
"""Attachment lookup should not overwrite the payload filename with disk name."""
|
||||
stored_file = tmp_path / "uuid.txt"
|
||||
stored_file.write_text("payload", encoding="utf-8")
|
||||
attachment = SimpleNamespace(
|
||||
attachment_id="attachment-1",
|
||||
path=str(stored_file),
|
||||
type="file",
|
||||
)
|
||||
|
||||
async def get_attachment_by_id(attachment_id):
|
||||
assert attachment_id == "attachment-1"
|
||||
return attachment
|
||||
|
||||
parts = await build_webchat_message_parts(
|
||||
[
|
||||
{
|
||||
"type": "file",
|
||||
"attachment_id": "attachment-1",
|
||||
"filename": r"C:\fakepath\report.txt",
|
||||
}
|
||||
],
|
||||
get_attachment_by_id=get_attachment_by_id,
|
||||
strict=True,
|
||||
)
|
||||
|
||||
assert parts == [
|
||||
{
|
||||
"type": "file",
|
||||
"attachment_id": "attachment-1",
|
||||
"filename": "report.txt",
|
||||
"path": str(stored_file),
|
||||
"stored_filename": "uuid.txt",
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user