11import { BrowserWindow } from 'electron'
2- import type { AgentConfig , CreateDraft , HomeStreamEvent , LlmMessage , Persona } from '@shared/types'
2+ import type {
3+ AgentConfig ,
4+ CreateDraft ,
5+ CreateMeta ,
6+ HomeStreamEvent ,
7+ LlmMessage ,
8+ Persona ,
9+ } from '@shared/types'
310import { withHandler } from './handler'
411import {
512 getDefaultProvider ,
@@ -16,7 +23,13 @@ import {
1623 saveSkill ,
1724 savePersona ,
1825} from '../storage/models'
19- import { addMessage , createSession , listMessages } from '../storage/sessions'
26+ import {
27+ addMessage ,
28+ createSession ,
29+ findMessageByCreateDraftId ,
30+ listMessages ,
31+ updateMessageMeta ,
32+ } from '../storage/sessions'
2033import { Agent } from '../orchestrator/agent'
2134import {
2235 TeamJsonDetector ,
@@ -25,8 +38,13 @@ import {
2538 buildRoutingInstruction ,
2639 buildCapabilityFocusBlock ,
2740 buildTeamGraph ,
41+ createKindFromToolName ,
42+ inferCreateKind ,
43+ needsCreateRecovery ,
44+ proposeToolNameForKind ,
2845 resolveMentions ,
2946 runTeam ,
47+ type CreateKind ,
3048} from '../orchestrator/home'
3149import { SkillContextProvider } from '../skills/provider'
3250import { injectL0 } from '../storage/memory/l0'
@@ -64,6 +82,36 @@ const DRAFT_TTL_MS = 30 * 60 * 1000
6482const MAX_PENDING_DRAFTS = 100
6583const pendingDrafts = new Map < string , { draft : CreateDraft ; ts : number } > ( )
6684
85+ /** propose_* 工具结果是否表示失败(Zod invalid_args / empty_payload 等) */
86+ function parseProposeFailure ( content : string ) : {
87+ error : string
88+ messageKey : string
89+ detail ?: unknown
90+ } | null {
91+ try {
92+ const parsed = JSON . parse ( content ) as {
93+ ok ?: boolean
94+ error ?: string
95+ messageKey ?: string
96+ detail ?: unknown
97+ hint ?: string
98+ }
99+ if ( parsed . ok === true ) return null
100+ if ( ! parsed . error && parsed . ok !== false ) return null
101+ const error = parsed . error ?? 'propose_failed'
102+ const messageKey =
103+ parsed . messageKey ??
104+ ( error === 'invalid_args'
105+ ? 'errors.create.invalid_args'
106+ : error === 'empty_payload'
107+ ? 'errors.create.empty_payload'
108+ : 'errors.create.propose_failed' )
109+ return { error, messageKey, detail : parsed . detail ?? parsed . hint }
110+ } catch {
111+ return null
112+ }
113+ }
114+
67115/** 惰性清理超时草稿(随新提案/确认调用,防用户不点按钮直接离开导致的内存驻留) */
68116function pruneDrafts ( ) : void {
69117 const now = Date . now ( )
@@ -216,6 +264,8 @@ export function registerHomeHandlers(): void {
216264
217265 // R1/R2:builtin + 显式 exposeToAgents 且已连接的 MCP 工具(同一快照供主 Agent / 组队节点共用)
218266 const agentTools = await listToolsForAgents ( )
267+ const proposeToolNames = agentTools . filter ( ( t ) => t . name . startsWith ( 'propose_' ) ) . map ( ( t ) => t . name )
268+ logger . info ( '[home] propose_* 工具:' , proposeToolNames . length ? proposeToolNames . join ( ',' ) : '(无!创建链路不可用)' )
219269
220270 // 6. Agent(带 memory 工具:L3 recall/search/retain)
221271 // thinking:按供应商开关 + 模型类型选择 thinking 参数格式
@@ -231,21 +281,47 @@ export function registerHomeHandlers(): void {
231281 defaultOptions : { maxTokens : 16384 } ,
232282 thinking,
233283 }
284+ /** 本回合是否已弹出 propose_* 确认卡(用于幻觉入库补跑) */
285+ let proposeCount = 0
286+ /** 用数组承接闭包写入,避免 TS 把 let 收窄成恒 null → never */
287+ const proposedThisTurn : CreateDraft [ ] = [ ]
288+ let lastProposeFailKind : CreateKind | null = null
289+ let createRecovered = false
290+ const emitProposeFailure = ( toolName : string , content : string ) : void => {
291+ const kind = createKindFromToolName ( toolName )
292+ if ( ! kind ) return
293+ const fail = parseProposeFailure ( content )
294+ if ( ! fail ) return
295+ lastProposeFailKind = kind
296+ logger . warn ( `[home:create] propose failed: kind=${ kind } code=${ fail . error } ` )
297+ emitStream ( {
298+ type : 'proposal_error' ,
299+ kind,
300+ error : fail . error ,
301+ messageKey : fail . messageKey ,
302+ detail : fail . detail ,
303+ } )
304+ }
234305 const agent = new Agent ( config , {
235306 llmOpts : { apiKey, baseURL, authHeader } ,
236307 toolCtx : {
237308 sessionId : sid ,
238309 signal,
239310 // propose_* 工具产出草稿 → 经此桥 emitStream proposal → 前端确认卡(不落库)
311+ // 打上 sessionId:回合结束清 streamMsgs 后仍可按会话重挂,避免确认卡闪没
240312 onPropose : ( draft ) => {
313+ proposeCount += 1
241314 pruneDrafts ( )
315+ const stamped : CreateDraft = { ...draft , sessionId : sid }
316+ proposedThisTurn . push ( stamped )
242317 // 超上限挤掉最旧草稿(Map 迭代序即插入序)
243- if ( pendingDrafts . size >= MAX_PENDING_DRAFTS && ! pendingDrafts . has ( draft . draftId ) ) {
318+ if ( pendingDrafts . size >= MAX_PENDING_DRAFTS && ! pendingDrafts . has ( stamped . draftId ) ) {
244319 const oldest = pendingDrafts . keys ( ) . next ( ) . value
245320 if ( oldest ) pendingDrafts . delete ( oldest )
246321 }
247- pendingDrafts . set ( draft . draftId , { draft, ts : Date . now ( ) } )
248- emitStream ( { type : 'proposal' , draft } )
322+ pendingDrafts . set ( stamped . draftId , { draft : stamped , ts : Date . now ( ) } )
323+ emitStream ( { type : 'proposal' , draft : stamped } )
324+ logger . info ( `[home:create] propose invoked: kind=${ stamped . kind } draftId=${ stamped . draftId } ` )
249325 } ,
250326 // HITL 提问桥(ask_user 工具):事件经 orch_event 包装,前端渲染 AskUserCard;
251327 // respond 收口在 orchestrate:respond(与组队节点同一 userInput 队列)
@@ -400,30 +476,119 @@ export function registerHomeHandlers(): void {
400476 let finalText = ''
401477 let finalThinking = ''
402478
479+ const streamCallbacks = {
480+ onText : ( text : string ) => {
481+ const safe = detector . feed ( text )
482+ if ( safe ) emitStream ( { type : 'text' , text : safe } )
483+ } ,
484+ onThinking : ( text : string ) => emitStream ( { type : 'thinking' , text } ) ,
485+ onRetry : ( info : {
486+ attempt : number
487+ maxRetries : number
488+ delayMs : number
489+ reason : string
490+ } ) =>
491+ emitStream ( {
492+ type : 'retry' ,
493+ attempt : info . attempt ,
494+ maxRetries : info . maxRetries ,
495+ delayMs : info . delayMs ,
496+ reason : info . reason ,
497+ } ) ,
498+ onToolResult : ( tool : string , result : unknown ) => {
499+ if ( tool . startsWith ( 'propose_' ) ) emitProposeFailure ( tool , String ( result ) )
500+ } ,
501+ }
502+
403503 const result = await agent . run (
404504 { messages : l1Messages , runId : sid , signal } ,
405- {
406- onText : ( text ) => {
407- const safe = detector . feed ( text )
408- if ( safe ) emitStream ( { type : 'text' , text : safe } )
409- } ,
410- onThinking : ( text ) => emitStream ( { type : 'thinking' , text } ) ,
411- onRetry : ( info ) =>
412- emitStream ( {
413- type : 'retry' ,
414- attempt : info . attempt ,
415- maxRetries : info . maxRetries ,
416- delayMs : info . delayMs ,
417- reason : info . reason ,
418- } ) ,
419- } ,
505+ streamCallbacks ,
420506 )
421507 finalText = result . finalText
422508 finalThinking = result . finalThinking
423509 if ( result . hitIterationLimit ) {
424510 logger . warn ( '[home] 主 Agent 达工具轮次上限,已强制无工具收尾' )
425511 }
426512
513+ // —— 创建幻觉补跑:自称已入库 / 否认持久化,但从未调 propose_* → 按 kind 定向挂工具再跑 ——
514+ // 挡「嘴上创建成功、确认卡从未出现」;澄清追问不触发(见 needsCreateRecovery)。
515+ if (
516+ proposeCount === 0 &&
517+ needsCreateRecovery ( finalText ) &&
518+ ! signal . aborted &&
519+ proposeToolNames . length > 0
520+ ) {
521+ const inferred = inferCreateKind ( message , finalText )
522+ const createTools = inferred
523+ ? agentTools . filter ( ( t ) => t . name === proposeToolNameForKind ( inferred ) )
524+ : agentTools . filter ( ( t ) => t . name . startsWith ( 'propose_' ) )
525+ const toolsForRecovery = createTools . length > 0 ? createTools : agentTools . filter ( ( t ) => t . name . startsWith ( 'propose_' ) )
526+ const kindParam = inferred ?? 'unknown'
527+ logger . warn (
528+ `[home:create] recovery triggered: kind=${ kindParam } reason=hallucination tools=${ toolsForRecovery . map ( ( t ) => t . name ) . join ( ',' ) } ` ,
529+ )
530+ emitStream ( {
531+ type : 'create_notice' ,
532+ messageKey : 'home:create.recovery.pending' ,
533+ params : { kind : kindParam } ,
534+ level : 'warn' ,
535+ } )
536+ const toolList = toolsForRecovery . map ( ( t ) => t . name ) . join ( ' / ' )
537+ const recoveryAgent = new Agent (
538+ {
539+ ...config ,
540+ tools : toolsForRecovery ,
541+ instructions : `${ config . instructions } \n\n【系统强制】你必须立即调用 ${ toolList } 生成确认卡。本环境已具备入库链路。禁止声称没有存储或已入库。只调与对话匹配的一个 propose_*。` ,
542+ } ,
543+ agent . deps ,
544+ )
545+ const recovery = await recoveryAgent . run (
546+ {
547+ messages : [
548+ ...result . messages ,
549+ {
550+ role : 'user' ,
551+ content : inferred
552+ ? `(系统纠正)你刚才没有调用 propose_*,资产未入库。请立刻调用 ${ proposeToolNameForKind ( inferred ) } 弹出确认卡。禁止再说「已入库」「没有持久化」「只是模拟」。`
553+ : '(系统纠正)你刚才没有调用 propose_* 工具,资产并未写入库。请根据本对话里已确认的需求,立刻调用对应 propose_* 工具弹出确认卡。禁止再说「已入库」「没有持久化」「只是模拟」。' ,
554+ } ,
555+ ] ,
556+ runId : sid ,
557+ signal,
558+ } ,
559+ streamCallbacks ,
560+ { maxIterations : 4 } ,
561+ )
562+ if ( recovery . finalThinking ) finalThinking = recovery . finalThinking
563+ if ( proposeCount > 0 ) {
564+ createRecovered = true
565+ // 补跑已弹出卡:历史不要留「已入库/没有持久化」谎言;正文由前端 notice/卡表达
566+ finalText = recovery . finalText ?. trim ( ) || ''
567+ logger . info ( `[home:create] recovery done: proposed=${ proposeCount } ` )
568+ } else {
569+ logger . error ( `[home:create] recovery done: proposed=0 kind=${ kindParam } ` )
570+ emitStream ( {
571+ type : 'create_notice' ,
572+ messageKey : 'home:create.recovery.failed' ,
573+ params : { kind : kindParam } ,
574+ level : 'error' ,
575+ } )
576+ if ( recovery . finalText ) finalText = recovery . finalText
577+ }
578+ }
579+
580+ // 创建事实源(A5):proposed / hallucination_recovered / failed
581+ const lastProposed = proposedThisTurn [ proposedThisTurn . length - 1 ]
582+ const createMeta : CreateMeta | undefined = lastProposed
583+ ? {
584+ status : createRecovered ? 'hallucination_recovered' : 'proposed' ,
585+ kind : lastProposed . kind ,
586+ draftId : lastProposed . draftId ,
587+ }
588+ : lastProposeFailKind && proposeCount === 0
589+ ? { status : 'failed' , kind : lastProposeFailKind }
590+ : undefined
591+
427592 // 流结束:判定直答 vs 组队
428593 const decision = detector . decide ( )
429594 if ( decision . kind === 'team' ) {
@@ -437,7 +602,11 @@ export function registerHomeHandlers(): void {
437602 sessionId : sid ,
438603 role : 'assistant' ,
439604 content : teamResult . output ,
440- meta : { thinking : finalThinking || undefined , team : decision . json } ,
605+ meta : {
606+ thinking : finalThinking || undefined ,
607+ team : decision . json ,
608+ ...( createMeta ? { create : createMeta } : { } ) ,
609+ } ,
441610 } )
442611 } else {
443612 // 组队 JSON 指向的 role/capability 全失效 → 回退直答
@@ -448,7 +617,10 @@ export function registerHomeHandlers(): void {
448617 sessionId : sid ,
449618 role : 'assistant' ,
450619 content : finalText ,
451- meta : finalThinking ? { thinking : finalThinking } : undefined ,
620+ meta : {
621+ ...( finalThinking ? { thinking : finalThinking } : { } ) ,
622+ ...( createMeta ? { create : createMeta } : { } ) ,
623+ } ,
452624 } )
453625 }
454626 } else {
@@ -459,7 +631,10 @@ export function registerHomeHandlers(): void {
459631 sessionId : sid ,
460632 role : 'assistant' ,
461633 content : finalText ,
462- meta : finalThinking ? { thinking : finalThinking } : undefined ,
634+ meta : {
635+ ...( finalThinking ? { thinking : finalThinking } : { } ) ,
636+ ...( createMeta ? { create : createMeta } : { } ) ,
637+ } ,
463638 } )
464639 }
465640
@@ -564,7 +739,22 @@ export function registerHomeHandlers(): void {
564739 }
565740
566741 pendingDrafts . delete ( draftId )
567- logger . info ( `[home:create] 已入库 ${ kind } :` , saved . id )
742+ // R3:confirm 成功后写 meta.create.status=confirmed(供 B 期降级/事实源)
743+ const sessionId = cached ?. sessionId
744+ if ( sessionId ) {
745+ const msg = findMessageByCreateDraftId ( sessionId , draftId )
746+ if ( msg ) {
747+ const prevCreate = ( msg . meta as { create ?: CreateMeta } | undefined ) ?. create
748+ updateMessageMeta ( msg . id , {
749+ create : {
750+ status : 'confirmed' as const ,
751+ kind : prevCreate ?. kind ?? kind ,
752+ draftId,
753+ } ,
754+ } )
755+ }
756+ }
757+ logger . info ( `[home:create] confirmCreate: kind=${ kind } id=${ saved . id } ` )
568758 return { id : saved . id }
569759 } )
570760
@@ -574,4 +764,15 @@ export function registerHomeHandlers(): void {
574764 pendingDrafts . delete ( draftId )
575765 logger . info ( '[home:create] 已取消草稿:' , draftId )
576766 } )
767+
768+ // 列出未确认草稿(按会话):回合结束 / 切回会话时重挂确认卡,防 streamMsgs 清空后卡片消失
769+ withHandler < CreateDraft [ ] > ( 'home:listPendingDrafts' , ( _e , input ) => {
770+ pruneDrafts ( )
771+ const sessionId = ( input as { sessionId ?: string } | undefined ) ?. sessionId
772+ const drafts : CreateDraft [ ] = [ ]
773+ for ( const { draft } of pendingDrafts . values ( ) ) {
774+ if ( ! sessionId || draft . sessionId === sessionId ) drafts . push ( draft )
775+ }
776+ return drafts
777+ } )
577778}
0 commit comments