feat: 聊天室模型选择器 + 会话更新 API + 消息部件重构

- 新增 PATCH /api/projects/:id/conversations/:cid 端点,支持更新 modelId 和 title
- 聊天面板新增模型选择下拉框,切换模型自动持久化
- 新建会话时传入默认文本模型 modelId
- 将 ToolCallCard 拆分为 ReasoningPart / TextPart / ToolPart 独立部件
- ToolPart 增加流式状态图标、折叠面板自动展开、错误详情展示
- ReasoningPart 增加思考中/思考完成状态指示
- 补充 PATCH 端点测试:更新成功、跨项目 403、不存在 404、无效 modelId 400
This commit is contained in:
2026-05-31 21:56:50 +08:00
parent 3e1f3b554d
commit f2e3d84fb1
15 changed files with 536 additions and 122 deletions

1
.gitignore vendored
View File

@@ -415,6 +415,7 @@ data/
backend/bin
backend/server
backend/desktop
!src/**/*
# Embedfs generated
embedfs/assets/

View File

@@ -2,7 +2,7 @@ import type Database from "bun:sqlite";
import { desc, eq } from "drizzle-orm";
import type { Conversation, Message } from "../../shared/api";
import type { Conversation, Message, UpdateConversationRequest } from "../../shared/api";
import { paginateQuery, wrap } from "./connection";
import { conversations, messages, models } from "./schema";
@@ -150,6 +150,33 @@ export function listMessages(
});
}
export function updateConversation(
raw: Database,
id: string,
data: UpdateConversationRequest,
): { conversation: Conversation } | { error: string; status: number } {
const db = wrap(raw);
const existing = db.select().from(conversations).where(eq(conversations.id, id)).get();
if (!existing) return { error: "会话不存在", status: 404 };
const updates: { modelId?: string; title?: string; updatedAt: string } = { updatedAt: new Date().toISOString() };
if (data.modelId !== undefined) {
const model = db.select().from(models).where(eq(models.id, data.modelId)).get();
if (!model) return { error: "模型不存在", status: 400 };
updates.modelId = data.modelId;
}
if (data.title !== undefined) {
updates.title = data.title;
}
db.update(conversations).set(updates).where(eq(conversations.id, id)).run();
const row = db.select().from(conversations).where(eq(conversations.id, id)).get();
return { conversation: toConversation(row!) };
}
export function updateConversationTimestamp(raw: Database, id: string): void {
const db = wrap(raw);
db.update(conversations).set({ updatedAt: new Date().toISOString() }).where(eq(conversations.id, id)).run();

View File

@@ -0,0 +1,47 @@
import type Database from "bun:sqlite";
import type { RuntimeMode, UpdateConversationRequest } from "../../../shared/api";
import { getConversation, updateConversation } from "../../db/conversations";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export async function handleUpdateConversation(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
const url = new URL(req.url);
const parts = url.pathname.split("/");
const projectId = parts[3];
const conversationId = parts[5];
const validatedProject = validateIdParam(projectId ?? "", mode);
if (validatedProject instanceof Response) return validatedProject;
const validatedConv = validateIdParam(conversationId ?? "", mode);
if (validatedConv instanceof Response) return validatedConv;
const existing = getConversation(db, validatedConv.id);
if ("error" in existing) {
return jsonResponse(createApiError(existing.error, existing.status), { mode, status: existing.status });
}
if (existing.conversation.projectId !== validatedProject.id) {
return jsonResponse(createApiError("会话不属于该项目", 403), { mode, status: 403 });
}
let body: UpdateConversationRequest;
try {
body = (await req.json()) as UpdateConversationRequest;
} catch {
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}
if (body.modelId === undefined && body.title === undefined) {
return jsonResponse(createApiError("至少需要传 modelId 或 title", 400), { mode, status: 400 });
}
const result = updateConversation(db, validatedConv.id, body);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
return jsonResponse({ conversation: result.conversation }, { mode });
}

View File

@@ -201,6 +201,14 @@ export function startServer(options: StartServerOptions) {
mode,
logger,
),
PATCH: withErrorHandler(
async (req) => {
const { handleUpdateConversation } = await import("./routes/chat/update");
return handleUpdateConversation(req, db, mode);
},
mode,
logger,
),
},
"/api/projects/:id/conversations/:cid/messages": {
GET: withErrorHandler(

View File

@@ -42,11 +42,6 @@ export interface CreateProjectRequest {
name: string;
}
// ==========================================
// 在此定义你的业务类型
// 前后端共享的类型都放在这个文件中
// ==========================================
export interface CreateProviderRequest {
apiKey: string;
baseUrl: string;
@@ -54,6 +49,11 @@ export interface CreateProviderRequest {
type: ProviderType;
}
// ==========================================
// 在此定义你的业务类型
// 前后端共享的类型都放在这个文件中
// ==========================================
export interface Message {
content: string;
conversationId: string;
@@ -104,6 +104,11 @@ export interface SendMessageRequest {
messages: Array<{ content: string; role: "assistant" | "system" | "user" }>;
}
export interface UpdateConversationRequest {
modelId?: string;
title?: string;
}
export const MODEL_CAPABILITIES: readonly ModelCapability[] = [
"audio-generation",
"audio-recognition",

View File

@@ -1,11 +1,13 @@
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type UIMessage } from "ai";
import { App, Button, Card, Collapse, Empty, Flex, Input, Spin, Typography } from "antd";
import { useCallback, useEffect, useRef, useState } from "react";
import { Streamdown } from "streamdown";
import { App, Button, Card, Empty, Flex, Input, Select, Spin } from "antd";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchMessages } from "../../../../hooks/use-conversations";
import { ToolCallCard } from "./ToolCallCard";
import { fetchConversation, fetchMessages, updateConversation } from "../../../../hooks/use-conversations";
import { useModelList } from "../../../../hooks/use-models";
import { ReasoningPart } from "./parts/ReasoningPart";
import { TextPart } from "./parts/TextPart";
import { ToolPart } from "./parts/ToolPart";
interface ChatPanelProps {
conversationId: null | string;
@@ -16,9 +18,18 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
const { message } = App.useApp();
const [input, setInput] = useState("");
const [loadingHistory, setLoadingHistory] = useState(false);
const [selectedModelId, setSelectedModelId] = useState<null | string>(null);
const fetchRef = useRef(fetchMessages);
const scrollRef = useRef<HTMLDivElement>(null);
const { data: modelsData } = useModelList({ pageSize: 200 });
const textModels = useMemo(
() => (modelsData?.items ?? []).filter((m) => m.capabilities.includes("text")),
[modelsData],
);
const modelOptions = useMemo(() => textModels.map((m) => ({ label: m.name, value: m.id })), [textModels]);
const { messages, sendMessage, setMessages, status, stop } = useChat({
onError: (err) => {
void message.error(`发送失败:${err.message}`);
@@ -45,8 +56,21 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
setInput("");
setMessages([]);
try {
const data = await fetchRef.current(projectId, conversationId);
const convPromise = fetchConversation(projectId, conversationId);
const msgPromise = fetchRef.current(projectId, conversationId);
const conv = await convPromise;
const data = await msgPromise;
if (cancelled) return;
const firstTextId = textModels[0]?.id;
if (firstTextId && textModels.every((m) => m.id !== conv.modelId)) {
setSelectedModelId(firstTextId);
void updateConversation(projectId, conversationId, { modelId: firstTextId });
} else {
setSelectedModelId(conv.modelId);
}
const history = data.items
.filter((m: { role: string }) => m.role === "user" || m.role === "assistant")
.reverse()
@@ -71,7 +95,19 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
return () => {
cancelled = true;
};
}, [conversationId, projectId, setMessages, message]);
}, [conversationId, projectId, setMessages, message, textModels]);
const displayModelId = conversationId ? selectedModelId : (textModels[0]?.id ?? null);
const handleModelChange = useCallback(
(value: string) => {
setSelectedModelId(value);
if (conversationId) {
void updateConversation(projectId, conversationId, { modelId: value });
}
},
[projectId, conversationId],
);
const handleSend = useCallback(() => {
if (!input.trim() || !conversationId) return;
@@ -118,7 +154,7 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
</Flex>
</div>
)}
<Flex align="end" className="chat-input-area" gap={8}>
<div className="chat-input-area">
<Input.TextArea
autoSize={{ maxRows: 6, minRows: 1 }}
className="chat-textarea"
@@ -133,6 +169,16 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
placeholder="输入消息..."
value={input}
/>
<Flex align="center" gap={8} justify="space-between">
<Select
className="chat-model-select"
disabled={isLoading}
onChange={handleModelChange}
options={modelOptions}
placeholder="选择模型"
value={displayModelId}
/>
<Flex align="center" gap={8}>
{isLoading ? (
<Button
danger
@@ -148,6 +194,8 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
</Button>
)}
</Flex>
</Flex>
</div>
</div>
);
}
@@ -155,29 +203,14 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
function PartRenderer({ part, role }: { part: Record<string, unknown>; role: string }) {
const partType = typeof part["type"] === "string" ? part["type"] : "";
if (partType === "text" && role === "user") {
return <Typography.Paragraph className="message-body-text">{part["text"] as string}</Typography.Paragraph>;
}
if (partType === "text" && role === "assistant") {
return <Streamdown parseIncompleteMarkdown>{part["text"] as string}</Streamdown>;
if (partType === "text") {
return <TextPart part={part} role={role} />;
}
if (partType.startsWith("tool-")) {
return <ToolCallCard part={part} />;
return <ToolPart part={part} />;
}
if (partType === "reasoning") {
return (
<Collapse
ghost
items={[
{
children: <Typography.Text type="secondary">{part["text"] as string}</Typography.Text>,
key: "reasoning",
label: <Typography.Text type="secondary"></Typography.Text>,
},
]}
size="small"
/>
);
return <ReasoningPart part={part} />;
}
return null;
}

View File

@@ -5,6 +5,7 @@ import { App, Button, Flex, Popconfirm, Spin, Typography } from "antd";
import type { Conversation } from "../../../../../shared/api";
import { createConversation, deleteConversation, fetchConversations } from "../../../../hooks/use-conversations";
import { useModelList } from "../../../../hooks/use-models";
interface ChatSidebarProps {
activeId: null | string;
@@ -23,8 +24,12 @@ export function ChatSidebar({ activeId, onSelect, projectId }: ChatSidebarProps)
queryKey: CONVERSATIONS_KEY,
});
const { data: modelsData } = useModelList({ pageSize: 200 });
const textModels = (modelsData?.items ?? []).filter((m) => m.capabilities.includes("text"));
const defaultModelId = textModels[0]?.id;
const createMutation = useMutation({
mutationFn: () => createConversation(projectId),
mutationFn: () => createConversation(projectId, defaultModelId),
onError: (err: Error) => {
void message.error(`创建会话失败:${err.message}`);
},

View File

@@ -1,70 +0,0 @@
import { Collapse, Flex, Tag, Typography } from "antd";
interface ToolPart {
errorText?: string;
input?: unknown;
output?: unknown;
toolCallId?: string;
toolName?: string;
type?: string;
}
function getToolState(part: ToolPart) {
if ("errorText" in part && part.errorText) return "output-error" as const;
if ("output" in part) return "output-available" as const;
if ("input" in part) return "input-available" as const;
return "input-streaming" as const;
}
const FORMAT_JSON = (v: unknown) => JSON.stringify(v, null, 2);
interface ToolCallCardProps {
part: ToolPart;
}
export function ToolCallCard({ part }: ToolCallCardProps) {
const state = getToolState(part);
const toolName = part.toolName ?? (part.type ?? "unknown").replace(/^tool-/, "");
switch (state) {
case "input-available":
return <Tag color="processing">{toolName} · </Tag>;
case "input-streaming":
return <Tag color="processing">...</Tag>;
case "output-available":
return (
<Collapse
ghost
items={[
{
children: (
<Flex gap={4} vertical>
<Typography.Text type="secondary"></Typography.Text>
<pre className="tool-result-pre">{FORMAT_JSON(part.input)}</pre>
<Typography.Text type="secondary"></Typography.Text>
<pre className="tool-result-pre">{FORMAT_JSON(part.output)}</pre>
</Flex>
),
key: part.toolCallId ?? toolName,
label: (
<>
<Tag color="success"></Tag> {toolName}
</>
),
},
]}
size="small"
/>
);
case "output-error":
return (
<Flex align="center" gap={4}>
<Tag color="error"></Tag>
<Typography.Text type="danger">{part.errorText}</Typography.Text>
</Flex>
);
}
}

View File

@@ -0,0 +1,34 @@
import { CheckCircleFilled, LoadingOutlined } from "@ant-design/icons";
import { Collapse, Flex, Typography } from "antd";
import type { PartProps } from "./types";
export function ReasoningPart({ part }: PartProps) {
const text = typeof part["text"] === "string" ? part["text"] : "";
const state = typeof part["state"] === "string" ? part["state"] : "";
const isStreaming = state === "streaming";
return (
<Collapse
ghost
items={[
{
children: <Typography.Text type="secondary">{text}</Typography.Text>,
key: "reasoning",
label: isStreaming ? (
<Flex align="center" component="span" gap={4}>
<LoadingOutlined className="icon-primary" />
<Typography.Text type="secondary"></Typography.Text>
</Flex>
) : (
<Flex align="center" component="span" gap={4}>
<CheckCircleFilled className="icon-success" />
<Typography.Text type="secondary"></Typography.Text>
</Flex>
),
},
]}
size="small"
/>
);
}

View File

@@ -0,0 +1,22 @@
import { Typography } from "antd";
import { Streamdown } from "streamdown";
import type { PartProps } from "./types";
interface TextPartProps extends PartProps {
role: string;
}
export function TextPart({ part, role }: TextPartProps) {
const text = typeof part["text"] === "string" ? part["text"] : "";
return (
<div className="part-body">
{role === "user" ? (
<Typography.Paragraph className="message-body-text">{text}</Typography.Paragraph>
) : (
<Streamdown parseIncompleteMarkdown>{text}</Streamdown>
)}
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from "@ant-design/icons";
import { Collapse, Flex, Typography } from "antd";
import type { PartProps } from "./types";
interface ToolPartData {
errorText?: string;
input?: unknown;
output?: unknown;
toolCallId?: string;
toolName?: string;
type?: string;
}
function getToolState(part: ToolPartData) {
if ("errorText" in part && part.errorText) return "output-error" as const;
if ("output" in part) return "output-available" as const;
if ("input" in part) return "input-available" as const;
return "input-streaming" as const;
}
const FORMAT_JSON = (v: unknown) => JSON.stringify(v, null, 2);
export function ToolPart({ part }: PartProps) {
const toolPart = part as unknown as ToolPartData;
const state = getToolState(toolPart);
const toolName = toolPart.toolName ?? (toolPart.type ?? "unknown").replace(/^tool-/, "");
const isStreaming = state === "input-streaming" || state === "input-available";
if (state === "output-error") {
return (
<Collapse
ghost
items={[
{
children: <Typography.Text type="danger">{toolPart.errorText}</Typography.Text>,
key: toolPart.toolCallId ?? toolName,
label: (
<Flex align="center" component="span" gap={4}>
<CloseCircleFilled className="icon-error" />
<Typography.Text type="danger">{toolName} </Typography.Text>
</Flex>
),
},
]}
size="small"
/>
);
}
return (
<Collapse
defaultActiveKey={isStreaming ? [toolPart.toolCallId ?? toolName] : undefined}
ghost
items={[
{
children: (
<Flex gap={4} vertical>
{toolPart.input != null && (
<>
<Typography.Text type="secondary"></Typography.Text>
<pre className="tool-result-pre">{FORMAT_JSON(toolPart.input)}</pre>
</>
)}
{"output" in toolPart && toolPart.output != null && (
<>
<Typography.Text type="secondary"></Typography.Text>
<pre className="tool-result-pre">{FORMAT_JSON(toolPart.output)}</pre>
</>
)}
{!toolPart.input && !("output" in toolPart) && (
<Typography.Text type="secondary">...</Typography.Text>
)}
</Flex>
),
key: toolPart.toolCallId ?? toolName,
label: isStreaming ? (
<Flex align="center" component="span" gap={4}>
<LoadingOutlined className="icon-primary" />
<Typography.Text type="secondary">
{state === "input-streaming" ? "生成参数" : `调用 ${toolName}`}
</Typography.Text>
</Flex>
) : (
<Flex align="center" component="span" gap={4}>
<CheckCircleFilled className="icon-success" />
<Typography.Text type="secondary">{toolName}</Typography.Text>
</Flex>
),
},
]}
size="small"
/>
);
}

View File

@@ -0,0 +1,3 @@
export interface PartProps {
part: Record<string, unknown>;
}

View File

@@ -3,6 +3,7 @@ import type {
ConversationListResponse,
ConversationResponse,
MessageListResponse,
UpdateConversationRequest,
} from "../../shared/api";
import { handleResponse, handleVoidResponse } from "../utils/api";
@@ -43,3 +44,16 @@ export async function fetchMessages(projectId: string, conversationId: string):
}
return response.json() as Promise<MessageListResponse>;
}
export async function updateConversation(
projectId: string,
conversationId: string,
data: UpdateConversationRequest,
): Promise<Conversation> {
const response = await fetch(`/api/projects/${projectId}/conversations/${conversationId}`, {
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
method: "PATCH",
});
return handleResponse(response, (data) => (data as ConversationResponse).conversation);
}

View File

@@ -157,6 +157,9 @@ body {
.chat-input-area {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 16px;
border-top: 1px solid var(--ant-color-border-secondary);
}
@@ -177,7 +180,7 @@ body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px;
padding-left: var(--ant-padding-lg);
}
.chat-loading-indicator {
@@ -204,3 +207,23 @@ body {
.msg-title-ai {
color: var(--ant-color-primary);
}
.part-body {
padding: 0 var(--ant-padding-sm);
}
.icon-primary {
color: var(--ant-color-primary);
}
.icon-success {
color: var(--ant-color-success);
}
.icon-error {
color: var(--ant-color-error);
}
.chat-model-select {
width: 180px;
}

View File

@@ -57,10 +57,15 @@ async function listMessagesViaHandler(req: Request, db: Database): Promise<Respo
return h(req, db, MODE);
}
function seedModel(db: Database, providerId: string, modelName = "GPT-4o"): string {
async function patchConversationViaHandler(req: Request, db: Database): Promise<Response> {
const { handleUpdateConversation: h } = await import("../../../src/server/routes/chat/update");
return h(req, db, MODE);
}
function seedModel(db: Database, providerId: string, modelName = "GPT-4o", modelId = "gpt-4o"): string {
const result = createModel(db, {
capabilities: ["text"],
modelId: "gpt-4o",
modelId,
name: modelName,
providerId,
});
@@ -352,6 +357,167 @@ describe("聊天 API 路由", () => {
});
});
describe("PATCH /api/projects/:id/conversations/:cid", () => {
test("更新会话 modelId 成功", async () => {
const handle = createMigratedMemoryTestDatabase("chat-patch-model");
try {
const db = handle.db;
const projectId = seedProject(db);
const providerId = seedProvider(db);
const modelId1 = seedModel(db, providerId, "GPT-4o", "gpt-4o");
const modelId2 = seedModel(db, providerId, "GPT-4o-mini", "gpt-4o-mini");
const createRes = await createConversationViaHandler(
new Request(`http://localhost/api/projects/${projectId}/conversations`, {
body: JSON.stringify({ modelId: modelId1 }),
headers: { "Content-Type": "application/json" },
method: "POST",
}),
db,
);
const created = ((await createRes.json()) as { conversation: Conversation }).conversation;
const res = await patchConversationViaHandler(
new Request(`http://localhost/api/projects/${projectId}/conversations/${created.id}`, {
body: JSON.stringify({ modelId: modelId2 }),
headers: { "Content-Type": "application/json" },
method: "PATCH",
}),
db,
);
expect(res.status).toBe(200);
const body = (await res.json()) as { conversation: Conversation };
expect(body.conversation.modelId).toBe(modelId2);
handle.close();
} finally {
handle.cleanup();
}
});
test("更新会话 title 成功", async () => {
const handle = createMigratedMemoryTestDatabase("chat-patch-title");
try {
const db = handle.db;
const projectId = seedProject(db);
const providerId = seedProvider(db);
seedModel(db, providerId);
const createRes = await createConversationViaHandler(
new Request(`http://localhost/api/projects/${projectId}/conversations`, {
body: JSON.stringify({}),
headers: { "Content-Type": "application/json" },
method: "POST",
}),
db,
);
const created = ((await createRes.json()) as { conversation: Conversation }).conversation;
const res = await patchConversationViaHandler(
new Request(`http://localhost/api/projects/${projectId}/conversations/${created.id}`, {
body: JSON.stringify({ title: "新标题" }),
headers: { "Content-Type": "application/json" },
method: "PATCH",
}),
db,
);
expect(res.status).toBe(200);
const body = (await res.json()) as { conversation: Conversation };
expect(body.conversation.title).toBe("新标题");
handle.close();
} finally {
handle.cleanup();
}
});
test("跨项目更新会话返回 403", async () => {
const handle = createMigratedMemoryTestDatabase("chat-patch-403");
try {
const db = handle.db;
const projectA = seedProject(db, "项目A");
const projectB = seedProject(db, "项目B");
const providerId = seedProvider(db);
seedModel(db, providerId);
const createRes = await createConversationViaHandler(
new Request(`http://localhost/api/projects/${projectA}/conversations`, {
body: JSON.stringify({}),
headers: { "Content-Type": "application/json" },
method: "POST",
}),
db,
);
const created = ((await createRes.json()) as { conversation: Conversation }).conversation;
const res = await patchConversationViaHandler(
new Request(`http://localhost/api/projects/${projectB}/conversations/${created.id}`, {
body: JSON.stringify({ title: "探测" }),
headers: { "Content-Type": "application/json" },
method: "PATCH",
}),
db,
);
expect(res.status).toBe(403);
handle.close();
} finally {
handle.cleanup();
}
});
test("不存在的会话返回 404", async () => {
const handle = createMigratedMemoryTestDatabase("chat-patch-404");
try {
const db = handle.db;
const projectId = seedProject(db);
const res = await patchConversationViaHandler(
new Request(`http://localhost/api/projects/${projectId}/conversations/nonexistent`, {
body: JSON.stringify({ title: "探测" }),
headers: { "Content-Type": "application/json" },
method: "PATCH",
}),
db,
);
expect(res.status).toBe(404);
handle.close();
} finally {
handle.cleanup();
}
});
test("无效 modelId 返回 400", async () => {
const handle = createMigratedMemoryTestDatabase("chat-patch-bad-model");
try {
const db = handle.db;
const projectId = seedProject(db);
const providerId = seedProvider(db);
seedModel(db, providerId);
const createRes = await createConversationViaHandler(
new Request(`http://localhost/api/projects/${projectId}/conversations`, {
body: JSON.stringify({}),
headers: { "Content-Type": "application/json" },
method: "POST",
}),
db,
);
const created = ((await createRes.json()) as { conversation: Conversation }).conversation;
const res = await patchConversationViaHandler(
new Request(`http://localhost/api/projects/${projectId}/conversations/${created.id}`, {
body: JSON.stringify({ modelId: "invalid-model-id" }),
headers: { "Content-Type": "application/json" },
method: "PATCH",
}),
db,
);
expect(res.status).toBe(400);
handle.close();
} finally {
handle.cleanup();
}
});
});
describe("GET /api/projects/:id/conversations/:cid/messages", () => {
test("跨项目获取消息返回 403", async () => {
const handle = createMigratedMemoryTestDatabase("chat-msg-403");