初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+605
View File
@@ -0,0 +1,605 @@
"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,
}),
},
),
);