优化: AI Chat 渲染与无障碍 + 文件查看 + 同步 IO 收口

- 渲染: 结果/过程分离(气泡只放最终结果,过程轮叙述+工具卡收进轨迹卡默认收起可展开——治『流式后正文消失』且不再 N 气泡打扰)
- 渲染 P0: v-memo 定向 dep(只有真正渲染流式块的 item 失效,其余取占位) + renderItems 桶 memo(签名覆盖全消费输入,item 引用稳定) + 指纹 watch 降为 O(1) toolEpoch
- TopBar 三处 O(n) messages computed 合并单次遍历;md 缓存改有界 LRU(除满即全清抖动);useTurnTrace 补 never 穷尽 + default;MessageItem 模板去重
- 无障碍(四批累计): 可点容器键盘化(含 role/tabindex/keydown/aria-expanded) + icon-only aria-label 约 60 处 + 装饰图标 aria-hidden + 52 个滚动容器 overscroll-behavior:contain + 设置开关聚焦可见环 + outline 零补偿站点补 :focus-visible
- UX 走查项: 灵感 IDEA-10/11、知识 KNOW-6/7/9、项目详情 O8、审计 SA-4、设置 SA-6/26/27/28(脏表单/冗余 save/clamp/删除二次确认)
- 文件查看专项: 切工程状态残留 F5/F6、FileTree 去重与展开反馈 F7/F15/F17、切文件头部残留 F18、R 状态着色 F19;FilePreview diff 文件头(---/+++)误当增删渲染修复 + 大纲行高亮死样式修复 + 图片失败文案 i18n 化
- 同步 IO 收口(异步上下文内阻塞): unattended 看板读写改 tokio::fs、fetch_url 探活改 spawn_blocking、http/generate_image 清理改 tokio::fs
- 子代理 P3 批量: 任务书钉扎上限/截断文案区分父子语境/环境画像日期/temperature 可覆盖/model 回退上报/lang 透传
- crate 清理: df-execute·df-types 注释与格式、df-ideas 过时注释、df-nodes subflow 夸大描述、df-project 死变量
This commit is contained in:
lxy
2026-10-10 08:09:45 +08:00
parent 8df4e33656
commit 39e0eac39d
67 changed files with 1344 additions and 364 deletions
+1
View File
@@ -29,6 +29,7 @@
<button
class="approval-overlay-close"
:title="$t('common.close')"
:aria-label="$t('common.close')"
@click.stop="onDismiss"
>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+60
View File
@@ -70,33 +70,41 @@
idle(或 conv_state 未收到回退旧 streaming=false)显发送按钮。双轨过渡不强制全替旧 bool。 -->
<button
v-if="stopBtnState === 'stopping'"
type="button"
class="ai-send-btn ai-send-btn--stop ai-send-btn--stopping"
:title="$t('aiChat.stopping')"
:aria-label="$t('aiChat.stopping')"
disabled
>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
</button>
<button
v-else-if="stopBtnState === 'retry'"
type="button"
class="ai-send-btn ai-send-btn--stop ai-send-btn--retry"
:title="$t('aiChat.stopFailedRetry')"
:aria-label="$t('aiChat.stopFailedRetry')"
@click="store.stopChat"
>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
<button
v-else-if="stopBtnState === 'stop'"
type="button"
class="ai-send-btn ai-send-btn--stop"
:title="$t('aiChat.stopGenerating')"
:aria-label="$t('aiChat.stopGenerating')"
@click="store.stopChat"
>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
</button>
<button
v-else
type="button"
class="ai-send-btn"
:class="{ 'ai-send-btn--active': canSend }"
:disabled="!canSend"
:aria-label="$t('aiChat.queueSendNow')"
@click="handleSend"
>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
@@ -128,7 +136,9 @@ import { ref, computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { useProjectStore } from '../../stores/project'
import { useAppSettingsStore } from '../../stores/appSettings'
import { getConvState } from '../../composables/ai/useAiEvents'
import { continueList, exitListOnBackspace } from '../../composables/ai/useListContinuation'
import { textIdle } from '../../composables/ai/useAiPendingState'
import { getConvDraft, setConvDraft, classifySendError, type SendErrorKind } from '../../composables/ai/aiShared'
import { extractImageUrlParts } from '../../composables/ai/utils'
@@ -158,6 +168,7 @@ const emit = defineEmits<{
const store = useAiStore()
const projectsStore = useProjectStore()
const appSettings = useAppSettingsStore()
const { t } = useI18n()
const inputText = ref('')
@@ -803,6 +814,33 @@ function selectMention(item: MentionItem) {
})
}
// ── 列表续写开关(设计:docs/02-架构设计/专项设计/输入框列表预览与自动编号-设计-2026-10-10.md,P1=V0 零视觉) ──
// appSettings 键 `df-ai-list-input`,默认关:关=完全回退现状 textarea 行为,零残留。
// 逻辑层为纯函数(useListContinuation),此处只做接线(键分支 + 应用编辑 + @span 平移)。
const listInputEnabled = computed(() => appSettings.get<boolean>('df-ai-list-input', false))
/** 当前光标位(textarea 未挂载时退化为文末) */
function caretOffset(): number {
return inputEl.value?.selectionStart ?? inputText.value.length
}
/** 应用列表续写/退出的编辑结果:改文本 → 显式平移 @span(程序赋值不派发 input)→ 回光标 + 重算高度 */
function applyListEdit(r: { text: string; caret: number }): void {
const old = inputText.value
inputText.value = r.text
// 程序赋值不触发 input 事件(见 onInputText 说明),mention span 须手动平移,同 shiftMentionSpans 调用范式
shiftMentionSpans(old, r.text)
nextTick(() => {
const el = inputEl.value
if (el) {
el.focus()
el.setSelectionRange(r.caret, r.caret)
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, INPUT_MAX_HEIGHT) + 'px'
}
})
}
function handleKeydown(e: KeyboardEvent) {
// P1-1 输入法组词守卫:拼音/双拼组词未上屏时 Enter=选词、Esc=取消组词,均非用户发送/取消意图,
// 直接放行给 IME。isComposing 覆盖标准浏览器;keyCode===229 兜部分环境 keydown 阶段不上报 isComposing。
@@ -895,6 +933,28 @@ function handleKeydown(e: KeyboardEvent) {
return
}
}
// 列表续写(开关 df-ai-list-input,默认关):空项退格退出 / Shift+Enter 续写补号。
// 插在技能·@ 浮层导航之后(浮层开时不拦,用户意图是选实体)、Enter 发送之前;
// 只绑 Shift+Enter(换行链),Enter 发送语义不变。关闭时整段短路 → 零行为变更。
if (listInputEnabled.value && !skillOpen.value && !mentionOpen.value) {
const withMod = e.ctrlKey || e.metaKey || e.altKey
if (e.key === 'Backspace' && !e.shiftKey && !withMod) {
const r = exitListOnBackspace(inputText.value, caretOffset())
if (r) {
e.preventDefault()
applyListEdit(r)
return
}
}
if (e.key === 'Enter' && e.shiftKey && !withMod) {
const r = continueList(inputText.value, caretOffset())
if (r) {
e.preventDefault()
applyListEdit(r)
return
}
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
+15 -4
View File
@@ -47,7 +47,7 @@
}"
tabindex="0"
@click="onSelectSearchResult(conv.id)"
@keydown="onItemKeydown($event, conv.id)"
@keydown="onItemKeydown($event, conv.id, true)"
>
<div class="ai-conv-item-main">
<!-- UX走查 P3-1: 搜索态此前无重命名入口(无 startRename 分支),与分组视图降级不一致;
@@ -140,7 +140,7 @@
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
tabindex="0"
@click="beginSwitch(conv.id)"
@keydown="onItemKeydown($event, conv.id)"
@keydown="onItemKeydown($event, conv.id, true)"
>
<div class="ai-conv-item-main">
<input
@@ -220,6 +220,7 @@
:title="t('aiChat.recoverableHint')"
@click="onRecover(conv.id)"
@keydown.enter.prevent="onRecover(conv.id)"
@keydown.space.prevent="onRecover(conv.id)"
>
<div class="ai-conv-item-main">
<span class="ai-conv-item-title">
@@ -429,13 +430,19 @@ function onSearchKeydown(e: KeyboardEvent) {
}
// UX走查 P2-6: 会话项键盘可达(tabindex=0)。Enter/Space 等价点击选中;仅焦点落在本项 div
// 自身时处理——焦点在内部按钮/重命名输入框时 target≠currentTarget,避免与控件自身语义双触发
function onItemKeydown(e: KeyboardEvent, convId: string) {
// 自身时处理——焦点在内部按钮/重命名输入框时 target≠currentTarget,避免与控件自身语义双触发。
// renamable=true 的会话项(搜索结果/时间分组,即双击标题可重命名的项)额外支持 F2 键盘重命名,
// 作为 .ai-conv-item-title 双击入口的键盘等价(文件管理器惯例;归档项不支持重命名故不传该标志)。
function onItemKeydown(e: KeyboardEvent, convId: string, renamable = false) {
if (e.isComposing || e.keyCode === 229) return
if (e.target !== e.currentTarget) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
beginSwitch(convId)
} else if (renamable && e.key === 'F2') {
e.preventDefault()
const conv = store.state.conversations.find(c => c.id === convId)
if (conv) startRename(conv)
}
}
@@ -791,6 +798,8 @@ body.ai-sidebar-resizing * {
font-size: 12px;
color: var(--df-text);
}
/* 聚焦可见环:无边框输入聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
.ai-conv-search-input:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
.ai-conv-search-input::placeholder {
color: var(--df-text-dim);
}
@@ -916,6 +925,8 @@ body.ai-sidebar-resizing * {
outline: none;
box-sizing: border-box;
}
/* 聚焦可见环:边框恒为 accent 聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
.ai-conv-item-edit:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
.ai-conv-item-time {
font-size: 9px;
color: var(--df-text-dim);
+1
View File
@@ -14,6 +14,7 @@
<button
v-for="opt in helpActive.options"
:key="opt"
type="button"
class="ai-help-required-btn"
@click="handleSelectOption(opt)"
>{{ opt }}</button>
+14 -7
View File
@@ -8,6 +8,7 @@
//! (含流式分块、工具卡片、token 用量、错误重试、压缩摘要置顶等聚合逻辑),
//! 本组件专注 user,不做 AI 消息渲染,避免两套渲染入口并存导致的死代码与职责混乱。
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AiMessage, ContentPart, MentionSpan } from '../../api/types'
import { useAiStore } from '../../stores/ai'
@@ -30,7 +31,7 @@ function leadingSkill(msg: AiMessage): { name: string; rest: string } | null {
return { name: m[1], rest: m[2] ?? '' }
}
defineProps<{
const props = defineProps<{
msg: AiMessage
/** 是否显示编辑按钮(父合成:末条 user + 非生成中 + 非编辑中) */
canEdit?: boolean
@@ -92,6 +93,12 @@ function imageSrc(img: ImageContentPart): string {
function imgAlt(img: ImageContentPart): string {
return img.alt || t('aiChat.imagePreviewAlt')
}
// 模板中同一渲染内多次引用的纯派生值 → computed 缓存一次求值
// (纯函数仅依赖 props.msg / store.state.skills 等响应式源,缓存语义与原先逐次调用等价)
const skill = computed(() => leadingSkill(props.msg))
const segments = computed(() => segmentUserContent(props.msg))
const imageParts = computed(() => msgImageParts(props.msg))
</script>
<template>
@@ -107,19 +114,19 @@ function imgAlt(img: ImageContentPart): string {
</div>
<div class="ai-msg-bubble ai-msg-bubble--user">
<!-- 问题3 技能调用留痕:前导 /技能名 命中已安装技能 → 紫色 chip 标识,替代原文前缀显示 -->
<template v-if="leadingSkill(msg)">
<span class="ai-msg-chip ai-msg-chip--skill" :title="t('aiChat.skillBadgeTip', { name: leadingSkill(msg)!.name })">⚡ /{{ leadingSkill(msg)!.name }}</span>
<template v-if="leadingSkill(msg)!.rest">{{ ' ' + leadingSkill(msg)!.rest }}</template>
<template v-if="skill">
<span class="ai-msg-chip ai-msg-chip--skill" :title="t('aiChat.skillBadgeTip', { name: skill!.name })">⚡ /{{ skill!.name }}</span>
<template v-if="skill!.rest">{{ ' ' + skill!.rest }}</template>
</template>
<template v-else>
<template v-for="(seg, idx) in segmentUserContent(msg)" :key="msg.id + '-seg-' + idx">
<template v-for="(seg, idx) in segments" :key="msg.id + '-seg-' + idx">
<span v-if="seg.type === 'text'">{{ seg.text }}</span>
<span v-else class="ai-msg-chip" :class="'ai-msg-chip--' + seg.span.kind">{{ seg.span.label }}</span>
</template>
</template>
<template v-if="msgImageParts(msg).length">
<template v-if="imageParts.length">
<div class="ai-msg-images">
<img v-for="(img, idx) in msgImageParts(msg)" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" />
<img v-for="(img, idx) in imageParts" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" />
</div>
</template>
<!-- UX-09:末条 user 消息编辑按钮(hover 显示),点击经 start-edit 链回填输入框编辑重发 -->
+194 -107
View File
@@ -22,7 +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 { getConvAgentFinished, toolEpoch, type AgentFinishedCard } from '../../composables/ai/aiShared'
import { aiApi } from '../../api'
import ToolCardList from '../ToolCardList.vue'
import MessageItem from './MessageItem.vue'
@@ -420,22 +420,16 @@ function onPendingOutOfView(): void {
}
/**
* 移除 deep:true,改在 watch source 中计算指纹(消息数 + toolCalls 的 id/status),
* 避免 deep 每帧全量深遍历长会话数组引起掉帧。流式 delta 只改 content 不影响指纹,
* 不触发无意义的收起重算。length 变化(新消息/截断)和 toolCall 状态翻转(新卡/完成/拒绝)
* 均能正确触发——与 body 的快照收缩逻辑等价,无需 deep 兜底。
* 工具卡自动收起:工具状态翻转(新卡/完成/待审批/拒绝)时收起旧的已完成/已拒绝卡片,
* 保留 running/pending/write_file 活跃卡片。
*
* P0 渲染层:源从 `map(toolCalls→{id,s}) + JSON.stringify` 的 O(n) 指纹改为 toolEpoch 版本计数(O(1))。
* 版本号在工具态突变点(aiShared.bumpToolEpoch)自增,免每次工具事件全量遍历长会话数组。
* messages.length 变化(新消息/截断/切会话)同样触发(messages 长度与 toolEpoch 任一变即回调)。
*/
let lastMsgSnapshot = ''
watch(
() => JSON.stringify({
n: store.state.messages.length,
tc: store.state.messages.map(m => (m.toolCalls || []).map(t => ({ id: t.id, s: t.status }))),
}),
(snap) => {
if (snap === lastMsgSnapshot) return
const isFirst = lastMsgSnapshot === ''
lastMsgSnapshot = snap
if (isFirst) return
[() => store.state.messages.length, toolEpoch],
() => {
// R10 同门:非跟随态(上滚阅读中)不强制收起正浏览的工具卡
if (isFollowingBottom.value) collapseAllToolLists(buildActiveToolIds(store.state.messages))
},
@@ -683,7 +677,12 @@ const DECO_CACHE_LIMIT = 800
function decorateCodeHtml(raw: string): string {
if (!raw || !raw.includes('<pre><code')) return raw
const hit = _decoCache.get(raw)
if (hit !== undefined) return hit
if (hit !== undefined) {
// LRU:命中即置为最近使用(delete+set 移到 Map 末尾),淘汰时优先保留热项
_decoCache.delete(raw)
_decoCache.set(raw, hit)
return hit
}
// marked 的 code renderer 统一输出 <pre><code class="hljs ...">…</code></pre>
//(含缩进码块),代码内容已 HTML 转义,正则非贪婪到首个闭合标签不会越界。
// R4 语言角标:code class 形如 hljs language-ts,捕获 language-xxx 输出到 .md-code-block
@@ -696,7 +695,11 @@ function decorateCodeHtml(raw: string): string {
return `<div class="md-code-block"${langAttr}><button type="button" class="md-code-copy">复制</button><pre><code${attrs}>${body}</code></pre></div>`
},
)
if (_decoCache.size > DECO_CACHE_LIMIT) _decoCache.clear()
// 有界 LRU 淘汰:超限删最久未用项(Map 首项)后插入,替代原整体 clear
if (_decoCache.size >= DECO_CACHE_LIMIT) {
const oldest = _decoCache.keys().next().value
if (oldest !== undefined) _decoCache.delete(oldest)
}
_decoCache.set(raw, out)
return out
}
@@ -1029,117 +1032,173 @@ type RenderItem =
* 无过程消息的轮次(单轮问答)零改动原样渲染。
*
* UX 密度改造:相邻消息间隔 ≥30 分钟或跨天时插居中时间分隔线(timesep 项),
* per-block 相对时间已移除。轨距追踪 lastMsgTs 只在消息桶边界推进,
* per-block 相对时间已移除。轨距追踪 lastTs(renderItems 局部)只在消息桶边界推进,
* 轨迹卡与其内部轮次之间不插线(同轮间隔无叙事价值)。轨迹桶自身也不再发
* timesep 行:卡头「第 N 轮」+ 绝对起始时刻已承载时间行职责(两行合一)。
*/
const TIME_SEP_GAP_MS = 30 * 60 * 1000
let lastMsgTs = 0
/** 与上一条已渲染消息是否需要时间分隔线(间隔 ≥30 分钟或跨天;首条不插) */
function needsTimeSep(ts: number): boolean {
if (!lastMsgTs || !ts) return false
if (Math.abs(ts - lastMsgTs) >= TIME_SEP_GAP_MS) return true
const a = new Date(lastMsgTs)
/** 与上一时刻是否需要时间分隔线(间隔 ≥30 分钟或跨天;首条不插)。
* prevTs 由调用方传入(桶级缓存不持跨桶可变态,改参数化保纯)。 */
function needsTimeSep(ts: number, prevTs: number): boolean {
if (!prevTs || !ts) return false
if (Math.abs(ts - prevTs) >= TIME_SEP_GAP_MS) return true
const a = new Date(prevTs)
const b = new Date(ts)
return a.getFullYear() !== b.getFullYear()
|| a.getMonth() !== b.getMonth()
|| a.getDate() !== b.getDate()
}
/** 需要时插 timesep 项并推进 lastMsgTs(同一出口防漏插/重插) */
function pushTimeSepIfNeeded(items: RenderItem[], ts: number): void {
if (needsTimeSep(ts)) {
items.push({ kind: 'timesep', key: 'tsep-' + ts + '-' + items.length, ts })
// ── 桶级 memo(P0 渲染层):同签名桶复用同一批 item 对象 ──
// 两重收益:①工具事件时 O(n)→O(变更桶)(其余桶命中缓存不重算);②item 对象引用稳定
// → 模板 v-memo 的 [item] dep 生效前提(桶未变 → item 同引用 → v-memo 跳过该 item 的 patch)。
// 签名覆盖 flushBucket 产出真正消费的全部输入(消息身份/文本/token + 工具态 + 轮号 + 生成态 +
// 前桶时刻 + 轨迹展开态);漏字段 → 命中陈旧 item → 局部不刷新,故从宽纳入。LRU 有界(沿用 _decoCache 范式)。
const _bucketCache = new Map<string, { items: RenderItem[]; endTs: number }>()
const BUCKET_CACHE_LIMIT = 128
function bucketSig(msgs: AiMessage[], round: number, executing: boolean, prevTs: number, traceExpanded: boolean): string {
const parts: string[] = [round + '#' + (executing ? 1 : 0) + '#' + prevTs + '#' + (traceExpanded ? 1 : 0)]
for (const m of msgs) {
let s = m.id + ':' + m.role + ':' + (m.status ?? '') + ':' + (m.isError ? 1 : 0) + ':' + (m.isRetry ? 1 : 0)
+ ':' + (m.timestamp ?? 0) + ':' + (m.model ?? '')
+ ':' + (m.content ?? '') + ':' + (m.reasoning_content ?? '')
+ ':' + (m.tokenUsage?.prompt ?? m.prompt_tokens ?? '')
+ ':' + (m.tokenUsage?.completion ?? m.completion_tokens ?? '')
+ ':' + (m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? '')
+ ':' + (m.tokenUsage?.cache_miss ?? m.prompt_cache_miss_tokens ?? '')
+ ':' + (m.tokenUsage?.reasoning ?? m.reasoning_tokens ?? '')
+ ':' + (m.tokenUsage?.is_estimated ? 1 : 0)
+ ':' + (m.parts?.length ?? 0) + ':' + (m.mentionSpans?.length ?? 0)
for (const t of m.toolCalls ?? []) {
s += ',' + t.id + ':' + t.status + ':' + (t.completedAt ?? 0)
}
parts.push(s)
}
lastMsgTs = ts
return parts.join('|')
}
/** 无缓存构建一个桶的 item(重活:collectTurnStats/sumTurnTokens/isToolResultJson 均在此)。
* 返回 endTs(末条时刻)= 该桶结束后应作为下一桶 prevTs 的值。 */
function genBucketItems(
msgs: AiMessage[],
round: number,
executing: boolean,
prevTs: number,
traceKey: string,
traceExpanded: boolean,
): { items: RenderItem[]; endTs: number } {
const items: RenderItem[] = []
const cur = msgs[msgs.length - 1]
const pushSep = (ts: number) => {
if (needsTimeSep(ts, prevTs)) items.push({ kind: 'timesep', key: 'tsep-' + ts + '-' + items.length, ts })
}
// BUG-261001:执行中分支仅作用于「正在生成」的轮次桶(含 store 末条消息的桶)。
// 原实现只判 isViewingGenerating——只要会话在生成,全部轮次桶(含早已完成的历史轮次)
// 都被降级为不可交互的 step 摘要行,历史轨迹卡展开/折叠点击全部失效
// (用户表象:新一轮执行时,上一轮轨迹也"不可点击")。历史桶保持完成态轨迹卡可点。
if (executing) {
const prev = msgs.slice(0, -1)
if (prev.length > 0) {
pushSep(msgs[0].timestamp)
// 闪烁修复(2026-10-09):step 项 key 与下方完成态 trace 项对齐(同为 trace-{首条id}),
// 使「执行中 step → 完成态 trace」切换时 Vue 复用同一 DOM 节点(key 稳定),不销毁重建。
items.push({ kind: 'step', key: 'trace-' + prev[0].id, round, stats: collectTurnStats(msgs) })
} else {
pushSep(cur.timestamp)
}
items.push({ kind: 'msg', key: 'm-' + cur.id, msg: cur, final: true })
return { items, endTs: cur.timestamp }
}
// 最终答复:末条有正文且非纯 JSON 工具结果的消息;全无正文时保底末条(工具轮可见)
let fi = -1
for (let i = msgs.length - 1; i >= 0; i--) {
const c = msgs[i].content
if (c && c.trim() && !isToolResultJson(c)) { fi = i; break }
}
const finalMsg = fi >= 0 ? msgs[fi] : msgs[msgs.length - 1]
// 过程消息 = finalMsg 之外全部(含其后尾气泡):异常收尾(错误/停止/流中断)时末轮气泡
// 常只有 toolCalls 无正文,按「finalMsg 之前」切片会把尾气泡丢出渲染与轨迹卡,
// 执行中可见的工具卡在收尾瞬间整块消失(用户实测「空白覆盖一大堆内容」)。
const traceMsgs = msgs.filter(m => m !== finalMsg)
// 完成:轨迹折叠为一张卡,轮次内容在卡内 rail 面板展开(不再摊平成独立消息项)
if (traceMsgs.length > 0) {
// 终点计入末轮时长:过程消息里最晚的工具完成时刻与末轮开始时刻取较大者
let lastCompleted = 0
for (const m of traceMsgs) {
for (const tc of m.toolCalls || []) {
if (tc.completedAt && tc.completedAt > lastCompleted) lastCompleted = tc.completedAt
}
}
// 轨迹桶不再发 timesep 行:卡头「第 N 轮」+ 绝对起始时刻已承载时间行职责;
// 仅推进轨距,保持出口语义(后续消息的间隔判定从本桶起点起算)
items.push({
kind: 'trace', key: traceKey,
round,
stats: collectTurnStats(traceMsgs),
startTs: traceMsgs[0].timestamp,
endTs: Math.max(lastCompleted, finalMsg.timestamp),
tokens: sumTurnTokens(traceMsgs),
traceMsgs, expanded: traceExpanded,
})
} else {
pushSep(finalMsg.timestamp)
}
// 结果/过程分离(2026-10-10 用户口径:过程太多压住结果、密度过大):
// 气泡**只放最终结果**(finalMsg 正文);本桶其余轮的叙述与工具卡全收进「执行轨迹卡」(默认一行
// 收起,卡内展开可看逐轮全文与工具卡)——既不再"流式后正文消失",又不再"一条消息回 N 个气泡"。
items.push({ kind: 'msg', key: 'm-' + finalMsg.id, msg: finalMsg, final: true })
return { items, endTs: finalMsg.timestamp }
}
/** 桶 memo 取用:签名命中回同一对象数组(引用稳定);未命中构建 + 有界 LRU 写入(命中置末)。 */
function buildBucket(msgs: AiMessage[], round: number, executing: boolean, prevTs: number): { items: RenderItem[]; endTs: number } {
const traceKey = 'trace-' + msgs[0].id
const traceExpanded = expandedTraceIds.value.has(traceKey)
const sig = bucketSig(msgs, round, executing, prevTs, traceExpanded)
const hit = _bucketCache.get(sig)
if (hit) {
_bucketCache.delete(sig)
_bucketCache.set(sig, hit)
return hit
}
const built = genBucketItems(msgs, round, executing, prevTs, traceKey, traceExpanded)
if (_bucketCache.size >= BUCKET_CACHE_LIMIT) {
const oldest = _bucketCache.keys().next().value
if (oldest !== undefined) _bucketCache.delete(oldest)
}
_bucketCache.set(sig, built)
return built
}
const renderItems = computed<RenderItem[]>(() => {
const items: RenderItem[] = []
let bucket: AiMessage[] = []
lastMsgTs = 0
let lastTs = 0
// 轨迹序号计数:轨迹卡头「第 N 轮」与执行中步骤行同一来源(computed 重算从 0 起,
// 计数即轨迹卡出现顺序,与轮次导航条序号一致)
let traceCount = 0
const pushSep = (ts: number) => {
if (needsTimeSep(ts, lastTs)) {
items.push({ kind: 'timesep', key: 'tsep-' + ts + '-' + items.length, ts })
}
lastTs = ts
}
const flushBucket = () => {
const msgs = bucket.filter(shouldRenderMsg)
bucket = []
if (msgs.length === 0) return
// BUG-261001:执行中分支仅作用于「正在生成」的轮次桶(含 store 末条消息的桶)。
// 原实现只判 isViewingGenerating——只要会话在生成,全部轮次桶(含早已完成的历史轮次)
// 都被降级为不可交互的 step 摘要行,历史轨迹卡展开/折叠点击全部失效
// (用户表象:新一轮执行时,上一轮轨迹也"不可点击")。历史桶保持完成态轨迹卡可点。
const lastStoreId = store.state.messages[store.state.messages.length - 1]?.id
const cur = msgs[msgs.length - 1]
if (isViewingGenerating.value && cur.id === lastStoreId) {
const prev = msgs.slice(0, -1)
if (prev.length > 0) {
pushTimeSepIfNeeded(items, msgs[0].timestamp)
traceCount++
// 闪烁修复(2026-10-09):step 项 key 与下方完成态 trace 项对齐(同为 trace-{首条id}),
// 使「执行中 step → 完成态 trace」切换时 Vue 复用同一 DOM 节点(key 稳定),不销毁重建。
// 原 step-{id} 与 trace-{id} key 不同 → 完成瞬间整块轨迹 DOM 重建 → 闪烁。
items.push({ kind: 'step', key: 'trace-' + prev[0].id, round: traceCount, stats: collectTurnStats(msgs) })
} else {
pushTimeSepIfNeeded(items, cur.timestamp)
}
items.push({ kind: 'msg', key: 'm-' + cur.id, msg: cur, final: true })
lastMsgTs = cur.timestamp
return
}
// 最终答复:末条有正文且非纯 JSON 工具结果的消息;全无正文时保底末条(工具轮可见)
let fi = -1
for (let i = msgs.length - 1; i >= 0; i--) {
const c = msgs[i].content
if (c && c.trim() && !isToolResultJson(c)) { fi = i; break }
}
const finalMsg = fi >= 0 ? msgs[fi] : msgs[msgs.length - 1]
// 过程消息 = finalMsg 之外全部(含其后尾气泡):异常收尾(错误/停止/流中断)时末轮气泡
// 常只有 toolCalls 无正文,按「finalMsg 之前」切片会把尾气泡丢出渲染与轨迹卡,
// 执行中可见的工具卡在收尾瞬间整块消失(用户实测「空白覆盖一大堆内容」)。
const traceMsgs = msgs.filter(m => m !== finalMsg)
// 完成:轨迹折叠为一张卡,轮次内容在卡内 rail 面板展开(不再摊平成独立消息项)
if (traceMsgs.length > 0) {
const key = 'trace-' + traceMsgs[0].id
const expanded = expandedTraceIds.value.has(key)
// 终点计入末轮时长:过程消息里最晚的工具完成时刻与末轮开始时刻取较大者
let lastCompleted = 0
for (const m of traceMsgs) {
for (const tc of m.toolCalls || []) {
if (tc.completedAt && tc.completedAt > lastCompleted) lastCompleted = tc.completedAt
}
}
// 轨迹桶不再发 timesep 行:卡头「第 N 轮」+ 绝对起始时刻已承载时间行职责;
// 仅推进轨距,保持出口语义(后续消息的间隔判定从本桶起点起算)
lastMsgTs = traceMsgs[0].timestamp
traceCount++
items.push({
kind: 'trace', key,
round: traceCount,
stats: collectTurnStats(traceMsgs),
startTs: traceMsgs[0].timestamp,
endTs: Math.max(lastCompleted, finalMsg.timestamp),
tokens: sumTurnTokens(traceMsgs),
traceMsgs, expanded,
})
} else {
pushTimeSepIfNeeded(items, finalMsg.timestamp)
}
// 正文不折叠(2026-10-10 根因修复):此前整轮(含模型正文)折进轨迹卡默认收起 → 流式期间
// 能看到正文、轮末正文随卡收起,用户表象为「气泡被清空/进入等待」,历次在 flush/清空/骨架层
// 修补均无效。现把过程消息里**有正文**的各成一个常显气泡(剥掉 toolCalls——工具卡仍归轨迹卡,
// 不重复渲染),轨迹卡只折叠工具步骤与思考。
for (const m of traceMsgs) {
const c = m.content
if (m.role === 'assistant' && c && c.trim() && !isToolResultJson(c)) {
items.push({ kind: 'msg', key: 'm-' + m.id, msg: { ...m, toolCalls: undefined } })
}
}
items.push({ kind: 'msg', key: 'm-' + finalMsg.id, msg: finalMsg, final: true })
lastMsgTs = finalMsg.timestamp
const executing = isViewingGenerating.value && msgs[msgs.length - 1].id === lastStoreId
// 轨迹/步骤项产生时轮号自增(仅 msgs.length>1 会产出 step/trace;轮号计入桶签名,
// 前序桶变化致轮号位移时该桶正确重算)
const round = msgs.length > 1 ? ++traceCount : traceCount
const built = buildBucket(msgs, round, executing, lastTs)
for (const it of built.items) items.push(it)
lastTs = built.endTs
}
for (const seg of messageSegments.value) {
@@ -1149,7 +1208,7 @@ const renderItems = computed<RenderItem[]>(() => {
items.push({ kind: 'sep', key: seg.key, seg })
if (expandedSegmentIds.value.has(seg.key)) {
for (const m of seg.msgs) {
pushTimeSepIfNeeded(items, m.timestamp)
pushSep(m.timestamp)
items.push({ kind: 'msg', key: 'e-' + seg.key + '-' + m.id, msg: m })
}
}
@@ -1161,7 +1220,7 @@ const renderItems = computed<RenderItem[]>(() => {
bucket.push(m)
} else {
flushBucket()
pushTimeSepIfNeeded(items, m.timestamp)
pushSep(m.timestamp)
items.push({ kind: 'msg', key: 'm-' + m.id, msg: m })
}
}
@@ -1186,6 +1245,23 @@ const renderItems = computed<RenderItem[]>(() => {
return items
})
// ── v-memo 依赖数组(P0 渲染层,内联在模板 v-for 的 v-memo 处) ──
// renderItems(computed)已读源经 [item] 自动失效(桶 memo 保证桶未变时 item 引用稳定 → 跳过 patch);
// v-memo 额外补「renderItems 未读、但渲染该 item 时读了」的兄弟响应式源 —— 漏一个 → 该项局部不刷新:
// - expandedThinkIds / expandedJsonIds / expandedLongIds:思考 pill / JSON 块 / 长文折叠展开态
// (整体 Set 引用纳入;useToggleSet 每次 toggle 换新 Set → 引用变 → 该项重渲);
// - tokenPopoverMsgId / editingMsgId / agentPeekLoadingId:token 面板 / user 编辑按钮 / 子代理 peek 转圈;
// - store.state.streaming / isViewingGenerating:光标、user 编辑按钮 can-edit、流式块闸直接读的源
// (非 renderItems 已读,须显式纳入,否则流式启停该 item 不刷新);
// - _mdRenderKey:marked 就绪后 forceUpdate 递增、经 renderMdDecorated 读;taskTitleCache:任务 chip 标题懒加载。
// - 流式文本源(定向 dep):流式 delta 只改 currentText→streamingBlocks→decoratedStreamingBlocks,
// 不改任何消息 content、不改桶签名 → item 引用稳定。若此处不纳入流式源,流式 item 的 v-memo 恒命中,
// 子树跳过 patch,气泡冻结在首个空帧(要等工具事件/收尾才整段出现)。
// 只对「正在渲染流式块的 item」(isLastAi + streaming,即模板内 v-if 渲染 decoratedStreamingBlocks 的那一项)
// 纳入 decoratedStreamingBlocks(每 rAF 换新数组引用 → dep 失效 → 该项重渲),其余 item 取常量 '' 占位,
// 使 v-memo 仅在流式项失效,空闲/历史项不重渲的 P0 收益不受影响。
// 注:vue-tsc 不解析 v-memo 表达式,抽独立函数会触发 noUnusedLocals 误报,故直接内联数组。
// ── S1 子代理完成卡(AiAgentFinished) ──
// 状态→色调三态:completed 中性(常态结果不抢视线)/ truncated 警示(结果不完整)/
// failed 错误(需关注);未知的 status 字面量兜底中性,不误报成功也不误报失败。
@@ -1502,7 +1578,7 @@ defineExpose({
每项外层包 .ai-msg-slot(虚拟滚动已移除,现恒渲染;
slot 仅作 flex 子占位 + 消息分组的语义容器,gap 由 .ai-messages 承担)。
UX 密度改造:timesep 项=相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线。 -->
<template v-for="item in renderItems" :key="item.key">
<template v-for="item in renderItems" :key="item.key" v-memo="[item, store.state.streaming, isViewingGenerating, expandedThinkIds, expandedJsonIds, expandedLongIds, tokenPopoverMsgId, editingMsgId, agentPeekLoadingId, _mdRenderKey, taskTitleCache, item.kind === 'msg' && isLastAi(item.msg) && store.state.streaming ? decoratedStreamingBlocks : '']">
<!-- 执行轨迹卡(完成轮次:过程内容折叠为一张,默认一行聚合,点开在卡内 rail 面板回看
每轮思考 pill + 工具卡;头部聚合行含调用/耗时/全程 token 汇总) -->
<div v-if="item.kind === 'trace'" class="ai-msg-slot ai-msg-slot--proc">
@@ -1526,8 +1602,12 @@ defineExpose({
v-for="ref in traceTaskRefs(item.traceMsgs)"
:key="ref.taskId"
class="ai-trace-task-chip"
role="button"
tabindex="0"
:title="$t('ai.trace.taskRef', { name: taskTitleOf(ref.taskId) })"
@click.stop="openTask(ref.taskId)"
@keydown.enter.stop.prevent="openTask(ref.taskId)"
@keydown.space.stop.prevent="openTask(ref.taskId)"
>🔗 {{ taskTitleOf(ref.taskId) }}</span>
<!-- 绝对起始时刻(mono 小字):轨迹桶已不发独立时间分隔线,时刻职责由卡头承载 -->
<span class="ai-trace-tokens">{{ formatAbsolute(item.startTs) }}</span>
@@ -1550,11 +1630,18 @@ defineExpose({
<span v-if="roundTokenText(m)" class="ai-think-tokens">{{ roundTokenText(m) }}</span>
</button>
<div
v-if="expandedThinkIds.has(m.id) && m.content?.trim()"
v-if="expandedThinkIds.has(m.id) && m.reasoning_content?.trim()"
class="ai-think-full ai-md"
v-html="renderMdDecorated(m.content)"
v-html="renderMdDecorated(m.reasoning_content)"
></div>
</template>
<!-- 轮内正文(该轮叙述):随卡默认收起(结果优先、控密度),展开轨迹卡即见全文。
复用 .ai-think-full 的弱化样式(12px/次要色/左缩进),零新增 CSS。 -->
<div
v-if="m.role === 'assistant' && m.content && m.content.trim()"
class="ai-think-full ai-md"
v-html="renderMdDecorated(m.content)"
></div>
<ToolCardList
v-if="m.toolCalls && m.toolCalls.length > 0"
ref="toolCardListRef"
+2 -2
View File
@@ -35,7 +35,7 @@
<div v-if="budgetOpen" class="ai-boundary-budget">
<div class="ai-boundary-field" :class="{ 'is-error': budgetErrField === 'rounds' }">
<span>{{ t('ai.taskBoundary.budgetRounds') }}</span>
<input v-model="rounds" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked || roundsUnlimited" @input="budgetErrField = ''" />
<input v-model="rounds" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :aria-label="t('ai.taskBoundary.budgetRounds')" :disabled="locked || roundsUnlimited" @input="budgetErrField = ''" />
<label class="ai-boundary-unlimited">
<input v-model="roundsUnlimited" type="checkbox" :disabled="locked" />
<span>{{ t('ai.taskBoundary.budgetUnlimited') }}</span>
@@ -43,7 +43,7 @@
</div>
<div class="ai-boundary-field" :class="{ 'is-error': budgetErrField === 'wallSecs' }">
<span>{{ t('ai.taskBoundary.budgetWallSecs') }}</span>
<input v-model="wallSecs" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :disabled="locked || wallUnlimited" @input="budgetErrField = ''" />
<input v-model="wallSecs" class="ai-boundary-input ai-boundary-input--num" type="text" inputmode="numeric" :aria-label="t('ai.taskBoundary.budgetWallSecs')" :disabled="locked || wallUnlimited" @input="budgetErrField = ''" />
<label class="ai-boundary-unlimited">
<input v-model="wallUnlimited" type="checkbox" :disabled="locked" />
<span>{{ t('ai.taskBoundary.budgetUnlimited') }}</span>
+48 -36
View File
@@ -209,7 +209,7 @@
<span v-if="g.status === 'active'" class="ai-goal-dot"></span>
<span v-else class="ai-goal-check">✓</span>
<span class="ai-goal-text">{{ g.text }}</span>
<button class="ai-goal-remove" @click.stop="removeGoal(i)">×</button>
<button class="ai-goal-remove" @click.stop="removeGoal(i)" :aria-label="$t('common.delete')">×</button>
</div>
</div>
</Transition>
@@ -222,7 +222,7 @@
</button>
<Transition name="ai-pop">
<div v-if="historyExpanded" class="ai-history-inline-list">
<div v-for="h in historyMsgs" :key="h.id" class="ai-history-inline-item" @click="scrollToMessage(h.id)">
<div v-for="h in historyMsgs" :key="h.id" class="ai-history-inline-item" role="button" tabindex="0" @click="scrollToMessage(h.id)" @keydown.enter.prevent="scrollToMessage(h.id)" @keydown.space.prevent="scrollToMessage(h.id)">
<span class="ai-history-index">{{ h.num }}</span>
<span class="ai-history-text">{{ h.text }}</span>
<span class="ai-history-time">{{ h.time }}</span>
@@ -238,7 +238,7 @@
</button>
<Transition name="ai-pop">
<div v-if="summaryExpanded" class="ai-summary-inline-list">
<div v-for="(s, i) in summaryMsgs" :key="i" class="ai-summary-inline-item" @click="scrollToMessage(s.id)">
<div v-for="(s, i) in summaryMsgs" :key="i" class="ai-summary-inline-item" role="button" tabindex="0" @click="scrollToMessage(s.id)" @keydown.enter.prevent="scrollToMessage(s.id)" @keydown.space.prevent="scrollToMessage(s.id)">
<span class="ai-summary-label">{{ $t('aiChat.compressedSummaryLabel') }} #{{ summaryMsgs.length - i }}</span>
<p class="ai-summary-preview">{{ s.text }}</p>
</div>
@@ -309,7 +309,7 @@ import { aiApi } from '../../api/ai'
import { pendingTaskProgress } from '../../composables/ai/useAiPendingState'
import { convRounds } from '../../composables/ai/aiShared'
import { switchConversation } from '../../composables/ai/useAiConversations'
import type { AiMessage, ModelConfig, GoalEntry } from '../../api/types'
import type { ModelConfig, GoalEntry } from '../../api/types'
defineProps<{
/** 分离窗口模式(嵌入 vs 分离决定顶部按钮组) */
@@ -595,6 +595,7 @@ const goalsExpanded = ref(false)
// ── 📋 历史消息(当前会话 user 消息列表) ──
interface HistoryItem { id: string; text: string; time: string; num: number }
interface SummaryItem { id: string; text: string }
/** 时间显示:今日 HH:mm / 昨天 HH:mm / MM-DD HH:mm */
function fmtTime(ts: number): string {
const d = new Date(ts)
@@ -616,17 +617,45 @@ function fmtTime(ts: number): string {
}
const MAX_HISTORY = 20
const MAX_CHARS = 80
const historyMsgs = computed<HistoryItem[]>(() => {
const allUserMsgs = store.state.messages
.filter((m): m is AiMessage & { timestamp: number } => m.role === 'user' && !!m.content && !!m.timestamp)
.map((m, idx) => ({
id: m.id,
text: m.content.length > MAX_CHARS ? m.content.slice(0, MAX_CHARS) + '…' : m.content,
time: fmtTime(m.timestamp),
num: idx + 1,
}))
return allUserMsgs.slice(-MAX_HISTORY).reverse()
// 单次遍历 messages,一次产出「历史(user 消息)/摘要(system 消息)/项目名」三路派生,
// 替代原先三处 computed 各自全量扫描(historyMsgs/summaryMsgs/contextInfo)。
// 三路的筛选条件、收集顺序、截断与反转语义与拆分前逐项一致。
const msgDerived = computed(() => {
const history: HistoryItem[] = []
const summaries: SummaryItem[] = []
const projectLabels = new Set<string>()
for (const m of store.state.messages) {
if (m.role === 'user') {
// 项目名收集只认 user 的 mentionSpans(不设 content/timestamp 门槛),
// 与原 contextInfo 的独立遍历同条件,故与下面的历史筛选互不干扰。
if (m.mentionSpans?.length) {
for (const sp of m.mentionSpans) {
if (sp.kind === 'project' && sp.label) projectLabels.add(sp.label)
}
}
if (m.content && m.timestamp) {
history.push({
id: m.id,
text: m.content.length > MAX_CHARS ? m.content.slice(0, MAX_CHARS) + '…' : m.content,
time: fmtTime(m.timestamp),
num: history.length + 1,
})
}
} else if (m.role === 'system' && m.content) {
summaries.push({
id: m.id,
text: m.content.length > 120 ? m.content.slice(0, 120) + '…' : m.content,
})
}
}
return {
history: history.slice(-MAX_HISTORY).reverse(),
summaries,
projectNames: Array.from(projectLabels).join(', '),
}
})
const historyMsgs = computed<HistoryItem[]>(() => msgDerived.value.history)
const summaryMsgs = computed<SummaryItem[]>(() => msgDerived.value.summaries)
const historyExpanded = ref(false)
/** 点击历史消息项 → 滚动定位到对应的消息气泡 */
@@ -672,15 +701,7 @@ function onBottomToolsClickOutside(e: MouseEvent) {
}
}
// ── 📄 上下文摘要(压缩/归档的系统消息) ──
interface SummaryItem { id: string; text: string }
const summaryMsgs = computed<SummaryItem[]>(() => {
return store.state.messages
.filter(m => m.role === 'system' && !!m.content)
.map(m => ({
id: m.id,
text: m.content.length > 120 ? m.content.slice(0, 120) + '…' : m.content,
}))
})
// SummaryItem 类型与 summaryMsgs 派生已上移至历史区(与 historyMsgs 共享单次遍历)。
const summaryExpanded = ref(false)
// ── L3 完整上下文 ──
@@ -707,22 +728,11 @@ function togglePanel(panel: 'goals' | 'history' | 'summary' | 'context' | 'runni
const contextInfo = computed<ContextInfo>(() => {
const convId = store.state.activeConversationId
const conv = convId ? store.state.conversations.find(c => c.id === convId) : undefined
// 从所有 user 消息中收集 mentionSpans(kind === 'project'),去重
const projectLabels = new Set<string>()
for (const m of store.state.messages) {
if (m.role === 'user' && m.mentionSpans?.length) {
for (const sp of m.mentionSpans) {
if (sp.kind === 'project' && sp.label) {
projectLabels.add(sp.label)
}
}
}
}
return {
goals: conv?.pinned_goals ?? [],
summaryCount: summaryMsgs.value.length,
summaryCount: msgDerived.value.summaries.length,
osInfo: 'Windows 11 / PowerShell',
projectNames: Array.from(projectLabels).join(', ') || t('aiChat.none'),
projectNames: msgDerived.value.projectNames || t('aiChat.none'),
}
})
@@ -916,6 +926,8 @@ onBeforeUnmount(() => {
outline: none;
cursor: pointer;
}
/* 聚焦可见环:聚焦态无边框变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
.ai-model-select:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
.ai-model-select--compact {
max-width: 130px;
}