优化: 聊天体验批与Web规范合规

This commit is contained in:
lxy
2026-10-05 23:41:32 +08:00
parent 9e81182b4f
commit 28e6acbf3f
41 changed files with 1518 additions and 570 deletions
+2 -1
View File
@@ -153,9 +153,10 @@ async function batchApprove(decision: 'approve' | 'reject') {
}
}
_batchProcessing.value = false
// 若有失败项,console.warn 诊断(用户可打开 DevTools 查看具体失败项)
// #14(2026-10-05):部分失败从静默 console.warn 升级为用户可见 toast(仍保留 warn 留痕诊断)
if (failCount > 0) {
console.warn(`[AI] 批量审批完成: ${successCount} 成功, ${failCount} 失败`)
void emit('ai-toast', { msg: t('aiTool.batchPartialFail', { ok: successCount, fail: failCount }), type: 'warning' })
}
}
+27 -6
View File
@@ -5,6 +5,7 @@
//! - newConversation/switchConversation 写 appSettings 持久化活跃会话 id
import { ref } from 'vue'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
@@ -15,12 +16,32 @@ import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApp
import { t } from '@/i18n/i18n-helpers'
import type { AiConversationDetail, AiMessage, AiToolCallInfo, AiToolCallStatus, ConvId, MessageId } from '@/api/types'
// ── V11c(2026-10-05)活跃会话 KV 按窗口域分流 ──
// 主窗与分离窗是独立 JS context,却共用同一 SQLite KV:两窗各自切会话都写 'df-ai-active-conv'
// 会互相覆盖(后写者赢),重启后两窗恢复到同一会话。按窗口 label 分流:主窗 'df-ai-active-conv'
// (历史 KV 兼容,主窗数据零迁移),分离窗 'df-ai-active-conv-detached';读端(loadConversations
// 启动恢复)与写端(切会话/新建/自动路由/删除回落)统一走本函数取键。
// 每次调用现解析:窗口对象在 webview 启动即存在(getCurrentWebviewWindow 同步),无启动竞态;
// 非 Tauri 环境(单测/工具链)兜底主窗键。useAiEvents 的自动路由写入点同源 import 消费。
export function activeConvKvKey(): string {
try {
return getCurrentWebviewWindow().label === 'main'
? 'df-ai-active-conv'
: 'df-ai-active-conv-detached'
} catch {
return 'df-ai-active-conv'
}
}
const appSettings = useAppSettingsStore()
/** 拉取会话列表;首次加载时若 appSettings 仍有有效 active-conv 且当前无活跃对话则恢复 */
async function loadConversations() {
try {
state.conversations = await aiApi.listConversations()
// includeArchived=true:侧栏归档分组(ConversationSidebar archivedConvs)数据源即本内存列表,
// 若拉取时排除归档,重启后已归档会话不可见且无恢复入口(2026-10-05 实锤 bug)。
// 活跃/归档分组由侧栏 groupedActive/archivedConvs 各自分离渲染;后端已分组截断,归档不挤占活跃名额。
state.conversations = await aiApi.listConversations(50, true)
// 虚拟项保底:活跃会话未落库(懒创建——首条消息前 DB 无记录)时,侧栏显示虚拟占位项。
// 后端 ai_conversation_create 仅生成 id 存内存不落库,首条消息发送后 save_conversation 才写库。
// 此处 active 会话不在 DB 列表时补一个虚拟项,实现「点新建立即出现在侧栏,不发消息不落库」。
@@ -42,7 +63,7 @@ async function loadConversations() {
})
}
// 恢复上次活跃对话(仅在首次加载、ID 仍有效且当前无活跃对话时)
const pending = appSettings.get<string | null>('df-ai-active-conv', null)
const pending = appSettings.get<string | null>(activeConvKvKey(), null)
if (pending && !state.activeConversationId && state.conversations.some(c => c.id === pending)) {
await switchConversation(pending)
}
@@ -101,7 +122,7 @@ async function newConversation() {
)
if (!result) return // 失败已推气泡,保持当前视图
state.activeConversationId = result.id
void appSettings.set('df-ai-active-conv', result.id)
void appSettings.set(activeConvKvKey(), result.id)
// G3.5:新对话无历史,load_more 游标复位
loadMoreCursor.hasMore = false
loadMoreCursor.earliestSeq = null
@@ -319,7 +340,7 @@ export async function switchConversation(id: string, force = false, opts?: { noC
console.warn('[AI] switchConversation 失败(对话不存在),创建新对话:', id)
const created = await aiApi.createConversation()
if (mySwitchId !== _latestSwitchId) return 'ok'
void appSettings.set('df-ai-active-conv', created.id)
void appSettings.set(activeConvKvKey(), created.id)
// 兜底置 activeId:此路径原只持久化不写 state.activeConversationId,旧(不存在)id 残留
// 会让后续所有事件被 isCurrent 判为非当前而丢弃或误路由
state.activeConversationId = created.id
@@ -418,7 +439,7 @@ export async function switchConversation(id: string, force = false, opts?: { noC
return 'ok'
}
// 切换提交完成才持久化活跃会话 + 重算 streaming(active 已指向目标 conv)
void appSettings.set('df-ai-active-conv', id)
void appSettings.set(activeConvKvKey(), id)
setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' })
// P2-2 完成卡回补:重启/分离窗回收后内存卡清零,按父 conv 反查子代理轨迹重建。
@@ -552,7 +573,7 @@ async function deleteConversation(id: string) {
clearLastDelta(id)
discardConvPendingApprovals(id)
if (wasActive) {
void appSettings.remove('df-ai-active-conv')
void appSettings.remove(activeConvKvKey())
// 删除活跃会话 → load_more 游标复位(无 active 视图)
loadMoreCursor.hasMore = false
loadMoreCursor.earliestSeq = null
+2 -2
View File
@@ -19,7 +19,7 @@ import { state } from '@/stores/ai'
import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearConvStreamState, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, addConvAgentFinished, clearConvAgentFinished, type AgentFinishedStatus } from './aiShared'
import { resetStreamWatchdog, clearAllStreamWatchdogs, clearResumeAttempts } from './useAiStream'
import { setStreaming } from './streamingGuard'
import { scheduleConversationsRefresh } from './useAiConversations'
import { scheduleConversationsRefresh, activeConvKvKey } from './useAiConversations'
import { pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
import { clearConvBoundaryCache } from './useTaskBoundary'
import { handleStreamingEvent } from './useAiStreamingEvents'
@@ -187,7 +187,7 @@ export function handleEvent(event: AiChatEvent) {
// 首次收到事件时同步当前对话 id(后端自动建对话的场景)并持久化
if (convId && !state.activeConversationId) {
state.activeConversationId = convId
void appSettings.set('df-ai-active-conv', convId)
void appSettings.set(activeConvKvKey(), convId)
}
// L2 状态机:AiConvStateChanged 是 per-conv 状态信号,在 isCurrent 守卫之前处理
// (切走会话时也须更新 conv_state,防停止按钮/MaxRoundsCard 错态)
+7 -1
View File
@@ -14,7 +14,7 @@
//! - drainQueue 由 handleEvent(AiCompleted) 经 ai-drain-queue 事件总线触发(非直接调用,
//! 打破 events↔send 循环依赖);本模块模块级监听该事件
import { listen } from '@tauri-apps/api/event'
import { listen, emit } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core'
import { ref } from 'vue'
import { aiApi } from '@/api'
@@ -537,6 +537,12 @@ async function stopChat() {
// 决策 a(逐条续发):不再清队列,保留供 AiCompleted 链式触发 drainQueue 续发。
// 后端 agentic/mod.rs emit AiCompleted 注释明确「保证前端收事件时后端已可接下一条,队列续发不被拒」;
// 当前未完成回复已入库(agentic/mod.rs save_conversation)保留为截断回复。
// I8(2026-10-05):停止≠清队,用户预期常是「全部停下」——队列非空时一次性提示
// 「停止后 N 条排队消息仍将自动发送」,给用户明确预期(要全停可逐条取消/清队列)。
const pendingQueued = state.queue.filter(q => q.conversationId === state.activeConversationId).length
if (pendingQueued > 0) {
void emit('ai-toast', { msg: t('ai.queueWillContinue', { n: pendingQueued }), type: 'info' })
}
try {
// F-260616-09 B 批4(决策 e):传 activeConversationId,停止仅作用于当前 conv。
await aiApi.stopChat(state.activeConversationId)
+12 -3
View File
@@ -25,10 +25,19 @@ export function handleToolEvent(event: AiChatEvent): boolean {
}
// 同 id 重复 Started 不重复 push(provider id 不稳,防残留 running 空卡)
if (!findToolCall(event.id)) {
// #15(2026-10-05):末条非 assistant(如 system 占位/思考 pill/完成轨迹折叠重排)时
// 原直接丢弃工具卡 → running 卡消失但工具仍在执行。兜底向前找最近 assistant 落卡;
// 全程无 assistant(极端:首轮工具前无任何回复)才真丢弃(与原行为一致)。
const lastMsg = state.messages[state.messages.length - 1]
if (lastMsg && lastMsg.role === 'assistant') {
lastMsg.toolCalls = lastMsg.toolCalls || []
lastMsg.toolCalls.push(info)
let target = (lastMsg && lastMsg.role === 'assistant') ? lastMsg : null
if (!target) {
for (let i = state.messages.length - 1; i >= 0; i--) {
if (state.messages[i].role === 'assistant') { target = state.messages[i]; break }
}
}
if (target) {
target.toolCalls = target.toolCalls || []
target.toolCalls.push(info)
}
}
// 工具开始执行即挂慢执行计时器(超时仅 toast,不动 running 态)
+52 -1
View File
@@ -12,6 +12,7 @@
import { invoke } from '@tauri-apps/api/core'
import { state } from '@/stores/ai'
import { useAppSettingsStore } from '@/stores/appSettings'
import { nextMsgId, getConvState, convStates } from './aiShared'
import type { MessageId } from '@/api/types'
import { setStreaming } from './streamingGuard'
@@ -58,6 +59,45 @@ export async function emitDetachedOpenConversation(convId: string): Promise<void
}
}
// ── V11b(2026-10-05)跨窗收回分离窗:reattachPanel 的 UI 入口 ──
// 病灶:reattachPanel(关闭全部分离窗+回主面板)全仓零 UI 调用——分离窗内「关闭」按钮走
// closeDetachedWindow(仅关窗不恢复主面板),用户想收回只能手动重开主面板。主/分离窗是
// 独立 JS context,分离窗按钮无法直调主窗 store;走 Tauri 全局事件广播(对齐 OPEN_CONV_EVENT
// 模式):分离窗 emit → 主窗(label 判定)监听 → 执行 reattachPanel。
const REATTACH_EVENT = 'ai-reattach-panel'
/** 发起方(分离窗)侧:广播收回事件,主窗口消费 */
export async function emitReattachToMain(): Promise<void> {
try {
const { emit } = await import('@tauri-apps/api/event')
await emit(REATTACH_EVENT, {})
} catch (e) {
console.error('[AI] 跨窗收回事件发送失败:', e)
}
}
/** 承接方(主窗口)侧:注册收回监听。handler=reattachPanel(由 AiChat 挂载时注入,归窗口域) */
let _reattachListenerRegistered = false
export function registerMainReattachListener(handler: () => Promise<void>): void {
if (_reattachListenerRegistered) return
_reattachListenerRegistered = true
void (async () => {
try {
const [{ listen }, { getCurrentWebviewWindow }] = await Promise.all([
import('@tauri-apps/api/event'),
import('@tauri-apps/api/webviewWindow'),
])
if (getCurrentWebviewWindow().label !== 'main') return
await listen<Record<string, never>>(REATTACH_EVENT, async () => {
await handler()
})
} catch (e) {
console.error('[AI] 主窗口收回监听注册失败:', e)
_reattachListenerRegistered = false
}
})()
}
/** 承接方(分离窗)侧:注册跨窗切会话监听,切换完成后聚焦本窗口(点击发生在主窗口,
* 焦点须跟到承接会话的窗口,否则用户感知「点了没反应」)。
* handler 由 useAiConversations 注入:切会话逻辑归会话域,避免本模块反向依赖造成模块环。 */
@@ -114,6 +154,12 @@ async function detachPanel(convId?: string) {
localStorage.setItem(textKey(targetConv), state.currentText)
}
}
// V11c: 种子分离窗活跃会话 KV——分离窗 AiChat 挂载同样跑 loadConversations(),
// 读按窗口域分流的 'df-ai-active-conv-detached'(独立 JS context 不与主窗共 pinia state),
// 不种子则分离窗启动回落列表首条而非主窗正看的会话。目标 conv 为空(无活跃会话)跳过。
if (targetConv) {
void useAppSettingsStore().set('df-ai-active-conv-detached', targetConv)
}
// 主窗口 state 与分离窗口 state 独立(各自 webview 独立 JS context,
// stores/ai.ts 模块级单例仅在同 webview 内共享),清主窗口 state 不影响分离窗口生成态。
// 分离窗口接管生成后,主窗口不应再持该会话生成态残留(防重开面板时 UI 显生成中幽灵/进度条)。
@@ -133,7 +179,10 @@ async function detachPanel(convId?: string) {
minHeight: 400,
center: true,
decorations: true,
alwaysOnTop: true, // 分离窗口默认置顶(AI 助手常态:用户在主窗口工作时希望 AI 辅助窗口可见)
// V11a(2026-10-05):默认不置顶——原 true 会让分离窗盖住其他应用(VSCode/浏览器)妨碍工作;
// 用户开过置顶的偏好走 AiChat.vue 的 df-ai-always-on-top KV 恢复链(分离窗挂载时
// setAlwaysOnTop(saved)),默认改 false 不影响已开置顶用户,只改变首次/未开者的初始态。
alwaysOnTop: false,
})
win.once('tauri://created', () => {
state.panelOpen = false
@@ -183,6 +232,8 @@ async function reattachPanel() {
state.docked = false
state.panelOpen = true
persistUiState()
// V11c: 清分离窗活跃会话 KV(分离窗已全关,残留无意义;下次 detachPanel 会重新种子)
void useAppSettingsStore().remove('df-ai-active-conv-detached')
// 时序加固:reattach 关闭分离窗口后,显式停分离窗口残留 listener(分离窗口 close 异步,
// onBeforeUnmount stopListener 可能延迟),防主面板重新挂载 startListener 时与旧 listener 重叠竞态。
stopListener()
+20 -16
View File
@@ -15,7 +15,7 @@
//! 与 TaskDetail/AiChat/MessageList 同实例,渲染结果零变化。
import { ref, computed, nextTick, type Ref, type ComputedRef } from 'vue'
import { useMarkdown } from '../useMarkdown'
import { useMarkdown, wrapNakedDiff } from '../useMarkdown'
import { useAppSettingsStore } from '../../stores/appSettings'
/// 流式渲染返回的单块渲染结果(html + 模板 v-for key)
@@ -122,7 +122,9 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
const purify = getPurify()
if (!marked || !purify) return escapeFallback(block)
try {
return purify.sanitize(marked.parse(block) as string)
// R8:流式块同源裸 diff 预处理(原仅完成态 renderMd 有),流式中裸 +/- 行即红绿代码块,
// 不再等完成态突变丢阅读锚点。守卫3(围栏内不动)保证与完成态 renderMd 幂等不双重包裹。
return purify.sanitize(marked.parse(wrapNakedDiff(block)) as string)
} catch {
return escapeFallback(block)
}
@@ -150,7 +152,8 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
// AR-1 流式 MD 开关关 → 回退纯文本短路(escapeFallback 全文转义 + <br>,原行为)
// mdReady 未就绪也走纯文本兜底(marked 尚未加载完成)。
if (!streamingMdEnabled.value || !mdReady.value || !getMarked() || !getPurify()) {
_prevStreamText = '' // 纯文本降级路径不增量缓存(下帧回全量,防旧帧基线误命中)
// 纯文本降级路径不增量缓存(下帧回全量,防旧帧基线误命中)→ 三元组清零走统一入口(DRY)
resetIncrementalBaseline()
return [{ html: escapeFallback(text), key: 'fallback' }]
}
// 增量切块:追加场景复用上一帧已完成块,只对「旧末块 + 增长段」增量 lexer。
@@ -159,13 +162,10 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
let blocks: string[]
if (appendOnly) {
const growth = text.slice(_prevStreamText.length)
const combined = _prevTail + growth
const combinedBlocks = splitBlocks(combined)
blocks = combinedBlocks.length <= 1
// 仍单块增长(旧末块未跨边界)→ 已完成块不变,新末块 = 旧末块+增长
? _prevCompleted.concat(combinedBlocks)
// 旧末块跨边界拆出若干已完成块(围栏闭合/新 \n\n)+ 新末块
: _prevCompleted.concat(combinedBlocks.slice(0, -1), combinedBlocks[combinedBlocks.length - 1])
// splitBlocks 输出无论 1 块(旧末块未跨边界,新末块=旧末块+增长)还是 N 块
//(旧末块跨边界拆出若干已完成块+新末块),语义均为「追加到已完成块序列之后」,
// 原 if/else 两分支 concat 结果恒等价 → 收敛单 concat(死分支清除,DRY)。
blocks = _prevCompleted.concat(splitBlocks(_prevTail + growth))
} else {
blocks = splitBlocks(text)
}
@@ -240,6 +240,14 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
})
}
/// 三元组增量基线清零(唯一写入口):纯文本降级路径/清流式状态/清块缓存三处共用。
/// 手抄三行式已废弃,清基线必须走本函数(DRY 2026-10-05)。
function resetIncrementalBaseline(): void {
_prevStreamText = ''
_prevCompleted = []
_prevTail = ''
}
/// 清理流式渲染状态(streaming 翻 false / 会话切换 / 卸载前调)
/// 取消 pending rAF + 清 streamingBlocks + 重置 lastStreamText + 清增量基线。
function clearStreamingState(): void {
@@ -247,9 +255,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
streamingBlocks.value = []
lastStreamText = ''
// 清增量基线(防新轮/新会话误命中旧帧已完成块)。
_prevStreamText = ''
_prevCompleted = []
_prevTail = ''
resetIncrementalBaseline()
}
/// 仅取消 pending rAF(不清 streamingBlocks/lastStreamText)。
@@ -263,9 +269,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
/// 同步清增量基线(已完成块引用旧会话内容,防跨会话误复用)。
function resetStreamCache(): void {
_blockCache.clear()
_prevStreamText = ''
_prevCompleted = []
_prevTail = ''
resetIncrementalBaseline()
}
return {
+5 -1
View File
@@ -205,6 +205,10 @@ function renderMd(text: string): string {
* 本函数在 marked.parse 前扫描文本,把「连续 ≥2 行以 + 或 - 开头、且至少含 1 行 +」的
* 段落整段包成 ```diff 围栏代码块,复用②子项已就绪的 hljs diff 红绿样式,零额外 CSS。
*
* R8(2026-10-05)改 export:流式渲染链(useStreamRenderer.renderStreamingBlocks)同源复用,
* 治「流式中裸 diff 渲染为普通段落,完成后突变红绿代码块」的阅读锚点丢失。
* 幂等安全:守卫3(围栏内不动)保证流式已包的块,完成态 renderMd 再处理不双重包裹。
*
* 守卫:
* 1. **纯 - 列表不触发**: 普通 markdown ul 每行 `- ` 是常见语法,纯 - 会误伤成代码块。
* 故要求段内至少含 1 行 `+ `(+ 必须含),这是裸 diff 区别于普通列表的强特征。
@@ -221,7 +225,7 @@ const _DIFF_LINE_RE = /^[ \t]*[+-][^\n]*$/ // 行首可选空格/tab + +/- + 任
const _PLUS_LINE_RE = /^[ \t]*\+[^\n]*$/ // 含 + 的行(+ 必须含的判定)
const _FENCE_OPEN_RE = /^[ \t]{0,3}(```|~~~)/ // marked 围栏开头(行首 ≤3 空格 + 3+ 反引号/波浪)
function wrapNakedDiff(text: string): string {
export function wrapNakedDiff(text: string): string {
if (!text) return text
// 快速短路:全文无 +/- 行首特征直接返回原文(避免大文本白跑切段)
if (!/[+-]/.test(text)) return text