优化: 会话状态 per-conv 化与交互守卫
- 待审批跨会话 per-conv 合并,审批类事件全局放行 - 看门狗/重试计数/流式残段按会话键控迁移 - 输入草稿与子代理完成卡数据按会话隔离 - 切换兜底 activeId,收敛守卫防幽灵气泡 - 发送错误按限流/额度/网络分类文案
This commit is contained in:
@@ -361,6 +361,78 @@ export function clearConvStreamState(convId: string | null | undefined): void {
|
||||
convStreamStates.delete(convId)
|
||||
}
|
||||
|
||||
// ── per-conv 待审批集合(审批挂起按 conversation_id 归会话,跨会话互不覆盖) ──
|
||||
//
|
||||
// 背景:state.pendingApprovals 是全局单数组,switchConversation 恢复挂起时对它整体替换
|
||||
// (= restored / = []),后台会话经事件到达的挂起会被切换动作整组覆盖丢失(全局单值覆盖语义)。
|
||||
// 改为双层结构:convPendingApprovals Map 是跨会话真相源(各会话独立数组,互不覆盖);
|
||||
// state.pendingApprovals 退化为「当前活跃会话」的视图投影,组件消费方(徽标/浮层/审批浮窗)
|
||||
// 仍只读该数组,零改动保持视图按当前会话展示。
|
||||
//
|
||||
// 注:Map 本体不建响应式(仅切换/收尾时由本组函数读写,渲染只依赖 state.pendingApprovals)。
|
||||
export const convPendingApprovals = new Map<string, AiToolCallInfo[]>()
|
||||
|
||||
function pendingActiveKey(): string {
|
||||
return getState()?.activeConversationId ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加一条待审批:按 conversationId 归入所属会话集合(缺省归当前活跃会话);
|
||||
* 属当前活跃会话时同步投影进 state.pendingApprovals。同 id 幂等,返回是否真正新增
|
||||
* (调用方据此决定是否做工具卡改写/计时器启动/滚动通知等一次性副作用)。
|
||||
*/
|
||||
export function addPendingApproval(info: AiToolCallInfo): boolean {
|
||||
const key = info.conversationId || pendingActiveKey()
|
||||
let arr = convPendingApprovals.get(key)
|
||||
if (!arr) {
|
||||
arr = []
|
||||
convPendingApprovals.set(key, arr)
|
||||
}
|
||||
let added = false
|
||||
if (!arr.some(p => p.id === info.id)) {
|
||||
arr.push(info)
|
||||
added = true
|
||||
}
|
||||
if (key === pendingActiveKey()) {
|
||||
const view = getState()?.pendingApprovals
|
||||
if (view && !view.some(p => p.id === info.id)) view.push(info)
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
/** 按 id 移除一条待审批(全会话集合扫描;审批结果不依赖当前视图是哪个会话) */
|
||||
export function removePendingApprovalById(id: string): void {
|
||||
for (const [k, arr] of convPendingApprovals) {
|
||||
if (arr.some(p => p.id === id)) {
|
||||
convPendingApprovals.set(k, arr.filter(p => p.id !== id))
|
||||
}
|
||||
}
|
||||
const view = getState()?.pendingApprovals
|
||||
if (view) {
|
||||
const idx = view.findIndex(p => p.id === id)
|
||||
if (idx >= 0) view.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 整体替换某会话的待审批集合(切换恢复后端审计挂起 / 新建清空用);
|
||||
* 目标为当前活跃会话时同步投影进视图,否则只写集合不动视图(跨会话互不覆盖的关键)。
|
||||
*/
|
||||
export function setConvPendingApprovals(convId: string | null | undefined, items: AiToolCallInfo[]): void {
|
||||
if (!convId) return
|
||||
convPendingApprovals.set(convId, items)
|
||||
if (convId === pendingActiveKey()) {
|
||||
const view = getState()?.pendingApprovals
|
||||
if (view) view.splice(0, view.length, ...items)
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅删除某会话的集合项,不动视图数组(会话终止/删除时用;视图侧无法归属的旧项由调用方自定保留策略) */
|
||||
export function discardConvPendingApprovals(convId: string | null | undefined): void {
|
||||
if (!convId) return
|
||||
convPendingApprovals.delete(convId)
|
||||
}
|
||||
|
||||
// ── 切换中缓冲 ──
|
||||
//
|
||||
// 背景:后台会话(A)生成中,用户点击切换到 A(switchConversation await 往返期间),A 的
|
||||
@@ -385,6 +457,7 @@ export interface AiStateShape {
|
||||
streaming: boolean
|
||||
messages: AiMessage[]
|
||||
activeConversationId: string | null
|
||||
pendingApprovals: AiToolCallInfo[]
|
||||
}
|
||||
let _stateGetter: (() => AiStateShape) | null = null
|
||||
|
||||
@@ -494,3 +567,101 @@ export function clearAllToolSlowTimers(): void {
|
||||
_toolTimers.clear()
|
||||
_toolSlowNotified.clear()
|
||||
}
|
||||
|
||||
// ── V8 per-conv 输入草稿 ──
|
||||
//
|
||||
// 背景:输入框草稿原先只在 ChatInput 组件内持单份 ref,切会话后 A 会话未发送的草稿直接
|
||||
// 显示在 B 会话输入框(串稿)。本 Map 是跨会话草稿的内存真相源,ChatInput 在
|
||||
// activeConversationId 变化时写旧会话文本、读新会话文本。内存态,不落 localStorage。
|
||||
//
|
||||
// 放本模块的原因:沿用 convStreamStates 同款 per-conv reactive Map 模式集中管理,
|
||||
// 消费方(ChatInput)与潜在的其他输入域消费方都可读,且不新增依赖环。
|
||||
export const convDrafts = reactive(new Map<string, string>())
|
||||
|
||||
/** 读某会话草稿;无记录返回空串(调用方直接回填输入框即可) */
|
||||
export function getConvDraft(convId: string | null | undefined): string {
|
||||
if (!convId) return ''
|
||||
return convDrafts.get(convId) ?? ''
|
||||
}
|
||||
|
||||
/** 写某会话草稿;空串收敛删 Map 项(对齐 convStates idle 收敛语义,Map 不持陈旧空项) */
|
||||
export function setConvDraft(convId: string | null | undefined, text: string): void {
|
||||
if (!convId) return
|
||||
if (text) {
|
||||
convDrafts.set(convId, text)
|
||||
} else {
|
||||
convDrafts.delete(convId)
|
||||
}
|
||||
}
|
||||
|
||||
// ── I5 发送失败错误分类 ──
|
||||
//
|
||||
// 背景:发送失败错误串原样透出到 toast,后端/上游错误原文(429 Too Many Requests、
|
||||
// insufficient_quota、ECONNREFUSED 等)对用户不友好。按内容归类四类,消费方
|
||||
// (ChatInput 发送失败 toast)据类别映射 i18n 文案,原始错误串作为 {msg} 细节保留不丢。
|
||||
export type SendErrorKind = 'rate_limit' | 'quota' | 'network' | 'other'
|
||||
|
||||
/**
|
||||
* 按错误串内容分类。顺序敏感:429/限流先判(部分上游限流文案同时含网络相关词),
|
||||
* 其次额度/余额,再网络/超时,兜底 other。
|
||||
*/
|
||||
export function classifySendError(raw: string): SendErrorKind {
|
||||
const s = String(raw ?? '')
|
||||
if (/\b429\b|rate[_\s-]?limit|too\s*many\s*requests|限流|请求过于频繁/i.test(s)) return 'rate_limit'
|
||||
if (/\b402\b|quota|insufficient|balance|billing|credit|额度|余额|欠费|配额/i.test(s)) return 'quota'
|
||||
if (/timeout|timed?[_\s-]?out|network|connection|ECONN|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|\bDNS\b|超时|网络|连接/i.test(s)) return 'network'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
// ── S1 子代理完成卡(per-conv 归集,消费后端 AiAgentFinished 事件) ──
|
||||
//
|
||||
// 背景:spawn_agent 每个子循环结束经统一出口挂父会话 emit 一条 AiAgentFinished
|
||||
// (status/summary/rounds/tokens,后端 sub_agent.rs;status 字面量对齐后端 SubAgentStatus::as_str)。
|
||||
// 本 Map 是跨会话完成卡的内存真相源,沿用 convStreamStates 同款 per-conv reactive Map 模式:
|
||||
// 后台会话的完成事件也归集,切会话重进仍可见;MessageList 只读当前活跃会话的数组,不串视图。
|
||||
// 内存态不落库(与待审批/流式态同口径),会话删除即清。
|
||||
export type AgentFinishedStatus = 'completed' | 'truncated' | 'failed'
|
||||
|
||||
export interface AgentFinishedCard {
|
||||
/** 子会话 id(形态 {parent}::agent-{n}),同会话多子代理各占一卡按此键控 */
|
||||
agentId: string
|
||||
summary: string
|
||||
status: AgentFinishedStatus
|
||||
rounds: number
|
||||
tokens: number
|
||||
finishedAt: number
|
||||
}
|
||||
|
||||
export const convAgentFinished = reactive(new Map<string, AgentFinishedCard[]>())
|
||||
|
||||
/** 读某会话的完成卡数组;未出现过子代理返回空数组(渲染方零特判) */
|
||||
export function getConvAgentFinished(convId: string | null | undefined): AgentFinishedCard[] {
|
||||
if (!convId) return []
|
||||
return convAgentFinished.get(convId) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加一条完成卡(按 agentId 幂等覆盖)。后端单一出口每子循环只 emit 一次,覆盖仅为防
|
||||
* 监听重放双卡,不改变「同一会话多子代理多卡共存」语义;追加序即完成序。
|
||||
*/
|
||||
export function addConvAgentFinished(
|
||||
convId: string | null | undefined,
|
||||
card: Omit<AgentFinishedCard, 'finishedAt'>,
|
||||
): void {
|
||||
if (!convId || !card.agentId) return
|
||||
let arr = convAgentFinished.get(convId)
|
||||
if (!arr) {
|
||||
arr = []
|
||||
convAgentFinished.set(convId, arr)
|
||||
}
|
||||
const withTs: AgentFinishedCard = { ...card, finishedAt: Date.now() }
|
||||
const idx = arr.findIndex(c => c.agentId === card.agentId)
|
||||
if (idx >= 0) arr.splice(idx, 1, withTs)
|
||||
else arr.push(withTs)
|
||||
}
|
||||
|
||||
/** 清某会话完成卡(会话删除时调用,防 Map 持已删会话条目无限增长) */
|
||||
export function clearConvAgentFinished(convId: string | null | undefined): void {
|
||||
if (!convId) return
|
||||
convAgentFinished.delete(convId)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user