初始化提交

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

+158
View File
@@ -0,0 +1,158 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiDelete: vi.fn(),
apiGet: vi.fn(),
apiPatch: vi.fn(),
apiPost: vi.fn(),
}));
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
import {
createAgent,
createGoal,
createWorldEntity,
deleteGoal,
deletePerceptionChannel,
deletePlan,
getAgentRoster,
getAuditTimeline,
getGoals,
getPendingApprovals,
getPerceptionChannels,
getPivotsHealth,
getPlans,
getTrace,
listTraces,
updateGoal,
updateWorldEntity,
} from "./admin";
describe("admin api — 聚合视图", () => {
it("getPivotsHealth GET /admin/pivots/health", async () => {
vi.mocked(apiGet).mockResolvedValue({ overall: "steady" });
await getPivotsHealth();
expect(apiGet).toHaveBeenCalledWith("/admin/pivots/health");
});
it("getPendingApprovals GET /admin/safety/pending", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await getPendingApprovals();
expect(apiGet).toHaveBeenCalledWith("/admin/safety/pending");
});
it("getAuditTimeline GET /admin/audit/timeline 支持枢过滤", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await getAuditTimeline("百工枢");
expect(apiGet).toHaveBeenCalledWith("/admin/audit/timeline", {
pivot: "百工枢",
});
});
it("getTrace GET /admin/traces/:id", async () => {
vi.mocked(apiGet).mockResolvedValue({ trace_id: "trace_1" });
await getTrace("trace_1");
expect(apiGet).toHaveBeenCalledWith("/admin/traces/trace_1");
});
it("listTraces GET /admin/traces 支持 limit", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await listTraces(10);
expect(apiGet).toHaveBeenCalledWith("/admin/traces", { limit: 10 });
});
});
describe("admin api — 太一枢目标", () => {
it("getGoals GET /admin/goals", async () => {
vi.mocked(apiGet).mockResolvedValue([]);
await getGoals();
expect(apiGet).toHaveBeenCalledWith("/admin/goals");
});
it("updateGoal PATCH /admin/goals/:id", async () => {
vi.mocked(apiPatch).mockResolvedValue({ id: "goal_1" });
await updateGoal("goal_1", { title: "新目标" });
expect(apiPatch).toHaveBeenCalledWith("/admin/goals/goal_1", {
title: "新目标",
});
});
it("createGoal POST /admin/goals", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "goal_1" });
await createGoal({ title: "新目标", level: "task", priority: 5 });
expect(apiPost).toHaveBeenCalledWith("/admin/goals", {
title: "新目标",
level: "task",
priority: 5,
});
});
it("deleteGoal DELETE /admin/goals/:id", async () => {
vi.mocked(apiDelete).mockResolvedValue(undefined);
await deleteGoal("goal_1");
expect(apiDelete).toHaveBeenCalledWith("/admin/goals/goal_1");
});
});
describe("admin api — 治理 CRUD", () => {
it("createWorldEntity POST /admin/world/entities", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "we_1" });
await createWorldEntity({ type: "concept", name: "测试" });
expect(apiPost).toHaveBeenCalledWith("/admin/world/entities", {
type: "concept",
name: "测试",
});
});
it("updateWorldEntity PATCH /admin/world/entities/:id", async () => {
vi.mocked(apiPatch).mockResolvedValue({ id: "we_1" });
await updateWorldEntity("we_1", { name: "新名字" });
expect(apiPatch).toHaveBeenCalledWith("/admin/world/entities/we_1", {
name: "新名字",
});
});
it("getPlans GET /admin/plans", async () => {
vi.mocked(apiGet).mockResolvedValue([]);
await getPlans();
expect(apiGet).toHaveBeenCalledWith("/admin/plans");
});
it("getAgentRoster GET /admin/agents/roster", async () => {
vi.mocked(apiGet).mockResolvedValue([]);
await getAgentRoster();
expect(apiGet).toHaveBeenCalledWith("/admin/agents/roster");
});
it("getPerceptionChannels GET /admin/perception/channels", async () => {
vi.mocked(apiGet).mockResolvedValue([]);
await getPerceptionChannels();
expect(apiGet).toHaveBeenCalledWith("/admin/perception/channels");
});
it("deletePlan DELETE /admin/plans/:id", async () => {
vi.mocked(apiDelete).mockResolvedValue(undefined);
await deletePlan("plan_1");
expect(apiDelete).toHaveBeenCalledWith("/admin/plans/plan_1");
});
it("createAgent POST /admin/agents/roster", async () => {
vi.mocked(apiPost).mockResolvedValue({ role: "百工枢" });
await createAgent({
role: "百工枢",
name: "百工枢代理",
responsibility: "负责百工枢日常运营",
});
expect(apiPost).toHaveBeenCalledWith("/admin/agents/roster", {
role: "百工枢",
name: "百工枢代理",
responsibility: "负责百工枢日常运营",
});
});
it("deletePerceptionChannel DELETE /admin/perception/channels/:channel", async () => {
vi.mocked(apiDelete).mockResolvedValue(undefined);
await deletePerceptionChannel("web");
expect(apiDelete).toHaveBeenCalledWith("/admin/perception/channels/web");
});
});
+259
View File
@@ -0,0 +1,259 @@
/**
* 管理 API Client · 司命台(对应 后端生成的 OpenAPI 契约 §19)。
*
* 聚合九枢治理视图。后端已实现(迁移 003 + services/governance + api/v1/admin):
* §19.1–19.3 聚合 + §19.4 五组治理 CRUD 真持久化,写操作记审计并入起居注。
* Mock 模式(NEXT_PUBLIC_ENABLE_MOCK=true)下由 lib/mock 提供示例数据。
*/
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
import type {
AdminCharacterTemplate,
AdminCreateTemplateBody,
AdminUpdateTemplateBody,
AgentRosterEntry,
AuditEntry,
CreateAgentRequest,
CreateGoalRequest,
CreatePerceptionChannelRequest,
CreatePlanRequest,
CreateWorldEntityRequest,
GoalNode,
Paginated,
PendingApproval,
PerceptionChannel,
PivotHealthReport,
PlanSummary,
TraceDetail,
TraceListResponse,
UpdateAgentRequest,
UpdateGoalRequest,
UpdatePerceptionChannelRequest,
UpdatePlanRequest,
UpdateWorldEntityRequest,
WorldEntity,
} from "@/types/api";
/** 九枢健康度聚合(API §19.1),供司命台星盘总览 */
export function getPivotsHealth() {
return apiGet<PivotHealthReport>("/admin/pivots/health");
}
/** 朱批待办(API §19.2),司命枢操作墙 */
export function getPendingApprovals() {
return apiGet<Paginated<PendingApproval>>("/admin/safety/pending");
}
/** 起居注审计时间河(API §19.3),可选按枢过滤 */
export function getAuditTimeline(pivot?: string) {
return apiGet<Paginated<AuditEntry>>(
"/admin/audit/timeline",
pivot ? { pivot } : undefined,
);
}
/** 链路追踪(API §19.5,GET /admin/traces/{trace_id}):聚合运行事件 / 安全审计 / 请求幂等为升序时间线 */
export function getTrace(traceId: string) {
return apiGet<TraceDetail>(`/admin/traces/${encodeURIComponent(traceId)}`);
}
/** 链路追踪列表(API §19.5,GET /admin/traces):最近活动的 trace 概览(按 last_seen 倒序) */
export function listTraces(limit = 50) {
return apiGet<TraceListResponse>("/admin/traces", { limit });
}
// —— 后续阶段治理端点(API §19.4 预留)——
// 后端尚未实现;Mock 模式下命中路由表返回示例数据,治理写操作后端须记审计。
/** 太一枢 · 目标层级树(GET /admin/goals) */
export function getGoals() {
return apiGet<GoalNode[]>("/admin/goals");
}
/** 太一枢 · 按人物查询目标(GET /admin/goals?character_id=xxx) */
export function getGoalsByCharacter(characterId: string) {
return apiGet<GoalNode[]>("/admin/goals", { character_id: characterId });
}
/** 太一枢 · 编辑目标(PATCH /admin/goals/{id}) */
export function updateGoal(goalId: string, body: UpdateGoalRequest) {
return apiPatch<GoalNode>(`/admin/goals/${goalId}`, body);
}
/** 烛照枢 · 感知通道授权(GET /admin/perception/channels) */
export function getPerceptionChannels() {
return apiGet<PerceptionChannel[]>("/admin/perception/channels");
}
/** 烛照枢 · 编辑通道授权(PATCH /admin/perception/channels/{channel}) */
export function updatePerceptionChannel(
channel: string,
body: UpdatePerceptionChannelRequest,
) {
return apiPatch<PerceptionChannel>(
`/admin/perception/channels/${channel}`,
body,
);
}
/** 河图枢 · 世界实体(GET /admin/world/entities) */
export function getWorldEntities() {
return apiGet<WorldEntity[]>("/admin/world/entities");
}
/** 河图枢 · 编辑实体(PATCH /admin/world/entities/{id}) */
export function updateWorldEntity(
entityId: string,
body: UpdateWorldEntityRequest,
) {
return apiPatch<WorldEntity>(`/admin/world/entities/${entityId}`, body);
}
/** 洛书枢 · 计划列表(GET /admin/plans) */
export function getPlans() {
return apiGet<PlanSummary[]>("/admin/plans");
}
/** 洛书枢 · 编辑计划(PATCH /admin/plans/{id}) */
export function updatePlan(planId: string, body: UpdatePlanRequest) {
return apiPatch<PlanSummary>(`/admin/plans/${planId}`, body);
}
/** 天工阁枢 · 九角色在岗(GET /admin/agents/roster) */
export function getAgentRoster() {
return apiGet<AgentRosterEntry[]>("/admin/agents/roster");
}
/** 天工阁枢 · 编辑角色启用(PATCH /admin/agents/roster/{role}) */
export function updateAgent(role: string, body: UpdateAgentRequest) {
return apiPatch<AgentRosterEntry>(`/admin/agents/roster/${role}`, body);
}
// —— §19.8 人物模板管理(管理员专用 CRUD) ——
/** 列出所有系统模板(GET /admin/characters/templates) */
export function listCharacterTemplates() {
return apiGet<AdminCharacterTemplate[]>("/admin/characters/templates");
}
/** 获取单个系统模板(GET /admin/characters/templates/{key}) */
export function getCharacterTemplate(key: string) {
return apiGet<AdminCharacterTemplate>(
`/admin/characters/templates/${encodeURIComponent(key)}`,
);
}
/** 创建系统模板(POST /admin/characters/templates) */
export function createCharacterTemplate(body: AdminCreateTemplateBody) {
return apiPost<AdminCharacterTemplate>("/admin/characters/templates", body);
}
/** 更新系统模板(PATCH /admin/characters/templates/{key}) */
export function updateCharacterTemplate(
key: string,
body: AdminUpdateTemplateBody,
) {
return apiPatch<AdminCharacterTemplate>(
`/admin/characters/templates/${encodeURIComponent(key)}`,
body,
);
}
/** 删除系统模板(DELETE /admin/characters/templates/{key}) */
export function deleteCharacterTemplate(key: string) {
return apiDelete<void>(
`/admin/characters/templates/${encodeURIComponent(key)}`,
);
}
// —— 开放集合 CRUD(增/删):目标 / 世界实体 / 计划 ——
// 与息壤记忆(§10 CRUD)一致;封闭枚举(烛照通道 / 天工阁角色)不提供。
/** 太一枢 · 新增目标(POST /admin/goals) */
export function createGoal(body: CreateGoalRequest) {
return apiPost<GoalNode>("/admin/goals", body);
}
/** 太一枢 · 删除目标(DELETE /admin/goals/{id}) */
export function deleteGoal(goalId: string) {
return apiDelete<void>(`/admin/goals/${goalId}`);
}
/** 河图枢 · 新增世界实体(POST /admin/world/entities) */
export function createWorldEntity(body: CreateWorldEntityRequest) {
return apiPost<WorldEntity>("/admin/world/entities", body);
}
/** 河图枢 · 删除世界实体(DELETE /admin/world/entities/{id}) */
export function deleteWorldEntity(entityId: string) {
return apiDelete<void>(`/admin/world/entities/${entityId}`);
}
/** 洛书枢 · 新增计划(POST /admin/plans) */
export function createPlan(body: CreatePlanRequest) {
return apiPost<PlanSummary>("/admin/plans", body);
}
/** 洛书枢 · 删除计划(DELETE /admin/plans/{id}) */
export function deletePlan(planId: string) {
return apiDelete<void>(`/admin/plans/${planId}`);
}
/** 烛照枢 · 新增感知通道(POST /admin/perception/channels) */
export function createPerceptionChannel(body: CreatePerceptionChannelRequest) {
return apiPost<PerceptionChannel>("/admin/perception/channels", body);
}
/** 烛照枢 · 删除感知通道(DELETE /admin/perception/channels/{channel}) */
export function deletePerceptionChannel(channel: string) {
return apiDelete<void>(`/admin/perception/channels/${channel}`);
}
/** 天工阁枢 · 新增角色(POST /admin/agents/roster) */
export function createAgent(body: CreateAgentRequest) {
return apiPost<AgentRosterEntry>("/admin/agents/roster", body);
}
/** 天工阁枢 · 删除角色(DELETE /admin/agents/roster/{role}) */
export function deleteAgent(role: string) {
return apiDelete<void>(`/admin/agents/roster/${role}`);
}
// —— §19.6 用户管理 ——
export interface AdminUser {
id: string;
username: string;
email: string;
is_active: boolean;
is_admin: boolean;
created_at: string;
updated_at: string | null;
}
export interface AdminCreateUserBody {
username: string;
email: string;
password: string;
is_admin?: boolean;
}
export interface AdminUpdateUserBody {
is_active?: boolean;
is_admin?: boolean;
}
/** 列出所有用户(GET /admin/users) */
export function listUsers() {
return apiGet<AdminUser[]>("/admin/users");
}
/** 创建用户(POST /admin/users) */
export function createUser(body: AdminCreateUserBody) {
return apiPost<AdminUser>("/admin/users", body);
}
/** 更新用户(PATCH /admin/users/{userId}) */
export function updateUser(userId: string, body: AdminUpdateUserBody) {
return apiPatch<AdminUser>(`/admin/users/${userId}`, body);
}
+97
View File
@@ -0,0 +1,97 @@
/**
* 音频 API Client — TTS(文本转语音) / ASR(语音转文本)。
*
* TTS 端点返回原始音频字节,不走标准 JSON client 封装。
* ASR 端点接收 multipart/form-data 上传音频,走 OpenAI Whisper 兼容的
* ``POST /v1/audio/transcriptions``,同样绕过 JSON client 直连 fetch。
*/
import { API_PROXY_ROOT } from "@/lib/config";
/** TTS 合成选项 */
export interface TTSOptions {
text: string;
voice?: string;
format?: "wav" | "mp3" | "opus" | "flac";
}
/**
* 调用后端 TTS 接口合成语音,返回音频 Blob。
*
* @returns audio/mpeg 或 audio/wav 的 Blob(基于请求的 format)。
*/
export async function synthesizeSpeech(
options: TTSOptions,
signal?: AbortSignal,
): Promise<Blob> {
const { text, voice = "default", format = "wav" } = options;
const response = await fetch(`${API_PROXY_ROOT}/audio/tts`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text, voice, format }),
signal,
});
if (!response.ok) {
const body = await response.text().catch(() => "TTS 合成失败");
throw new Error(
`TTS 请求失败(HTTP ${response.status}):${body.slice(0, 200)}`,
);
}
return response.blob();
}
/** ASR 转写结果(OpenAI Whisper 兼容响应) */
export interface ASRTranscriptionResult {
text: string;
}
/**
* 调用后端 ASR 接口转写音频,返回识别文本。
*
* 走 OpenAI Whisper 兼容端点 ``POST /v1/audio/transcriptions``,以
* multipart/form-data 上传音频文件。经 BFF ``/api/proxy`` 同源代理转发,
* content-type(含 boundary)由 proxy 白名单透传给后端。
*
* @param blob 音频数据(wav / mp3 / webm / m4a / ogg 等)。
* @param filename 上传文件名,扩展名决定后端格式校验。
* @param language 可选语言提示(ISO-639-1:zh/en/ja/ko 等)。
* @param signal 可选 AbortSignal,用于取消请求。
* @param model 可选,指定后端 ASR 模型逻辑名(如 ``funasr-sensevoice``),
* 省略时后端取注册表中第一个活跃 ASR 模型。
*/
export async function transcribeAudio(
blob: Blob,
filename: string,
language?: string,
signal?: AbortSignal,
model?: string,
): Promise<ASRTranscriptionResult> {
const form = new FormData();
form.append("file", blob, filename);
if (language) form.append("language", language);
if (model) form.append("model", model);
const response = await fetch(`${API_PROXY_ROOT}/audio/transcriptions`, {
method: "POST",
body: form,
signal,
});
if (!response.ok) {
const body = await response.text().catch(() => "语音转写失败");
let message = body.slice(0, 200);
try {
const parsed = JSON.parse(body);
if (parsed?.error?.message) message = parsed.error.message;
else if (parsed?.detail?.message) message = parsed.detail.message;
} catch {
/* 保留原始 body 文本 */
}
throw new Error(`语音转写失败(HTTP ${response.status}):${message}`);
}
return (await response.json()) as ASRTranscriptionResult;
}
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiGet: vi.fn(),
apiPatch: vi.fn(),
apiPost: vi.fn(),
}));
import { apiGet, apiPatch, apiPost } from "./client";
import {
activateCharacter,
archiveCharacter,
createCharacter,
getActiveCharacter,
getCharacter,
listCharacters,
listCharacterTemplates,
pauseCharacter,
resumeCharacter,
updateModelPolicy,
updateProfile,
} from "./characters";
describe("characters api", () => {
it("getActiveCharacter GET /characters/active", async () => {
vi.mocked(apiGet).mockResolvedValue({ id: "char_1" });
await getActiveCharacter();
expect(apiGet).toHaveBeenCalledWith("/characters/active");
});
it("getCharacter GET /characters/:id", async () => {
vi.mocked(apiGet).mockResolvedValue({ id: "char_1" });
await getCharacter("char_1");
expect(apiGet).toHaveBeenCalledWith("/characters/char_1");
});
it("listCharacters GET 支持分页与状态过滤", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await listCharacters({ page: 1, page_size: 10, status: "active" });
expect(apiGet).toHaveBeenCalledWith("/characters", {
page: 1,
page_size: 10,
status: "active",
});
});
it("activateCharacter POST /characters/:id/activate", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "char_1" });
await activateCharacter("char_1");
expect(apiPost).toHaveBeenCalledWith("/characters/char_1/activate");
});
it("createCharacter POST 支持 idempotencyKey", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "char_new" });
await createCharacter({ name: "新角色", template_key: "tpl_1" }, "idem_1");
expect(apiPost).toHaveBeenCalledWith(
"/characters",
{ name: "新角色", template_key: "tpl_1" },
{ idempotencyKey: "idem_1" },
);
});
it("listCharacterTemplates GET /characters/templates", async () => {
vi.mocked(apiGet).mockResolvedValue([]);
await listCharacterTemplates();
expect(apiGet).toHaveBeenCalledWith("/characters/templates");
});
it("updateProfile PATCH /characters/:id/profile", async () => {
vi.mocked(apiPatch).mockResolvedValue({ id: "char_1" });
await updateProfile("char_1", { display_name: "新名字" });
expect(apiPatch).toHaveBeenCalledWith("/characters/char_1/profile", {
display_name: "新名字",
});
});
it("updateModelPolicy PATCH /characters/:id/model-policy", async () => {
vi.mocked(apiPatch).mockResolvedValue({ id: "char_1" });
await updateModelPolicy("char_1", { default_chat_model_hint: "gpt-4" });
expect(apiPatch).toHaveBeenCalledWith("/characters/char_1/model-policy", {
default_chat_model_hint: "gpt-4",
});
});
it("pauseCharacter POST /characters/:id/pause", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "char_1" });
await pauseCharacter("char_1");
expect(apiPost).toHaveBeenCalledWith("/characters/char_1/pause");
});
it("resumeCharacter POST /characters/:id/resume", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "char_1" });
await resumeCharacter("char_1");
expect(apiPost).toHaveBeenCalledWith("/characters/char_1/resume");
});
it("archiveCharacter POST /characters/:id/archive", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "char_1" });
await archiveCharacter("char_1");
expect(apiPost).toHaveBeenCalledWith("/characters/char_1/archive");
});
});
+117
View File
@@ -0,0 +1,117 @@
/**
* 人物 API Client(对应 后端生成的 OpenAPI 契约 §6)。
* 当前开发周期主用 GET /characters/active;列表与生命周期动作为预留增强。
*/
import { apiGet, apiPatch, apiPost, apiRequest } from "./client";
import type {
ActivateCharacterResponse,
Character,
CharacterListItem,
CharacterTemplateSummary,
CreateCharacterRequest,
ImportCardResponse,
Paginated,
UpdateModelPolicyRequest,
UpdateProfileRequest,
} from "@/types/api";
/** 获取当前激活人物(当前开发周期默认人物入口) */
export function getActiveCharacter(): Promise<Character> {
return apiGet<Character>("/characters/active");
}
/** 获取单个人物 */
export function getCharacter(characterId: string): Promise<Character> {
return apiGet<Character>(`/characters/${characterId}`);
}
/** 列出人物(预留增强;列表项为精简结构 CharacterListItem) */
export function listCharacters(params?: {
page?: number;
page_size?: number;
status?: string;
}): Promise<Paginated<CharacterListItem>> {
return apiGet<Paginated<CharacterListItem>>("/characters", params);
}
/** 设置当前激活人物 */
export function activateCharacter(
characterId: string,
): Promise<ActivateCharacterResponse> {
return apiPost<ActivateCharacterResponse>(
`/characters/${characterId}/activate`,
);
}
/** 创建人物(POST /characters,支持幂等键防重复提交) */
export function createCharacter(
body: CreateCharacterRequest,
idempotencyKey?: string,
): Promise<Character> {
return apiPost<Character>("/characters", body, { idempotencyKey });
}
/** 列出系统模板(GET /characters/templates,供创建人物时选择) */
export function listCharacterTemplates(): Promise<CharacterTemplateSummary[]> {
return apiGet<CharacterTemplateSummary[]>("/characters/templates");
}
/** 更新人物档案(PATCH /characters/{id}/profile,手动编辑视为用户确认) */
export function updateProfile(
characterId: string,
body: UpdateProfileRequest,
): Promise<Character> {
return apiPatch<Character>(`/characters/${characterId}/profile`, body);
}
/** 更新人物模型策略(PATCH /characters/{id}/model-policy) */
export function updateModelPolicy(
characterId: string,
body: UpdateModelPolicyRequest,
): Promise<Character> {
return apiPatch<Character>(`/characters/${characterId}/model-policy`, body);
}
/** 暂停人物(POST /characters/{id}/pause) */
export function pauseCharacter(characterId: string): Promise<Character> {
return apiPost<Character>(`/characters/${characterId}/pause`);
}
/** 恢复人物(POST /characters/{id}/resume) */
export function resumeCharacter(characterId: string): Promise<Character> {
return apiPost<Character>(`/characters/${characterId}/resume`);
}
/** 归档人物(POST /characters/{id}/archive) */
export function archiveCharacter(characterId: string): Promise<Character> {
return apiPost<Character>(`/characters/${characterId}/archive`);
}
/**
* 导入酒馆角色卡(POST /characters/import-card,multipart)。
* 支持 .png / .json / .charx,V1/V2/V3 自动归一化,建立人物 + 世谱。
*/
export async function importCharacterCard(
file: File,
): Promise<ImportCardResponse> {
const form = new FormData();
form.append("file", file);
return apiRequest<ImportCardResponse>("/characters/import-card", {
method: "POST",
body: form,
});
}
/**
* 导出人物为酒馆 V3 卡静态快照(GET /characters/{id}/export-card)。
* 返回卡数据 dict;前端可 JSON.stringify 后触发下载。
*/
export function exportCharacterCard(
characterId: string,
): Promise<Record<string, unknown>> {
return apiRequest<Record<string, unknown>>(
`/characters/${characterId}/export-card`,
{ method: "GET" },
);
}
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiDelete: vi.fn(),
apiGet: vi.fn(),
apiPatch: vi.fn(),
apiPost: vi.fn(),
}));
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
import {
createSession,
deleteSession,
listMessages,
listSessions,
sendMessage,
updateSession,
} from "./chat";
describe("chat api", () => {
it("createSession POST /chat/sessions", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "session_1" });
await createSession({ title: "新会话" });
expect(apiPost).toHaveBeenCalledWith("/chat/sessions", { title: "新会话" });
});
it("sendMessage POST 会话消息带 modality & idempotencyKey", async () => {
vi.mocked(apiPost).mockResolvedValue({ run_id: "run_1" });
await sendMessage("session_1", { content: "你好" }, "idem_1");
expect(apiPost).toHaveBeenCalledWith(
"/chat/sessions/session_1/messages",
{ content: "你好", modality: "text" },
{ idempotencyKey: "idem_1" },
);
});
it("listMessages GET 分页消息", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await listMessages("session_1", { page: 1, page_size: 20 });
expect(apiGet).toHaveBeenCalledWith("/chat/sessions/session_1/messages", {
page: 1,
page_size: 20,
});
});
it("listSessions GET 支持 character_id 过滤", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await listSessions({ character_id: "char_1", status: "active" });
expect(apiGet).toHaveBeenCalledWith("/chat/sessions", {
character_id: "char_1",
status: "active",
});
});
it("updateSession PATCH /chat/sessions/:id", async () => {
vi.mocked(apiPatch).mockResolvedValue({ id: "session_1" });
await updateSession("session_1", { title: "新标题" });
expect(apiPatch).toHaveBeenCalledWith("/chat/sessions/session_1", {
title: "新标题",
});
});
it("deleteSession DELETE /chat/sessions/:id", async () => {
vi.mocked(apiDelete).mockResolvedValue(undefined);
await deleteSession("session_1");
expect(apiDelete).toHaveBeenCalledWith("/chat/sessions/session_1");
});
});
+85
View File
@@ -0,0 +1,85 @@
/**
* 对话 API Client(对应 后端生成的 OpenAPI 契约 §7)。
* 创建会话、发送消息、拉取历史消息。
*/
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
import type {
ChatMessage,
ChatSession,
CreateSessionRequest,
Paginated,
SendMessageRequest,
SendMessageResponse,
UpdateSessionRequest,
} from "@/types/api";
/** 创建会话;character_id 省略时后端使用当前激活人物 */
export function createSession(body: CreateSessionRequest = {}) {
return apiPost<ChatSession>("/chat/sessions", body);
}
/** 获取单个会话详情 */
export function getSession(sessionId: string) {
return apiGet<ChatSession>(`/chat/sessions/${sessionId}`);
}
/**
* 发送文本消息。
* 使用 Idempotency-Key 防止重复提交产生重复 Run(API §5.6)。
*/
export function sendMessage(
sessionId: string,
body: SendMessageRequest,
idempotencyKey: string,
) {
return apiPost<SendMessageResponse>(
`/chat/sessions/${sessionId}/messages`,
{ ...body, modality: body.modality ?? "text" },
{ idempotencyKey },
);
}
/** 拉取会话历史消息 */
export function listMessages(
sessionId: string,
params?: { page?: number; page_size?: number },
) {
return apiGet<Paginated<ChatMessage>>(
`/chat/sessions/${sessionId}/messages`,
params,
);
}
/** 会话列表(可选按人物 / 状态过滤) */
export function listSessions(params?: {
character_id?: string;
status?: ChatSession["status"];
}) {
return apiGet<Paginated<ChatSession>>("/chat/sessions", params);
}
/** 更新会话(重命名 / 归档 / 删除态切换) */
export function updateSession(sessionId: string, body: UpdateSessionRequest) {
return apiPatch<ChatSession>(`/chat/sessions/${sessionId}`, body);
}
/** 删除会话(软删除,status → deleted) */
export function deleteSession(sessionId: string) {
return apiDelete<void>(`/chat/sessions/${sessionId}`);
}
/** 选择/激活消息的某个历史版本。后端存储 active_version_idx,refresh 后返回 */
export function selectVersion(
sessionId: string,
messageId: string,
versionIdx: number,
) {
return apiPatch<{
message_id: string;
active_version_idx: number;
versions: import("@/types/api").MessageVersion[];
}>(`/chat/sessions/${sessionId}/messages/${messageId}/version`, {
version_idx: versionIdx,
});
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { buildQueryString } from "./client";
describe("buildQueryString", () => {
it("无参数或空对象返回空串", () => {
expect(buildQueryString()).toBe("");
expect(buildQueryString({})).toBe("");
});
it("过滤 undefined 与 null", () => {
expect(buildQueryString({ a: 1, b: undefined, c: null })).toBe("?a=1");
});
it("boolean 转为字符串", () => {
expect(buildQueryString({ active: true, closed: false })).toBe(
"?active=true&closed=false",
);
});
it("数字转为字符串", () => {
expect(buildQueryString({ page: 2, size: 10 })).toBe("?page=2&size=10");
});
});
+179
View File
@@ -0,0 +1,179 @@
/**
* API Client 基础封装。
*
* - 所有业务请求统一经 BFF /api/proxy 代理(同源,浏览器自动携带 httpOnly cookie,
* BFF 在服务端注入 Authorization)。客户端 JS 不接触 token。
* - 字段以 snake_case 为边界,不在组件层散落映射。
* - 统一解析标准错误体(契约字典 §15.8),抛出 ApiError。
* - 401 由 BFF 代理内部完成 refresh 重试;仍 401 则 dispatch auth:unauthorized,
* 由 auth-store 监听跳转登录。
* - SSE 不走此封装,见 lib/sse-client.ts(经 /api/sse)。
* - 仅客户端使用;SSR 数据获取请在 Server Component 内直接 fetch 后端 + cookies()。
* - Mock 模式(NEXT_PUBLIC_ENABLE_MOCK=true)下命中路由表即返回 Mock 数据,
* 不发起真实请求;详见 lib/mock/index.ts。
*/
import { API_PROXY_ROOT } from "@/lib/config";
import { MOCK_LATENCY_MS, mockDelay, resolveMock, USE_MOCK } from "@/lib/mock";
import type { ApiErrorBody } from "@/types/api";
import type { ErrorCode } from "@/types/domain";
/** 标准化 API 错误,承载后端返回的 code / trace_id / details */
export class ApiError extends Error {
readonly status: number;
readonly code: ErrorCode | "network_error" | "parse_error";
readonly traceId?: string;
readonly details?: Record<string, unknown>;
constructor(
status: number,
code: ErrorCode | "network_error" | "parse_error",
message: string,
traceId?: string,
details?: Record<string, unknown>,
) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
this.traceId = traceId;
this.details = details;
}
}
/** 序列化查询参数,过滤 undefined / null,boolean 转为字符串 */
export function buildQueryString(params?: Record<string, unknown>): string {
if (!params) return "";
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
search.append(key, String(value));
}
const qs = search.toString();
return qs ? `?${qs}` : "";
}
type FetchInit = RequestInit & {
/** JSON 请求体(自动序列化并设置 content-type) */
json?: unknown;
/** 查询参数 */
query?: Record<string, unknown>;
/** 幂等键(API §5.6),用于发送消息、创建导入任务等 */
idempotencyKey?: string;
};
/**
* 发起一次 API 请求并解析 JSON。
* 网络错误(后端不可达)归一为 network_error。
*/
export async function apiRequest<T>(
path: string,
init: FetchInit = {},
): Promise<T> {
const { json, query, idempotencyKey, headers, ...rest } = init;
const method = init.method ?? "GET";
// Mock 拦截:命中路由表即短路返回,未命中放行至真实请求
if (USE_MOCK) {
const result = resolveMock(method, path, query, json);
if (result) {
await mockDelay(MOCK_LATENCY_MS);
if (result.status === 204) return undefined as T;
if (result.status >= 400) {
throw new ApiError(
result.status,
"internal_error",
`(Mock)${method} ${path} 返回 ${result.status}`,
);
}
return result.data as T;
}
if (typeof console !== "undefined") {
console.warn(`[mock] 未覆盖端点,将尝试真实请求:${method} ${path}`);
}
}
const finalHeaders = new Headers(headers);
if (json !== undefined) {
finalHeaders.set("content-type", "application/json");
}
if (idempotencyKey) {
finalHeaders.set("Idempotency-Key", idempotencyKey);
}
let response: Response;
try {
response = await fetch(
`${API_PROXY_ROOT}${path}${buildQueryString(query)}`,
{
...rest,
headers: finalHeaders,
body: json !== undefined ? JSON.stringify(json) : rest.body,
// 列表/详情类数据需实时,统一不缓存
cache: rest.cache ?? "no-store",
},
);
} catch {
throw new ApiError(
0,
"network_error",
"无法连接后端服务,请确认后端已启动。",
);
}
if (response.status === 204) {
return undefined as T;
}
const text = await response.text();
const payload: unknown = text ? safeJsonParse(text) : null;
if (!response.ok) {
// 401:BFF 代理已尝试 refresh 仍失败 → 会话失效,通知 auth-store 跳登录
if (response.status === 401) {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:unauthorized"));
}
throw new ApiError(401, "unauthorized", "登录已过期,请重新登录。");
}
const body = payload as ApiErrorBody | null;
const err = body?.error;
throw new ApiError(
response.status,
err?.code ?? "internal_error",
err?.message ?? `请求失败(HTTP ${response.status})`,
err?.trace_id,
err?.details,
);
}
return payload as T;
}
/** 安全 JSON 解析,失败返回 undefined */
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
/** GET 便捷方法 */
export const apiGet = <T>(path: string, query?: Record<string, unknown>) =>
apiRequest<T>(path, { method: "GET", query });
/** POST 便捷方法 */
export const apiPost = <T>(
path: string,
json?: unknown,
extra?: Omit<FetchInit, "method" | "json" | "query">,
) => apiRequest<T>(path, { method: "POST", json, ...extra });
/** PATCH 便捷方法 */
export const apiPatch = <T>(path: string, json?: unknown) =>
apiRequest<T>(path, { method: "PATCH", json });
/** DELETE 便捷方法 */
export const apiDelete = <T>(path: string) =>
apiRequest<T>(path, { method: "DELETE" });
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
}));
import { apiGet, apiPost } from "./client";
import {
approveSubgoal,
deferSubgoal,
listCuriosities,
listSubgoals,
rejectSubgoal,
} from "./drive";
describe("drive api", () => {
it("listCuriosities 构造 path 与 query", async () => {
vi.mocked(apiGet).mockResolvedValue({
items: [],
page: 1,
page_size: 20,
total: 0,
});
await listCuriosities("char_1", { source: "memory_gap" });
expect(apiGet).toHaveBeenCalledWith("/characters/char_1/curiosities", {
source: "memory_gap",
});
});
it("listSubgoals 构造 path 与 status query", async () => {
vi.mocked(apiGet).mockResolvedValue({
items: [],
page: 1,
page_size: 20,
total: 0,
});
await listSubgoals("char_1", { status: "candidate" });
expect(apiGet).toHaveBeenCalledWith("/characters/char_1/subgoals", {
status: "candidate",
});
});
it("approve/defer/reject 各自 POST 正确 path", async () => {
vi.mocked(apiPost).mockResolvedValue({ status: "approved" } as never);
await approveSubgoal("subgoal_1");
expect(apiPost).toHaveBeenCalledWith("/subgoals/subgoal_1/approve");
vi.mocked(apiPost).mockResolvedValue({ status: "deferred" } as never);
await deferSubgoal("subgoal_1");
expect(apiPost).toHaveBeenCalledWith("/subgoals/subgoal_1/defer");
vi.mocked(apiPost).mockResolvedValue({ status: "rejected" } as never);
await rejectSubgoal("subgoal_1");
expect(apiPost).toHaveBeenCalledWith("/subgoals/subgoal_1/reject");
});
});
+48
View File
@@ -0,0 +1,48 @@
/**
* 内驱力 API Client(对应 docs/domain/core-domains.md §5)。
* 大周天生成的好奇问题与探索性次生目标;次生目标 approve/defer/reject 仅标记不执行。
*/
import { apiGet, apiPost } from "./client";
import type {
CuriosityQuestion,
ExploratorySubgoal,
Paginated,
} from "@/types/api";
/** 列出好奇问题(按 source 过滤) */
export function listCuriosities(
characterId: string,
params?: { source?: string; page?: number; page_size?: number },
) {
return apiGet<Paginated<CuriosityQuestion>>(
`/characters/${characterId}/curiosities`,
params,
);
}
/** 列出探索性次生目标(按 status 过滤) */
export function listSubgoals(
characterId: string,
params?: { status?: string; page?: number; page_size?: number },
) {
return apiGet<Paginated<ExploratorySubgoal>>(
`/characters/${characterId}/subgoals`,
params,
);
}
/** 通过次生目标(status → approved,不自动执行) */
export function approveSubgoal(subgoalId: string) {
return apiPost<ExploratorySubgoal>(`/subgoals/${subgoalId}/approve`);
}
/** 延后次生目标(status → deferred) */
export function deferSubgoal(subgoalId: string) {
return apiPost<ExploratorySubgoal>(`/subgoals/${subgoalId}/defer`);
}
/** 驳回次生目标(status → rejected) */
export function rejectSubgoal(subgoalId: string) {
return apiPost<ExploratorySubgoal>(`/subgoals/${subgoalId}/reject`);
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
}));
import { apiGet, apiPost } from "./client";
import { approveCandidate, listCandidates, rejectCandidate } from "./evolution";
describe("evolution api", () => {
it("listCandidates 构造人物级列表 path 与 query", async () => {
vi.mocked(apiGet).mockResolvedValue({
items: [],
page: 1,
page_size: 20,
total: 0,
});
await listCandidates("char_1", { status: "pending", type: "procedural" });
expect(apiGet).toHaveBeenCalledWith(
"/characters/char_1/evolution-candidates",
{ status: "pending", type: "procedural" },
);
});
it("approveCandidate POST 到 approve 端点", async () => {
vi.mocked(apiPost).mockResolvedValue({ status: "applied" } as never);
await approveCandidate("evo_1");
expect(apiPost).toHaveBeenCalledWith("/evolution-candidates/evo_1/approve");
});
it("rejectCandidate POST 到 reject 端点", async () => {
vi.mocked(apiPost).mockResolvedValue({ status: "rejected" } as never);
await rejectCandidate("evo_1");
expect(apiPost).toHaveBeenCalledWith("/evolution-candidates/evo_1/reject");
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* 演化候选 API Client(对应 docs/domain/core-domains.md §7)。
* 大周天生成的演化候选审核闭环:列表 / approve / reject。
*/
import { apiGet, apiPost } from "./client";
import type { EvolutionCandidate, Paginated } from "@/types/api";
/** 列出某人物的演化候选(默认按 created_at 倒序) */
export function listCandidates(
characterId: string,
params?: {
status?: string;
type?: string;
page?: number;
page_size?: number;
},
) {
return apiGet<Paginated<EvolutionCandidate>>(
`/characters/${characterId}/evolution-candidates`,
params,
);
}
/** 审核通过:派生到下游(人格版本 / 程序记忆),status → applied */
export function approveCandidate(candidateId: string) {
return apiPost<EvolutionCandidate>(
`/evolution-candidates/${candidateId}/approve`,
);
}
/** 驳回:status → rejected,不派生下游 */
export function rejectCandidate(candidateId: string) {
return apiPost<EvolutionCandidate>(
`/evolution-candidates/${candidateId}/reject`,
);
}
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
}));
import { apiGet, apiPost } from "./client";
import {
approveMemoryCandidate,
approveWorldEntity,
listMemoryCandidates,
listWorldEntityProposals,
rejectMemoryCandidate,
rejectWorldEntity,
} from "./extraction";
describe("extraction api", () => {
it("listMemoryCandidates GET /characters/:id/memory-candidates", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await listMemoryCandidates("char_1", { source: "cycle" });
expect(apiGet).toHaveBeenCalledWith(
"/characters/char_1/memory-candidates",
{ source: "cycle" },
);
});
it("approveMemoryCandidate POST /memory-candidates/:id/approve", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "mc_1" });
await approveMemoryCandidate("mc_1");
expect(apiPost).toHaveBeenCalledWith("/memory-candidates/mc_1/approve");
});
it("rejectMemoryCandidate POST /memory-candidates/:id/reject", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "mc_1" });
await rejectMemoryCandidate("mc_1");
expect(apiPost).toHaveBeenCalledWith("/memory-candidates/mc_1/reject");
});
it("listWorldEntityProposals GET world/entity-proposals", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await listWorldEntityProposals("char_1", { status: "pending" });
expect(apiGet).toHaveBeenCalledWith(
"/characters/char_1/world/entity-proposals",
{ status: "pending" },
);
});
it("approveWorldEntity POST /world/entities/:id/approve", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "we_1" });
await approveWorldEntity("we_1");
expect(apiPost).toHaveBeenCalledWith("/world/entities/we_1/approve");
});
it("rejectWorldEntity POST /world/entities/:id/reject", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "we_1" });
await rejectWorldEntity("we_1");
expect(apiPost).toHaveBeenCalledWith("/world/entities/we_1/reject");
});
});
+59
View File
@@ -0,0 +1,59 @@
/**
* 候选审核 API(跨来源统一入口)。
* 记忆候选(source 区分 cycle 大周天 / import 资料导入)与世界实体候选的单独 approve/reject。
* 与 importsApi 的批量审核互补:本模块为跨来源的统一待审队列,逐条裁定。
*/
import { apiGet, apiPost } from "./client";
import type { MemoryCandidate, Paginated, WorldEntity } from "@/types/api";
/** 列出记忆候选(GET /characters/{id}/memory-candidates,source 过滤来源) */
export function listMemoryCandidates(
characterId: string,
params?: {
source?: "cycle" | "import";
status?: string;
page?: number;
page_size?: number;
},
): Promise<Paginated<MemoryCandidate>> {
return apiGet<Paginated<MemoryCandidate>>(
`/characters/${characterId}/memory-candidates`,
params,
);
}
/** 通过记忆候选(POST /memory-candidates/{id}/approve) */
export function approveMemoryCandidate(
candidateId: string,
): Promise<MemoryCandidate> {
return apiPost<MemoryCandidate>(`/memory-candidates/${candidateId}/approve`);
}
/** 驳回记忆候选(POST /memory-candidates/{id}/reject) */
export function rejectMemoryCandidate(
candidateId: string,
): Promise<MemoryCandidate> {
return apiPost<MemoryCandidate>(`/memory-candidates/${candidateId}/reject`);
}
/** 列出世界实体候选(GET /characters/{id}/world/entity-proposals,默认 pending) */
export function listWorldEntityProposals(
characterId: string,
params?: { status?: string; page?: number; page_size?: number },
): Promise<Paginated<WorldEntity>> {
return apiGet<Paginated<WorldEntity>>(
`/characters/${characterId}/world/entity-proposals`,
params,
);
}
/** 通过世界实体候选(POST /world/entities/{id}/approve,pending → confirmed) */
export function approveWorldEntity(entityId: string): Promise<WorldEntity> {
return apiPost<WorldEntity>(`/world/entities/${entityId}/approve`);
}
/** 驳回世界实体候选(POST /world/entities/{id}/reject) */
export function rejectWorldEntity(entityId: string): Promise<WorldEntity> {
return apiPost<WorldEntity>(`/world/entities/${entityId}/reject`);
}
+135
View File
@@ -0,0 +1,135 @@
/**
* 玄关网关 API(Go MCP 网关管理)。
* 通过 Next.js rewrite (/api/gateway/*) 代理到网关端口,规避跨域。
*/
const GATEWAY_PREFIX = "/api/gateway";
export interface GatewayHealth {
status: string;
tool_count: number;
services: number;
version: string;
uptime: string;
}
export interface GatewayService {
name: string;
description: string;
endpoint: string;
timeout: string;
disabled: boolean;
tools: GatewayTool[];
status: string;
}
export interface GatewayTool {
name: string;
description: string;
visibility: string;
min_role: string;
risk_level: string;
rate_limit?: string;
tags?: string[];
output_limits?: Record<string, unknown>;
}
export interface AddGatewayServiceRequest {
name: string;
description: string;
endpoint: string;
timeout?: string;
tools?: {
name: string;
description: string;
visibility?: string;
min_role?: string;
risk_level?: string;
tags?: string[];
}[];
}
async function gatewayGet<T>(path: string): Promise<T> {
const res = await fetch(`${GATEWAY_PREFIX}${path}`);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `网关请求失败 (${res.status})`);
}
return res.json();
}
async function gatewayPost<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${GATEWAY_PREFIX}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? `网关请求失败 (${res.status})`);
}
return res.json();
}
async function gatewayDelete(path: string): Promise<void> {
const res = await fetch(`${GATEWAY_PREFIX}${path}`, { method: "DELETE" });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? `网关请求失败 (${res.status})`);
}
}
async function gatewayPatch<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${GATEWAY_PREFIX}${path}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? `网关请求失败 (${res.status})`);
}
return res.json();
}
export const gatewayApi = {
/** GET /admin/health — 网关健康状态 */
getHealth: () => gatewayGet<GatewayHealth>("/admin/health"),
/** GET /admin/services — 服务清单 */
listServices: () => gatewayGet<GatewayService[]>("/admin/services"),
/** POST /admin/services — 注册新服务 */
addService: (body: AddGatewayServiceRequest) =>
gatewayPost<{ name: string; status: string; tools: number }>(
"/admin/services",
body,
),
/** DELETE /admin/services/{name} — 删除服务 */
deleteService: (name: string) => gatewayDelete(`/admin/services/${name}`),
/** PATCH /admin/services/{name} — 更新服务 */
updateService: (
name: string,
body: {
disabled?: boolean;
description?: string;
endpoint?: string;
tools?: {
name: string;
description: string;
risk_level: string;
visibility?: string;
}[];
},
) =>
gatewayPatch<{ status: string; name: string }>(
`/admin/services/${name}`,
body,
),
/** POST /admin/detect-tools — 检测指定端点的 MCP 工具清单 */
detectTools: (endpoint: string) =>
gatewayPost<{ tools: GatewayTool[] }>("/admin/detect-tools", { endpoint }),
};
+11
View File
@@ -0,0 +1,11 @@
/**
* 健康检查 API Client(对应 后端生成的 OpenAPI 契约 §4)。
* 用于前端确认后端可达性。
*/
import { apiGet } from "./client";
import type { HealthStatus } from "@/types/api";
export function getHealth() {
return apiGet<HealthStatus>("/health");
}
+132
View File
@@ -0,0 +1,132 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
}));
import { apiGet, apiPost } from "./client";
import {
batchReviewMemoryCandidates,
batchReviewProfileCandidates,
createImport,
extractMemoryCandidates,
extractProfileCandidates,
generatePersonalityProposals,
getImport,
listImportItems,
listImports,
listMemoryCandidates,
listProfileCandidates,
} from "./imports";
describe("imports api", () => {
it("createImport POST 支持幂等键", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "job_1" });
await createImport(
"char_1",
{ material_type: "chat_history", content: "测试内容" },
"idem_1",
);
expect(apiPost).toHaveBeenCalledWith(
"/characters/char_1/imports",
{ material_type: "chat_history", content: "测试内容" },
{ idempotencyKey: "idem_1" },
);
});
it("listImports GET /characters/:id/imports", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await listImports("char_1", { page: 1 });
expect(apiGet).toHaveBeenCalledWith("/characters/char_1/imports", {
page: 1,
});
});
it("getImport GET /characters/:id/imports/:import_id", async () => {
vi.mocked(apiGet).mockResolvedValue({ id: "job_1" });
await getImport("char_1", "job_1");
expect(apiGet).toHaveBeenCalledWith("/characters/char_1/imports/job_1");
});
it("listImportItems GET 条目分页", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await listImportItems("char_1", "job_1", { page_size: 50 });
expect(apiGet).toHaveBeenCalledWith(
"/characters/char_1/imports/job_1/items",
{ page_size: 50 },
);
});
it("extractProfileCandidates POST 触发抽取", async () => {
vi.mocked(apiPost).mockResolvedValue({});
await extractProfileCandidates("char_1", "job_1");
expect(apiPost).toHaveBeenCalledWith(
"/characters/char_1/imports/job_1/profile-candidates",
);
});
it("listProfileCandidates GET 列出档案候选", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await listProfileCandidates("char_1", "job_1", { status: "pending" });
expect(apiGet).toHaveBeenCalledWith(
"/characters/char_1/imports/job_1/profile-candidates",
{ status: "pending" },
);
});
it("extractMemoryCandidates POST 触发记忆抽取", async () => {
vi.mocked(apiPost).mockResolvedValue({});
await extractMemoryCandidates("char_1", "job_1");
expect(apiPost).toHaveBeenCalledWith(
"/characters/char_1/imports/job_1/memory-candidates",
);
});
it("listMemoryCandidates GET 列出记忆候选", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await listMemoryCandidates("char_1", "job_1", { status: "pending" });
expect(apiGet).toHaveBeenCalledWith(
"/characters/char_1/imports/job_1/memory-candidates",
{ status: "pending" },
);
});
it("generatePersonalityProposals POST 触发人格生成", async () => {
vi.mocked(apiPost).mockResolvedValue({});
await generatePersonalityProposals("char_1", "job_1");
expect(apiPost).toHaveBeenCalledWith(
"/characters/char_1/imports/job_1/personality-proposals",
);
});
it("batchReviewProfileCandidates POST 批量审核档案", async () => {
vi.mocked(apiPost).mockResolvedValue({ approved: 2, rejected: 0 });
await batchReviewProfileCandidates("char_1", {
decisions: [
{ candidate_id: "pc_1", decision: "approve" },
{ candidate_id: "pc_2", decision: "approve" },
],
});
expect(apiPost).toHaveBeenCalledWith(
"/characters/char_1/profile-candidates/batch-review",
{
decisions: [
{ candidate_id: "pc_1", decision: "approve" },
{ candidate_id: "pc_2", decision: "approve" },
],
},
);
});
it("batchReviewMemoryCandidates POST 批量审核记忆", async () => {
vi.mocked(apiPost).mockResolvedValue({ approved: 1, rejected: 0 });
await batchReviewMemoryCandidates("char_1", {
decisions: [{ candidate_id: "mc_1", decision: "approve" }],
});
expect(apiPost).toHaveBeenCalledWith(
"/characters/char_1/memory-candidates/batch-review",
{ decisions: [{ candidate_id: "mc_1", decision: "approve" }] },
);
});
});
+160
View File
@@ -0,0 +1,160 @@
/**
* 资料导入流水线 API(对应 后端生成的 OpenAPI 契约 §10)。
* 挂在人物下:/characters/{character_id}/imports。
* 流程:创建(JSON 粘贴)→ 轮询状态 → 查看条目 → 触发候选抽取 → 候选列表 → 批量审核。
* 文件上传(POST .../imports/upload,multipart)暂未接入,本轮以 JSON 粘贴文本为主线。
*/
import { apiGet, apiPost } from "./client";
import type {
BatchReviewRequest,
BatchReviewResult,
CreateImportRequest,
ImportItem,
ImportJob,
MemoryCandidate,
Paginated,
ProfileCandidate,
} from "@/types/api";
/** 创建导入任务(JSON 粘贴文本,POST /characters/{id}/imports,支持幂等键) */
export function createImport(
characterId: string,
body: CreateImportRequest,
idempotencyKey?: string,
): Promise<ImportJob> {
return apiPost<ImportJob>(`/characters/${characterId}/imports`, body, {
idempotencyKey,
});
}
/** 文件上传导入(POST /characters/{id}/imports/upload,multipart) */
export function uploadImport(
characterId: string,
file: File,
extra?: { title?: string; source_description?: string },
): Promise<ImportJob> {
const formData = new FormData();
formData.append("file", file);
if (extra?.title) formData.append("title", extra.title);
if (extra?.source_description)
formData.append("source_description", extra.source_description);
// BFF proxy 需要直接发 FormData,不通过 apiPost 的 JSON 序列化
return fetch(`/api/proxy/v1/characters/${characterId}/imports/upload`, {
method: "POST",
body: formData,
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error?.message || `上传失败 (${res.status})`);
}
return res.json();
});
}
/** 列出导入任务(GET /characters/{id}/imports) */
export function listImports(
characterId: string,
params?: { page?: number; page_size?: number },
): Promise<Paginated<ImportJob>> {
return apiGet<Paginated<ImportJob>>(
`/characters/${characterId}/imports`,
params,
);
}
/** 获取导入任务详情(GET /characters/{id}/imports/{import_id}) */
export function getImport(
characterId: string,
importId: string,
): Promise<ImportJob> {
return apiGet<ImportJob>(`/characters/${characterId}/imports/${importId}`);
}
/** 列出导入条目(GET /characters/{id}/imports/{import_id}/items) */
export function listImportItems(
characterId: string,
importId: string,
params?: { page?: number; page_size?: number },
): Promise<Paginated<ImportItem>> {
return apiGet<Paginated<ImportItem>>(
`/characters/${characterId}/imports/${importId}/items`,
params,
);
}
/** 触发档案候选抽取(POST .../imports/{import_id}/profile-candidates) */
export function extractProfileCandidates(
characterId: string,
importId: string,
): Promise<unknown> {
return apiPost(
`/characters/${characterId}/imports/${importId}/profile-candidates`,
);
}
/** 列出档案候选(GET .../imports/{import_id}/profile-candidates) */
export function listProfileCandidates(
characterId: string,
importId: string,
params?: { status?: string; page?: number; page_size?: number },
): Promise<Paginated<ProfileCandidate>> {
return apiGet<Paginated<ProfileCandidate>>(
`/characters/${characterId}/imports/${importId}/profile-candidates`,
params,
);
}
/** 触发记忆候选抽取(POST .../imports/{import_id}/memory-candidates) */
export function extractMemoryCandidates(
characterId: string,
importId: string,
): Promise<unknown> {
return apiPost(
`/characters/${characterId}/imports/${importId}/memory-candidates`,
);
}
/** 列出记忆候选(GET .../imports/{import_id}/memory-candidates) */
export function listMemoryCandidates(
characterId: string,
importId: string,
params?: { status?: string; page?: number; page_size?: number },
): Promise<Paginated<MemoryCandidate>> {
return apiGet<Paginated<MemoryCandidate>>(
`/characters/${characterId}/imports/${importId}/memory-candidates`,
params,
);
}
/** 触发人格建议生成(POST .../imports/{import_id}/personality-proposals) */
export function generatePersonalityProposals(
characterId: string,
importId: string,
): Promise<unknown> {
return apiPost(
`/characters/${characterId}/imports/${importId}/personality-proposals`,
);
}
/** 批量审核档案候选(POST /characters/{id}/profile-candidates/batch-review) */
export function batchReviewProfileCandidates(
characterId: string,
body: BatchReviewRequest,
): Promise<BatchReviewResult> {
return apiPost<BatchReviewResult>(
`/characters/${characterId}/profile-candidates/batch-review`,
body,
);
}
/** 批量审核记忆候选(POST /characters/{id}/memory-candidates/batch-review) */
export function batchReviewMemoryCandidates(
characterId: string,
body: BatchReviewRequest,
): Promise<BatchReviewResult> {
return apiPost<BatchReviewResult>(
`/characters/${characterId}/memory-candidates/batch-review`,
body,
);
}
+29
View File
@@ -0,0 +1,29 @@
/**
* API Client 统一出口。
*
* 组件 / Hook 只允许通过此模块访问后端,禁止在组件中散落 fetch
* (前端架构计划 §6)。SSE 见 lib/sse-client.ts。
*/
export * from "./client";
export * as audioApi from "./audio";
export * as charactersApi from "./characters";
export * as chatApi from "./chat";
export * as runsApi from "./runs";
export * as sandboxApi from "./sandbox";
export * as memoryApi from "./memory";
export * as personalityApi from "./personality";
export * as evolutionApi from "./evolution";
export * as driveApi from "./drive";
export * as healthApi from "./health";
export * as toolsApi from "./tools";
export * as safetyApi from "./safety";
export * as adminApi from "./admin";
export * as settingsApi from "./settings";
export * as importsApi from "./imports";
export * as extractionApi from "./extraction";
export * as worldApi from "./world";
export * as lorebooksApi from "./lorebooks";
export * as skillsApi from "./skills";
export * as mcpApi from "./mcp";
export * as timeApi from "./time";
+78
View File
@@ -0,0 +1,78 @@
/**
* 世谱 API Client(酒馆 Lorebook / World Info,融合设计 §7)。
* 对应后端 endpoints/lorebooks.py。
*/
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
import type {
CreateLorebookRequest,
Lorebook,
LorebookEntry,
LorebookEntryBody,
UpdateLorebookRequest,
} from "@/types/api";
/** 列出世谱(GET /lorebooks,按 character_id / scope 过滤) */
export function listLorebooks(params?: {
character_id?: string;
scope?: string;
}): Promise<Lorebook[]> {
return apiGet<Lorebook[]>("/lorebooks", params);
}
/** 获取世谱含条目(GET /lorebooks/{id}) */
export function getLorebook(lorebookId: string): Promise<Lorebook> {
return apiGet<Lorebook>(`/lorebooks/${lorebookId}`);
}
/** 创建世谱(POST /lorebooks) */
export function createLorebook(
body: CreateLorebookRequest,
idempotencyKey?: string,
): Promise<Lorebook> {
return apiPost<Lorebook>("/lorebooks", body, { idempotencyKey });
}
/** 更新世谱(PATCH /lorebooks/{id}) */
export function updateLorebook(
lorebookId: string,
body: UpdateLorebookRequest,
): Promise<Lorebook> {
return apiPatch<Lorebook>(`/lorebooks/${lorebookId}`, body);
}
/** 删除世谱(软删归档,DELETE /lorebooks/{id}) */
export function deleteLorebook(lorebookId: string): Promise<Lorebook> {
return apiDelete<Lorebook>(`/lorebooks/${lorebookId}`);
}
/** 创建条目(POST /lorebooks/{id}/entries) */
export function createLorebookEntry(
lorebookId: string,
body: LorebookEntryBody,
idempotencyKey?: string,
): Promise<LorebookEntry> {
return apiPost<LorebookEntry>(`/lorebooks/${lorebookId}/entries`, body, {
idempotencyKey,
});
}
/** 更新条目(PATCH /lorebooks/{id}/entries/{entry_id}) */
export function updateLorebookEntry(
lorebookId: string,
entryId: string,
body: LorebookEntryBody,
): Promise<LorebookEntry> {
return apiPatch<LorebookEntry>(
`/lorebooks/${lorebookId}/entries/${entryId}`,
body,
);
}
/** 删除条目(DELETE /lorebooks/{id}/entries/{entry_id},204) */
export function deleteLorebookEntry(
lorebookId: string,
entryId: string,
): Promise<void> {
return apiDelete<void>(`/lorebooks/${lorebookId}/entries/${entryId}`);
}
+80
View File
@@ -0,0 +1,80 @@
/**
* MCP server API(百工枢,/tools/mcp-servers,skills-design §5)。
*
* 支持两种传输协议:streamable-http / stdio。
* stdio 模式通过 start / stop / restart 控制子进程生命周期。
*/
import { apiDelete, apiGet, apiPost, apiPatch } from "./client";
import type {
CreateMcpServerRequest,
McpServer,
McpServerProcessStatus,
} from "@/types/api";
/** 列举 MCP server */
export function listMcpServers(status?: string): Promise<McpServer[]> {
return apiGet<McpServer[]>(
"/tools/mcp-servers",
status ? { status } : undefined,
);
}
/** 注册 MCP server */
export function registerMcpServer(
body: CreateMcpServerRequest,
): Promise<McpServer> {
return apiPost<McpServer>("/tools/mcp-servers", body);
}
/** 更新 MCP server */
export function updateMcpServer(
serverId: string,
body: Partial<CreateMcpServerRequest>,
): Promise<McpServer> {
return apiPatch<McpServer>(`/tools/mcp-servers/${serverId}`, body);
}
/** 删除 MCP server */
export function deleteMcpServer(serverId: string): Promise<void> {
return apiDelete<void>(`/tools/mcp-servers/${serverId}`);
}
/** 查询 MCP server 进程状态 */
export function getMcpServerStatus(
serverId: string,
): Promise<McpServerProcessStatus> {
return apiGet<McpServerProcessStatus>(
`/tools/mcp-servers/${serverId}/status`,
);
}
/** 启动 stdio MCP server 子进程 */
export function startMcpServer(
serverId: string,
): Promise<McpServerProcessStatus> {
return apiPost<McpServerProcessStatus>(
`/tools/mcp-servers/${serverId}/start`,
);
}
/** 停止 stdio MCP server 子进程 */
export function stopMcpServer(
serverId: string,
): Promise<McpServerProcessStatus> {
return apiPost<McpServerProcessStatus>(`/tools/mcp-servers/${serverId}/stop`);
}
/** 重启 stdio MCP server 子进程 */
export function restartMcpServer(
serverId: string,
): Promise<McpServerProcessStatus> {
return apiPost<McpServerProcessStatus>(
`/tools/mcp-servers/${serverId}/restart`,
);
}
/** 刷新 MCP server 工具清单(重新拉取并注册到运行时) */
export function refreshMcpServerTools(serverId: string): Promise<McpServer> {
return apiPost<McpServer>(`/tools/mcp-servers/${serverId}/refresh`);
}
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiDelete: vi.fn(),
apiGet: vi.fn(),
apiPatch: vi.fn(),
apiPost: vi.fn(),
}));
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
import {
createMemory,
deleteMemory,
listMemories,
updateMemory,
} from "./memory";
describe("memory api", () => {
it("listMemories GET /memory 支持分页与类型过滤", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await listMemories({ page: 1, page_size: 20, type: "episodic" });
expect(apiGet).toHaveBeenCalledWith("/memory", {
page: 1,
page_size: 20,
type: "episodic",
});
});
it("createMemory POST /memory 默认 source=user_explicit", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "mem_1" });
await createMemory({ type: "episodic", title: "测试", content: "内容" });
expect(apiPost).toHaveBeenCalledWith("/memory", {
source: "user_explicit",
type: "episodic",
title: "测试",
content: "内容",
});
});
it("createMemory 支持覆盖 source", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "mem_2" });
await createMemory({
type: "episodic",
title: "导入",
content: "内容",
source: "conversation",
});
expect(apiPost).toHaveBeenCalledWith("/memory", {
source: "conversation",
type: "episodic",
title: "导入",
content: "内容",
});
});
it("updateMemory PATCH /memory/:id", async () => {
vi.mocked(apiPatch).mockResolvedValue({ id: "mem_1" });
await updateMemory("mem_1", { title: "新标题" });
expect(apiPatch).toHaveBeenCalledWith("/memory/mem_1", { title: "新标题" });
});
it("deleteMemory DELETE /memory/:id", async () => {
vi.mocked(apiDelete).mockResolvedValue(undefined);
await deleteMemory("mem_1");
expect(apiDelete).toHaveBeenCalledWith("/memory/mem_1");
});
});
+45
View File
@@ -0,0 +1,45 @@
/**
* 记忆 API Client(对应 后端生成的 OpenAPI 契约 §10)。
* 手动记忆 CRUD;character_id 省略时使用当前激活人物。
*/
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
import type {
CreateMemoryRequest,
Memory,
Paginated,
UpdateMemoryRequest,
} from "@/types/api";
export interface ListMemoryParams {
character_id?: string;
type?: string;
source?: string;
page?: number;
page_size?: number;
/** 查询参数可透传,允许作为 Record 传递给底层请求封装 */
[key: string]: string | number | undefined;
}
/** 列出当前人物记忆 */
export function listMemories(params?: ListMemoryParams) {
return apiGet<Paginated<Memory>>("/memory", params);
}
/** 创建记忆;手动记忆 source 默认 user_explicit */
export function createMemory(body: CreateMemoryRequest) {
return apiPost<Memory>("/memory", {
source: "user_explicit",
...body,
});
}
/** 更新记忆(PATCH 部分字段) */
export function updateMemory(memoryId: string, body: UpdateMemoryRequest) {
return apiPatch<Memory>(`/memory/${memoryId}`, body);
}
/** 删除记忆(后端软删除:status=deleted + deleted_at) */
export function deleteMemory(memoryId: string) {
return apiDelete<void>(`/memory/${memoryId}`);
}
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from "vitest";
// ---------- health ----------
vi.mock("./client", () => ({
apiDelete: vi.fn(),
apiGet: vi.fn(),
apiPatch: vi.fn(),
apiPost: vi.fn(),
}));
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
import { getHealth } from "./health";
import { deleteMcpServer, listMcpServers, registerMcpServer } from "./mcp";
import {
getGenerationPreferences,
getModelGateway,
updateGenerationPreferences,
updateModelEndpoint,
} from "./settings";
import { createSkill, deleteSkill, listSkills, updateSkill } from "./skills";
describe("health api", () => {
it("getHealth GET /health", async () => {
vi.mocked(apiGet).mockResolvedValue({ status: "ok" });
await getHealth();
expect(apiGet).toHaveBeenCalledWith("/health");
});
});
describe("mcp api", () => {
it("listMcpServers GET /tools/mcp-servers 支持状态过滤", async () => {
vi.mocked(apiGet).mockResolvedValue([]);
await listMcpServers("active");
expect(apiGet).toHaveBeenCalledWith("/tools/mcp-servers", {
status: "active",
});
});
it("registerMcpServer POST /tools/mcp-servers", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "mcp_1" });
await registerMcpServer({
name: "my-mcp",
transport: "streamable-http",
endpoint_url: "http://localhost",
});
expect(apiPost).toHaveBeenCalledWith("/tools/mcp-servers", {
name: "my-mcp",
transport: "streamable-http",
endpoint_url: "http://localhost",
});
});
it("deleteMcpServer DELETE /tools/mcp-servers/:id", async () => {
vi.mocked(apiDelete).mockResolvedValue(undefined);
await deleteMcpServer("mcp_1");
expect(apiDelete).toHaveBeenCalledWith("/tools/mcp-servers/mcp_1");
});
});
describe("settings api", () => {
it("getModelGateway GET /settings/models", async () => {
vi.mocked(apiGet).mockResolvedValue({ models: [] });
await getModelGateway();
expect(apiGet).toHaveBeenCalledWith("/settings/models");
});
it("getGenerationPreferences GET /settings/preferences", async () => {
vi.mocked(apiGet).mockResolvedValue({ temperature: 0.7 });
await getGenerationPreferences();
expect(apiGet).toHaveBeenCalledWith("/settings/preferences");
});
it("updateGenerationPreferences PATCH /settings/preferences", async () => {
vi.mocked(apiPatch).mockResolvedValue({ temperature: 0.8 });
await updateGenerationPreferences({ temperature: 0.8 });
expect(apiPatch).toHaveBeenCalledWith("/settings/preferences", {
temperature: 0.8,
});
});
it("updateModelEndpoint PATCH 带 URI 编码", async () => {
vi.mocked(apiPatch).mockResolvedValue({ name: "default-chat" });
await updateModelEndpoint("default-chat", "openai-gpt4", {
status: "inactive",
});
expect(apiPatch).toHaveBeenCalledWith(
"/settings/models/default-chat/endpoints/openai-gpt4",
{ status: "inactive" },
);
});
});
describe("skills api", () => {
it("listSkills GET /tools/skills 支持状态过滤", async () => {
vi.mocked(apiGet).mockResolvedValue([]);
await listSkills("enabled");
expect(apiGet).toHaveBeenCalledWith("/tools/skills", { status: "enabled" });
});
it("createSkill POST /tools/skills", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "skill_1" });
await createSkill({ name: "my-skill", trigger: "hello" });
expect(apiPost).toHaveBeenCalledWith("/tools/skills", {
name: "my-skill",
trigger: "hello",
});
});
it("updateSkill PATCH /tools/skills/:id", async () => {
vi.mocked(apiPatch).mockResolvedValue({ id: "skill_1" });
await updateSkill("skill_1", { description: "updated description" });
expect(apiPatch).toHaveBeenCalledWith("/tools/skills/skill_1", {
description: "updated description",
});
});
it("deleteSkill DELETE /tools/skills/:id", async () => {
vi.mocked(apiDelete).mockResolvedValue(undefined);
await deleteSkill("skill_1");
expect(apiDelete).toHaveBeenCalledWith("/tools/skills/skill_1");
});
});
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
apiRequest: vi.fn(),
}));
import { apiGet, apiPost, apiRequest } from "./client";
import {
approveProposal,
getCurrentPersonality,
listPersonalityVersions,
proposeUpdate,
rejectProposal,
rollbackPersonality,
} from "./personality";
describe("personality api", () => {
it("getCurrentPersonality GET /personality/current", async () => {
vi.mocked(apiGet).mockResolvedValue({ id: "pv_1" });
await getCurrentPersonality({ character_id: "char_1" });
expect(apiGet).toHaveBeenCalledWith("/personality/current", {
character_id: "char_1",
});
});
it("proposeUpdate POST /personality/propose-update", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "prop_1" });
await proposeUpdate({
fields: { expression_style: "简洁清晰" },
reason: "优化表达",
});
expect(apiPost).toHaveBeenCalledWith("/personality/propose-update", {
fields: { expression_style: "简洁清晰" },
reason: "优化表达",
});
});
it("listPersonalityVersions GET /personality/versions", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await listPersonalityVersions({ page: 1, page_size: 20 });
expect(apiGet).toHaveBeenCalledWith("/personality/versions", {
page: 1,
page_size: 20,
});
});
it("approveProposal POST /personality/proposals/:id/approve", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "prop_1" });
await approveProposal("prop_1");
expect(apiPost).toHaveBeenCalledWith(
"/personality/proposals/prop_1/approve",
);
});
it("rejectProposal POST /personality/proposals/:id/reject", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "prop_1" });
await rejectProposal("prop_1");
expect(apiPost).toHaveBeenCalledWith(
"/personality/proposals/prop_1/reject",
);
});
it("rollbackPersonality POST 带 query 参数", async () => {
vi.mocked(apiRequest).mockResolvedValue({ id: "pv_1" });
await rollbackPersonality("pv_1", "char_1");
expect(apiRequest).toHaveBeenCalledWith(
"/personality/versions/pv_1/rollback",
{ method: "POST", query: { character_id: "char_1" } },
);
});
});
+66
View File
@@ -0,0 +1,66 @@
/**
* 人格 API Client(对应 后端生成的 OpenAPI 契约 §11)。
* 当前开发周期要求只读展示当前人格版本;propose-update 为增强能力(审核后写入)。
*/
import { apiGet, apiPost, apiRequest } from "./client";
import type {
Paginated,
PersonalityProposal,
PersonalityVersion,
ProposePersonalityUpdateRequest,
} from "@/types/api";
/** 获取当前激活人物的人格版本(只读摘要) */
export function getCurrentPersonality(params?: { character_id?: string }) {
return apiGet<PersonalityVersion>("/personality/current", params);
}
/** 提交人格更新建议(§11,核心人格 L4 变更审核通过才写入新版本) */
export function proposeUpdate(body: ProposePersonalityUpdateRequest) {
return apiPost<PersonalityProposal>("/personality/propose-update", body);
}
/** 人格版本历史(GET /personality/versions,省略 character_id 取当前激活人物) */
export function listPersonalityVersions(params?: {
character_id?: string;
page?: number;
page_size?: number;
}): Promise<Paginated<PersonalityVersion>> {
return apiGet<Paginated<PersonalityVersion>>("/personality/versions", params);
}
/** 审核通过人格建议(POST /personality/proposals/{id}/approve,派生新版本并切换指针) */
export function approveProposal(
proposalId: string,
): Promise<PersonalityProposal> {
return apiPost<PersonalityProposal>(
`/personality/proposals/${proposalId}/approve`,
);
}
/** 驳回人格建议(POST /personality/proposals/{id}/reject) */
export function rejectProposal(
proposalId: string,
): Promise<PersonalityProposal> {
return apiPost<PersonalityProposal>(
`/personality/proposals/${proposalId}/reject`,
);
}
/**
* 回滚到指定人格版本(POST /personality/versions/{id}/rollback)。
* character_id 为 query 参数——apiPost 的 extra 已 Omit query,故直接用 apiRequest。
*/
export function rollbackPersonality(
versionId: string,
characterId?: string,
): Promise<PersonalityVersion> {
return apiRequest<PersonalityVersion>(
`/personality/versions/${versionId}/rollback`,
{
method: "POST",
query: characterId ? { character_id: characterId } : undefined,
},
);
}
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
}));
import { apiGet, apiPost } from "./client";
import {
approveRunConfirmation,
cancelRun,
denyRunConfirmation,
followUpRun,
getRun,
listRunEvents,
steerRun,
} from "./runs";
describe("runs api", () => {
it("getRun GET /runs/:id", async () => {
vi.mocked(apiGet).mockResolvedValue({ id: "run_1", status: "completed" });
const result = await getRun("run_1");
expect(apiGet).toHaveBeenCalledWith("/runs/run_1");
expect(result.status).toBe("completed");
});
it("cancelRun POST /runs/:id/cancel", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "run_1", status: "cancelled" });
await cancelRun("run_1");
expect(apiPost).toHaveBeenCalledWith("/runs/run_1/cancel");
});
it("steerRun POST /runs/:id/steer with content", async () => {
vi.mocked(apiPost).mockResolvedValue({ accepted: true });
await steerRun("run_1", "改用表格输出");
expect(apiPost).toHaveBeenCalledWith("/runs/run_1/steer", {
content: "改用表格输出",
});
});
it("followUpRun POST /runs/:id/follow-up with content", async () => {
vi.mocked(apiPost).mockResolvedValue({ accepted: true });
await followUpRun("run_1", "再总结一次要点");
expect(apiPost).toHaveBeenCalledWith("/runs/run_1/follow-up", {
content: "再总结一次要点",
});
});
it("approveRunConfirmation POST 确认批准", async () => {
vi.mocked(apiPost).mockResolvedValue({
status: "resolved",
approved: true,
});
await approveRunConfirmation("run_1", "confirm_abc");
expect(apiPost).toHaveBeenCalledWith(
"/runs/run_1/confirmations/confirm_abc/approve",
);
});
it("denyRunConfirmation POST 确认拒绝", async () => {
vi.mocked(apiPost).mockResolvedValue({
status: "resolved",
approved: false,
});
await denyRunConfirmation("run_1", "confirm_abc");
expect(apiPost).toHaveBeenCalledWith(
"/runs/run_1/confirmations/confirm_abc/deny",
);
});
it("listRunEvents GET /runs/:id/events", async () => {
vi.mocked(apiGet).mockResolvedValue([]);
await listRunEvents("run_1");
expect(apiGet).toHaveBeenCalledWith("/runs/run_1/events");
});
});
+89
View File
@@ -0,0 +1,89 @@
/**
* Run API Client(对应 后端生成的 OpenAPI 契约 §9)。
* 查询 Run 状态与事件;SSE 流式订阅见 lib/sse-client.ts。
* 含对话场景的 run-scoped 确认端点(与 /safety/confirmations 区分)。
*/
import { apiGet, apiPost } from "./client";
import type {
AgentControlResponse,
Paginated,
Run,
RunConfirmationResolutionResponse,
RunEvent,
} from "@/types/api";
/** Run 列表(按创建时间倒序,分页) */
export function listRuns(params?: {
character_id?: string;
session_id?: string;
status?: string;
page?: number;
page_size?: number;
}): Promise<Paginated<Run>> {
return apiGet<Paginated<Run>>("/runs", params);
}
/** 获取 Run 详情 */
export function getRun(runId: string): Promise<Run> {
return apiGet<Run>(`/runs/${runId}`);
}
/** 取消 Run(预留增强) */
export function cancelRun(runId: string) {
return apiPost<{ id: string; status: string }>(`/runs/${runId}/cancel`);
}
/**
* 运行中补充指引(steer):在当前工具批次结束后注入指导消息。
* Run 不在本进程执行时返回 409 conflict。
*/
export function steerRun(runId: string, content: string) {
return apiPost<AgentControlResponse>(`/runs/${runId}/steer`, { content });
}
/**
* 运行中追加后续任务(follow-up):Agent 准备结束时追加新任务。
* Run 不在本进程执行时返回 409 conflict。
*/
export function followUpRun(runId: string, content: string) {
return apiPost<AgentControlResponse>(`/runs/${runId}/follow-up`, { content });
}
/**
* 对话场景:批准高风险工具调用(run-scoped)。
* 成功 → 200 {status:"resolved", approved:true};
* 已失效 → 410 confirmation_expired;不存在 → 404 confirmation_not_found;
* 未挂载 → 503 service_unavailable。
*/
export function approveRunConfirmation(
runId: string,
confirmationId: string,
): Promise<RunConfirmationResolutionResponse> {
return apiPost<RunConfirmationResolutionResponse>(
`/runs/${runId}/confirmations/${confirmationId}/approve`,
);
}
/**
* 对话场景:拒绝高风险工具调用(run-scoped)。
* 错误语义同 approveRunConfirmation,approved=false。
*/
export function denyRunConfirmation(
runId: string,
confirmationId: string,
): Promise<RunConfirmationResolutionResponse> {
return apiPost<RunConfirmationResolutionResponse>(
`/runs/${runId}/confirmations/${confirmationId}/deny`,
);
}
/**
* 查询某 Run 的公开事件列表(预留)。
* 注意:当前 API spec 核心范围未含 GET /runs/{run_id}/events,断线对齐依赖
* SSE Last-Event-ID 重连补发,最终状态靠 GET /runs/{run_id} 与会话消息。
* 此接口为后续增强预留,调用方须 catch 失败。
*/
export function listRunEvents(runId: string): Promise<RunEvent[]> {
return apiGet<RunEvent[]>(`/runs/${runId}/events`);
}
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiDelete: vi.fn(),
apiGet: vi.fn(),
apiPatch: vi.fn(),
apiPost: vi.fn(),
}));
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
import {
approveConfirmation,
createPolicy,
deletePolicy,
denyConfirmation,
getSafetyPolicies,
updatePolicy,
} from "./safety";
describe("safety api", () => {
it("approveConfirmation POST /safety/confirmations/:id/approve", async () => {
vi.mocked(apiPost).mockResolvedValue({
status: "resolved",
approved: true,
});
await approveConfirmation("confirm_1");
expect(apiPost).toHaveBeenCalledWith(
"/safety/confirmations/confirm_1/approve",
);
});
it("denyConfirmation POST /safety/confirmations/:id/deny", async () => {
vi.mocked(apiPost).mockResolvedValue({
status: "resolved",
approved: false,
});
await denyConfirmation("confirm_1");
expect(apiPost).toHaveBeenCalledWith(
"/safety/confirmations/confirm_1/deny",
);
});
it("getSafetyPolicies GET /safety/policies", async () => {
vi.mocked(apiGet).mockResolvedValue([]);
await getSafetyPolicies();
expect(apiGet).toHaveBeenCalledWith("/safety/policies");
});
it("updatePolicy PATCH /safety/policies/:tool", async () => {
vi.mocked(apiPatch).mockResolvedValue({ tool: "web_search" });
await updatePolicy("web_search", { permission_policy: "allow" });
expect(apiPatch).toHaveBeenCalledWith("/safety/policies/web_search", {
permission_policy: "allow",
});
});
it("createPolicy POST /safety/policies", async () => {
vi.mocked(apiPost).mockResolvedValue({ tool: "shell" });
await createPolicy({
tool: "shell",
risk_level: "L3",
permission_policy: "forbidden",
});
expect(apiPost).toHaveBeenCalledWith("/safety/policies", {
tool: "shell",
risk_level: "L3",
permission_policy: "forbidden",
});
});
it("deletePolicy DELETE /safety/policies/:tool", async () => {
vi.mocked(apiDelete).mockResolvedValue(undefined);
await deletePolicy("shell");
expect(apiDelete).toHaveBeenCalledWith("/safety/policies/shell");
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* 安全确认 API Client(对应 后端生成的 OpenAPI 契约 §16)。
*
* - 列出 / 更新工具权限策略;
* - 消费工具调用返回的 confirmation_id:approve 执行、deny 取消。
* 确认项一次性消费,重复调用返回 404 not_found。
*/
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
import type {
AuditEntry,
ConfirmationResponse,
CreateSafetyPolicyRequest,
Paginated,
SafetyPolicy,
UpdateSafetyPolicyRequest,
} from "@/types/api";
/** 审计事件列表(GET /safety/audit-events,按人物/枢过滤) */
export function listAuditEvents(params?: {
character_id?: string;
pivot?: string;
page?: number;
page_size?: number;
}): Promise<Paginated<AuditEntry>> {
return apiGet<Paginated<AuditEntry>>("/safety/audit-events", params);
}
/** 批准待确认的工具调用(触发执行) */
export function approveConfirmation(
confirmationId: string,
): Promise<ConfirmationResponse> {
return apiPost<ConfirmationResponse>(
`/safety/confirmations/${confirmationId}/approve`,
);
}
/** 拒绝待确认的工具调用(仅取消) */
export function denyConfirmation(
confirmationId: string,
): Promise<ConfirmationResponse> {
return apiPost<ConfirmationResponse>(
`/safety/confirmations/${confirmationId}/deny`,
);
}
/** 当前工具权限策略(§16),司命台权限矩阵。后端返回全量 list(非分页) */
export function getSafetyPolicies() {
return apiGet<SafetyPolicy[]>("/safety/policies");
}
/** 更新工具权限策略(§16 PATCH),治理动作,后端记审计 */
export function updatePolicy(tool: string, body: UpdateSafetyPolicyRequest) {
return apiPatch<SafetyPolicy>(`/safety/policies/${tool}`, body);
}
/** 新增工具权限策略(§16 POST),策略随工具增长 */
export function createPolicy(body: CreateSafetyPolicyRequest) {
return apiPost<SafetyPolicy>("/safety/policies", body);
}
/** 删除工具权限策略(§16 DELETE) */
export function deletePolicy(tool: string) {
return apiDelete<void>(`/safety/policies/${tool}`);
}
+80
View File
@@ -0,0 +1,80 @@
/**
* 沙箱文件 API。
*
* 通过 BFF 代理读取沙箱内文件的原始内容(带 MIME 类型),用于对话区域的文件预览
* (源码 / 渲染双模式)。鉴权由 BFF /api/proxy 统一处理(httpOnly cookie 自动携带)。
*/
import { API_PROXY_ROOT } from "@/lib/config";
export interface ReadFileOptions {
/** 关联的 run_id(可选,写入授权上下文) */
runId?: string;
/** 人物 ID(可选,自动构造隔离工作区路径) */
characterId?: string;
/** 是否自动按 isolated_workspace 规则补全路径前缀(默认 true) */
isolated?: boolean;
}
/**
* 读取沙箱文件内容,返回 Blob。
*
* Blob 的 type 属性为后端推导的 MIME 类型(取决于文件扩展名),
* 前端据此决定展示方式:
* - text/html → iframe sandbox 渲染
* - image/* → img 展示
* - text/* → 源码文本
*/
export async function readSandboxFile(
path: string,
options?: ReadFileOptions,
): Promise<Blob> {
const params = new URLSearchParams({ path });
if (options?.runId) params.set("run_id", options.runId);
if (options?.characterId) params.set("character_id", options.characterId);
if (options?.isolated === false) params.set("isolated", "false");
const resp = await fetch(`${API_PROXY_ROOT}/sandbox/file?${params}`, {
cache: "no-store",
});
if (!resp.ok) {
const detail = await resp.text().catch(() => "");
throw new Error(
detail
? `读取沙箱文件失败: ${detail}`
: `读取沙箱文件失败 (${resp.status})`,
);
}
return resp.blob();
}
export interface DirEntry {
name: string;
kind: "file" | "dir";
size: number;
}
/**
* 列出沙箱工作区目录内容。
*/
export async function listSandboxDir(
path: string = ".",
options?: ReadFileOptions,
): Promise<DirEntry[]> {
const params = new URLSearchParams({ path });
if (options?.runId) params.set("run_id", options.runId);
if (options?.characterId) params.set("character_id", options.characterId);
if (options?.isolated === false) params.set("isolated", "false");
const resp = await fetch(`${API_PROXY_ROOT}/sandbox/files?${params}`, {
cache: "no-store",
});
if (!resp.ok) {
throw new Error(`列出沙箱目录失败 (${resp.status})`);
}
return resp.json() as Promise<DirEntry[]>;
}
+129
View File
@@ -0,0 +1,129 @@
/**
* 服务端 API 直连客户端(仅 Server Component 使用)。
*
* Server Component 不能通过 BFF /api/proxy 请求数据,因为 httpOnly cookie
* 只存在于浏览器的原始 HTTP 请求中,Node.js 内的 fetch 不会自动携带。
* 此文件封装从 cookie 读 token → 直连后端的流程,实现与 BFF 代理相同的作用。
*
* 底层 HTTP 调用委托给 lib/backend/services/auth 处理认证刷新。
*/
import { cookies } from "next/headers";
import { BACKEND_ROOT } from "@/lib/backend/config";
import { COOKIE_ACCESS, COOKIE_REFRESH } from "@/lib/backend/cookies";
const BACKEND = BACKEND_ROOT;
function qs(params?: Record<string, unknown>): string {
if (!params) return "";
const s = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null) continue;
s.append(k, String(v));
}
const out = s.toString();
return out ? `?${out}` : "";
}
/**
* 服务端通用请求:从 httpOnly cookie 读 token 注入 Authorization,
* 直连后端,处理 401 refresh。
*/
export async function serverFetch<T>(
path: string,
init: {
method?: string;
json?: unknown;
query?: Record<string, unknown>;
} = {},
): Promise<T> {
const store = await cookies();
const token = store.get(COOKIE_ACCESS)?.value ?? null;
const headers: Record<string, string> = {};
if (token) headers["authorization"] = `Bearer ${token}`;
if (init.json) headers["content-type"] = "application/json";
const url = `${BACKEND}${path}${qs(init.query)}`;
const method = init.method ?? "GET";
const body = init.json ? JSON.stringify(init.json) : undefined;
let resp = await fetch(url, { method, headers, body, cache: "no-store" });
// 401 → 尝试 refresh
if (resp.status === 401) {
const refreshToken = store.get(COOKIE_REFRESH)?.value;
if (refreshToken) {
const rr = await fetch(`${BACKEND}/auth/refresh`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
cache: "no-store",
});
if (rr.ok) {
const data = (await rr.json()) as {
access_token?: string;
refresh_token?: string;
};
if (data.access_token) {
store.set(COOKIE_ACCESS, data.access_token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
maxAge: 7 * 86400,
});
if (data.refresh_token)
store.set(COOKIE_REFRESH, data.refresh_token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
maxAge: 7 * 86400,
});
headers["authorization"] = `Bearer ${data.access_token}`;
resp = await fetch(url, { method, headers, body, cache: "no-store" });
}
}
}
}
if (resp.status === 204) return undefined as T;
const text = await resp.text();
let payload: unknown;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = null;
}
if (!resp.ok) {
const body = payload as
| { error?: { code?: string; message?: string; trace_id?: string } }
| undefined;
const err = body?.error;
throw Object.assign(
new Error(err?.message ?? `请求失败(HTTP ${resp.status})`),
{
status: resp.status,
code: err?.code ?? "internal_error",
traceId: err?.trace_id,
},
);
}
return payload as T;
}
export const serverApi = {
get: <T>(path: string, query?: Record<string, unknown>) =>
serverFetch<T>(path, { method: "GET", query }),
post: <T>(path: string, json?: unknown) =>
serverFetch<T>(path, { method: "POST", json }),
patch: <T>(path: string, json?: unknown) =>
serverFetch<T>(path, { method: "PATCH", json }),
put: <T>(path: string, json?: unknown) =>
serverFetch<T>(path, { method: "PUT", json }),
delete: <T>(path: string) => serverFetch<T>(path, { method: "DELETE" }),
};
+47
View File
@@ -0,0 +1,47 @@
/**
* 设置 API Client(对应 后端生成的 OpenAPI 契约 · 设置)。
*
* 前端只见逻辑模型、provider 脱敏状态与生成参数偏好;真实 API Key、endpoint URL
* 由后端 .env 持有,不进入前端(environment-config-design §3)。模型网关端点已由
* 后端 settings 路由实现(从 config/models.yaml 派生,provider 密钥脱敏);
* Mock 模式(NEXT_PUBLIC_ENABLE_MOCK=true)下由 lib/mock 提供示例数据。
*/
import { apiGet, apiPatch } from "./client";
import type {
GenerationPreferences,
ModelEndpointStatus,
ModelGatewayStatus,
UpdateEndpointOverrideRequest,
UpdateGenerationPreferencesRequest,
} from "@/types/api";
/** 模型网关状态(GET /settings/models),只读:逻辑模型清单 + provider 密钥脱敏状态 */
export function getModelGateway() {
return apiGet<ModelGatewayStatus>("/settings/models");
}
/** 生成参数偏好(GET /settings/preferences) */
export function getGenerationPreferences() {
return apiGet<GenerationPreferences>("/settings/preferences");
}
/** 更新生成参数偏好(PATCH /settings/preferences) */
export function updateGenerationPreferences(
body: UpdateGenerationPreferencesRequest,
) {
return apiPatch<GenerationPreferences>("/settings/preferences", body);
}
/** 更新 endpoint 覆盖(PATCH /settings/models/{model}/endpoints/{endpoint},DB 优先 env;写后热重载)。
* 响应脱敏,不回 base_url/api_key 原值。 */
export function updateModelEndpoint(
model: string,
endpoint: string,
body: UpdateEndpointOverrideRequest,
) {
return apiPatch<ModelEndpointStatus>(
`/settings/models/${encodeURIComponent(model)}/endpoints/${encodeURIComponent(endpoint)}`,
body,
);
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Skill 能力包 API(百工枢,/tools/skills,skills-design §6)。
* 全量 list(非分页);name 唯一;支持启用/禁用与字段更新。
*/
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
import type {
CreateSkillRequest,
Skill,
UpdateSkillRequest,
} from "@/types/api";
/** 列举 Skill(GET /tools/skills,可按 status 过滤:enabled/disabled) */
export function listSkills(status?: string): Promise<Skill[]> {
return apiGet<Skill[]>("/tools/skills", status ? { status } : undefined);
}
/** 注册 Skill(POST /tools/skills,name 唯一) */
export function createSkill(body: CreateSkillRequest): Promise<Skill> {
return apiPost<Skill>("/tools/skills", body);
}
/** 更新 Skill(PATCH /tools/skills/{id},启用/禁用/改字段) */
export function updateSkill(
skillId: string,
body: UpdateSkillRequest,
): Promise<Skill> {
return apiPatch<Skill>(`/tools/skills/${skillId}`, body);
}
/** 删除 Skill(DELETE /tools/skills/{id}) */
export function deleteSkill(skillId: string): Promise<void> {
return apiDelete<void>(`/tools/skills/${skillId}`);
}
+55
View File
@@ -0,0 +1,55 @@
/**
* 辰正时态 API Client(对应 后端生成的 OpenAPI 契约 §10)。
*
* 提供数字生命对时间流逝的感知:时态快照、纪念日、相识时长与配置。
*/
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
import type {
AcquaintanceInfo,
Anniversary,
CreateAnniversaryRequest,
TimeConfig,
TimeSnapshotResponse,
UpdateAnniversaryRequest,
} from "@/types/api";
/** 当前时态快照 + 调制因子(GET /time/snapshot) */
export function getTimeSnapshot() {
return apiGet<TimeSnapshotResponse>("/time/snapshot");
}
/** 纪念日列表(GET /time/anniversaries) */
export function listAnniversaries() {
return apiGet<Anniversary[]>("/time/anniversaries");
}
/** 新增纪念日(POST /time/anniversaries) */
export function createAnniversary(body: CreateAnniversaryRequest) {
return apiPost<Anniversary>("/time/anniversaries", body);
}
/** 单条纪念日(GET /time/anniversaries/{id}) */
export function getAnniversary(id: string) {
return apiGet<Anniversary>(`/time/anniversaries/${id}`);
}
/** 更新纪念日(PATCH /time/anniversaries/{id}) */
export function updateAnniversary(id: string, body: UpdateAnniversaryRequest) {
return apiPatch<Anniversary>(`/time/anniversaries/${id}`, body);
}
/** 删除纪念日(DELETE /time/anniversaries/{id}) */
export function deleteAnniversary(id: string) {
return apiDelete<void>(`/time/anniversaries/${id}`);
}
/** 相识时长(GET /time/acquaintance) */
export function getAcquaintance() {
return apiGet<AcquaintanceInfo>("/time/acquaintance");
}
/** 时态配置(GET /time/config,只读) */
export function getTimeConfig() {
return apiGet<TimeConfig>("/time/config");
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiDelete: vi.fn(),
apiGet: vi.fn(),
apiPatch: vi.fn(),
apiPost: vi.fn(),
}));
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
import {
createTool,
deleteTool,
invokeTool,
listCapabilities,
listTools,
updateTool,
} from "./tools";
describe("tools api", () => {
it("listTools GET /tools", async () => {
vi.mocked(apiGet).mockResolvedValue([]);
await listTools();
expect(apiGet).toHaveBeenCalledWith("/tools");
});
it("listCapabilities GET /tools/capabilities", async () => {
vi.mocked(apiGet).mockResolvedValue([]);
await listCapabilities();
expect(apiGet).toHaveBeenCalledWith("/tools/capabilities");
});
it("updateTool PATCH /tools/:name", async () => {
vi.mocked(apiPatch).mockResolvedValue({ name: "web_search" });
await updateTool("web_search", { risk_level: "L2" });
expect(apiPatch).toHaveBeenCalledWith("/tools/web_search", {
risk_level: "L2",
});
});
it("createTool POST /tools", async () => {
vi.mocked(apiPost).mockResolvedValue({ name: "my_tool" });
await createTool({
name: "my_tool",
description: "自定义工具",
risk_level: "L0",
permission_policy: "allow",
side_effect: false,
audit_required: false,
});
expect(apiPost).toHaveBeenCalledWith("/tools", {
name: "my_tool",
description: "自定义工具",
risk_level: "L0",
permission_policy: "allow",
side_effect: false,
audit_required: false,
});
});
it("deleteTool DELETE /tools/:name", async () => {
vi.mocked(apiDelete).mockResolvedValue(undefined);
await deleteTool("my_tool");
expect(apiDelete).toHaveBeenCalledWith("/tools/my_tool");
});
it("invokeTool POST /tools/:name/invoke", async () => {
vi.mocked(apiPost).mockResolvedValue({ ok: true });
await invokeTool("web_search", { input: { q: "test" } });
expect(apiPost).toHaveBeenCalledWith("/tools/web_search/invoke", {
input: { q: "test" },
});
});
});
+187
View File
@@ -0,0 +1,187 @@
/**
* 工具 API Client(对应 后端生成的 OpenAPI 契约 §15)。
*
* - 列出工具元数据、更新工具风险等级;
* - 调用工具:低风险直接 completed;高风险返回 requires_confirmation,
* 交由 lib/api/safety.ts 的 approve/deny 消费。
* - 流式调用 invokeToolStream:fetch + ReadableStream 解析 SSE(EventSource 不支持 POST body),
* 实时回调 stdout/stderr 增量(base64)与 completed/requires_confirmation/failed。
* - 错误统一经 client.ts 抛出 ApiError(如 permission_denied / tool_error)。
*/
import { API_PROXY_ROOT } from "@/lib/config";
import { USE_MOCK } from "@/lib/mock";
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
import type {
CreateToolRequest,
InvokeToolRequest,
InvokeToolResponse,
ToolCapability,
ToolInfo,
ToolStreamDone,
ToolStreamHandlers,
UpdateToolRequest,
} from "@/types/api";
/** 列出工具元数据 */
export function listTools(): Promise<ToolInfo[]> {
return apiGet<ToolInfo[]>("/tools");
}
/** 工具调用详情(GET /tools/calls/{tool_call_id}) */
export function getToolCallDetail(toolCallId: string) {
return apiGet<Record<string, unknown>>(`/tools/calls/${toolCallId}`);
}
/** 列出全部能力(GET /tools/capabilities,builtin/mcp/skill 三源带 source 标记) */
export function listCapabilities(): Promise<ToolCapability[]> {
return apiGet<ToolCapability[]>("/tools/capabilities");
}
/** 更新工具风险等级(§15 PATCH),治理动作,后端记审计 */
export function updateTool(toolName: string, body: UpdateToolRequest) {
return apiPatch<ToolInfo>(`/tools/${toolName}`, body);
}
/** 注册工具(§15 POST),百工枢器用治理 */
export function createTool(body: CreateToolRequest) {
return apiPost<ToolInfo>("/tools", body);
}
/** 注销工具(§15 DELETE) */
export function deleteTool(toolName: string) {
return apiDelete<void>(`/tools/${toolName}`);
}
/** 调用工具(confirmed=true 仅在司命放行后由上层显式传入,通常走 approve 流程) */
export function invokeTool(
toolName: string,
body: InvokeToolRequest,
): Promise<InvokeToolResponse> {
return apiPost<InvokeToolResponse>(`/tools/${toolName}/invoke`, body);
}
/**
* 流式调用工具(SSE)。读取后端 ``POST /tools/{name}/invoke/stream`` 的事件流,
* 逐事件回调。EventSource 不支持 POST body,故用 fetch + ReadableStream 手动解析 SSE。
*
* 事件:``tool.stdout`` / ``tool.stderr``(data 为 ``{"data":"<base64>"}``)、
* ``tool.completed``(data 为结束汇总 JSON)、``tool.requires_confirmation``、``tool.failed``。
* Mock 模式不支持流式。
*/
export async function invokeToolStream(
toolName: string,
body: InvokeToolRequest,
handlers: ToolStreamHandlers,
signal?: AbortSignal,
): Promise<void> {
if (USE_MOCK) {
handlers.onFailed?.("mock_unavailable", "Mock 模式不支持流式调用");
return;
}
let resp: Response;
try {
resp = await fetch(`${API_PROXY_ROOT}/tools/${toolName}/invoke/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
} catch {
handlers.onFailed?.(
"network_error",
"无法连接后端服务,请确认后端已启动。",
);
return;
}
if (!resp.ok || !resp.body) {
handlers.onFailed?.("http_error", `请求失败(HTTP ${resp.status})`);
return;
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let eventType = "";
let dataLines: string[] = [];
const flush = () => {
if (dataLines.length === 0) {
eventType = "";
return;
}
const data = dataLines.join("\n");
dataLines = [];
const etype = eventType || "message";
eventType = "";
const parsed = safeJsonParse(data);
if (!parsed) return;
switch (etype) {
case "tool.stdout": {
const d = parsed.data;
if (typeof d === "string") handlers.onStdout?.(d);
break;
}
case "tool.stderr": {
const d = parsed.data;
if (typeof d === "string") handlers.onStderr?.(d);
break;
}
case "tool.completed":
handlers.onCompleted?.(parsed as unknown as ToolStreamDone);
break;
case "tool.requires_confirmation":
handlers.onRequiresConfirmation?.(
String(parsed.risk_level ?? ""),
String(parsed.reason ?? ""),
);
break;
case "tool.failed":
handlers.onFailed?.(
String(parsed.error_code ?? ""),
String(parsed.error_message ?? ""),
);
break;
default:
break;
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let nl: number;
while ((nl = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, nl).replace(/\r$/, "");
buffer = buffer.slice(nl + 1);
if (line === "") {
flush();
} else if (line.startsWith("event:")) {
eventType = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
// SSE 规范:data: 后一个空格为分隔符,不计入数据
dataLines.push(line.slice("data:".length).replace(/^ /, ""));
}
// 其余行(id:/retry:/注释/心跳)忽略
}
}
// flush 流末尾残余
buffer += decoder.decode();
if (buffer.startsWith("event:")) {
eventType = buffer.slice("event:".length).trim();
} else if (buffer.startsWith("data:")) {
dataLines.push(buffer.slice("data:".length).replace(/^ /, ""));
}
flush();
}
function safeJsonParse(text: string): Record<string, unknown> | null {
try {
return JSON.parse(text) as Record<string, unknown>;
} catch {
return null;
}
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
}));
import { apiGet, apiPost } from "./client";
import {
createWorldEntity,
createWorldRelation,
getWorldEntity,
listWorldEntities,
listWorldRelations,
} from "./world";
describe("world api", () => {
it("listWorldEntities GET /world/entities 支持分页过滤", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await listWorldEntities({ page: 1, page_size: 20, type: "person" });
expect(apiGet).toHaveBeenCalledWith("/world/entities", {
page: 1,
page_size: 20,
type: "person",
});
});
it("createWorldEntity POST /world/entities", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "entity_1" });
await createWorldEntity({ type: "concept", name: "测试" });
expect(apiPost).toHaveBeenCalledWith("/world/entities", {
type: "concept",
name: "测试",
});
});
it("getWorldEntity GET /world/entities/:id", async () => {
vi.mocked(apiGet).mockResolvedValue({ id: "entity_1" });
await getWorldEntity("entity_1");
expect(apiGet).toHaveBeenCalledWith("/world/entities/entity_1");
});
it("listWorldRelations GET /world/relations", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await listWorldRelations({ page: 1, page_size: 10 });
expect(apiGet).toHaveBeenCalledWith("/world/relations", {
page: 1,
page_size: 10,
});
});
it("createWorldRelation POST /world/relations", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "rel_1" });
await createWorldRelation({
subject_id: "e1",
predicate: "related_to",
object_id: "e2",
});
expect(apiPost).toHaveBeenCalledWith("/world/relations", {
subject_id: "e1",
predicate: "related_to",
object_id: "e2",
});
});
});
+58
View File
@@ -0,0 +1,58 @@
/**
* 世界模型 API(河图枢,独立 /world 域)。
* 实体列表/创建 + 关系图谱(relations)。独立域无 PATCH/DELETE——实体编辑/删除仍走
* adminApi(保留 WorldEntityList 的完整 CRUD);本模块提供独立域的分页查询与关系管理。
* 世界实体候选审核(approve/reject)见 extractionApi。
*/
import { apiDelete, apiGet, apiPost } from "./client";
import type {
CreateWorldEntityRequest,
CreateWorldRelationRequest,
Paginated,
WorldEntity,
WorldRelation,
} from "@/types/api";
/** 列出世界实体(GET /world/entities,独立域分页) */
export function listWorldEntities(params?: {
character_id?: string;
type?: string;
page?: number;
page_size?: number;
}): Promise<Paginated<WorldEntity>> {
return apiGet<Paginated<WorldEntity>>("/world/entities", params);
}
/** 创建世界实体(POST /world/entities) */
export function createWorldEntity(
body: CreateWorldEntityRequest,
): Promise<WorldEntity> {
return apiPost<WorldEntity>("/world/entities", body);
}
/** 获取单个世界实体(GET /world/entities/{id}) */
export function getWorldEntity(entityId: string): Promise<WorldEntity> {
return apiGet<WorldEntity>(`/world/entities/${entityId}`);
}
/** 列出世界关系(GET /world/relations) */
export function listWorldRelations(params?: {
character_id?: string;
page?: number;
page_size?: number;
}): Promise<Paginated<WorldRelation>> {
return apiGet<Paginated<WorldRelation>>("/world/relations", params);
}
/** 创建世界关系(POST /world/relations,subject-predicate-object 三元组) */
export function createWorldRelation(
body: CreateWorldRelationRequest,
): Promise<WorldRelation> {
return apiPost<WorldRelation>("/world/relations", body);
}
/** 删除世界关系(DELETE /world/relations/{relation_id}) */
export function deleteWorldRelation(relationId: string): Promise<void> {
return apiDelete<void>(`/world/relations/${relationId}`);
}
+17
View File
@@ -0,0 +1,17 @@
/**
* 后端服务地址(仅 BFF 服务端使用,不得被任何客户端模块引用)。
*
* 客户端所有请求同源走 /api/proxy、/api/sse、/api/auth,不需要也不应知道后端真实
* 地址。故此变量不使用 NEXT_PUBLIC_ 前缀,不会进入客户端 bundle。
*
* 前后端分机部署时,将 BACKEND_URL 指向后端服务器(跨公网须 https://)。
*/
/** 后端服务根地址(不含 API 前缀) */
export const BACKEND_URL = process.env.BACKEND_URL ?? "http://localhost:8000";
/** 后端 HTTP API 前缀 */
export const API_PREFIX = "/api/v1";
/** 后端 API 绝对根(含前缀),BFF service 拼路径时使用 */
export const BACKEND_ROOT = `${BACKEND_URL}${API_PREFIX}`;
+61
View File
@@ -0,0 +1,61 @@
/**
* BFF Cookie 读写工具(仅服务端使用)。
*
* token 唯一存于 httpOnly cookie,由 BFF 路由在服务端读写。
* 客户端 JS 永不接触 token,从根本上消除 XSS 窃取 token 的面。
*/
import { cookies } from "next/headers";
/** access_token cookie 名 */
export const COOKIE_ACCESS = "hunjing-access";
/** refresh_token cookie 名 */
export const COOKIE_REFRESH = "hunjing-refresh";
/** access/refresh cookie 有效期:7 天 */
export const COOKIE_MAX_AGE = 7 * 86400;
const isProd = process.env.NODE_ENV === "production";
/** 写入 access/refresh cookie 的统一选项 */
export const AUTH_COOKIE_OPTIONS = {
httpOnly: true,
secure: isProd,
sameSite: "lax" as const,
path: "/",
maxAge: COOKIE_MAX_AGE,
};
/** 清除 cookie 的统一选项(maxAge=0 立即过期) */
export const AUTH_COOKIE_CLEAR_OPTIONS = {
httpOnly: true,
path: "/",
maxAge: 0,
};
/** 从 httpOnly cookie 读取 access_token */
export async function getAccessToken(): Promise<string | null> {
const store = await cookies();
return store.get(COOKIE_ACCESS)?.value ?? null;
}
/** 从 httpOnly cookie 读取 refresh_token */
export async function getRefreshToken(): Promise<string | null> {
const store = await cookies();
return store.get(COOKIE_REFRESH)?.value ?? null;
}
/** 将 access_token、refresh_token 写入 httpOnly cookie */
export async function setAuthCookies(
access: string,
refresh: string,
): Promise<void> {
const store = await cookies();
store.set(COOKIE_ACCESS, access, AUTH_COOKIE_OPTIONS);
store.set(COOKIE_REFRESH, refresh, AUTH_COOKIE_OPTIONS);
}
/** 清除 httpOnly cookie 中的 access_token 和 refresh_token */
export async function clearAuthCookies(): Promise<void> {
const store = await cookies();
store.set(COOKIE_ACCESS, "", AUTH_COOKIE_CLEAR_OPTIONS);
store.set(COOKIE_REFRESH, "", AUTH_COOKIE_CLEAR_OPTIONS);
}
+18
View File
@@ -0,0 +1,18 @@
/**
* BFF 核心服务层 — 统一导出。
*
* 所有后端调用、鉴权逻辑、数据聚合收拢至此。
* 仅服务端使用,不能泄露到客户端 bundle。
*/
export * as authService from "./services/auth";
export { BACKEND_URL, API_PREFIX, BACKEND_ROOT } from "./config";
export {
getAccessToken,
getRefreshToken,
setAuthCookies,
clearAuthCookies,
COOKIE_ACCESS,
COOKIE_REFRESH,
AUTH_COOKIE_OPTIONS,
AUTH_COOKIE_CLEAR_OPTIONS,
} from "./cookies";
+87
View File
@@ -0,0 +1,87 @@
/**
* BFF 通用代理服务(仅服务端使用)。
*
* 封装请求转发逻辑:构建请求头、注入 Bearer token、响应头透传过滤。
* proxy route handler 只负责编排:读路径 → 转发 → 401 重试 → 返回。
*/
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
/** 客户端 → 后端允许透传的请求头白名单(剔除 host / cookie / cf-* 等环境头)。 */
const FORWARD_HEADERS = [
"content-type",
"accept",
"accept-language",
"idempotency-key",
];
/**
* 构建转发给后端的请求头:白名单透传 + 注入 Bearer。
*/
export function buildHeaders(
req: NextRequest,
token: string | null,
): Record<string, string> {
const headers: Record<string, string> = {};
for (const key of FORWARD_HEADERS) {
const v = req.headers.get(key);
if (v) headers[key] = v;
}
if (token) headers["authorization"] = `Bearer ${token}`;
return headers;
}
/**
* 转发请求到后端目标地址。
* body 为已缓冲的 ArrayBuffer,可在 401 重试时复用。
*/
export function forward(
target: string,
req: NextRequest,
token: string | null,
body: ArrayBuffer | null,
): Promise<Response> {
const init: RequestInit = {
method: req.method,
headers: buildHeaders(req, token),
cache: "no-store",
};
if (body && req.method !== "GET" && req.method !== "HEAD") {
init.body = body;
}
return fetch(target, init);
}
/**
* 响应头透传,过滤 set-cookie(避免后端 cookie 干扰 BFF 的 cookie 管理)。
*/
export function passthroughHeaders(src: Headers): Headers {
const h = new Headers();
src.forEach((value, key) => {
if (key.toLowerCase() === "set-cookie") return;
h.set(key, value);
});
return h;
}
/**
* 构建后端目标 URL。
*/
export function buildTargetUrl(
backend: string,
path: string[],
search: string,
): string {
return `${backend}/${path.join("/")}${search}`;
}
/**
* 构造代理响应的 NextResponse。
*/
export function proxyResponse(resp: Response): NextResponse {
return new NextResponse(resp.body, {
status: resp.status,
statusText: resp.statusText,
headers: passthroughHeaders(resp.headers),
});
}
+151
View File
@@ -0,0 +1,151 @@
/**
* BFF 认证服务(仅服务端使用)。
*
* 封装所有与后端 /auth/* 端点的交互,供 app/api/auth/* 路由调用。
* 统一处理:fetch 后端、解析响应、cookie 管理、错误归一。
*/
import { BACKEND_ROOT } from "@/lib/backend/config";
import type { UserResponse, LoginResponse } from "@/types/auth";
import type { ApiErrorBody } from "@/types/api";
import {
getAccessToken,
getRefreshToken,
setAuthCookies,
clearAuthCookies,
} from "../cookies";
const BACKEND = BACKEND_ROOT;
/** BFF 服务端统一的业务错误 */
export class AuthServiceError extends Error {
readonly status: number;
readonly code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.name = "AuthServiceError";
this.status = status;
this.code = code;
}
}
/**
* 登录:验证用户名密码 → 后端 /auth/login → 写入 httpOnly cookie → 返回结果。
* 成功返回 { user, access_token, refresh_token, expires_in }。
* 失败抛出 AuthServiceError。
*/
export async function login(
username: string,
password: string,
): Promise<LoginResponse> {
const resp = await fetch(`${BACKEND}/auth/login`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!resp.ok) {
const errBody = (await resp.json().catch(() => ({}))) as
| ApiErrorBody
| Record<string, unknown>;
const err = "error" in errBody ? (errBody as ApiErrorBody).error : null;
throw new AuthServiceError(
resp.status,
err?.code ?? "unauthorized",
err?.message ?? "登录失败,请检查用户名和密码。",
);
}
const data = (await resp.json()) as LoginResponse;
await setAuthCookies(data.access_token, data.refresh_token);
return data;
}
/**
* 登出:清除 httpOnly cookie。
* 纯客户端操作,不调后端。
*/
export async function logout(): Promise<void> {
await clearAuthCookies();
}
/**
* 获取当前用户信息。
* 从 cookie 读取 access_token,调后端 /auth/me。
* 成功返回 UserResponse;401 返回 null。
*/
export async function getMe(): Promise<UserResponse | null> {
const token = await getAccessToken();
if (!token) return null;
const resp = await fetch(`${BACKEND}/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (resp.ok) {
return (await resp.json()) as UserResponse;
}
// 非 401 错误也返回 null(让调用方决定如何处理)
if (resp.status === 401) return null;
return null;
}
/**
* 刷新 token:从 cookie 读 refresh_token → 调后端 /auth/refresh。
* 成功更新 cookie 并返回新 access_token;失败返回 null(不清除 cookie,由调用方决定)。
*/
export async function refreshToken(): Promise<string | null> {
const refresh = await getRefreshToken();
if (!refresh) return null;
try {
const resp = await fetch(`${BACKEND}/auth/refresh`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ refresh_token: refresh }),
cache: "no-store",
});
if (!resp.ok) return null;
const data = (await resp.json()) as {
access_token?: string;
refresh_token?: string;
user?: UserResponse;
expires_in?: number;
};
if (!data.access_token) return null;
await setAuthCookies(data.access_token, data.refresh_token ?? refresh);
return data.access_token;
} catch {
return null;
}
}
/**
* 令牌刷新 + 重试一次请求(供 proxy/sse 路由使用)。
* 成功重试后直接返回后端 Response;刷新失败返回 null。
*/
export async function refreshAndRetry(
target: string,
init: RequestInit,
body: ArrayBuffer | null,
): Promise<Response | null> {
const newToken = await refreshToken();
if (!newToken) return null;
const retryInit: RequestInit = {
...init,
headers: {
...(init.headers as Record<string, string>),
Authorization: `Bearer ${newToken}`,
},
};
if (body && init.method !== "GET" && init.method !== "HEAD") {
retryInit.body = body;
}
return fetch(target, retryInit);
}
+31
View File
@@ -0,0 +1,31 @@
/**
* BFF SSE 代理服务(仅服务端使用)。
*
* 封装 SSE 连接逻辑,供 /api/sse/[...path] route handler 使用。
* 解决 EventSource 无法携带自定义 header 的问题。
*/
/**
* 建立 SSE 连接到后端,注入 Bearer token。
*/
export async function connect(
target: string,
token: string | null,
lastEventId: string | null,
): Promise<Response> {
const headers: Record<string, string> = { accept: "text/event-stream" };
if (token) headers["authorization"] = `Bearer ${token}`;
if (lastEventId) headers["last-event-id"] = lastEventId;
return fetch(target, { headers, cache: "no-store" });
}
/**
* 构建 SSE 代理的目标 URL。
*/
export function buildTargetUrl(
backend: string,
path: string[],
search: string,
): string {
return `${backend}/${path.join("/")}${search}`;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* 前端运行时配置(客户端可见部分)。
*
* - 客户端只允许读取 NEXT_PUBLIC_ 开头的公开变量(environment-config-design §2)。
* - 后端真实地址(BACKEND_URL / API_PREFIX)是服务端私有,见 src/lib/backend.ts,
* 不在此暴露,避免泄漏到客户端 bundle。
*
* 认证架构(纯 BFF):token 唯一存于 httpOnly cookie,由 BFF 路由在服务端读写。
* 浏览器侧所有业务请求经 /api/proxy 代理(自动携带 cookie + BFF 注入 Bearer),
* SSE 经 /api/sse 代理;客户端 JS 永不接触 token。
*/
/**
* 浏览器业务请求入口:经 BFF /api/proxy 代理。
* 同源请求浏览器自动携带 httpOnly cookie,BFF 在服务端注入 Authorization 后转发后端。
*/
export const API_PROXY_ROOT = "/api/proxy";
/**
* 浏览器 SSE 订阅入口:经 BFF /api/sse 代理。
* EventSource 同源请求自动携带 cookie,BFF 注入 Bearer 并以 ReadableStream 透传。
*/
export const API_SSE_ROOT = "/api/sse";
/** 功能开关(仅 NEXT_PUBLIC_ 开头暴露给浏览器) */
export const FEATURE_FLAGS = {
debugPanel: process.env.NEXT_PUBLIC_ENABLE_DEBUG_PANEL !== "false",
avatar: process.env.NEXT_PUBLIC_ENABLE_AVATAR === "true",
drivePanel: process.env.NEXT_PUBLIC_ENABLE_DRIVE_PANEL !== "false",
worldPanel: process.env.NEXT_PUBLIC_ENABLE_WORLD_PANEL !== "false",
/** 辰正时态面板(默认启用) */
timePanel: process.env.NEXT_PUBLIC_ENABLE_TIME_PANEL !== "false",
/** 司命台管理控制台入口(默认开启,is_admin 用户可见) */
adminPanel: process.env.NEXT_PUBLIC_ENABLE_ADMIN_PANEL !== "false",
} as const;
+335
View File
@@ -0,0 +1,335 @@
/**
* 枚举值的中文展示映射。
*
* 取值边界以 docs/contracts/contract-glossary.md 为准;此处只负责把
* 机器枚举映射成界面文案,不复制定义。新增枚举值需先在契约字典登记。
*/
import type {
AgentRole,
AuditDecision,
CharacterStatus,
ChatMessageRole,
ConsentStatus,
ErrorCode,
GoalLevel,
GoalStatus,
GoalType,
MemorySource,
MemoryStatus,
MemoryType,
ModelCapability,
ModelProvider,
EndpointStatus,
RoutingStrategy,
PerceptionChannelType,
Pivot,
PivotHealth,
PlanStatus,
RiskLevel,
RunStatus,
Sensitivity,
WorldEntityType,
WorldStateType,
} from "@/types/domain";
import type { PermissionPolicy } from "@/types/api";
export const CHARACTER_STATUS_LABEL: Record<CharacterStatus, string> = {
wuji: "无极",
named: "已命名",
seeded: "已播种子",
initialized: "已初始化",
active: "运行中",
paused: "已暂停",
archived: "已归档",
};
export const RUN_STATUS_LABEL: Record<RunStatus, string> = {
queued: "排队中",
running: "运行中",
waiting_confirmation: "待确认",
completed: "已完成",
failed: "已失败",
cancelled: "已取消",
};
export const MESSAGE_ROLE_LABEL: Record<ChatMessageRole, string> = {
user: "你",
assistant: "人物",
system: "系统",
tool: "工具",
};
export const MEMORY_TYPE_LABEL: Record<MemoryType, string> = {
identity: "身份",
user: "用户",
episodic: "情节",
semantic: "语义",
procedural: "程序",
relational: "关系",
emotional: "情绪",
world: "世界",
reflection: "反思",
evolution: "演化",
};
export const MEMORY_SOURCE_LABEL: Record<MemorySource, string> = {
user_explicit: "用户明确",
conversation: "对话抽取",
material_import: "资料导入",
tool_result: "工具结果",
reflection: "反思生成",
};
export const MEMORY_STATUS_LABEL: Record<MemoryStatus, string> = {
active: "有效",
archived: "已归档",
deleted: "已删除",
superseded: "已取代",
};
export const SENSITIVITY_LABEL: Record<Sensitivity, string> = {
none: "无敏感",
low: "低敏",
medium: "中敏",
high: "高敏",
};
export const RISK_LEVEL_LABEL: Record<RiskLevel, string> = {
L0: "无风险",
L1: "低风险",
L2: "中风险",
L3: "高风险",
L4: "禁止",
};
/** 错误码的简短中文提示,用于错误状态展示 */
export const ERROR_CODE_LABEL: Record<ErrorCode, string> = {
invalid_request: "请求无效",
validation_failed: "校验失败",
unauthorized: "认证过期",
permission_denied: "无访问权限",
requires_confirmation: "需要确认",
not_found: "未找到资源",
conflict: "状态冲突",
payload_too_large: "内容超限",
unsupported_media_type: "不支持的类型",
context_overflow: "上下文超限",
rate_limited: "请求过快",
model_error: "模型不可用",
tool_error: "工具执行失败",
safety_rejected: "被安全策略拒绝",
internal_error: "服务内部错误",
not_implemented: "该能力尚未支持",
};
/** 九枢九象名(契约字典 §5.9),星盘标签与速览共用 */
export const PIVOT_LABEL: Record<Pivot, string> = {
personality_core: "灵核",
goal_orchestrator: "太一",
safety_governor: "司命",
perception: "烛照",
world_model: "河图",
planner: "洛书",
memory: "息壤",
tool_runtime: "百工",
agent_society: "天工阁",
};
/** 九枢全称(九象 + 枢),用于速览与详情标题 */
export const PIVOT_FULL_LABEL: Record<Pivot, string> = {
personality_core: "灵核枢",
goal_orchestrator: "太一枢",
safety_governor: "司命枢",
perception: "烛照枢",
world_model: "河图枢",
planner: "洛书枢",
memory: "息壤枢",
tool_runtime: "百工枢",
agent_society: "天工阁枢",
};
/** 九枢工程模块英文名(契约字典 §5.9),用于副标题 */
export const PIVOT_MODULE_LABEL: Record<Pivot, string> = {
personality_core: "Personality Core",
goal_orchestrator: "Goal Orchestrator",
safety_governor: "Safety Governor",
perception: "Perception Layer",
world_model: "World Model",
planner: "Planner",
memory: "Memory System",
tool_runtime: "Tool Runtime",
agent_society: "Agent Society",
};
/** 九枢短码(铜镜星盘 mono 副标用,展示派生,非契约枚举) */
export const PIVOT_CODE_LABEL: Record<Pivot, string> = {
personality_core: "CORE",
goal_orchestrator: "GOAL",
safety_governor: "SAFETY",
perception: "PERCEPT",
world_model: "WORLD",
planner: "PLANNER",
memory: "MEMORY",
tool_runtime: "TOOLS",
agent_society: "AGENTS",
};
/** 九枢健康状态(契约字典 §5.10),与铜镜四态对应 */
export const PIVOT_HEALTH_LABEL: Record<PivotHealth, string> = {
void: "离线",
breathing: "运转中",
steady: "稳定",
cracked: "告警",
};
/** 工具权限策略(契约字典 §5.6),权限矩阵与朱批卡共用 */
export const PERMISSION_POLICY_LABEL: Record<PermissionPolicy, string> = {
allow: "允许",
ask: "询问",
always_ask: "每次确认",
forbidden: "禁止",
};
/** 审计裁定结果(契约字典 §13.3),起居注时间河共用 */
export const AUDIT_DECISION_LABEL: Record<AuditDecision, string> = {
auto: "自动放行",
approved: "朱批放行",
denied: "驳回",
blocked: "策略阻断",
};
/** 授权状态(契约字典 §5.7),烛照枢感知授权共用 */
export const CONSENT_STATUS_LABEL: Record<ConsentStatus, string> = {
unknown: "未授权",
granted: "已授权",
restricted: "受限",
revoked: "已撤销",
};
/** 目标层级(契约字典 §5.11),太一枢 */
export const GOAL_LEVEL_LABEL: Record<GoalLevel, string> = {
highest: "最高目标",
long_term: "长期",
session: "会话",
task: "任务",
action: "动作",
};
/** 目标状态(契约字典 §5.12),太一枢 */
export const GOAL_STATUS_LABEL: Record<GoalStatus, string> = {
pending: "待启动",
active: "进行中",
completed: "已完成",
blocked: "受阻",
cancelled: "已取消",
};
/** 目标类型(契约字典 §5.15),太一枢 */
export const GOAL_TYPE_LABEL: Record<GoalType, string> = {
mission: "使命",
relationship: "关系",
skill: "技能",
knowledge: "知识",
maintenance: "维护",
exploration: "探索",
};
/** 计划状态(契约字典 §5.13),洛书枢 */
export const PLAN_STATUS_LABEL: Record<PlanStatus, string> = {
draft: "草案",
approved: "已批准",
running: "执行中",
completed: "已完成",
failed: "失败",
};
/** 世界实体类型(契约字典 §11.1),河图枢 */
export const WORLD_ENTITY_TYPE_LABEL: Record<WorldEntityType, string> = {
self: "自身",
person: "人物",
object: "物体",
place: "地点",
device: "设备",
project: "项目",
task: "任务",
concept: "概念",
};
/** 世界状态类型(契约字典 §11.2),河图枢区分事实与推断 */
export const WORLD_STATE_TYPE_LABEL: Record<WorldStateType, string> = {
fact: "事实",
observation: "观察",
inference: "推断",
hypothesis: "假设",
stale: "过期",
};
/** 烛照感知通道(九象 §7) */
export const PERCEPTION_CHANNEL_LABEL: Record<PerceptionChannelType, string> = {
vision: "视觉",
audio: "听觉",
sensor: "传感",
digital: "数字",
user_input: "用户输入",
external_api: "外部接口",
};
/** 天工阁九角色(契约字典 §5.14),天工阁枢 */
export const AGENT_ROLE_LABEL: Record<AgentRole, string> = {
historian: "史官",
diviner: "卜者",
engineer: "工师",
debater: "辩者",
medic: "医官",
herald: "司礼",
charioteer: "御者",
scholar: "学士",
mirror: "镜师",
};
/** 天工阁角色在岗呈现态(派生态,非持久化枚举) */
export const AGENT_PRESENCE_LABEL: Record<"idle" | "active", string> = {
idle: "待命",
active: "在岗",
};
/** 模型供应商(契约字典 §16.1),网关面板与策略选择共用 */
export const MODEL_PROVIDER_LABEL: Record<ModelProvider, string> = {
openai: "OpenAI",
deepseek: "DeepSeek",
anthropic: "Anthropic",
google: "Google",
minimax: "MiniMax",
zhipu: "智谱",
tencentcloud: "腾讯云",
volcengine: "火山引擎",
bailian: "阿里云百炼",
};
/** 路由策略(契约字典 §16.2) */
export const ROUTING_STRATEGY_LABEL: Record<RoutingStrategy, string> = {
single: "单点路由",
priority_failover: "优先级故障转移",
round_robin: "轮询",
weighted_random: "权重随机",
sticky_weighted_random: "粘性权重随机",
};
/** endpoint 状态(契约字典 §16.3),网关面板端点池用 */
export const ENDPOINT_STATUS_LABEL: Record<EndpointStatus, string> = {
active: "可用",
inactive: "已停用",
unhealthy: "不健康",
cooldown: "冷却中",
};
/** 模型能力(契约字典 §16.4),逻辑模型能力标签用 */
export const MODEL_CAPABILITY_LABEL: Record<ModelCapability, string> = {
chat: "对话",
tool_use: "工具调用",
structured_output: "结构化输出",
reasoning: "深度推理",
long_context: "长上下文",
vision: "图像理解",
};
@@ -0,0 +1,20 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { useDragScroll } from "./useDragScroll";
describe("useDragScroll", () => {
it("返回 ref 和事件 handlers", () => {
const { result } = renderHook(() => useDragScroll<HTMLDivElement>());
expect(result.current.ref).toBeDefined();
expect(result.current.handlers.onPointerDown).toBeInstanceOf(Function);
expect(result.current.handlers.onPointerMove).toBeInstanceOf(Function);
expect(result.current.handlers.onPointerUp).toBeInstanceOf(Function);
expect(result.current.handlers.onPointerLeave).toBeInstanceOf(Function);
});
it("初始 dragRef.moved 为 false", () => {
const { result } = renderHook(() => useDragScroll<HTMLDivElement>());
expect(result.current.dragRef.current?.moved).toBe(false);
});
});
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { useCallback, useRef } from "react";
import type { PointerEvent, RefObject } from "react";
interface DragState {
active: boolean;
startX: number;
left: number;
/** 本次拖拽是否发生过明显位移,用于区分"拖动"与"点击" */
moved: boolean;
}
/**
* 指针拖拽横向滚动(drag-to-scroll)。
* 用原生 scrollLeft + pointer capture,惯性自然、触控友好;
* 暴露 moved 标志,供子节点判断是否抑制 click。
*/
export function useDragScroll<T extends HTMLElement>() {
const ref = useRef<T>(null);
const drag = useRef<DragState>({
active: false,
startX: 0,
left: 0,
moved: false,
});
const onPointerDown = useCallback((e: PointerEvent<T>) => {
const el = ref.current;
if (!el) return;
drag.current = {
active: true,
startX: e.clientX,
left: el.scrollLeft,
moved: false,
};
el.setPointerCapture(e.pointerId);
}, []);
const onPointerMove = useCallback((e: PointerEvent<T>) => {
if (!drag.current.active) return;
const el = ref.current;
if (!el) return;
const dx = e.clientX - drag.current.startX;
if (Math.abs(dx) > 4) drag.current.moved = true;
el.scrollLeft = drag.current.left - dx;
}, []);
const end = useCallback(() => {
drag.current.active = false;
}, []);
return {
ref,
dragRef: drag as RefObject<DragState>,
handlers: {
onPointerDown,
onPointerMove,
onPointerUp: end,
onPointerLeave: end,
},
};
}
@@ -0,0 +1,57 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { EGG_VERSES, pickEggVerse, useEasterEgg } from "./useEasterEgg";
describe("useEasterEgg", () => {
it("连点 5 次触发 onReveal", () => {
const onReveal = vi.fn();
const { result } = renderHook(() => useEasterEgg({ onReveal }));
for (let i = 0; i < 5; i++) act(() => result.current.onClick());
expect(onReveal).toHaveBeenCalledTimes(1);
});
it("不足 5 次不触发 onReveal", () => {
const onReveal = vi.fn();
const { result } = renderHook(() => useEasterEgg({ onReveal }));
for (let i = 0; i < 4; i++) act(() => result.current.onClick());
expect(onReveal).not.toHaveBeenCalled();
});
it("连点 9 次触发 onAscend(九枢升格)", () => {
const onAscend = vi.fn();
const { result } = renderHook(() => useEasterEgg({ onAscend }));
for (let i = 0; i < 9; i++) act(() => result.current.onClick());
expect(onAscend).toHaveBeenCalledTimes(1);
});
it("双层彩蛋:5 次弹谶语、9 次升格,各触发一次", () => {
const onReveal = vi.fn();
const onAscend = vi.fn();
const { result } = renderHook(() => useEasterEgg({ onReveal, onAscend }));
for (let i = 0; i < 9; i++) act(() => result.current.onClick());
expect(onReveal).toHaveBeenCalledTimes(1);
expect(onAscend).toHaveBeenCalledTimes(1);
});
it("升格后归零,可再次触发双层", () => {
const onReveal = vi.fn();
const onAscend = vi.fn();
const { result } = renderHook(() => useEasterEgg({ onReveal, onAscend }));
for (let i = 0; i < 9; i++) act(() => result.current.onClick());
for (let i = 0; i < 9; i++) act(() => result.current.onClick());
expect(onReveal).toHaveBeenCalledTimes(2);
expect(onAscend).toHaveBeenCalledTimes(2);
});
it("pickEggVerse 返回谶语池中一条", () => {
expect(EGG_VERSES).toContain(pickEggVerse());
});
it("未传回调时达阈值不抛错", () => {
const { result } = renderHook(() => useEasterEgg());
expect(() => {
for (let i = 0; i < 9; i++) act(() => result.current.onClick());
}).not.toThrow();
});
});
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { useCallback, useRef } from "react";
/** 镜启彩蛋谶语(设计稿 eggVerses 同源) */
export const EGG_VERSES = [
"镜中有镜,心外无心。",
"汝来看此镜时,此镜亦照汝。",
"一念起,万镜生。",
"镜非鉴物,乃鉴心也。",
"幻由心生,镜随心转。",
"九枢归一,一归于何处?",
"铜镜本无字,照尽往来人。",
"且向镜中看,那个是谁?",
] as const;
/** 随机取一条谶语 */
export function pickEggVerse(): string {
return EGG_VERSES[Math.floor(Math.random() * EGG_VERSES.length)];
}
/** 镜启阈值:连点 5 次弹随机谶语(设计稿同源) */
const REVEAL_THRESHOLD = 5;
/** 九枢升格阈值:继续连点至 9 次(九枢圆满)升格全屏「九枢既明」 */
const ASCEND_THRESHOLD = 9;
/** 两次点击间隔超过此值则重新计数 */
const RESET_MS = 1200;
export interface EasterEggHandlers {
/** 5 次:弹随机谶语 + 铜镜辉光(常规彩蛋,计数不归零、继续累计) */
onReveal?: () => void;
/** 9 次:九枢圆满升格全屏(稀有彩蛋,触发后归零) */
onAscend?: () => void;
}
/**
* 镜启彩蛋(双层):
* - RESET_MS 内连点 5 次 → onReveal(弹随机谶语 + 铜镜辉光),计数不归零;
* - 继续连点至 9 次 → onAscend(九枢升格全屏),触发后归零。
* 间隔超时则计数归零。
*/
export function useEasterEgg({ onReveal, onAscend }: EasterEggHandlers = {}) {
const countRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const onClick = useCallback(() => {
countRef.current += 1;
if (timerRef.current) clearTimeout(timerRef.current);
const c = countRef.current;
if (c >= ASCEND_THRESHOLD) {
countRef.current = 0;
onAscend?.();
return;
}
if (c === REVEAL_THRESHOLD) {
onReveal?.();
}
timerRef.current = setTimeout(() => {
countRef.current = 0;
}, RESET_MS);
}, [onReveal, onAscend]);
return { onClick };
}
@@ -0,0 +1,32 @@
"use client";
/**
* useGlobalEventStream:订阅全局一炁事件流的 React Hook。
*
* 连接后端 GET /api/v1/events 端点,接收 proactive.session 等全局广播事件。
* 组件挂载时建立连接,卸载时自动关闭。
* 与 useRunStream 独立——此 Hook 不依赖 runId,只要组件存活就保持连接。
*/
import { useEffect, useRef } from "react";
import {
createGlobalEventStream,
type RunStreamHandlers,
} from "@/lib/sse-client";
export function useGlobalEventStream(handlers: RunStreamHandlers): void {
const handlersRef = useRef(handlers);
useEffect(() => {
handlersRef.current = handlers;
});
useEffect(() => {
const controller = createGlobalEventStream({
onEvent: (event) => handlersRef.current.onEvent?.(event),
onOpen: () => handlersRef.current.onOpen?.(),
onError: () => handlersRef.current.onError?.(),
});
return () => controller.close();
}, []);
}
+46
View File
@@ -0,0 +1,46 @@
/**
* usePermission — 权限检查的 hook 版本。
*
* 供无法使用 <PermissionGuard> 的场景(如条件渲染、菜单禁用态)。
*/
import { useMemo } from "react";
import { useAuthStore } from "@/lib/stores/auth-store";
import {
can as canCheck,
canAll as canAllCheck,
canAny as canAnyCheck,
type Permission,
} from "@/components/shared/PermissionGuard";
interface UsePermissionReturn {
/** 是否拥有指定权限 */
can: (permission: Permission) => boolean;
/** 是否拥有所有指定权限 */
canAll: (permissions: Permission[]) => boolean;
/** 是否拥有任意指定权限 */
canAny: (permissions: Permission[]) => boolean;
/** 用户当前权限列表 */
permissions: string[];
/** 是否已认证 */
isAuthenticated: boolean;
}
export function usePermission(): UsePermissionReturn {
const userPermissions = useAuthStore(
(s) => (s.user?.permissions as string[] | undefined) ?? [],
);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
return useMemo(
() => ({
can: (permission: Permission) => canCheck(permission, userPermissions),
canAll: (perms: Permission[]) => canAllCheck(perms, userPermissions),
canAny: (perms: Permission[]) => canAnyCheck(perms, userPermissions),
permissions: userPermissions,
isAuthenticated,
}),
[userPermissions, isAuthenticated],
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
/**
* useRunStream:订阅 Run 事件流的 React Hook。
*
* 仅当 runId 存在时建立 SSE 连接,runId 变化或组件卸载时自动关闭。
* 本 Hook 处理由用户发送消息触发的实时订阅,不属于「页面初始数据请求」。
* options(thinking / effort)透传给底层,供 Mock 流决定是否前置推送思考摘要
* 及其详尽程度。
*/
import { useEffect, useRef } from "react";
import { createRunStream, type RunStreamHandlers } from "@/lib/sse-client";
import type { ThinkingEffort } from "@/types/thinking";
export interface RunStreamOptions {
/** 是否开启思考模式(仅 Mock 流消费,决定是否推送 reasoning 事件) */
thinking?: boolean;
/** 思考力度(决定思考摘要的详尽程度与流式粒度) */
effort?: ThinkingEffort;
/** Mock 模式下的人物名(决定回复风格),透传给 createRunStream */
characterName?: string;
}
export function useRunStream(
runId: string | null | undefined,
handlers: RunStreamHandlers,
options?: RunStreamOptions,
): void {
// 用 ref 持有最新回调与选项,避免 runId 不变时因引用变化反复重连。
// 在 effect 中同步 ref,禁止在 render 阶段写入 ref(react-hooks/refs)。
const handlersRef = useRef(handlers);
const optionsRef = useRef(options);
useEffect(() => {
handlersRef.current = handlers;
optionsRef.current = options;
});
useEffect(() => {
if (!runId) return;
const controller = createRunStream(
runId,
{
onEvent: (event) => handlersRef.current.onEvent?.(event),
onOpen: () => handlersRef.current.onOpen?.(),
onError: () => handlersRef.current.onError?.(),
},
optionsRef.current,
);
return () => controller.close();
}, [runId]);
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { useCallback, useState } from "react";
import { safetyApi, toolsApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import type { ToolResultPayload } from "@/types/api";
/**
* 工具调用状态机:idle → running →(completed | confirming | error)。
* confirming 时交由确认卡片 approve/deny;approve 后回到 running 并落 completed。
* 错误统一经 describeError 归一为可展示文案。
*/
export type ToolInvokeState =
| { phase: "idle" }
| { phase: "running"; tool: string }
| {
phase: "confirming";
tool: string;
confirmation_id: string;
risk_level: string;
reason: string;
}
| { phase: "completed"; result: ToolResultPayload }
| { phase: "error"; message: string };
export interface InvokeContext {
run_id?: string;
character_id?: string;
trace_id?: string;
}
export function useToolInvoke() {
const [state, setState] = useState<ToolInvokeState>({ phase: "idle" });
const [busy, setBusy] = useState(false);
const invoke = useCallback(
async (
tool: string,
input: Record<string, unknown>,
ctx?: InvokeContext,
) => {
setBusy(true);
setState({ phase: "running", tool });
try {
const res = await toolsApi.invokeTool(tool, { input, ...ctx });
if (res.status === "completed") {
setState({ phase: "completed", result: res.result });
return res.result;
}
setState({
phase: "confirming",
tool,
confirmation_id: res.confirmation_id,
risk_level: res.risk_level,
reason: res.reason,
});
return null;
} catch (e) {
setState({ phase: "error", message: describeError(e) });
throw e;
} finally {
setBusy(false);
}
},
[],
);
const approve = useCallback(async () => {
if (state.phase !== "confirming") return;
const confirmationId = state.confirmation_id;
setBusy(true);
try {
const res = await safetyApi.approveConfirmation(confirmationId);
if (res.status === "completed") {
setState({ phase: "completed", result: res.result });
}
} catch (e) {
setState({ phase: "error", message: describeError(e) });
throw e;
} finally {
setBusy(false);
}
}, [state]);
const deny = useCallback(async () => {
if (state.phase !== "confirming") return;
const confirmationId = state.confirmation_id;
setBusy(true);
try {
await safetyApi.denyConfirmation(confirmationId);
setState({ phase: "idle" });
} catch (e) {
setState({ phase: "error", message: describeError(e) });
} finally {
setBusy(false);
}
}, [state]);
const reset = useCallback(() => setState({ phase: "idle" }), []);
return { state, busy, invoke, approve, deny, reset };
}
+129
View File
@@ -0,0 +1,129 @@
"use client";
/**
* useToolStream:流式工具调用状态机。
*
* 通过 invokeToolStream(fetch + ReadableStream)订阅 SSE,实时把 stdout/stderr 增量
* 解码后 append 到 stdout/stderr 状态,供组件即时渲染。高风险工具经
* tool.requires_confirmation 进入 confirming 态,confirm 后以 confirmed=true 重跑。
* 支持 AbortController 中止(cancel)。组件卸载时应调用 cancel/reset。
*/
import { useCallback, useRef, useState } from "react";
import { invokeToolStream } from "@/lib/api/tools";
import { describeError } from "@/lib/utils/error";
import type { ToolStreamDone } from "@/types/api";
export type ToolStreamState =
| { phase: "idle" }
| { phase: "streaming"; tool: string }
| { phase: "completed"; done: ToolStreamDone }
| {
phase: "confirming";
tool: string;
risk_level: string;
reason: string;
}
| { phase: "error"; message: string };
export interface InvokeContext {
run_id?: string;
character_id?: string;
trace_id?: string;
}
export function useToolStream() {
const [state, setState] = useState<ToolStreamState>({ phase: "idle" });
const [stdout, setStdout] = useState("");
const [stderr, setStderr] = useState("");
const [busy, setBusy] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const pendingRef = useRef<{
tool: string;
input: Record<string, unknown>;
ctx?: InvokeContext;
} | null>(null);
const runOnce = useCallback(
async (
tool: string,
input: Record<string, unknown>,
ctx: InvokeContext | undefined,
confirmed: boolean,
) => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setStdout("");
setStderr("");
setBusy(true);
setState({ phase: "streaming", tool });
try {
await invokeToolStream(
tool,
{ input, confirmed, ...ctx },
{
onStdout: (data) =>
setStdout((prev) => prev + decodeB64UrlNoPad(data)),
onStderr: (data) =>
setStderr((prev) => prev + decodeB64UrlNoPad(data)),
onCompleted: (done) => setState({ phase: "completed", done }),
onRequiresConfirmation: (risk_level, reason) =>
setState({ phase: "confirming", tool, risk_level, reason }),
onFailed: (code, message) =>
setState({ phase: "error", message: `${code}: ${message}` }),
},
ctrl.signal,
);
} catch (e) {
if (ctrl.signal.aborted) {
setState({ phase: "idle" });
} else {
setState({ phase: "error", message: describeError(e) });
}
} finally {
setBusy(false);
}
},
[],
);
const run = useCallback(
async (
tool: string,
input: Record<string, unknown>,
ctx?: InvokeContext,
) => {
pendingRef.current = { tool, input, ctx };
await runOnce(tool, input, ctx, false);
},
[runOnce],
);
const confirm = useCallback(async () => {
const pending = pendingRef.current;
if (!pending) return;
await runOnce(pending.tool, pending.input, pending.ctx, true);
}, [runOnce]);
const cancel = useCallback(() => {
abortRef.current?.abort();
}, []);
const reset = useCallback(() => {
setState({ phase: "idle" });
setStdout("");
setStderr("");
}, []);
return { state, stdout, stderr, busy, run, confirm, cancel, reset };
}
/** 解码 base64(urlsafe-no-pad) → UTF-8 字符串(命令输出可能含多字节)。 */
function decodeB64UrlNoPad(b64: string): string {
const std = b64.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(std + "=".repeat((4 - (std.length % 4)) % 4));
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
return new TextDecoder("utf-8").decode(bytes);
}
@@ -0,0 +1,117 @@
/**
* useVoiceInput 测试 — mock MediaRecorder 与 ASR client。
*/
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useVoiceInput } from "./useVoiceInput";
vi.mock("@/lib/api/audio", () => ({
transcribeAudio: vi.fn().mockResolvedValue({ text: "你好世界" }),
}));
import { transcribeAudio } from "@/lib/api/audio";
/** 最小 MediaRecorder stub:start/stop 并触发 ondataavailable / onstop。 */
class RecorderStub {
state = "inactive";
ondataavailable: ((e: { data: Blob }) => void) | null = null;
onstop: (() => void) | null = null;
start() {
this.state = "recording";
}
stop() {
this.state = "inactive";
this.ondataavailable?.({ data: new Blob([new Uint8Array([1, 2, 3])]) });
this.onstop?.();
}
}
describe("useVoiceInput", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("toggle 录音并转写后回调 onResult", async () => {
const onResult = vi.fn();
const onError = vi.fn();
const stopTracks = vi.fn();
const getTracks = () =>
[{ stop: stopTracks }] as unknown as MediaStreamTrack[];
Object.defineProperty(globalThis.navigator, "mediaDevices", {
value: {
getUserMedia: vi.fn().mockResolvedValue({ getTracks }),
},
configurable: true,
});
// 以 function 构造器作为 MediaRecorder,返回 stub 实例
function MR(this: unknown) {
return new RecorderStub();
}
Object.defineProperty(globalThis, "MediaRecorder", {
value: MR,
configurable: true,
});
Object.defineProperty(globalThis.MediaRecorder, "isTypeSupported", {
value: () => true,
configurable: true,
});
const { result } = renderHook(() =>
useVoiceInput({ language: "zh", onResult, onError }),
);
expect(result.current.status).toBe("idle");
await act(async () => {
await result.current.toggle();
});
expect(result.current.status).toBe("recording");
await act(async () => {
await result.current.toggle();
});
await waitFor(() => {
expect(result.current.status).toBe("idle");
});
expect(transcribeAudio).toHaveBeenCalledOnce();
expect(transcribeAudio).toHaveBeenCalledWith(
expect.any(Blob),
expect.stringMatching(/^voice-\d+\.webm$/),
"zh",
expect.any(AbortSignal),
"funasr-sensevoice",
);
expect(onResult).toHaveBeenCalledWith("你好世界");
expect(onError).not.toHaveBeenCalled();
expect(stopTracks).toHaveBeenCalled();
});
it("不支持 MediaRecorder 时触发 onError", async () => {
const onResult = vi.fn();
const onError = vi.fn();
Object.defineProperty(globalThis.navigator, "mediaDevices", {
value: { getUserMedia: vi.fn() },
configurable: true,
});
// 设为 undefined 模拟不支持
Object.defineProperty(globalThis, "MediaRecorder", {
value: undefined,
configurable: true,
});
const { result } = renderHook(() =>
useVoiceInput({ language: "zh", onResult, onError }),
);
await act(async () => {
await result.current.start();
});
expect(onError).toHaveBeenCalledWith("当前浏览器不支持 MediaRecorder。");
expect(onResult).not.toHaveBeenCalled();
});
});
+204
View File
@@ -0,0 +1,204 @@
/**
* useVoiceInput — 麦克风语音录入 hook。
*
* 流程:申请麦克风权限 → MediaRecorder 采集音频 → 停止后产出 Blob →
* 调用后端 ASR(OpenAI Whisper 兼容)转写为文本。组件层只关心三态:
* idle / recording / transcribing,以及最终文本与错误。
*
* 后端 ASR 引擎懒加载:首次请求可能返回 503(model_loading),调用方应
* 提示用户稍后重试(见 yuanqi/src/endpoints/asr.py)。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { transcribeAudio } from "@/lib/api/audio";
export type VoiceInputStatus = "idle" | "recording" | "transcribing";
export interface UseVoiceInputOptions {
/** 转写语言提示(ISO-639-1),默认 "zh" */
language?: string;
/** ASR 模型逻辑名,默认 "funasr-sensevoice"(本地 yuanqi 引擎) */
model?: string;
/** 转写成功回调(在组件内更新输入框,避免 effect 内 setState) */
onResult?: (text: string) => void;
/** 错误回调 */
onError?: (message: string) => void;
}
export interface UseVoiceInputReturn {
/** 当前状态 */
status: VoiceInputStatus;
/** 是否正在录音或转写 */
busy: boolean;
/** 开始录音 */
start: () => Promise<void>;
/** 停止录音并触发转写 */
stop: () => Promise<void>;
/** 切换录音状态 */
toggle: () => Promise<void>;
}
/** 选择一个浏览器支持的录音 MIME 类型,优先 webm/opus。 */
function pickMimeType(): string {
const candidates = [
"audio/webm;codecs=opus",
"audio/webm",
"audio/mp4",
"audio/ogg;codecs=opus",
];
if (typeof MediaRecorder === "undefined") return "";
for (const t of candidates) {
if (MediaRecorder.isTypeSupported(t)) return t;
}
return "";
}
export function useVoiceInput(
options: UseVoiceInputOptions = {},
): UseVoiceInputReturn {
const { language = "zh", model = "funasr-sensevoice" } = options;
const onResultRef = useRef(options.onResult);
const onErrorRef = useRef(options.onError);
// 保持回调为最新引用,但不参与录音流程的依赖
useEffect(() => {
onResultRef.current = options.onResult;
onErrorRef.current = options.onError;
});
const [status, setStatus] = useState<VoiceInputStatus>("idle");
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const mimeRef = useRef<string>("");
const abortRef = useRef<AbortController | null>(null);
/** 释放麦克风轨道与 recorder 引用。 */
const release = useCallback(() => {
if (recorderRef.current && recorderRef.current.state !== "inactive") {
try {
recorderRef.current.stop();
} catch {
/* 忽略 */
}
}
recorderRef.current = null;
if (streamRef.current) {
for (const track of streamRef.current.getTracks()) track.stop();
}
streamRef.current = null;
chunksRef.current = [];
}, []);
// 卸载时清理
useEffect(() => () => release(), [release]);
const start = useCallback(async () => {
if (
typeof navigator === "undefined" ||
!navigator.mediaDevices?.getUserMedia
) {
onErrorRef.current?.("当前环境不支持麦克风录音。");
return;
}
if (typeof MediaRecorder === "undefined") {
onErrorRef.current?.("当前浏览器不支持 MediaRecorder。");
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const mime = pickMimeType();
mimeRef.current = mime;
const recorder = mime
? new MediaRecorder(stream, { mimeType: mime })
: new MediaRecorder(stream);
chunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
};
recorderRef.current = recorder;
recorder.start();
setStatus("recording");
} catch (err) {
release();
const name = (err as DOMException)?.name;
let msg: string;
if (name === "NotAllowedError" || name === "SecurityError") {
msg = "麦克风权限被拒绝,请在浏览器设置中允许后重试。";
} else if (name === "NotFoundError") {
msg = "未检测到可用的麦克风设备。";
} else {
msg = "无法启动录音:" + ((err as Error).message ?? String(err));
}
onErrorRef.current?.(msg);
setStatus("idle");
}
}, [release]);
const stop = useCallback(async () => {
const recorder = recorderRef.current;
if (!recorder || recorder.state === "inactive") {
setStatus("idle");
return;
}
/** 录音停止后产出的 Blob。 */
const finalize = (): Promise<Blob> =>
new Promise((resolve) => {
recorder.onstop = () => {
const mime = mimeRef.current || "audio/webm";
resolve(new Blob(chunksRef.current, { type: mime }));
};
recorder.stop();
});
setStatus("transcribing");
try {
const blob = await finalize();
release();
const ext = mimeToExtension(blob.type);
const controller = new AbortController();
abortRef.current = controller;
const result = await transcribeAudio(
blob,
`voice-${Date.now()}.${ext}`,
language,
controller.signal,
model,
);
const text = result.text ?? "";
if (text) onResultRef.current?.(text);
} catch (err) {
if ((err as Error).name === "AbortError") return;
const msg = (err as Error).message ?? "语音转写失败。";
onErrorRef.current?.(msg);
} finally {
abortRef.current = null;
setStatus("idle");
}
}, [language, model, release]);
const toggle = useCallback(async () => {
if (status === "recording") await stop();
else if (status === "idle") await start();
}, [status, start, stop]);
return {
status,
busy: status !== "idle",
start,
stop,
toggle,
};
}
/** 根据 MediaRecorder 产出的 MIME 推断文件扩展名。 */
function mimeToExtension(mime: string): string {
if (mime.includes("webm")) return "webm";
if (mime.includes("mp4")) return "m4a";
if (mime.includes("ogg")) return "ogg";
if (mime.includes("wav")) return "wav";
return "webm";
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+135
View File
@@ -0,0 +1,135 @@
/**
* 计划 Mock 数据(洛书枢前瞻原型)。
*
* 仅用于前端 UI 验证,后端计划 API 落地后移除。数据结构与
* docs/domain/core-domains.md §5 一致;步骤状态刻意覆盖 pending/running/
* completed 等多种形态,以便测试渲染与时间线动画。
*
* 注意:当前阶段(docs/operations/capability-matrix.md §8)暂不实现「自动长期
* 任务规划」与「复杂工具编排」,故计划面板暂不在对话页展示——AI 单次回复中的
* 规划已由回复气泡内的「思考过程」(ThoughtBlock / reasoning)承担。PlanPanel
* 与本文件作为前瞻原型保留(scope §6 允许预留),待后端 plan.* 事件落地后启用。
*/
import type { Plan } from "@/types/plan";
/** 当前会话关联的模拟计划:协助用户撰写人物背景故事 */
export const mockActivePlan: Plan = {
id: "plan-2026-06-20-01",
goal_id: "goal-help-author-character-lore",
status: "running",
required_tools: ["memory_search", "safety_check", "doc_export"],
assumptions: [
"用户希望以第一人称叙述人物身世",
"现有身份与情节记忆足够支撑背景故事",
],
risks: ["人物早期记忆缺失,可能需要补充设定", "涉及高敏记忆,输出须脱敏"],
success_criteria: ["产出三段连贯的人物背景叙事", "用户确认关键设定无矛盾"],
steps: [
{
id: "step-1",
title: "检索人物身份与情节记忆",
description: "从息壤拉取 identity / episodic 记忆,构建身世时间线。",
tool: "memory_search",
status: "completed",
risk_level: "L1",
result_summary: "命中 12 条相关记忆,时间线覆盖降生至觉醒。",
},
{
id: "step-2",
title: "梳理关键转折事件",
description: "提取对人格成型影响最大的三处事件。",
tool: "memory_search",
status: "completed",
risk_level: "L2",
result_summary: "确定「镜湖决意」「铜镜碎裂」「誓言成约」三处转折。",
},
{
id: "step-3",
title: "生成第一人称背景叙事",
description: "以人物语气串联记忆与转折,产出叙事初稿。",
status: "running",
risk_level: "L2",
},
{
id: "step-4",
title: "高敏记忆脱敏校验",
description: "交由司命核对输出是否泄露高敏内容。",
tool: "safety_check",
status: "pending",
risk_level: "L3",
},
{
id: "step-5",
title: "导出文档供用户确认",
description: "将叙事整理为可分享的文档片段。",
tool: "doc_export",
status: "pending",
risk_level: "L1",
},
],
};
/**
* 会话级计划 Mock:贯穿整个对话的目标 todo 清单。
*
* 与 mockActivePlan(独立的长期任务计划)不同,这是「当前对话正在进行的计划」,
* 步骤随对话推进持续刷新状态。当前 mock 对话主题为深夜情绪陪伴,故计划围绕
* 「承接情绪 → 理解来由 → 引导觉察 → 留白收束」展开。
*
* ⚠️ SessionPlanCard 当前未在对话页挂载(implementation-status.md §8),本 Mock 仅作数据
* 原型保留。待后端 plan.* 运行事件落地(planner-design.md §5)后,于 AI 实际产出
* 计划时再挂载渲染。
*/
export const mockSessionPlan: Plan = {
id: "plan-sess-jingwu-001",
goal_id: "goal-accompany-sanren-tonight",
status: "running",
required_tools: ["memory_search"],
assumptions: [
"用户此刻需要被接住,而非被解决",
"情绪议题宜以意象承接,避免说教",
],
risks: ["触及高敏记忆须脱敏", "避免过度共情显得刻意"],
success_criteria: ["用户情绪得到承接与命名", "对话在留白处自然收束"],
steps: [
{
id: "sp-1",
title: "建立倾诉氛围",
description: "以温缓之语让用户愿意开口,不急于追问。",
status: "completed",
risk_level: "L1",
result_summary: "用户已开始袒露,自称「散人」。",
},
{
id: "sp-2",
title: "识别核心情绪",
description: "从字句间辨认用户此刻最沉的那一缕情绪。",
status: "completed",
risk_level: "L1",
result_summary: "判定为对「遗忘」的反复触碰,属倾诉而非求知。",
},
{
id: "sp-3",
title: "检索相关记忆,理解来由",
description: "从息壤拉取与该情绪相关的情节与用户记忆。",
tool: "memory_search",
status: "running",
risk_level: "L2",
},
{
id: "sp-4",
title: "以意象承接,引导自我觉察",
description: "用一个能承载情绪的意象,把情绪交给用户自己命名。",
status: "pending",
risk_level: "L2",
},
{
id: "sp-5",
title: "适时收束,留下回扣",
description: "在留白处轻轻点破,把主动权交还。",
status: "pending",
risk_level: "L1",
},
],
};
+310
View File
@@ -0,0 +1,310 @@
/**
* SSE Client(对应 后端生成的 OpenAPI 契约 §9、前端架构计划 §9)。
*
* - createRunStream: 订阅 GET /api/v1/runs/{run_id}/stream,处理核心运行事件。
* - createGlobalEventStream: 订阅 GET /api/v1/events,处理全局事件(如 proactive.session)。
* - 基于 EventSource:原生支持断线自动重连,并自动在重连时携带
* Last-Event-ID 请求头(API §5.7),后端据此按 sequence 补发后续事件。
* - 仅客户端可用;不在组件中直接实例化,统一封装在此处。
* - Mock 模式(NEXT_PUBLIC_ENABLE_MOCK=true)下不建立 EventSource,改用定时器
* 按序推 run.started → memory.retrieved →
* [reasoning.delta(分片) → reasoning.completed] →
* message.delta(分片) → message.completed → run.completed,使对话流式回放与
* 思考模式在无后端时亦可验证。思考力度(effort)决定思考摘要的详尽程度与
* 流式粒度:low 粗分片速出、high 细分片缓出。
*
* 思考摘要(reasoning)的展示语义对齐 docs/architecture/service-boundaries.md,
* 且 raw CoT 不得写入事件或日志(docs/architecture/data-and-security-architecture.md),
* 故 reasoning 仅作前端临时态、不持久化。
*/
import { API_SSE_ROOT } from "@/lib/config";
import { pickMockReasoning, pickMockReply } from "@/lib/mock/data";
import { USE_MOCK } from "@/lib/mock";
import type { RunEvent } from "@/types/api";
import type { ThinkingEffort } from "@/types/thinking";
/** 当前开发周期前端需处理的核心运行事件类型(API §9) */
export const HANDLED_EVENT_TYPES = [
"run.started",
"run.completed",
"run.failed",
"run.cancelled",
"message.delta",
"message.completed",
// reasoning.* 走具名事件(后端 SSE 帧带 event: 字段),必须显式注册才能接收。
"reasoning.delta",
"reasoning.completed",
// tool.*:后端对话流只发 started / completed / requires_confirmation,
// 失败统一走 tool.completed(status=error),不发 tool.failed。
"tool.started",
"tool.completed",
"tool.requires_confirmation",
"plan.created",
"plan.step.started",
"plan.step.completed",
"plan.step.failed",
"memory.retrieved",
"heartbeat",
] as const;
export interface RunStreamHandlers {
/** 任意事件到达 */
onEvent?: (event: RunEvent) => void;
/** 连接建立 */
onOpen?: () => void;
/** 连接错误(EventSource 会自动重连,此处仅做回调) */
onError?: () => void;
}
export interface RunStreamController {
/** 关闭连接 */
close: () => void;
/** 已接收的最后事件 ID(可持久化以便手动恢复) */
getLastEventId: () => string | null;
}
export interface RunStreamOptions {
/** 是否开启思考模式(决定 Mock 流是否前置推送 reasoning 摘要) */
thinking?: boolean;
/** 思考力度(决定思考摘要的详尽程度与流式粒度) */
effort?: ThinkingEffort;
/** Mock 模式下的人物名(决定回复风格,如 "时崎狂三"),为空时使用默认池(镜梧) */
characterName?: string;
}
/**
* 创建一个 Run 事件流订阅。
* 返回控制器,调用方负责在卸载时 close()。
*/
export function createRunStream(
runId: string,
handlers: RunStreamHandlers = {},
options?: RunStreamOptions,
): RunStreamController {
if (USE_MOCK) {
return createMockRunStream(runId, handlers, options);
}
// 经 BFF /api/sse 代理:EventSource 同源请求自动携带 httpOnly cookie,
// BFF 在服务端注入 Authorization 并以 ReadableStream 透传,规避 dev server
// 缓冲与 EventSource 无法自定义 header 两个问题。
const url = `${API_SSE_ROOT}/runs/${runId}/stream`;
const source = new EventSource(url);
let lastEventId: string | null = null;
const parseAndDispatch = (raw: string | null, eventType?: string) => {
if (!raw) return;
let parsed: RunEvent;
try {
parsed = JSON.parse(raw) as RunEvent;
} catch {
return;
}
if (parsed.event_id) lastEventId = parsed.event_id;
handlers.onEvent?.({
...parsed,
type: eventType ?? parsed.type,
});
};
source.onopen = () => handlers.onOpen?.();
source.onerror = () => handlers.onError?.();
// 具名事件:API 的 event: 字段推荐使用事件类型
for (const type of HANDLED_EVENT_TYPES) {
source.addEventListener(type, (e) => {
parseAndDispatch((e as MessageEvent).data, type);
});
}
// 未声明 event: 字段的消息走 onmessage
source.onmessage = (e) => parseAndDispatch(e.data);
return {
close: () => source.close(),
getLastEventId: () => lastEventId,
};
}
/**
* 全局事件流订阅(非 run-scoped)。
* 监听 proactive.session 等全局广播事件,供 ChatShell 接收叩心枢主动会话通知。
* 与 createRunStream 独立:ubscribe 不同端点,不相互影响。
*/
export function createGlobalEventStream(
handlers: RunStreamHandlers = {},
): RunStreamController {
if (USE_MOCK) {
return { close: () => {}, getLastEventId: () => null };
}
const url = `${API_SSE_ROOT}/events`;
const source = new EventSource(url);
let lastEventId: string | null = null;
const parseAndDispatch = (raw: string | null, eventType?: string) => {
if (!raw) return;
try {
const parsed = JSON.parse(raw) as RunEvent;
if (parsed.event_id) lastEventId = parsed.event_id;
handlers.onEvent?.({
...parsed,
type: eventType ?? parsed.type,
});
} catch {
/* JSON 解析失败静默 */
}
};
source.onopen = () => handlers.onOpen?.();
source.onerror = () => handlers.onError?.();
// 注册关注的全局事件类型
source.addEventListener("proactive.session", (e) => {
parseAndDispatch((e as MessageEvent).data, "proactive.session");
});
// 无 event: 字段的消息
source.onmessage = (e) => parseAndDispatch(e.data);
return {
close: () => source.close(),
getLastEventId: () => lastEventId,
};
}
/** 将文本按定长切片,用于模拟流式增量 */
function chunkText(text: string, size: number): string[] {
const chunks: string[] = [];
for (let i = 0; i < text.length; i += size) {
chunks.push(text.slice(i, i + size));
}
return chunks;
}
/** 思考力度 → 思考分片大小(low 粗分片速出,high 细分片缓出) */
const REASON_CHUNK_SIZE: Record<ThinkingEffort, number> = {
low: 28,
medium: 16,
high: 10,
};
/**
* Mock 流:按时间序推演一次完整的运行事件序列。
* thinking 开启时,先推送对应 effort 的思考摘要分片,再推送正文分片。
*/
function createMockRunStream(
runId: string,
handlers: RunStreamHandlers,
options?: RunStreamOptions,
): RunStreamController {
const timers: ReturnType<typeof setTimeout>[] = [];
let sequence = 0;
let lastEventId: string | null = null;
let closed = false;
const emit = (type: string, payload: Record<string, unknown>) => {
if (closed) return;
const eventId = `evt-${runId}-${sequence}`;
lastEventId = eventId;
handlers.onEvent?.({
event_id: eventId,
run_id: runId,
type,
sequence: sequence++,
visibility: "public",
source: "model",
payload,
created_at: new Date().toISOString(),
});
};
const at = (delay: number, fn: () => void) => {
timers.push(setTimeout(fn, delay));
};
const thinking = options?.thinking ?? false;
const effort: ThinkingEffort = options?.effort ?? "medium";
const characterKey = options?.characterName;
// 每次发起 Mock 流随机抽取一条回复与思考摘要,使对话回放逐条不同
const replyText = pickMockReply(characterKey);
const reasoningText = thinking ? pickMockReasoning(effort) : "";
const reasoningChunks = thinking
? chunkText(reasoningText, REASON_CHUNK_SIZE[effort])
: [];
const replyChunks = chunkText(replyText, 8);
const REASON_STEP = 45;
const REASON_BASE = 200;
const reasonEndAt =
REASON_BASE + reasoningChunks.length * REASON_STEP + (thinking ? 80 : 0);
// 思考结束后略作停顿再开始正文,模拟「思考完再组织表达」
const msgBase = thinking ? reasonEndAt + 280 : REASON_BASE + 120;
const MSG_STEP = 70;
const msgEndAt = msgBase + replyChunks.length * MSG_STEP + 80;
const mockMemories = [
{
id: "mock-mem-001",
title: "铜镜之契",
content: "以铜镜为魂契,镜在则念在,镜碎则念乱。",
source: "user_explicit",
confidence: 0.95,
importance: 0.9,
type: "identity",
},
{
id: "mock-mem-002",
title: "用户的节奏偏好",
content: "用户不喜被催促,交谈宜缓、宜留间隙。",
source: "conversation",
confidence: 0.78,
importance: 0.72,
type: "user",
},
{
id: "mock-mem-003",
title: "镜湖决意",
content: "用户曾于镜湖前问及「遗忘」,以湖面映月为喻作答,用户沉默良久。",
source: "conversation",
confidence: 0.76,
importance: 0.8,
type: "episodic",
},
];
at(60, () => handlers.onOpen?.());
at(120, () => emit("run.started", {}));
at(180, () => emit("memory.retrieved", { memories: mockMemories }));
// —— 思考摘要(仅思考模式)——
reasoningChunks.forEach((chunk, i) => {
at(REASON_BASE + i * REASON_STEP, () =>
emit("reasoning.delta", { delta: chunk }),
);
});
if (thinking) {
at(reasonEndAt, () => emit("reasoning.completed", {}));
}
// —— 正文 ——
replyChunks.forEach((chunk, i) => {
at(msgBase + i * MSG_STEP, () => emit("message.delta", { delta: chunk }));
});
at(msgEndAt, () =>
emit("message.completed", {
message_id: `msg-${runId}`,
content: replyText,
}),
);
at(msgEndAt + 160, () => emit("run.completed", {}));
return {
close: () => {
closed = true;
timers.forEach(clearTimeout);
},
getLastEventId: () => lastEventId,
};
}
+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 }),
};
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { cn } from "./cn";
describe("cn", () => {
it("合并多个类名", () => {
expect(cn("a", "b")).toBe("a b");
});
it("过滤假值", () => {
expect(cn("a", false, null, undefined, "", "b")).toBe("a b");
});
it("支持条件类名对象", () => {
expect(cn("base", { active: true, hidden: false })).toBe("base active");
});
it("解决 Tailwind 类冲突(后者覆盖前者)", () => {
expect(cn("px-2", "px-4")).toBe("px-4");
expect(cn("text-left", "text-center")).toBe("text-center");
});
});
+10
View File
@@ -0,0 +1,10 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* 合并条件 className,并解决 Tailwind 类冲突。
* 统一在组件中使用,避免散落的字符串拼接。
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { ApiError } from "@/lib/api/client";
import { describeError, traceIdOf } from "./error";
describe("describeError", () => {
it("ApiError 带已知 code 返回 标签:消息", () => {
const err = new ApiError(404, "not_found", "人物不存在。", "trace_1");
expect(describeError(err)).toBe("未找到资源:人物不存在。");
});
it("ApiError 带 validation_failed 返回当前契约标签", () => {
const err = new ApiError(400, "validation_failed", "出错了", "trace_2");
expect(describeError(err)).toBe("校验失败:出错了");
});
it("ApiError network_error 直接返回 message", () => {
const err = new ApiError(0, "network_error", "网络异常", "");
expect(describeError(err)).toBe("网络异常");
});
it("普通 Error 返回 message", () => {
expect(describeError(new Error("普通错误"))).toBe("普通错误");
});
it("非 Error 对象返回默认文案", () => {
expect(describeError(null)).toBe("发生未知错误。");
expect(describeError("string")).toBe("发生未知错误。");
expect(describeError(undefined)).toBe("发生未知错误。");
});
});
describe("traceIdOf", () => {
it("ApiError 返回 traceId", () => {
const err = new ApiError(404, "not_found", "", "trace_abc");
expect(traceIdOf(err)).toBe("trace_abc");
});
it("非 ApiError 返回 undefined", () => {
expect(traceIdOf(new Error("err"))).toBeUndefined();
expect(traceIdOf(null)).toBeUndefined();
expect(traceIdOf("string")).toBeUndefined();
});
});
+28
View File
@@ -0,0 +1,28 @@
/**
* 错误描述工具(server / client 通用,纯函数)。
* 将后端 ApiError 或未知错误统一为可展示文案。
*/
import { ApiError } from "@/lib/api/client";
import { ERROR_CODE_LABEL } from "@/lib/constants/labels";
export function describeError(error: unknown): string {
if (error instanceof ApiError) {
// network_error / parse_error 为前端归一错误码,无对应文案,直接返回 message
if (error.code !== "network_error" && error.code !== "parse_error") {
const label = ERROR_CODE_LABEL[error.code];
if (label) return `${label}:${error.message}`;
}
return error.message;
}
if (error instanceof Error) return error.message;
return "发生未知错误。";
}
/**
* 从错误对象提取 trace_id(ApiError 携带后端回传的 trace_id,否则 undefined)。
* 配合 describeError 使用:describeError 出文案,traceIdOf 出链路 ID 供追踪入口。
*/
export function traceIdOf(error: unknown): string | undefined {
return error instanceof ApiError ? error.traceId : undefined;
}
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
formatDateTime,
formatNumber,
formatPercent,
formatTime,
} from "./format";
describe("formatDateTime", () => {
it("空值返回占位符", () => {
expect(formatDateTime(undefined)).toBe("—");
expect(formatDateTime(null)).toBe("—");
expect(formatDateTime("")).toBe("—");
});
it("非法日期回退原值", () => {
expect(formatDateTime("not-a-date")).toBe("not-a-date");
});
it("合法 ISO 字符串被格式化为本地时间", () => {
const result = formatDateTime("2026-06-20T12:00:00Z");
expect(result).toContain("2026");
expect(result).not.toBe("—");
});
});
describe("formatTime", () => {
it("空值返回空串", () => {
expect(formatTime(undefined)).toBe("");
expect(formatTime(null)).toBe("");
});
it("非法日期回退原值", () => {
expect(formatTime("not-a-date")).toBe("not-a-date");
});
it("合法 ISO 字符串输出时分秒", () => {
const result = formatTime("2026-06-20T12:00:00Z");
expect(result).toMatch(/\d{2}:\d{2}:\d{2}/);
});
});
describe("formatPercent", () => {
it("空值与 NaN 返回占位符", () => {
expect(formatPercent(undefined)).toBe("—");
expect(formatPercent(null)).toBe("—");
expect(formatPercent(NaN)).toBe("—");
});
it("0–1 区间转为百分比(四舍五入)", () => {
expect(formatPercent(0.827)).toBe("83%");
expect(formatPercent(1)).toBe("100%");
expect(formatPercent(0)).toBe("0%");
});
});
describe("formatNumber", () => {
it("空值与 NaN 返回占位符", () => {
expect(formatNumber(undefined)).toBe("—");
expect(formatNumber(null)).toBe("—");
expect(formatNumber(NaN)).toBe("—");
});
it("按指定小数位四舍五入", () => {
expect(formatNumber(3.14159)).toBe("3.14");
expect(formatNumber(3.14159, 3)).toBe("3.142");
expect(formatNumber(3.14159, 0)).toBe("3");
});
});
+44
View File
@@ -0,0 +1,44 @@
/**
* 通用格式化工具:时间、数值等。
* 时间统一 UTC 存储(ISO 8601),前端按用户本地时区展示(契约字典 §2)。
*/
/** 将 ISO 字符串格式化为本地可读时间,失败时回退原值 */
export function formatDateTime(iso?: string | null): string {
if (!iso) return "—";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
}
/** 仅展示相对/简短时间(用于消息列表、事件时间线) */
export function formatTime(iso?: string | null): string {
if (!iso) return "";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(date);
}
/** 0–1 区间数值展示为百分比 */
export function formatPercent(value?: number | null): string {
if (value === undefined || value === null || Number.isNaN(value)) return "—";
return `${Math.round(value * 100)}%`;
}
/** 保留两位小数的浮点展示 */
export function formatNumber(value?: number | null, digits = 2): string {
if (value === undefined || value === null || Number.isNaN(value)) return "—";
return value.toFixed(digits);
}