This commit is contained in:
108
apps/web/src/lib/mentor-chat-utils.test.ts
Normal file
108
apps/web/src/lib/mentor-chat-utils.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
appendOptimisticMessages,
|
||||
applySendError,
|
||||
buildDisplayMessages,
|
||||
clearOptimisticMessages,
|
||||
createOptimisticUserMessage,
|
||||
createThinkingMessage,
|
||||
createErrorMessage,
|
||||
shouldBlockSend,
|
||||
createPendingStateOnSend,
|
||||
createPendingStateOnError,
|
||||
DEFAULT_SEND_ERROR_MESSAGE,
|
||||
OPTIMISTIC_THINKING_ID,
|
||||
} from "./mentor-chat-utils";
|
||||
|
||||
describe("mentor-chat-utils", () => {
|
||||
const serverMessages = [
|
||||
{ id: "1", role: "user", content: "Hello" },
|
||||
{ id: "2", role: "assistant", content: "Hi there" },
|
||||
];
|
||||
|
||||
it("appendOptimisticMessages adds user and thinking entries", () => {
|
||||
const result = appendOptimisticMessages(serverMessages, "New question", 1000);
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result[2]).toMatchObject({
|
||||
role: "user",
|
||||
content: "New question",
|
||||
status: "sending",
|
||||
id: "optimistic-user-1000",
|
||||
});
|
||||
expect(result[3]).toMatchObject({
|
||||
role: "assistant",
|
||||
status: "thinking",
|
||||
id: OPTIMISTIC_THINKING_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it("appendOptimisticMessages replaces existing optimistic entries", () => {
|
||||
const withOptimistic = appendOptimisticMessages(serverMessages, "First", 1000);
|
||||
const result = appendOptimisticMessages(withOptimistic, "Second", 2000);
|
||||
const optimistic = result.filter((m) => m.id.startsWith("optimistic"));
|
||||
expect(optimistic).toHaveLength(2);
|
||||
expect(optimistic[0].content).toBe("Second");
|
||||
});
|
||||
|
||||
it("applySendError removes thinking and adds error bubble", () => {
|
||||
const pending = appendOptimisticMessages(serverMessages, "Failed msg", 1000);
|
||||
const result = applySendError(pending, "Custom error", 2000);
|
||||
expect(result.find((m) => m.status === "thinking")).toBeUndefined();
|
||||
expect(result.find((m) => m.status === "error")).toMatchObject({
|
||||
content: "Custom error",
|
||||
role: "assistant",
|
||||
});
|
||||
const userMsg = result.find((m) => m.content === "Failed msg");
|
||||
expect(userMsg?.status).toBe("sent");
|
||||
});
|
||||
|
||||
it("applySendError keeps user message visible", () => {
|
||||
const pending = appendOptimisticMessages([], "My message", 1000);
|
||||
const result = applySendError(pending);
|
||||
expect(result.some((m) => m.role === "user" && m.content === "My message")).toBe(true);
|
||||
});
|
||||
|
||||
it("buildDisplayMessages merges server messages with pending state", () => {
|
||||
const pending = createPendingStateOnSend("Pending question", 1000);
|
||||
const result = buildDisplayMessages(serverMessages, pending);
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result[2].content).toBe("Pending question");
|
||||
expect(result[3].status).toBe("thinking");
|
||||
});
|
||||
|
||||
it("buildDisplayMessages returns server messages when no pending state", () => {
|
||||
expect(buildDisplayMessages(serverMessages, null)).toEqual(serverMessages);
|
||||
});
|
||||
|
||||
it("buildDisplayMessages shows error state from pending", () => {
|
||||
const pending = createPendingStateOnError("Retry me", DEFAULT_SEND_ERROR_MESSAGE, 1000);
|
||||
const result = buildDisplayMessages(serverMessages, pending);
|
||||
expect(result.some((m) => m.status === "error")).toBe(true);
|
||||
expect(result.some((m) => m.content === "Retry me")).toBe(true);
|
||||
});
|
||||
|
||||
it("clearOptimisticMessages removes all optimistic entries", () => {
|
||||
const withOptimistic = appendOptimisticMessages(serverMessages, "Test", 1000);
|
||||
const result = clearOptimisticMessages(withOptimistic);
|
||||
expect(result).toEqual(serverMessages);
|
||||
});
|
||||
|
||||
it("shouldBlockSend blocks when pending or empty", () => {
|
||||
expect(shouldBlockSend(true, "hello")).toBe(true);
|
||||
expect(shouldBlockSend(false, "")).toBe(true);
|
||||
expect(shouldBlockSend(false, " ")).toBe(true);
|
||||
expect(shouldBlockSend(false, "hello")).toBe(false);
|
||||
});
|
||||
|
||||
it("createOptimisticUserMessage trims content", () => {
|
||||
expect(createOptimisticUserMessage(" hi ").content).toBe("hi");
|
||||
});
|
||||
|
||||
it("createThinkingMessage has thinking status", () => {
|
||||
expect(createThinkingMessage().status).toBe("thinking");
|
||||
});
|
||||
|
||||
it("createErrorMessage uses default message", () => {
|
||||
expect(createErrorMessage().content).toBe(DEFAULT_SEND_ERROR_MESSAGE);
|
||||
});
|
||||
});
|
||||
135
apps/web/src/lib/mentor-chat-utils.ts
Normal file
135
apps/web/src/lib/mentor-chat-utils.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
export type MentorMessageStatus = "sending" | "thinking" | "error" | "sent";
|
||||
|
||||
export type MentorChatMessage = {
|
||||
id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
status?: MentorMessageStatus;
|
||||
metadata?: { offline?: boolean };
|
||||
};
|
||||
|
||||
export type MentorPendingState = {
|
||||
optimisticUser?: MentorChatMessage;
|
||||
thinking?: MentorChatMessage;
|
||||
error?: MentorChatMessage;
|
||||
lastFailedContent?: string;
|
||||
};
|
||||
|
||||
export const OPTIMISTIC_USER_PREFIX = "optimistic-user-";
|
||||
export const OPTIMISTIC_THINKING_ID = "optimistic-thinking";
|
||||
export const OPTIMISTIC_ERROR_ID = "optimistic-error";
|
||||
|
||||
export const DEFAULT_SEND_ERROR_MESSAGE =
|
||||
"The AI could not respond just now. Your message is still here — you can retry.";
|
||||
|
||||
export function createOptimisticUserMessage(content: string, now = Date.now()): MentorChatMessage {
|
||||
return {
|
||||
id: `${OPTIMISTIC_USER_PREFIX}${now}`,
|
||||
role: "user",
|
||||
content: content.trim(),
|
||||
status: "sending",
|
||||
};
|
||||
}
|
||||
|
||||
export function createThinkingMessage(): MentorChatMessage {
|
||||
return {
|
||||
id: OPTIMISTIC_THINKING_ID,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
status: "thinking",
|
||||
};
|
||||
}
|
||||
|
||||
export function createErrorMessage(
|
||||
message = DEFAULT_SEND_ERROR_MESSAGE,
|
||||
now = Date.now()
|
||||
): MentorChatMessage {
|
||||
return {
|
||||
id: `${OPTIMISTIC_ERROR_ID}-${now}`,
|
||||
role: "assistant",
|
||||
content: message,
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
export function isOptimisticMessage(message: MentorChatMessage): boolean {
|
||||
return (
|
||||
message.id.startsWith(OPTIMISTIC_USER_PREFIX) ||
|
||||
message.id === OPTIMISTIC_THINKING_ID ||
|
||||
message.id.startsWith(OPTIMISTIC_ERROR_ID)
|
||||
);
|
||||
}
|
||||
|
||||
export function appendOptimisticMessages(
|
||||
messages: MentorChatMessage[],
|
||||
content: string,
|
||||
now = Date.now()
|
||||
): MentorChatMessage[] {
|
||||
const withoutOptimistic = messages.filter((m) => !isOptimisticMessage(m));
|
||||
return [
|
||||
...withoutOptimistic,
|
||||
createOptimisticUserMessage(content, now),
|
||||
createThinkingMessage(),
|
||||
];
|
||||
}
|
||||
|
||||
export function applySendError(
|
||||
messages: MentorChatMessage[],
|
||||
errorMessage = DEFAULT_SEND_ERROR_MESSAGE,
|
||||
now = Date.now()
|
||||
): MentorChatMessage[] {
|
||||
const withoutThinking = messages.filter((m) => m.status !== "thinking");
|
||||
const withSentUser = withoutThinking.map((m) =>
|
||||
m.status === "sending" ? { ...m, status: "sent" as const } : m
|
||||
);
|
||||
return [...withSentUser, createErrorMessage(errorMessage, now)];
|
||||
}
|
||||
|
||||
export function clearOptimisticMessages(messages: MentorChatMessage[]): MentorChatMessage[] {
|
||||
return messages.filter((m) => !isOptimisticMessage(m));
|
||||
}
|
||||
|
||||
export function buildDisplayMessages(
|
||||
serverMessages: MentorChatMessage[],
|
||||
pendingState: MentorPendingState | null
|
||||
): MentorChatMessage[] {
|
||||
if (!pendingState) return serverMessages;
|
||||
|
||||
const base = clearOptimisticMessages(serverMessages);
|
||||
const result = [...base];
|
||||
|
||||
if (pendingState.optimisticUser) {
|
||||
result.push(pendingState.optimisticUser);
|
||||
}
|
||||
if (pendingState.thinking) {
|
||||
result.push(pendingState.thinking);
|
||||
}
|
||||
if (pendingState.error) {
|
||||
result.push(pendingState.error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function shouldBlockSend(isPending: boolean, content: string): boolean {
|
||||
return isPending || !content.trim();
|
||||
}
|
||||
|
||||
export function createPendingStateOnSend(content: string, now = Date.now()): MentorPendingState {
|
||||
return {
|
||||
optimisticUser: createOptimisticUserMessage(content, now),
|
||||
thinking: createThinkingMessage(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPendingStateOnError(
|
||||
content: string,
|
||||
errorMessage = DEFAULT_SEND_ERROR_MESSAGE,
|
||||
now = Date.now()
|
||||
): MentorPendingState {
|
||||
return {
|
||||
optimisticUser: { ...createOptimisticUserMessage(content, now), status: "sent" },
|
||||
error: createErrorMessage(errorMessage, now),
|
||||
lastFailedContent: content.trim(),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, isNull, inArray } from "drizzle-orm";
|
||||
import { and, desc, eq, isNull, inArray } from "drizzle-orm";
|
||||
import { db, adventureTemplates, adventureItems } from "@/lib/db";
|
||||
import { recordAction } from "@/lib/services/action-events";
|
||||
import { ACTION_TYPES } from "@/lib/config";
|
||||
@@ -27,7 +27,8 @@ export async function getTemplates(userId: string) {
|
||||
const templates = await db
|
||||
.select()
|
||||
.from(adventureTemplates)
|
||||
.where(and(eq(adventureTemplates.userId, userId), isNull(adventureTemplates.deletedAt)));
|
||||
.where(and(eq(adventureTemplates.userId, userId), isNull(adventureTemplates.deletedAt)))
|
||||
.orderBy(desc(adventureTemplates.sortPriority));
|
||||
|
||||
const result = [];
|
||||
for (const t of templates) {
|
||||
|
||||
Reference in New Issue
Block a user