初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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" }] },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" } },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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[]>;
|
||||
}
|
||||
@@ -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" }),
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}`;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}, []);
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { create } from "zustand";
|
||||
import type { UserResponse, LoginRequest } from "@/types/auth";
|
||||
|
||||
/**
|
||||
* 认证状态(纯 BFF 架构)。
|
||||
*
|
||||
* token 唯一存于 httpOnly cookie,由 BFF /api/auth/* 路由在服务端管理,客户端
|
||||
* 永不持有 token。本 store 仅维护 user 信息:
|
||||
* - login / logout / loadFromBff / tryRefresh 均经 BFF 路由(浏览器自动携带 cookie);
|
||||
* - 另存一份 user-only 的 localStorage 做首屏防闪,由 /api/auth/me 返回后校正。
|
||||
*/
|
||||
|
||||
const USER_KEY = "hunjing-user";
|
||||
|
||||
/** 从 localStorage 读取缓存的 user(仅 user,不含 token)。 */
|
||||
function loadStoredUser(): UserResponse | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(USER_KEY);
|
||||
if (!raw) return null;
|
||||
return (JSON.parse(raw) as { user: UserResponse | null }).user ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入 / 清除本地 user 缓存。 */
|
||||
function saveStoredUser(user: UserResponse | null): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (user) {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify({ user }));
|
||||
} else {
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: UserResponse | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
|
||||
login: (credentials: LoginRequest) => Promise<void>;
|
||||
logout: () => void;
|
||||
/** 从 localStorage 同步恢复 user(首屏防闪),随后应由 loadFromBff 校正 */
|
||||
loadFromStorage: () => void;
|
||||
loadFromBff: () => Promise<void>;
|
||||
tryRefresh: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
|
||||
/** BFF 登录:经 /api/auth/login 代理,token 存入 httpOnly cookie。 */
|
||||
login: async (credentials) => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const resp = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err?.error?.message ?? "登录失败。");
|
||||
}
|
||||
const data = await resp.json();
|
||||
const user = (data.user ?? null) as UserResponse | null;
|
||||
saveStoredUser(user);
|
||||
set({ user, isAuthenticated: true, isLoading: false });
|
||||
} catch (error) {
|
||||
set({ isLoading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/** 登出:清 httpOnly cookie(BFF)+ 本地 user 缓存。 */
|
||||
logout: () => {
|
||||
fetch("/api/auth/logout", { method: "POST" }).catch(() => {});
|
||||
saveStoredUser(null);
|
||||
set({ user: null, isAuthenticated: false });
|
||||
},
|
||||
|
||||
loadFromStorage: () => {
|
||||
const user = loadStoredUser();
|
||||
if (user) set({ user, isAuthenticated: true });
|
||||
},
|
||||
|
||||
/** BFF 恢复:从 /api/auth/me 获取当前用户(依赖 httpOnly cookie,自动刷新)。 */
|
||||
loadFromBff: async () => {
|
||||
try {
|
||||
const resp = await fetch("/api/auth/me");
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
const user = (data.user ?? null) as UserResponse | null;
|
||||
saveStoredUser(user);
|
||||
set({ user, isAuthenticated: true });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* BFF 不可达 */
|
||||
}
|
||||
saveStoredUser(null);
|
||||
set({ user: null, isAuthenticated: false });
|
||||
},
|
||||
|
||||
/** 尝试通过 BFF 刷新 token(/api/auth/refresh,cookie 自动携带)。 */
|
||||
tryRefresh: async () => {
|
||||
try {
|
||||
const resp = await fetch("/api/auth/refresh", { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
get().logout();
|
||||
return false;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const user = (data.user ?? null) as UserResponse | null;
|
||||
saveStoredUser(user);
|
||||
set({ user, isAuthenticated: true });
|
||||
return true;
|
||||
} catch {
|
||||
get().logout();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,264 @@
|
||||
import { act } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { useChatStore, streamMessageId } from "./chat-store";
|
||||
|
||||
describe("useChatStore", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useChatStore.setState({
|
||||
sessionId: null,
|
||||
characterId: null,
|
||||
messages: [],
|
||||
activeRunId: null,
|
||||
runStatus: null,
|
||||
sending: false,
|
||||
error: null,
|
||||
errorTraceId: null,
|
||||
sessions: [],
|
||||
newSessionIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("初始状态", () => {
|
||||
const s = useChatStore.getState();
|
||||
expect(s.sessionId).toBeNull();
|
||||
expect(s.messages).toEqual([]);
|
||||
expect(s.sending).toBe(false);
|
||||
});
|
||||
|
||||
describe("initSession", () => {
|
||||
it("设置 session/character ID 并清空消息", () => {
|
||||
act(() =>
|
||||
useChatStore.getState().initSession({
|
||||
session_id: "sess_1",
|
||||
character_id: "char_1",
|
||||
title: "测试",
|
||||
status: "active",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
const s = useChatStore.getState();
|
||||
expect(s.sessionId).toBe("sess_1");
|
||||
expect(s.characterId).toBe("char_1");
|
||||
expect(s.messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushUserMessage", () => {
|
||||
it("追加一条用户消息并返回临时 ID", () => {
|
||||
let tempId: string = "";
|
||||
act(() => {
|
||||
tempId = useChatStore.getState().pushUserMessage("你好");
|
||||
});
|
||||
expect(tempId).toMatch(/^local:/);
|
||||
const msgs = useChatStore.getState().messages;
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0].role).toBe("user");
|
||||
expect(msgs[0].content).toBe("你好");
|
||||
});
|
||||
});
|
||||
|
||||
describe("流式消息", () => {
|
||||
it("beginAssistantStream 创建流式消息", () => {
|
||||
act(() => useChatStore.getState().beginAssistantStream("run_1"));
|
||||
const msgs = useChatStore.getState().messages;
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0].message_id).toBe(streamMessageId("run_1"));
|
||||
expect(msgs[0].streaming).toBe(true);
|
||||
expect(msgs[0].reasoning).toBe("");
|
||||
expect(msgs[0].reasoningStreaming).toBe(true);
|
||||
});
|
||||
|
||||
it("appendDelta 追加文本", () => {
|
||||
act(() => useChatStore.getState().beginAssistantStream("run_1"));
|
||||
act(() => useChatStore.getState().appendDelta("run_1", "你好"));
|
||||
const msgs = useChatStore.getState().messages;
|
||||
expect(msgs[0].content).toBe("你好");
|
||||
});
|
||||
|
||||
it("finalizeAssistant 定稿消息", () => {
|
||||
act(() => useChatStore.getState().beginAssistantStream("run_1"));
|
||||
act(() =>
|
||||
useChatStore
|
||||
.getState()
|
||||
.finalizeAssistant("run_1", "msg_real_1", "最终回复"),
|
||||
);
|
||||
const msgs = useChatStore.getState().messages;
|
||||
expect(msgs[0].message_id).toBe("msg_real_1");
|
||||
expect(msgs[0].content).toBe("最终回复");
|
||||
expect(msgs[0].streaming).toBe(false);
|
||||
});
|
||||
|
||||
it("appendReasoningDelta 追加思考摘要", () => {
|
||||
act(() => useChatStore.getState().beginAssistantStream("run_1"));
|
||||
act(() =>
|
||||
useChatStore.getState().appendReasoningDelta("run_1", "正在思考"),
|
||||
);
|
||||
const msgs = useChatStore.getState().messages;
|
||||
expect(msgs[0].reasoning).toBe("正在思考");
|
||||
});
|
||||
});
|
||||
|
||||
describe("会话管理", () => {
|
||||
it("addSession 追加不重复的会话", () => {
|
||||
act(() =>
|
||||
useChatStore.getState().addSession({
|
||||
session_id: "sess_1",
|
||||
character_id: "char_1",
|
||||
title: "测试",
|
||||
status: "active",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
}),
|
||||
);
|
||||
expect(useChatStore.getState().sessions).toHaveLength(1);
|
||||
|
||||
// 重复添加应被忽略
|
||||
act(() =>
|
||||
useChatStore.getState().addSession({
|
||||
session_id: "sess_1",
|
||||
character_id: "char_1",
|
||||
title: "测试",
|
||||
status: "active",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
}),
|
||||
);
|
||||
expect(useChatStore.getState().sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("removeSession 移除指定会话", () => {
|
||||
act(() =>
|
||||
useChatStore.getState().addSession({
|
||||
session_id: "sess_1",
|
||||
character_id: "char_1",
|
||||
title: "测试",
|
||||
status: "active",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
}),
|
||||
);
|
||||
act(() => useChatStore.getState().removeSession("sess_1"));
|
||||
expect(useChatStore.getState().sessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("patchSession 更新指定会话", () => {
|
||||
act(() =>
|
||||
useChatStore.getState().addSession({
|
||||
session_id: "sess_1",
|
||||
character_id: "char_1",
|
||||
title: "旧标题",
|
||||
status: "active",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
useChatStore.getState().patchSession("sess_1", { title: "新标题" }),
|
||||
);
|
||||
const patched = useChatStore.getState().sessions[0];
|
||||
expect(patched.title).toBe("新标题");
|
||||
});
|
||||
});
|
||||
|
||||
describe("叩心枢新会话提醒", () => {
|
||||
it("addNewSessionId 追加不重复的 ID", () => {
|
||||
act(() => useChatStore.getState().addNewSessionId("sess_1"));
|
||||
act(() => useChatStore.getState().addNewSessionId("sess_1"));
|
||||
expect(useChatStore.getState().newSessionIds).toEqual(["sess_1"]);
|
||||
});
|
||||
|
||||
it("markSessionSeen 移除已读", () => {
|
||||
act(() => useChatStore.getState().addNewSessionId("sess_1"));
|
||||
act(() => useChatStore.getState().addNewSessionId("sess_2"));
|
||||
act(() => useChatStore.getState().markSessionSeen("sess_1"));
|
||||
expect(useChatStore.getState().newSessionIds).toEqual(["sess_2"]);
|
||||
});
|
||||
|
||||
it("clearAllNewSessions 全部清除", () => {
|
||||
act(() => useChatStore.getState().addNewSessionId("sess_1"));
|
||||
act(() => useChatStore.getState().clearAllNewSessions());
|
||||
expect(useChatStore.getState().newSessionIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("工具调用状态", () => {
|
||||
it("beginToolCall 写入 toolCalls 展示态", () => {
|
||||
act(() => useChatStore.getState().beginAssistantStream("run_1"));
|
||||
act(() =>
|
||||
useChatStore.getState().beginToolCall("run_1", {
|
||||
tool_name: "web_search",
|
||||
tool_call_id: "tc_1",
|
||||
args: '{"q":"test"}',
|
||||
}),
|
||||
);
|
||||
const tcs = useChatStore.getState().messages[0].toolCalls;
|
||||
expect(tcs).toHaveLength(1);
|
||||
expect(tcs?.[0].toolName).toBe("web_search");
|
||||
expect(tcs?.[0].status).toBe("running");
|
||||
});
|
||||
|
||||
it("beginToolCall 多次调用各自独立展示", () => {
|
||||
act(() => useChatStore.getState().beginAssistantStream("run_1"));
|
||||
act(() =>
|
||||
useChatStore.getState().beginToolCall("run_1", {
|
||||
tool_name: "web_search",
|
||||
tool_call_id: "tc_1",
|
||||
args: "{}",
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
useChatStore.getState().beginToolCall("run_1", {
|
||||
tool_name: "read_file",
|
||||
tool_call_id: "tc_2",
|
||||
args: '{"path":"/tmp/x"}',
|
||||
}),
|
||||
);
|
||||
const tcs = useChatStore.getState().messages[0].toolCalls;
|
||||
expect(tcs).toHaveLength(2);
|
||||
expect(tcs?.[0].toolName).toBe("web_search");
|
||||
expect(tcs?.[1].toolName).toBe("read_file");
|
||||
});
|
||||
|
||||
it("completeToolCall 更新工具终态", () => {
|
||||
act(() => useChatStore.getState().beginAssistantStream("run_1"));
|
||||
act(() =>
|
||||
useChatStore.getState().beginToolCall("run_1", {
|
||||
tool_name: "web_search",
|
||||
tool_call_id: "tc_1",
|
||||
args: "{}",
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
useChatStore.getState().completeToolCall("run_1", {
|
||||
tool_name: "web_search",
|
||||
tool_call_id: "tc_1",
|
||||
status: "success",
|
||||
result: "搜索结果",
|
||||
}),
|
||||
);
|
||||
const tcs = useChatStore.getState().messages[0].toolCalls;
|
||||
expect(tcs).toHaveLength(1);
|
||||
expect(tcs?.[0].status).toBe("success");
|
||||
expect(tcs?.[0].result).toBe("搜索结果");
|
||||
});
|
||||
});
|
||||
|
||||
describe("视图/偏好设置", () => {
|
||||
it("setSidebarCollapsed / toggleSidebar", () => {
|
||||
act(() => useChatStore.getState().setSidebarCollapsed(true));
|
||||
expect(useChatStore.getState().sidebarCollapsed).toBe(true);
|
||||
act(() => useChatStore.getState().toggleSidebar());
|
||||
expect(useChatStore.getState().sidebarCollapsed).toBe(false);
|
||||
});
|
||||
|
||||
it("setThinkingMode / setThinkingEffort", () => {
|
||||
act(() => useChatStore.getState().setThinkingMode(false));
|
||||
expect(useChatStore.getState().thinkingMode).toBe(false);
|
||||
act(() => useChatStore.getState().setThinkingEffort("high"));
|
||||
expect(useChatStore.getState().thinkingEffort).toBe("high");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,605 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Chat 运行时状态(前端架构计划 §8、§10)。
|
||||
*
|
||||
* Store 只持有数据与同步 setter;API 调用与 SSE 订阅由 ChatShell 组件编排,
|
||||
* 事件回调写入 store,保持职责清晰。流式助手消息用 `stream:<runId>` 作为
|
||||
* 临时 message_id,message.completed 时以真实 id 定稿。
|
||||
*
|
||||
* 持久化(zustand persist / localStorage):持久化当前会话(sessionId /
|
||||
* characterId)与用户偏好(思考模式 / 力度 / 智能搜索 / 模型 / 侧栏折叠);消息
|
||||
* 不持久化——以后端为权威,刷新后由 ChatShell 拉取最新历史 hydrate 对齐(跨设备
|
||||
* / 后端变更同步)。流式草稿与思考摘要(reasoning)不持久化——刷新后无法续传
|
||||
* 中断的流,且思考摘要属隐私敏感内容,不应落盘(见
|
||||
* docs/architecture/data-and-security-architecture.md §日志红线:raw CoT 不入事件/日志)。
|
||||
* 瞬态字段(activeRunId / runStatus / sending / error)刷新即重置。
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatSession,
|
||||
MemoryRetrievedPayload,
|
||||
ToolCallStatus,
|
||||
ToolCompletedPayload,
|
||||
ToolRequiresConfirmationPayload,
|
||||
ToolStartedPayload,
|
||||
} from "@/types/api";
|
||||
import type { ThinkingEffort } from "@/types/thinking";
|
||||
import type { RunStatus } from "@/types/domain";
|
||||
|
||||
/**
|
||||
* 工具调用展示态(对话流 tool.* 事件驱动,临时态,不持久化)。
|
||||
* 状态机:running → success | error | denied | skipped;中间可能进入 confirming。
|
||||
*/
|
||||
export interface ToolCallDisplay {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
status: "running" | "confirming" | ToolCallStatus;
|
||||
/** tool.completed 的 result 文本(错误 / 拒绝时含原因) */
|
||||
result?: string;
|
||||
/** 原始入参(tool.started / requires_confirmation 的 args 字符串) */
|
||||
args?: string;
|
||||
/** confirming 时携带的 confirmation_id(approve/deny 调用用) */
|
||||
confirmationId?: string;
|
||||
/** confirming 时的风险等级(L0–L4) */
|
||||
riskLevel?: string;
|
||||
/** confirming 时的说明(展示给用户的理由) */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** 右侧预览面板的文件状态。 */
|
||||
export interface PreviewFileState {
|
||||
path: string;
|
||||
runId?: string;
|
||||
fileName?: string;
|
||||
isolated?: boolean;
|
||||
}
|
||||
|
||||
/** 一条助手回复的历史版本(重答留存,本地态,不持久化) */
|
||||
export interface MessageVersion {
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** 带流式标记的本地消息草稿 */
|
||||
export type ChatMessageDraft = ChatMessage & {
|
||||
streaming?: boolean;
|
||||
/** 思考摘要(reasoning):临时态,不持久化。仅思考模式开启时存在 */
|
||||
reasoning?: string;
|
||||
/** 思考是否仍在流式输出中 */
|
||||
reasoningStreaming?: boolean;
|
||||
/** 工具调用展示态列表(对话流 tool.* 事件):临时态,不持久化。每次工具调用独立展示,不互相覆盖 */
|
||||
toolCalls?: ToolCallDisplay[];
|
||||
/**
|
||||
* 重答历史版本。按生成顺序追加,末尾为最新;首次回复
|
||||
* 定稿后 length===1。重答时旧版本经 priorVersions 迁入新消息,实现单消息多版本。
|
||||
* 后端消息接口返回 versions 数组;hydrate 时直接带到前端,版本面板对历史消息也可见。
|
||||
*/
|
||||
versions?: MessageVersion[];
|
||||
/** 当前展示 / 采用的版本索引(指向 versions);流式中无意义,定稿后设定 */
|
||||
activeVersionIdx?: number;
|
||||
};
|
||||
|
||||
interface ChatState {
|
||||
sessionId: string | null;
|
||||
characterId: string | null;
|
||||
messages: ChatMessageDraft[];
|
||||
activeRunId: string | null;
|
||||
runStatus: RunStatus | null;
|
||||
sending: boolean;
|
||||
error: string | null;
|
||||
/** 最近一次错误的 trace_id(若来自后端 ApiError),供错误条展示「追踪」入口 */
|
||||
errorTraceId: string | null;
|
||||
/** 右侧预览面板:文件列表(支持多文件标签页切换) */
|
||||
previewFiles: PreviewFileState[];
|
||||
activePreviewIndex: number;
|
||||
openPreviewFile: (file: PreviewFileState) => void;
|
||||
closePreviewPanel: () => void;
|
||||
switchPreviewFile: (index: number) => void;
|
||||
/** AppShell 左侧主导航栏是否展开 */
|
||||
appSidebarExpanded: boolean;
|
||||
setAppSidebarExpanded: (
|
||||
expanded: boolean | ((prev: boolean) => boolean),
|
||||
) => void;
|
||||
/** 思考模式开关:开启后助手回复前流式展示可折叠的思考摘要 */
|
||||
thinkingMode: boolean;
|
||||
/** 思考力度(reasoning_effort):决定思考摘要的详尽程度 */
|
||||
thinkingEffort: ThinkingEffort;
|
||||
/**
|
||||
* 智能搜索(web_search)开关:开启后允许助手调用搜索工具获取实时信息。
|
||||
* 默认关闭,持久化到 localStorage(参考 thinkingMode)。
|
||||
*/
|
||||
webSearchEnabled: boolean;
|
||||
/**
|
||||
* 当前对话模型逻辑名(model_hint)。null 时由 ChatShell 以模型网关默认初始化;
|
||||
* 用户在对话工具栏切换后持久化,跨刷新保留。
|
||||
*/
|
||||
modelHint: string | null;
|
||||
/** 会话列表(从后端拉取,不持久化;mock 内存态刷新重置) */
|
||||
sessions: ChatSession[];
|
||||
/**
|
||||
* 对话页左侧会话栏是否收起。持久化到 localStorage 以保留用户偏好。
|
||||
* 收起时 SessionSidebar 隐藏,主区域左上角提供展开入口。
|
||||
*/
|
||||
sidebarCollapsed: boolean;
|
||||
|
||||
initSession: (session: ChatSession) => void;
|
||||
hydrateMessages: (messages: ChatMessage[]) => void;
|
||||
pushUserMessage: (content: string) => string;
|
||||
beginAssistantStream: (
|
||||
runId: string,
|
||||
options?: { priorVersions?: MessageVersion[] },
|
||||
) => void;
|
||||
/** 浏览某个历史版本:瞬时同步主消息 content,不触达后端(◀▶ 翻页用) */
|
||||
browseVersion: (
|
||||
messageId: string,
|
||||
versionIdx: number,
|
||||
content: string,
|
||||
) => void;
|
||||
/** 采用某个历史版本:同步 activeVersionIdx 与 content(版本面板「使用此版」) */
|
||||
useVersion: (messageId: string, versionIdx: number) => void;
|
||||
appendDelta: (runId: string, delta: string) => void;
|
||||
finalizeAssistant: (
|
||||
runId: string,
|
||||
messageId: string,
|
||||
content?: string,
|
||||
) => void;
|
||||
appendReasoningDelta: (runId: string, delta: string) => void;
|
||||
finalizeReasoning: (runId: string) => void;
|
||||
setRunStatus: (status: RunStatus | null) => void;
|
||||
setSending: (sending: boolean) => void;
|
||||
setError: (error: string | null, traceId?: string | null) => void;
|
||||
setThinkingMode: (enabled: boolean) => void;
|
||||
setThinkingEffort: (effort: ThinkingEffort) => void;
|
||||
setWebSearchEnabled: (enabled: boolean) => void;
|
||||
setModelHint: (hint: string) => void;
|
||||
setSessions: (sessions: ChatSession[]) => void;
|
||||
addSession: (session: ChatSession) => void;
|
||||
patchSession: (sessionId: string, patch: Partial<ChatSession>) => void;
|
||||
removeSession: (sessionId: string) => void;
|
||||
/** 叩心枢主动推送的新会话 ID 列表(前端通知用,不持久化) */
|
||||
/** 息壤:当前 run 检索到的记忆列表(来自 memory.retrieved 事件) */
|
||||
retrievedMemories: MemoryRetrievedPayload["memories"];
|
||||
setRetrievedMemories: (memories: MemoryRetrievedPayload["memories"]) => void;
|
||||
clearRetrievedMemories: () => void;
|
||||
newSessionIds: string[];
|
||||
addNewSessionId: (sessionId: string) => void;
|
||||
markSessionSeen: (sessionId: string) => void;
|
||||
clearAllNewSessions: () => void;
|
||||
switchSession: (
|
||||
sessionId: string | null,
|
||||
characterId?: string | null,
|
||||
) => void;
|
||||
setActiveRunId: (runId: string | null) => void;
|
||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||
toggleSidebar: () => void;
|
||||
truncateMessagesAfter: (index: number) => void;
|
||||
/** tool.started:在当前助手流消息上记录工具调用开始 */
|
||||
beginToolCall: (runId: string, payload: ToolStartedPayload) => void;
|
||||
/** tool.completed:按终态更新工具调用展示 */
|
||||
completeToolCall: (runId: string, payload: ToolCompletedPayload) => void;
|
||||
/** tool.requires_confirmation:置为待确认态,展示确认卡片 */
|
||||
requireToolConfirmation: (
|
||||
runId: string,
|
||||
payload: ToolRequiresConfirmationPayload,
|
||||
) => void;
|
||||
/** 用户确认 / 拒绝后,本地先把状态回到 running(等后端推 tool.completed) */
|
||||
markToolCallResolving: (runId: string, toolCallId: string) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/** 流式助手消息的临时 id */
|
||||
export const streamMessageId = (runId: string) => `stream:${runId}`;
|
||||
|
||||
export const useChatStore = create<ChatState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
sessionId: null,
|
||||
characterId: null,
|
||||
messages: [],
|
||||
activeRunId: null,
|
||||
runStatus: null,
|
||||
sending: false,
|
||||
error: null,
|
||||
errorTraceId: null,
|
||||
thinkingMode: true,
|
||||
thinkingEffort: "medium",
|
||||
webSearchEnabled: false,
|
||||
modelHint: null,
|
||||
sessions: [],
|
||||
sidebarCollapsed: false,
|
||||
retrievedMemories: [],
|
||||
newSessionIds: [] as string[],
|
||||
previewFiles: [],
|
||||
activePreviewIndex: -1,
|
||||
appSidebarExpanded: true,
|
||||
|
||||
initSession: (session) =>
|
||||
set({
|
||||
sessionId: session.session_id,
|
||||
characterId: session.character_id,
|
||||
messages: [],
|
||||
activeRunId: null,
|
||||
runStatus: null,
|
||||
error: null,
|
||||
errorTraceId: null,
|
||||
}),
|
||||
|
||||
hydrateMessages: (messages) =>
|
||||
set({
|
||||
messages: messages.map((m) => {
|
||||
const base: ChatMessageDraft = { ...m, streaming: false };
|
||||
// 从 metadata.tool_calls 重建 ToolCallDisplay 列表,使所有工具调用信息刷新后仍可见
|
||||
const rawCalls = m.metadata?.tool_calls;
|
||||
if (Array.isArray(rawCalls) && rawCalls.length > 0) {
|
||||
base.toolCalls = rawCalls.map((tc) => {
|
||||
const item = tc as Record<string, unknown>;
|
||||
return {
|
||||
toolName: String(item.tool_name ?? ""),
|
||||
toolCallId: String(item.tool_call_id ?? ""),
|
||||
status:
|
||||
(item.status as ToolCallDisplay["status"]) ?? "success",
|
||||
args: String(item.args ?? ""),
|
||||
result: String(item.result ?? ""),
|
||||
};
|
||||
});
|
||||
}
|
||||
return base;
|
||||
}),
|
||||
}),
|
||||
|
||||
pushUserMessage: (content) => {
|
||||
const tempId = `local:${Date.now()}`;
|
||||
set((state) => ({
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
message_id: tempId,
|
||||
session_id: state.sessionId ?? "",
|
||||
role: "user",
|
||||
modality: "text",
|
||||
content,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
}));
|
||||
return tempId;
|
||||
},
|
||||
|
||||
beginAssistantStream: (runId, options) =>
|
||||
set((state) => {
|
||||
const thinking = get().thinkingMode;
|
||||
// 重答场景:把旧 assistant 的历史版本迁入新消息,定稿时再追加新版
|
||||
const priorVersions = options?.priorVersions;
|
||||
return {
|
||||
activeRunId: runId,
|
||||
runStatus: "running",
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
message_id: streamMessageId(runId),
|
||||
session_id: state.sessionId ?? "",
|
||||
character_id: state.characterId ?? undefined,
|
||||
role: "assistant",
|
||||
modality: "text",
|
||||
content: "",
|
||||
run_id: runId,
|
||||
streaming: true,
|
||||
// 思考模式开启时预置空思考草稿,承接 reasoning.delta
|
||||
reasoning: thinking ? "" : undefined,
|
||||
reasoningStreaming: thinking ? true : undefined,
|
||||
created_at: new Date().toISOString(),
|
||||
versions: priorVersions,
|
||||
activeVersionIdx:
|
||||
priorVersions && priorVersions.length > 0
|
||||
? priorVersions.length - 1
|
||||
: undefined,
|
||||
},
|
||||
],
|
||||
};
|
||||
}),
|
||||
|
||||
appendDelta: (runId, delta) =>
|
||||
set((state) => ({
|
||||
messages: state.messages.map((m) =>
|
||||
m.message_id === streamMessageId(runId)
|
||||
? { ...m, content: m.content + delta }
|
||||
: m,
|
||||
),
|
||||
})),
|
||||
|
||||
finalizeAssistant: (runId, messageId, content) =>
|
||||
set((state) => ({
|
||||
messages: state.messages.map((m) => {
|
||||
if (m.message_id !== streamMessageId(runId)) return m;
|
||||
const finalContent = content ?? m.content;
|
||||
// 定稿即沉淀一个版本:重答场景下 priorVersions 已携旧版本,此处追加新版
|
||||
const versions = [
|
||||
...(m.versions ?? []),
|
||||
{
|
||||
content: finalContent,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
return {
|
||||
...m,
|
||||
message_id: messageId,
|
||||
content: finalContent,
|
||||
streaming: false,
|
||||
versions,
|
||||
activeVersionIdx: versions.length - 1,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
|
||||
appendReasoningDelta: (runId, delta) =>
|
||||
set((state) => {
|
||||
// 思考模式关闭时不处理 reasoning.delta,防止后端误发
|
||||
if (!state.thinkingMode) return state;
|
||||
return {
|
||||
messages: state.messages.map((m) =>
|
||||
m.message_id === streamMessageId(runId)
|
||||
? { ...m, reasoning: (m.reasoning ?? "") + delta }
|
||||
: m,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
finalizeReasoning: (runId) =>
|
||||
set((state) => {
|
||||
if (!state.thinkingMode) return state;
|
||||
return {
|
||||
messages: state.messages.map((m) =>
|
||||
m.message_id === streamMessageId(runId)
|
||||
? { ...m, reasoningStreaming: false }
|
||||
: m,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
/** 浏览版本:瞬时同步主消息 content,不触达后端(◀▶ 翻页时调用) */
|
||||
browseVersion: (messageId, versionIdx, content) =>
|
||||
set((state) => ({
|
||||
messages: state.messages.map((m) =>
|
||||
m.message_id === messageId
|
||||
? { ...m, activeVersionIdx: versionIdx, content }
|
||||
: m,
|
||||
),
|
||||
})),
|
||||
|
||||
useVersion: (messageId, versionIdx) =>
|
||||
set((state) => ({
|
||||
messages: state.messages.map((m) =>
|
||||
m.message_id === messageId && m.versions?.[versionIdx]
|
||||
? {
|
||||
...m,
|
||||
activeVersionIdx: versionIdx,
|
||||
content: m.versions[versionIdx].content,
|
||||
}
|
||||
: m,
|
||||
),
|
||||
})),
|
||||
|
||||
setRunStatus: (runStatus) => set({ runStatus }),
|
||||
setSending: (sending) => set({ sending }),
|
||||
setError: (error, traceId = null) =>
|
||||
set({ error, errorTraceId: traceId }),
|
||||
openPreviewFile: (file) =>
|
||||
set((state) => {
|
||||
// 重复打开同一文件→切换到已有标签;否则追加
|
||||
const existingIdx = state.previewFiles.findIndex(
|
||||
(f) => f.path === file.path && f.runId === file.runId,
|
||||
);
|
||||
if (existingIdx >= 0) {
|
||||
return {
|
||||
activePreviewIndex: existingIdx,
|
||||
appSidebarExpanded: false,
|
||||
sidebarCollapsed: true,
|
||||
};
|
||||
}
|
||||
const newFiles = [...state.previewFiles, file];
|
||||
return {
|
||||
previewFiles: newFiles,
|
||||
activePreviewIndex: newFiles.length - 1,
|
||||
appSidebarExpanded: false,
|
||||
sidebarCollapsed: true,
|
||||
};
|
||||
}),
|
||||
closePreviewPanel: () =>
|
||||
set({
|
||||
previewFiles: [],
|
||||
activePreviewIndex: -1,
|
||||
}),
|
||||
switchPreviewFile: (index) => set({ activePreviewIndex: index }),
|
||||
setAppSidebarExpanded: (expanded) => {
|
||||
const val =
|
||||
typeof expanded === "function"
|
||||
? expanded(get().appSidebarExpanded)
|
||||
: expanded;
|
||||
set({ appSidebarExpanded: val });
|
||||
},
|
||||
setThinkingMode: (thinkingMode) => set({ thinkingMode }),
|
||||
setThinkingEffort: (thinkingEffort) => set({ thinkingEffort }),
|
||||
setWebSearchEnabled: (webSearchEnabled) => set({ webSearchEnabled }),
|
||||
setModelHint: (modelHint) => set({ modelHint }),
|
||||
setSessions: (sessions) => set({ sessions }),
|
||||
addSession: (session) =>
|
||||
set((state) => ({
|
||||
sessions: state.sessions.some(
|
||||
(s) => s.session_id === session.session_id,
|
||||
)
|
||||
? state.sessions
|
||||
: [session, ...state.sessions],
|
||||
})),
|
||||
patchSession: (sessionId, patch) =>
|
||||
set((state) => ({
|
||||
sessions: state.sessions.map((s) =>
|
||||
s.session_id === sessionId ? { ...s, ...patch } : s,
|
||||
),
|
||||
})),
|
||||
removeSession: (sessionId) =>
|
||||
set((state) => ({
|
||||
sessions: state.sessions.filter((s) => s.session_id !== sessionId),
|
||||
})),
|
||||
setRetrievedMemories: (memories) => set({ retrievedMemories: memories }),
|
||||
clearRetrievedMemories: () => set({ retrievedMemories: [] }),
|
||||
addNewSessionId: (sessionId) =>
|
||||
set((state) => ({
|
||||
newSessionIds: state.newSessionIds.includes(sessionId)
|
||||
? state.newSessionIds
|
||||
: [...state.newSessionIds, sessionId],
|
||||
})),
|
||||
markSessionSeen: (sessionId) =>
|
||||
set((state) => ({
|
||||
newSessionIds: state.newSessionIds.filter((id) => id !== sessionId),
|
||||
})),
|
||||
clearAllNewSessions: () => set({ newSessionIds: [] }),
|
||||
switchSession: (sessionId, characterId) =>
|
||||
set({
|
||||
sessionId,
|
||||
characterId: characterId ?? null,
|
||||
messages: [],
|
||||
activeRunId: null,
|
||||
runStatus: null,
|
||||
sending: false,
|
||||
error: null,
|
||||
errorTraceId: null,
|
||||
retrievedMemories: [],
|
||||
}),
|
||||
setActiveRunId: (activeRunId) => set({ activeRunId }),
|
||||
setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }),
|
||||
toggleSidebar: () =>
|
||||
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
|
||||
truncateMessagesAfter: (index) =>
|
||||
set((state) => ({ messages: state.messages.slice(0, index + 1) })),
|
||||
|
||||
// —— tool.* 事件:写入当前助手流消息的 toolCalls 展示态(临时态,不持久化)——
|
||||
// 同一 runId 只挂一条流式助手消息(streamMessageId),工具调用追加到 toolCalls 数组,
|
||||
// 每次工具调用单独展示,互不覆盖。后续 complete/confirm/resolve 通过 toolCallId 定位。
|
||||
beginToolCall: (runId, payload) =>
|
||||
set((state) => ({
|
||||
messages: state.messages.map((m) =>
|
||||
m.message_id === streamMessageId(runId)
|
||||
? {
|
||||
...m,
|
||||
toolCalls: [
|
||||
...(m.toolCalls ?? []),
|
||||
{
|
||||
toolName: payload.tool_name,
|
||||
toolCallId: payload.tool_call_id,
|
||||
status: "running",
|
||||
args: payload.args,
|
||||
},
|
||||
],
|
||||
}
|
||||
: m,
|
||||
),
|
||||
})),
|
||||
|
||||
completeToolCall: (runId, payload) =>
|
||||
set((state) => ({
|
||||
messages: state.messages.map((m) => {
|
||||
if (m.message_id !== streamMessageId(runId)) return m;
|
||||
const calls = m.toolCalls;
|
||||
if (!calls) {
|
||||
// 理论上应先收到 tool.started,直接落 completed
|
||||
return {
|
||||
...m,
|
||||
toolCalls: [
|
||||
{
|
||||
toolName: payload.tool_name,
|
||||
toolCallId: payload.tool_call_id,
|
||||
status: payload.status,
|
||||
result: payload.result,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...m,
|
||||
toolCalls: calls.map((tc) =>
|
||||
tc.toolCallId === payload.tool_call_id
|
||||
? {
|
||||
...tc,
|
||||
status: payload.status,
|
||||
result: payload.result,
|
||||
toolName: payload.tool_name,
|
||||
toolCallId: payload.tool_call_id,
|
||||
}
|
||||
: tc,
|
||||
),
|
||||
};
|
||||
}),
|
||||
})),
|
||||
|
||||
requireToolConfirmation: (runId, payload) =>
|
||||
set((state) => ({
|
||||
messages: state.messages.map((m) => {
|
||||
if (m.message_id !== streamMessageId(runId)) return m;
|
||||
return {
|
||||
...m,
|
||||
toolCalls: [
|
||||
...(m.toolCalls ?? []),
|
||||
{
|
||||
toolName: payload.tool_name,
|
||||
toolCallId: payload.tool_call_id,
|
||||
status: "confirming",
|
||||
args: payload.args,
|
||||
confirmationId: payload.confirmation_id,
|
||||
riskLevel: payload.risk_level,
|
||||
reason: payload.reason,
|
||||
},
|
||||
],
|
||||
};
|
||||
}),
|
||||
})),
|
||||
|
||||
markToolCallResolving: (runId, toolCallId) =>
|
||||
set((state) => ({
|
||||
messages: state.messages.map((m) =>
|
||||
m.message_id === streamMessageId(runId)
|
||||
? {
|
||||
...m,
|
||||
toolCalls: (m.toolCalls ?? []).map((tc) =>
|
||||
tc.toolCallId === toolCallId
|
||||
? { ...tc, status: "running" }
|
||||
: tc,
|
||||
),
|
||||
}
|
||||
: m,
|
||||
),
|
||||
})),
|
||||
reset: () =>
|
||||
set({
|
||||
sessionId: null,
|
||||
characterId: null,
|
||||
messages: [],
|
||||
activeRunId: null,
|
||||
runStatus: null,
|
||||
sending: false,
|
||||
error: null,
|
||||
errorTraceId: null,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "hunjing-chat",
|
||||
version: 1,
|
||||
partialize: (s) => ({
|
||||
// 持久化当前会话与用户偏好,支持跨刷新恢复;消息不持久化——以后端为权威,
|
||||
// 刷新后由 ChatShell 拉取最新历史 hydrate 对齐(跨设备 / 后端变更同步)。
|
||||
sessionId: s.sessionId,
|
||||
characterId: s.characterId,
|
||||
thinkingMode: s.thinkingMode,
|
||||
thinkingEffort: s.thinkingEffort,
|
||||
webSearchEnabled: s.webSearchEnabled,
|
||||
modelHint: s.modelHint,
|
||||
sidebarCollapsed: s.sidebarCollapsed,
|
||||
appSidebarExpanded: s.appSidebarExpanded,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,56 @@
|
||||
import { act } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
NEXT_THEME_LABEL,
|
||||
resolveTheme,
|
||||
useSettingsStore,
|
||||
} from "./settings-store";
|
||||
|
||||
describe("useSettingsStore", () => {
|
||||
afterEach(() => {
|
||||
useSettingsStore.setState({ theme: "system" });
|
||||
});
|
||||
|
||||
it("初始 theme 为 system", () => {
|
||||
expect(useSettingsStore.getState().theme).toBe("system");
|
||||
});
|
||||
|
||||
it("setTheme 更新主题", () => {
|
||||
act(() => useSettingsStore.getState().setTheme("dark"));
|
||||
expect(useSettingsStore.getState().theme).toBe("dark");
|
||||
});
|
||||
|
||||
it("toggleTheme 轮换 light → dark → system → light", () => {
|
||||
act(() => useSettingsStore.getState().setTheme("light"));
|
||||
act(() => useSettingsStore.getState().toggleTheme());
|
||||
expect(useSettingsStore.getState().theme).toBe("dark");
|
||||
|
||||
act(() => useSettingsStore.getState().toggleTheme());
|
||||
expect(useSettingsStore.getState().theme).toBe("system");
|
||||
|
||||
act(() => useSettingsStore.getState().toggleTheme());
|
||||
expect(useSettingsStore.getState().theme).toBe("light");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NEXT_THEME_LABEL", () => {
|
||||
it("light 显示素绢", () => {
|
||||
expect(NEXT_THEME_LABEL.light).toBe("素绢");
|
||||
});
|
||||
it("dark 显示夜铜", () => {
|
||||
expect(NEXT_THEME_LABEL.dark).toBe("夜铜");
|
||||
});
|
||||
it("system 显示随系统", () => {
|
||||
expect(NEXT_THEME_LABEL.system).toBe("随系统");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTheme", () => {
|
||||
it("light 返回 light", () => {
|
||||
expect(resolveTheme("light")).toBe("light");
|
||||
});
|
||||
it("dark 返回 dark", () => {
|
||||
expect(resolveTheme("dark")).toBe("dark");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 用户设置 store(前端架构计划 §10)。
|
||||
* 当前仅管理主题;模型/服务地址偏好为后续增强。
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
import type { ThemeMode } from "@/types/domain";
|
||||
|
||||
interface SettingsState {
|
||||
theme: ThemeMode;
|
||||
setTheme: (theme: ThemeMode) => void;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
theme: "system",
|
||||
setTheme: (theme) => set({ theme }),
|
||||
toggleTheme: () => {
|
||||
const order: ThemeMode[] = ["light", "dark", "system"];
|
||||
const idx = order.indexOf(get().theme);
|
||||
set({ theme: order[(idx + 1) % order.length] });
|
||||
},
|
||||
}),
|
||||
{ name: "hunjing-settings" },
|
||||
),
|
||||
);
|
||||
|
||||
/** toggleTheme 的下一态文案(供主题按钮展示当前操作意图) */
|
||||
export const NEXT_THEME_LABEL: Record<ThemeMode, string> = {
|
||||
light: "素绢",
|
||||
dark: "夜铜",
|
||||
system: "随系统",
|
||||
};
|
||||
|
||||
/** 当前主题的显示名 */
|
||||
export const CURRENT_THEME_LABEL: Record<ThemeMode, string> = {
|
||||
light: "昼镜",
|
||||
dark: "夜镜",
|
||||
system: "系统",
|
||||
};
|
||||
|
||||
/** 解析最终生效主题(system 需结合媒体查询) */
|
||||
export function resolveTheme(mode: ThemeMode): "light" | "dark" {
|
||||
if (mode === "system") {
|
||||
return typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export type ToastType = "success" | "error" | "info" | "warning";
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
title: string;
|
||||
description?: string;
|
||||
duration?: number; // ms, 默认 4000
|
||||
}
|
||||
|
||||
interface ToastStore {
|
||||
toasts: Toast[];
|
||||
addToast: (toast: Omit<Toast, "id">) => void;
|
||||
removeToast: (id: string) => void;
|
||||
clearToasts: () => void;
|
||||
}
|
||||
|
||||
let toastCounter = 0;
|
||||
|
||||
export const useToastStore = create<ToastStore>((set) => ({
|
||||
toasts: [],
|
||||
addToast: (toast) => {
|
||||
const id = `toast-${++toastCounter}`;
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts, { ...toast, id }],
|
||||
}));
|
||||
const duration = toast.duration ?? 4000;
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
}));
|
||||
}, duration);
|
||||
}
|
||||
},
|
||||
removeToast: (id) =>
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
})),
|
||||
clearToasts: () => set({ toasts: [] }),
|
||||
}));
|
||||
|
||||
/** 便捷调用 */
|
||||
export const toast = {
|
||||
success: (title: string, description?: string) =>
|
||||
useToastStore.getState().addToast({ type: "success", title, description }),
|
||||
error: (title: string, description?: string) =>
|
||||
useToastStore.getState().addToast({ type: "error", title, description }),
|
||||
info: (title: string, description?: string) =>
|
||||
useToastStore.getState().addToast({ type: "info", title, description }),
|
||||
warning: (title: string, description?: string) =>
|
||||
useToastStore.getState().addToast({ type: "warning", title, description }),
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user