新增: F-05多模态Phase2b前端(图片粘贴/拖拽+渲染+parts透传·保留虚拟滚动)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 读取,类型闭环在两端。
|
||||
|
||||
@@ -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,再发停止信号。
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '清空',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user