新增: 待发箱抽屉与对话体验前端批
- 待发箱抽屉: ChatInput ⏰ 入口 + OutboxDrawer + useAiOutbox IPC 封装
- U2 进度语义三层: 内容层(最近动作) + 静默三档降速脉冲, 永不自动判死
- 流式心跳自愈: 竞态窗口旧任务迟到完成事件误收敛的自愈判定(949 前端兜底)
- 953 截断提示: 末条气泡渲染截断标记 + 继续输出按钮, 事件契约透传
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 渲染为琥珀细条而非错误红卡),
|
||||
|
||||
@@ -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 注入:切会话逻辑归会话域,避免本模块反向依赖造成模块环。 */
|
||||
|
||||
Reference in New Issue
Block a user