feat(characters): support character-scoped conversations
This commit is contained in:
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" }
|
||||
}
|
||||
|
||||
@@ -98,4 +98,7 @@ export class ApiPath {
|
||||
|
||||
// ============ 用户反馈相关 ============
|
||||
static readonly feedback = apiContract.feedback.path;
|
||||
|
||||
// ============ 角色相关 ============
|
||||
static readonly characters = apiContract.characters.path;
|
||||
}
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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,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,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user