修复: force_send单锁竞态+classify unknown不重试(撤销误判)+UX交互批
This commit is contained in:
@@ -43,7 +43,7 @@
|
||||
'ai-conv-item--active': conv.id === store.state.activeConversationId,
|
||||
'ai-conv-item--archived': conv.archived,
|
||||
}"
|
||||
@click="store.switchConversation(conv.id)"
|
||||
@click="onSelectSearchResult(conv.id)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<span class="ai-conv-item-title" :title="conv.title || ''">{{ conv.title || $t('aiChat.newConversation') }}</span>
|
||||
@@ -397,8 +397,11 @@
|
||||
|
||||
<!-- 消息(正常段 + 展开的折叠段内消息)— 不可见时卸载(sentinel 占位高度),
|
||||
流式末条经 pinnedKeys 保活(shouldRenderMsg 恒 true) -->
|
||||
<!-- UX-260617-22:跳过已完成且无内容且非错误的 AI 消息 ——
|
||||
原模板 ai-md 气泡 v-if 仅 content 非空才渲染,空内容只剩 avatar+空白,渲染无意义;
|
||||
仅当是末条流式(可能 text 刚开始)时不过滤,保留光标气泡占位。 -->
|
||||
<div
|
||||
v-else-if="shouldRenderMsg(item.key)"
|
||||
v-else-if="shouldRenderMsg(item.key) && !(item.msg.role === 'assistant' && !item.msg.content && !item.msg.isError && !(isLastAi(item.msg) && store.state.streaming))"
|
||||
class="ai-msg"
|
||||
:class="'ai-msg--' + item.msg.role"
|
||||
:data-msg-id="item.msg.id"
|
||||
@@ -842,10 +845,20 @@ function splitBlocks(text: string): string[] {
|
||||
|
||||
/// 末块不缓存(可能不完整,下次 delta 变),每次重 parse 处理未闭合 token
|
||||
function parseBlockNoCache(block: string): string {
|
||||
// _marked/_purify 在 loadMarkdown 后才就绪;此处仅在 mdReady 后被流式分支调用
|
||||
// _marked/_purify 在 loadMarkdown 后才就绪;此处仅在 mdReady 后被流式分支调用。
|
||||
// UX-260617-25:防御性 null check —— renderStreamingBlocks 顶部已有 mdReady/getMarked/
|
||||
// getPurify 守卫,正常路径 marked/purify 必非 null。但 marked.use(highlightCode) 注入的
|
||||
// renderer 在动态 import 后仍可能抛异常(hljs 注册竞态/未知语言)→ 此前直接 marked!.parse()
|
||||
// 会让异常冒泡到 rAF 回调中断流式,表现为生成卡死。这里 null 兜底降级 escapeFallback,
|
||||
// parse 抛错时 try/catch 同样降级,保证流式不中断。
|
||||
const marked = getMarked()
|
||||
const purify = getPurify()
|
||||
return purify!.sanitize(marked!.parse(block) as string)
|
||||
if (!marked || !purify) return escapeFallback(block)
|
||||
try {
|
||||
return purify.sanitize(marked.parse(block) as string)
|
||||
} catch {
|
||||
return escapeFallback(block)
|
||||
}
|
||||
}
|
||||
|
||||
/// 块级 memo parse:已完成块缓存命中,O(末块)
|
||||
@@ -1140,10 +1153,22 @@ const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
async function confirmDeleteConversation(id: string) {
|
||||
const conv = store.state.conversations.find(c => c.id === id)
|
||||
const title = conv?.title || t('aiChat.newConversation')
|
||||
if (!await confirmDialog(t('aiChat.confirmDeleteConversation', { title }))) return
|
||||
// UX-260617-24:删除当前活跃对话加醒目提示(通用文案不区分,活跃对话误删影响更大)。
|
||||
const isActive = id === store.state.activeConversationId
|
||||
const msgKey = isActive ? 'aiChat.confirmDeleteActiveConversation' : 'aiChat.confirmDeleteConversation'
|
||||
if (!await confirmDialog(t(msgKey, { title }))) return
|
||||
store.deleteConversation(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* UX-260617-23:搜索结果点击切对话后清空 searchQuery,退出搜索模式回原时间分组视图。
|
||||
* 原实现只 switchConversation 不清 searchQuery → 切回的对话在搜索态平铺里,无法回到分组浏览。
|
||||
*/
|
||||
function onSelectSearchResult(id: string) {
|
||||
void store.switchConversation(id)
|
||||
store.state.searchQuery = ''
|
||||
}
|
||||
|
||||
/** 清空当前对话(真删 DB messages,带二次确认防误删) */
|
||||
async function confirmClearChat() {
|
||||
if (!await confirmDialog(t('aiChat.confirmClearChat'))) return
|
||||
@@ -1696,6 +1721,29 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UX-260617-16:未发送输入保护。检测 inputText/pendingImages/pendingSkill 任一非空,
|
||||
* 用于 beforeunload 拦截(分离窗口关闭/刷新)与未来 onBeforeUnmount 拦截(面板切换)。
|
||||
*/
|
||||
function hasUnsentInput(): boolean {
|
||||
return (
|
||||
inputText.value.trim().length > 0 ||
|
||||
pendingImages.value.length > 0 ||
|
||||
!!pendingSkill.value
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* beforeunload 处理器:有未发送输入时阻止关闭/刷新,触发浏览器原生「确定离开?」提示。
|
||||
* 现代标准:e.preventDefault()(Chrome 同时要求 returnValue 非空才弹原生提示,故两者都设)。
|
||||
* 注:无法自定义文案(浏览器已废弃 custom message),只能给通用离开提示。
|
||||
*/
|
||||
function onBeforeUnloadGuard(e: BeforeUnloadEvent) {
|
||||
if (!hasUnsentInput()) return
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
|
||||
// ── UX-2025-16: 侧栏宽度拖拽 ──
|
||||
// 生命周期:mousedown 时临时挂 document mousemove/mouseup,mouseup 时摘除(非常驻 onMounted)。
|
||||
// 对齐 onGlobalKeydown 的 listener 思路,但拖拽监听只活在拖拽期间。
|
||||
@@ -2012,6 +2060,8 @@ watch(() => store.state.streaming, (s) => {
|
||||
onBeforeUnmount(() => {
|
||||
// 全局快捷键(UX-2025-07):卸载时移除 window listener,防分离窗口/切组件后泄漏
|
||||
window.removeEventListener('keydown', onGlobalKeydown)
|
||||
// UX-260617-16:摘除未发送输入保护(分离窗口关闭/页面刷新触发 beforeunload)
|
||||
window.removeEventListener('beforeunload', onBeforeUnloadGuard)
|
||||
// UX-18: 摘除导出菜单外部点击监听
|
||||
document.removeEventListener('click', onExportOutsideClick)
|
||||
if (rafId !== null) cancelAnimationFrame(rafId)
|
||||
@@ -2100,6 +2150,11 @@ watch(
|
||||
onMounted(async () => {
|
||||
// 全局快捷键(UX-2025-07):window 级 listener,卸载时移除(对齐 _unlistenToolSlow 生命周期)
|
||||
window.addEventListener('keydown', onGlobalKeydown)
|
||||
// UX-260617-16:未发送输入保护。AiChat 非路由组件(App.vue 内嵌 + AiDetached 路由),
|
||||
// beforeRouteLeave 不适用。分离窗口关闭/页面刷新时若有未发送 inputText/pendingImages/
|
||||
// pendingSkill,经 beforeunload 拦截提示用户。注意:e.preventDefault() 才是现代浏览器
|
||||
// 触发原生「确定离开?」的标准做法 returnValue 仅 Chrome 兼容旧写法。
|
||||
window.addEventListener('beforeunload', onBeforeUnloadGuard)
|
||||
// UX-2025-19: 滚动容器 ref 就绪,补建 IntersectionObserver(模板 sentinel 已在挂载时逐项 observe)
|
||||
setupVirtualScroll()
|
||||
// UX-18: 点击外部关闭导出格式菜单
|
||||
@@ -3281,6 +3336,16 @@ body.ai-sidebar-resizing * {
|
||||
color: var(--df-danger);
|
||||
border-color: rgba(240,101,101,0.3);
|
||||
}
|
||||
/* UX-260617-21:Markdown 表格横向溢出处理。全局 ai-md.css 的 .ai-md table 用 width:100%,
|
||||
宽表格被压窄单元格换行而非横向滚动;此处覆盖为 max-width:100% + display:block + overflow-x:auto,
|
||||
使宽表格在气泡内横向滚动不撑破气泡容器,窄表格仍自适应不出现多余滚动条。
|
||||
深度选择器 :deep() 因 table 经 v-html 注入非 scoped 受控节点。 */
|
||||
.ai-msg-bubble.ai-md :deep(table) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
width: max-content;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* ── 消息时间戳(UX-2025-13) ── */
|
||||
.ai-msg-time {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="confirm-msg">{{ msg }}</div>
|
||||
<div class="confirm-actions">
|
||||
<button class="confirm-btn confirm-btn--cancel" @click="$emit('result', false)">{{ $t('common.cancel') }}</button>
|
||||
<button class="confirm-btn confirm-btn--danger" @click="$emit('result', true)">{{ dangerLabel || $t('common.delete') }}</button>
|
||||
<button class="confirm-btn confirm-btn--danger" @click="$emit('result', true)">{{ dangerLabel || $t('common.confirm') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -18,7 +18,7 @@
|
||||
withDefaults(defineProps<{
|
||||
visible: boolean
|
||||
msg: string
|
||||
/** 危险按钮文案;不传则回退到 common.delete(随语言切换) */
|
||||
/** 危险按钮文案;不传则回退到 common.confirm(语义中性,删除等场景应显式传 common.delete) */
|
||||
dangerLabel?: string
|
||||
}>(), {
|
||||
dangerLabel: '',
|
||||
|
||||
@@ -107,6 +107,8 @@ export default {
|
||||
|
||||
// ── Confirm dialog ──
|
||||
confirmDeleteConversation: 'Delete chat "{title}"? This cannot be undone.',
|
||||
// UX-260617-24: stronger warning when deleting the currently active conversation
|
||||
confirmDeleteActiveConversation: '"{title}" is the chat you are currently viewing. Deleting it will clear the current view and cannot be undone. Continue?',
|
||||
clearChat: 'Clear',
|
||||
confirmClearChat: 'Are you sure you want to clear all messages in this chat? This cannot be undone.',
|
||||
// UX-08 §3.4: generate-in-progress confirm when starting a new chat (only when generating, otherwise no behavior change)
|
||||
|
||||
@@ -108,6 +108,8 @@ export default {
|
||||
|
||||
// ── 确认弹层 ──
|
||||
confirmDeleteConversation: '确定删除对话「{title}」?该操作不可恢复。',
|
||||
// UX-260617-24:删除当前活跃对话的醒目提示(活跃对话误删会清空当前视图,影响更大)
|
||||
confirmDeleteActiveConversation: '「{title}」是当前正在查看的对话,删除后将清空当前视图且不可恢复,确定继续?',
|
||||
clearChat: '清空',
|
||||
confirmClearChat: '确定清空当前对话的所有消息?此操作不可恢复。',
|
||||
// UX-08 §3.4:生成中点新建对话确认弹窗(仅生成中触发,非生成中零行为变化)
|
||||
|
||||
@@ -34,6 +34,20 @@ import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, A
|
||||
* - 现有 push 逻辑(composables 内)零改动,上限由本模块 watch 统一兜底。
|
||||
*/
|
||||
const MESSAGE_CAP = 200
|
||||
|
||||
/**
|
||||
* 单对话 messages 的 parts 体积软上限(UX-260617-20)。
|
||||
*
|
||||
* 背景:MESSAGE_CAP 仅按"条数"裁剪,不感知单条体积。多模态 base64 图片(F-260614-05)
|
||||
* 单条可达数 MB,极端 200 条 × 5MB ≈ 1GB 会撑爆渲染内存。
|
||||
*
|
||||
* 设计:
|
||||
* - 仅度量 image 片的 base64/url 字符串长度(其余 text/diff 普通字符串可忽略量级);
|
||||
* base64 char 数 ≈ 编码后字节数(3/4 编码开销),量纲稳定可作预算代理。
|
||||
* - 超出预算时,从最旧消息起逐条裁掉其 parts(置空数组),保条数与文本可见、丢历史图片。
|
||||
* - 与 MESSAGE_CAP 互补:条数先裁体积小场景,体积裁多模态大体积场景;两 watch 各司其职。
|
||||
*/
|
||||
const PARTS_SIZE_BUDGET = 50 * 1024 * 1024 // 50MB(base64 char 计)
|
||||
import { useAiEvents } from '@/composables/ai/useAiEvents'
|
||||
import { useAiStream } from '@/composables/ai/useAiStream'
|
||||
import { useAiSend, initDrainQueueListener } from '@/composables/ai/useAiSend'
|
||||
@@ -106,6 +120,49 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 估算单条消息 parts 的字符体积(base64/url 字符串长度之和,UX-260617-20)。
|
||||
*
|
||||
* 只计 image 片的 base64 与 url(大体量载荷);text 片(正文/工具结果文本)不在此预算口径内,
|
||||
* 避免对常规长文本消息误伤。无 parts 或非 image 片返回 0。
|
||||
*/
|
||||
function estimatePartsSize(parts: ContentPart[] | undefined): number {
|
||||
if (!parts || parts.length === 0) return 0
|
||||
let size = 0
|
||||
for (const p of parts) {
|
||||
if (p.type === 'image') {
|
||||
if (p.base64) size += p.base64.length
|
||||
if (p.url) size += p.url.length
|
||||
}
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
/**
|
||||
* parts 体积软上限守卫(UX-260617-20):push 式增长使总 parts 体积超 PARTS_SIZE_BUDGET 时,
|
||||
* 从最旧消息起逐条置空其 parts(丢历史多模态图,保文本/条数)。
|
||||
*
|
||||
* 仅拦截 push(同上 length watch 的判定),避免切换对话整体替换时误裁后端历史图。
|
||||
*/
|
||||
watch(
|
||||
() => state.messages.length,
|
||||
(newLen, oldLen) => {
|
||||
if (oldLen <= 0 || newLen - oldLen > 2) return
|
||||
let total = 0
|
||||
for (const m of state.messages) total += estimatePartsSize(m.parts)
|
||||
if (total <= PARTS_SIZE_BUDGET) return
|
||||
// 超预算:从最旧起逐条清 parts,直到总量回落到预算内
|
||||
for (const m of state.messages) {
|
||||
if (total <= PARTS_SIZE_BUDGET) break
|
||||
const partSize = estimatePartsSize(m.parts)
|
||||
if (partSize > 0) {
|
||||
m.parts = []
|
||||
total -= partSize
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 对话搜索过滤结果(UX-06 §3.1)。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user