新增: 待发箱抽屉与对话体验前端批

- 待发箱抽屉: ChatInput ⏰ 入口 + OutboxDrawer + useAiOutbox IPC 封装
- U2 进度语义三层: 内容层(最近动作) + 静默三档降速脉冲, 永不自动判死
- 流式心跳自愈: 竞态窗口旧任务迟到完成事件误收敛的自愈判定(949 前端兜底)
- 953 截断提示: 末条气泡渲染截断标记 + 继续输出按钮, 事件契约透传
This commit is contained in:
lxy
2026-10-11 07:38:00 +08:00
parent 31510d3555
commit 3818a63522
22 changed files with 1151 additions and 19 deletions
+16
View File
@@ -0,0 +1,16 @@
//! 心跳自愈判定纯函数(看板 949 前端兜底)
//!
//! 场景:force_send 竞态窗口下,旧任务迟到的 AiCompleted 可能把前端流式态误收敛为
//! false(迟到仲裁查后端 generating 时新 loop 尚未占用,误判真收尾);后端新 loop 仍在
//! 生成,delta 照流但发送按钮失态。心跳(后端 30s 一拍)到达时用本判定决定是否自愈。
//!
//! 互补不打架:仅「后端在生成 且 前端流式态丢失」才自愈;迟到仲裁只拦「后端 true 且
//! 前端 true」,两机制判定域不相交,不会同时动作。
//!
//! 独立零依赖模块:node --test 单测直接 import(useAiStreamingEvents.ts 带 @/ 别名与
//! Tauri 依赖,单测环境无法加载),useAiStreamingEvents.ts 消费本判定。
/** 自愈判定:后端生成中(backendGenerating=true)且前端流式态丢失(frontendStreaming=false)→ true */
export function shouldSelfHealStreaming(backendGenerating: boolean, frontendStreaming: boolean): boolean {
return backendGenerating && !frontendStreaming
}
@@ -202,6 +202,19 @@ export async function handleLifecycleEvent(event: AiChatEvent): Promise<boolean>
timestamp: Date.now(),
})
}
// 看板 953:max_tokens 截断标记(主对话气泡截断提示条的数据源)。后端正常收敛出口且
// 最终回复被 provider 上限腰斩时为 true;旧事件无字段(undefined)不标,向后兼容。
// 只标末条 assistant(倒序找,跳过错误气泡):用户再发消息/继续输出后新气泡顶掉末条
// 位置,提示条随 isLastAi 判定自然消失,无需清除逻辑。
if (event.truncated_by_max_tokens) {
for (let i = state.messages.length - 1; i >= 0; i--) {
const m = state.messages[i]
if (m.role === 'assistant' && !m.isError) {
m.truncatedByMaxTokens = true
break
}
}
}
// 直接从事件更新 pinned_goals,不等 loadConversations 异步刷新
if (event.pinned_goals && state.activeConversationId) {
const conv = state.conversations.find(c => c.id === state.activeConversationId)
+131
View File
@@ -0,0 +1,131 @@
//! 待发箱(outbox)视图 — 定时消息/待发队列的 IPC 封装 + 本地缓存
//!
//! 对应后端 `src-tauri/src/commands/ai/outbox.rs` 的 ai_outbox_* 命令族。
//! ⚠️ 后端命令已实现但未注册进 generate_handler(接线清单见交付说明):接线前所有
//! invoke 均失败,本模块**静默降级**(console.warn + 空列表/false 返回),保证抽屉
//! 可用不崩;接线后行为自动完整,前端零改动。
//!
//! 加载/失效时机:进入抽屉 refresh()(OutboxDrawer watch open)+ 每次操作成功后
//! 自动失效重拉 + 监听后端 ai-outbox-changed 事件增量刷新(他窗口操作也能同步)。
//!
//! 设计见 docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.4。
import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
/** 与后端 AiOutboxItem(Tauri DTO)同形,snake_case 原样透传 */
export interface AiOutboxItem {
id: string
conversation_id: string
kind: 'queue' | 'scheduled'
text: string
skill: string | null
model_override: string | null
/** epoch ms;null=尽快队列项 */
due_at: number | null
status: 'pending' | 'sending' | 'sent' | 'failed' | 'canceled'
attempts: number
last_error: string | null
created_at: number
updated_at: number
sent_at: number | null
}
// 模块级单例缓存:抽屉与入口按钮(角标计数等未来消费方)共享同一份视图,
// 操作后统一失效重拉,避免多实例缓存漂移。主/分离窗口各自 JS context 天然隔离。
const items = ref<AiOutboxItem[]>([])
const loading = ref(false)
/** invoke 静默降级封装:命令未接线/后端错误时 warn 并返回 fallback,调用方 UI 不崩 */
async function safeInvoke<T>(cmd: string, args: Record<string, unknown>, fallback: T, label: string): Promise<T> {
try {
return await invoke<T>(cmd, args)
} catch (e) {
console.warn(`[AI outbox] ${label}失败(命令未接线或后端错误):`, e)
return fallback
}
}
/** 拉取列表写入缓存。convId 缺省=跨会话全量(抽屉口径);失败降级空列表 */
async function refresh(convId?: string): Promise<void> {
loading.value = true
try {
const list = await invoke<AiOutboxItem[]>('ai_outbox_list', { convId: convId ?? null })
items.value = Array.isArray(list) ? list : []
} catch (e) {
console.warn('[AI outbox] 列表拉取失败(命令未接线或后端错误):', e)
items.value = []
} finally {
loading.value = false
}
}
/**
* 新建定时/待发项。dueAt=epoch ms(定时项,须晚于当前时刻);缺省=尽快队列项。
* conversationId 必传(后端不猜 active);返回新项 id,失败 null(错误已 warn)。
*/
async function enqueue(
text: string,
dueAt?: number,
opts?: { skill?: string; modelOverride?: string; conversationId?: string },
): Promise<string | null> {
const id = await safeInvoke<string | null>(
'ai_outbox_enqueue',
{
text,
dueAt: dueAt ?? null,
skill: opts?.skill ?? null,
modelOverride: opts?.modelOverride ?? null,
conversationId: opts?.conversationId ?? null,
},
null,
'入队',
)
if (id) void refresh()
return id
}
/** 取消待发项。成功 true 并刷新;未接线/后端拒绝(终态等)false */
async function cancel(id: string): Promise<boolean> {
const ok = await safeInvoke<boolean>('ai_outbox_cancel', { id }, false, '取消')
if (ok) void refresh()
return ok
}
/** 改期(dueAt=epoch ms;后端允许过去时刻=立即派发语义)。成功 true 并刷新 */
async function reschedule(id: string, dueAt: number): Promise<boolean> {
const ok = await safeInvoke<boolean>('ai_outbox_reschedule', { id, dueAt }, false, '改期')
if (ok) void refresh()
return ok
}
/** 编辑待发文本。终态项后端拒绝(返 false) */
async function updateText(id: string, text: string): Promise<boolean> {
const ok = await safeInvoke<boolean>('ai_outbox_update_text', { id, text }, false, '编辑')
if (ok) void refresh()
return ok
}
/** 立即派发一次(后端与调度器同一条派发链;不受 df-outbox-enabled 开关约束)。成功 true 并刷新 */
async function sendNow(id: string): Promise<boolean> {
const ok = await safeInvoke<boolean>('ai_outbox_send_now', { id }, false, '立即发送')
if (ok) void refresh()
return ok
}
// 后端变更事件驱动刷新:任何入队/派发/失败/改期后各窗口视图同步。
// 幂等注册(模块级一次);后端未接线时不会有该事件,注册本身零开销。
let changeListenerReady = false
function ensureChangeListener(): void {
if (changeListenerReady) return
changeListenerReady = true
listen('ai-outbox-changed', () => { void refresh() }).catch(() => {
changeListenerReady = false // 注册失败(非 Tauri 环境等)允许下次重试
})
}
export function useAiOutbox() {
ensureChangeListener()
return { items, loading, refresh, enqueue, cancel, reschedule, updateText, sendNow }
}
+12 -2
View File
@@ -94,6 +94,16 @@ export function setSidebarWidth(px: number) {
state.sidebarWidth = clampSidebarWidth(px)
}
/** 设置最大化态 — 单一写入入口(所有 maximized 赋值收敛于此,杜绝绕过 persistUiState 的分歧)。
* 修复 P0:App.vue 路由 watch 直接置 state.maximized=false 未走 persistUiState,
* 导致内存态与磁盘态(df-ai-ui)不一致,重启后旧值闪回。本函数保证任何调用方改最大化
* 态都会落盘。
* 参数 persist:默认 true(立即落盘)。高频/连续赋值场景可调 false,由调用方收口落盘。 */
export function setMaximized(v: boolean, persist = true) {
state.maximized = v
if (persist) persistUiState()
}
/** 切换面板展开/收起(收起时同步退出最大化,避免 v-show 卡死 main 交互) */
function togglePanel() {
state.panelOpen = !state.panelOpen
@@ -104,8 +114,7 @@ function togglePanel() {
/** 最大化/还原侧栏宽度 */
function toggleMaximize() {
state.maximized = !state.maximized
persistUiState()
setMaximized(!state.maximized)
}
/**
@@ -225,6 +234,7 @@ export function useAiPanel() {
clearChat,
// 侧栏宽度拖拽
setSidebarWidth,
setMaximized,
persistUiState,
}
}
+36 -3
View File
@@ -10,9 +10,12 @@ import { emit } from '@tauri-apps/api/event'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers'
import { nextMsgId, findToolCall, startApprovalTimer, clearToolSlowTimer, getLastDelta, setLastDelta, flushCurrentText, addPendingApproval, setConvRound } from './aiShared'
import { nextMsgId, findToolCall, startApprovalTimer, clearToolSlowTimer, getLastDelta, setLastDelta, flushCurrentText, addPendingApproval, setConvRound, getConvStreamState } from './aiShared'
import { resetTextIdleTimer, clearTextIdleTimer, pendingDirAuths, pushPendingDirAuth } from './useAiPendingState'
import { clearStreamWatchdog } from './useAiStream'
import { clearStreamWatchdog, resetStreamWatchdog } from './useAiStream'
import { setStreaming } from './streamingGuard'
import { aiApi } from '@/api/ai'
import { shouldSelfHealStreaming } from './streamSelfHeal'
import type { AiChatEvent, AiMessage, MessageId } from '@/api/types'
const appSettings = useAppSettingsStore()
@@ -23,6 +26,32 @@ function isUnifiedApproval(): boolean {
return appSettings.get<boolean>('df-ai-unified-approval', true)
}
/**
* 心跳自愈(火后不理,看板 949 兜底):前端流式态丢失而后端仍在生成时恢复 streaming + 重启看门狗。
*
* 链路:force_send 竞态让旧任务迟到 AiCompleted 误杀前端流式态(仲裁查后端时新 loop 尚未
* 占用 generating),后端内容照流但按钮失态;心跳 30s 一拍,到达即先零开销短路(前端仍 true
* 不查态),疑似丢失才 IPC 查后端真值,「后端 true 且前端 false」才动作 —— 与迟到仲裁
* (后端 true 且前端 true 才拦截)判定域互补,两机制不同时动作。
* IPC 返回后重读前端流式态再决策(等待窗口内可能有新事件改写,防陈旧快照反向覆盖);
* 查态失败静默放弃,等下一拍心跳再试。
*/
function selfHealStreamingOnHeartbeat(convId?: string | null): void {
const target = convId || state.activeConversationId
// 仅当前视图会话:state.streaming 是当前视图流式态,后台会话事件在 useAiEvents 路由层已过滤
if (!target || target !== state.activeConversationId) return
// 疑似丢失才发起 IPC(前端仍 streaming=true 是常态,零开销短路)
if (!shouldSelfHealStreaming(true, getConvStreamState(target)?.streaming ?? false)) return
void aiApi.isGenerating(target)
.then(backendGenerating => {
if (!shouldSelfHealStreaming(backendGenerating, getConvStreamState(target)?.streaming ?? false)) return
setStreaming(true, { convId: target, reason: 'AiHeartbeat 自愈' })
resetStreamWatchdog(target)
console.warn(`[AI-Streaming] 心跳自愈:后端生成中而前端流式态丢失,已恢复(conv=${target.slice(0, 8)})`)
})
.catch(() => { /* 查态失败:本拍不动作,等下一拍心跳 */ })
}
/** streaming 域:流式文本累积/新轮/心跳/重试。返回 true=已处理。 */
export function handleStreamingEvent(event: AiChatEvent): boolean {
switch (event.type) {
@@ -72,9 +101,13 @@ export function handleStreamingEvent(event: AiChatEvent): boolean {
return true
}
case 'AiHeartbeat':
case 'AiHeartbeat': {
// 心跳仅维持看门狗(已在上方 reset),显式 case 防 switch 穿透
// 看板 949 自愈兜底:后端在生成而前端流式态丢失(force_send 竞态误杀)时恢复,
// 自愈延迟≤一拍心跳(后端 30s)
selfHealStreamingOnHeartbeat(event.conversation_id)
return true
}
case 'AiStreamRetry': {
// 重试中——首条重试推过渡气泡(isRetry 标记,MessageList 渲染为琥珀细条而非错误红卡),
+38
View File
@@ -98,6 +98,44 @@ export function registerMainReattachListener(handler: () => Promise<void>): void
})()
}
// ── P1(2026-10-10)分离窗口「去设置」跨窗直达:分离窗无 Settings 路由,原实现仅 toast
// 提示用户「去主窗口设置」,链路断裂(分离窗长时间工作场景下改配置须手动找窗口)。
// 现走事件广播(对齐 REATTACH_EVENT 模式):分离窗 emit → 主窗口 listen → 显示/聚焦
// 主窗 + router.push('/settings')。
const SETTINGS_EVENT = 'ai-open-settings'
/** 发起方(分离窗)侧:广播「打开设置」事件,主窗口消费 */
export async function emitOpenSettings(): Promise<void> {
try {
const { emit } = await import('@tauri-apps/api/event')
await emit(SETTINGS_EVENT, {})
} catch (e) {
console.error('[AI] 跨窗打开设置事件发送失败:', e)
}
}
/** 承接方(主窗口)侧:注册「打开设置」监听。handler 由 AiChat 注入(显示主窗 + 导航) */
let _settingsListenerRegistered = false
export function registerMainOpenSettingsListener(handler: () => Promise<void>): void {
if (_settingsListenerRegistered) return
_settingsListenerRegistered = 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>>(SETTINGS_EVENT, async () => {
await handler()
})
} catch (e) {
console.error('[AI] 主窗口设置监听注册失败:', e)
_settingsListenerRegistered = false
}
})()
}
/** 承接方(分离窗)侧:注册跨窗切会话监听,切换完成后聚焦本窗口(点击发生在主窗口,
* 焦点须跟到承接会话的窗口,否则用户感知「点了没反应」)。
* handler 由 useAiConversations 注入:切会话逻辑归会话域,避免本模块反向依赖造成模块环。 */