修复: force_send单锁竞态+classify unknown不重试(撤销误判)+UX交互批

This commit is contained in:
2026-06-17 14:31:47 +08:00
parent 0633aa6614
commit 924158cff2
8 changed files with 256 additions and 31 deletions

View File

@@ -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 {

View File

@@ -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: '',