154
apps/web/src/lib/services/ai-chat.ts
Normal file
154
apps/web/src/lib/services/ai-chat.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
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 { listMemories, getProfileSummary } from "./ai-memory";
|
||||
import { MEMORY_CATEGORIES, type MemoryCategory } from "@adventureos/shared";
|
||||
|
||||
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;
|
||||
|
||||
await db.insert(aiChatMessages).values({
|
||||
sessionId,
|
||||
role: "user",
|
||||
content: content.trim(),
|
||||
});
|
||||
|
||||
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 reply: string;
|
||||
let offline = false;
|
||||
let memoryIdsUsed = ctx.memoryIds;
|
||||
|
||||
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: formatContextForPrompt(ctx),
|
||||
history,
|
||||
});
|
||||
|
||||
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 },
|
||||
})
|
||||
.returning();
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user