新增: F-05多模态Phase2b前端(图片粘贴/拖拽+渲染+parts透传·保留虚拟滚动)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user