新增: F-05多模态Phase2b前端(图片粘贴/拖拽+渲染+parts透传·保留虚拟滚动)
This commit is contained in:
@@ -278,6 +278,25 @@ export type AiChatEvent = ({
|
|||||||
conversation_id?: string
|
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 标记错误消息用于差异化样式) */
|
/** AI 消息(前端渲染用,isError 标记错误消息用于差异化样式) */
|
||||||
export interface AiMessage {
|
export interface AiMessage {
|
||||||
id: string
|
id: string
|
||||||
@@ -287,6 +306,13 @@ export interface AiMessage {
|
|||||||
toolCalls?: AiToolCallInfo[]
|
toolCalls?: AiToolCallInfo[]
|
||||||
/** 生成该消息的 model(仅 assistant 消息,历史消息从 DB 读) */
|
/** 生成该消息的 model(仅 assistant 消息,历史消息从 DB 读) */
|
||||||
model?: string
|
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
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -410,6 +410,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ai-msg-bubble ai-msg-bubble--user">
|
<div class="ai-msg-bubble ai-msg-bubble--user">
|
||||||
{{ item.msg.content }}
|
{{ 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
|
<button
|
||||||
class="ai-copy-btn"
|
class="ai-copy-btn"
|
||||||
:title="$t('aiChat.copyMsg')"
|
: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>
|
<button class="ai-skill-chip-x" @click="clearSkill" :title="$t('aiChat.clearSkill')" :aria-label="$t('aiChat.clearSkill')">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-input-wrap">
|
<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
|
<textarea
|
||||||
ref="inputEl"
|
ref="inputEl"
|
||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
@@ -656,6 +683,9 @@
|
|||||||
rows="1"
|
rows="1"
|
||||||
@keydown="handleKeydown"
|
@keydown="handleKeydown"
|
||||||
@input="autoResize"
|
@input="autoResize"
|
||||||
|
@paste="onPaste"
|
||||||
|
@dragover="onDragOver"
|
||||||
|
@drop="onDrop"
|
||||||
></textarea>
|
></textarea>
|
||||||
<!-- 技能联想浮层 -->
|
<!-- 技能联想浮层 -->
|
||||||
<div v-if="skillOpen" class="ai-skill-popover">
|
<div v-if="skillOpen" class="ai-skill-popover">
|
||||||
@@ -714,8 +744,8 @@
|
|||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
class="ai-send-btn"
|
class="ai-send-btn"
|
||||||
:class="{ 'ai-send-btn--active': inputText.trim() }"
|
:class="{ 'ai-send-btn--active': canSend }"
|
||||||
:disabled="!inputText.trim()"
|
:disabled="!canSend"
|
||||||
@click="handleSend"
|
@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>
|
<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 ToolCardList from './ToolCardList.vue'
|
||||||
import { formatRelativeZh, formatDate } from '../utils/time'
|
import { formatRelativeZh, formatDate } from '../utils/time'
|
||||||
import { useProjectStore } from '../stores/project'
|
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<{
|
const props = defineProps<{
|
||||||
detached?: boolean
|
detached?: boolean
|
||||||
@@ -950,6 +980,125 @@ let _unlistenAutoApproved: (() => void) | null = null
|
|||||||
|
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const inputEl = ref<HTMLTextAreaElement>()
|
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。
|
// UX-09: 编辑末条 user 消息态。editingMsgId 有值时 handleSend 走 editMessage 而非 sendMessage。
|
||||||
const editingMsgId = ref<string | null>(null)
|
const editingMsgId = ref<string | null>(null)
|
||||||
const messagesContainer = ref<HTMLDivElement>()
|
const messagesContainer = ref<HTMLDivElement>()
|
||||||
@@ -1673,12 +1822,37 @@ async function handleSend() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const skill = pendingSkill.value
|
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 会把消息排入待发送队列,完成后自动续发
|
// 生成中不再拦截:sendMessage 会把消息排入待发送队列,完成后自动续发
|
||||||
// 先清输入(无论立即发还是入队);队列满/IPC 失败时 catch 回填
|
// 先清输入(无论立即发还是入队);队列满/IPC 失败时 catch 回填
|
||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
pendingSkill.value = null
|
pendingSkill.value = null
|
||||||
|
pendingImages.value = []
|
||||||
skillOpen.value = false
|
skillOpen.value = false
|
||||||
mentionOpen.value = false
|
mentionOpen.value = false
|
||||||
mentionStart.value = -1
|
mentionStart.value = -1
|
||||||
@@ -1686,11 +1860,15 @@ async function handleSend() {
|
|||||||
// 同步清空输入框后立即发送:sendMessage 内部同步 push 用户消息,
|
// 同步清空输入框后立即发送:sendMessage 内部同步 push 用户消息,
|
||||||
// 清空与 push 合并到同一渲染周期 flush,消除"输入框已空但消息未显示"的间隙
|
// 清空与 push 合并到同一渲染周期 flush,消除"输入框已空但消息未显示"的间隙
|
||||||
try {
|
try {
|
||||||
await store.sendMessage(text, skill?.name)
|
await store.sendMessage(text, skill?.name, false, parts)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[AI] 发送失败:', e)
|
console.error('[AI] 发送失败:', e)
|
||||||
inputText.value = text
|
inputText.value = text
|
||||||
pendingSkill.value = skill
|
pendingSkill.value = skill
|
||||||
|
// 发送失败:回填图片供重试(快照原样还原)
|
||||||
|
if (parts) {
|
||||||
|
pendingImages.value = snapshotImgs
|
||||||
|
}
|
||||||
// 用户可见提示(原仅 console.error,用户无感);Tauri IPC 错误是字符串非 Error 对象
|
// 用户可见提示(原仅 console.error,用户无感);Tauri IPC 错误是字符串非 Error 对象
|
||||||
const msg = e instanceof Error ? e.message : String(e)
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
showToast(t('aiChat.toastSendFail', { msg }), 'error')
|
showToast(t('aiChat.toastSendFail', { msg }), 'error')
|
||||||
@@ -3354,6 +3532,8 @@ body.ai-sidebar-resizing * {
|
|||||||
.ai-input-wrap {
|
.ai-input-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
/* F-260614-05 Phase 2b: flex-wrap 让全宽的图片预览行强制 textarea+按钮换行到下一行 */
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
background: var(--df-bg-card);
|
background: var(--df-bg-card);
|
||||||
@@ -3362,6 +3542,72 @@ body.ai-sidebar-resizing * {
|
|||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
transition: border-color 0.15s;
|
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 {
|
.ai-input-wrap:focus-within {
|
||||||
border-color: color-mix(in srgb, var(--df-accent) 40%, transparent);
|
border-color: color-mix(in srgb, var(--df-accent) 40%, transparent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ export async function switchConversation(id: string) {
|
|||||||
content: m.content || '',
|
content: m.content || '',
|
||||||
model: m.model,
|
model: m.model,
|
||||||
timestamp: Date.now(),
|
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),
|
// F-15 阶段2: 透传 status(archived_segment/compressed/null|active),
|
||||||
// 供 AiChat.vue 按 status 折叠分组渲染。types.ts 未含此字段(不在本任务白名单),
|
// 供 AiChat.vue 按 status 折叠分组渲染。types.ts 未含此字段(不在本任务白名单),
|
||||||
// 经 as any 透传,消费方 AiChat.vue 同样 cast 读取,类型闭环在两端。
|
// 经 as any 透传,消费方 AiChat.vue 同样 cast 读取,类型闭环在两端。
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { ref } from 'vue'
|
|||||||
import { aiApi } from '@/api'
|
import { aiApi } from '@/api'
|
||||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
import { state } from '@/stores/ai'
|
import { state } from '@/stores/ai'
|
||||||
|
import type { ContentPart } from '@/api/types'
|
||||||
import i18n from '@/i18n'
|
import i18n from '@/i18n'
|
||||||
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
||||||
import { startListener } from './useAiEvents'
|
import { startListener } from './useAiEvents'
|
||||||
@@ -63,18 +64,27 @@ const _pendingApprovalIds = new Set<string>()
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 入队时记录时间戳,供 UI 展示排队时长和触发超时提示。
|
* 入队时记录时间戳,供 UI 展示排队时长和触发超时提示。
|
||||||
* queue item 扩展: 原 { text, skill? } → { text, skill?, enqueuedAt }
|
* queue item 扩展: 原 { text, skill? } → { text, skill?, enqueuedAt, parts? }
|
||||||
* 兼容 drainQueue/cancelQueued/clearQueue 等已有消费方(enqueuedAt 仅读不写)。
|
* 兼容 drainQueue/cancelQueued/clearQueue 等已有消费方(enqueuedAt/parts 仅读不写)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ── 内核发送:推送气泡+调 IPC,被 normal/force 两条路径共用 ──
|
// ── 内核发送:推送气泡+调 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()}`
|
const userMsgId = `user-${nextMsgId()}`
|
||||||
state.messages.push({
|
state.messages.push({
|
||||||
id: userMsgId,
|
id: userMsgId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: text.trim(),
|
content: text.trim(),
|
||||||
|
// 仅在非空时挂 parts(纯文本消息保持 undefined,渲染走原 content 路径零回归)
|
||||||
|
parts: parts && parts.length > 0 ? parts : undefined,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -247,7 +257,7 @@ async function editMessage(newMessage: string) {
|
|||||||
export function drainQueue() {
|
export function drainQueue() {
|
||||||
if (state.queue.length === 0) return
|
if (state.queue.length === 0) return
|
||||||
const next = state.queue.shift()!
|
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。
|
* forceMode=true 时跳过入队,直接走 ai_chat_force_send。
|
||||||
*/
|
*/
|
||||||
async function sendMessage(text: string, skill?: string, forceMode = false) {
|
async function sendMessage(text: string, skill?: string, forceMode = false, parts?: ContentPart[]) {
|
||||||
if (!text.trim()) return
|
if (!text.trim() && !(parts && parts.length)) return
|
||||||
|
|
||||||
// ── L2 强制模式:跳过所有预检,直接 force_send ──
|
// ── L2 强制模式:跳过所有预检,直接 force_send ──
|
||||||
if (forceMode) {
|
if (forceMode) {
|
||||||
await doSend(text, skill, true)
|
await doSend(text, skill, true, parts)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,12 +293,18 @@ async function sendMessage(text: string, skill?: string, forceMode = false) {
|
|||||||
throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT }))
|
throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT }))
|
||||||
}
|
}
|
||||||
state.streaming = true
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── L0:normal 直接发送 ──
|
// ── 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 清流式块再重建,产生瞬态抖动)
|
// 此处无需前置 false(前置 false 会触发 AiChat watch 清流式块再重建,产生瞬态抖动)
|
||||||
clearStreamWatchdog()
|
clearStreamWatchdog()
|
||||||
try {
|
try {
|
||||||
await sendMessage(first.text, first.skill, true)
|
await sendMessage(first.text, first.skill, true, first.parts)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
// force_send 也失败:消息不回队列(避免循环),让用户看到错误
|
// force_send 也失败:消息不回队列(避免循环),让用户看到错误
|
||||||
@@ -407,7 +423,7 @@ async function sendQueuedNow(index: number) {
|
|||||||
if (index < 0 || index >= state.queue.length) return
|
if (index < 0 || index >= state.queue.length) return
|
||||||
const spliced = state.queue.splice(index, 1)[0]
|
const spliced = state.queue.splice(index, 1)[0]
|
||||||
await stopChat()
|
await stopChat()
|
||||||
await sendMessage(spliced.text, spliced.skill)
|
await sendMessage(spliced.text, spliced.skill, false, spliced.parts)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 停止当前生成:本地先复位 streaming,再发停止信号。
|
/** 停止当前生成:本地先复位 streaming,再发停止信号。
|
||||||
|
|||||||
@@ -98,6 +98,13 @@ export default {
|
|||||||
// ── Input hint ──
|
// ── Input hint ──
|
||||||
inputHint: 'Enter to send · Shift+Enter for newline',
|
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 ──
|
// ── Confirm dialog ──
|
||||||
confirmDeleteConversation: 'Delete chat "{title}"? This cannot be undone.',
|
confirmDeleteConversation: 'Delete chat "{title}"? This cannot be undone.',
|
||||||
clearChat: 'Clear',
|
clearChat: 'Clear',
|
||||||
|
|||||||
@@ -98,6 +98,14 @@ export default {
|
|||||||
// ── 输入提示 ──
|
// ── 输入提示 ──
|
||||||
inputHint: 'Enter 发送 · Shift+Enter 换行',
|
inputHint: 'Enter 发送 · Shift+Enter 换行',
|
||||||
|
|
||||||
|
// ── F-260614-05 Phase 2b: 图片输入(粘贴/拖拽) ──
|
||||||
|
// 粘贴/拖拽图片到输入框,发送时构造多模态 ContentPart[] 渲染到用户气泡
|
||||||
|
removeImage: '移除图片',
|
||||||
|
imagePreviewAlt: '待发送图片',
|
||||||
|
imageTooLarge: '图片过大(上限 10MB),已跳过',
|
||||||
|
imageLimit: '最多 {n} 张图片,已忽略多余的',
|
||||||
|
imageReadFailed: '图片读取失败',
|
||||||
|
|
||||||
// ── 确认弹层 ──
|
// ── 确认弹层 ──
|
||||||
confirmDeleteConversation: '确定删除对话「{title}」?该操作不可恢复。',
|
confirmDeleteConversation: '确定删除对话「{title}」?该操作不可恢复。',
|
||||||
clearChat: '清空',
|
clearChat: '清空',
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
//! - state 为模块级单例,全应用共享同一引用
|
//! - state 为模块级单例,全应用共享同一引用
|
||||||
|
|
||||||
import { computed, reactive, watch } from 'vue'
|
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 软上限(滚动淘汰)。
|
* 单对话 messages 软上限(滚动淘汰)。
|
||||||
@@ -68,7 +68,8 @@ export const state = reactive({
|
|||||||
// 本机技能(`/` 联想)
|
// 本机技能(`/` 联想)
|
||||||
skills: [] as SkillInfo[],
|
skills: [] as SkillInfo[],
|
||||||
// 待发送队列(生成中发的消息排队,AiCompleted 后自动续发;enqueuedAt 记录入队时间供超时检测)
|
// 待发送队列(生成中发的消息排队,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)
|
// Agentic 循环当前轮次(0=非agentic/未开始;>0=收到后端 AiAgentRound 事件的 round)
|
||||||
// AE-2025-07:进度条用; AiCompleted/AiError 复位为 0
|
// AE-2025-07:进度条用; AiCompleted/AiError 复位为 0
|
||||||
agentRound: 0,
|
agentRound: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user