图片上传识别功能

This commit is contained in:
renjianbo
2026-04-13 22:52:36 +08:00
parent df4fab1e6e
commit 63b54116a5
13 changed files with 708 additions and 17 deletions

View File

@@ -48,6 +48,28 @@
/>
<div class="message-content">
<div class="message-bubble" v-html="formatMessage(message.content)"></div>
<div
v-if="message.attachments?.length"
:class="['message-attachments', message.role === 'user' ? 'is-user' : 'is-agent']"
>
<div
v-for="(a, ai) in message.attachments"
:key="`msg-att-${index}-${ai}`"
class="message-attachment-item"
>
<img
v-if="a.isImage && a.thumbUrl"
:src="a.thumbUrl"
:alt="a.filename"
class="message-attachment-image"
@click="openImagePreview(a)"
/>
<div v-else class="message-attachment-file">
<el-icon :size="14"><Document /></el-icon>
<span class="message-attachment-name">{{ a.filename }}</span>
</div>
</div>
</div>
<div class="message-time">{{ formatTime(message.timestamp) }}</div>
</div>
<el-avatar
@@ -104,8 +126,31 @@
</el-alert>
</div>
</div>
<div class="chat-input-area">
<el-dialog
v-model="imagePreviewVisible"
title="图片预览"
width="min(86vw, 980px)"
append-to-body
destroy-on-close
>
<div class="image-preview-dialog-body">
<img
v-if="imagePreviewSrc"
:src="imagePreviewSrc"
:alt="imagePreviewTitle || '图片预览'"
class="image-preview-dialog-img"
/>
</div>
</el-dialog>
<div
class="chat-input-area"
:class="{ 'is-drag-over': inputDragOver }"
@dragenter.prevent="onInputDragEnter"
@dragleave.prevent="onInputDragLeave"
@dragover.prevent="onInputDragOver"
@drop.prevent="onInputDrop"
>
<input
ref="fileInputRef"
type="file"
@@ -160,7 +205,7 @@
v-model="inputMessage"
type="textarea"
:rows="2"
placeholder="发送消息..."
placeholder="发送消息…(可将图片拖入此区域)"
@keydown.enter.exact.prevent="handleSendMessage"
@keydown.enter.shift.exact="handleNewLine"
:disabled="loading || !agentId"
@@ -206,12 +251,14 @@ interface Message {
role: 'user' | 'agent'
content: string
timestamp: number
attachments?: MessageAttachment[]
}
interface PendingAttachment {
relative_path: string
filename: string
size?: number
content_type?: string
/** 图片:本地 blob URL用于缩略图移除或发送后 revoke */
thumbUrl?: string
/** 浏览器本地读取的纯文本预览(发送后也会写入气泡) */
@@ -220,6 +267,14 @@ interface PendingAttachment {
previewNote?: string
}
interface MessageAttachment {
relative_path: string
filename: string
thumbUrl?: string
isImage?: boolean
content_type?: string
}
const props = defineProps<{
agentId?: string
agentName?: string
@@ -244,6 +299,11 @@ const fileInputRef = ref<HTMLInputElement | null>(null)
const pendingAttachments = ref<PendingAttachment[]>([])
/** 已发送、待轮询结束后再 revoke 的附件(含 thumbUrl */
const blobsPendingRevokeAfterRun = ref<PendingAttachment[] | null>(null)
/** 拖放图片到输入区时高亮 */
const inputDragOver = ref(false)
const imagePreviewVisible = ref(false)
const imagePreviewSrc = ref('')
const imagePreviewTitle = ref('')
let pollingInterval: any = null
let replyAdded = false // 标志位:防止重复添加回复
@@ -275,7 +335,17 @@ function readDesignChatFromStorage(agentId: string): Message[] {
.map((m: any) => ({
role: m.role,
content: m.content,
timestamp: m.timestamp
timestamp: m.timestamp,
attachments: Array.isArray(m.attachments)
? m.attachments
.filter((a: any) => a && typeof a.relative_path === 'string' && typeof a.filename === 'string')
.map((a: any) => ({
relative_path: String(a.relative_path),
filename: String(a.filename),
isImage: !!a.isImage,
content_type: a.content_type ? String(a.content_type) : undefined
}))
: undefined
}))
} catch {
return []
@@ -285,7 +355,18 @@ function readDesignChatFromStorage(agentId: string): Message[] {
function writeDesignChatToStorage(agentId: string, list: Message[]) {
try {
const slice = list.slice(-MAX_PERSIST_MESSAGES)
let raw = JSON.stringify(slice)
const compact = slice.map((m) => ({
role: m.role,
content: m.content,
timestamp: m.timestamp,
attachments: m.attachments?.map((a) => ({
relative_path: a.relative_path,
filename: a.filename,
isImage: !!a.isImage,
content_type: a.content_type
}))
}))
let raw = JSON.stringify(compact)
if (raw.length > 450_000) {
raw = JSON.stringify(slice.slice(-80))
}
@@ -363,6 +444,7 @@ async function loadChatHistory() {
const cached = readDesignChatFromStorage(aid)
if (cached.length > 0) {
messages.value = cached
await hydrateMessageAttachmentThumbs(messages.value)
nextTick(() => scrollToBottom())
}
@@ -372,15 +454,29 @@ async function loadChatHistory() {
agent_text: string
created_at: string
execution_id: string
attachments?: Array<{
relative_path: string
filename: string
content_type?: string
}>
}>
) => {
const next: Message[] = []
for (const t of turns) {
const base = new Date(t.created_at).getTime()
const msgAttachments: MessageAttachment[] = (t.attachments || []).map((a) => ({
relative_path: a.relative_path,
filename: a.filename,
content_type: a.content_type,
isImage:
!!a.content_type?.startsWith('image/') ||
/\.(png|jpe?g|gif|webp|bmp|tiff?)$/i.test(a.filename || a.relative_path)
}))
next.push({
role: 'user',
content: t.user_text || ' ',
timestamp: Number.isFinite(base) ? base - 400 : Date.now() - 400
timestamp: Number.isFinite(base) ? base - 400 : Date.now() - 400,
attachments: msgAttachments.length ? msgAttachments : undefined
})
next.push({
role: 'agent',
@@ -396,6 +492,11 @@ async function loadChatHistory() {
agent_text: string
created_at: string
execution_id: string
attachments?: Array<{
relative_path: string
filename: string
content_type?: string
}>
}
const fetchHist = async (previewUserId: string | undefined): Promise<HistTurn[]> => {
@@ -439,7 +540,9 @@ async function loadChatHistory() {
if (Array.isArray(turns) && turns.length > 0) {
const next = applyServerTurns(turns)
revokeMessageAttachmentBlobs(messages.value)
messages.value = next
await hydrateMessageAttachmentThumbs(messages.value)
writeDesignChatToStorage(aid, next)
}
// 服务端无记录时保留上面已展示的本地镜像(若有)
@@ -476,6 +579,55 @@ function revokeAttachmentBlobs(items: PendingAttachment[]) {
}
}
function revokeMessageAttachmentBlobs(items: Message[]) {
for (const m of items) {
for (const a of m.attachments || []) {
if (a.thumbUrl) {
try {
URL.revokeObjectURL(a.thumbUrl)
} catch {
/* ignore */
}
}
}
}
}
function isThumbInMessages(url: string): boolean {
if (!url) return false
return messages.value.some((m) => (m.attachments || []).some((a) => a.thumbUrl === url))
}
async function fetchAttachmentThumb(relativePath: string): Promise<string | undefined> {
try {
const res = await api.get('/api/v1/uploads/preview/file', {
params: { file_path: relativePath },
responseType: 'blob',
skipErrorHandler: true
})
return URL.createObjectURL(res.data as Blob)
} catch {
return undefined
}
}
async function hydrateMessageAttachmentThumbs(target: Message[]) {
for (const m of target) {
for (const a of m.attachments || []) {
if (!a.isImage || a.thumbUrl || !a.relative_path) continue
const url = await fetchAttachmentThumb(a.relative_path)
if (url) a.thumbUrl = url
}
}
}
function openImagePreview(a: MessageAttachment) {
if (!a.thumbUrl) return
imagePreviewSrc.value = a.thumbUrl
imagePreviewTitle.value = a.filename
imagePreviewVisible.value = true
}
function removeAttachment(index: number) {
const cur = pendingAttachments.value[index]
if (cur?.thumbUrl) {
@@ -552,18 +704,15 @@ function readLocalTextPreview(file: File): Promise<string | null> {
})
}
async function onFileInputChange(ev: Event) {
const input = ev.target as HTMLInputElement
const files = input.files
input.value = ''
if (!files?.length) return
/** 与回形针选择、拖放共用:上传到预览区并加入待发送列表 */
async function uploadPreviewFiles(fileList: File[]) {
if (!fileList.length) return
if (!props.agentId) {
ElMessage.warning('请先选中要预览的智能体后再上传附件')
return
}
const uploadedNames: string[] = []
for (let i = 0; i < files.length; i++) {
const file = files[i]
for (const file of fileList) {
const fd = new FormData()
fd.append('file', file)
try {
@@ -582,6 +731,7 @@ async function onFileInputChange(ev: Event) {
relative_path: d.relative_path,
filename: d.filename || file.name,
size: d.size,
content_type: (res.data as any)?.content_type,
thumbUrl,
previewText,
previewNote: previewText
@@ -614,6 +764,56 @@ async function onFileInputChange(ev: Event) {
}
}
async function onFileInputChange(ev: Event) {
const input = ev.target as HTMLInputElement
const files = input.files
input.value = ''
if (!files?.length) return
await uploadPreviewFiles(Array.from(files))
}
function onInputDragEnter(e: DragEvent) {
if (!props.agentId || loading.value) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
inputDragOver.value = true
}
function onInputDragLeave(e: DragEvent) {
e.preventDefault()
const el = e.currentTarget as HTMLElement
const rel = e.relatedTarget as Node | null
if (rel && el.contains(rel)) return
inputDragOver.value = false
}
function onInputDragOver(e: DragEvent) {
if (!props.agentId || loading.value) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
async function onInputDrop(e: DragEvent) {
e.preventDefault()
inputDragOver.value = false
if (!props.agentId || loading.value) {
if (!props.agentId) ElMessage.warning('请先选中要预览的智能体后再拖入图片')
return
}
const dt = e.dataTransfer
if (!dt?.files?.length) return
const list = Array.from(dt.files)
const images = list.filter((f) => isImageFile(f))
if (images.length === 0) {
ElMessage.warning('请拖入图片文件(如 png、jpg、jpeg、webp、gif 等)')
return
}
if (images.length < list.length) {
ElMessage.info('已忽略非图片文件,仅添加图片附件')
}
await uploadPreviewFiles(images)
}
// 发送消息
const handleSendMessage = async () => {
if ((!inputMessage.value.trim() && pendingAttachments.value.length === 0) || loading.value || !props.agentId)
@@ -647,10 +847,21 @@ const handleSendMessage = async () => {
}
// 添加用户消息
const userAttachments: MessageAttachment[] = attachSnap.map((a) => ({
relative_path: a.relative_path,
filename: a.filename,
thumbUrl: a.thumbUrl,
content_type: a.content_type,
isImage:
!!a.thumbUrl ||
Boolean(a.content_type?.startsWith('image/')) ||
/\.(png|jpe?g|gif|webp|bmp|tiff?)$/i.test(a.filename)
}))
messages.value.push({
role: 'user',
content: userBubble || '(附件)',
timestamp: Date.now()
timestamp: Date.now(),
attachments: userAttachments.length ? userAttachments : undefined
})
// 滚动到底部
@@ -679,7 +890,10 @@ const handleSendMessage = async () => {
const checkStatus = async () => {
const finishBlobs = () => {
if (blobsPendingRevokeAfterRun.value) {
revokeAttachmentBlobs(blobsPendingRevokeAfterRun.value)
const revokable = blobsPendingRevokeAfterRun.value.filter(
(x) => !x.thumbUrl || !isThumbInMessages(x.thumbUrl)
)
revokeAttachmentBlobs(revokable)
blobsPendingRevokeAfterRun.value = null
}
}
@@ -849,6 +1063,7 @@ const handlePresetQuestion = (question: string) => {
// 清空对话
const handleClearChat = () => {
revokeAttachmentBlobs([...pendingAttachments.value])
revokeMessageAttachmentBlobs(messages.value)
pendingAttachments.value = []
if (blobsPendingRevokeAfterRun.value) {
revokeAttachmentBlobs(blobsPendingRevokeAfterRun.value)
@@ -996,6 +1211,7 @@ onUnmounted(() => {
pollingInterval = null
}
revokeAttachmentBlobs([...pendingAttachments.value])
revokeMessageAttachmentBlobs(messages.value)
if (blobsPendingRevokeAfterRun.value) {
revokeAttachmentBlobs(blobsPendingRevokeAfterRun.value)
blobsPendingRevokeAfterRun.value = null
@@ -1096,6 +1312,76 @@ onUnmounted(() => {
padding: 0 4px;
}
.message-attachments {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
max-width: min(70%, 420px);
gap: 8px;
margin-top: 6px;
padding-bottom: 2px;
}
.message-attachments.is-user {
justify-content: flex-end;
}
.message-attachments.is-agent {
justify-content: flex-start;
}
.message-attachment-item {
max-width: 220px;
flex: 0 0 auto;
}
.message-attachment-image {
width: 120px;
height: 120px;
object-fit: cover;
border-radius: 8px;
border: 1px solid #e4e7ed;
background: #fff;
display: block;
cursor: zoom-in;
}
.message-attachment-file {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border: 1px solid #e4e7ed;
border-radius: 8px;
font-size: 12px;
background: #fff;
color: #606266;
max-width: 220px;
}
.message-attachment-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-preview-dialog-body {
display: flex;
justify-content: center;
align-items: center;
min-height: 240px;
}
.image-preview-dialog-img {
max-width: 100%;
max-height: 72vh;
object-fit: contain;
border-radius: 10px;
border: 1px solid #ebeef5;
background: #fff;
}
.preset-questions {
display: flex;
flex-direction: column;
@@ -1167,6 +1453,13 @@ onUnmounted(() => {
background: white;
border-top: 1px solid #e4e7ed;
padding: 12px;
border-radius: 0;
transition: background-color 0.15s ease, box-shadow 0.15s ease;
}
.chat-input-area.is-drag-over {
background: #ecf5ff;
box-shadow: inset 0 0 0 2px #409eff;
}
.input-toolbar {