优化: 对话交互修复与界面完善

- 修输入法组词回车直发/分离窗误关/出错后按钮死锁
- 新建任务失败弹窗内联提示,会话切换乐观高亮
- 状态多选联动刷新不再冲空可见行
- 测试连接保留已配开关与权重,防抖按提供商分键
- 代码块复制/长回复折叠带量/队列回填/排队时钟
- 子代理完成卡渲染,死链卡片与引用清理
- 截图渲染换 pro 版依赖,样式 token 化收尾
This commit is contained in:
lxy
2026-10-03 15:35:18 +08:00
parent b4fafa0e9a
commit 2d43f90e50
20 changed files with 609 additions and 255 deletions
+75 -3
View File
@@ -116,6 +116,7 @@ import { useAiStore } from '../../stores/ai'
import { useProjectStore } from '../../stores/project'
import { getConvState } from '../../composables/ai/useAiEvents'
import { textIdle } from '../../composables/ai/useAiPendingState'
import { getConvDraft, setConvDraft, classifySendError, type SendErrorKind } from '../../composables/ai/aiShared'
import { extractImageUrlParts } from '../../composables/ai/utils'
import { uploadFile } from '../../composables/ai/fileUpload'
import { open } from '@tauri-apps/plugin-dialog'
@@ -292,7 +293,12 @@ const stopBtnState = computed<'idle' | 'stop' | 'stopping' | 'retry'>(() => {
const convId = store.state.activeConversationId
const cs = getConvState(convId)
if (cs === 'stopping') return 'stopping'
if (cs === 'error') return 'retry'
// P1-3: conv_state='error' 是 AiError 收尾落的终态(useAiLifecycleEvents cleanup 先
// setStreaming(false) 再置 error),此时本会话 loop 已死——「重试停止」按钮点 stopChat 对死
// loop 无意义,却顶掉发送位,出错后鼠标用户点不了发送(Enter 不看按钮态仍可发,语义错位)。
// 仅流式仍在跑(停止失败等 loop 未断的 error)时保留 retry 语义,否则回发送按钮,
// 与 Enter/handleSend 对齐。
if (cs === 'error') return store.state.streaming ? 'retry' : 'idle'
const textActive = store.state.streaming && (cs === 'generating' || !textIdle.value)
return textActive ? 'stop' : 'idle'
})
@@ -676,21 +682,29 @@ function selectMention(item: MentionItem) {
}
function handleKeydown(e: KeyboardEvent) {
// P1-1 输入法组词守卫:拼音/双拼组词未上屏时 Enter=选词、Esc=取消组词,均非用户发送/取消意图,
// 直接放行给 IME。isComposing 覆盖标准浏览器;keyCode===229 兜部分环境 keydown 阶段不上报 isComposing。
if (e.isComposing || e.keyCode === 229) return
// 各 Esc 消费分支 stopPropagation:P1-2 双层防,分离窗口 AiChat 的 window 级 Esc 会关整个窗口,
// 内层已消费的 Esc 不再外抛(主防线是 AiChat 侧的 e.target 输入控件守卫,此处为辅)
// 编辑态 Escape 取消编辑(最高优先级)
if (props.editingMsgId && e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
emit('cancel-edit')
return
}
// Escape 关闭技能联想浮层(无论是否有匹配项)
if (skillOpen.value && e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
skillOpen.value = false
return
}
// 已选技能 chip 态下 Escape 取消选中(浮层已关闭,这里兜底 chip 可取消路径)
if (pendingSkill.value && e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
pendingSkill.value = null
return
}
@@ -713,6 +727,7 @@ function handleKeydown(e: KeyboardEvent) {
}
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
skillOpen.value = false
return
}
@@ -736,6 +751,7 @@ function handleKeydown(e: KeyboardEvent) {
}
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
mentionOpen.value = false
mentionStart.value = -1
return
@@ -859,9 +875,10 @@ async function handleSend() {
if (snapshotSpans.length) {
pendingMentionSpans.value = snapshotSpans
}
// 用户可见提示(原仅 console.error,用户无感);Tauri IPC 错误是字符串非 Error 对象
// 用户可见提示(原仅 console.error,用户无感);Tauri IPC 错误是字符串非 Error 对象。
// I5:按错误内容分类映射友好文案,原始错误串留在 {msg} 细节里不丢排查线索
const msg = e instanceof Error ? e.message : String(e)
emit('error', { msg: t('aiChat.toastSendFail', { msg }), type: 'error' })
emit('error', { msg: t(SEND_ERR_KEY[classifySendError(msg)], { msg }), type: 'error' })
}
await nextTick()
emit('sent')
@@ -902,11 +919,65 @@ function clearInput(): void {
if (inputEl.value) inputEl.value.style.height = 'auto'
}
/** I4 队列回填:父把取出的队列项文本填回输入框重新编辑。
* 可选 skill:队列项携带技能名时由父按名找回 SkillInfo 回挂 chip;找回失败传 null 仅丢
* chip(与队列原地编辑只保 text 同一取舍)。清 mention 区间/浮层态,光标置尾并聚焦。 */
function fillDraft(text: string, skill: SkillInfo | null = null): void {
pendingSkill.value = skill
inputText.value = text
pendingMentionSpans.value = []
mentionOpen.value = false
mentionStart.value = -1
nextTick(() => {
inputEl.value?.focus()
const el = inputEl.value
if (el) {
const len = el.value.length
el.setSelectionRange(len, len)
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
}
})
}
/** 聚焦输入框 */
function focus(): void {
inputEl.value?.focus()
}
// ── V8 per-conv 草稿:切会话保存/恢复,防 A 会话未发送草稿串到 B 输入框 ──
// 草稿真相源在 aiShared.convDrafts(内存 Map,不落 localStorage)。父组件 AiChat 的
// activeConversationId watcher 注册更早先执行:编辑态切换时父先 cancelEdit 清输入,
// 本 watcher 后执行,旧会话保存到的是清空后的空串,与既有「切会话丢弃编辑缓冲」语义一致。
watch(() => store.state.activeConversationId, (nv, ov) => {
if (ov) setConvDraft(ov, inputText.value)
inputText.value = getConvDraft(nv)
// 随输入框的临时态不跨会话携带:mention 区间偏移只对旧文本有效,照搬到恢复的文本会渲染错位
// chip;技能 chip 与待发图片同理,残留到新会话会静默混入下一条消息
pendingMentionSpans.value = []
mentionOpen.value = false
mentionStart.value = -1
pendingSkill.value = null
pendingImages.value = []
skillOpen.value = false
// 恢复文本会触发输入框自身 watch 重算联想(文本以 @ 结尾时按旧光标位误拉起浮层),
// nextTick 在该 watch 之后统一收口,并按恢复文本重算输入框高度
nextTick(() => {
mentionOpen.value = false
mentionStart.value = -1
autoResize()
})
})
// ── I5 发送失败错误分类文案:类别到 i18n 键的映射(分类逻辑在 aiShared.classifySendError)。
// other 复用既有 toastSendFail 文案,行为与改造前一致 ──
const SEND_ERR_KEY: Record<SendErrorKind, string> = {
rate_limit: 'aiChat.sendErrRateLimit',
quota: 'aiChat.sendErrQuota',
network: 'aiChat.sendErrNetwork',
other: 'aiChat.toastSendFail',
}
/**
* 未发送输入保护。检测 inputText/pendingImages/pendingSkill 任一非空,
* 用于 beforeunload 拦截(分离窗口关闭/刷新)与未来 onBeforeUnmount 拦截(面板切换)。
@@ -923,6 +994,7 @@ defineExpose({
focus,
startEditFocus,
clearInput,
fillDraft,
hasUnsentInput,
sendExamplePrompt,
})