Skip to content

Commit 0c04aca

Browse files
committed
feat(create): 聊天创建入库根治方案 A期+B1/B2/B4 (v0.1.12)
- A1: 四类 propose_* 工具描述对齐防幻觉尾句 (NO_CLAIM_SUCCESS) - A2: needsCreateRecovery 扩展检测词表 (已添加/已配好/已就绪等) - A3: inferCreateKind 关键词匹配定向补跑 (R1: 不引入 LLM 分类) - A4: proposal_error 事件 + CreateProposalErrorCard 失败卡 (R2: 含重试按钮) - A5: CreateMeta 事实源 + confirmCreate 写 confirmed (R3) - A6: createRecovery.test.ts 10 用例 - B1: 待确认草稿指示条 - B2: 补跑失败兜底 CreateNoticeBar + 管理页入口 - B4: 全链路 i18n (home:create.*/errors.create.*) 中英双语 (R4/R5) - docs/CHAT_CREATE_PERSISTENCE_FIX.md 设计文档 + Review 建议 R1-R5
1 parent 5bfe5ed commit 0c04aca

24 files changed

Lines changed: 1435 additions & 64 deletions

docs/CHAT_CREATE_PERSISTENCE_FIX.md

Lines changed: 475 additions & 0 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "one",
3-
"version": "0.1.11",
3+
"version": "0.1.12",
44
"description": "One desktop app built with Electron, React, and TypeScript.",
55
"main": "./out/main/index.cjs",
66
"author": "shijianzhong",

src/main/ipc/home.ts

Lines changed: 225 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { 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'
310
import { withHandler } from './handler'
411
import {
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'
2033
import { Agent } from '../orchestrator/agent'
2134
import {
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'
3149
import { SkillContextProvider } from '../skills/provider'
3250
import { injectL0 } from '../storage/memory/l0'
@@ -64,6 +82,36 @@ const DRAFT_TTL_MS = 30 * 60 * 1000
6482
const MAX_PENDING_DRAFTS = 100
6583
const 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
/** 惰性清理超时草稿(随新提案/确认调用,防用户不点按钮直接离开导致的内存驻留) */
68116
function 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

Comments
 (0)