新增: 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

@@ -278,6 +278,25 @@ export type AiChatEvent = ({
conversation_id?: string
}
/**
* 多模态消息内容片(F-260614-05 Phase 2b 前端类型,对齐后端 df-ai-core ContentPart)。
*
* 后端 serde:`#[serde(tag = "type", rename_all = "snake_case")]`,
* 前端 discriminator 用字面量 `type` 字段('text' | 'image'),与后端 wire 格式严格对齐。
* Image 片:url / base64 二选一(base64 非空时 url 忽略),media_type 仅 base64 模式必填。
*/
export type ContentPart =
| { type: 'text'; text: string }
| {
type: 'image'
url?: string | null
base64?: string | null
/** base64 模式必填(image/png|jpeg|webp|gif);url 模式可空 */
media_type?: string | null
/** 可选 alt(vision 模型/降级文本时用) */
alt?: string | null
}
/** AI 消息前端渲染用isError 标记错误消息用于差异化样式) */
export interface AiMessage {
id: string
@@ -287,6 +306,13 @@ export interface AiMessage {
toolCalls?: AiToolCallInfo[]
/** 生成该消息的 model(仅 assistant 消息,历史消息从 DB 读) */
model?: string
/**
* 多模态内容片(F-260614-05 Phase 2b)。
* undefined/空=纯文本消息(content 即全部载荷);非空含 Image 片=多模态消息,
* 用户气泡内渲染 <img>(base64 data URI 或 url)。仅 user 消息有(parts 来自粘贴/拖拽输入)。
* 历史消息从 DB JSON 反序列化时透传(useAiConversations switchConversation)。
*/
parts?: ContentPart[]
timestamp: number
}

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);
}

View File

@@ -83,6 +83,11 @@ export async function switchConversation(id: string) {
content: m.content || '',
model: m.model,
timestamp: Date.now(),
// F-260614-05 Phase 2b: 透传 parts(多模态 Image 片)。后端序列化的 ContentPart[]
// 含 type:'text'|'image' discriminator + url/base64/media_type/alt 字段,
// 此处原样透传供 AiChat.vue 用户气泡渲染 <img>(base64 模式持久化层已替换占位 Text 片,
// 故历史 parts 通常仅含 url 模式 Image 或纯 Text)。
parts: Array.isArray(m.parts) && m.parts.length > 0 ? m.parts : undefined,
// F-15 阶段2: 透传 status(archived_segment/compressed/null|active),
// 供 AiChat.vue 按 status 折叠分组渲染。types.ts 未含此字段(不在本任务白名单),
// 经 as any 透传,消费方 AiChat.vue 同样 cast 读取,类型闭环在两端。

View File

@@ -21,6 +21,7 @@ import { ref } from 'vue'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import type { ContentPart } from '@/api/types'
import i18n from '@/i18n'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
import { startListener } from './useAiEvents'
@@ -63,18 +64,27 @@ const _pendingApprovalIds = new Set<string>()
/**
* 入队时记录时间戳,供 UI 展示排队时长和触发超时提示。
* queue item 扩展: 原 { text, skill? } → { text, skill?, enqueuedAt }
* 兼容 drainQueue/cancelQueued/clearQueue 等已有消费方(enqueuedAt 仅读不写)。
* queue item 扩展: 原 { text, skill? } → { text, skill?, enqueuedAt, parts? }
* 兼容 drainQueue/cancelQueued/clearQueue 等已有消费方(enqueuedAt/parts 仅读不写)。
*/
// ── 内核发送:推送气泡+调 IPC,被 normal/force 两条路径共用 ──
async function doSend(text: string, skill?: string, force = false) {
/**
* F-260614-05 Phase 2b: parts 仅影响本地 push 的 user 消息(渲染多模态图)。
*
* 后端 ai_chat_send IPC 当前不接 parts(Phase 2c 接入);本轮 doSend 仅把 parts 写入本地 push 的
* user 消息让用户气泡可见图,后端请求仍走 content 文本路径(无图)。
* Phase 2c 后端接 parts 后,本函数透传给 aiApi.sendMessage/forceSend 即可全链路生效。
*/
async function doSend(text: string, skill?: string, force = false, parts?: ContentPart[]) {
const userMsgId = `user-${nextMsgId()}`
state.messages.push({
id: userMsgId,
role: 'user',
content: text.trim(),
// 仅在非空时挂 parts(纯文本消息保持 undefined,渲染走原 content 路径零回归)
parts: parts && parts.length > 0 ? parts : undefined,
timestamp: Date.now(),
})
@@ -247,7 +257,7 @@ async function editMessage(newMessage: string) {
export function drainQueue() {
if (state.queue.length === 0) return
const next = state.queue.shift()!
void sendMessage(next.text, next.skill)
void sendMessage(next.text, next.skill, false, next.parts)
}
/**
@@ -260,12 +270,12 @@ export function drainQueue() {
*
* forceMode=true 时跳过入队,直接走 ai_chat_force_send。
*/
async function sendMessage(text: string, skill?: string, forceMode = false) {
if (!text.trim()) return
async function sendMessage(text: string, skill?: string, forceMode = false, parts?: ContentPart[]) {
if (!text.trim() && !(parts && parts.length)) return
// ── L2 强制模式:跳过所有预检,直接 force_send ──
if (forceMode) {
await doSend(text, skill, true)
await doSend(text, skill, true, parts)
return
}
@@ -283,12 +293,18 @@ async function sendMessage(text: string, skill?: string, forceMode = false) {
throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT }))
}
state.streaming = true
state.queue.push({ text: text.trim(), skill: skill || undefined, enqueuedAt: Date.now() })
// F-260614-05 Phase 2b: 入队项挂 parts(供 drainQueue 续发时本地 user 消息渲染图)
state.queue.push({
text: text.trim(),
skill: skill || undefined,
enqueuedAt: Date.now(),
parts: parts && parts.length > 0 ? parts : undefined,
})
return
}
// ── L0:normal 直接发送 ──
await doSend(text, skill, false)
await doSend(text, skill, false, parts)
}
/** 排队是否已超时(任一条超即算,因队首阻塞导致后续全堵) */
@@ -309,7 +325,7 @@ export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>)
// 此处无需前置 false(前置 false 会触发 AiChat watch 清流式块再重建,产生瞬态抖动)
clearStreamWatchdog()
try {
await sendMessage(first.text, first.skill, true)
await sendMessage(first.text, first.skill, true, first.parts)
return true
} catch {
// force_send 也失败:消息不回队列(避免循环),让用户看到错误
@@ -407,7 +423,7 @@ async function sendQueuedNow(index: number) {
if (index < 0 || index >= state.queue.length) return
const spliced = state.queue.splice(index, 1)[0]
await stopChat()
await sendMessage(spliced.text, spliced.skill)
await sendMessage(spliced.text, spliced.skill, false, spliced.parts)
}
/** 停止当前生成:本地先复位 streaming再发停止信号。

View File

@@ -98,6 +98,13 @@ export default {
// ── Input hint ──
inputHint: 'Enter to send · Shift+Enter for newline',
// ── F-260614-05 Phase 2b: image input (paste / drag-drop) ──
removeImage: 'Remove image',
imagePreviewAlt: 'Pending image',
imageTooLarge: 'Image too large (max 10MB), skipped',
imageLimit: 'Up to {n} images, extras ignored',
imageReadFailed: 'Failed to read image',
// ── Confirm dialog ──
confirmDeleteConversation: 'Delete chat "{title}"? This cannot be undone.',
clearChat: 'Clear',

View File

@@ -98,6 +98,14 @@ export default {
// ── 输入提示 ──
inputHint: 'Enter 发送 · Shift+Enter 换行',
// ── F-260614-05 Phase 2b: 图片输入(粘贴/拖拽) ──
// 粘贴/拖拽图片到输入框,发送时构造多模态 ContentPart[] 渲染到用户气泡
removeImage: '移除图片',
imagePreviewAlt: '待发送图片',
imageTooLarge: '图片过大(上限 10MB已跳过',
imageLimit: '最多 {n} 张图片,已忽略多余的',
imageReadFailed: '图片读取失败',
// ── 确认弹层 ──
confirmDeleteConversation: '确定删除对话「{title}」?该操作不可恢复。',
clearChat: '清空',

View File

@@ -20,7 +20,7 @@
//! - state 为模块级单例,全应用共享同一引用
import { computed, reactive, watch } from 'vue'
import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, SkillInfo } from '@/api/types'
import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, ContentPart, SkillInfo } from '@/api/types'
/**
* 单对话 messages 软上限(滚动淘汰)。
@@ -68,7 +68,8 @@ export const state = reactive({
// 本机技能(`/` 联想)
skills: [] as SkillInfo[],
// 待发送队列生成中发的消息排队AiCompleted 后自动续发enqueuedAt 记录入队时间供超时检测)
queue: [] as { text: string; skill?: string; enqueuedAt: number }[],
// F-260614-05 Phase 2b: parts 挂多模态图片片(供 drainQueue/force 续发时本地 user 消息渲染图)
queue: [] as { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[] }[],
// Agentic 循环当前轮次(0=非agentic/未开始;>0=收到后端 AiAgentRound 事件的 round)
// AE-2025-07:进度条用; AiCompleted/AiError 复位为 0
agentRound: 0,