优化: UI/UX遗留批(审计/AI命令层/前端组件,会话前基线收尾)

- AuditLog +298(筛选/详情/i18n)+ audit 后端 record/mod

- AI 命令层:generate_image +81 / fetch_url / fetch_search / skills / tool_registry / tools/file / provider / conversation

- 前端组件:AiChat/TopBar/ConversationSidebar/GitChanges/ApprovalPopup/Dashboard/ProjectDetail 等 30+ + composables + i18n

- 诊断文档: aichat历史会话实证诊断-2026-08-04 + project_soft_delete 测试
This commit is contained in:
lxy
2026-08-05 22:15:34 +08:00
parent 28de5d6143
commit e68d283651
49 changed files with 1257 additions and 138 deletions
+70 -13
View File
@@ -1,4 +1,4 @@
//! 面板与持久化 — togglePanel/toggleMaximize + UI 持久化(restoreUiState/persistUiState)
//! 面板与持久化 — togglePanel/toggleMaximize/cyclePanelMode + UI 持久化(restoreUiState/persistUiState)
//! + token 开关 + providers/skills/clearChat
//!
//! 模块级私有:
@@ -13,7 +13,9 @@ import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import { setStreaming } from './streamingGuard'
import type { AiProviderConfig } from '@/api/types'
import { nextMsgId } from './aiShared'
import { t } from '@/i18n/i18n-helpers'
import type { AiMessage, AiProviderConfig } from '@/api/types'
const appSettings = useAppSettingsStore()
@@ -46,16 +48,25 @@ function applyUiState(s: any) {
state.sidebarWidth = clampSidebarWidth(s.sidebarWidth)
}
// 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局(模块级单例 state)
// 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局 + activeProvider(模块级单例 state)
// F-260616-10: activeProvider 恢复对齐 df-ai-active-conv 模式;loadProviders 后还会二次校验
// DB is_default(双保险:localStorage 可能与 DB 不同步时以 DB 为准)。
function restoreUiState() {
applyUiState(appSettings.get<any>('df-ai-ui', null))
const savedProvider = appSettings.get<string | null>('df-ai-active-provider', null)
if (savedProvider) state.activeProvider = savedProvider
}
restoreUiState()
// F-260616-10: 启动恢复 activeProvider(对齐 df-ai-active-conv 模式)。
// loadProviders 后还会二次校验 DB is_default(双保险:localStorage 可能与 DB 不同步时以 DB 为准)。
const savedProvider = appSettings.get<string | null>('df-ai-active-provider', null)
if (savedProvider) state.activeProvider = savedProvider
// 循环依赖兜底:stores/ai ↔ useAiPanel 互 import,顶层 restoreUiState 执行时 state 可能尚未
// 初始化(TDZ)。冷启动靠 appSettings 缓存为空 → applyUiState(null) 提前 return 绕过访问;
// 但 HMR 热重载 stores/ai.ts 时 appSettings 已有缓存,restoreUiState 真正访问 state 触发
// ReferenceError: Cannot access 'state' before initialization。对齐 stores/ai.ts __bindMessages
// 既存模式:try 失败则微任务延迟重试,待 state 就绪后落库恢复。
try {
restoreUiState()
} catch {
Promise.resolve().then(restoreUiState)
}
// loadAll() 异步填充缓存后,再次应用 UI 布局(模块级 state,watch 仅触发一次)
watch(() => appSettings.cache['df-ai-ui'], (v) => applyUiState(v as any))
@@ -96,6 +107,34 @@ function toggleMaximize() {
persistUiState()
}
/**
* Ctrl+I 三态循环(关闭 → 分栏打开 → 最大化 → 关闭)。
*
* 与 togglePanel(二态开关)区分:本函数仅供全局快捷键 Ctrl+I 用;UI 按钮(侧栏 AI 按钮 /
* TopBar 关闭钮)与审批浮层激活仍走 togglePanel,保持"开/关"二态语义不被破坏。
*
* 循环方向逐级增强再回关闭,贴合"要用 AI → 专注看 AI → 用完了"心智:
* 关闭(panelOpen=false) → 分栏打开(panelOpen=true, maximized=false)
* 分栏打开(maximized=false) → 最大化(maximized=true,全宽占满 main)
* 最大化(maximized=true) → 关闭(panelOpen=false, maximized=false)
*
* detached 模式 no-op:分离窗口下主窗口不渲染面板,循环无意义;分离窗口自身走系统标题栏最大化。
*/
function cyclePanelMode() {
// 分离窗口模式:主窗口无面板,三态循环无意义(no-op;关闭分离窗口时 destroyed 钩子自恢复 panelOpen)
if (state.detached) return
if (!state.panelOpen) {
state.panelOpen = true
state.maximized = false
} else if (!state.maximized) {
state.maximized = true
} else {
state.panelOpen = false
state.maximized = false
}
persistUiState()
}
// ── Providers / Skills / 清屏 ──
/** 拉取 provider 列表(用于顶部 bar 展示与切换) + 同步 DB 默认 provider 到 activeProvider */
@@ -129,17 +168,35 @@ async function setProvider(providerId: string) {
/** 清空当前会话消息(后端 + 本地 state) */
async function clearChat() {
await aiApi.clearChat()
state.messages = []
state.currentText = ''
state.pendingApprovals = []
setStreaming(false, { reason: 'clearChat' })
try {
await aiApi.clearChat()
state.messages = []
state.currentText = ''
state.pendingApprovals = []
setStreaming(false, { reason: 'clearChat' })
} catch (e) {
// A2-B9(G3.2):清空失败需反馈,原裸 await 失败静默(异常进 unhandledrejection,用户无感)。
// 后端裁决「先停 loop → DB 单事务 → 内存清」:DB 失败时内存不动(回滚一致),故此处
// 只推错误气泡告知用户(对齐 useAiConversations.pushConvOpFail 模式),不吞异常日志。
// 注意:不 rethrow——confirmClearChat 无 try,裸抛会进 unhandledrejection 且用户无感;
// 错误气泡已足够反馈,且状态保持未清(与后端内存一致)。
const msg = e instanceof Error ? e.message : String(e)
state.messages.push({
id: `conv-op-fail-${nextMsgId()}`,
role: 'assistant',
content: t('aiChat.clearError', { msg }),
isError: true,
timestamp: Date.now(),
} as AiMessage)
console.error('[AI] 清空对话失败:', e)
}
}
export function useAiPanel() {
return {
togglePanel,
toggleMaximize,
cyclePanelMode,
loadProviders,
loadSkills,
setProvider,
+1 -1
View File
@@ -16,7 +16,7 @@ export function useMessageScroll(containerRef: Ref<HTMLDivElement | undefined>)
// 回到底部按钮:内容溢出且不在底部时显示
const showBackToBottom = ref(false)
// B-260618-24: 跟随意图锁存。点回到底部/滚到底/发新消息→true;用户上滑→false。
// 跟随意图锁存。点回到底部/滚到底/发新消息→true;用户上滑→false。
const isFollowingBottom = ref(true)
// 边沿检测:上一帧是否在底部(用于触发 onNearBottom 回调)
let wasNearBottom = true
+2 -2
View File
@@ -30,7 +30,7 @@ export interface UseStreamRendererOptions {
saveSelection: () => void
/** UX-2025-01:恢复之前保存的选区(DOM 重建后调) */
restoreSelection: () => void
/** B-260618-24:跟随意图锁存(跟随中则 rAF 回调内滚到真实底部) */
/** 跟随意图锁存(跟随中则 rAF 回调内滚到真实底部) */
isFollowingBottom: Ref<boolean> | ComputedRef<boolean>
/** 回到底部操作 */
scrollToBottom: () => void
@@ -185,7 +185,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
streamingBlocks.value = renderStreamingBlocks(lastStreamText)
nextTick(() => {
opts.restoreSelection()
// B-260618-24: rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。
// rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。
// 治 onContentChange 的 nextTick(scrollToBottom) 抢跑旧 scrollHeight(微任务先于 rAF,
// 滚到渲染前高度)致"差一截"。
if (opts.isFollowingBottom.value) opts.scrollToBottom()
+13 -3
View File
@@ -26,6 +26,10 @@ import type { AiToolCallInfo } from '@/api/types'
/** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */
export interface ToolResult {
path?: string
// A1-B5: 只读工具(git status/diff/log)失败路径后端返 {success:false,error} 合法 JSON 对象(非 Err),
// 前端 isToolFailure 靠此字段判失败(防绿框假成功)
success?: boolean
error?: string
pattern?: string
from?: string
to?: string
@@ -131,9 +135,11 @@ export function shouldKeepOpen(tc: AiToolCallInfo): boolean {
*
* 后端 AR-6:Low 风险工具失败不 emit AiError,改 emit AiToolCallCompleted(result=错误),
* 故失败工具 status 仍为 'completed',与成功工具视觉同态(原绿色边框+绿色结果框)。
* 此函数区分类失败:
* 此函数区分类失败:
* - run_command exit_code≠0:结果 JSON 合法,parseResult 命中,看 exit_code 判失败
* - 通用工具失败:AR-6 路径后端塞 serde_json::Value::String("工具 X 执行失败: ..."),
* - 通用工具 success===false:只读工具(git status/diff/log)失败返 {success:false,error}
* 合法 JSON 对象(非 Err),parseResult 命中但显式 success 为 false → 判失败
* - 通用工具 raw 文本:AR-6 路径后端塞 serde_json::Value::String("工具 X 执行失败: ..."),
* 前端 parseResult 返回 null(非 JSON 对象)→ 走 raw 文本兜底,无结构信号
* 此时按行首前缀 "执行失败"/"Error:"/"Failed:" 启发式判定(对齐后端 err_msg 格式,
* 行首锚定避免误伤含这些子串的正常产出,见 UX-260617-18)。
@@ -150,8 +156,12 @@ export function isToolFailure(tc: AiToolCallInfo): boolean {
// run_command 但无 exit_code(异常)→ 保守判失败
if (r) return true
}
// 通用工具:可解析 JSON 且显式 success===false(git status/diff/log 只读工具失败返
// {success:false,error} 合法 JSON 对象非 Err)→ 判失败,防绿框假成功
const r = parseResult(tc.result)
if (r && r.success === false) return true
// 通用工具:parseResult 返回 null(非 JSON,AR-6 错误字符串路径)→ 看文本前缀
if (!parseResult(tc.result)) {
if (!r) {
const raw = typeof tc.result === 'string' ? tc.result : ''
if (!raw) return false
// UX-260617-18:收窄为行首锚定(原 /执行失败|failed|error[:\s]/i 匹配任意位置的子串,
+6
View File
@@ -88,6 +88,12 @@ function highlightCode(code: string, infostring: string): string {
// infostring 形如 "ts" / "javascript" / "rust,norun" —— 取首个 token 去空白小写
const langRaw = (infostring || '').split(/[\s,]/)[0]?.trim().toLowerCase() ?? ''
const lang = _LANG_ALIASES[langRaw] ?? langRaw
// Mermaid 图:保留原始语法(渲染方按需 mermaid.render 成图,不经过 highlight 误处理)。
// 全项目 markdown 渲染共用此 renderer,统一让 mermaid 块以原文 class="language-mermaid" 呈现。
if (lang === 'mermaid') {
const safe = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
return `<pre><code class="hljs language-mermaid">${safe}</code></pre>`
}
let highlighted: string
try {
if (lang && hljs.getLanguage(lang)) {