606 lines
22 KiB
TypeScript
606 lines
22 KiB
TypeScript
"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,
|
||
}),
|
||
},
|
||
),
|
||
);
|