优化: 会话状态键控与提示补全

- 队列清空/发送失败补提示,错误接入分类文案
- 审批超时气泡按会话归属,流守卫按会话清理
- 会话操作失败就近反馈,拖拽非法流转提示原因
This commit is contained in:
lxy
2026-10-03 16:27:39 +08:00
parent 8710a09be2
commit 31f7a9570b
6 changed files with 118 additions and 26 deletions
+36 -7
View File
@@ -175,13 +175,8 @@ export function startApprovalTimer(
denyP.catch(e => {
console.error('[AI] 审批超时自动拒绝 IPC 未送达:', e)
})
getMessages().push({
id: `approval-timeout-${nextMsgId()}` as MessageId,
role: 'assistant',
content: t('ai.approvalTimeout', { toolName }),
isError: true,
timestamp: Date.now(),
})
// UX-P2-A3:气泡推送改经 per-conv 守卫(函数追加在本文件尾部),后台会话超时不串当前视图
pushApprovalTimeoutBubble(toolName, toolCallId)
}, timeoutMs)
_approvalTimers.set(toolCallId, { timer, startedAt: Date.now(), timeoutMs })
}
@@ -665,3 +660,37 @@ export function clearConvAgentFinished(convId: string | null | undefined): void
if (!convId) return
convAgentFinished.delete(convId)
}
// ── UX-P2-A3 审批超时错误气泡 per-conv 守卫 ──
//
// 背景:startApprovalTimer 到点把「审批超时已自动拒绝」错误气泡直接推进 getMessages()
// (当前活跃视图),后台会话 A 的审批超时,气泡落在用户正查看的会话 B 里(跨会话串扰)。
// 本守卫按审批所属 conv 过滤:仅当审批所属会话是当前活跃会话(或无法归属)才推视图气泡;
// 后台会话的超时拒绝 IPC 照常执行,只是不串视图。
// 归属反查:到点时刻从 per-conv 待审批集合(convPendingApprovals)按 toolCallId 反查 convId——
// 此时拒绝 IPC 才刚发出,审批结果事件未到,集合项必在;查不到(旧数据/已被清)保守回落为推,
// 不改变无归属场景的原行为。
export function pushApprovalTimeoutBubble(toolName: string, toolCallId: string): void {
let convId: string | null = null
for (const [k, arr] of convPendingApprovals) {
if (arr.some(p => p.id === toolCallId)) {
convId = k
break
}
}
const active = getState()?.activeConversationId ?? null
if (convId && convId !== active) return
getMessages().push({
id: `approval-timeout-${nextMsgId()}` as MessageId,
role: 'assistant',
content: t('ai.approvalTimeout', { toolName }),
isError: true,
timestamp: Date.now(),
})
}
/** 队列被清空的统一提示(UX-P2-A2:AiError/regenerate/editMessage 清队从静默改 toast,
* 清 0 条不提示;经 ai-toast 事件总线,AiChat 本地 toast 消费) */
export function notifyQueueCleared(n: number): void {
if (n > 0) void emit('ai-toast', { msg: t('ai.queueCleared', { n }), type: 'warning' })
}
+12 -5
View File
@@ -21,7 +21,7 @@
//! streaming 是 boolean 无真正非法跃迁,guard 价值在集中入口与日志而非拦截。
import { state } from '@/stores/ai'
import { convStates, getConvStreamState, setConvStreaming } from './aiShared'
import { convStates, getConvStreamState, setConvStreaming, setConvCurrentText } from './aiShared'
import { useAppSettingsStore } from '@/stores/appSettings'
/// feature flag key(appSettings KV)。关=回退散布语义(灰度/回退用)。
@@ -88,10 +88,9 @@ export function setStreaming(
* TD-260621-01 per-conv:看门狗超时携带 convId 时,经 setStreaming 仅复位该会话 streaming 态;
* 该会话的 convStates 项由各自会话收尾事件精确管理(on/off 后端均会 emit 收尾态)。
*
* 注:currentText/queue 仍是单例(F-09 域,本批不动),per-conv 超时在多会话并发下
* 会清当前累积的 currentText——但 currentText 仅活跃视图写入(useAiEvents handleEvent delta 累积),
* 而超时仅发生在该 conv 长时间无数据,此场景 currentText 多为空或已 flush,
* 实际误伤面远小于 generatingConvs 全清(队列 queue 的清理由 F-09 统一改,见 TD-260621-01 注释)。
* 注:currentText/queue 已随 UX-P2-A4 改 per-conv 键控:带 convId 的兜底只清该会话的
* 排队项与流式缓冲,不再连带清其他会话的排队消息(多会话并发下 queue 是跨会话混排数组,
* 原全清会把 B 会话排队一并丢掉);无 convId 全局兜底语义不变(全清)。
*
* @param reason 兜底来源(日志可观测)
* @param convId 关联会话 id(看门狗 per-conv 超时传;省略=全局兜底)
@@ -99,8 +98,16 @@ export function setStreaming(
export function forceResetStreaming(reason: string, convId?: string): void {
console.warn(`[AI-Guard] forceResetStreaming(卡死兜底): ${reason} (convId=${convId ?? 'none'})`)
setStreaming(false, { convId: convId ?? null, reason })
// UX-P2-A4:per-conv 键控清理——带 convId 只清该会话排队项(currentText 同步走 per-conv 键,
// state.currentText 是委派活跃会话的 accessor,后台 conv 超时原写法会误清当前视图缓冲);
// 无 convId 全局兜底保持全清(卡死恢复场景,精确性不重要)。
if (convId) {
state.queue = state.queue.filter(q => q.conversationId !== convId)
setConvCurrentText(convId, '')
} else {
state.currentText = ''
state.queue = []
}
// BUG-2026-07-17 根治:同时清理 convStates 项。
// 原实现仅清 streaming/currentText/queue,但按钮 stopBtnState 优先读 convStates
// (ChatInput.vue:243),convStates=generating 时仍显红色停止按钮。后端未发
+16 -2
View File
@@ -4,6 +4,7 @@
//! - 多处调 useAiEvents.notifyConversationChanged
//! - newConversation/switchConversation 写 appSettings 持久化活跃会话 id
import { ref } from 'vue'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
@@ -545,9 +546,22 @@ type ConvOpFailKey =
| 'renameConvFail' | 'archiveConvFail' | 'pinConvFail'
| 'newConvFail' | 'deleteConvFail' | 'switchConvFail'
/** M30:写后端 IPC 失败时推送错误气泡(对齐 loadConversations/switchConversation 的失败反馈),
* 保持侧栏已有交互不被无声吞掉(原仅向上抛,调用方 store 直调无 try → 异常进 unhandledrejection)。 */
/** UX走查 P2-7:rename/archive/pin 失败的侧栏就近通知(发布-订阅)。每次失败发布新对象,
* ConversationSidebar watch 到即弹 toast;本模块不感知展示细节(翻译/计时归订阅方)。 */
export const convOpFailNotice = ref<{
key: ConvOpFailKey
params?: Record<string, string | number>
} | null>(null)
/** M30:写后端 IPC 失败时推送反馈,保持侧栏已有交互不被无声吞掉(原仅向上抛,调用方 store
* 直调无 try → 异常进 unhandledrejection)。UX走查 P2-7 分流:rename/archive/pin 发起与
* 视线焦点都在侧栏,改发 convOpFailNotice 由侧栏弹就近 toast,不再推进聊天流(错误气泡
* 视线错位,且消息区被清空/切换后不可见);new/delete/switch 与聊天区强关联,仍走错误气泡。 */
function pushConvOpFail(key: ConvOpFailKey, params?: Record<string, string | number>) {
if (key === 'renameConvFail' || key === 'archiveConvFail' || key === 'pinConvFail') {
convOpFailNotice.value = { key, params }
return
}
state.messages.push({
id: `conv-op-fail-${nextMsgId()}`,
role: 'assistant',
+6 -2
View File
@@ -9,7 +9,7 @@ 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, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals } from './aiShared'
import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, notifyQueueCleared } from './aiShared'
import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths } from './useAiPendingState'
import { clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
import { setStreaming } from './streamingGuard'
@@ -176,10 +176,14 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
case 'AiError': {
cleanupTerminatedConversation(event.conversation_id || '', 'AiError')
// 只清除出错会话的队列项,不误伤其他会话的排队消息
// 只清除出错会话的队列项,不误伤其他会话的排队消息;
// UX-P2-A2:清队从静默改 toast 提示条数(用户排队的消息无声消失原无任何反馈)
if (event.conversation_id) {
const dropped = state.queue.filter(q => q.conversationId === event.conversation_id).length
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
notifyQueueCleared(dropped)
} else {
notifyQueueCleared(state.queue.length)
state.queue = []
}
state.messages.push({
+21 -4
View File
@@ -24,7 +24,16 @@ import { t } from '@/i18n/i18n-helpers'
import { resetStreamWatchdog, clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
import { setStreaming } from './streamingGuard'
import { startListener } from './useAiEvents'
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates, setConvCurrentText, setConvStreaming, flushCurrentText } from './aiShared'
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates, setConvCurrentText, setConvStreaming, flushCurrentText, notifyQueueCleared, classifySendError, type SendErrorKind } from './aiShared'
/// UX-P2-A5:drainQueue 失败气泡的错误分类文案映射(分类逻辑 aiShared.classifySendError,
/// 键复用 ChatInput 发送失败 toast 同一批 aiChat.sendErr* 文案,原始错误经 { msg } 细节保留)
const QUEUED_SEND_ERR_KEY: Record<SendErrorKind, string> = {
rate_limit: 'aiChat.sendErrRateLimit',
quota: 'aiChat.sendErrQuota',
network: 'aiChat.sendErrNetwork',
other: 'aiChat.toastSendFail',
}
/// 待发送队列上限(超过抛错提示用户)
const QUEUE_LIMIT = 10
@@ -163,9 +172,12 @@ async function regenerate() {
setStreaming(true, { convId: state.activeConversationId, reason: 'regenerate' })
state.currentText = ''
// 只清当前会话的排队消息,不误伤其他会话
// 只清当前会话的排队消息,不误伤其他会话;
// UX-P2-A2:清队从静默改 toast 提示条数(排队消息无声消失原无任何反馈)
const regenConvId = state.activeConversationId
const droppedQueued = state.queue.filter(q => q.conversationId === regenConvId).length
state.queue = state.queue.filter(q => q.conversationId !== regenConvId)
notifyQueueCleared(droppedQueued)
// 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback
resetStreamWatchdog(regenConvId || undefined)
@@ -236,9 +248,12 @@ async function editMessage(newMessage: string) {
setStreaming(true, { convId: state.activeConversationId, reason: 'editMessage' })
state.currentText = ''
// 只清当前会话的排队消息,不误伤其他会话
// 只清当前会话的排队消息,不误伤其他会话;
// UX-P2-A2:清队从静默改 toast 提示条数(排队消息无声消失原无任何反馈)
const editConvId = state.activeConversationId
const droppedQueued = state.queue.filter(q => q.conversationId === editConvId).length
state.queue = state.queue.filter(q => q.conversationId !== editConvId)
notifyQueueCleared(droppedQueued)
// 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback
resetStreamWatchdog(editConvId || undefined)
@@ -301,10 +316,12 @@ export function drainQueue(convId?: string | null) {
// 仅目标会话为当前视图才推错误气泡(后台会话续发失败不污染当前视图;错误消息留在队列
// 供切回后编辑/重试/取消)
if (targetId === state.activeConversationId) {
// UX-P2-A5:失败气泡接入 classifySendError 分类文案(对齐 ChatInput 发送失败 toast 四类),
// 替代原 raw 错误直出的 ai.queuedSendFailed;原始错误串经 { msg } 细节保留
state.messages.push({
id: `err-${nextMsgId()}`,
role: 'assistant',
content: t('ai.queuedSendFailed', { error: errMsg }),
content: t(QUEUED_SEND_ERR_KEY[classifySendError(errMsg)], { msg: errMsg }),
isError: true,
timestamp: Date.now(),
} as AiMessage)
+25 -4
View File
@@ -2,7 +2,7 @@ import { ref } from 'vue'
import Sortable from 'sortablejs'
import { useI18n } from 'vue-i18n'
import { taskApi } from '@/api/task'
import { TASK_STATUS_TRANSITIONS } from '@/constants/project'
import { TASK_STATUS_TRANSITIONS, taskStatusLabel } from '@/constants/project'
import { useToast } from '../useToast'
export function useTaskKanban(opts: {
@@ -15,6 +15,8 @@ export function useTaskKanban(opts: {
let s: Sortable[] = []
let _dragId: string | null = null
let _executing = false
// 最近一次被拒的流转方向:onMove 只记录不提示(避免拖拽悬停过程误报),onEnd 弹回时消费并清空
let _rejected: { from: string; to: string } | null = null
function destroy() { s.forEach(x => x.destroy()); s = [] }
@@ -30,12 +32,16 @@ export function useTaskKanban(opts: {
onMove: (e) => {
const from = (e.from as HTMLElement).dataset.status ?? ''
const to = ((e.to as HTMLElement).closest('[data-status]') as HTMLElement)?.dataset.status ?? ''
if (from === to) return true
return TASK_STATUS_TRANSITIONS[from]?.includes(to) ?? false
if (from === to) { _rejected = null; return true }
const legal = TASK_STATUS_TRANSITIONS[from]?.includes(to) ?? false
// UX走查 P2-5:非法流转只记录方向;合法流转清掉旧拒绝,避免陈旧标记污染后续 onEnd 判断
_rejected = legal ? null : { from, to }
return legal
},
onStart: (e) => {
_dragId = (e.item as HTMLElement).dataset.taskId ?? null
_executing = false
_rejected = null
},
onEnd: async (e) => {
if (_executing) return
@@ -44,7 +50,22 @@ export function useTaskKanban(opts: {
const to = ((e.to as HTMLElement).closest('[data-status]') as HTMLElement)?.dataset.status
const id = _dragId
_dragId = null
if (!to || !from || to === from) { _executing = false; return }
if (!to || !from || to === from) {
// UX走查 P2-5:非法流转被 onMove 拒绝时卡片弹回原列(落点 to===from),
// 此处补一条原因 toast,替代原先的静默弹回(原因与父卡 tooltip 同源:状态机流转表)
if (_rejected) {
showToast(
t('tasks.kanban.illegalMove', {
from: t(taskStatusLabel(_rejected.from)),
to: t(taskStatusLabel(_rejected.to)),
}),
'error',
)
_rejected = null
}
_executing = false
return
}
if (!id) { _executing = false; return }
kanbanBusy.value = true
try {