"use client"; /** * Chat 运行时状态(前端架构计划 §8、§10)。 * * Store 只持有数据与同步 setter;API 调用与 SSE 订阅由 ChatShell 组件编排, * 事件回调写入 store,保持职责清晰。流式助手消息用 `stream:` 作为 * 临时 message_id,message.completed 时以真实 id 定稿。 * * 持久化(zustand persist / localStorage):持久化当前会话(sessionId / * characterId)与用户偏好(思考模式 / 力度 / 智能搜索 / 模型 / 侧栏折叠);消息 * 不持久化——以后端为权威,刷新后由 ChatShell 拉取最新历史 hydrate 对齐(跨设备 * / 后端变更同步)。流式草稿与思考摘要(reasoning)不持久化——刷新后无法续传 * 中断的流,且思考摘要属隐私敏感内容,不应落盘(见 * docs/architecture/data-and-security-architecture.md §日志红线:raw CoT 不入事件/日志)。 * 瞬态字段(activeRunId / runStatus / sending / error)刷新即重置。 */ import { create } from "zustand"; import { persist } from "zustand/middleware"; import type { ChatMessage, ChatSession, MemoryRetrievedPayload, ToolCallStatus, ToolCompletedPayload, ToolRequiresConfirmationPayload, ToolStartedPayload, } from "@/types/api"; import type { ThinkingEffort } from "@/types/thinking"; import type { RunStatus } from "@/types/domain"; /** * 工具调用展示态(对话流 tool.* 事件驱动,临时态,不持久化)。 * 状态机:running → success | error | denied | skipped;中间可能进入 confirming。 */ export interface ToolCallDisplay { toolName: string; toolCallId: string; status: "running" | "confirming" | ToolCallStatus; /** tool.completed 的 result 文本(错误 / 拒绝时含原因) */ result?: string; /** 原始入参(tool.started / requires_confirmation 的 args 字符串) */ args?: string; /** confirming 时携带的 confirmation_id(approve/deny 调用用) */ confirmationId?: string; /** confirming 时的风险等级(L0–L4) */ riskLevel?: string; /** confirming 时的说明(展示给用户的理由) */ reason?: string; } /** 右侧预览面板的文件状态。 */ export interface PreviewFileState { path: string; runId?: string; fileName?: string; isolated?: boolean; } /** 一条助手回复的历史版本(重答留存,本地态,不持久化) */ export interface MessageVersion { content: string; created_at: string; } /** 带流式标记的本地消息草稿 */ export type ChatMessageDraft = ChatMessage & { streaming?: boolean; /** 思考摘要(reasoning):临时态,不持久化。仅思考模式开启时存在 */ reasoning?: string; /** 思考是否仍在流式输出中 */ reasoningStreaming?: boolean; /** 工具调用展示态列表(对话流 tool.* 事件):临时态,不持久化。每次工具调用独立展示,不互相覆盖 */ toolCalls?: ToolCallDisplay[]; /** * 重答历史版本。按生成顺序追加,末尾为最新;首次回复 * 定稿后 length===1。重答时旧版本经 priorVersions 迁入新消息,实现单消息多版本。 * 后端消息接口返回 versions 数组;hydrate 时直接带到前端,版本面板对历史消息也可见。 */ versions?: MessageVersion[]; /** 当前展示 / 采用的版本索引(指向 versions);流式中无意义,定稿后设定 */ activeVersionIdx?: number; }; interface ChatState { sessionId: string | null; characterId: string | null; messages: ChatMessageDraft[]; activeRunId: string | null; runStatus: RunStatus | null; sending: boolean; error: string | null; /** 最近一次错误的 trace_id(若来自后端 ApiError),供错误条展示「追踪」入口 */ errorTraceId: string | null; /** 右侧预览面板:文件列表(支持多文件标签页切换) */ previewFiles: PreviewFileState[]; activePreviewIndex: number; openPreviewFile: (file: PreviewFileState) => void; closePreviewPanel: () => void; switchPreviewFile: (index: number) => void; /** AppShell 左侧主导航栏是否展开 */ appSidebarExpanded: boolean; setAppSidebarExpanded: ( expanded: boolean | ((prev: boolean) => boolean), ) => void; /** 思考模式开关:开启后助手回复前流式展示可折叠的思考摘要 */ thinkingMode: boolean; /** 思考力度(reasoning_effort):决定思考摘要的详尽程度 */ thinkingEffort: ThinkingEffort; /** * 智能搜索(web_search)开关:开启后允许助手调用搜索工具获取实时信息。 * 默认关闭,持久化到 localStorage(参考 thinkingMode)。 */ webSearchEnabled: boolean; /** * 当前对话模型逻辑名(model_hint)。null 时由 ChatShell 以模型网关默认初始化; * 用户在对话工具栏切换后持久化,跨刷新保留。 */ modelHint: string | null; /** 会话列表(从后端拉取,不持久化;mock 内存态刷新重置) */ sessions: ChatSession[]; /** * 对话页左侧会话栏是否收起。持久化到 localStorage 以保留用户偏好。 * 收起时 SessionSidebar 隐藏,主区域左上角提供展开入口。 */ sidebarCollapsed: boolean; initSession: (session: ChatSession) => void; hydrateMessages: (messages: ChatMessage[]) => void; pushUserMessage: (content: string) => string; beginAssistantStream: ( runId: string, options?: { priorVersions?: MessageVersion[] }, ) => void; /** 浏览某个历史版本:瞬时同步主消息 content,不触达后端(◀▶ 翻页用) */ browseVersion: ( messageId: string, versionIdx: number, content: string, ) => void; /** 采用某个历史版本:同步 activeVersionIdx 与 content(版本面板「使用此版」) */ useVersion: (messageId: string, versionIdx: number) => void; appendDelta: (runId: string, delta: string) => void; finalizeAssistant: ( runId: string, messageId: string, content?: string, ) => void; appendReasoningDelta: (runId: string, delta: string) => void; finalizeReasoning: (runId: string) => void; setRunStatus: (status: RunStatus | null) => void; setSending: (sending: boolean) => void; setError: (error: string | null, traceId?: string | null) => void; setThinkingMode: (enabled: boolean) => void; setThinkingEffort: (effort: ThinkingEffort) => void; setWebSearchEnabled: (enabled: boolean) => void; setModelHint: (hint: string) => void; setSessions: (sessions: ChatSession[]) => void; addSession: (session: ChatSession) => void; patchSession: (sessionId: string, patch: Partial) => void; removeSession: (sessionId: string) => void; /** 叩心枢主动推送的新会话 ID 列表(前端通知用,不持久化) */ /** 息壤:当前 run 检索到的记忆列表(来自 memory.retrieved 事件) */ retrievedMemories: MemoryRetrievedPayload["memories"]; setRetrievedMemories: (memories: MemoryRetrievedPayload["memories"]) => void; clearRetrievedMemories: () => void; newSessionIds: string[]; addNewSessionId: (sessionId: string) => void; markSessionSeen: (sessionId: string) => void; clearAllNewSessions: () => void; switchSession: ( sessionId: string | null, characterId?: string | null, ) => void; setActiveRunId: (runId: string | null) => void; setSidebarCollapsed: (collapsed: boolean) => void; toggleSidebar: () => void; truncateMessagesAfter: (index: number) => void; /** tool.started:在当前助手流消息上记录工具调用开始 */ beginToolCall: (runId: string, payload: ToolStartedPayload) => void; /** tool.completed:按终态更新工具调用展示 */ completeToolCall: (runId: string, payload: ToolCompletedPayload) => void; /** tool.requires_confirmation:置为待确认态,展示确认卡片 */ requireToolConfirmation: ( runId: string, payload: ToolRequiresConfirmationPayload, ) => void; /** 用户确认 / 拒绝后,本地先把状态回到 running(等后端推 tool.completed) */ markToolCallResolving: (runId: string, toolCallId: string) => void; reset: () => void; } /** 流式助手消息的临时 id */ export const streamMessageId = (runId: string) => `stream:${runId}`; export const useChatStore = create()( persist( (set, get) => ({ sessionId: null, characterId: null, messages: [], activeRunId: null, runStatus: null, sending: false, error: null, errorTraceId: null, thinkingMode: true, thinkingEffort: "medium", webSearchEnabled: false, modelHint: null, sessions: [], sidebarCollapsed: false, retrievedMemories: [], newSessionIds: [] as string[], previewFiles: [], activePreviewIndex: -1, appSidebarExpanded: true, initSession: (session) => set({ sessionId: session.session_id, characterId: session.character_id, messages: [], activeRunId: null, runStatus: null, error: null, errorTraceId: null, }), hydrateMessages: (messages) => set({ messages: messages.map((m) => { const base: ChatMessageDraft = { ...m, streaming: false }; // 从 metadata.tool_calls 重建 ToolCallDisplay 列表,使所有工具调用信息刷新后仍可见 const rawCalls = m.metadata?.tool_calls; if (Array.isArray(rawCalls) && rawCalls.length > 0) { base.toolCalls = rawCalls.map((tc) => { const item = tc as Record; return { toolName: String(item.tool_name ?? ""), toolCallId: String(item.tool_call_id ?? ""), status: (item.status as ToolCallDisplay["status"]) ?? "success", args: String(item.args ?? ""), result: String(item.result ?? ""), }; }); } return base; }), }), pushUserMessage: (content) => { const tempId = `local:${Date.now()}`; set((state) => ({ messages: [ ...state.messages, { message_id: tempId, session_id: state.sessionId ?? "", role: "user", modality: "text", content, created_at: new Date().toISOString(), }, ], })); return tempId; }, beginAssistantStream: (runId, options) => set((state) => { const thinking = get().thinkingMode; // 重答场景:把旧 assistant 的历史版本迁入新消息,定稿时再追加新版 const priorVersions = options?.priorVersions; return { activeRunId: runId, runStatus: "running", messages: [ ...state.messages, { message_id: streamMessageId(runId), session_id: state.sessionId ?? "", character_id: state.characterId ?? undefined, role: "assistant", modality: "text", content: "", run_id: runId, streaming: true, // 思考模式开启时预置空思考草稿,承接 reasoning.delta reasoning: thinking ? "" : undefined, reasoningStreaming: thinking ? true : undefined, created_at: new Date().toISOString(), versions: priorVersions, activeVersionIdx: priorVersions && priorVersions.length > 0 ? priorVersions.length - 1 : undefined, }, ], }; }), appendDelta: (runId, delta) => set((state) => ({ messages: state.messages.map((m) => m.message_id === streamMessageId(runId) ? { ...m, content: m.content + delta } : m, ), })), finalizeAssistant: (runId, messageId, content) => set((state) => ({ messages: state.messages.map((m) => { if (m.message_id !== streamMessageId(runId)) return m; const finalContent = content ?? m.content; // 定稿即沉淀一个版本:重答场景下 priorVersions 已携旧版本,此处追加新版 const versions = [ ...(m.versions ?? []), { content: finalContent, created_at: new Date().toISOString(), }, ]; return { ...m, message_id: messageId, content: finalContent, streaming: false, versions, activeVersionIdx: versions.length - 1, }; }), })), appendReasoningDelta: (runId, delta) => set((state) => { // 思考模式关闭时不处理 reasoning.delta,防止后端误发 if (!state.thinkingMode) return state; return { messages: state.messages.map((m) => m.message_id === streamMessageId(runId) ? { ...m, reasoning: (m.reasoning ?? "") + delta } : m, ), }; }), finalizeReasoning: (runId) => set((state) => { if (!state.thinkingMode) return state; return { messages: state.messages.map((m) => m.message_id === streamMessageId(runId) ? { ...m, reasoningStreaming: false } : m, ), }; }), /** 浏览版本:瞬时同步主消息 content,不触达后端(◀▶ 翻页时调用) */ browseVersion: (messageId, versionIdx, content) => set((state) => ({ messages: state.messages.map((m) => m.message_id === messageId ? { ...m, activeVersionIdx: versionIdx, content } : m, ), })), useVersion: (messageId, versionIdx) => set((state) => ({ messages: state.messages.map((m) => m.message_id === messageId && m.versions?.[versionIdx] ? { ...m, activeVersionIdx: versionIdx, content: m.versions[versionIdx].content, } : m, ), })), setRunStatus: (runStatus) => set({ runStatus }), setSending: (sending) => set({ sending }), setError: (error, traceId = null) => set({ error, errorTraceId: traceId }), openPreviewFile: (file) => set((state) => { // 重复打开同一文件→切换到已有标签;否则追加 const existingIdx = state.previewFiles.findIndex( (f) => f.path === file.path && f.runId === file.runId, ); if (existingIdx >= 0) { return { activePreviewIndex: existingIdx, appSidebarExpanded: false, sidebarCollapsed: true, }; } const newFiles = [...state.previewFiles, file]; return { previewFiles: newFiles, activePreviewIndex: newFiles.length - 1, appSidebarExpanded: false, sidebarCollapsed: true, }; }), closePreviewPanel: () => set({ previewFiles: [], activePreviewIndex: -1, }), switchPreviewFile: (index) => set({ activePreviewIndex: index }), setAppSidebarExpanded: (expanded) => { const val = typeof expanded === "function" ? expanded(get().appSidebarExpanded) : expanded; set({ appSidebarExpanded: val }); }, setThinkingMode: (thinkingMode) => set({ thinkingMode }), setThinkingEffort: (thinkingEffort) => set({ thinkingEffort }), setWebSearchEnabled: (webSearchEnabled) => set({ webSearchEnabled }), setModelHint: (modelHint) => set({ modelHint }), setSessions: (sessions) => set({ sessions }), addSession: (session) => set((state) => ({ sessions: state.sessions.some( (s) => s.session_id === session.session_id, ) ? state.sessions : [session, ...state.sessions], })), patchSession: (sessionId, patch) => set((state) => ({ sessions: state.sessions.map((s) => s.session_id === sessionId ? { ...s, ...patch } : s, ), })), removeSession: (sessionId) => set((state) => ({ sessions: state.sessions.filter((s) => s.session_id !== sessionId), })), setRetrievedMemories: (memories) => set({ retrievedMemories: memories }), clearRetrievedMemories: () => set({ retrievedMemories: [] }), addNewSessionId: (sessionId) => set((state) => ({ newSessionIds: state.newSessionIds.includes(sessionId) ? state.newSessionIds : [...state.newSessionIds, sessionId], })), markSessionSeen: (sessionId) => set((state) => ({ newSessionIds: state.newSessionIds.filter((id) => id !== sessionId), })), clearAllNewSessions: () => set({ newSessionIds: [] }), switchSession: (sessionId, characterId) => set({ sessionId, characterId: characterId ?? null, messages: [], activeRunId: null, runStatus: null, sending: false, error: null, errorTraceId: null, retrievedMemories: [], }), setActiveRunId: (activeRunId) => set({ activeRunId }), setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }), toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })), truncateMessagesAfter: (index) => set((state) => ({ messages: state.messages.slice(0, index + 1) })), // —— tool.* 事件:写入当前助手流消息的 toolCalls 展示态(临时态,不持久化)—— // 同一 runId 只挂一条流式助手消息(streamMessageId),工具调用追加到 toolCalls 数组, // 每次工具调用单独展示,互不覆盖。后续 complete/confirm/resolve 通过 toolCallId 定位。 beginToolCall: (runId, payload) => set((state) => ({ messages: state.messages.map((m) => m.message_id === streamMessageId(runId) ? { ...m, toolCalls: [ ...(m.toolCalls ?? []), { toolName: payload.tool_name, toolCallId: payload.tool_call_id, status: "running", args: payload.args, }, ], } : m, ), })), completeToolCall: (runId, payload) => set((state) => ({ messages: state.messages.map((m) => { if (m.message_id !== streamMessageId(runId)) return m; const calls = m.toolCalls; if (!calls) { // 理论上应先收到 tool.started,直接落 completed return { ...m, toolCalls: [ { toolName: payload.tool_name, toolCallId: payload.tool_call_id, status: payload.status, result: payload.result, }, ], }; } return { ...m, toolCalls: calls.map((tc) => tc.toolCallId === payload.tool_call_id ? { ...tc, status: payload.status, result: payload.result, toolName: payload.tool_name, toolCallId: payload.tool_call_id, } : tc, ), }; }), })), requireToolConfirmation: (runId, payload) => set((state) => ({ messages: state.messages.map((m) => { if (m.message_id !== streamMessageId(runId)) return m; return { ...m, toolCalls: [ ...(m.toolCalls ?? []), { toolName: payload.tool_name, toolCallId: payload.tool_call_id, status: "confirming", args: payload.args, confirmationId: payload.confirmation_id, riskLevel: payload.risk_level, reason: payload.reason, }, ], }; }), })), markToolCallResolving: (runId, toolCallId) => set((state) => ({ messages: state.messages.map((m) => m.message_id === streamMessageId(runId) ? { ...m, toolCalls: (m.toolCalls ?? []).map((tc) => tc.toolCallId === toolCallId ? { ...tc, status: "running" } : tc, ), } : m, ), })), reset: () => set({ sessionId: null, characterId: null, messages: [], activeRunId: null, runStatus: null, sending: false, error: null, errorTraceId: null, }), }), { name: "hunjing-chat", version: 1, partialize: (s) => ({ // 持久化当前会话与用户偏好,支持跨刷新恢复;消息不持久化——以后端为权威, // 刷新后由 ChatShell 拉取最新历史 hydrate 对齐(跨设备 / 后端变更同步)。 sessionId: s.sessionId, characterId: s.characterId, thinkingMode: s.thinkingMode, thinkingEffort: s.thinkingEffort, webSearchEnabled: s.webSearchEnabled, modelHint: s.modelHint, sidebarCollapsed: s.sidebarCollapsed, appSidebarExpanded: s.appSidebarExpanded, }), }, ), );