improving ai usage
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-06-26 12:25:02 +01:00
parent e3bb654cbc
commit 25d62ce723
9 changed files with 787 additions and 138 deletions

View 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);
});
});

View 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(),
};
}

View File

@@ -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) {