Compare commits

...

1 Commits

Author SHA1 Message Date
Weilong Liao
b43cc6dee0 feat: improve ChatUI attachment display (#9134) 2026-07-04 17:56:33 +08:00
17 changed files with 644 additions and 232 deletions

View File

@@ -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

View File

@@ -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",

View 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(

View File

@@ -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)

View File

@@ -329,6 +329,7 @@ export type MessagePart = {
attachment_id?: string;
url?: string;
filename?: string;
stored_filename?: string;
mime_type?: string;
[key: string]: unknown | string;
};

View File

@@ -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";
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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);
}
}

View 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",
}
);
}

View 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);

View File

@@ -5455,6 +5455,8 @@ components:
type: string
filename:
type: string
stored_filename:
type: string
mime_type:
type: string
additionalProperties: true

View 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",
}
]