优化: 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:
@@ -10,7 +10,7 @@
|
||||
//! - 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers):
|
||||
//! events 模块触发(start/clear/allClear),send 模块也需导出给组件,故下沉到共享层
|
||||
|
||||
import { reactive } from 'vue'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { emit } from '@tauri-apps/api/event'
|
||||
import { aiApi } from '@/api'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
@@ -305,6 +305,18 @@ export interface ConvStreamState {
|
||||
*/
|
||||
export const convStreamStates = reactive(new Map<string, ConvStreamState>())
|
||||
|
||||
/**
|
||||
* 工具状态版本号(O(1) 收敛):工具卡状态任一突变(push 新卡/完成/待审批/拒绝)经本值自增,
|
||||
* MessageList 的「收起旧工具卡」联动 watch 改 watch 本值 —— 替代原 map+JSON.stringify 的
|
||||
* O(n) 指纹,工具事件从每次全量遍历降为常数。单调递增,消费方只需「变没变」不需值语义。
|
||||
*/
|
||||
export const toolEpoch = ref(0)
|
||||
|
||||
/** 工具状态变更唯一出口:自增版本号(所有工具态突变点调用,避免散点漏调)。 */
|
||||
export function bumpToolEpoch(): void {
|
||||
toolEpoch.value++
|
||||
}
|
||||
|
||||
/**
|
||||
* 取某 conv 的 stream state;未追踪过返回 null。
|
||||
*
|
||||
@@ -412,6 +424,7 @@ export function addPendingApproval(info: AiToolCallInfo): boolean {
|
||||
const view = getState()?.pendingApprovals
|
||||
if (view && !view.some(p => p.id === info.id)) view.push(info)
|
||||
}
|
||||
if (added) bumpToolEpoch() // 新增待审批 = 工具态变更
|
||||
return added
|
||||
}
|
||||
|
||||
@@ -427,6 +440,7 @@ export function removePendingApprovalById(id: string): void {
|
||||
const idx = view.findIndex(p => p.id === id)
|
||||
if (idx >= 0) view.splice(idx, 1)
|
||||
}
|
||||
bumpToolEpoch() // 待审批移除(审批结果/完成)= 工具态变更
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ import { state } from '@/stores/ai'
|
||||
import { persistUiState } from './useAiPanel'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { emitDetachedOpenConversation, registerDetachedOpenConvListener } from './useAiWindow'
|
||||
import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged, setConvPendingApprovals, discardConvPendingApprovals, backfillConvAgentFinished } from './aiShared'
|
||||
import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged, setConvPendingApprovals, discardConvPendingApprovals, backfillConvAgentFinished, bumpToolEpoch } from './aiShared'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import type { AiConversationDetail, AiMessage, AiToolCallInfo, AiToolCallStatus, ConvId, MessageId } from '@/api/types'
|
||||
|
||||
@@ -499,6 +499,7 @@ export async function switchConversation(id: string, force = false, opts?: { noC
|
||||
for (const tc of (m.toolCalls || [])) {
|
||||
if (pendingIds.has(tc.id)) {
|
||||
tc.status = 'pending_approval'
|
||||
bumpToolEpoch()
|
||||
const kind = pendingKindMap.get(tc.id) ?? 'risk'
|
||||
tc.kind = kind
|
||||
// path 类审批:从 tc.args.path 推 path/dir 文案(后端 IPC 仅返 kind,无 dir/path;
|
||||
|
||||
@@ -19,7 +19,7 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
import { ref } from 'vue'
|
||||
import { aiApi } from '@/api'
|
||||
import { state } from '@/stores/ai'
|
||||
import type { ContentPart, AiMessage, MentionSpan, MessageId } from '@/api/types'
|
||||
import type { ContentPart, AiMessage, MentionSpan, MessageId, AiErrorType } from '@/api/types'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { resetStreamWatchdog, clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
@@ -35,6 +35,20 @@ const QUEUED_SEND_ERR_KEY: Record<SendErrorKind, string> = {
|
||||
other: 'aiChat.toastSendFail',
|
||||
}
|
||||
|
||||
/// B3:发送错误串 → 不可重试错误类型。仅永久性错误返回 fatal 类型(provider_config),
|
||||
/// 瞬时错误(限流/网络)返回 'unknown' 保持可重试。复用 classifySendError 的额度特征
|
||||
/// (402/额度/余额→quota)+ 404/未找到/无权限/鉴权等永久性特征,产出 MessageList.canRetry
|
||||
/// 认可的 fatal 类型,使其隐藏「重试」按钮——避免用户对永久错误(模型不存在/无权访问/
|
||||
/// 额度耗尽)反复重试必再败。原始错误串仍经 { msg } 保留在气泡文案里。
|
||||
function permanentErrorType(raw: string): AiErrorType {
|
||||
const s = String(raw ?? '')
|
||||
if (classifySendError(s) === 'quota') return 'provider_config'
|
||||
if (/\b404\b|not[\s_-]?found|不存在|无权|forbidden|\b403\b|unauthor|invalid[\s_-]?api[\s_-]?key|鉴权/i.test(s)) {
|
||||
return 'provider_config'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/// 待发送队列上限(超过抛错提示用户)
|
||||
const QUEUE_LIMIT = 10
|
||||
|
||||
@@ -321,10 +335,13 @@ export function drainQueue(convId?: string | null) {
|
||||
// UX-P2-A5:失败气泡接入 classifySendError 分类文案(对齐 ChatInput 发送失败 toast 四类),
|
||||
// 替代原 raw 错误直出的 ai.queuedSendFailed;原始错误串经 { msg } 细节保留
|
||||
state.messages.push({
|
||||
id: `err-${nextMsgId()}`,
|
||||
id: `err-${nextMsgId()}` as MessageId,
|
||||
role: 'assistant',
|
||||
content: t(QUEUED_SEND_ERR_KEY[classifySendError(errMsg)], { msg: errMsg }),
|
||||
isError: true,
|
||||
// B3:永久性错误(额度/404/无权)附 fatal errorType,MessageList.canRetry 据此隐藏「重试」;
|
||||
// 瞬时错误(限流/网络)为 'unknown' 维持可重试。
|
||||
errorType: permanentErrorType(errMsg),
|
||||
timestamp: Date.now(),
|
||||
} as AiMessage)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { emit } from '@tauri-apps/api/event'
|
||||
import { state } from '@/stores/ai'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { findToolCall, clearApprovalTimer, startApprovalTimer, startToolSlowTimer, clearToolSlowTimer, addPendingApproval, removePendingApprovalById } from './aiShared'
|
||||
import { findToolCall, clearApprovalTimer, startApprovalTimer, startToolSlowTimer, clearToolSlowTimer, addPendingApproval, removePendingApprovalById, bumpToolEpoch } from './aiShared'
|
||||
import { removePendingDirAuth } from './useAiPendingState'
|
||||
import { clearStreamWatchdog } from './useAiStream'
|
||||
import type { AiChatEvent, AiToolCallInfo } from '@/api/types'
|
||||
@@ -38,6 +38,9 @@ export function handleToolEvent(event: AiChatEvent): boolean {
|
||||
if (target) {
|
||||
target.toolCalls = target.toolCalls || []
|
||||
target.toolCalls.push(info)
|
||||
// 工具态变更 → 版本号自增(MessageList 收起联动 O(1) 检测;完成/待审批/拒绝经
|
||||
// aiShared 的 pending 出入口自增,此处仅覆盖「新卡 push」这一不经 pending 的路径)
|
||||
bumpToolEpoch()
|
||||
}
|
||||
}
|
||||
// 工具开始执行即挂慢执行计时器(超时仅 toast,不动 running 态)
|
||||
@@ -117,7 +120,7 @@ export function handleToolEvent(event: AiChatEvent): boolean {
|
||||
const tc = findToolCall(event.id)
|
||||
if (tc) {
|
||||
tc.status = 'rejected'
|
||||
tc.result = t('ai.aiTool.rejectedHint')
|
||||
tc.result = t('aiTool.rejectedHint')
|
||||
}
|
||||
// 移除待审批(含 per-conv 集合)
|
||||
removePendingApprovalById(event.id)
|
||||
|
||||
@@ -72,6 +72,15 @@ export function aggregateCalls(calls: AiToolCallInfo[]): TurnCallStats {
|
||||
if (isToolFailure(tc)) s.fail++
|
||||
else s.ok++
|
||||
break
|
||||
default: {
|
||||
// 穷尽性保护:四个状态全部显式处理后,此处 tc.status 收窄为 never;
|
||||
// 新增状态漏处理时该赋值在编译期报错,防静默漏计(运行时仍可能收到后端新状态)。
|
||||
const _exhaustive: never = tc.status
|
||||
void _exhaustive
|
||||
// 运行时兜底:未知状态仅记 debug 日志,不计数 —— 与旧实现的跳过语义等价(旧无匹配即只计 total)。
|
||||
console.debug(`[TurnTrace] 未知工具调用状态,跳过计数:${tc.status}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
|
||||
@@ -75,6 +75,8 @@ let _purify: typeof import('dompurify')['default'] | null = null
|
||||
const mdReady = ref(false)
|
||||
// 历史消息整段缓存(文本不变,重渲染命中省去 marked+sanitize 全量解析)
|
||||
const _mdCache = new Map<string, string>()
|
||||
// 缓存容量上限:超限按 LRU 淘汰最久未用项(Map 首项),非整体 clear
|
||||
const _MD_CACHE_LIMIT = 200
|
||||
|
||||
/**
|
||||
* marked code renderer 注入 —— 优先按 infostring 指定语言高亮(需已注册),
|
||||
@@ -190,9 +192,18 @@ function renderMd(text: string): string {
|
||||
// 防御性:即使现网 AI 多带围栏,裸行也正确处理(围栏内整段不动)。
|
||||
const wrapped = wrapNakedDiff(text)
|
||||
const cached = _mdCache.get(wrapped)
|
||||
if (cached !== undefined) return cached
|
||||
if (cached !== undefined) {
|
||||
// LRU:命中即置为最近使用(delete+set 移到 Map 末尾),淘汰时优先保留热项
|
||||
_mdCache.delete(wrapped)
|
||||
_mdCache.set(wrapped, cached)
|
||||
return cached
|
||||
}
|
||||
const html = _purify.sanitize(_marked.parse(wrapped) as string)
|
||||
if (_mdCache.size > 200) _mdCache.clear()
|
||||
// 有界 LRU 淘汰:超限删最久未用项(Map 首项)后插入,替代原整体 clear 造成的重复解析抖动
|
||||
if (_mdCache.size >= _MD_CACHE_LIMIT) {
|
||||
const oldest = _mdCache.keys().next().value
|
||||
if (oldest !== undefined) _mdCache.delete(oldest)
|
||||
}
|
||||
_mdCache.set(wrapped, html)
|
||||
return html
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user