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

@@ -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再发停止信号。