初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -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;
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
Reference in new issue
Block a user