198 lines
6.3 KiB
TypeScript
198 lines
6.3 KiB
TypeScript
import { and, eq, desc, isNull } from "drizzle-orm";
|
|
import { db, aiChatSessions, aiChatMessages } from "../db";
|
|
import { buildMentorContext, formatContextForPrompt } from "./ai-context";
|
|
import { generateChatReply, getAiAvailability } from "./ai";
|
|
import {
|
|
formatLibraryContextForPrompt,
|
|
getLibraryContextForTopic,
|
|
shouldIncludeLibraryContextInChat,
|
|
} from "./library-context";
|
|
import { listMemories, getProfileSummary } from "./ai-memory";
|
|
import { MEMORY_CATEGORIES, type MemoryCategory } from "@adventureos/shared";
|
|
import { extractMemoryCandidates } from "./memory-extraction";
|
|
import { buildMemoryActionReply, performChatMemoryAction } from "./chat-memory-actions";
|
|
|
|
export async function listChatSessions(userId: string) {
|
|
return db
|
|
.select()
|
|
.from(aiChatSessions)
|
|
.where(and(eq(aiChatSessions.userId, userId), isNull(aiChatSessions.archivedAt)))
|
|
.orderBy(desc(aiChatSessions.updatedAt))
|
|
.limit(20);
|
|
}
|
|
|
|
export async function createChatSession(
|
|
userId: string,
|
|
title?: string,
|
|
featureContext?: Record<string, unknown>
|
|
) {
|
|
const [session] = await db
|
|
.insert(aiChatSessions)
|
|
.values({
|
|
userId,
|
|
title: title ?? "New conversation",
|
|
featureContext: featureContext ?? {},
|
|
})
|
|
.returning();
|
|
return session;
|
|
}
|
|
|
|
export async function getChatSession(userId: string, sessionId: string) {
|
|
const [session] = await db
|
|
.select()
|
|
.from(aiChatSessions)
|
|
.where(and(eq(aiChatSessions.userId, userId), eq(aiChatSessions.id, sessionId)));
|
|
if (!session) return null;
|
|
|
|
const messages = await db
|
|
.select()
|
|
.from(aiChatMessages)
|
|
.where(eq(aiChatMessages.sessionId, sessionId))
|
|
.orderBy(aiChatMessages.createdAt);
|
|
|
|
return { session, messages };
|
|
}
|
|
|
|
export async function sendChatMessage(userId: string, sessionId: string, content: string) {
|
|
const sessionData = await getChatSession(userId, sessionId);
|
|
if (!sessionData) throw new Error("Session not found");
|
|
|
|
const { session, messages } = sessionData;
|
|
|
|
const [userMsg] = await db
|
|
.insert(aiChatMessages)
|
|
.values({
|
|
sessionId,
|
|
role: "user",
|
|
content: content.trim(),
|
|
})
|
|
.returning();
|
|
|
|
const memoryAction = await performChatMemoryAction(userId, sessionId, userMsg, messages);
|
|
const authoritativeMemoryReply = buildMemoryActionReply(memoryAction);
|
|
|
|
const availability = await getAiAvailability(userId);
|
|
const ctx = await buildMentorContext(userId, {
|
|
userMessage: content,
|
|
feature: "mentor",
|
|
activeDate: (session.featureContext as Record<string, unknown>)?.date as string | undefined,
|
|
logContext: true,
|
|
});
|
|
|
|
let contextBlock = formatContextForPrompt(ctx);
|
|
if (shouldIncludeLibraryContextInChat(content)) {
|
|
try {
|
|
const libraryPayload = await getLibraryContextForTopic(userId, content);
|
|
contextBlock = `${contextBlock}\n\n${formatLibraryContextForPrompt(libraryPayload)}`;
|
|
} catch {
|
|
/* library optional */
|
|
}
|
|
}
|
|
|
|
let reply: string;
|
|
let offline = false;
|
|
const memoryIdsUsed = ctx.memoryIds;
|
|
|
|
if (authoritativeMemoryReply) {
|
|
reply = authoritativeMemoryReply;
|
|
} else if (!availability.canUse || !availability.online) {
|
|
offline = true;
|
|
reply = buildOfflineReply(content, ctx);
|
|
} else {
|
|
const history = messages
|
|
.slice(-6)
|
|
.map((m) => `${m.role}: ${m.content}`)
|
|
.join("\n");
|
|
|
|
const generated = await generateChatReply(userId, {
|
|
userMessage: content,
|
|
contextBlock,
|
|
history,
|
|
memoryAction: JSON.stringify(memoryAction),
|
|
});
|
|
|
|
reply =
|
|
generated ??
|
|
"I'm having trouble responding right now. Your memories are still saved — try again when the AI is online.";
|
|
if (!generated) offline = true;
|
|
}
|
|
|
|
const [assistantMsg] = await db
|
|
.insert(aiChatMessages)
|
|
.values({
|
|
sessionId,
|
|
role: "assistant",
|
|
content: reply,
|
|
metadata: { memoryIdsUsed, offline, memoryAction },
|
|
})
|
|
.returning();
|
|
|
|
const memorySuggestions =
|
|
memoryAction.intent === "ordinary_message"
|
|
? await extractMemoryCandidates(userId, "chat", content, {
|
|
type: "ai_chat",
|
|
id: userMsg.id,
|
|
})
|
|
: memoryAction.type === "memory_suggestion_created"
|
|
? [
|
|
{
|
|
id: memoryAction.id,
|
|
title: memoryAction.title,
|
|
category: memoryAction.category,
|
|
status: memoryAction.status,
|
|
},
|
|
]
|
|
: [];
|
|
|
|
await db
|
|
.update(aiChatSessions)
|
|
.set({ updatedAt: new Date(), title: session.title === "New conversation" ? truncateTitle(content) : session.title })
|
|
.where(eq(aiChatSessions.id, sessionId));
|
|
|
|
return { message: assistantMsg, reply, memoryIdsUsed, offline, memorySuggestions, memoryAction };
|
|
}
|
|
|
|
function truncateTitle(text: string): string {
|
|
const t = text.trim();
|
|
return t.length > 40 ? `${t.slice(0, 37)}…` : t;
|
|
}
|
|
|
|
function buildOfflineReply(message: string, ctx: Awaited<ReturnType<typeof buildMentorContext>>): string {
|
|
const lower = message.toLowerCase();
|
|
if (lower.includes("what do you know") || lower.includes("about me")) {
|
|
const memLines = ctx.memories.map((m) => `• ${m.title}: ${m.content}`).join("\n");
|
|
return [
|
|
"The AI is offline, but here is what I have saved locally:",
|
|
ctx.profileSummary ? `\nSummary: ${ctx.profileSummary}` : "",
|
|
memLines ? `\nMemories:\n${memLines}` : "\nNo memories saved yet.",
|
|
"\nYou can edit these in Settings → AI Memory.",
|
|
].join("");
|
|
}
|
|
return "The mentor is offline right now. Your adventure data is still here — try again when AI is online, or check Settings → AI Memory.";
|
|
}
|
|
|
|
export async function archiveChatSession(userId: string, sessionId: string) {
|
|
await db
|
|
.update(aiChatSessions)
|
|
.set({ archivedAt: new Date() })
|
|
.where(and(eq(aiChatSessions.userId, userId), eq(aiChatSessions.id, sessionId)));
|
|
}
|
|
|
|
export async function getWhatAiKnows(userId: string) {
|
|
const summary = await getProfileSummary(userId);
|
|
const memories = await listMemories(userId, { enabled: true });
|
|
const grouped: Record<string, typeof memories> = {};
|
|
for (const m of memories) {
|
|
if (!grouped[m.category]) grouped[m.category] = [];
|
|
grouped[m.category].push(m);
|
|
}
|
|
return {
|
|
summary: summary?.summary ?? "",
|
|
grouped: Object.entries(grouped).map(([category, items]) => ({
|
|
category,
|
|
label: MEMORY_CATEGORIES[category as MemoryCategory] ?? category,
|
|
items,
|
|
})),
|
|
};
|
|
}
|