mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-04 19:50:16 +08:00
Compare commits
1 Commits
codex/chat
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b43cc6dee0 |
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 – 279 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";
|
||||
}
|
||||
@@ -496,10 +496,6 @@
|
||||
content: "\F024B";
|
||||
}
|
||||
|
||||
.mdi-folder-cog-outline::before {
|
||||
content: "\F1080";
|
||||
}
|
||||
|
||||
.mdi-folder-move::before {
|
||||
content: "\F0252";
|
||||
}
|
||||
@@ -628,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";
|
||||
}
|
||||
@@ -652,14 +632,6 @@
|
||||
content: "\F0F5B";
|
||||
}
|
||||
|
||||
.mdi-language-python::before {
|
||||
content: "\F0320";
|
||||
}
|
||||
|
||||
.mdi-language-typescript::before {
|
||||
content: "\F06E6";
|
||||
}
|
||||
|
||||
.mdi-layers-outline::before {
|
||||
content: "\F09FE";
|
||||
}
|
||||
@@ -1016,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 长按录音相关
|
||||
@@ -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)"
|
||||
@@ -407,6 +437,10 @@ 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 {
|
||||
attachmentName,
|
||||
attachmentPresentation,
|
||||
} from "@/components/chat/attachmentPresentation";
|
||||
import {
|
||||
displayParts as displayMessageParts,
|
||||
messageBlocks as buildMessageBlocks,
|
||||
@@ -604,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);
|
||||
@@ -696,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 "";
|
||||
}
|
||||
@@ -824,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 {
|
||||
@@ -962,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));
|
||||
}
|
||||
|
||||
@@ -986,7 +954,7 @@ function formatDuration(seconds: number) {
|
||||
.sent-image-card img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 11px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@@ -1005,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 {
|
||||
@@ -1027,6 +1006,7 @@ function formatDuration(seconds: number) {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 12px;
|
||||
color: var(--attachment-color);
|
||||
}
|
||||
|
||||
.sent-attachment-name {
|
||||
@@ -1187,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)"
|
||||
@@ -268,6 +289,10 @@ 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,
|
||||
@@ -350,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 "";
|
||||
}
|
||||
@@ -484,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 {
|
||||
@@ -712,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
|
||||
@@ -194,6 +212,10 @@ 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,
|
||||
@@ -411,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 "";
|
||||
}
|
||||
|
||||
@@ -575,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 {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
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