新增: 无人值守P0任务边界全链
- 任务级授权边界判定与审批旁路单点插桩 - boundary_json 随会话落库(V45 可逆迁移) - 六路非正常终局统一安全停靠与托盘通知 - 每五轮进度事件与会话头部进度条 - 前端边界配置卡侧栏徽标及 UX 校验补齐
This commit is contained in:
@@ -10,9 +10,10 @@ import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { state } from '@/stores/ai'
|
||||
import { persistUiState } from './useAiPanel'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged, setConvPendingApprovals, discardConvPendingApprovals } from './aiShared'
|
||||
import { emitDetachedOpenConversation, registerDetachedOpenConvListener } from './useAiWindow'
|
||||
import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged, setConvPendingApprovals, discardConvPendingApprovals, backfillConvAgentFinished } from './aiShared'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId, MessageId } from '@/api/types'
|
||||
import type { AiConversationDetail, AiMessage, AiToolCallInfo, AiToolCallStatus, ConvId, MessageId } from '@/api/types'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
@@ -154,6 +155,12 @@ function getLoadMoreState() {
|
||||
return loadMoreCursor
|
||||
}
|
||||
|
||||
/** 会话是否处于在途生成态(非终止三态;switchConversation/loadMoreHistory 回载口径一致) */
|
||||
function isConvGenerating(convId: string): boolean {
|
||||
const cs = getConvState(convId)
|
||||
return cs === 'generating' || cs === 'stopping' || cs === 'compressed'
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端 ChatMessage[] → 前端 AiMessage[](switch + load_more 共用)。
|
||||
* 过滤/映射语义与 switchConversation 原内联逻辑逐行对齐(tool 消息 + truncated 软删剔除、
|
||||
@@ -161,8 +168,12 @@ function getLoadMoreState() {
|
||||
* id:优先后端真实消息 id(DB 主键,prepend 时 v-for key 稳定),缺失兜底 `${fallbackPrefix}-${i}`
|
||||
* (老数据无 id / 旧 messages JSON 列)。fallbackPrefix 区分 switch('loaded')与 load_more('older'),
|
||||
* 防两条 id 方案在 prepend 后冲突。
|
||||
* convGenerating:目标会话是否在途生成。主 loop 在工具执行前已落库带 tool_calls 的 assistant
|
||||
* 消息,回载时无 tool_result 的 tool_call 不能恒标 completed(UX走查 P1-2:运行期切回父会话
|
||||
* 见「已完成」幽灵态);在途时标 running 由后续 AiToolCallCompleted 按 id 推进终态。
|
||||
* 会话不在生成(后端崩溃残留等)无更好真相,维持 completed 原行为。
|
||||
*/
|
||||
function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[] {
|
||||
function parseConvMessages(rawMsgs: any[], fallbackPrefix: string, convGenerating = false): AiMessage[] {
|
||||
// 构建 tool_call_id → tool_result 映射,用于回填工具执行结果
|
||||
const toolResultMap = new Map<string, string>()
|
||||
for (const m of rawMsgs) {
|
||||
@@ -227,7 +238,9 @@ function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[]
|
||||
id: tc.id,
|
||||
name: tc.function?.name || '',
|
||||
args,
|
||||
status: 'completed' as const,
|
||||
status: (toolResultMap.has(tc.id) || !convGenerating
|
||||
? 'completed'
|
||||
: 'running') as AiToolCallStatus,
|
||||
result: toolResultMap.get(tc.id),
|
||||
}
|
||||
}),
|
||||
@@ -248,7 +261,7 @@ async function loadMoreHistory(): Promise<boolean> {
|
||||
// 过期丢弃:等待期间用户切走/删除会话,游标已指向别的 conv → 不 prepend 错视图
|
||||
if (cur.convId !== state.activeConversationId) return false
|
||||
const rawMsgs = typeof res.messages === 'string' ? JSON.parse(res.messages) : res.messages
|
||||
const older = parseConvMessages(rawMsgs, 'older')
|
||||
const older = parseConvMessages(rawMsgs, 'older', isConvGenerating(cur.convId))
|
||||
// 按 id 去重(防御:游标边界变化 / 与已加载消息重复)
|
||||
const existing = new Set(state.messages.map(m => m.id))
|
||||
const fresh = older.filter(m => !existing.has(m.id))
|
||||
@@ -272,12 +285,16 @@ async function loadMoreHistory(): Promise<boolean> {
|
||||
|
||||
/** 切换到指定会话:加载历史消息(含 tool_calls 回填 + tool_result 映射 + pending 审批恢复)
|
||||
* @param force true=跳过同会话短路(程序化刷新当前会话用:清空/压缩后回刷,如 AiChat.vue
|
||||
* onCleared/onCompressed 回调);false(默认)=同会话点击直接 return。 */
|
||||
export async function switchConversation(id: string, force = false) {
|
||||
* onCleared/onCompressed 回调);false(默认)=同会话点击直接 return。
|
||||
* @param opts.noCreateFallback true=「对话不存在」时不再兜底新建会话,返回 'not_found'
|
||||
* 交调用方反馈(UX走查 P3-7:程序化跳转命中已删会话,静默新建落空白对话)。
|
||||
* @returns 'not_found' 仅在 noCreateFallback 且目标会话不存在时;其余路径(含瞬态失败,
|
||||
* 已保留原视图并推气泡)一律 'ok'。 */
|
||||
export async function switchConversation(id: string, force = false, opts?: { noCreateFallback?: boolean }): Promise<'ok' | 'not_found'> {
|
||||
// 同会话点击短路:已活跃会话不重拉。原实现无条件走 currentText='' + 全量 parse +
|
||||
// pending 重恢复,流式中点侧栏高亮项会清空流式文本、delta 从空续流 → 回复缺前缀。短路保留
|
||||
// in-flight 流式文本。程序化刷新(force=true)不受影响。
|
||||
if (!force && id === state.activeConversationId) return
|
||||
if (!force && id === state.activeConversationId) return 'ok'
|
||||
// 切换会话时清空所有审批计时器(防切走后过期 timer 误拒后台挂起审批 + 错误气泡推错视图)。
|
||||
// 新会话的审批计时器由下文恢复挂起路径的 startApprovalTimer 重新建立。
|
||||
clearAllApprovalTimers()
|
||||
@@ -294,9 +311,14 @@ export async function switchConversation(id: string, force = false) {
|
||||
// 瞬态 IPC 失败(网络抖动/后端异常)保留原视图 + 推错误气泡,不再吞当前视图。
|
||||
const errMsg = e instanceof Error ? e.message : String(e)
|
||||
if (errMsg.includes('对话不存在')) {
|
||||
// 程序化跳转(P3-7):目标已删不静默新建,留原视图由调用方就地反馈
|
||||
if (opts?.noCreateFallback) {
|
||||
console.warn('[AI] switchConversation: 会话不存在(已删除),保持原视图:', id)
|
||||
return 'not_found'
|
||||
}
|
||||
console.warn('[AI] switchConversation 失败(对话不存在),创建新对话:', id)
|
||||
const created = await aiApi.createConversation()
|
||||
if (mySwitchId !== _latestSwitchId) return
|
||||
if (mySwitchId !== _latestSwitchId) return 'ok'
|
||||
void appSettings.set('df-ai-active-conv', created.id)
|
||||
// 兜底置 activeId:此路径原只持久化不写 state.activeConversationId,旧(不存在)id 残留
|
||||
// 会让后续所有事件被 isCurrent 判为非当前而丢弃或误路由
|
||||
@@ -313,15 +335,15 @@ export async function switchConversation(id: string, force = false) {
|
||||
setConvPendingApprovals(created.id, [])
|
||||
notifyConversationChanged()
|
||||
scheduleConversationsRefresh()
|
||||
return
|
||||
return 'ok'
|
||||
}
|
||||
// 瞬态失败:保留当前视图 + 错误气泡(复用会话操作失败气泡模式);过期响应丢弃。
|
||||
// 保留 scheduleConversationsRefresh():陈旧 id(已被后端删除)在下一次列表刷新中消失。
|
||||
console.error('[AI] switchConversation 失败(瞬态),保留当前视图:', e)
|
||||
if (mySwitchId !== _latestSwitchId) return
|
||||
if (mySwitchId !== _latestSwitchId) return 'ok'
|
||||
pushConvOpFail('switchConvFail')
|
||||
scheduleConversationsRefresh()
|
||||
return
|
||||
return 'ok'
|
||||
} finally {
|
||||
// 切换结束(含失败/过期/新建会话路径)清切换中标记 + 统一收集并清空缓冲。
|
||||
// 成功提交路径在下方用 _switchBufferedText 恢复续显;失败/过期路径缓冲被丢弃,
|
||||
@@ -331,7 +353,7 @@ export async function switchConversation(id: string, force = false) {
|
||||
setConvCurrentText(id, '')
|
||||
}
|
||||
// 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B)
|
||||
if (mySwitchId !== _latestSwitchId) return
|
||||
if (mySwitchId !== _latestSwitchId) return 'ok'
|
||||
// 透传 has_more/earliest_seq 游标(前端滚顶加载更多历史)。
|
||||
// 后端在 generating/空对话等场景返 false/null,此处默认兜底。
|
||||
const switchDetailAny = detail as any
|
||||
@@ -343,8 +365,7 @@ export async function switchConversation(id: string, force = false) {
|
||||
// (输入区 v-if=streaming)→ 点击停止会传 activeConversationId 发错会话。对齐新建会话的
|
||||
// 复位语义;目标在后台生成时保留 true(停止/流式显示正确)。读会话状态(枚举真相源)派生,
|
||||
// 桥接语义:非终止三态(generating/stopping/compressed)视为生成中。
|
||||
const targetCs = getConvState(id)
|
||||
const targetGen = targetCs === 'generating' || targetCs === 'stopping' || targetCs === 'compressed'
|
||||
const targetGen = isConvGenerating(id)
|
||||
|
||||
// 先 parse 成功替换 messages,成功后再置 activeConversationId(同一同步块内赋值,
|
||||
// 无 await 间隙 → 无中间态渲染)。原顺序先置 active 后 parse,parse 失败时 active 已是新 id 而
|
||||
@@ -356,7 +377,8 @@ export async function switchConversation(id: string, force = false) {
|
||||
// 消息映射收敛进 parseConvMessages(切换 + 加载更多共用,过滤/映射语义一致)。
|
||||
// 相比原内联逻辑唯一行为变化:id 由 `loaded-${i}` 改为优先后端真实消息 id
|
||||
// (DB 主键,prepend 时 v-for key 稳定不重建 DOM),缺失才兜底 `loaded-${i}`。
|
||||
state.messages = parseConvMessages(rawMsgs, 'loaded')
|
||||
// targetGen 透传(UX走查 P1-2):在途回载时无结果的 tool_call 标 running。
|
||||
state.messages = parseConvMessages(rawMsgs, 'loaded', targetGen)
|
||||
// parse 成功才置 active(保证 active 与 messages 一致)
|
||||
state.activeConversationId = id
|
||||
// 切到 round0 进行中的会话时,后端该轮 per_conv 只有 user 消息(assistant 占位
|
||||
@@ -393,12 +415,17 @@ export async function switchConversation(id: string, force = false) {
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
} as AiMessage)
|
||||
return
|
||||
return 'ok'
|
||||
}
|
||||
// 切换提交完成才持久化活跃会话 + 重算 streaming(active 已指向目标 conv)
|
||||
void appSettings.set('df-ai-active-conv', id)
|
||||
setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' })
|
||||
|
||||
// P2-2 完成卡回补:重启/分离窗回收后内存卡清零,按父 conv 反查子代理轨迹重建。
|
||||
// 启动恢复也走本函数(loadConversations 的 switchConversation(pending)),一处挂载全覆盖;
|
||||
// fire-and-forget 不阻塞切换,幂等去重与静默降级见 backfillConvAgentFinished。
|
||||
void backfillConvAgentFinished(id)
|
||||
|
||||
// 加载历史对话 token 总量(来自 DB summary);切换对话清空实时值
|
||||
const conv = state.conversations.find(c => c.id === id)
|
||||
if (conv && conv.prompt_tokens != null && conv.completion_tokens != null) {
|
||||
@@ -429,7 +456,7 @@ export async function switchConversation(id: string, force = false) {
|
||||
try {
|
||||
const pending = await aiApi.pendingToolCalls(id)
|
||||
// 第二 await 后二次比对:切换 A→B 期间避免用 A 的挂起覆写 B 的 pendingApprovals
|
||||
if (mySwitchId !== _latestSwitchId) return
|
||||
if (mySwitchId !== _latestSwitchId) return 'ok'
|
||||
if (pending.length) {
|
||||
// kind 索引:tool_call_id → kind('risk'/'path'),供恢复工具卡/挂起列表按类型渲染
|
||||
const pendingKindMap = new Map(pending.map(p => [p.tool_call_id, p.kind]))
|
||||
@@ -478,6 +505,7 @@ export async function switchConversation(id: string, force = false) {
|
||||
state.pendingApprovals = []
|
||||
}
|
||||
}
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
/** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化。
|
||||
@@ -541,13 +569,16 @@ async function deleteConversation(id: string) {
|
||||
notifyConversationChanged({ deletedConvId: id })
|
||||
}
|
||||
|
||||
/** 会话操作失败气泡的 i18n key(M30 族 rename/archive/pin + G3.4 new/delete + G3.3 switch 瞬态失败) */
|
||||
/** 会话操作失败气泡的 i18n key(M30 族 rename/archive/pin + G3.4 new/delete + G3.3 switch 瞬态失败
|
||||
* + convDeleted 关联会话跳转命中已删会话) */
|
||||
type ConvOpFailKey =
|
||||
| 'renameConvFail' | 'archiveConvFail' | 'pinConvFail'
|
||||
| 'newConvFail' | 'deleteConvFail' | 'switchConvFail'
|
||||
| 'convDeleted'
|
||||
|
||||
/** UX走查 P2-7:rename/archive/pin 失败的侧栏就近通知(发布-订阅)。每次失败发布新对象,
|
||||
* ConversationSidebar watch 到即弹 toast;本模块不感知展示细节(翻译/计时归订阅方)。 */
|
||||
* ConversationSidebar watch 到即弹 toast;本模块不感知展示细节(翻译/计时归订阅方)。
|
||||
* convDeleted(P3-7/P1-1 分离窗路径)也走此通道:反馈须落在承接会话的窗口内。 */
|
||||
export const convOpFailNotice = ref<{
|
||||
key: ConvOpFailKey
|
||||
params?: Record<string, string | number>
|
||||
@@ -657,17 +688,33 @@ function toggleSidebar() {
|
||||
}
|
||||
|
||||
/** 跨视图跳转会话(S4 TaskDrawer「关联会话」入口):AI 无路由,跳转 = 面板展开 + 切会话。
|
||||
* 主窗口面板未展开时先展开再切换;detached(分离窗口)下主窗口无面板,只切会话不动面板。 */
|
||||
async function openConversation(id: string) {
|
||||
* 主窗口面板未展开时先展开再切换;detached(分离窗口)下主窗口无面板,emit 跨窗事件由
|
||||
* 分离窗切会话并聚焦(UX走查 P1-1:原实现只切主窗内存态,分离窗不跟随不聚焦,感知「点了没反应」)。
|
||||
* 跳转属程序化导航:命中已删会话不静默新建(P3-7),返回 'not_found' 由调用方就地反馈;
|
||||
* detached 路径的实际切换结果落在分离窗,本窗一律返回 'ok'。 */
|
||||
async function openConversation(id: string): Promise<'ok' | 'not_found'> {
|
||||
if (state.detached) {
|
||||
await emitDetachedOpenConversation(id)
|
||||
return 'ok'
|
||||
}
|
||||
// 已展开不重复写 persistUiState(省一次 debounced SQLite 写)
|
||||
if (!state.detached && !state.panelOpen) {
|
||||
if (!state.panelOpen) {
|
||||
state.panelOpen = true
|
||||
persistUiState()
|
||||
}
|
||||
await switchConversation(id)
|
||||
return switchConversation(id, false, { noCreateFallback: true })
|
||||
}
|
||||
|
||||
/** 分离窗消费跨窗切会话事件(P1-1):本地切会话;命中已删会话经 convOpFailNotice 由本窗
|
||||
* 侧栏弹 toast(事件发起方在主窗口,反馈必须落在承接会话的分离窗内)。 */
|
||||
async function handleDetachedOpenConversation(convId: string) {
|
||||
const res = await switchConversation(convId, false, { noCreateFallback: true })
|
||||
if (res === 'not_found') convOpFailNotice.value = { key: 'convDeleted' }
|
||||
}
|
||||
|
||||
export function useAiConversations() {
|
||||
// 跨窗切会话监听注册(幂等;label 非分离窗不实际挂 listener,主窗口为事件发起方)
|
||||
registerDetachedOpenConvListener(handleDetachedOpenConversation)
|
||||
return {
|
||||
loadConversations,
|
||||
scheduleConversationsRefresh,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! useAiEvents() 返回 shape 保持不变(stores/ai.ts spread 形状不变 → 组件零改动)。
|
||||
|
||||
import { listen, emit } from '@tauri-apps/api/event'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { aiApi } from '@/api'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { state } from '@/stores/ai'
|
||||
@@ -19,9 +20,11 @@ import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState
|
||||
import { resetStreamWatchdog, clearAllStreamWatchdogs, clearResumeAttempts } from './useAiStream'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { scheduleConversationsRefresh } from './useAiConversations'
|
||||
import { pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
|
||||
import { clearConvBoundaryCache } from './useTaskBoundary'
|
||||
import { handleStreamingEvent } from './useAiStreamingEvents'
|
||||
import { handleToolEvent } from './useAiToolEvents'
|
||||
import { handleLifecycleEvent } from './useAiLifecycleEvents'
|
||||
import { handleLifecycleEvent, recordTaskParked } from './useAiLifecycleEvents'
|
||||
import type { AiChatEvent, MessageId } from '@/api/types'
|
||||
|
||||
// 跨域共享 helper re-export(原为 useAiEvents 导出,拆分后从 aiShared/叶子透传,消费方 import 路径不变)
|
||||
@@ -40,9 +43,12 @@ let _startPromise: Promise<void> | null = null
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// 看门狗不重置的事件集合(审批等待/完成/错误/达 max/路径授权/求助由各自 case 内 clear)。
|
||||
// 看门狗不重置的事件集合(审批等待/完成/错误/达 max/路径授权/求助/停靠由各自 case 内 clear)。
|
||||
// 模块级 Set 复用,避免每事件(delta/token 高频)新建数组字面量做 includes。
|
||||
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired', 'AiHelpRequired'])
|
||||
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired', 'AiHelpRequired', 'AiTaskParked'])
|
||||
|
||||
// 后台会话完成 toast 的摘要截长(轻提醒只够判断「做完了/值不值得切回去看」)
|
||||
const AGENT_FINISHED_TOAST_SUMMARY_MAX = 40
|
||||
|
||||
/** 全局审批类事件:跨会话放行,不因非当前会话被过滤。审批挂起是等用户决策的跨会话资产,
|
||||
* 丢弃后用户在切回该会话前没有任何感知与处理入口;归口仍按 conversation_id 写 per-conv
|
||||
@@ -73,6 +79,8 @@ function handleAgentFinishedEvent(event: AiChatEvent): boolean {
|
||||
status: AgentFinishedStatus
|
||||
rounds: number
|
||||
tokens: number
|
||||
rejected_tools?: string[] | null
|
||||
stop_kind?: string | null
|
||||
}
|
||||
const convId = event.conversation_id
|
||||
if (!convId || !ev.agent_id) return true
|
||||
@@ -82,13 +90,73 @@ function handleAgentFinishedEvent(event: AiChatEvent): boolean {
|
||||
status: ev.status,
|
||||
rounds: ev.rounds,
|
||||
tokens: ev.tokens,
|
||||
// 后端 Option→null/缺省,卡片语义统一为缺省=零拒绝
|
||||
rejected_tools: ev.rejected_tools ?? undefined,
|
||||
// 结构化终止原因(可选,旧后端不发):存卡供卡面状态词优先消费,替代 summary 启发式
|
||||
stop_kind: ev.stop_kind ?? undefined,
|
||||
})
|
||||
// P3-6:后台会话完成轻提醒(当前会话卡已在消息流可见,不弹)。
|
||||
// composable 无组件上下文,复用 ai-toast 事件总线先例(ai-tool-slow-toast),AiChat listen 弹本地 toast;
|
||||
// 多个子代理连发时 toast 单槽自然合并,无风暴面。
|
||||
if (convId !== state.activeConversationId) {
|
||||
// 未知 status 兜底按 completed 语气(防 t 回显键名);failed 用 warning 色与完成区分
|
||||
const statusKey = ev.status === 'failed' ? 'failed' : ev.status === 'truncated' ? 'truncated' : 'completed'
|
||||
const collapsed = ev.summary.replace(/\s+/g, ' ').trim()
|
||||
const summary = collapsed.length > AGENT_FINISHED_TOAST_SUMMARY_MAX
|
||||
? collapsed.slice(0, AGENT_FINISHED_TOAST_SUMMARY_MAX) + '…'
|
||||
: collapsed
|
||||
void emit('ai-toast', {
|
||||
msg: t('ai.agentFinishedToast', { status: t(`ai.agentFinished.${statusKey}`), summary }),
|
||||
type: ev.status === 'failed' ? 'warning' : 'info',
|
||||
})
|
||||
}
|
||||
// 子代理执行是该会话的真实活跃信号(父 loop 在 spawn_agent 内等待):此前该事件穿透到
|
||||
// 活跃事件分支会重置看门狗,现提前归集后显式补上,保持既有不误判停滞的语义。
|
||||
resetStreamWatchdog(convId)
|
||||
return true
|
||||
}
|
||||
|
||||
/** 无人值守停靠域:消费 AiTaskParked(per-conv 落卡 + 后台会话收敛,挂起家族终局)。
|
||||
* 返回 true=已处理。仅拦截后台会话:当前会话放行到 lifecycle 域统一收尾(看门狗/流式复位
|
||||
* 在 cleanupTerminatedConversation,后台分支此处的收敛集合与其对齐但不重复触发两次)。
|
||||
* 停靠卡必须 per-conv 落卡:多会话并发各自可停靠,后台会话的卡丢了用户切回前无从得知。 */
|
||||
function handleTaskParkedEvent(event: AiChatEvent): boolean {
|
||||
if (event.type !== 'AiTaskParked') return false
|
||||
const convId = event.conversation_id || state.activeConversationId
|
||||
if (convId && convId !== state.activeConversationId) {
|
||||
recordTaskParked(convId, event.reason, event.stop_kind ?? undefined)
|
||||
// 对齐 AiCompleted 家族后台分支的收敛:删状态项回不在生成、清 per-conv 流式态/去重记录/
|
||||
// 挂起审批集合/自动续跑计数/进度快照,再刷新侧栏(防抖调度)
|
||||
convStates.delete(convId)
|
||||
clearConvStreamState(convId)
|
||||
clearLastDelta(convId)
|
||||
discardConvPendingApprovals(convId)
|
||||
clearResumeAttempts(convId || undefined)
|
||||
pendingTaskProgress.delete(convId)
|
||||
scheduleConversationsRefresh()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 无人值守进度域:消费 AiTaskProgress(per-conv 快照,静默期「在干活」可见)。
|
||||
* 返回 true=已处理。isCurrent 守卫之前归集(同停靠卡范式):后台会话的进度也要落 Map,
|
||||
* 切回仍可见。进度事件即活体信号,顺带 resetStreamWatchdog(子代理长跑/重入退避期间
|
||||
* 后端心跳之外的第二重存活证明,防看门狗误判停滞)。 */
|
||||
function handleTaskProgressEvent(event: AiChatEvent): boolean {
|
||||
if (event.type !== 'AiTaskProgress') return false
|
||||
const convId = event.conversation_id || state.activeConversationId
|
||||
if (!convId) return true
|
||||
pendingTaskProgress.set(convId, {
|
||||
round: event.round,
|
||||
maxRounds: event.max_rounds,
|
||||
tokensUsed: event.tokens_used,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
resetStreamWatchdog(convId)
|
||||
return true
|
||||
}
|
||||
|
||||
/** 跨端用户消息域:miniapp→桌面 user 气泡同步(仅处理 AiUserMessage)。
|
||||
* 去重:末条已是同 content 的 user 则跳过(防本地乐观 push + 事件回灌双气泡)。 */
|
||||
function handleUserMessageEvent(event: AiChatEvent): boolean {
|
||||
@@ -128,6 +196,10 @@ export function handleEvent(event: AiChatEvent) {
|
||||
if (handleUserMessageEvent(event)) return
|
||||
// S1 子代理完成卡归集:在 isCurrent 守卫之前处理(per-conv 落卡,后台会话不丢卡)
|
||||
if (handleAgentFinishedEvent(event)) return
|
||||
// 无人值守停靠卡归集:在 isCurrent 守卫之前处理(per-conv 落卡,后台会话不丢卡)
|
||||
if (handleTaskParkedEvent(event)) return
|
||||
// 无人值守进度快照归集:在 isCurrent 守卫之前处理(per-conv 落 Map,后台会话不丢进度)
|
||||
if (handleTaskProgressEvent(event)) return
|
||||
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏;
|
||||
// 全局审批类事件例外(isGlobalApprovalEvent),放行到下方按域分派
|
||||
const isCurrent = !convId || convId === state.activeConversationId
|
||||
@@ -145,6 +217,8 @@ export function handleEvent(event: AiChatEvent) {
|
||||
clearLastDelta(convId || '')
|
||||
discardConvPendingApprovals(convId || '')
|
||||
clearResumeAttempts(convId || undefined)
|
||||
// 终态即清进度快照(事件即逝态,会话已终止不留陈旧「进行中」)
|
||||
pendingTaskProgress.delete(convId || '')
|
||||
// FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。
|
||||
scheduleConversationsRefresh()
|
||||
// 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列)
|
||||
@@ -168,8 +242,10 @@ export function handleEvent(event: AiChatEvent) {
|
||||
// 无目标会话(事件缺 convId 且当前无活跃会话):流式 delta 无处累积,跳过写防 null 键无意义写入
|
||||
if (!convId && !state.activeConversationId && event.type === 'AiTextDelta') return
|
||||
// 活跃事件(delta/工具/新轮/审批结果)到达即标记该会话生成中(enum 兜底写入口;
|
||||
// 后端 AiConvStateChanged{generating} 已先到时同值 set 幂等覆盖,无副作用)
|
||||
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired') {
|
||||
// 后端 AiConvStateChanged{generating} 已先到时同值 set 幂等覆盖,无副作用)。
|
||||
// 终态家族(AiCompleted/AiError/AiHelpRequired/AiTaskParked)除外:标记生成中会与
|
||||
// 收尾的删项收敛打架(停靠后端已复位 generating,不能被兜底写回)
|
||||
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired' && event.type !== 'AiTaskParked') {
|
||||
setConvState(convId, 'generating')
|
||||
}
|
||||
// 流式看门狗:活跃事件重置;审批等待/完成/错误在 case 内 clear(per-conv,各会话独立计时)
|
||||
@@ -206,6 +282,10 @@ export async function startListener() {
|
||||
// S1:会话删除连带清其完成卡(Map 不持已删会话条目;存在即清,不限当前视图)
|
||||
if (deleted) {
|
||||
clearConvAgentFinished(deleted)
|
||||
// 无人值守三件随会话同亡:停靠卡挂起 + 进度快照 + 边界镜像(边界生命周期=会话存续,设计 §二)
|
||||
pendingTaskParked.delete(deleted)
|
||||
pendingTaskProgress.delete(deleted)
|
||||
clearConvBoundaryCache(deleted)
|
||||
}
|
||||
})
|
||||
_unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 生命周期事件域 — handleLifecycleEvent + cleanupTerminatedConversation
|
||||
//!
|
||||
//! 处理:AiCompleted / AiError / AiHelpRequired + 上下文生命周期事件(AiCompressing/
|
||||
//! 处理:AiCompleted / AiError / AiHelpRequired / AiTaskParked + 上下文生命周期事件(AiCompressing/
|
||||
//! AiManualCompressed/AiAutoCompressed/AiContextCleared)。
|
||||
//! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream / streamingGuard /
|
||||
//! useAiConversations,不导入任何同级子 composable。
|
||||
@@ -10,7 +10,7 @@ import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { state } from '@/stores/ai'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, notifyQueueCleared } from './aiShared'
|
||||
import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths } from './useAiPendingState'
|
||||
import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths, pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
|
||||
import { clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import type { AiChatEvent, AiMessage, MessageId } from '@/api/types'
|
||||
@@ -43,14 +43,16 @@ function isShowTokenUsage(): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共会话终止收尾(AiCompleted/AiError/AiHelpRequired 共用):清看门狗/计时器/流式态/挂起。
|
||||
* 公共会话终止收尾(AiCompleted/AiError/AiHelpRequired/AiTaskParked 共用):清看门狗/计时器/流式态/挂起。
|
||||
*
|
||||
* 语义差异(调用方自行处理):
|
||||
* - AiCompleted: 调用后追加 incomplete 气泡 / token 用量 / 队列 drain
|
||||
* - AiError: 调用后追加错误气泡 + 清 queue
|
||||
* - AiHelpRequired: 调用后翻 pendingHelp 驱动求助卡
|
||||
* - AiTaskParked: 调用后登记 pendingTaskParked 驱动停靠卡(摘旧卡;不清 pendingHelp,
|
||||
* 熔断并存场景见下方 pendingHelp 分支注释)
|
||||
*/
|
||||
function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired') {
|
||||
function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired' | 'AiTaskParked') {
|
||||
clearStreamWatchdog(convId || undefined)
|
||||
// 正常收尾路径清该会话的 delta 去重记录:flushCurrentText 只清活跃会话的,这里按 convId
|
||||
// 显式清一遍兜住 convId 与 active 不一致或事件缺 convId 的时序,防残留记录误丢下轮同内容 delta
|
||||
@@ -83,8 +85,16 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
|
||||
pendingMaxRounds.value = null
|
||||
}
|
||||
if (pendingHelp.value && pendingHelp.value.conversationId === convId) {
|
||||
pendingHelp.value = null
|
||||
// 熔断场景后端并存发求助卡+停靠卡(agentic/mod.rs 熔断分支先 emit 求助卡再「补发」
|
||||
// AiTaskParked,注释自称并存意图):停靠收尾不清求助卡,help options 三选项不丢,
|
||||
// 两卡并存供用户任选入口。其余终态(AiCompleted/AiError/AiHelpRequired)照旧清。
|
||||
if (reason !== 'AiTaskParked') pendingHelp.value = null
|
||||
}
|
||||
// 无人值守停靠卡:会话有任何终态(含停靠本身)即摘旧卡;停靠 case 在收尾后重新登记,
|
||||
// 其余终态(AiCompleted/AiError,点继续续跑后的正常收敛/出错)摘卡即消失,语义对齐 pendingHelp
|
||||
pendingTaskParked.delete(convId)
|
||||
// 无人值守进度快照:终态即清(事件即逝态,终止后不留陈旧「进行中」;下次 loop 每 5 轮重建)
|
||||
pendingTaskProgress.delete(convId)
|
||||
// 路径授权挂起仅清本 conv(终止只清本会话弹窗,不连累并发会话;无归属旧项保守保留)
|
||||
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId !== convId)
|
||||
// 审批收尾仅清目标 conv 的待审批项 + 对称清其超时计时器(其他会话的审批卡/计时器不受影响;
|
||||
@@ -104,6 +114,21 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
|
||||
localStorage.removeItem('df-ai-text')
|
||||
}
|
||||
|
||||
/**
|
||||
* 登记无人值守停靠卡(幂等,convId 键控覆盖)。lifecycle 主路径与 useAiEvents 后台会话
|
||||
* 分支共用唯一写入口,防止两处字段口径漂移。convId 缺失兜底当前活跃会话(优于丢卡)。
|
||||
*/
|
||||
export function recordTaskParked(convId: string | null | undefined, reason: string, stopKind?: string): void {
|
||||
const id = convId || state.activeConversationId
|
||||
if (!id) return
|
||||
pendingTaskParked.set(id, {
|
||||
reason,
|
||||
stopKind: stopKind || undefined,
|
||||
conversationId: id,
|
||||
parkedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError)。返回 true=已处理。 */
|
||||
export function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
switch (event.type) {
|
||||
@@ -215,6 +240,18 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
case 'AiTaskParked': {
|
||||
// 无人值守安全停靠(设计 §四):五路非正常终局统一出口,后端已保存现场并复位
|
||||
// generating(guard 复位非死等)。对齐 AiHelpRequired 家族:终止态收尾不建错误气泡,
|
||||
// 改登记 pendingTaskParked 驱动停靠卡(reason + stop_kind 标签 + 继续/停止归档)。
|
||||
// 队列不动:停靠等用户拍板,排队消息不自动续发不静默清(继续/停止由用户决定)。
|
||||
cleanupTerminatedConversation(event.conversation_id || '', 'AiTaskParked')
|
||||
recordTaskParked(event.conversation_id, event.reason, event.stop_kind ?? undefined)
|
||||
// FE1:停靠刷新侧栏(更新活跃时间排序),由 listener 防抖调度统一收口。
|
||||
notifyConversationChanged()
|
||||
return true
|
||||
}
|
||||
|
||||
case 'AiCompressing':
|
||||
_contextCallbacks.onCompressing?.()
|
||||
return true
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! useAiEvents 拆分后,streaming/tool/lifecycle 三域与组件(ChatInput/MaxRoundsCard/
|
||||
//! DirAuthDialog/HelpRequiredCard)统一从这里取这些跨域信号。
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
@@ -75,3 +75,29 @@ export interface PendingHelp {
|
||||
conversationId: string | null
|
||||
}
|
||||
export const pendingHelp = ref<PendingHelp | null>(null)
|
||||
|
||||
// 无人值守停靠挂起(后端 AiTaskParked 置):convId 键控 Map 而非单 ref。
|
||||
// 与 pendingHelp 单 ref 的差异原因:停靠是无人值守长任务的常态终局,多会话并发下
|
||||
// 多个会话可各自停靠,单 ref 后到覆盖先到会丢卡(待审批 pendingDirAuths 同款教训)。
|
||||
// 消费方(TaskParkedCard)按 activeConversationId 取卡,per-conversation 归位不串台。
|
||||
// 幂等:同会话重复停靠按键覆盖(后端单出口单发,覆盖仅防监听重放双卡)。
|
||||
export interface PendingTaskParked {
|
||||
reason: string
|
||||
/** 后端结构化停止原因(词表 approval_timeout/cancelled/circuit_breaker/stall_breaker/max_rounds/fatal/midstream);旧后端不发为 undefined */
|
||||
stopKind?: string
|
||||
conversationId: string
|
||||
parkedAt: number
|
||||
}
|
||||
export const pendingTaskParked = reactive(new Map<string, PendingTaskParked>())
|
||||
|
||||
// 无人值守进度快照(后端 AiTaskProgress 每 5 轮置,设计 §4.3):convId 键控 Map(停靠卡同范式,
|
||||
// 多会话并发各自有进度互不覆盖)。只存最近一次快照(事件即逝,后端不落库);消费方(TopBar
|
||||
// 进度条)按 activeConversationId 取,静默期(子代理长跑/重入退避)凭最后快照可见「在干活」。
|
||||
// 终局(AiCompleted/AiError/AiHelpRequired/AiTaskParked)与会话删除时清理。maxRounds=0=不限轮次。
|
||||
export interface PendingTaskProgress {
|
||||
round: number
|
||||
maxRounds: number
|
||||
tokensUsed: number
|
||||
updatedAt: number
|
||||
}
|
||||
export const pendingTaskProgress = reactive(new Map<string, PendingTaskProgress>())
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! 无人值守任务边界(per-conv 边界缓存 + IPC 读写)
|
||||
//!
|
||||
//! 后端真相源是 per_conv.boundary 内存态 + boundary_json 落库快照(ai_task_boundary_get/set)。
|
||||
//! 前端这里维护一份只读镜像 Map,服务两个消费面:
|
||||
//! - ConversationSidebar 徽标:批量 IPC 不在契约内,镜像按「访问过/设置过」渐进填充
|
||||
//! (切会话时配置卡拉取、设置卡写入),未访问会话不显徽标属已接受取舍;
|
||||
//! - TaskBoundaryCard 回显:打开卡/切会话时读镜像,miss 时拉一次。
|
||||
//!
|
||||
//! 叶子定位:仅依赖 vue + api 层,不导入任何 composable,无环风险。
|
||||
|
||||
import { reactive } from 'vue'
|
||||
import { aiApi } from '@/api'
|
||||
import type { TaskBoundary } from '@/api/types'
|
||||
|
||||
/** convId → 当前边界镜像(无键=无边界或未拉取)。后端 set/get 单发,前端不做节流 */
|
||||
export const convBoundaries = reactive(new Map<string, TaskBoundary>())
|
||||
|
||||
/** 拉取失败会话集合(镜像 miss 的两种语义须区分:确认无边界 vs 拉取失败。
|
||||
* 失败态配置卡禁「一次授权」,防默认空表单静默覆盖库中原授权) */
|
||||
export const convBoundaryFetchFailed = reactive(new Set<string>())
|
||||
|
||||
/** 某会话边界最近一次拉取是否失败(失败态镜像内容不可信) */
|
||||
export function isConvBoundaryFetchFailed(convId: string | null | undefined): boolean {
|
||||
if (!convId) return false
|
||||
return convBoundaryFetchFailed.has(convId)
|
||||
}
|
||||
|
||||
/** 读某会话边界镜像;无记录返回 null */
|
||||
export function getConvBoundary(convId: string | null | undefined): TaskBoundary | null {
|
||||
if (!convId) return null
|
||||
return convBoundaries.get(convId) ?? null
|
||||
}
|
||||
|
||||
/** 拉取某会话边界并刷新镜像(切会话/打开配置卡时调)。侧栏徽标消费侧保持静默降级;
|
||||
* 配置卡经 isConvBoundaryFetchFailed 读取失败态(成功即清标记,失败置标记) */
|
||||
export async function fetchConvBoundary(convId: string | null | undefined): Promise<void> {
|
||||
if (!convId) return
|
||||
try {
|
||||
const b = await aiApi.getTaskBoundary(convId)
|
||||
convBoundaryFetchFailed.delete(convId)
|
||||
if (b) convBoundaries.set(convId, b)
|
||||
else convBoundaries.delete(convId)
|
||||
} catch (e) {
|
||||
convBoundaryFetchFailed.add(convId)
|
||||
console.warn('[TaskBoundary] 读取边界失败(徽标静默降级,配置卡显失败态):', convId, e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入某会话边界(boundary 传 null 清除),IPC 成功后才更新镜像(失败不撒谎) */
|
||||
export async function saveConvBoundary(convId: string | null | undefined, boundary: TaskBoundary | null): Promise<void> {
|
||||
if (!convId) return
|
||||
await aiApi.setTaskBoundary(convId, boundary)
|
||||
if (boundary) convBoundaries.set(convId, boundary)
|
||||
else convBoundaries.delete(convId)
|
||||
}
|
||||
|
||||
/** 清某会话镜像(会话删除时调,防 Map 持已删会话条目) */
|
||||
export function clearConvBoundaryCache(convId: string | null | undefined): void {
|
||||
if (!convId) return
|
||||
convBoundaries.delete(convId)
|
||||
}
|
||||
Reference in New Issue
Block a user