fix: 修复多模态图片上传和历史渲染
This commit is contained in:
parent
bfad68ac3f
commit
253cc0b149
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = {}
|
||||
|
||||
31
backend/test/unit/utils/test_image_processor.py
Normal file
31
backend/test/unit/utils/test_image_processor.py
Normal 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)
|
||||
@ -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 envelope,SSE 输出 `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 游标,前端订阅默认使用精简模式。
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 后,才展示“正在回复”的加载动画。
|
||||
|
||||
@ -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(' · ')
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user