fix: 修复多模态图片上传和历史渲染

This commit is contained in:
Wenjie Zhang 2026-06-12 00:03:04 +08:00
parent bfad68ac3f
commit 253cc0b149
14 changed files with 293 additions and 168 deletions

View File

@ -351,7 +351,7 @@ async def create_agent_run_view(
conversation_id=conversation.id,
role="user",
content=input_content,
message_type="resume" if run_type == "resume" else "text",
message_type="resume" if run_type == "resume" else "multimodal_image" if image_content else "text",
image_content=image_content,
run_id=run_id,
request_id=request_id,

View File

@ -100,3 +100,29 @@ def detect_preview_type(path: str, raw_content: bytes) -> tuple[str, bool, str |
return "text", True, None
except UnicodeDecodeError:
return "unsupported", False, "当前文件不是可读文本,暂不支持预览"
def detect_media_type(path: str, raw_content: bytes | None = None) -> str:
"""Detect response media type, preferring file signatures over filename suffixes."""
head = (raw_content or b"")[:512]
if head.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if head.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if head.startswith(b"GIF87a") or head.startswith(b"GIF89a"):
return "image/gif"
if head.startswith(b"RIFF") and b"WEBP" in head[:16]:
return "image/webp"
if head.startswith(b"BM"):
return "image/bmp"
if head.startswith(b"%PDF-"):
return "application/pdf"
stripped_head = head.lstrip()
if stripped_head.startswith(b"<svg") or stripped_head.startswith(b"<?xml"):
suffix = PurePosixPath(path).suffix.lower()
if suffix == ".svg" or b"<svg" in stripped_head[:256]:
return "image/svg+xml"
return mimetypes.guess_type(path)[0] or "application/octet-stream"

View File

@ -106,12 +106,7 @@ class ImageProcessor:
def _generate_thumbnail(self, img: Image.Image) -> bytes:
"""生成缩略图"""
try:
# 创建副本以避免修改原图
thumbnail = img.copy()
# 转换为RGB模式处理RGBA等格式
if thumbnail.mode != "RGB":
thumbnail = thumbnail.convert("RGB")
thumbnail = self._convert_to_rgb_for_export(img)
# 生成缩略图,保持宽高比
thumbnail.thumbnail(self.THUMBNAIL_SIZE, Image.Resampling.LANCZOS)
@ -129,6 +124,20 @@ class ImageProcessor:
empty_img.save(output, format="JPEG", quality=85)
return output.getvalue()
def _convert_to_rgb_for_export(self, img: Image.Image) -> Image.Image:
"""转换为 RGB同时把透明像素按白底合成避免隐藏颜色变成可见像素。"""
if img.mode == "RGB":
return img.copy()
has_alpha = img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info)
if not has_alpha:
return img.convert("RGB")
rgba_img = img.convert("RGBA")
background = Image.new("RGBA", rgba_img.size, (255, 255, 255, 255))
background.alpha_composite(rgba_img)
return background.convert("RGB")
def _compress_image(self, img: Image.Image, original_format: str) -> tuple[bytes, str]:
"""
压缩图片如果超过大小限制
@ -140,12 +149,7 @@ class ImageProcessor:
Returns:
Tuple[bytes, str]: (压缩后的图片数据, 最终格式)
"""
# 创建副本
processed_img = img.copy()
# 转换为RGB模式如果需要
if processed_img.mode in ("RGBA", "LA", "P"):
processed_img = processed_img.convert("RGB")
processed_img = self._convert_to_rgb_for_export(img)
# 尝试保持原始格式但优先使用JPEG更好的压缩
target_format = "JPEG" if original_format != "PNG" else "PNG"

View File

@ -1,7 +1,6 @@
import traceback
import uuid
from typing import Any
from mimetypes import guess_type
from fastapi import APIRouter, Body, Depends, HTTPException, Query, UploadFile, File
from fastapi.responses import FileResponse, StreamingResponse
@ -27,6 +26,7 @@ from yuxi.services.conversation_service import (
upload_thread_attachment_view,
upload_tmp_attachment_view,
)
from yuxi.services.file_preview import detect_media_type
from yuxi.services.thread_files_service import (
list_thread_files_view,
read_thread_file_content_view,
@ -544,7 +544,7 @@ async def get_thread_artifact(
path=path,
)
media_type = guess_type(file_path.name)[0] or "application/octet-stream"
media_type = detect_media_type(file_path.name, file_path.read_bytes())
headers = {"Content-Disposition": f'attachment; filename="{file_path.name}"'} if download else None
return FileResponse(path=file_path, media_type=media_type, headers=headers)

View File

@ -4,9 +4,12 @@ Integration tests for chat router endpoints.
from __future__ import annotations
import base64
import io
import uuid
import pytest
from PIL import Image
from yuxi.agents.backends.sandbox import ensure_thread_dirs, sandbox_user_data_dir, sandbox_workspace_dir
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
@ -17,6 +20,58 @@ async def test_chat_endpoints_require_authentication(test_client):
assert (await test_client.get("/api/agent")).status_code == 401
async def test_image_upload_composites_transparent_png_pixels_on_white(test_client, admin_headers):
image = Image.new("RGBA", (2, 2), (255, 255, 255, 0))
image.putpixel((0, 0), (50, 87, 244, 0))
image.putpixel((1, 0), (50, 87, 244, 255))
with io.BytesIO() as buffer:
image.save(buffer, format="PNG")
image_bytes = buffer.getvalue()
response = await test_client.post(
"/api/chat/image/upload",
headers=admin_headers,
files={"file": ("transparent.png", image_bytes, "image/png")},
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["success"] is True
assert payload["mime_type"] == "image/png"
processed_data = base64.b64decode(payload["image_content"])
with Image.open(io.BytesIO(processed_data)) as processed_image:
rgb_image = processed_image.convert("RGB")
assert rgb_image.getpixel((0, 0)) == (255, 255, 255)
assert rgb_image.getpixel((1, 0)) == (50, 87, 244)
async def test_thread_artifact_uses_image_signature_for_content_type(test_client, admin_headers):
thread_id = await _create_thread_for_user(test_client, admin_headers)
image = Image.new("RGBA", (2, 2), (255, 255, 255, 0))
with io.BytesIO() as buffer:
image.save(buffer, format="PNG")
image_bytes = buffer.getvalue()
upload_response = await test_client.post(
f"/api/chat/thread/{thread_id}/attachments",
headers=admin_headers,
files={"file": ("mislabeled.jpg", image_bytes, "image/jpeg")},
)
assert upload_response.status_code == 200, upload_response.text
attachment = upload_response.json()
artifact_response = await test_client.get(attachment["original_artifact_url"], headers=admin_headers)
assert artifact_response.status_code == 200, artifact_response.text
assert artifact_response.headers["content-type"].startswith("image/png")
assert artifact_response.content.startswith(b"\x89PNG\r\n\x1a\n")
async def _create_thread_for_user(test_client, headers: dict[str, str]) -> str:
agents_resp = await test_client.get("/api/agent", headers=headers)
assert agents_resp.status_code == 200, agents_resp.text

View File

@ -766,6 +766,45 @@ async def test_create_chat_run_persists_validated_model_spec(monkeypatch: pytest
assert captured["input_payload"]["model_spec"] == "claude-x"
@pytest.mark.asyncio
async def test_create_chat_run_with_image_persists_multimodal_message_type(monkeypatch: pytest.MonkeyPatch):
captured = {}
created_run = SimpleNamespace(id="", thread_id="thread-1", status="pending", request_id="req-1", uid="user-1")
class RunRepo:
def __init__(self, db_session):
del db_session
async def get_run_by_request_id(self, request_id: str):
del request_id
return None
async def create_run(self, **kwargs):
captured["input_payload"] = kwargs["input_payload"]
created_run.id = kwargs["run_id"]
return created_run
async def set_input_message(self, run_id: str, message_id: int):
del run_id, message_id
return created_run
db = _patch_common_run_repos(monkeypatch, RunRepo)
await agent_run_service.create_agent_run_view(
query="看图",
agent_id="default",
thread_id="thread-1",
meta={"request_id": "req-1"},
image_content="base64-image",
current_uid="user-1",
db=db,
)
assert captured["input_payload"]["image_content"] == "base64-image"
assert db.added[0].message_type == "multimodal_image"
assert db.added[0].image_content == "base64-image"
@pytest.mark.asyncio
async def test_create_chat_run_snapshots_agent_configured_model_spec(monkeypatch: pytest.MonkeyPatch):
captured = {}

View File

@ -0,0 +1,31 @@
from __future__ import annotations
import base64
import io
from PIL import Image
from yuxi.utils.image_processor import process_uploaded_image
def test_process_uploaded_image_composites_transparent_png_pixels_on_white():
image = Image.new("RGBA", (2, 2), (255, 255, 255, 0))
image.putpixel((0, 0), (50, 87, 244, 0))
image.putpixel((1, 0), (50, 87, 244, 255))
with io.BytesIO() as buffer:
image.save(buffer, format="PNG")
image_data = buffer.getvalue()
result = process_uploaded_image(image_data, "transparent.png")
assert result["success"] is True
assert result["format"] == "PNG"
assert result["mime_type"] == "image/png"
processed_data = base64.b64decode(result["image_content"])
with Image.open(io.BytesIO(processed_data)) as processed_image:
rgb_image = processed_image.convert("RGB")
assert rgb_image.getpixel((0, 0)) == (255, 255, 255)
assert rgb_image.getpixel((1, 0)) == (50, 87, 244)

View File

@ -59,6 +59,7 @@
- 工作区知识库分类显示:知识库侧边栏按创建者分组为“我的知识库”和“共享知识库”,自己创建的知识库显示在“我的知识库”下,非自己创建的显示在“共享知识库”下;`knowledge_bases` 表新增 `created_by` 字段记录创建者 uid。
- 工作区文件上传支持多选:`/workspace/upload` 与 Viewer 工作区上传统一使用 `files` 多文件字段,一次最多上传 50 个文件,批量上传失败时清理本次已写入文件。
- 聊天附件新增 MinIO tmp 临时上传、可选 PDF/图片解析、确认后加入线程附件的流程;前端改为弹窗内上传、解析与确认。
- 修复智能体对话上传透明 PNG 后图片失真的问题:多模态图片处理在导出 RGB 前会先按白底合成 alpha 通道,避免透明像素中的隐藏颜色被直接转为可见像素;交付物预览优先按文件头识别 MIME避免 `.jpg` 文件名包裹 PNG 内容时前端按错误格式加载Agent run 输入消息会持久化为 `multimodal_image`,刷新历史后仍能显示用户上传图片。
- 优化智能体对话页细节:状态面板隐藏空 section待办名称限制为 20 个中文汉字以内,模型选择器展示供应商名称,并收紧附件状态标签与文件编辑浮动操作样式。
- 标准化 Agent run/SSE 执行链路run 创建时持久化输入消息并提交后入队worker 统一写入 Redis Stream envelopeSSE 输出 `event/data/id`、心跳注释、`Last-Event-ID` 回放和终止 `end` 事件;前端强制使用 run API 并支持 ask_user_question 中断后以 resume run 恢复;事件 envelope 构造收敛到统一 helper前端优先使用 envelope 一级 `thread_id` 路由。
- Agent run SSE 新增 `verbose=false` 精简模式:默认仍返回完整事件载荷;精简模式仅在 SSE 输出前重建最小 payload跳过 `metadata` 和空 `yuxi.agent_state`,将同一 data 内的 `request_id` 外提为单个字段,移除 chunk 中重复的 `meta`、`metadata`、`thread_id`、`response`、空 `namespace` 和图片 base64 等调试字段,保留消息增量、工具调用、工具结果、非空 Agent state、终止状态和 SSE 游标,前端订阅默认使用精简模式。

View File

@ -1283,12 +1283,68 @@ const historyConversations = computed(() => {
return MessageProcessor.convertServerHistoryToMessages(currentThreadMessages.value)
})
function getMessageRequestId(message) {
const metadataRequestId = message?.extra_metadata?.request_id
if (typeof metadataRequestId === 'string' && metadataRequestId.trim()) return metadataRequestId.trim()
if (message?.type === 'human' && typeof message.id === 'string' && message.id.trim()) {
return message.id.trim()
}
return null
}
function mergeLocalImageFields(message, localMessage) {
if (!localMessage?.image_content || message?.image_content) return message
return {
...message,
message_type: localMessage.message_type || message.message_type,
image_content: localMessage.image_content,
extra_metadata: message.extra_metadata || {}
}
}
function mergeOngoingUserMessageIntoHistory(historyConvs, ongoingMessages) {
if (!Array.isArray(historyConvs) || !historyConvs.length || !Array.isArray(ongoingMessages)) {
return { historyConvs, ongoingMessages }
}
const firstOngoingMessage = ongoingMessages[0]
if (!firstOngoingMessage || firstOngoingMessage.type !== 'human') {
return { historyConvs, ongoingMessages }
}
const lastHistoryConv = historyConvs[historyConvs.length - 1]
const historyMessages = Array.isArray(lastHistoryConv?.messages) ? lastHistoryConv.messages : []
const historyHumanIndex = historyMessages.findIndex((message) => message?.type === 'human')
if (historyHumanIndex === -1) return { historyConvs, ongoingMessages }
const historyHuman = historyMessages[historyHumanIndex]
const historyRequestId = getMessageRequestId(historyHuman)
const ongoingRequestId = getMessageRequestId(firstOngoingMessage)
if (!historyRequestId || !ongoingRequestId || historyRequestId !== ongoingRequestId) {
return { historyConvs, ongoingMessages }
}
const patchedHistoryHuman = mergeLocalImageFields(historyHuman, firstOngoingMessage)
if (patchedHistoryHuman === historyHuman) {
return { historyConvs, ongoingMessages: ongoingMessages.slice(1) }
}
const patchedHistoryMessages = [...historyMessages]
patchedHistoryMessages[historyHumanIndex] = patchedHistoryHuman
const patchedHistoryConvs = [...historyConvs]
patchedHistoryConvs[historyConvs.length - 1] = {
...lastHistoryConv,
messages: patchedHistoryMessages
}
return { historyConvs: patchedHistoryConvs, ongoingMessages: ongoingMessages.slice(1) }
}
const conversations = computed(() => {
const historyConvs = historyConversations.value
const mergedOngoingMessages = stripDuplicatedOngoingHumanMessage(
historyConvs,
onGoingConvMessages.value
)
const {
historyConvs: mergedHistoryConvs,
ongoingMessages: mergedOngoingMessages
} = mergeOngoingUserMessageIntoHistory(historyConvs, onGoingConvMessages.value)
// 线
if (mergedOngoingMessages.length > 0) {
@ -1296,9 +1352,9 @@ const conversations = computed(() => {
messages: mergedOngoingMessages,
status: 'streaming'
}
return [...historyConvs, onGoingConv]
return [...mergedHistoryConvs, onGoingConv]
}
return historyConvs
return mergedHistoryConvs
})
const conversationRows = computed(() => {
@ -1391,49 +1447,6 @@ const buildOptimisticHumanMessage = ({
return message
}
const getMessageRequestId = (message) => {
if (!message || typeof message !== 'object') return null
const metadataRequestId = message.extra_metadata?.request_id
if (typeof metadataRequestId === 'string' && metadataRequestId.trim()) {
return metadataRequestId.trim()
}
if (message.type === 'human' && typeof message.id === 'string' && message.id.trim()) {
return message.id.trim()
}
return null
}
// ongoing user message
// 线 request_id
const stripDuplicatedOngoingHumanMessage = (historyConvs, ongoingMessages) => {
if (!Array.isArray(historyConvs) || !historyConvs.length || !Array.isArray(ongoingMessages)) {
return ongoingMessages
}
const firstOngoingMessage = ongoingMessages[0]
if (!firstOngoingMessage || firstOngoingMessage.type !== 'human') {
return ongoingMessages
}
const lastHistoryConv = historyConvs[historyConvs.length - 1]
const historyMessages = Array.isArray(lastHistoryConv?.messages) ? lastHistoryConv.messages : []
const lastHistoryHuman = historyMessages.find((message) => message?.type === 'human')
if (!lastHistoryHuman) {
return ongoingMessages
}
const historyRequestId = getMessageRequestId(lastHistoryHuman)
const ongoingRequestId = getMessageRequestId(firstOngoingMessage)
if (!historyRequestId || !ongoingRequestId || historyRequestId !== ongoingRequestId) {
return ongoingMessages
}
return ongoingMessages.slice(1)
}
// runs worker
const insertOptimisticHumanMessage = (
threadState,

View File

@ -23,27 +23,7 @@
<div v-if="previewAttachments.length" class="attachment-preview-list">
<div
v-for="attachment in previewImageAttachments"
:key="attachment.fileId"
class="attachment-preview-image"
>
<img
:src="attachment.previewUrl"
:alt="attachment.name"
class="attachment-image-thumb"
/>
<button
class="attachment-remove-btn"
type="button"
:aria-label="`移除附件 ${attachment.name}`"
@click.stop="handleAttachmentRemoved(attachment)"
>
<X :size="14" />
</button>
</div>
<div
v-for="attachment in previewFileAttachments"
v-for="attachment in previewAttachments"
:key="attachment.fileId"
class="attachment-file-card"
>
@ -124,12 +104,6 @@ const currentImage = ref(null)
const placeholder = '问点什么?使用 @ 可以提及哦~'
const previewAttachments = computed(() => normalizeAttachmentPreviews(props.attachments))
const previewImageAttachments = computed(() =>
previewAttachments.value.filter((attachment) => attachment.isImage && attachment.previewUrl)
)
const previewFileAttachments = computed(() =>
previewAttachments.value.filter((attachment) => !attachment.isImage || !attachment.previewUrl)
)
const updateValue = (val) => {
emit('update:modelValue', val)
@ -213,23 +187,6 @@ defineExpose({
gap: 8px;
}
.attachment-preview-image {
position: relative;
width: 80px;
height: 80px;
border-radius: 12px;
border: 1px solid var(--gray-150);
background: var(--gray-25);
overflow: hidden;
}
.attachment-image-thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.attachment-file-card {
position: relative;
display: flex;
@ -281,8 +238,8 @@ defineExpose({
position: absolute;
top: 6px;
right: 6px;
width: 24px;
height: 24px;
width: 20px;
height: 20px;
border: none;
border-radius: 50%;
display: inline-flex;

View File

@ -1,9 +1,9 @@
<template>
<div
v-if="message.message_type === 'multimodal_image' && message.image_content"
v-if="message.type === 'human' && message.image_content"
class="message-image"
>
<img :src="`data:image/jpeg;base64,${message.image_content}`" alt="用户上传的图片" />
<img :src="`data:${messageImageMimeType};base64,${message.image_content}`" alt="用户上传的图片" />
</div>
<div
class="message-box"
@ -109,15 +109,7 @@
class="human-message-attachments"
>
<div
v-for="attachment in imageAttachments"
:key="attachment.fileId"
class="message-attachment-image"
>
<img :src="attachment.previewUrl" :alt="attachment.name" class="message-attachment-thumb" />
</div>
<div
v-for="attachment in fileAttachments"
v-for="attachment in messageAttachments"
:key="attachment.fileId"
class="message-attachment-file"
>
@ -146,7 +138,7 @@ import { useAgentStore } from '@/stores/agent'
import { useInfoStore } from '@/stores/info'
import { storeToRefs } from 'pinia'
import { MessageProcessor } from '@/utils/messageProcessor'
import { normalizeAttachmentPreviews } from '@/utils/file_utils'
import { inferImageMimeTypeFromBase64, normalizeAttachmentPreviews } from '@/utils/file_utils'
import { buildMentionDisplayLabels } from '@/utils/mention_utils'
import FileTypeIcon from '@/components/common/FileTypeIcon.vue'
import { enrichTaskToolCalls } from '@/components/ToolCallingResult/toolRegistry'
@ -266,12 +258,8 @@ const infoStore = useInfoStore()
const messageAttachments = computed(() =>
normalizeAttachmentPreviews(props.message.extra_metadata?.attachments)
)
const imageAttachments = computed(() =>
messageAttachments.value.filter((attachment) => attachment.isImage && attachment.previewUrl)
)
const fileAttachments = computed(() =>
messageAttachments.value.filter((attachment) => !attachment.isImage || !attachment.previewUrl)
const messageImageMimeType = computed(
() => inferImageMimeTypeFromBase64(props.message.image_content) || 'image/jpeg'
)
const mentionDisplayLabels = computed(() => buildMentionDisplayLabels(props.mention || {}))
@ -497,22 +485,6 @@ const parsedData = computed(() => {
margin-bottom: 0.8rem;
}
.message-attachment-image {
width: 112px;
height: 112px;
overflow: hidden;
border: 1px solid var(--gray-200);
border-radius: 0.5rem;
background: var(--gray-0);
}
.message-attachment-thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.message-attachment-file {
width: 220px;
min-width: 0;

View File

@ -6,8 +6,13 @@
:alt="imageData.originalName"
class="preview-image"
/>
<button class="remove-button" @click="handleRemove">
<X />
<button
class="remove-button"
type="button"
:aria-label="`移除图片 ${imageData.originalName || ''}`"
@click="handleRemove"
>
<X :size="14" />
</button>
</div>
</div>
@ -49,22 +54,29 @@ const handleRemove = () => {
.remove-button {
position: absolute;
top: 3px;
right: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
top: 6px;
right: 6px;
width: 20px;
height: 20px;
border: none;
background: rgba(0, 0, 0, 0.2);
color: var(--gray-0);
cursor: pointer;
display: flex;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color 0.15s ease;
padding: 0;
color: var(--gray-0);
background: var(--gray-900);
cursor: pointer;
transition:
background-color 0.15s ease,
transform 0.15s ease;
&:hover {
background: rgba(0, 0, 0, 0.3);
background: var(--gray-700);
}
&:active {
transform: scale(0.96);
}
}
</style>

View File

@ -105,16 +105,22 @@ export function useAgentStreamHandler({
threadState.pendingRequestId = resolvedRequestId
}
if (resolvedRequestId && msg && msg.type !== 'system') {
threadState.onGoingConv.msgChunks[resolvedRequestId] = [
{
...msg,
id: msg?.id || resolvedRequestId,
extra_metadata: {
...(msg?.extra_metadata || {}),
request_id: resolvedRequestId
}
const localHumanMessage = threadState.onGoingConv.msgChunks[resolvedRequestId]?.find(
(item) => item?.type === 'human' || item?.role === 'user'
)
const initMessage = {
...msg,
id: msg?.id || resolvedRequestId,
extra_metadata: {
...(msg?.extra_metadata || {}),
request_id: resolvedRequestId
}
]
}
if (localHumanMessage?.image_content && !initMessage.image_content) {
initMessage.message_type = localHumanMessage.message_type || initMessage.message_type
initMessage.image_content = localHumanMessage.image_content
}
threadState.onGoingConv.msgChunks[resolvedRequestId] = [initMessage]
}
}
// 只有在服务端确认 init 后,才展示“正在回复”的加载动画。

View File

@ -1,4 +1,4 @@
import { getPreviewFileExtension, getPreviewTypeByPath } from '@/utils/file_preview'
import { getPreviewFileExtension } from '@/utils/file_preview'
import { formatRelative, parseToShanghai } from '@/utils/time'
export const formatRelativeTime = (value) => formatRelative(value)
@ -47,6 +47,16 @@ export const getMimeSubtypeLabel = (mimeType) => {
return subtype ? subtype.toUpperCase() : ''
}
export const inferImageMimeTypeFromBase64 = (base64Content) => {
const head = String(base64Content || '').slice(0, 48)
if (head.startsWith('iVBORw0KGgo')) return 'image/png'
if (head.startsWith('/9j/')) return 'image/jpeg'
if (head.startsWith('R0lGODdh') || head.startsWith('R0lGODlh')) return 'image/gif'
if (head.startsWith('UklGR')) return 'image/webp'
if (head.startsWith('Qk')) return 'image/bmp'
return null
}
export const normalizeAttachmentPreview = (attachment) => {
const name = getDisplayFileName(
attachment?.file_name || attachment?.name || attachment?.path,
@ -62,7 +72,6 @@ export const normalizeAttachmentPreview = (attachment) => {
fileId,
name,
previewUrl: attachment?.original_artifact_url || attachment?.artifact_url || '',
isImage: fileType.startsWith('image/') || getPreviewTypeByPath(name) === 'image',
meta: [typeLabel, sizeLabel === '-' ? '' : sizeLabel].filter(Boolean).join(' · ')
}
}