-
{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}({{ queueWaitSeconds }}s)
+
{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}
@@ -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"
/>
@@ -96,6 +93,10 @@
/{{ item.skill }}
{{ item.text }}
+
+ {{ queueItemWait(item) }}
+
+
@@ -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 | 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 {
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 {
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 {
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;
diff --git a/src/components/ai/ChatInput.vue b/src/components/ai/ChatInput.vue
index 4caa963..1ae349e 100644
--- a/src/components/ai/ChatInput.vue
+++ b/src/components/ai/ChatInput.vue
@@ -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 = {
+ 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,
})
diff --git a/src/components/ai/ConversationSidebar.vue b/src/components/ai/ConversationSidebar.vue
index 2ec5ae6..ed9279d 100644
--- a/src/components/ai/ConversationSidebar.vue
+++ b/src/components/ai/ConversationSidebar.vue
@@ -40,6 +40,7 @@
class="ai-conv-item"
:class="{
'ai-conv-item--active': conv.id === store.state.activeConversationId,
+ 'ai-conv-item--switching': conv.id === pendingSwitchId,
'ai-conv-item--archived': conv.archived,
'ai-conv-item--pinned': conv.pinned,
}"
@@ -49,6 +50,8 @@
{{ conv.title || t('aiChat.newConversation') }}
+
+ {{ pendingCountOf(conv.id) }}
{{ formatRelative(conv.updated_at) }}
@@ -102,8 +105,8 @@
:key="conv.id"
:ref="(el) => setItemEl(conv.id, el)"
class="ai-conv-item"
- :class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--pinned': conv.pinned }"
- @click="store.switchConversation(conv.id)"
+ :class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
+ @click="beginSwitch(conv.id)"
>
@@ -171,13 +175,15 @@
:key="'a-' + conv.id"
:ref="(el) => setItemEl(conv.id, el)"
class="ai-conv-item ai-conv-item--archived"
- :class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--pinned': conv.pinned }"
- @click="store.switchConversation(conv.id)"
+ :class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
+ @click="beginSwitch(conv.id)"
>
{{ conv.title || t('aiChat.newConversation') }}
+
+ {{ pendingCountOf(conv.id) }}
{{ formatRelative(conv.updated_at) }}
@@ -235,6 +241,8 @@
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount, type ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
+// V1 只读消费:跨会话待审批真相源(state.pendingApprovals 仅是活跃会话投影,后台会话挂起不进投影)
+import { convPendingApprovals } from '../../composables/ai/aiShared'
import { formatRelative } from '../../utils/time'
import type { AiConversationSummary } from '../../api/types'
@@ -302,6 +310,20 @@ function cancelRename() {
editingConvId.value = null
}
+/** P1-1 重命名框键盘处理:输入法组词中(isComposing/229)的 Enter/Esc 属 IME 选词/取消组词,
+ * 不当重命名提交/取消处理,防标题被半截拼音覆盖。原 keyup.enter/.esc 改 keydown 统一挂守卫:
+ * 组词上屏的回车到 keyup 阶段 isComposing 已复位守不住,keydown 才拦得到。 */
+function onRenameKeydown(e: KeyboardEvent, convId: string) {
+ if (e.isComposing || e.keyCode === 229) return
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ commitRename(convId)
+ } else if (e.key === 'Escape') {
+ e.preventDefault()
+ cancelRename()
+ }
+}
+
// ── 会话项「更多」操作菜单(置顶/归档/删除)──
const actionsOpenId = ref
(null)
@@ -321,13 +343,31 @@ function onActionsOutsideClick(e: MouseEvent) {
actionsOpenId.value = null
}
+// ── 会话切换过渡态(UX走查 2026-10-03 P1):点击即预高亮目标项(乐观),IPC 返回后随
+// activeConversationId 落定接管高亮;失败/过期时 finally 清 pending,高亮自动回滚到原活跃项。
+// 治:大历史会话 IPC 数百 ms 期间点击项不高亮、原高亮不动、无任何进行时反馈,快速连点像失灵。
+const pendingSwitchId = ref(null)
+function beginSwitch(id: string) {
+ // 已活跃会话:switchConversation 内部同判短路,无过渡期,不挂 pending
+ if (id === store.state.activeConversationId) {
+ void store.switchConversation(id)
+ return
+ }
+ pendingSwitchId.value = id
+ void store.switchConversation(id).finally(() => {
+ // 仅当 pending 仍是本次点击的 id 才清:快速连点 B 覆盖 A 后,A 的迟到返回不得清掉 B 的过渡态
+ if (pendingSwitchId.value === id) pendingSwitchId.value = null
+ })
+}
+
/**
* 搜索结果点击切对话后清空 searchQuery,退出搜索模式回原时间分组视图。
* M3:命中项可能在折叠的时间分组或折叠的归档分组内,选中态不可见。切回分组视图后展开所在分组
* 并 scrollIntoView,确保选中项立即可见(原仅切 searchQuery,选中项落在折叠组里看不到高亮)。
*/
function onSelectSearchResult(id: string) {
- void store.switchConversation(id)
+ // 走 beginSwitch 挂切换过渡态(乐观预高亮),与分组视图点击口径一致
+ beginSwitch(id)
store.state.searchQuery = ''
// nextTick 后视图已切回分组态;ensureVisible 展开所在组 + 滚动定位
void nextTick(() => ensureActiveVisible())
@@ -370,6 +410,30 @@ function ensureActiveVisible(): void {
})
}
+// ── V1: 跨会话待审批徽标 ──
+// convPendingApprovals Map 本体非响应式(aiShared 注明渲染只依赖投影数组),故 computed 读 Map 的
+// 同时锚定三类响应式源触发重算:
+// - state.pendingApprovals(活跃会话投影,审批增删/切换恢复整体替换时必变)→ 活跃会话即时刷新;
+// - conversations 列表(新建/删除/排序刷新);
+// - pendingTick 2.5s 轮询兜底:后台会话新增/解除挂起不触碰任何响应式源(aiShared 只写裸 Map),
+// 轻量周期重算补偿(徽标属弱实时信息,秒级延迟可接受)。
+const pendingTick = ref(0)
+let pendingPollTimer: ReturnType | null = null
+
+const pendingCountByConv = computed(() => {
+ void store.state.pendingApprovals.length
+ void store.state.conversations.length
+ void pendingTick.value
+ const out = new Map()
+ for (const [convId, arr] of convPendingApprovals) {
+ if (arr.length > 0) out.set(convId, arr.length)
+ }
+ return out
+})
+function pendingCountOf(convId: string): number {
+ return pendingCountByConv.value.get(convId) ?? 0
+}
+
// ── 对话分组:今天 / 昨天 / 更早 ──
const bucketLabels = computed>(() => ({
today: t('aiChat.today'),
@@ -447,10 +511,14 @@ function onResizeStart(e: MouseEvent) {
onMounted(() => {
// 点击外部关闭更多操作菜单
document.addEventListener('click', onActionsOutsideClick)
+ // V1:后台会话待审批变化兜底轮询(见 pendingCountByConv 注)
+ pendingPollTimer = setInterval(() => { pendingTick.value++ }, 2500)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onActionsOutsideClick)
+ // 释放待审批徽标轮询计时器
+ if (pendingPollTimer) { clearInterval(pendingPollTimer); pendingPollTimer = null }
// 拖拽中途卸载兜底摘临时 document listener(原 AiChat 同段迁移)
document.removeEventListener('mousemove', onResizeMove)
document.removeEventListener('mouseup', onResizeEnd)
@@ -612,6 +680,12 @@ body.ai-sidebar-resizing * {
.ai-conv-item--active {
background: var(--df-sidebar-active);
}
+/* 切换进行时(UX走查 P1):与活跃项同底色示"已选中"(乐观预高亮),降不透明度示"加载中";
+ IPC 返回后 activeConversationId 落定、本类移除,视觉无缝衔接;失败回滚高亮回原活跃项 */
+.ai-conv-item--switching {
+ background: var(--df-sidebar-active);
+ opacity: 0.7;
+}
.ai-conv-item--archived .ai-conv-item-title {
opacity: 0.6;
}
@@ -676,6 +750,21 @@ body.ai-sidebar-resizing * {
0%, 100% { opacity: 0.35; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.15); }
}
+/* V1: 跨会话待审批徽标(橙点计数,warning 色系与待审批语义一致;固定尺寸不挤标题换行) */
+.ai-conv-approve-badge {
+ flex-shrink: 0;
+ min-width: 12px;
+ height: 12px;
+ padding: 0 3px;
+ border-radius: 6px;
+ background: var(--df-warning, #f0c75e);
+ color: #000;
+ font-size: 9px;
+ font-weight: 600;
+ line-height: 12px;
+ text-align: center;
+ cursor: help;
+}
.ai-conv-item-actions {
display: none;
align-items: center;
diff --git a/src/components/ai/MaxRoundsCard.vue b/src/components/ai/MaxRoundsCard.vue
deleted file mode 100644
index ad2df7b..0000000
--- a/src/components/ai/MaxRoundsCard.vue
+++ /dev/null
@@ -1,180 +0,0 @@
-
-
-
-
{{ $t('aiChat.maxRoundsReached') }}
-
{{ $t('aiChat.maxRoundsHint') }}
-
-
-
-
-
-
-
-
-
-
diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue
index ab3453d..811e68a 100644
--- a/src/components/ai/MessageList.vue
+++ b/src/components/ai/MessageList.vue
@@ -22,6 +22,7 @@ import { useMessageScroll } from '../../composables/ai/useMessageScroll'
import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations'
import { collectTurnStats, turnSummaryText, sumTurnTokens, formatTokensK, type TurnCallStats } from '../../composables/ai/useTurnTrace'
+import { getConvAgentFinished, type AgentFinishedCard } from '../../composables/ai/aiShared'
import ToolCardList from '../ToolCardList.vue'
import MessageItem from './MessageItem.vue'
import EmptyState from './EmptyState.vue'
@@ -540,16 +541,18 @@ async function copyMsgContent(msg: AiMessage): Promise {
// ── 消息气泡复制为图片(html2canvas 渲染 PNG 写入剪贴板) ──
// 原生 ClipboardItem 即可写图片到剪贴板,故不加 Tauri 剪贴板插件(少装依赖)
+// 用 html2canvas-pro 替代 html2canvas:后者不支持 color-mix/oklch 现代色彩函数,渲染气泡样式会抛错
async function copyMsgAsImage(msg: AiMessage): Promise {
+ // msg.id 经 CSS.escape 转义:含特殊字符的 id 在属性选择器里会匹配失败(引号内转义合法)
const bubble = messagesContainer.value?.querySelector(
- `.ai-msg[data-msg-id="${msg.id}"] .ai-msg-bubble`,
+ `.ai-msg[data-msg-id="${CSS.escape(msg.id)}"] .ai-msg-bubble`,
)
if (!bubble) {
emit('toast', { msg: t('aiChat.copyAsImageFailed'), type: 'error' })
return
}
try {
- const { default: html2canvas } = await import('html2canvas')
+ const { default: html2canvas } = await import('html2canvas-pro')
const canvas = await html2canvas(bubble, { backgroundColor: null, useCORS: true, scale: 2 })
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'))
if (!blob) throw new Error('toBlob failed')
@@ -574,6 +577,72 @@ async function copyMsgAsImage(msg: AiMessage): Promise {
}
}
+// ── R4 代码块复制:渲染层 html 装饰 + 容器级事件委托 ──
+// 代码块经 marked 渲染后以 v-html 注入,模板层无法逐块插按钮;改为在 html 字符串层把
+// 包进 .md-code-block 并注入复制按钮(发生在 renderMd 的 DOMPurify 消毒之后,
+// 注入节点不经消毒通道,仅静态结构无用户内容,安全)。点击走容器级事件委托(单监听器,
+// 不逐块绑定)。结果 memo 缓存:v-html 按字符串相等决定是否重写 DOM,不缓存会导致组件每次
+// 重渲染都重设 innerHTML,打破 AC-EFF-S2-1「已完成块 DOM 稳定」优化。
+const _decoCache = new Map()
+const DECO_CACHE_LIMIT = 800
+function decorateCodeHtml(raw: string): string {
+ if (!raw || !raw.includes('…
+ //(含缩进码块),代码内容已 HTML 转义,正则非贪婪到首个闭合标签不会越界
+ const out = raw.replace(
+ /]*)>([\s\S]*?)<\/code><\/pre>/g,
+ '',
+ )
+ if (_decoCache.size > DECO_CACHE_LIMIT) _decoCache.clear()
+ _decoCache.set(raw, out)
+ return out
+}
+/** renderMd 包装:渲染 + 代码块装饰(双层 memo:renderMd 的 _mdCache + 本缓存,未变即零成本) */
+function renderMdDecorated(raw: string): string {
+ return decorateCodeHtml(renderMd(raw))
+}
+
+// 流式分块同走装饰(computed 缓存:块 html 未变时返回同串,v-html 不触发 DOM 重写)
+const decoratedStreamingBlocks = computed(() =>
+ streamingBlocks.value.map(b => ({ key: b.key, html: decorateCodeHtml(b.html) })),
+)
+
+/** 容器级事件委托:点击 .md-code-copy 复制所属代码块原文(pre.textContent 自动还原 HTML 转义) */
+async function onMdCodeCopyClick(e: MouseEvent): Promise {
+ const btn = (e.target as HTMLElement | null)?.closest('button.md-code-copy') as HTMLButtonElement | null
+ if (!btn) return
+ const code = btn.parentElement?.querySelector('pre')?.textContent ?? ''
+ // 必须在 click 同步链内发起 clipboard 调用(浏览器安全限制);非 secure context 走
+ // execCommand 兜底(对齐 copyMsgContent 降级链),两者均失败如实反馈
+ let ok = false
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(code)
+ ok = true
+ }
+ } catch { /* 权限拒绝/中断,落入 execCommand 兜底 */ }
+ if (!ok) {
+ const ta = document.createElement('textarea')
+ ta.value = code
+ ta.style.position = 'fixed'
+ ta.style.opacity = '0'
+ document.body.appendChild(ta)
+ ta.select()
+ try { ok = document.execCommand('copy') } catch { ok = false }
+ document.body.removeChild(ta)
+ }
+ // 成功态短暂反馈(直接改按钮 DOM;下次 v-html 重渲染自然还原,无需状态机)
+ const prev = btn.textContent
+ btn.textContent = ok ? '已复制' : '复制失败'
+ btn.classList.add(ok ? 'is-ok' : 'is-err')
+ setTimeout(() => {
+ btn.textContent = prev
+ btn.classList.remove('is-ok', 'is-err')
+ }, 1200)
+}
+
// ── 错误气泡操作入口 ──
// errorType 经 useAiEvents push 错误消息时附带(types.ts AiMessage 未含此字段,
// 经 cast 读写闭环在 AiChat.vue / useAiEvents 两端,不污染 types.ts 定义)。
@@ -710,6 +779,18 @@ function shouldCollapseLong(msg: AiMessage): boolean {
return true
}
+// R3:折叠条带量 —— 折叠态按钮显示「已折叠 N 字符 / 约 M tokens,点击展开」。
+// 折叠量按 M1 的标定估算(400px 视口 ≈ 阈值字符量,见 LONG_MSG_THRESHOLD 注),
+// 即 content.length - 阈值;token 为粗估(中英混合 1 token ≈ 2.5 字符),只供量级感知。
+// 文案硬编码中文:i18n locale 文件本批锁定(他代理并行追加,不新增键),
+// 展开态仍复用既有 aiChat.collapse 键。
+const CHAR_PER_TOKEN_EST = 2.5
+function collapsedLongLabel(msg: AiMessage): string {
+ const folded = Math.max(0, (msg.content?.length ?? 0) - LONG_MSG_THRESHOLD)
+ const tokens = Math.max(1, Math.round(folded / CHAR_PER_TOKEN_EST))
+ return `已折叠 ${folded} 字符 / 约 ${tokens} tokens,点击展开`
+}
+
/**
* JSON 工具结果折叠防御(对齐 miniapp apps/df-miniapp/src/pages/chat/index.vue:344-354
* isToolResultJson):assistant content 为纯 JSON 工具结果(trim 后以 {/[ 开头且 JSON.parse
@@ -828,6 +909,8 @@ type RenderItem =
| { kind: 'step'; key: string; round: number; stats: TurnCallStats }
// UX 密度改造:相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线(替代 per-block 相对时间)
| { kind: 'timesep'; key: string; ts: number }
+ // S1 子代理完成卡(AiAgentFinished 事件):非消息数据(per-conv 存 aiShared),逐卡一项
+ | { kind: 'agentFin'; key: string; card: AgentFinishedCard }
/**
* 渲染项(A 过程压缩):在归档分段之上叠加轮次折叠。
@@ -964,9 +1047,36 @@ const renderItems = computed(() => {
}
}
flushBucket()
+ // S1 子代理完成卡追加在消息流末尾:子代理在父轮内运行,完成语义上晚于当轮内容;
+ // 逐卡一项(key 按 agentId 稳定),同会话多子代理多卡共存,Vue diff 复用不闪。
+ // 真相源是 aiShared.convAgentFinished(per-conv),切会话重进仍可见,非组件局部状态。
+ for (const card of getConvAgentFinished(store.state.activeConversationId)) {
+ items.push({ kind: 'agentFin', key: 'agentfin-' + card.agentId, card })
+ }
return items
})
+// ── S1 子代理完成卡(AiAgentFinished) ──
+// 状态→色调三态:completed 中性(常态结果不抢视线)/ truncated 警示(结果不完整)/
+// failed 错误(需关注);未知的 status 字面量兜底中性,不误报成功也不误报失败。
+function agentFinTone(status: AgentFinishedCard['status']): 'ok' | 'warn' | 'err' {
+ if (status === 'truncated') return 'warn'
+ if (status === 'failed') return 'err'
+ return 'ok'
+}
+// 状态词独立于颜色透出(色弱可用);键与后端 SubAgentStatus::as_str 字面量一一对应
+function agentFinStatusText(status: AgentFinishedCard['status']): string {
+ if (status === 'truncated') return t('ai.agentFinished.truncated')
+ if (status === 'failed') return t('ai.agentFinished.failed')
+ return t('ai.agentFinished.completed')
+}
+// 摘要超长截断:单行细条承载,压平空白后按 160 字截断(对齐轨迹卡 500 字口径的轻量版),
+// 全文经 title hover 兜底不丢信息。
+function agentFinSummary(summary: string): string {
+ const s = (summary || '').replace(/\s+/g, ' ').trim()
+ return s.length > 160 ? s.slice(0, 160) + '…' : s
+}
+
// UX 密度改造:轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转不做统计)
const traceNav = computed(() =>
renderItems.value.filter((it): it is Extract => it.kind === 'trace')
@@ -1025,7 +1135,7 @@ defineExpose({
-
+
{{ stepText(item.stats, item.round) }}