初始化提交

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

+126
View File
@@ -0,0 +1,126 @@
import { create } from "zustand";
import type { UserResponse, LoginRequest } from "@/types/auth";
/**
* 认证状态(纯 BFF 架构)。
*
* token 唯一存于 httpOnly cookie,由 BFF /api/auth/* 路由在服务端管理,客户端
* 永不持有 token。本 store 仅维护 user 信息:
* - login / logout / loadFromBff / tryRefresh 均经 BFF 路由(浏览器自动携带 cookie);
* - 另存一份 user-only 的 localStorage 做首屏防闪,由 /api/auth/me 返回后校正。
*/
const USER_KEY = "hunjing-user";
/** 从 localStorage 读取缓存的 user(仅 user,不含 token)。 */
function loadStoredUser(): UserResponse | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(USER_KEY);
if (!raw) return null;
return (JSON.parse(raw) as { user: UserResponse | null }).user ?? null;
} catch {
return null;
}
}
/** 写入 / 清除本地 user 缓存。 */
function saveStoredUser(user: UserResponse | null): void {
if (typeof window === "undefined") return;
if (user) {
localStorage.setItem(USER_KEY, JSON.stringify({ user }));
} else {
localStorage.removeItem(USER_KEY);
}
}
interface AuthState {
user: UserResponse | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (credentials: LoginRequest) => Promise<void>;
logout: () => void;
/** 从 localStorage 同步恢复 user(首屏防闪),随后应由 loadFromBff 校正 */
loadFromStorage: () => void;
loadFromBff: () => Promise<void>;
tryRefresh: () => Promise<boolean>;
}
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
isAuthenticated: false,
isLoading: false,
/** BFF 登录:经 /api/auth/login 代理,token 存入 httpOnly cookie。 */
login: async (credentials) => {
set({ isLoading: true });
try {
const resp = await fetch("/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(credentials),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err?.error?.message ?? "登录失败。");
}
const data = await resp.json();
const user = (data.user ?? null) as UserResponse | null;
saveStoredUser(user);
set({ user, isAuthenticated: true, isLoading: false });
} catch (error) {
set({ isLoading: false });
throw error;
}
},
/** 登出:清 httpOnly cookie(BFF)+ 本地 user 缓存。 */
logout: () => {
fetch("/api/auth/logout", { method: "POST" }).catch(() => {});
saveStoredUser(null);
set({ user: null, isAuthenticated: false });
},
loadFromStorage: () => {
const user = loadStoredUser();
if (user) set({ user, isAuthenticated: true });
},
/** BFF 恢复:从 /api/auth/me 获取当前用户(依赖 httpOnly cookie,自动刷新)。 */
loadFromBff: async () => {
try {
const resp = await fetch("/api/auth/me");
if (resp.ok) {
const data = await resp.json();
const user = (data.user ?? null) as UserResponse | null;
saveStoredUser(user);
set({ user, isAuthenticated: true });
return;
}
} catch {
/* BFF 不可达 */
}
saveStoredUser(null);
set({ user: null, isAuthenticated: false });
},
/** 尝试通过 BFF 刷新 token(/api/auth/refresh,cookie 自动携带)。 */
tryRefresh: async () => {
try {
const resp = await fetch("/api/auth/refresh", { method: "POST" });
if (!resp.ok) {
get().logout();
return false;
}
const data = await resp.json();
const user = (data.user ?? null) as UserResponse | null;
saveStoredUser(user);
set({ user, isAuthenticated: true });
return true;
} catch {
get().logout();
return false;
}
},
}));
+264
View File
@@ -0,0 +1,264 @@
import { act } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { useChatStore, streamMessageId } from "./chat-store";
describe("useChatStore", () => {
beforeEach(() => {
localStorage.clear();
useChatStore.setState({
sessionId: null,
characterId: null,
messages: [],
activeRunId: null,
runStatus: null,
sending: false,
error: null,
errorTraceId: null,
sessions: [],
newSessionIds: [],
});
});
it("初始状态", () => {
const s = useChatStore.getState();
expect(s.sessionId).toBeNull();
expect(s.messages).toEqual([]);
expect(s.sending).toBe(false);
});
describe("initSession", () => {
it("设置 session/character ID 并清空消息", () => {
act(() =>
useChatStore.getState().initSession({
session_id: "sess_1",
character_id: "char_1",
title: "测试",
status: "active",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
}),
);
const s = useChatStore.getState();
expect(s.sessionId).toBe("sess_1");
expect(s.characterId).toBe("char_1");
expect(s.messages).toEqual([]);
});
});
describe("pushUserMessage", () => {
it("追加一条用户消息并返回临时 ID", () => {
let tempId: string = "";
act(() => {
tempId = useChatStore.getState().pushUserMessage("你好");
});
expect(tempId).toMatch(/^local:/);
const msgs = useChatStore.getState().messages;
expect(msgs).toHaveLength(1);
expect(msgs[0].role).toBe("user");
expect(msgs[0].content).toBe("你好");
});
});
describe("流式消息", () => {
it("beginAssistantStream 创建流式消息", () => {
act(() => useChatStore.getState().beginAssistantStream("run_1"));
const msgs = useChatStore.getState().messages;
expect(msgs).toHaveLength(1);
expect(msgs[0].message_id).toBe(streamMessageId("run_1"));
expect(msgs[0].streaming).toBe(true);
expect(msgs[0].reasoning).toBe("");
expect(msgs[0].reasoningStreaming).toBe(true);
});
it("appendDelta 追加文本", () => {
act(() => useChatStore.getState().beginAssistantStream("run_1"));
act(() => useChatStore.getState().appendDelta("run_1", "你好"));
const msgs = useChatStore.getState().messages;
expect(msgs[0].content).toBe("你好");
});
it("finalizeAssistant 定稿消息", () => {
act(() => useChatStore.getState().beginAssistantStream("run_1"));
act(() =>
useChatStore
.getState()
.finalizeAssistant("run_1", "msg_real_1", "最终回复"),
);
const msgs = useChatStore.getState().messages;
expect(msgs[0].message_id).toBe("msg_real_1");
expect(msgs[0].content).toBe("最终回复");
expect(msgs[0].streaming).toBe(false);
});
it("appendReasoningDelta 追加思考摘要", () => {
act(() => useChatStore.getState().beginAssistantStream("run_1"));
act(() =>
useChatStore.getState().appendReasoningDelta("run_1", "正在思考"),
);
const msgs = useChatStore.getState().messages;
expect(msgs[0].reasoning).toBe("正在思考");
});
});
describe("会话管理", () => {
it("addSession 追加不重复的会话", () => {
act(() =>
useChatStore.getState().addSession({
session_id: "sess_1",
character_id: "char_1",
title: "测试",
status: "active",
created_at: "",
updated_at: "",
}),
);
expect(useChatStore.getState().sessions).toHaveLength(1);
// 重复添加应被忽略
act(() =>
useChatStore.getState().addSession({
session_id: "sess_1",
character_id: "char_1",
title: "测试",
status: "active",
created_at: "",
updated_at: "",
}),
);
expect(useChatStore.getState().sessions).toHaveLength(1);
});
it("removeSession 移除指定会话", () => {
act(() =>
useChatStore.getState().addSession({
session_id: "sess_1",
character_id: "char_1",
title: "测试",
status: "active",
created_at: "",
updated_at: "",
}),
);
act(() => useChatStore.getState().removeSession("sess_1"));
expect(useChatStore.getState().sessions).toHaveLength(0);
});
it("patchSession 更新指定会话", () => {
act(() =>
useChatStore.getState().addSession({
session_id: "sess_1",
character_id: "char_1",
title: "旧标题",
status: "active",
created_at: "",
updated_at: "",
}),
);
act(() =>
useChatStore.getState().patchSession("sess_1", { title: "新标题" }),
);
const patched = useChatStore.getState().sessions[0];
expect(patched.title).toBe("新标题");
});
});
describe("叩心枢新会话提醒", () => {
it("addNewSessionId 追加不重复的 ID", () => {
act(() => useChatStore.getState().addNewSessionId("sess_1"));
act(() => useChatStore.getState().addNewSessionId("sess_1"));
expect(useChatStore.getState().newSessionIds).toEqual(["sess_1"]);
});
it("markSessionSeen 移除已读", () => {
act(() => useChatStore.getState().addNewSessionId("sess_1"));
act(() => useChatStore.getState().addNewSessionId("sess_2"));
act(() => useChatStore.getState().markSessionSeen("sess_1"));
expect(useChatStore.getState().newSessionIds).toEqual(["sess_2"]);
});
it("clearAllNewSessions 全部清除", () => {
act(() => useChatStore.getState().addNewSessionId("sess_1"));
act(() => useChatStore.getState().clearAllNewSessions());
expect(useChatStore.getState().newSessionIds).toEqual([]);
});
});
describe("工具调用状态", () => {
it("beginToolCall 写入 toolCalls 展示态", () => {
act(() => useChatStore.getState().beginAssistantStream("run_1"));
act(() =>
useChatStore.getState().beginToolCall("run_1", {
tool_name: "web_search",
tool_call_id: "tc_1",
args: '{"q":"test"}',
}),
);
const tcs = useChatStore.getState().messages[0].toolCalls;
expect(tcs).toHaveLength(1);
expect(tcs?.[0].toolName).toBe("web_search");
expect(tcs?.[0].status).toBe("running");
});
it("beginToolCall 多次调用各自独立展示", () => {
act(() => useChatStore.getState().beginAssistantStream("run_1"));
act(() =>
useChatStore.getState().beginToolCall("run_1", {
tool_name: "web_search",
tool_call_id: "tc_1",
args: "{}",
}),
);
act(() =>
useChatStore.getState().beginToolCall("run_1", {
tool_name: "read_file",
tool_call_id: "tc_2",
args: '{"path":"/tmp/x"}',
}),
);
const tcs = useChatStore.getState().messages[0].toolCalls;
expect(tcs).toHaveLength(2);
expect(tcs?.[0].toolName).toBe("web_search");
expect(tcs?.[1].toolName).toBe("read_file");
});
it("completeToolCall 更新工具终态", () => {
act(() => useChatStore.getState().beginAssistantStream("run_1"));
act(() =>
useChatStore.getState().beginToolCall("run_1", {
tool_name: "web_search",
tool_call_id: "tc_1",
args: "{}",
}),
);
act(() =>
useChatStore.getState().completeToolCall("run_1", {
tool_name: "web_search",
tool_call_id: "tc_1",
status: "success",
result: "搜索结果",
}),
);
const tcs = useChatStore.getState().messages[0].toolCalls;
expect(tcs).toHaveLength(1);
expect(tcs?.[0].status).toBe("success");
expect(tcs?.[0].result).toBe("搜索结果");
});
});
describe("视图/偏好设置", () => {
it("setSidebarCollapsed / toggleSidebar", () => {
act(() => useChatStore.getState().setSidebarCollapsed(true));
expect(useChatStore.getState().sidebarCollapsed).toBe(true);
act(() => useChatStore.getState().toggleSidebar());
expect(useChatStore.getState().sidebarCollapsed).toBe(false);
});
it("setThinkingMode / setThinkingEffort", () => {
act(() => useChatStore.getState().setThinkingMode(false));
expect(useChatStore.getState().thinkingMode).toBe(false);
act(() => useChatStore.getState().setThinkingEffort("high"));
expect(useChatStore.getState().thinkingEffort).toBe("high");
});
});
});
+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,
}),
},
),
);
@@ -0,0 +1,56 @@
import { act } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import {
NEXT_THEME_LABEL,
resolveTheme,
useSettingsStore,
} from "./settings-store";
describe("useSettingsStore", () => {
afterEach(() => {
useSettingsStore.setState({ theme: "system" });
});
it("初始 theme 为 system", () => {
expect(useSettingsStore.getState().theme).toBe("system");
});
it("setTheme 更新主题", () => {
act(() => useSettingsStore.getState().setTheme("dark"));
expect(useSettingsStore.getState().theme).toBe("dark");
});
it("toggleTheme 轮换 light → dark → system → light", () => {
act(() => useSettingsStore.getState().setTheme("light"));
act(() => useSettingsStore.getState().toggleTheme());
expect(useSettingsStore.getState().theme).toBe("dark");
act(() => useSettingsStore.getState().toggleTheme());
expect(useSettingsStore.getState().theme).toBe("system");
act(() => useSettingsStore.getState().toggleTheme());
expect(useSettingsStore.getState().theme).toBe("light");
});
});
describe("NEXT_THEME_LABEL", () => {
it("light 显示素绢", () => {
expect(NEXT_THEME_LABEL.light).toBe("素绢");
});
it("dark 显示夜铜", () => {
expect(NEXT_THEME_LABEL.dark).toBe("夜铜");
});
it("system 显示随系统", () => {
expect(NEXT_THEME_LABEL.system).toBe("随系统");
});
});
describe("resolveTheme", () => {
it("light 返回 light", () => {
expect(resolveTheme("light")).toBe("light");
});
it("dark 返回 dark", () => {
expect(resolveTheme("dark")).toBe("dark");
});
});
+57
View File
@@ -0,0 +1,57 @@
"use client";
/**
* 用户设置 store(前端架构计划 §10)。
* 当前仅管理主题;模型/服务地址偏好为后续增强。
*/
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { ThemeMode } from "@/types/domain";
interface SettingsState {
theme: ThemeMode;
setTheme: (theme: ThemeMode) => void;
toggleTheme: () => void;
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set, get) => ({
theme: "system",
setTheme: (theme) => set({ theme }),
toggleTheme: () => {
const order: ThemeMode[] = ["light", "dark", "system"];
const idx = order.indexOf(get().theme);
set({ theme: order[(idx + 1) % order.length] });
},
}),
{ name: "hunjing-settings" },
),
);
/** toggleTheme 的下一态文案(供主题按钮展示当前操作意图) */
export const NEXT_THEME_LABEL: Record<ThemeMode, string> = {
light: "素绢",
dark: "夜铜",
system: "随系统",
};
/** 当前主题的显示名 */
export const CURRENT_THEME_LABEL: Record<ThemeMode, string> = {
light: "昼镜",
dark: "夜镜",
system: "系统",
};
/** 解析最终生效主题(system 需结合媒体查询) */
export function resolveTheme(mode: ThemeMode): "light" | "dark" {
if (mode === "system") {
return typeof window !== "undefined" &&
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
return mode;
}
+55
View File
@@ -0,0 +1,55 @@
import { create } from "zustand";
export type ToastType = "success" | "error" | "info" | "warning";
export interface Toast {
id: string;
type: ToastType;
title: string;
description?: string;
duration?: number; // ms, 默认 4000
}
interface ToastStore {
toasts: Toast[];
addToast: (toast: Omit<Toast, "id">) => void;
removeToast: (id: string) => void;
clearToasts: () => void;
}
let toastCounter = 0;
export const useToastStore = create<ToastStore>((set) => ({
toasts: [],
addToast: (toast) => {
const id = `toast-${++toastCounter}`;
set((state) => ({
toasts: [...state.toasts, { ...toast, id }],
}));
const duration = toast.duration ?? 4000;
if (duration > 0) {
setTimeout(() => {
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}));
}, duration);
}
},
removeToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
})),
clearToasts: () => set({ toasts: [] }),
}));
/** 便捷调用 */
export const toast = {
success: (title: string, description?: string) =>
useToastStore.getState().addToast({ type: "success", title, description }),
error: (title: string, description?: string) =>
useToastStore.getState().addToast({ type: "error", title, description }),
info: (title: string, description?: string) =>
useToastStore.getState().addToast({ type: "info", title, description }),
warning: (title: string, description?: string) =>
useToastStore.getState().addToast({ type: "warning", title, description }),
};