优化: 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
+5 -3
View File
@@ -96,8 +96,8 @@
@keydown="onQueueEditKeydown"
@blur="saveEditQueued"
/>
<button class="ai-queue-btn" @mousedown.prevent="saveEditQueued" :title="$t('aiChat.queueSave')">✓</button>
<button class="ai-queue-btn" @mousedown.prevent="cancelEditQueued" :title="$t('aiChat.queueCancel')">✕</button>
<button class="ai-queue-btn" @mousedown.prevent="saveEditQueued" :title="$t('aiChat.queueSave')" :aria-label="$t('aiChat.queueSave')">✓</button>
<button class="ai-queue-btn" @mousedown.prevent="cancelEditQueued" :title="$t('aiChat.queueCancel')" :aria-label="$t('aiChat.queueCancel')">✕</button>
</template>
<template v-else>
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
@@ -110,7 +110,7 @@
<button class="ai-queue-btn" @click="startEditQueued(idx)" :title="$t('aiChat.queueEdit')">{{ $t('aiChat.queueEdit') }}</button>
<!-- 立即发送(打断当前+发本条) -->
<button class="ai-queue-btn" @click="handleSendQueuedNow(idx)" :title="$t('aiChat.queueSendNow')">{{ $t('aiChat.queueSendNow') }}</button>
<button class="ai-queue-x" @click="store.cancelQueued(idx)" :title="$t('common.cancel')">×</button>
<button class="ai-queue-x" @click="store.cancelQueued(idx)" :title="$t('common.cancel')" :aria-label="$t('common.cancel')">×</button>
</template>
</div>
</div>
@@ -1024,6 +1024,8 @@ async function initContextEventListeners(): Promise<void> {
border-radius: calc(var(--df-radius) - 2px);
outline: none;
}
/* 聚焦可见环:边框恒为 accent 聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
.ai-queue-edit-input:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
/* (.ai-btn-icon:disabled / .ai-btn-spinner / @keyframes ai-btn-spin 已抽 components.css 全局共享) */
+1 -1
View File
@@ -5,7 +5,7 @@
渲染非元素根节点(slot 透传)导致过渡动画警告/卡死(App.vue <transition name="page">)。 -->
<div class="error-boundary-root" :class="{ 'is-error': !!error }">
<template v-if="error">
<div class="error-boundary-icon">⚠</div>
<div class="error-boundary-icon" aria-hidden="true">⚠</div>
<div class="error-boundary-title">{{ $t('error.title') }}</div>
<div class="error-boundary-desc">{{ errorMessage }}</div>
<details class="error-boundary-details">
+48 -5
View File
@@ -24,7 +24,7 @@
<span class="ai-tool-name">{{ toolDisplayName(tc) }}</span>
<span v-if="tc.status === 'running'" class="ai-tool-sub">{{ $t('aiTool.running') }}</span>
<span v-else-if="isFailed" class="ai-tool-sub ai-tool-sub--failed" :title="summaryTitle">{{ $t('aiTool.executionFailed') }}</span>
<span v-else-if="toolResultSummary(tc)" class="ai-tool-sub" :title="summaryTitle">{{ toolResultSummary(tc) }}</span>
<span v-else-if="resultSummary" class="ai-tool-sub" :title="summaryTitle">{{ resultSummary }}</span>
</div>
<span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isFailed ? 'failed' : tc.status)" :title="statusTitle"></span>
<!-- 统一卡形态第4要素:耗时(前端 startedAt/completedAt 差;历史消息无时间戳隐藏不显 0)。
@@ -70,14 +70,14 @@
{{ argsExpanded ? $t('aiTool.argsCollapse') : $t('aiTool.argsMore', { n: argsCount - 1 }) }}
</button>
</div>
<div v-if="tc.reason" class="ai-tool-approval-reason">⚠ {{ tc.reason }}</div>
<div v-if="tc.reason" class="ai-tool-approval-reason"><span aria-hidden="true">⚠</span> {{ tc.reason }}</div>
<!-- BUG-260624-02: 审批到期倒计时(与前端自动拒绝时刻精确对齐,≤60s 红色脉冲);
无活跃计时器(不限时/已到期)回退显挂起时长(>2分钟橙色,>5分钟红色) -->
<div v-if="expireSecs !== null" class="ai-tool-wait-time" :class="'is-' + expireLevel">
{{ $t('aiTool.expireIn', { time: formatWaitTime(expireSecs) }) }}
</div>
<div v-else-if="waitSecs > 0" class="ai-tool-wait-time" :class="'is-' + waitLevel">
⏱ {{ $t('aiTool.waitTime', { time: formatWaitTime(waitSecs) }) }}
<span aria-hidden="true">⏱</span> {{ $t('aiTool.waitTime', { time: formatWaitTime(waitSecs) }) }}
</div>
<!-- AE-2025-03: write_file 审批 diff 预览(红删绿增,行级)。旧文件不存在→tc.diff 为空,回退显上方 args content -->
<div v-if="tc.diff" class="ai-tool-approval-diff">
@@ -137,14 +137,32 @@
<!-- 已拒绝提示 -->
<div v-if="tc.status === 'rejected'" class="ai-tool-rejected">
<span class="ai-tool-rejected-icon">✕</span>
<span class="ai-tool-rejected-icon" aria-hidden="true">✕</span>
{{ tc.result || $t('aiTool.rejectedHint') }}
</div>
<!-- D2a 外置结果(句柄 JSON):显「结果已外置」+ 展开;读回原文后复用 ToolResultBody 渲染 -->
<div v-if="tc.status === 'completed' && offloadHandle" class="ai-tool-offloaded">
<OffloadedResultBar
:handle="offloadHandle"
:expanded="offloadExpanded"
:loading="offloadLoading"
:error="offloadError"
@toggle="toggleOffload"
/>
<ToolResultBody
v-if="offloadExpanded && offloadResolved != null"
:tc="resolvedOffloadTc"
:isContentExpanded="isContentExpanded"
@expand-content="(id) => emit('expand-content', id)"
@install-obscura="() => emit('install-obscura')"
/>
</div>
<!-- 各工具结果展示(read_file/list_directory/search_files/write_file/patch_file/run_command/http_request/fetch_url/通用)
委托 ToolResultBody 子组件,透传 tc + 内容展开态 + expand-content/install-obscura 冒泡 -->
<ToolResultBody
v-if="tc.status === 'completed'"
v-else-if="tc.status === 'completed'"
:tc="tc"
:isContentExpanded="isContentExpanded"
@expand-content="(id) => emit('expand-content', id)"
@@ -179,6 +197,7 @@ import {
argString,
} from '@/composables/ai/useToolCard'
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
import { useOffloadedResult } from '@/composables/ai/useOffloadedResult'
import { useToolCardHeader } from '@/composables/ai/useToolCardHeader'
import { useToolApproval, toolRiskLevel } from '@/composables/ai/useToolApproval'
import { useTimerOwnership } from '@/composables/useTimerOwnership'
@@ -187,6 +206,7 @@ import { formatDate, formatDuration } from '@/utils/time'
import type { AiToolCallInfo } from '@/api/types'
import ConfirmDialog from './ConfirmDialog.vue'
import ToolResultBody from './ToolResultBody.vue'
import OffloadedResultBar from './OffloadedResultBar.vue'
// 审批按钮图标 SVG 常量(消除模板 6 处内联重复;v-html 渲染完整 svg 元素)
const ICON_CHECK = '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'
@@ -246,6 +266,27 @@ const {
/** 一次解析结果供失败判定复用(头部 isFailed 用于卡片 border/status-dot 着色) */
const parsed = computed(() => parseResult(props.tc.result))
/**
* D2a 外置句柄:completed 结果若是句柄 JSON(超 8KB 落盘、DB 只留句柄),
* 头部摘要与结果区改走「结果已外置」+ 展开读回,不把句柄当普通结果解析渲染。
*/
const {
handle: offloadHandle,
expanded: offloadExpanded,
loading: offloadLoading,
error: offloadError,
resolved: offloadResolved,
toggle: toggleOffload,
} = useOffloadedResult(() => (props.tc.status === 'completed' ? props.tc.result : null))
/** 读回后把原文塞回 tc.result,交 ToolResultBody 按工具名走既有渲染分支(零重复渲染逻辑) */
const resolvedOffloadTc = computed(() => ({ ...props.tc, result: offloadResolved.value ?? props.tc.result }))
/** 头部副标题:外置句柄显「结果已外置」,其余走既有摘要函数 */
const resultSummary = computed(() =>
offloadHandle.value ? t('aiTool.resultOffloaded') : toolResultSummary(props.tc),
)
/**
* 是否工具失败(completed 语义)。头部多处复用(.ai-tool-card--failed / 状态点 / sub 文案)。
* 逻辑与模块级 isToolFailure 同源;结果展示区的 isFailed 已迁入 ToolResultBody(各自 computed)。
@@ -253,6 +294,8 @@ const parsed = computed(() => parseResult(props.tc.result))
const isFailed = computed(() => {
const tc = props.tc
if (tc.status !== 'completed') return false
// 外置句柄是「结果留了指针」非执行失败,不参与失败判定
if (offloadHandle.value) return false
const r = parsed.value
if (tc.name === 'run_command') {
if (r && typeof r.exit_code === 'number') return r.exit_code !== 0
+4 -4
View File
@@ -134,7 +134,7 @@
<span class="ai-tool-file-meta">{{ formatSizeDiff(parsed?.size_diff) }} {{ $t('aiTool.bytesUnit') }}</span>
<span v-if="parsed?.matches_found && parsed.matches_found > 1" class="ai-tool-file-meta">{{ $t('aiTool.patchedMatches', { n: parsed.matches_found }) }}</span>
</div>
<div v-if="parsed?.warning" class="ai-tool-approval-reason">⚠ {{ parsed.warning }}</div>
<div v-if="parsed?.warning" class="ai-tool-approval-reason"><span aria-hidden="true">⚠</span> {{ parsed.warning }}</div>
<div v-if="resultDiffLines.length" class="ai-tool-approval-diff">
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
<!-- R12a:长 diff 展开入口(默认预览 120 行,点开全量) -->
@@ -177,7 +177,7 @@
>{{ httpBodyExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}</button>
</div>
<div v-if="parsed?.truncated && !httpBodyExpanded" class="ai-tool-http-trunc-warn">
⚠ {{ $t('aiTool.httpTruncated', { bytes: formatBytes(parsed?.body_bytes) }) }}
<span aria-hidden="true">⚠</span> {{ $t('aiTool.httpTruncated', { bytes: formatBytes(parsed?.body_bytes) }) }}
</div>
<pre v-if="httpBodyExpanded && parsed?.body" class="ai-tool-cmd-output"><code>{{ parsed.body }}</code></pre>
</div>
@@ -207,7 +207,7 @@
<div v-if="parsed?.title" class="ai-tool-fetch-title">{{ parsed?.title }}</div>
<!-- obscura 未装引导(render=true 但回退静态):提示 + "AI 帮装"按钮 -->
<div v-if="obscuraMissing" class="ai-tool-obscura-hint">
<div class="ai-tool-obscura-hint-text">⚠ {{ parsed?.hint || $t('aiTool.fetchUrlFallbackHint') }}</div>
<div class="ai-tool-obscura-hint-text"><span aria-hidden="true">⚠</span> {{ parsed?.hint || $t('aiTool.fetchUrlFallbackHint') }}</div>
<button class="ai-tool-btn ai-tool-btn--install" @click="onInstallObscura">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
{{ $t('aiTool.installObscura') }}
@@ -222,7 +222,7 @@
class="ai-tool-result"
:class="{ 'ai-tool-result--failed': isFailed }"
>
<div v-if="isFailed" class="ai-tool-failed-banner">⚠ {{ $t('aiTool.executionFailedHint') }}</div>
<div v-if="isFailed" class="ai-tool-failed-banner"><span aria-hidden="true">⚠</span> {{ $t('aiTool.executionFailedHint') }}</div>
<code>{{ formatToolResult(tc) }}</code>
</div>
</template>
+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;
}
+1 -1
View File
@@ -12,7 +12,7 @@
<div class="stat-card-inner">
<div class="stat-top">
<div class="stat-icon-wrap" :style="{ background: stat.iconBg }">
<span class="stat-icon">{{ stat.icon }}</span>
<span class="stat-icon" aria-hidden="true">{{ stat.icon }}</span>
</div>
</div>
<div class="stat-value">{{ stat.value }}</div>
+2 -2
View File
@@ -17,7 +17,7 @@
class="btn btn-primary btn-sm"
:to="`/projects/${idea.promoted_to}`"
>
🚀 {{ $t('ideas.promotedProject') }} →
<span aria-hidden="true">🚀</span> {{ $t('ideas.promotedProject') }} →
</router-link>
<span class="status-tag" :class="'status-' + idea.status">{{ $t(ideaStatusLabelKey(idea.status)) }}</span>
</div>
@@ -119,7 +119,7 @@
</button>
<!-- P1-⑥ 重试样式:错误提示分两行,重试用 ghost btn-sm(替代突兀的内联 accent 按钮) -->
<div v-if="evalError" class="eval-error">
<p class="eval-error-msg">⚠️ {{ evalError }}</p>
<p class="eval-error-msg"><span aria-hidden="true">⚠️</span> {{ evalError }}</p>
<button class="btn btn-ghost btn-sm btn-retry" :disabled="evaluating" @click="$emit('evaluate')">
{{ $t('ideas.retryEval') }}
</button>
+1 -1
View File
@@ -155,7 +155,7 @@
<div v-show="sectionOpen.lifecycle" class="section-body">
<div class="timeline">
<div v-for="(node, idx) in timelineNodes" :key="node.id" class="tl-node">
<div class="tl-dot">{{ node.icon }}</div>
<div class="tl-dot" aria-hidden="true">{{ node.icon }}</div>
<div v-if="idx < timelineNodes.length - 1" class="tl-line"></div>
<div class="tl-body">
<div class="tl-label">{{ node.label }}</div>
+5 -3
View File
@@ -24,6 +24,7 @@
class="nav-link"
active-class="nav-link--active"
:title="collapsed ? $t(item.label) : undefined"
:aria-label="collapsed ? $t(item.label) : undefined"
>
<span class="nav-link-icon" v-html="item.svg"></span>
<span v-if="!collapsed" class="nav-link-text">{{ $t(item.label) }}</span>
@@ -41,6 +42,7 @@
class="nav-link"
active-class="nav-link--active"
:title="collapsed ? $t(item.label) : undefined"
:aria-label="collapsed ? $t(item.label) : undefined"
>
<span class="nav-link-icon" v-html="item.svg"></span>
<span v-if="!collapsed" class="nav-link-text">{{ $t(item.label) }}</span>
@@ -52,7 +54,7 @@
<!-- Footer -->
<div class="sidebar-footer">
<!-- AI 面板切换按钮(footer 常驻跨路由渲染,承担后台运行中会话的全局可见标识) -->
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="runningConvCount > 0 ? `${runningConvCount} 个会话运行中` : (collapsed ? $t('nav.aiPanel') : undefined)">
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="runningConvCount > 0 ? `${runningConvCount} 个会话运行中` : (collapsed ? $t('nav.aiPanel') : undefined)" :aria-label="runningConvCount > 0 ? `${runningConvCount} 个会话运行中` : (collapsed ? $t('nav.aiPanel') : undefined)">
<span class="nav-link-icon ai-spark-icon">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 3v3M21 4.5h-3M5 17v3M6.5 18.5h-3"/></svg>
</span>
@@ -61,14 +63,14 @@
<span v-if="collapsed && runningConvCount > 0" class="ai-running-dot"></span>
<span v-if="!collapsed" class="ai-toggle-shortcut">Ctrl+I</span>
</button>
<router-link to="/settings" class="nav-link" active-class="nav-link--active" :title="collapsed ? $t('nav.settings') : undefined">
<router-link to="/settings" class="nav-link" active-class="nav-link--active" :title="collapsed ? $t('nav.settings') : undefined" :aria-label="collapsed ? $t('nav.settings') : undefined">
<span class="nav-link-icon">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
</span>
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.settings') }}</span>
</router-link>
<!-- 7b:收缩/展开切换按钮(最底部,快捷键 Ctrl+B 同效) -->
<button class="nav-link collapse-toggle-btn" @click="toggleCollapsed" :title="collapsed ? $t('nav.expand') : $t('nav.collapse')">
<button class="nav-link collapse-toggle-btn" @click="toggleCollapsed" :title="collapsed ? $t('nav.expand') : $t('nav.collapse')" :aria-label="collapsed ? $t('nav.expand') : $t('nav.collapse')">
<span class="nav-link-icon">
<svg aria-hidden="true" v-if="!collapsed" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
<svg aria-hidden="true" v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
@@ -29,6 +29,7 @@
@click="onDeleteDepBtnClick"
type="button"
:title="$t('fileExplorer.deleteCurrentModule')"
:aria-label="$t('fileExplorer.deleteCurrentModule')"
>
<svg aria-hidden="true" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
+12 -6
View File
@@ -71,20 +71,20 @@
</div>
<div class="toolbar-right">
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.editCurrentModule')" @click="openEditModule">
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.editCurrentModule')" :aria-label="$t('fileExplorer.editCurrentModule')" @click="openEditModule">
<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"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.deleteCurrentModule')" @click="confirmRemoveModule">
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.deleteCurrentModule')" :aria-label="$t('fileExplorer.deleteCurrentModule')" @click="confirmRemoveModule">
<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="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.addModuleTitle')" @click="openAddModule">
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.addModuleTitle')" :aria-label="$t('fileExplorer.addModuleTitle')" @click="openAddModule">
<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="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="btn btn-ghost btn-sm" type="button" :disabled="refreshing" @click="refresh">
<span v-if="refreshing" class="spinner"></span>
{{ refreshing ? $t('fileExplorer.refreshing') : $t('fileExplorer.refresh') }}
</button>
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.detachToWindow')" @click="onDetach">
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.detachToWindow')" :aria-label="$t('fileExplorer.detachToWindow')" @click="onDetach">
<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">
<path d="M15 3h6v6"/>
<path d="M10 14L21 3"/>
@@ -99,10 +99,10 @@
<!-- 左:视图切换按钮(文件树 / 变更) -->
<div class="explorer-sidebar">
<div class="explorer-view-tabs">
<button class="view-tab" :class="{ active: viewMode === 'tree' }" @click="viewMode = 'tree'">
<button class="view-tab" :class="{ active: viewMode === 'tree' }" type="button" :title="$t('fileExplorer.viewTree')" :aria-label="$t('fileExplorer.viewTree')" @click="viewMode = 'tree'">
<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"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</button>
<button class="view-tab" :class="{ active: viewMode === 'changes' }" @click="switchToChanges">
<button class="view-tab" :class="{ active: viewMode === 'changes' }" type="button" :title="$t('fileExplorer.viewChanges')" :aria-label="$t('fileExplorer.viewChanges')" @click="switchToChanges">
<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"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<span v-if="changedCount > 0" class="changed-badge">{{ changedCount }}</span>
</button>
@@ -426,6 +426,12 @@ onDeactivated(() => { viewVisible.value = false })
const changedCount = computed(() => gitStatusData.value?.changed_files.length ?? 0)
// F5:切工程清 git 状态缓存 —— 否则 changedCount 徽标沿用上一工程的计数(张冠李戴),
// 且 switchToChanges 因 gitStatusData 非空短路,不再为新工程重拉。进入变更视图时自然重拉。
watch(currentModuleId, () => {
gitStatusData.value = null
})
async function switchToChanges() {
viewMode.value = 'changes'
if (!currentModuleId.value || gitStatusData.value) return
+39 -4
View File
@@ -25,6 +25,7 @@
:class="{ active: showDiff }"
@click="toggleDiff"
:title="showDiff ? $t('fileExplorer.showContent') : $t('fileExplorer.showDiff')"
:aria-label="showDiff ? $t('fileExplorer.showContent') : $t('fileExplorer.showDiff')"
>
{{ showDiff ? '📄' : '📝' }}
</button>
@@ -81,7 +82,7 @@
/>
<div v-else class="preview-image-error">
<div class="preview-placeholder-icon">🖼️</div>
<div class="preview-placeholder-text">图片加载失败(路径: {{ filePath }})</div>
<div class="preview-placeholder-text">{{ $t('fileExplorer.imageLoadFailed', { path: filePath }) }}</div>
</div>
</div>
@@ -105,7 +106,7 @@
<!-- 文本/代码(highlight.js 语法高亮 + 行号;非 diff 模式时显示) -->
<div v-else ref="codeScrollRef" class="preview-code-scroll">
<div class="preview-line-numbers" aria-hidden="true">
<div v-for="n in lineCount" :key="n" class="preview-line-num">{{ n }}</div>
<div v-for="n in lineCount" :key="n" class="preview-line-num" :class="{ 'is-active': activeOutlineLine === n }">{{ n }}</div>
</div>
<pre class="preview-code"><code :class="hljsClass" v-html="htmlContent"></code></pre>
</div>
@@ -121,7 +122,15 @@
☰ <span class="outline-overlay-count">{{ outlineSymbols.length }}</span>
</button>
<div v-else class="preview-outline">
<div class="preview-outline-head" @click="outlineCollapsed = true">
<div
class="preview-outline-head"
role="button"
tabindex="0"
:aria-expanded="!outlineCollapsed"
@click="outlineCollapsed = true"
@keydown.enter.prevent="outlineCollapsed = true"
@keydown.space.prevent="outlineCollapsed = true"
>
<span class="preview-outline-title">☰ {{ $t('fileExplorer.symbols') }}</span>
<span class="preview-outline-count">{{ outlineSymbols.length }}</span>
<span class="preview-outline-spacer"></span>
@@ -140,7 +149,11 @@
class="outline-item"
:class="['grp-' + grp.key, { 'is-active': activeOutlineLine === sym.line }]"
:title="sym.signature"
role="button"
tabindex="0"
@click="gotoOutlineLine(sym.line)"
@keydown.enter.prevent="gotoOutlineLine(sym.line)"
@keydown.space.prevent="gotoOutlineLine(sym.line)"
>
<span class="outline-kind">{{ outlineKindLabel(sym.kind) }}</span>
<span class="outline-name">{{ sym.name || sym.signature || '?' }}</span>
@@ -256,12 +269,22 @@ const diffLines = computed<DiffLine[]>(() => {
const lines: DiffLine[] = []
let oldNum = 0
let newNum = 0
// `diff --git` 起、首个 `@@` 之前的行属文件头块(index/mode/---/+++ 等)。
// `--- a/x` / `+++ b/x` 是文件路径行而非增删行,否则会被误当删除/新增渲染且占行号。
let inFileHeader = false
for (const raw of diffViewContent.value.split('\n')) {
if (raw.startsWith('@@')) {
if (raw.startsWith('diff --git ')) {
inFileHeader = true
lines.push({ type: 'ctx', prefix: ' ', text: raw, oldNum: '', newNum: '' })
} else if (raw.startsWith('@@')) {
inFileHeader = false
const h = parseHunkHeader(raw)
oldNum = h.oldStart
newNum = h.newStart
lines.push({ type: 'hdr', prefix: '', text: raw, oldNum: '', newNum: '' })
} else if (inFileHeader) {
// 文件头元信息行:不属内容,不占行号
lines.push({ type: 'ctx', prefix: ' ', text: raw, oldNum: '', newNum: '' })
} else if (raw.startsWith('+')) {
lines.push({ type: 'add', prefix: '+', text: raw.slice(1), oldNum: '', newNum: String(newNum++) })
} else if (raw.startsWith('-')) {
@@ -485,10 +508,21 @@ async function loadFile() {
outlineSupported.value = false
outlineSymbols.value = []
activeOutlineLine.value = -1
// 未选文件:一并复位头部/内容态,避免残留上一文件的大小、截断、二进制标记(头部常驻渲染)
loading.value = false
error.value = null
fileSize.value = null
truncated.value = false
isBinary.value = false
imageLoadError.value = false
return
}
loading.value = true
error.value = null
// F18:切文件即清上一文件的头部元信息(大小/截断/二进制),避免加载期间残留旧值
fileSize.value = null
truncated.value = false
isBinary.value = false
// Markdown 文件触发预热(确保 marked 就绪,useRendered computed 据此渲染)
if (props.filePath.toLowerCase().endsWith('.md')) {
ensureMdLoaded()
@@ -590,6 +624,7 @@ function gitStatusClass(status: string): string {
if (x.startsWith('A')) return 'git-added'
if (x.startsWith('M')) return 'git-modified'
if (x.startsWith('D')) return 'git-deleted'
if (x.startsWith('R')) return 'git-modified' // F19:R(重命名)与文件树/变更视图一致按 modified 着色
return 'git-other'
}
+62 -20
View File
@@ -37,9 +37,17 @@
v-if="entry.is_dir"
class="tree-row tree-row-dir"
:title="entry.path"
role="button"
tabindex="0"
:aria-expanded="expandedPaths.has(entry.path)"
@click="toggleDir(entry)"
@keydown.enter.prevent="toggleDir(entry)"
@keydown.space.prevent="toggleDir(entry)"
>
<span class="expand-icon">{{ expandedPaths.has(entry.path) ? '▾' : '▸' }}</span>
<span class="expand-icon">
<span v-if="loadingDirs.has(entry.path)" class="dir-spinner"></span>
<template v-else>{{ expandedPaths.has(entry.path) ? '▾' : '▸' }}</template>
</span>
<span class="file-icon">📁</span>
<span class="file-name">{{ entry.name }}</span>
<!-- 空目录标:has_children 由后端列目录时即时统计,未展开即可见「空」,
@@ -62,7 +70,11 @@
v-else
class="tree-row tree-row-file"
:title="entry.path"
role="button"
tabindex="0"
@click="emit('file-select', entry.path)"
@keydown.enter.prevent="emit('file-select', entry.path)"
@keydown.space.prevent="emit('file-select', entry.path)"
>
<span class="expand-icon placeholder"></span>
<!-- 有类别图标(TS/JS/MD 等)只显彩色类别标,无则显通用 📄,避免叠加 -->
@@ -149,6 +161,14 @@ const error = ref<string | null>(null)
// 会让整个子树渲染分支(v-else-if="error")替换成错误文案,一次子目录失败就炸掉已展开的其他节点。
// 改为按 path 记录,条目行内显 ⚠ + title 原因,点击重试;成功加载即清除。
const toggleErrors = reactive(new Map<string, string>())
// F15/F17:按目录的「展开中」状态 —— 展开时该目录行内显 spinner(加载反馈),
// 并作为 in-flight 守卫防止快速双击同目录并发重复请求。
const loadingDirs = reactive(new Set<string>())
// F7:请求序号 + 在途键集合。切工程时 moduleId watch 与 loadedChildren 失效 watch 会并发
// 调用 loadEntries,用 (moduleId,subPath) 键去重避免同一目录双重请求;序号守卫丢弃旧响应。
let loadSeq = 0
const inflightEntries = new Set<string>()
/** 拉取当前 subPath 的条目列表。 */
async function loadEntries() {
@@ -162,17 +182,25 @@ async function loadEntries() {
entries.value = cached
return
}
const inflightKey = `${props.moduleId}::${cacheKey}`
if (inflightEntries.has(inflightKey)) return
const seq = ++loadSeq
inflightEntries.add(inflightKey)
loading.value = true
error.value = null
entries.value = [] // 清旧条目,避免切工程时短暂显示上一个工程的文件
try {
const res = await moduleApi.getModuleFileTree(props.moduleId, props.subPath || undefined)
if (seq !== loadSeq) return // 旧响应晚到,丢弃不覆盖新工程数据
entries.value = res.entries
// 缓存到顶层 loadedChildren(供 toggle 判断是否已有数据,避免重复请求)。
props.loadedChildren.set(cacheKey, res.entries)
} catch (e) {
if (seq !== loadSeq) return
error.value = e instanceof Error ? e.message : String(e)
} finally {
loading.value = false
inflightEntries.delete(inflightKey)
if (seq === loadSeq) loading.value = false
}
}
@@ -184,25 +212,27 @@ async function toggleDir(entry: FileTreeEntry) {
if (props.expandedPaths.has(entry.path)) {
// 收起:仅移除展开标记(loadedChildren 缓存保留,下次展开秒开)。
emit('toggle-dir', entry.path, [])
} else {
// 展开:若未加载过则先拉取并缓存(去重——只在这里拉一次;
// 递归子树实例 onMounted 时会复用此缓存,不重复请求同目录)。
if (!props.loadedChildren.has(entry.path)) {
emit('load-children', entry.path)
try {
loading.value = true
const res = await moduleApi.getModuleFileTree(props.moduleId, entry.path)
props.loadedChildren.set(entry.path, res.entries)
toggleErrors.delete(entry.path)
} catch (e) {
// B3[PD-P1-6]:行内错误态(该目录 ⚠ + title 原因),不设整树 error;不展开,点击可重试
toggleErrors.set(entry.path, e instanceof Error ? e.message : String(e))
return
} finally {
loading.value = false
}
}
return
}
if (props.loadedChildren.has(entry.path)) {
// 已加载过:直接展开,不重复请求
emit('toggle-dir', entry.path, props.loadedChildren.get(entry.path) ?? [])
return
}
// F17:展开中忽略重复点击(快速双击同目录不并发请求)
if (loadingDirs.has(entry.path)) return
emit('load-children', entry.path)
loadingDirs.add(entry.path)
try {
const res = await moduleApi.getModuleFileTree(props.moduleId, entry.path)
props.loadedChildren.set(entry.path, res.entries)
toggleErrors.delete(entry.path)
emit('toggle-dir', entry.path, res.entries)
} catch (e) {
// B3[PD-P1-6]:行内错误态(该目录 ⚠ + title 原因),不设整树 error;不展开,点击可重试
toggleErrors.set(entry.path, e instanceof Error ? e.message : String(e))
} finally {
loadingDirs.delete(entry.path)
}
}
@@ -380,6 +410,18 @@ watch(
font-size: 10px;
}
/* F15:目录展开中行内小 spinner(替代 ▸/▾),给展开操作即时反馈 */
.expand-icon .dir-spinner {
display: inline-block;
width: 9px;
height: 9px;
border: 1.5px solid var(--df-border, #444);
border-top-color: var(--df-accent, #3a8);
border-radius: 50%;
animation: df-spin 0.8s linear infinite;
vertical-align: middle;
}
.expand-icon.placeholder {
visibility: hidden;
width: 0; /* 文件无展开箭头,不占横向空间,避免图标前留白 */
+39 -5
View File
@@ -6,7 +6,7 @@
<span class="branch-name">{{ branchName }}</span>
<span v-if="!showHistory" class="branch-count">{{ gitStatus?.changed_files.length ?? 0 }} {{ $t('gitChanges.changedFiles') }}</span>
<span v-else class="branch-count">{{ totalCommits }} {{ $t('gitChanges.commitsUnit') }}</span>
<button v-if="!showHistory" class="branch-switch-btn" @click="onSwitchBranch" :title="$t('gitChanges.switchBranch')">
<button v-if="!showHistory" class="branch-switch-btn" @click="onSwitchBranch" :title="$t('gitChanges.switchBranch')" :aria-label="$t('gitChanges.switchBranch')">
<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"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
</button>
</div>
@@ -15,7 +15,7 @@
<div v-if="showBranchPicker" class="gc-branch-picker">
<div class="gc-branch-picker-header">
<span>{{ $t('gitChanges.branches') }}</span>
<button class="gc-branch-picker-close" @click="closeBranchPicker">✕</button>
<button class="gc-branch-picker-close" @click="closeBranchPicker" :aria-label="$t('common.close')">✕</button>
</div>
<div v-if="branchLoading" class="gc-loading"><span class="spinner"></span></div>
<div v-else class="gc-branch-list">
@@ -73,7 +73,11 @@
class="gc-file-row"
:class="{ selected: selectedFile === f.path }"
:style="{ paddingLeft: filePathDepth(f.path) * 16 + 14 + 'px' }"
role="button"
tabindex="0"
@click="selectFile(f.path)"
@keydown.enter.prevent="selectFile(f.path)"
@keydown.space.prevent="selectFile(f.path)"
>
<span class="gc-file-status" :class="'gc-st-' + group.key">{{ statusLabel(f.status) }}</span>
<span class="gc-file-name">{{ filePathLeaf(f.path) }}</span>
@@ -105,7 +109,12 @@
:key="c.hash"
class="gc-commit-row"
:class="{ selected: selectedCommit?.hash === c.hash }"
role="button"
tabindex="0"
:aria-expanded="selectedCommit?.hash === c.hash"
@click="selectCommit(c)"
@keydown.enter.prevent="selectCommit(c)"
@keydown.space.prevent="selectCommit(c)"
>
<div class="gc-commit-line1">
<span class="gc-commit-hash">{{ c.hash.slice(0, 8) }}</span>
@@ -117,7 +126,15 @@
</div>
<!-- 加载更多 -->
<div v-if="hasMoreCommits && !loadingMore" class="gc-load-more" @click="loadMoreCommits">
<div
v-if="hasMoreCommits && !loadingMore"
class="gc-load-more"
role="button"
tabindex="0"
@click="loadMoreCommits"
@keydown.enter.prevent="loadMoreCommits"
@keydown.space.prevent="loadMoreCommits"
>
{{ $t('gitChanges.loadMore') }}
</div>
<div v-if="loadingMore" class="gc-loading"><span class="spinner"></span></div>
@@ -132,7 +149,7 @@
<span class="gc-commit-detail-author">{{ selectedCommit.author }}</span>
<span class="gc-commit-detail-time">{{ formatTime(selectedCommit.timestamp) }}</span>
<span class="gc-commit-detail-msg">{{ selectedCommit.subject }}</span>
<button class="gc-detail-close" @click="closeCommitDetail">✕</button>
<button class="gc-detail-close" @click="closeCommitDetail" :aria-label="$t('common.close')">✕</button>
</div>
<div v-if="commitDetailLoading" class="gc-loading"><span class="spinner"></span></div>
<template v-else-if="commitDetail">
@@ -143,7 +160,11 @@
:key="f.path"
class="gc-commit-file-row"
:class="{ active: commitSelectedFile === f.path }"
role="button"
tabindex="0"
@click="showCommitFileDiff(f.path)"
@keydown.enter.prevent="showCommitFileDiff(f.path)"
@keydown.space.prevent="showCommitFileDiff(f.path)"
>
<span class="gc-file-status" :class="'gc-st-' + statusKey(f.status)">{{ statusLabel(f.status) }}</span>
<span class="gc-file-path">{{ f.path }}</span>
@@ -530,7 +551,20 @@ function showCommitFileDiff(path: string) {
emit('select-file', path, extractFileDiff(commitDetail.value.diff, path))
}
watch(() => props.moduleId, loadStatus, { immediate: true })
watch(() => props.moduleId, () => {
// 切换工程:清理上一工程的遗留状态(提交详情/选中文件/搜索词/分支弹层),防残留旧仓库数据。
// 刻意不复位 showHistory(保留用户所在 Tab);refreshKey 刷新走 loadStatus 亦不清理(见下),
// 故复位只挂在本 watch,不放 loadStatus。
selectedFile.value = ''
selectedCommit.value = null
commitDetail.value = null
commitSelectedFile.value = ''
commitSearch.value = ''
showBranchPicker.value = false
branchList.value = []
emit('select-file', '', '')
loadStatus()
}, { immediate: true })
// B2[PD-P1-5]:外部刷新信号(AI 写文件后 FileExplorer 自增)变化 → 重拉 git 状态。
// 需保留当前历史 Tab/详情展示(只刷 gitStatus/commits,不动 showHistory/selectedCommit)。
@@ -205,7 +205,12 @@ async function addDir() {
}
// 删除:即时持久化,dirs 由后端返回值回填(与添加对称,删除也立即生效)。
// SA-28: 删除即持久化且无二次确认(误点即丢一条授权),补确认弹层——复用本组件已有的
// useConfirm/内联弹层(与「信任所有目录」开关同套)。文案受「不新增 i18n 键」约束,
// 无专用键,以 common.delete 拼路径;理想应有 allowedDirsRemoveConfirm 专用键(记 note)。
async function removeDir(idx: number) {
const dir = dirs.value[idx]
if (!await confirmDialog(`${t('common.delete')}「${dir}」?`, t('common.delete'))) return
const next = dirs.value.filter((_, i) => i !== idx)
saving.value = true
try {
+1 -1
View File
@@ -11,7 +11,7 @@
<div class="connection-list" v-if="connections.length > 0">
<div class="connection-card" v-for="conn in connections" :key="conn.id">
<div class="conn-left">
<span class="conn-icon">{{ typeIcon(conn.type) }}</span>
<span class="conn-icon" aria-hidden="true">{{ typeIcon(conn.type) }}</span>
<div class="conn-info">
<div class="conn-name-row">
<span class="conn-name">{{ conn.name }}</span>
+19 -1
View File
@@ -17,7 +17,7 @@
</select>
</SettingRow>
<SettingRow ref="rowMinMessages" :label="$t('settings.labelMinMessages')" :desc="$t('settings.descMinMessages')">
<input type="number" min="2" max="50" v-model.number="knowledgeConfig.min_messages" class="setting-number" style="width:80px" @change="markSaved('minMessages')" />
<input type="number" min="2" max="50" v-model.number="knowledgeConfig.min_messages" class="setting-number" style="width:80px" @change="onMinMessagesChange" />
</SettingRow>
<SettingRow ref="rowAutoInject" :label="$t('settings.labelAutoInject')" :desc="$t('settings.descAutoInject')">
<label class="toggle">
@@ -76,9 +76,15 @@ const embeddingProviders = computed(() =>
props.aiProviders.filter(p => p.provider_type !== 'anthropic')
)
// SA-26: 加载回填(loaded 翻 true + 字段赋值)会触发下方 deep watch 一次 ->
// 把刚读到的值原样回写 saveConfig IPC,纯冗余。用一次性标志让该次 flush 跳过:
// 赋值前打标,watch 回调首行消费并 return。首次加载 loaded 恒 false->true = 必有变更,
// 该次 flush 必触发因而标志必被消费,不会误吞后续用户改动(本函数仅 onMounted 调用一次)。
let _skipNextFlush = false
async function loadKnowledgeConfig() {
try {
const cfg = await knowledgeApi.getConfig()
_skipNextFlush = true
Object.assign(knowledgeConfig, cfg)
knowledgeConfig.loaded = true
} catch (e) {
@@ -114,6 +120,8 @@ function saveKnowledgeConfigDebounced() {
watch(
() => ({ ...knowledgeConfig }),
() => {
// SA-26: 加载回填导致的 flush 跳过(非用户改动),避免进面板即多一次无谓 saveConfig IPC
if (_skipNextFlush) { _skipNextFlush = false; return }
if (knowledgeConfig.loaded) saveKnowledgeConfigDebounced()
},
{ deep: true },
@@ -138,6 +146,16 @@ function markSaved(key: SavedKey) {
_pendingSavedKey = key
}
// SA-27: input 原生 min/max 只约束步进按钮,手输越界不受限 -> 对齐 PerformanceSection clamp 范式。
// 非法值(NaN/清空)回退默认 4,合法值 clamp [2,50](与模板 min/max 一致),修正后回写 v-model
// 触发保存与「已保存」反馈。注:未加越界 toast(本组件未接 @toast 通道,且无通用的越界修正文案 key)。
function onMinMessagesChange() {
const raw = knowledgeConfig.min_messages
const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.trunc(raw) : 4
knowledgeConfig.min_messages = Math.min(50, Math.max(2, n))
markSaved('minMessages')
}
/** 真正点亮行内「✓已保存」反馈(仅 IPC 成功路径调用)。 */
function flashRow(key: SavedKey) {
const map: Record<SavedKey, InstanceType<typeof SettingRow> | null> = {
+7 -4
View File
@@ -168,16 +168,16 @@
<div class="model-tags">
<span v-for="md in m.modalities" v-show="!redundantTags.modalities.has(md)" :key="'md-' + md"
class="tag tag-modality" :title="$t('settings.tagTooltipModality')">
{{ $t('settings.tagModality.' + md) }}<span class="tag-remove" @click.stop="removeModality(m, md)">×</span>
{{ $t('settings.tagModality.' + md) }}<span class="tag-remove" role="button" tabindex="0" :aria-label="$t('common.delete')" @click.stop="removeModality(m, md)" @keydown.enter.prevent="removeModality(m, md)" @keydown.space.prevent="removeModality(m, md)">×</span>
</span>
<span v-for="cap in m.capabilities" v-show="!redundantTags.capabilities.has(cap)" :key="'cap-' + cap"
class="tag tag-capability" :title="$t('settings.tagTooltipCapability')">
{{ $t('settings.tagCapability.' + cap) }}<span class="tag-remove" @click.stop="removeCapability(m, cap)">×</span>
{{ $t('settings.tagCapability.' + cap) }}<span class="tag-remove" role="button" tabindex="0" :aria-label="$t('common.delete')" @click.stop="removeCapability(m, cap)" @keydown.enter.prevent="removeCapability(m, cap)" @keydown.space.prevent="removeCapability(m, cap)">×</span>
</span>
<span v-if="m.probe_source && m.probe_source !== 'user_set'" v-show="!redundantTags.sources.has(m.probe_source)"
class="tag tag-source" :title="$t('settings.tagTooltipSource')">{{ $t('settings.tagSource.' + m.probe_source) }}</span>
<span v-if="m.probe_source === 'user_set'" class="tag tag-source" :title="$t('settings.tagTooltipSource')">{{ $t('settings.tagSource.user_set') }}</span>
<span class="tag-add" :title="$t('settings.addTagTitle')" @click.stop="startAddTag(m)">+</span>
<span class="tag-add" role="button" tabindex="0" :title="$t('settings.addTagTitle')" :aria-label="$t('settings.addTagTitle')" @click.stop="startAddTag(m)" @keydown.enter.prevent="startAddTag(m)" @keydown.space.prevent="startAddTag(m)">+</span>
</div>
<!-- 添加能力内联菜单(点+展开;选未有的能力/模态即添加) -->
<div v-if="addingTagModel === m.model_id" class="add-tag-menu">
@@ -293,7 +293,10 @@ const providerForm = reactive({
*/
const redundantTags = computed(() => {
const models = providerForm.models
if (models.length === 0) return { modalities: new Set<string>(), capabilities: new Set<string>(), sources: new Set<string>() }
// SA-6: 去冗余的前提是「模型间对比」——仅 1 个模型时它的所有标签都满足「全模型共有」,
// 无脑隐藏会把唯一模型的能力标签全抹掉(行内空白,用户看不到该模型能力)。
// 故 ≥2 个模型起才去冗余;0/1 个一律全显(sources 分支本就 <2 直接放行,此处对齐)。
if (models.length < 2) return { modalities: new Set<string>(), capabilities: new Set<string>(), sources: new Set<string>() }
const common = (pick: (m: ModelConfig) => string[]) => {
const sets = models.map(m => new Set(pick(m)))
const first = sets[0]
+4
View File
@@ -28,11 +28,15 @@
v-for="cat in categories"
:key="cat.value"
class="nav-item"
role="button"
tabindex="0"
:class="{
'nav-item--active': cat.value === modelValue,
'nav-item--matched': isMatchedCategory(cat.value),
}"
@click="$emit('update:modelValue', cat.value)"
@keydown.enter.prevent="$emit('update:modelValue', cat.value)"
@keydown.space.prevent="$emit('update:modelValue', cat.value)"
>
<span class="nav-icon">{{ cat.icon }}</span>
<span class="nav-label">{{ $t(cat.labelKey) }}</span>
+8
View File
@@ -107,6 +107,14 @@
/* Toggle(3/5 Panel 重复) */
.toggle { position: relative; display: inline-block; width: 40px; height: 22px; cursor: pointer; }
.toggle input { opacity: 0; width: 0; height: 0; }
/* 键盘聚焦可见环:原生 checkbox 被 opacity:0+零尺寸隐藏,global.css 的 :focus-visible 兜底
画在零尺寸 input 上不可见;把聚焦态转移到可见的 slider。用 :focus-visible(非 :focus-within)
保证鼠标点击不显环、仅 Tab 键盘导航出现;.toggle--sm 变体同匹配(.toggle 前缀)。
:focus{border-color} 仅覆盖 setting-select/input 等可见输入,覆盖不到这种视觉隐藏 checkbox。 */
.toggle input:focus-visible + .toggle-slider {
outline: 2px solid var(--df-accent);
outline-offset: 2px;
}
.toggle-slider { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: var(--df-border); border-radius: var(--df-radius-lg); transition: background 0.2s; }
.toggle-slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; bottom: 3px; background: var(--df-text-dim); border-radius: 50%; transition: transform 0.2s, background 0.2s; }
.toggle input:checked + .toggle-slider { background: var(--df-accent); }
+3 -3
View File
@@ -8,7 +8,7 @@
<div class="tbb-actions">
<!-- 推进到 ▾(仅渲染全部可达的交集目标) -->
<div class="tbb-menu" ref="advanceMenuRef">
<button class="btn btn-ghost btn-sm" :disabled="busy || advanceTargets.length === 0" @click.stop="toggleMenu('advance')">
<button class="btn btn-ghost btn-sm" :disabled="busy || advanceTargets.length === 0" :aria-expanded="activeMenu === 'advance'" @click.stop="toggleMenu('advance')">
{{ $t('tasks.batch.advanceTo') }} ▾
</button>
<div v-if="activeMenu === 'advance'" class="tbb-dropdown" @click.stop>
@@ -25,7 +25,7 @@
<!-- 改优先级 ▾ -->
<div class="tbb-menu" ref="priorityMenuRef">
<button class="btn btn-ghost btn-sm" :disabled="busy" @click.stop="toggleMenu('priority')">
<button class="btn btn-ghost btn-sm" :disabled="busy" :aria-expanded="activeMenu === 'priority'" @click.stop="toggleMenu('priority')">
{{ $t('tasks.batch.priority') }} ▾
</button>
<div v-if="activeMenu === 'priority'" class="tbb-dropdown" @click.stop>
@@ -37,7 +37,7 @@
<!-- 指派 ▾(内嵌输入 + 建议) -->
<div class="tbb-menu" ref="assigneeMenuRef">
<button class="btn btn-ghost btn-sm" :disabled="busy" @click.stop="toggleMenu('assignee')">
<button class="btn btn-ghost btn-sm" :disabled="busy" :aria-expanded="activeMenu === 'assignee'" @click.stop="toggleMenu('assignee')">
{{ $t('tasks.batch.assignee') }} ▾
</button>
<div v-if="activeMenu === 'assignee'" class="tbb-dropdown tbb-dropdown--assignee" @click.stop>
+10 -1
View File
@@ -123,7 +123,16 @@
<span class="subtask-progress-text">{{ $t('taskDetail.progressTitle') }}: {{ subtaskProgress.done }}/{{ subtaskProgress.total }}</span>
</div>
<div v-if="children.length > 0" class="subtask-list">
<div v-for="child in children" :key="child.id" class="subtask-item" @click="router.push(`/tasks/${child.id}`); $emit('close')">
<div
v-for="child in children"
:key="child.id"
class="subtask-item"
role="button"
tabindex="0"
@click="router.push(`/tasks/${child.id}`); $emit('close')"
@keydown.enter.prevent="router.push(`/tasks/${child.id}`); $emit('close')"
@keydown.space.prevent="router.push(`/tasks/${child.id}`); $emit('close')"
>
<span class="subtask-title">{{ child.title }}</span>
<span class="status-tag" :class="taskStatusClass(child.status)">{{ $t(taskStatusLabel(child.status)) }}</span>
<span class="priority-badge" :class="priorityClass(child.priority)">{{ priorityLabel(child.priority) }}</span>
@@ -13,7 +13,7 @@
class="wf-dag-node"
:class="nodeClass(node.id, props.nodeStatuses)"
>
<span class="wf-dag-node-status-dot"></span>
<span class="wf-dag-node-status-dot" aria-hidden="true"></span>
<span class="wf-dag-node-name">{{ node.id }}</span>
<span class="wf-dag-node-type">{{ nodeTypeLabel[node.node_type] || node.node_type }}</span>
<span v-if="nodeStatuses[node.id]" class="wf-dag-node-status">{{ statusLabel(nodeStatuses[node.id]) }}</span>
@@ -27,7 +27,7 @@
</div>
</div>
<!-- 层间箭头 -->
<div v-if="li < layers.length - 1" class="wf-dag-layer-arrow">↓</div>
<div v-if="li < layers.length - 1" class="wf-dag-layer-arrow" aria-hidden="true">↓</div>
</div>
</div>
@@ -52,7 +52,11 @@
v-else
class="wf-dag-cond-label"
:class="{ 'wf-dag-cond-label--uncond': !edge.condition, 'wf-dag-cond-label--editable': true }"
role="button"
tabindex="0"
@click="startEdit(i, edge)"
@keydown.enter.prevent="startEdit(i, edge)"
@keydown.space.prevent="startEdit(i, edge)"
>{{ edge.condition || $t('taskDetail.workflowUnconditional') }}</span>
</div>
</div>