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

- 修输入法组词回车直发/分离窗误关/出错后按钮死锁
- 新建任务失败弹窗内联提示,会话切换乐观高亮
- 状态多选联动刷新不再冲空可见行
- 测试连接保留已配开关与权重,防抖按提供商分键
- 代码块复制/长回复折叠带量/队列回填/排队时钟
- 子代理完成卡渲染,死链卡片与引用清理
- 截图渲染换 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
+76 -19
View File
@@ -57,8 +57,6 @@
<span class="ai-agentic-progress-dot"></span>
<span class="ai-agentic-progress-text">{{ agenticProgressText }}</span>
</div>
<!-- 第五批抽离至 ai/MaxRoundsCard.vue(F-260616-03 达 max_iterations 暂停态操作卡,零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父) -->
<MaxRoundsCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
断路器熔断 emit AiHelpRequired → pendingHelp(模块级 ref)→ 本卡显 reason + options 按钮供用户选。
机制优先 prompt 说教:代码强制熔断 + 结构化求助卡(非教 AI 主动问用户)。 -->
@@ -71,7 +69,7 @@
<!-- 待发送队列(生成中排队的消息,完成后自动续发;仅显示当前活跃会话的条目) -->
<div v-if="activeQueue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }">
<div class="ai-queue-head">
<span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}<span v-if="queueWaitSeconds >= 0" class="ai-queue-timer">({{ queueWaitSeconds }}s)</span></span>
<span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}</span>
<div class="ai-queue-actions">
<button v-if="queueTimedOut" class="ai-queue-force" @click="handleForceSend">{{ $t('ai.forceSendBtn') }}</button>
<button class="ai-queue-clear" @click="store.clearQueue(store.state.activeConversationId)">{{ $t('aiChat.clearQueue') }}</button>
@@ -86,8 +84,7 @@
v-model="editingQueueText"
class="ai-queue-edit-input"
type="text"
@keydown.enter.prevent="saveEditQueued"
@keydown.esc.prevent="cancelEditQueued"
@keydown="onQueueEditKeydown"
@blur="saveEditQueued"
/>
<button class="ai-queue-btn" @mousedown.prevent="saveEditQueued" :title="$t('aiChat.queueSave')">✓</button>
@@ -96,6 +93,10 @@
<template v-else>
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
<span class="ai-queue-text">{{ item.text }}</span>
<!-- V7 排队时钟:该项已等待时长 m:ss(nowTick 每秒推进,入发送随队列清空即停) -->
<span class="ai-queue-wait">{{ queueItemWait(item) }}</span>
<!-- I4 队列回填:取出该项填回输入框重新编辑(从队列移除) -->
<button class="ai-queue-btn" @click="backfillQueued(idx)" :title="$t('aiChat.queueBackfill')">{{ $t('aiChat.queueBackfill') }}</button>
<!-- 编辑按钮 -->
<button class="ai-queue-btn" @click="startEditQueued(idx)" :title="$t('aiChat.queueEdit')">{{ $t('aiChat.queueEdit') }}</button>
<!-- 立即发送(打断当前+发本条) -->
@@ -141,7 +142,6 @@ import ConversationSidebar from './ai/ConversationSidebar.vue'
import ChatInput from './ai/ChatInput.vue'
import TopBar from './ai/TopBar.vue'
import MessageList from './ai/MessageList.vue'
import MaxRoundsCard from './ai/MaxRoundsCard.vue'
import HelpRequiredCard from './ai/HelpRequiredCard.vue'
import DirAuthDialog from './ai/DirAuthDialog.vue'
import { useConfirm } from '../composables/useConfirm'
@@ -364,7 +364,14 @@ function onGlobalKeydown(e: KeyboardEvent) {
answerConfirm(false) // 视为取消,resolve Promise 打断 confirmClearChat/confirmDelete
return
}
if (props.detached) {
// P1-2 Esc 仲裁:事件源落在内层 Esc 消费者(输入框/侧栏重命名框/队列编辑框等输入控件,
// 或技能/@联想浮层与技能 chip)时,Esc 语义归内层(关浮层/取消编辑/取消输入法组词),
// 不关分离窗——否则输入框按 Esc 关技能浮层的同时窗口也进入关闭流程,一次按键双重效果。
// 浮层打开时焦点通常仍在 textarea(已被输入控件分支覆盖),补浮层容器类名兜
// 「鼠标点过浮层项后焦点落在浮层内」的旁路(SkillMention/MentionPopover 共用 .ai-skill-popover 根类)。
const el = e.target as HTMLElement | null
const inInnerEscLayer = !!el?.closest?.('input, textarea, [contenteditable], .ai-skill-popover, .ai-skill-chip')
if (props.detached && !inInnerEscLayer) {
e.preventDefault()
store.closeDetachedWindow()
return
@@ -409,12 +416,36 @@ const activeQueue = computed(() =>
/** 队首排队是否已超 30s(仅看当前活跃会话队列) */
const queueTimedOut = computed(() => store.isQueueTimedOut(store.state.activeConversationId))
/** 队首已排队秒数(用于 UI 展示倒计时;无队列时 -1) */
const queueWaitSeconds = computed(() => {
const first = activeQueue.value[0]
if (!first) return -1
return Math.floor((Date.now() - first.enqueuedAt) / 1000)
})
// ── V7 排队时钟:响应式 tick 驱动排队项已等待时长 ──
// 原 queueWaitSeconds 在 computed 内直接取 Date.now(),非响应式依赖:纯等待期(无 store 变更)
// 永不重算,秒数冻结;且只覆盖队首。改为有排队时起 1s interval 推进 nowTick,展示函数读
// nowTick 建立依赖实现每秒刷新;队列清空(含队首进入发送被移除)即关表,卸载时显式清理防泄漏。
const nowTick = ref(Date.now())
let queueTickTimer: ReturnType<typeof setInterval> | null = null
watch(
() => activeQueue.value.length > 0,
(waiting) => {
if (waiting && queueTickTimer === null) {
nowTick.value = Date.now()
queueTickTimer = setInterval(() => { nowTick.value = Date.now() }, 1000)
} else if (!waiting && queueTickTimer !== null) {
clearInterval(queueTickTimer)
queueTickTimer = null
}
},
{ immediate: true },
)
/** 秒数转 m:ss(分钟不封顶,长等待继续累计) */
function formatWaitTime(totalSeconds: number): string {
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`
}
/** 排队项已等待时长(m:ss);读 nowTick 建立响应式依赖,每秒随 tick 刷新 */
function queueItemWait(item: { enqueuedAt: number }): string {
return formatWaitTime(Math.max(0, Math.floor((nowTick.value - item.enqueuedAt) / 1000)))
}
/** 强制发送:委托 tryForceSend 唯一负责弹 confirm + 移除队列 + force_send(作用于当前活跃会话) */
async function handleForceSend() {
@@ -442,6 +473,20 @@ function cancelEditQueued() {
editingQueueText.value = ''
}
/** P1-1 队列编辑框键盘处理:输入法组词中(isComposing/229)的 Enter/Esc 属 IME 选词/取消组词,
* 不当提交/取消处理,防半截拼音被写回队列项。原 @keydown.enter/.esc 内联修饰符挂不了守卫,
* 收敛到本函数统一判。 */
function onQueueEditKeydown(e: KeyboardEvent) {
if (e.isComposing || e.keyCode === 229) return
if (e.key === 'Enter') {
e.preventDefault()
saveEditQueued()
} else if (e.key === 'Escape') {
e.preventDefault()
cancelEditQueued()
}
}
/** 保存编辑:回车/失焦触发,委托 store.editQueued 写回 text */
function saveEditQueued() {
if (editingQueueIdx.value === null) return
@@ -455,6 +500,20 @@ async function handleSendQueuedNow(idx: number) {
await store.sendQueuedNow(idx)
}
/** I4 队列回填:取出该条填回输入框重新编辑(从队列移除,可改完再发)。
* cancelQueued 内部经 activeQueueRealIndex 做过滤视图下标映射,越界 no-op;
* 项携带技能时按名找回 SkillInfo 回挂 chip,找不到(技能已卸载)只回填文本,
* 与队列原地编辑只保 text 同一取舍。 */
function backfillQueued(idx: number) {
const item = activeQueue.value[idx]
if (!item) return
const skill = item.skill
? store.state.skills.find(s => s.name === item.skill) ?? null
: null
store.cancelQueued(idx)
chatInputRef.value?.fillDraft(item.text, skill)
}
// 第四批抽离: 滚动跟随/回底部/流式 Markdown 分块渲染/工具卡自动收起/选区保持 全部随
// MessageList 子组件迁移(零行为变更,store/useMarkdown 单例共享)。
// 父仅保留 scrollToBottom 转发桩(ChatInput @sent / TopBar @scroll-to-pending 经 expose 调)。
@@ -462,6 +521,8 @@ function scrollToBottom(smooth = false) {
messageListRef.value?.scrollToBottom(smooth)
}
onBeforeUnmount(() => {
// V7 排队时钟 interval 随组件卸载清理(watch 随卸载停但 interval 本体不自停,须显式清)
if (queueTickTimer !== null) { clearInterval(queueTickTimer); queueTickTimer = null }
// 全局快捷键卸载时移除 window listener,防分离窗口/切组件后泄漏
window.removeEventListener('keydown', onGlobalKeydown)
// 摘除未发送输入保护(分离窗口关闭/页面刷新触发 beforeunload)
@@ -580,10 +641,6 @@ async function toggleAlwaysOnTop() {
appSettingsStore.set('df-ai-always-on-top', alwaysOnTop.value)
}
// 第五批抽离: F-260616-03 max_iterations 暂停态操作卡(maxRoundsActing/showMaxRoundsCard/
// handleContinueLoop/handleStopLoop/pendingMaxRounds watch)已迁移至 ai/MaxRoundsCard.vue
// (零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父)。
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
// ChatMessage.status 渲染语义(types.ts AiMessage.status 字面量联合定义):
// null | undefined | 'active' → 正常消息(现状)
@@ -721,7 +778,6 @@ async function initContextEventListeners(): Promise<void> {
border-top: 0.5px solid var(--df-border);
background: var(--df-bg);
}
/* 第五批抽离: F-260616-03 max_iterations 操作卡样式已迁移至 ai/MaxRoundsCard.vue */
/* ── Agentic 循环进度条 ── */
.ai-agentic-progress {
@@ -772,7 +828,6 @@ async function initContextEventListeners(): Promise<void> {
margin-bottom: 4px;
}
.ai-queue-title { font-size: 11px; color: var(--df-text-dim); font-weight: 600; }
.ai-queue-timer { font-weight: 400; opacity: 0.7; }
.ai-queue--timeout .ai-queue-title { color: var(--df-warning); }
.ai-queue-actions { display: flex; align-items: center; gap: 8px; }
.ai-queue-force {
@@ -793,6 +848,8 @@ async function initContextEventListeners(): Promise<void> {
font-size: 12px;
}
.ai-queue-skill { color: var(--df-accent); font-weight: 600; flex-shrink: 0; }
/* V7 排队时钟:每项已等待时长 */
.ai-queue-wait { color: var(--df-text-dim); font-size: 10px; flex-shrink: 0; opacity: 0.7; }
.ai-queue-text {
color: var(--df-text);
flex: 1;