图片上传识别功能
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user