Files
hunjing/frontend/src/lib/stores/chat-store.ts
T
2026-08-21 16:00:13 +08:00

606 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* Chat 运行时状态(前端架构计划 §8、§10)。
*
* Store 只持有数据与同步 setter;API 调用与 SSE 订阅由 ChatShell 组件编排,
* 事件回调写入 store,保持职责清晰。流式助手消息用 `stream:<runId>` 作为
* 临时 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<ChatSession>) => 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<ChatState>()(
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<string, unknown>;
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,
}),
},
),
);