新增: F-05多模态Phase2b前端(图片粘贴/拖拽+渲染+parts透传·保留虚拟滚动)

This commit is contained in:
2026-06-17 03:17:30 +08:00
parent e3cd44802a
commit e1d396dbf2
7 changed files with 328 additions and 19 deletions

View File

@@ -410,6 +410,20 @@
</div>
<div class="ai-msg-bubble ai-msg-bubble--user">
{{ item.msg.content }}
<!-- F-260614-05 Phase 2b: 多模态图片渲染(随消息气泡挂载/卸载,虚拟滚动裁剪的是
整条消息,气泡内 <img> sentinel 卸载无冲突) -->
<template v-if="msgImageParts(item.msg).length">
<div class="ai-msg-images">
<img
v-for="(img, i) in msgImageParts(item.msg)"
:key="(item.msg.id) + '-img-' + i"
:src="imageSrc(img)"
:alt="imgAlt(img)"
class="ai-msg-image"
loading="lazy"
/>
</div>
</template>
<button
class="ai-copy-btn"
:title="$t('aiChat.copyMsg')"
@@ -645,6 +659,19 @@
<button class="ai-skill-chip-x" @click="clearSkill" :title="$t('aiChat.clearSkill')" :aria-label="$t('aiChat.clearSkill')">×</button>
</div>
<div class="ai-input-wrap">
<!-- F-260614-05 Phase 2b: 待发送图片预览(粘贴/拖拽加入,发送前可移除) -->
<div v-if="pendingImages.length" class="ai-img-preview-row">
<div v-for="img in pendingImages" :key="img.id" class="ai-img-preview">
<img :src="img.dataUrl" :alt="$t('aiChat.imagePreviewAlt')" class="ai-img-preview-thumb" />
<button
type="button"
class="ai-img-preview-x"
:title="$t('aiChat.removeImage')"
:aria-label="$t('aiChat.removeImage')"
@click="removePendingImage(img.id)"
>×</button>
</div>
</div>
<textarea
ref="inputEl"
v-model="inputText"
@@ -656,6 +683,9 @@
rows="1"
@keydown="handleKeydown"
@input="autoResize"
@paste="onPaste"
@dragover="onDragOver"
@drop="onDrop"
></textarea>
<!-- 技能联想浮层 -->
<div v-if="skillOpen" class="ai-skill-popover">
@@ -714,8 +744,8 @@
<button
v-else
class="ai-send-btn"
:class="{ 'ai-send-btn--active': inputText.trim() }"
:disabled="!inputText.trim()"
:class="{ 'ai-send-btn--active': canSend }"
:disabled="!canSend"
@click="handleSend"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
@@ -748,7 +778,7 @@ import { useMarkdown } from '../composables/useMarkdown'
import ToolCardList from './ToolCardList.vue'
import { formatRelativeZh, formatDate } from '../utils/time'
import { useProjectStore } from '../stores/project'
import type { AiConversationSummary, AiMessage, SkillInfo, ProjectRecord, TaskRecord } from '../api/types'
import type { AiConversationSummary, AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord } from '../api/types'
const props = defineProps<{
detached?: boolean
@@ -950,6 +980,125 @@ let _unlistenAutoApproved: (() => void) | null = null
const inputText = ref('')
const inputEl = ref<HTMLTextAreaElement>()
// ── F-260614-05 Phase 2b: 图片输入(粘贴/拖拽) ──
// 待发送图片列表(base64 data URI + media_type),handleSend 时构造 ContentPart[]。
// 仅本组件级 ref,不入 store;发送后清空。预览缩略图渲染在输入框上方。
//
// 约束:
// - 限制单次最多 6 张(防超长上下文/超限请求);超过 toast 提示。
// - 限制单张 10MB(纯文本 base64 估算 ≥ 13M 字符;超限 toast 提示跳过)。
// - 仅接受 image/* MIME;非图片(文本粘贴/文件拖入)走原生行为不拦。
const MAX_PENDING_IMAGES = 6
const MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10MB(解码后字节上限)
const pendingImages = ref<{ id: string; dataUrl: string; mediaType: string }[]>([])
/** 读 File → base64 data URL(供 <img> 预览 + ContentPart.image base64 字段) */
function readImageFile(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(reader.error ?? new Error('read failed'))
reader.readAsDataURL(file)
})
}
/** 把 image File 加入 pendingImages;超限/超大小跳过并 toast */
async function addImageFile(file: File): Promise<void> {
if (!file.type.startsWith('image/')) return
if (file.size > MAX_IMAGE_BYTES) {
showToast(t('aiChat.imageTooLarge'), 'warning')
return
}
if (pendingImages.value.length >= MAX_PENDING_IMAGES) {
showToast(t('aiChat.imageLimit', { n: MAX_PENDING_IMAGES }), 'warning')
return
}
try {
const dataUrl = await readImageFile(file)
pendingImages.value.push({
// id 用 timestamp+random,DOM key 唯一,移除按钮定位
id: `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
dataUrl,
mediaType: file.type,
})
} catch (e) {
showToast(t('aiChat.imageReadFailed'), 'error')
console.error('[AI] 图片读取失败:', e)
}
}
/** 移除待发送图片 */
function removePendingImage(id: string): void {
pendingImages.value = pendingImages.value.filter(p => p.id !== id)
}
/** 粘贴:ClipboardEvent 检测 image 类型条目,加入待发送列表(不插入文本占位) */
async function onPaste(e: ClipboardEvent): Promise<void> {
const items = e.clipboardData?.items
if (!items) return
const files: File[] = []
for (let i = 0; i < items.length; i++) {
const it = items[i]
if (it.kind === 'file' && it.type.startsWith('image/')) {
const f = it.getAsFile()
if (f) files.push(f)
}
}
if (files.length === 0) return
// 含图片:阻止默认粘贴(避免把图片的二进制当文本塞进 textarea),逐个加入
e.preventDefault()
for (const f of files) await addImageFile(f)
}
/** 拖拽进入:仅允许图片,preventDefault 让 drop 事件触发 */
function onDragOver(e: DragEvent): void {
if (!e.dataTransfer) return
// 判断当前是否含 image/* 类型(copy/move 光标提示)
const hasImage = Array.from(e.dataTransfer.types).includes('Files')
if (hasImage) {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
}
}
/** 拖拽放下:取出图片 File,加入待发送 */
async function onDrop(e: DragEvent): Promise<void> {
const files = e.dataTransfer?.files
if (!files || files.length === 0) return
// 仅处理图片文件(非图片拖入走原生不拦,但这里 preventDefault 已无副作用)
const images = Array.from(files).filter(f => f.type.startsWith('image/'))
if (images.length === 0) return
e.preventDefault()
for (const f of images) await addImageFile(f)
}
/** 发送可用:有文本或图片或已选技能(纯技能调用允许空文本) */
const canSend = computed(() => inputText.value.trim().length > 0 || pendingImages.value.length > 0 || !!pendingSkill.value)
/** ContentPart 图片片类型(discriminator type==='image' 的联合成员) */
type ImageContentPart = Extract<ContentPart, { type: 'image' }>
/** 取消息的图片 ContentPart 片(仅 type==='image');空数组=无图 */
function msgImageParts(msg: AiMessage): ImageContentPart[] {
if (!msg.parts) return []
return msg.parts.filter((p): p is ImageContentPart => p.type === 'image')
}
/** ContentPart.image → <img src>:base64 模式拼 data URI,url 模式原样 */
function imageSrc(img: ImageContentPart): string {
if (img.base64) {
// media_type 缺失兜底 image/png(provider 转发端点需完整 data URI)
const mt = img.media_type || 'image/png'
return `data:${mt};base64,${img.base64}`
}
return img.url || ''
}
/** ContentPart.image → alt 文案 */
function imgAlt(img: ImageContentPart): string {
return img.alt || t('aiChat.imagePreviewAlt')
}
// UX-09: 编辑末条 user 消息态。editingMsgId 有值时 handleSend 走 editMessage 而非 sendMessage。
const editingMsgId = ref<string | null>(null)
const messagesContainer = ref<HTMLDivElement>()
@@ -1673,12 +1822,37 @@ async function handleSend() {
return
}
const skill = pendingSkill.value
// 无文本未选技能时忽略;选了技能允许空文本(纯技能调用)
if (!text && !skill) return
// 无文本、无图片、未选技能时忽略;选了技能允许空文本(纯技能调用)
if (!text && !skill && pendingImages.value.length === 0) return
// F-260614-05 Phase 2b: 快照待发送图片,构造 ContentPart[]([Text?, Image...])。
// 后端 ai_chat_send IPC 当前不接 parts(Phase 2c 接入);本轮 parts 仅写本地 push 的
// user 消息渲染多模态图,后端请求仍走 content 文本路径。
const snapshotImgs = pendingImages.value.slice()
const parts: ContentPart[] | undefined = snapshotImgs.length > 0
? [
// 文本片(非空时作首片,便于后端 2c 接入后 content 与 parts 文本一致;
// 空文本则只发图片片)
...(text ? [{ type: 'text' as const, text }] : []),
...snapshotImgs.map(img => {
// dataUrl 形如 data:image/png;base64,xxxx —— 拆出纯 base64 + media_type
const m = /^data:([^;]+);base64,(.*)$/s.exec(img.dataUrl)
const mediaType = m?.[1] || img.mediaType
const base64 = m?.[2] || ''
return {
type: 'image' as const,
url: null,
base64,
media_type: mediaType,
alt: null,
}
}),
]
: undefined
// 生成中不再拦截sendMessage 会把消息排入待发送队列,完成后自动续发
// 先清输入(无论立即发还是入队);队列满/IPC 失败时 catch 回填
inputText.value = ''
pendingSkill.value = null
pendingImages.value = []
skillOpen.value = false
mentionOpen.value = false
mentionStart.value = -1
@@ -1686,11 +1860,15 @@ async function handleSend() {
// 同步清空输入框后立即发送sendMessage 内部同步 push 用户消息,
// 清空与 push 合并到同一渲染周期 flush消除"输入框已空但消息未显示"的间隙
try {
await store.sendMessage(text, skill?.name)
await store.sendMessage(text, skill?.name, false, parts)
} catch (e) {
console.error('[AI] 发送失败:', e)
inputText.value = text
pendingSkill.value = skill
// 发送失败:回填图片供重试(快照原样还原)
if (parts) {
pendingImages.value = snapshotImgs
}
// 用户可见提示(原仅 console.error,用户无感);Tauri IPC 错误是字符串非 Error 对象
const msg = e instanceof Error ? e.message : String(e)
showToast(t('aiChat.toastSendFail', { msg }), 'error')
@@ -3354,6 +3532,8 @@ body.ai-sidebar-resizing * {
.ai-input-wrap {
position: relative;
display: flex;
/* F-260614-05 Phase 2b: flex-wrap 让全宽的图片预览行强制 textarea+按钮换行到下一行 */
flex-wrap: wrap;
align-items: center;
gap: 6px;
background: var(--df-bg-card);
@@ -3362,6 +3542,72 @@ body.ai-sidebar-resizing * {
padding: 6px 8px;
transition: border-color 0.15s;
}
/* F-260614-05 Phase 2b: 待发送图片预览行(粘贴/拖拽加入,发送前可移除) */
.ai-img-preview-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
width: 100%; /* 全宽强制后续 textarea+按钮换行 */
padding-bottom: 4px;
border-bottom: 0.5px solid var(--df-border);
}
.ai-img-preview {
position: relative;
width: 56px;
height: 56px;
border-radius: var(--df-radius);
overflow: hidden;
border: 0.5px solid var(--df-border);
background: var(--df-bg);
}
.ai-img-preview-thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.ai-img-preview-x {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
background: color-mix(in srgb, var(--df-bg) 80%, transparent);
color: var(--df-text-dim);
font-size: 14px;
line-height: 1;
cursor: pointer;
padding: 0;
transition: background 0.15s, color 0.15s;
}
.ai-img-preview-x:hover {
background: var(--df-danger);
color: #fff;
}
/* 用户气泡内的图片(多模态消息渲染) */
.ai-msg-images {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.ai-msg-image {
max-width: 200px;
max-height: 200px;
width: auto;
height: auto;
border-radius: var(--df-radius);
object-fit: contain;
display: block;
background: var(--df-bg);
}
.ai-input-wrap:focus-within {
border-color: color-mix(in srgb, var(--df-accent) 40%, transparent);
}