feat(characters): support character-scoped conversations

This commit is contained in:
2026-07-17 11:42:31 +08:00
parent 93efcb6604
commit 2796010971
85 changed files with 1645 additions and 251 deletions
@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const httpClientMock = vi.hoisted(() => vi.fn());
vi.mock("../http_client", () => ({
httpClient: httpClientMock,
}));
import { CharacterApi } from "../character_api";
describe("CharacterApi", () => {
beforeEach(() => {
httpClientMock.mockReset();
});
it("loads characters and preserves the backend order", async () => {
httpClientMock.mockResolvedValue({
success: true,
data: {
items: [
{
id: "character_aria",
slug: "aria",
displayName: "Aria",
avatarUrl: "https://cdn.example.com/aria.jpg",
},
{
id: "character_elio",
slug: "elio",
displayName: "Elio Silvestri",
avatarUrl: "https://cdn.example.com/elio.jpg",
},
],
},
});
const response = await new CharacterApi().getCharacters();
expect(httpClientMock).toHaveBeenCalledWith("/api/characters");
expect(response.items.map((character) => character.id)).toEqual([
"character_aria",
"character_elio",
]);
});
});
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
SendMessageRequest,
UnlockHistoryRequest,
UnlockPrivateRequest,
} from "@/data/dto/chat";
const httpClientMock = vi.hoisted(() => vi.fn());
vi.mock("../http_client", () => ({
httpClient: httpClientMock,
}));
import { ChatApi } from "../chat_api";
import { PrivateRoomApi } from "../private_room_api";
const CHARACTER_ID = "character_elio";
describe("multi-character API contract", () => {
beforeEach(() => {
httpClientMock.mockReset();
});
it("sends characterId with chat messages", async () => {
httpClientMock.mockResolvedValue({
success: true,
data: {
reply: "Hello",
messageId: "message-1",
image: { type: null, url: null },
lockDetail: { locked: false, reason: null },
},
});
await new ChatApi().sendMessage(
SendMessageRequest.from({
characterId: CHARACTER_ID,
message: "Hello",
}),
);
expect(httpClientMock).toHaveBeenCalledWith("/api/chat/send", {
method: "POST",
body: expect.objectContaining({ characterId: CHARACTER_ID }),
});
});
it("scopes history to characterId", async () => {
httpClientMock.mockResolvedValue({
success: true,
data: { messages: [], total: 0, limit: 50, offset: 0 },
});
await new ChatApi().getHistory(CHARACTER_ID, 50, 10);
expect(httpClientMock).toHaveBeenCalledWith("/api/chat/history", {
query: { characterId: CHARACTER_ID, limit: 50, offset: 10 },
});
});
it("sends characterId with private and history unlocks", async () => {
const api = new ChatApi();
httpClientMock
.mockResolvedValueOnce({
success: true,
data: {
unlocked: false,
reason: "not_found",
image: { type: null, url: null },
},
})
.mockResolvedValueOnce({
success: true,
data: { unlocked: false, reason: "no_locked_messages" },
});
await api.unlockPrivateMessage(
UnlockPrivateRequest.from({
characterId: CHARACTER_ID,
messageId: "message-1",
}),
);
await api.unlockHistory(
UnlockHistoryRequest.from({ characterId: CHARACTER_ID }),
);
expect(httpClientMock).toHaveBeenNthCalledWith(
1,
"/api/chat/unlock-private",
{
method: "POST",
body: expect.objectContaining({ characterId: CHARACTER_ID }),
},
);
expect(httpClientMock).toHaveBeenNthCalledWith(
2,
"/api/chat/unlock-history",
{
method: "POST",
body: { characterId: CHARACTER_ID },
},
);
});
it("scopes private-room albums to characterId", async () => {
httpClientMock.mockResolvedValue({
success: true,
data: { items: [], creditBalance: 0 },
});
await new PrivateRoomApi().getAlbums({
characterId: CHARACTER_ID,
limit: 20,
});
expect(httpClientMock).toHaveBeenCalledWith("/api/private-room/albums", {
query: { characterId: CHARACTER_ID, limit: 20 },
});
});
});
+2 -1
View File
@@ -24,5 +24,6 @@
"privateRoomAlbumUnlock": { "method": "post", "path": "/api/private-room/albums/{albumId}/unlock" },
"metricsPwaEvent": { "method": "post", "path": "/api/metrics/pwa/event" },
"reportUserInfo": { "method": "post", "path": "/api/data/report-user-info" },
"feedback": { "method": "post", "path": "/api/feedback" }
"feedback": { "method": "post", "path": "/api/feedback" },
"characters": { "method": "get", "path": "/api/characters" }
}
+3
View File
@@ -98,4 +98,7 @@ export class ApiPath {
// ============ 用户反馈相关 ============
static readonly feedback = apiContract.feedback.path;
// ============ 角色相关 ============
static readonly characters = apiContract.characters.path;
}
+14
View File
@@ -0,0 +1,14 @@
import { CharactersResponse } from "@/data/dto/character";
import { ApiPath } from "./api_path";
import { httpClient } from "./http_client";
import { type ApiEnvelope, unwrap } from "./response_helper";
export class CharacterApi {
async getCharacters(): Promise<CharactersResponse> {
const envelope = await httpClient<ApiEnvelope<unknown>>(ApiPath.characters);
return CharactersResponse.fromJson(unwrap(envelope));
}
}
export const characterApi = new CharacterApi();
+11 -3
View File
@@ -11,6 +11,7 @@ import {
ChatHistoryResponse,
ChatSendResponse,
SendMessageRequest,
UnlockHistoryRequest,
UnlockHistoryResponse,
UnlockPrivateRequest,
UnlockPrivateResponse,
@@ -31,9 +32,13 @@ export class ChatApi {
/**
* 获取聊天历史
*/
async getHistory(limit = 50, offset = 0): Promise<ChatHistoryResponse> {
async getHistory(
characterId: string,
limit = 50,
offset = 0,
): Promise<ChatHistoryResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatHistory, {
query: { limit, offset },
query: { characterId, limit, offset },
});
return ChatHistoryResponse.fromJson(
unwrap(env) as Record<string, unknown>
@@ -61,11 +66,14 @@ export class ChatApi {
/**
* 一键解锁历史锁定消息
*/
async unlockHistory(): Promise<UnlockHistoryResponse> {
async unlockHistory(
body: UnlockHistoryRequest,
): Promise<UnlockHistoryResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.chatUnlockHistory,
{
method: "POST",
body: body.toJson(),
},
);
return UnlockHistoryResponse.fromJson(
+1
View File
@@ -6,6 +6,7 @@ export * from "./api_path";
export * from "./api_result";
export * from "./auth_api";
export * from "./chat_api";
export * from "./character_api";
export * from "./feedback_api";
export * from "./http_client";
export * from "./metrics_api";
+3 -2
View File
@@ -3,13 +3,14 @@ import {
PrivateAlbumUnlockResponse,
UnlockPrivateAlbumRequest,
} from "@/data/dto/private-room";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { ApiPath } from "./api_path";
import { httpClient } from "./http_client";
import { type ApiEnvelope, unwrap } from "./response_helper";
export interface GetPrivateAlbumsInput {
character?: string;
characterId?: string;
limit?: number;
}
@@ -21,7 +22,7 @@ export class PrivateRoomApi {
ApiPath.privateRoomAlbums,
{
query: {
character: input.character ?? "elio",
characterId: input.characterId ?? DEFAULT_CHARACTER_ID,
limit: input.limit ?? 20,
},
},