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, conversation_id=conversation.id,
role="user", role="user",
content=input_content, 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, image_content=image_content,
run_id=run_id, run_id=run_id,
request_id=request_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 return "text", True, None
except UnicodeDecodeError: except UnicodeDecodeError:
return "unsupported", False, "当前文件不是可读文本,暂不支持预览" 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: def _generate_thumbnail(self, img: Image.Image) -> bytes:
"""生成缩略图""" """生成缩略图"""
try: try:
# 创建副本以避免修改原图 thumbnail = self._convert_to_rgb_for_export(img)
thumbnail = img.copy()
# 转换为RGB模式处理RGBA等格式
if thumbnail.mode != "RGB":
thumbnail = thumbnail.convert("RGB")
# 生成缩略图,保持宽高比 # 生成缩略图,保持宽高比
thumbnail.thumbnail(self.THUMBNAIL_SIZE, Image.Resampling.LANCZOS) thumbnail.thumbnail(self.THUMBNAIL_SIZE, Image.Resampling.LANCZOS)
@ -129,6 +124,20 @@ class ImageProcessor:
empty_img.save(output, format="JPEG", quality=85) empty_img.save(output, format="JPEG", quality=85)
return output.getvalue() 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]: def _compress_image(self, img: Image.Image, original_format: str) -> tuple[bytes, str]:
""" """
压缩图片如果超过大小限制 压缩图片如果超过大小限制
@ -140,12 +149,7 @@ class ImageProcessor:
Returns: Returns:
Tuple[bytes, str]: (压缩后的图片数据, 最终格式) Tuple[bytes, str]: (压缩后的图片数据, 最终格式)
""" """
# 创建副本 processed_img = self._convert_to_rgb_for_export(img)
processed_img = img.copy()
# 转换为RGB模式如果需要
if processed_img.mode in ("RGBA", "LA", "P"):
processed_img = processed_img.convert("RGB")
# 尝试保持原始格式但优先使用JPEG更好的压缩 # 尝试保持原始格式但优先使用JPEG更好的压缩
target_format = "JPEG" if original_format != "PNG" else "PNG" target_format = "JPEG" if original_format != "PNG" else "PNG"

View File

@ -1,7 +1,6 @@
import traceback import traceback
import uuid import uuid
from typing import Any from typing import Any
from mimetypes import guess_type
from fastapi import APIRouter, Body, Depends, HTTPException, Query, UploadFile, File from fastapi import APIRouter, Body, Depends, HTTPException, Query, UploadFile, File
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
@ -27,6 +26,7 @@ from yuxi.services.conversation_service import (
upload_thread_attachment_view, upload_thread_attachment_view,
upload_tmp_attachment_view, upload_tmp_attachment_view,
) )
from yuxi.services.file_preview import detect_media_type
from yuxi.services.thread_files_service import ( from yuxi.services.thread_files_service import (
list_thread_files_view, list_thread_files_view,
read_thread_file_content_view, read_thread_file_content_view,
@ -544,7 +544,7 @@ async def get_thread_artifact(
path=path, 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 headers = {"Content-Disposition": f'attachment; filename="{file_path.name}"'} if download else None
return FileResponse(path=file_path, media_type=media_type, headers=headers) 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 from __future__ import annotations
import base64
import io
import uuid import uuid
import pytest import pytest
from PIL import Image
from yuxi.agents.backends.sandbox import ensure_thread_dirs, sandbox_user_data_dir, sandbox_workspace_dir from yuxi.agents.backends.sandbox import ensure_thread_dirs, sandbox_user_data_dir, sandbox_workspace_dir
pytestmark = [pytest.mark.asyncio, pytest.mark.integration] 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 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: async def _create_thread_for_user(test_client, headers: dict[str, str]) -> str:
agents_resp = await test_client.get("/api/agent", headers=headers) agents_resp = await test_client.get("/api/agent", headers=headers)
assert agents_resp.status_code == 200, agents_resp.text 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" 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 @pytest.mark.asyncio
async def test_create_chat_run_snapshots_agent_configured_model_spec(monkeypatch: pytest.MonkeyPatch): async def test_create_chat_run_snapshots_agent_configured_model_spec(monkeypatch: pytest.MonkeyPatch):
captured = {} 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。 - 工作区知识库分类显示:知识库侧边栏按创建者分组为“我的知识库”和“共享知识库”,自己创建的知识库显示在“我的知识库”下,非自己创建的显示在“共享知识库”下;`knowledge_bases` 表新增 `created_by` 字段记录创建者 uid。
- 工作区文件上传支持多选:`/workspace/upload` 与 Viewer 工作区上传统一使用 `files` 多文件字段,一次最多上传 50 个文件,批量上传失败时清理本次已写入文件。 - 工作区文件上传支持多选:`/workspace/upload` 与 Viewer 工作区上传统一使用 `files` 多文件字段,一次最多上传 50 个文件,批量上传失败时清理本次已写入文件。
- 聊天附件新增 MinIO tmp 临时上传、可选 PDF/图片解析、确认后加入线程附件的流程;前端改为弹窗内上传、解析与确认。 - 聊天附件新增 MinIO tmp 临时上传、可选 PDF/图片解析、确认后加入线程附件的流程;前端改为弹窗内上传、解析与确认。
- 修复智能体对话上传透明 PNG 后图片失真的问题:多模态图片处理在导出 RGB 前会先按白底合成 alpha 通道,避免透明像素中的隐藏颜色被直接转为可见像素;交付物预览优先按文件头识别 MIME避免 `.jpg` 文件名包裹 PNG 内容时前端按错误格式加载Agent run 输入消息会持久化为 `multimodal_image`,刷新历史后仍能显示用户上传图片。
- 优化智能体对话页细节:状态面板隐藏空 section待办名称限制为 20 个中文汉字以内,模型选择器展示供应商名称,并收紧附件状态标签与文件编辑浮动操作样式。 - 优化智能体对话页细节:状态面板隐藏空 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 执行链路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 游标,前端订阅默认使用精简模式。 - 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) 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 conversations = computed(() => {
const historyConvs = historyConversations.value const historyConvs = historyConversations.value
const mergedOngoingMessages = stripDuplicatedOngoingHumanMessage( const {
historyConvs, historyConvs: mergedHistoryConvs,
onGoingConvMessages.value ongoingMessages: mergedOngoingMessages
) } = mergeOngoingUserMessageIntoHistory(historyConvs, onGoingConvMessages.value)
// 线 // 线
if (mergedOngoingMessages.length > 0) { if (mergedOngoingMessages.length > 0) {
@ -1296,9 +1352,9 @@ const conversations = computed(() => {
messages: mergedOngoingMessages, messages: mergedOngoingMessages,
status: 'streaming' status: 'streaming'
} }
return [...historyConvs, onGoingConv] return [...mergedHistoryConvs, onGoingConv]
} }
return historyConvs return mergedHistoryConvs
}) })
const conversationRows = computed(() => { const conversationRows = computed(() => {
@ -1391,49 +1447,6 @@ const buildOptimisticHumanMessage = ({
return message 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 // runs worker
const insertOptimisticHumanMessage = ( const insertOptimisticHumanMessage = (
threadState, threadState,

View File

@ -23,27 +23,7 @@
<div v-if="previewAttachments.length" class="attachment-preview-list"> <div v-if="previewAttachments.length" class="attachment-preview-list">
<div <div
v-for="attachment in previewImageAttachments" v-for="attachment in previewAttachments"
: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"
:key="attachment.fileId" :key="attachment.fileId"
class="attachment-file-card" class="attachment-file-card"
> >
@ -124,12 +104,6 @@ const currentImage = ref(null)
const placeholder = '问点什么?使用 @ 可以提及哦~' const placeholder = '问点什么?使用 @ 可以提及哦~'
const previewAttachments = computed(() => normalizeAttachmentPreviews(props.attachments)) 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) => { const updateValue = (val) => {
emit('update:modelValue', val) emit('update:modelValue', val)
@ -213,23 +187,6 @@ defineExpose({
gap: 8px; 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 { .attachment-file-card {
position: relative; position: relative;
display: flex; display: flex;
@ -281,8 +238,8 @@ defineExpose({
position: absolute; position: absolute;
top: 6px; top: 6px;
right: 6px; right: 6px;
width: 24px; width: 20px;
height: 24px; height: 20px;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
display: inline-flex; display: inline-flex;

View File

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

View File

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

View File

@ -105,16 +105,22 @@ export function useAgentStreamHandler({
threadState.pendingRequestId = resolvedRequestId threadState.pendingRequestId = resolvedRequestId
} }
if (resolvedRequestId && msg && msg.type !== 'system') { if (resolvedRequestId && msg && msg.type !== 'system') {
threadState.onGoingConv.msgChunks[resolvedRequestId] = [ const localHumanMessage = threadState.onGoingConv.msgChunks[resolvedRequestId]?.find(
{ (item) => item?.type === 'human' || item?.role === 'user'
...msg, )
id: msg?.id || resolvedRequestId, const initMessage = {
extra_metadata: { ...msg,
...(msg?.extra_metadata || {}), id: msg?.id || resolvedRequestId,
request_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 后,才展示“正在回复”的加载动画。 // 只有在服务端确认 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' import { formatRelative, parseToShanghai } from '@/utils/time'
export const formatRelativeTime = (value) => formatRelative(value) export const formatRelativeTime = (value) => formatRelative(value)
@ -47,6 +47,16 @@ export const getMimeSubtypeLabel = (mimeType) => {
return subtype ? subtype.toUpperCase() : '' 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) => { export const normalizeAttachmentPreview = (attachment) => {
const name = getDisplayFileName( const name = getDisplayFileName(
attachment?.file_name || attachment?.name || attachment?.path, attachment?.file_name || attachment?.name || attachment?.path,
@ -62,7 +72,6 @@ export const normalizeAttachmentPreview = (attachment) => {
fileId, fileId,
name, name,
previewUrl: attachment?.original_artifact_url || attachment?.artifact_url || '', previewUrl: attachment?.original_artifact_url || attachment?.artifact_url || '',
isImage: fileType.startsWith('image/') || getPreviewTypeByPath(name) === 'image',
meta: [typeLabel, sizeLabel === '-' ? '' : sizeLabel].filter(Boolean).join(' · ') meta: [typeLabel, sizeLabel === '-' ? '' : sizeLabel].filter(Boolean).join(' · ')
} }
} }