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

@@ -69,6 +69,30 @@ body::after {
user-select: none; user-select: none;
} }
.mentor-thinking-dots span {
animation: mentor-dot-pulse 1.4s infinite;
opacity: 0.3;
}
.mentor-thinking-dots span:nth-child(2) {
animation-delay: 0.2s;
}
.mentor-thinking-dots span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes mentor-dot-pulse {
0%,
80%,
100% {
opacity: 0.3;
}
40% {
opacity: 1;
}
}
.retro-btn { .retro-btn {
background: linear-gradient(180deg, var(--color-surface-raised) 0%, var(--color-surface) 100%); background: linear-gradient(180deg, var(--color-surface-raised) 0%, var(--color-surface) 100%);
border: 2px solid; border: 2px solid;

View File

@@ -1,8 +1,10 @@
"use client"; "use client";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { AppShell } from "@/components/layout/app-shell"; import { AppShell } from "@/components/layout/app-shell";
import { MentorChatMessages } from "@/components/features/mentor-chat-messages";
import { createChatSession, fetchChatSessions, useMentorChat } from "@/hooks/useMentorChat";
import Link from "next/link"; import Link from "next/link";
const SUGGESTED = [ const SUGGESTED = [
@@ -16,40 +18,37 @@ const SUGGESTED = [
export default function MentorPage() { export default function MentorPage() {
const [sessionId, setSessionId] = useState<string | null>(null); const [sessionId, setSessionId] = useState<string | null>(null);
const [input, setInput] = useState("");
const [tab, setTab] = useState<"chat" | "knows" | "preview">("chat"); const [tab, setTab] = useState<"chat" | "knows" | "preview">("chat");
const bottomRef = useRef<HTMLDivElement>(null); const [previewInput, setPreviewInput] = useState("");
const { data: sessionsData } = useQuery({ const { data: sessionsData } = useQuery({
queryKey: ["chat-sessions"], queryKey: ["chat-sessions"],
queryFn: async () => { queryFn: fetchChatSessions,
const res = await fetch("/api/ai/chat/sessions");
return res.json();
},
}); });
useEffect(() => { useEffect(() => {
if (!sessionId && sessionsData?.sessions?.[0]) { if (!sessionId && sessionsData?.sessions?.[0]) {
setSessionId(sessionsData.sessions[0].id); setSessionId(sessionsData.sessions[0].id);
} else if (!sessionId) { } else if (!sessionId) {
fetch("/api/ai/chat/sessions", { createChatSession().then((d) => setSessionId(d.id));
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
})
.then((r) => r.json())
.then((d) => setSessionId(d.id));
} }
}, [sessionId, sessionsData]); }, [sessionId, sessionsData]);
const { data: chatData, refetch } = useQuery({ const chat = useMentorChat(sessionId);
queryKey: ["chat-session", sessionId],
queryFn: async () => { useEffect(() => {
const res = await fetch(`/api/ai/chat/sessions/${sessionId}`); if (!chat.archiveSuccess || !sessionId) return;
return res.json(); chat.resetArchiveSuccess();
},
enabled: !!sessionId, const remaining = (sessionsData?.sessions ?? []).filter(
}); (s: { id: string }) => s.id !== sessionId
);
if (remaining.length > 0) {
setSessionId(remaining[0].id);
} else {
createChatSession().then((d) => setSessionId(d.id));
}
}, [chat.archiveSuccess, sessionId, sessionsData, chat.resetArchiveSuccess]);
const { data: knowsData } = useQuery({ const { data: knowsData } = useQuery({
queryKey: ["ai-knows"], queryKey: ["ai-knows"],
@@ -61,45 +60,20 @@ export default function MentorPage() {
}); });
const { data: previewData, refetch: refetchPreview } = useQuery({ const { data: previewData, refetch: refetchPreview } = useQuery({
queryKey: ["context-preview", input], queryKey: ["context-preview", previewInput],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams({ message: input || "hello", feature: "mentor" }); const params = new URLSearchParams({ message: previewInput || "hello", feature: "mentor" });
const res = await fetch(`/api/ai/context/preview?${params}`); const res = await fetch(`/api/ai/context/preview?${params}`);
return res.json(); return res.json();
}, },
enabled: tab === "preview", enabled: tab === "preview",
}); });
const send = useMutation({
mutationFn: async (content: string) => {
const res = await fetch(`/api/ai/chat/sessions/${sessionId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error("Send failed");
return res.json();
},
onSuccess: () => {
refetch();
setInput("");
},
});
const newSession = useMutation({ const newSession = useMutation({
mutationFn: async () => { mutationFn: () => createChatSession(),
const res = await fetch("/api/ai/chat/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
return res.json();
},
onSuccess: (d) => setSessionId(d.id), onSuccess: (d) => setSessionId(d.id),
}); });
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [chatData?.messages?.length]);
const messages = chatData?.messages ?? [];
return ( return (
<AppShell> <AppShell>
<div className="p-4 pb-20 md:pb-4 max-w-4xl mx-auto"> <div className="p-4 pb-20 md:pb-4 max-w-4xl mx-auto">
@@ -141,17 +115,29 @@ export default function MentorPage() {
))} ))}
</div> </div>
<div className="retro-window md:col-span-3 flex flex-col min-h-[400px]"> <div className="retro-window md:col-span-3 flex flex-col min-h-[400px]">
<div className="flex-1 overflow-y-auto p-4 space-y-3"> <div className="flex items-center justify-between gap-2 px-3 py-2 border-b">
{messages.map((m: { id: string; role: string; content: string; metadata?: { offline?: boolean } }) => ( <span className="text-xs text-[var(--warm-grey)] truncate">
<div key={m.id} className={`p-3 rounded text-sm ${m.role === "user" ? "bg-white/50 ml-8" : "bg-[var(--xp-blue)]/10 mr-8"}`}> {sessionsData?.sessions?.find((s: { id: string }) => s.id === sessionId)?.title ?? "Chat"}
<span className="text-xs font-bold">{m.role === "user" ? "You" : "Mentor"}</span> </span>
{m.metadata?.offline && ( <button
<span className="text-[10px] ml-2 opacity-60">(offline/local)</span> type="button"
)} className="retro-btn text-[10px] shrink-0 opacity-80"
<p className="mt-1 whitespace-pre-wrap">{m.content}</p> onClick={chat.deleteChat}
disabled={!sessionId || chat.isDeletePending || chat.isSendPending}
>
{chat.isDeletePending ? "..." : "Delete Chat"}
</button>
</div> </div>
))} {chat.deleteError && (
<div ref={bottomRef} /> <p className="px-3 py-1 text-xs text-[var(--color-danger)]">{chat.deleteError}</p>
)}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
<MentorChatMessages
messages={chat.displayMessages}
onRetry={chat.canRetry ? chat.retry : undefined}
retryPending={chat.isSendPending}
bottomRef={chat.bottomRef}
/>
</div> </div>
<div className="p-2 flex flex-wrap gap-1 border-t"> <div className="p-2 flex flex-wrap gap-1 border-t">
{SUGGESTED.map((q) => ( {SUGGESTED.map((q) => (
@@ -159,8 +145,8 @@ export default function MentorPage() {
key={q} key={q}
type="button" type="button"
className="retro-btn text-[10px]" className="retro-btn text-[10px]"
onClick={() => send.mutate(q)} onClick={() => chat.sendMessage(q)}
disabled={send.isPending} disabled={!sessionId || chat.isSendPending}
> >
{q} {q}
</button> </button>
@@ -169,18 +155,19 @@ export default function MentorPage() {
<div className="p-3 flex gap-2 border-t"> <div className="p-3 flex gap-2 border-t">
<input <input
className="retro-window-inset flex-1 p-2 text-sm" className="retro-window-inset flex-1 p-2 text-sm"
value={input} value={chat.input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => chat.setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && input.trim() && send.mutate(input.trim())} onKeyDown={chat.handleKeyDown}
placeholder="Ask the mentor..." placeholder="Ask the mentor..."
disabled={!sessionId}
/> />
<button <button
type="button" type="button"
className="retro-btn retro-btn-primary" className="retro-btn retro-btn-primary"
disabled={!input.trim() || send.isPending} disabled={!sessionId || !chat.input.trim() || chat.isSendPending}
onClick={() => send.mutate(input.trim())} onClick={() => chat.sendMessage(chat.input)}
> >
Send {chat.isSendPending ? "..." : "Send"}
</button> </button>
</div> </div>
</div> </div>
@@ -216,8 +203,8 @@ export default function MentorPage() {
<input <input
className="retro-window-inset w-full p-2 text-sm" className="retro-window-inset w-full p-2 text-sm"
placeholder="Sample message to preview context..." placeholder="Sample message to preview context..."
value={input} value={previewInput}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setPreviewInput(e.target.value)}
/> />
<button type="button" className="retro-btn text-xs" onClick={() => refetchPreview()}> <button type="button" className="retro-btn text-xs" onClick={() => refetchPreview()}>
Refresh preview Refresh preview

View File

@@ -0,0 +1,128 @@
"use client";
import type { RefObject } from "react";
import type { MentorChatMessage } from "@/lib/mentor-chat-utils";
function MentorThinkingBubble({ compact }: { compact?: boolean }) {
return (
<div
className={`rounded bg-[var(--xp-blue)]/10 ${compact ? "p-2 mr-4" : "p-3 mr-8"}`}
aria-live="polite"
aria-label="Mentor is thinking"
>
<span className={`font-bold ${compact ? "text-[10px] block mb-1" : "text-xs"}`}>Mentor</span>
<p className={`mt-1 text-[var(--warm-grey)] ${compact ? "text-xs" : "text-sm"}`}>
AI is thinking
<span className="mentor-thinking-dots" aria-hidden="true">
<span>.</span>
<span>.</span>
<span>.</span>
</span>
</p>
</div>
);
}
function MentorErrorBubble({
content,
onRetry,
retryPending,
compact,
}: {
content: string;
onRetry?: () => void;
retryPending?: boolean;
compact?: boolean;
}) {
return (
<div
className={`rounded bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/20 ${compact ? "p-2 mr-4" : "p-3 mr-8"}`}
role="alert"
>
<span className={`font-bold ${compact ? "text-[10px] block mb-1" : "text-xs"}`}>Mentor</span>
<p className={`mt-1 whitespace-pre-wrap ${compact ? "text-xs" : "text-sm"}`}>{content}</p>
{onRetry && (
<button
type="button"
className="retro-btn text-[10px] mt-2"
onClick={onRetry}
disabled={retryPending}
>
{retryPending ? "..." : "Retry"}
</button>
)}
</div>
);
}
function MentorMessageBubble({
message,
compact,
}: {
message: MentorChatMessage;
compact?: boolean;
}) {
const isUser = message.role === "user";
return (
<div
className={`rounded ${
isUser
? `bg-white/50 ${compact ? "p-2 ml-4" : "p-3 ml-8"}`
: `bg-[var(--xp-blue)]/10 ${compact ? "p-2 mr-4" : "p-3 mr-8"}`
} ${compact ? "text-xs" : "text-sm"}`}
>
<span className={`font-bold ${compact ? "text-[10px] block mb-1" : "text-xs"}`}>
{isUser ? "You" : "Mentor"}
</span>
{message.metadata?.offline && (
<span className="text-[10px] ml-2 opacity-60">(offline/local)</span>
)}
<p className="mt-1 whitespace-pre-wrap">{message.content}</p>
</div>
);
}
export function MentorChatMessages({
messages,
emptyMessage,
onRetry,
retryPending,
compact,
bottomRef,
}: {
messages: MentorChatMessage[];
emptyMessage?: string;
onRetry?: () => void;
retryPending?: boolean;
compact?: boolean;
bottomRef?: RefObject<HTMLDivElement | null>;
}) {
return (
<>
{messages.length === 0 && emptyMessage && (
<p className={`text-[var(--warm-grey)] italic ${compact ? "text-xs" : "text-sm"}`}>
{emptyMessage}
</p>
)}
{messages.map((m) => {
if (m.status === "thinking") {
return <MentorThinkingBubble key={m.id} compact={compact} />;
}
if (m.status === "error") {
return (
<MentorErrorBubble
key={m.id}
content={m.content}
onRetry={onRetry}
retryPending={retryPending}
compact={compact}
/>
);
}
return <MentorMessageBubble key={m.id} message={m} compact={compact} />;
})}
<div ref={bottomRef} />
</>
);
}

View File

@@ -1,8 +1,9 @@
"use client"; "use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { MentorChatMessages } from "@/components/features/mentor-chat-messages";
import { createChatSession, useMentorChat } from "@/hooks/useMentorChat";
const SUGGESTED = [ const SUGGESTED = [
"What should I focus on today?", "What should I focus on today?",
@@ -14,58 +15,35 @@ const SUGGESTED = [
export function MentorPanel({ onClose }: { onClose?: () => void }) { export function MentorPanel({ onClose }: { onClose?: () => void }) {
const [sessionId, setSessionId] = useState<string | null>(null); const [sessionId, setSessionId] = useState<string | null>(null);
const [input, setInput] = useState("");
const bottomRef = useRef<HTMLDivElement>(null);
const qc = useQueryClient();
useEffect(() => { useEffect(() => {
if (!sessionId) { if (!sessionId) {
fetch("/api/ai/chat/sessions", { createChatSession("Quick chat").then((d) => setSessionId(d.id));
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Quick chat" }),
})
.then((r) => r.json())
.then((d) => setSessionId(d.id));
} }
}, [sessionId]); }, [sessionId]);
const { data, refetch } = useQuery({ const chat = useMentorChat(sessionId);
queryKey: ["chat-session", sessionId],
queryFn: async () => {
const res = await fetch(`/api/ai/chat/sessions/${sessionId}`);
return res.json();
},
enabled: !!sessionId,
});
const send = useMutation({
mutationFn: async (content: string) => {
const res = await fetch(`/api/ai/chat/sessions/${sessionId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error("Send failed");
return res.json();
},
onSuccess: () => {
refetch();
setInput("");
},
});
useEffect(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" }); if (!chat.archiveSuccess) return;
}, [data?.messages?.length]); chat.resetArchiveSuccess();
setSessionId(null);
const messages = data?.messages ?? []; createChatSession("Quick chat").then((d) => setSessionId(d.id));
}, [chat.archiveSuccess, chat.resetArchiveSuccess]);
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="retro-titlebar flex items-center justify-between"> <div className="retro-titlebar flex items-center justify-between">
<span>Mentor</span> <span>Mentor</span>
<div className="flex gap-2"> <div className="flex gap-2 items-center">
<button
type="button"
className="text-[10px] font-normal opacity-80"
onClick={chat.deleteChat}
disabled={!sessionId || chat.isDeletePending || chat.isSendPending}
>
{chat.isDeletePending ? "..." : "Delete Chat"}
</button>
<Link href="/mentor" className="text-xs underline font-normal" onClick={onClose}> <Link href="/mentor" className="text-xs underline font-normal" onClick={onClose}>
Open full Open full
</Link> </Link>
@@ -76,22 +54,18 @@ export function MentorPanel({ onClose }: { onClose?: () => void }) {
)} )}
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto p-3 space-y-2 text-sm min-h-[200px] max-h-[50vh]"> {chat.deleteError && (
{messages.length === 0 && ( <p className="px-2 py-1 text-[10px] text-[var(--color-danger)]">{chat.deleteError}</p>
<p className="text-xs text-[var(--warm-grey)] italic">
Ask anything about your journey. I use your saved memories nothing hidden.
</p>
)} )}
{messages.map((m: { id: string; role: string; content: string }) => ( <div className="flex-1 overflow-y-auto p-3 space-y-2 text-sm min-h-[200px] max-h-[50vh]">
<div <MentorChatMessages
key={m.id} messages={chat.displayMessages}
className={`p-2 rounded ${m.role === "user" ? "bg-white/50 ml-4" : "bg-[var(--xp-blue)]/10 mr-4"}`} emptyMessage="Ask anything about your journey. I use your saved memories — nothing hidden."
> onRetry={chat.canRetry ? chat.retry : undefined}
<span className="text-[10px] font-bold block mb-1">{m.role === "user" ? "You" : "Mentor"}</span> retryPending={chat.isSendPending}
{m.content} compact
</div> bottomRef={chat.bottomRef}
))} />
<div ref={bottomRef} />
</div> </div>
<div className="p-2 border-t flex flex-wrap gap-1"> <div className="p-2 border-t flex flex-wrap gap-1">
{SUGGESTED.slice(0, 3).map((q) => ( {SUGGESTED.slice(0, 3).map((q) => (
@@ -99,8 +73,8 @@ export function MentorPanel({ onClose }: { onClose?: () => void }) {
key={q} key={q}
type="button" type="button"
className="retro-btn text-[10px]" className="retro-btn text-[10px]"
onClick={() => send.mutate(q)} onClick={() => chat.sendMessage(q)}
disabled={!sessionId || send.isPending} disabled={!sessionId || chat.isSendPending}
> >
{q} {q}
</button> </button>
@@ -109,18 +83,19 @@ export function MentorPanel({ onClose }: { onClose?: () => void }) {
<div className="p-2 flex gap-2"> <div className="p-2 flex gap-2">
<input <input
className="retro-window-inset flex-1 p-1 text-sm" className="retro-window-inset flex-1 p-1 text-sm"
value={input} value={chat.input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => chat.setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && input.trim() && send.mutate(input.trim())} onKeyDown={chat.handleKeyDown}
placeholder="Ask the mentor..." placeholder="Ask the mentor..."
disabled={!sessionId}
/> />
<button <button
type="button" type="button"
className="retro-btn retro-btn-primary text-xs" className="retro-btn retro-btn-primary text-xs"
disabled={!input.trim() || send.isPending} disabled={!sessionId || !chat.input.trim() || chat.isSendPending}
onClick={() => send.mutate(input.trim())} onClick={() => chat.sendMessage(chat.input)}
> >
Send {chat.isSendPending ? "..." : "Send"}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -2,6 +2,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import type { DragEvent } from "react";
import type { TemplateData, AdventureItemType } from "@adventureos/shared"; import type { TemplateData, AdventureItemType } from "@adventureos/shared";
const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -14,10 +15,19 @@ const ITEM_TYPES: AdventureItemType[] = [
"note", "note",
]; ];
function moveEntry<T>(entries: T[], fromIndex: number, toIndex: number) {
const next = [...entries];
const [moved] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, moved);
return next;
}
export function TemplateEditor() { export function TemplateEditor() {
const qc = useQueryClient(); const qc = useQueryClient();
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [draggedTemplateId, setDraggedTemplateId] = useState<string | null>(null);
const [draggedItemId, setDraggedItemId] = useState<string | null>(null);
const { data: templates = [], isLoading } = useQuery<TemplateData[]>({ const { data: templates = [], isLoading } = useQuery<TemplateData[]>({
queryKey: ["templates"], queryKey: ["templates"],
@@ -60,6 +70,32 @@ export function TemplateEditor() {
onSuccess: invalidate, onSuccess: invalidate,
}); });
const reorderTemplates = useMutation({
mutationFn: async (ordered: TemplateData[]) => {
await Promise.all(
ordered.map((template, index) =>
fetch(`/api/templates/${template.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sortPriority: ordered.length - index }),
}).then((res) => {
if (!res.ok) throw new Error("Failed");
})
)
);
},
onMutate: async (ordered) => {
await qc.cancelQueries({ queryKey: ["templates"] });
const previous = qc.getQueryData<TemplateData[]>(["templates"]);
qc.setQueryData<TemplateData[]>(["templates"], ordered);
return { previous };
},
onError: (_error, _ordered, context) => {
if (context?.previous) qc.setQueryData(["templates"], context.previous);
},
onSettled: invalidate,
});
const duplicateTemplate = useMutation({ const duplicateTemplate = useMutation({
mutationFn: async (id: string) => { mutationFn: async (id: string) => {
const res = await fetch(`/api/templates/${id}`, { const res = await fetch(`/api/templates/${id}`, {
@@ -102,6 +138,44 @@ export function TemplateEditor() {
onSuccess: invalidate, onSuccess: invalidate,
}); });
const reorderItems = useMutation({
mutationFn: async (orderedItems: TemplateData["items"]) => {
if (!selected) throw new Error("No template selected");
await Promise.all(
orderedItems.map((item, index) =>
fetch(`/api/templates/${selected.id}/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: item.id,
type: item.type,
label: item.label,
config: item.config,
sortOrder: index,
enabled: item.enabled,
}),
}).then((res) => {
if (!res.ok) throw new Error("Failed");
})
)
);
},
onMutate: async (orderedItems) => {
await qc.cancelQueries({ queryKey: ["templates"] });
const previous = qc.getQueryData<TemplateData[]>(["templates"]);
qc.setQueryData<TemplateData[]>(["templates"], (current = []) =>
current.map((template) =>
template.id === selected?.id ? { ...template, items: orderedItems } : template
)
);
return { previous };
},
onError: (_error, _ordered, context) => {
if (context?.previous) qc.setQueryData(["templates"], context.previous);
},
onSettled: invalidate,
});
const deleteItem = useMutation({ const deleteItem = useMutation({
mutationFn: async ({ templateId, itemId }: { templateId: string; itemId: string }) => { mutationFn: async ({ templateId, itemId }: { templateId: string; itemId: string }) => {
const res = await fetch( const res = await fetch(
@@ -115,6 +189,34 @@ export function TemplateEditor() {
if (isLoading) return <p className="text-sm">Loading templates...</p>; if (isLoading) return <p className="text-sm">Loading templates...</p>;
const handleTemplateDrop = (event: DragEvent<HTMLButtonElement>, targetId: string) => {
event.preventDefault();
if (!draggedTemplateId || draggedTemplateId === targetId) return;
const fromIndex = templates.findIndex((template) => template.id === draggedTemplateId);
const toIndex = templates.findIndex((template) => template.id === targetId);
if (fromIndex === -1 || toIndex === -1) return;
reorderTemplates.mutate(moveEntry(templates, fromIndex, toIndex));
setDraggedTemplateId(null);
};
const handleItemDrop = (event: DragEvent<HTMLDivElement>, targetId: string) => {
event.preventDefault();
if (!selected || !draggedItemId || draggedItemId === targetId) return;
const fromIndex = selected.items.findIndex((item) => item.id === draggedItemId);
const toIndex = selected.items.findIndex((item) => item.id === targetId);
if (fromIndex === -1 || toIndex === -1) return;
const orderedItems = moveEntry(selected.items, fromIndex, toIndex).map((item, index) => ({
...item,
sortOrder: index,
}));
reorderItems.mutate(orderedItems);
setDraggedItemId(null);
};
return ( return (
<div> <div>
<h2 className="font-bold mb-3">Adventure Templates</h2> <h2 className="font-bold mb-3">Adventure Templates</h2>
@@ -126,9 +228,20 @@ export function TemplateEditor() {
{templates.map((t) => ( {templates.map((t) => (
<button <button
key={t.id} key={t.id}
className={`retro-btn text-xs ${selected?.id === t.id ? "retro-btn-primary" : ""}`} draggable
className={`retro-btn text-xs cursor-grab ${
draggedTemplateId === t.id ? "opacity-60" : ""
} ${selected?.id === t.id ? "retro-btn-primary" : ""}`}
onDragStart={(event) => {
setDraggedTemplateId(t.id);
event.dataTransfer.effectAllowed = "move";
}}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => handleTemplateDrop(event, t.id)}
onDragEnd={() => setDraggedTemplateId(null)}
onClick={() => setSelectedId(t.id)} onClick={() => setSelectedId(t.id)}
> >
<span aria-hidden="true"> </span>
{t.name} {t.name}
{t.isSystem && " ★"} {t.isSystem && " ★"}
</button> </button>
@@ -201,7 +314,27 @@ export function TemplateEditor() {
<p className="text-xs font-bold mb-2">Items</p> <p className="text-xs font-bold mb-2">Items</p>
<div className="space-y-2 mb-4"> <div className="space-y-2 mb-4">
{selected.items.map((item) => ( {selected.items.map((item) => (
<div key={item.id} className="flex flex-wrap gap-2 items-center text-sm"> <div
key={item.id}
className={`flex flex-wrap gap-2 items-center text-sm ${
draggedItemId === item.id ? "opacity-60" : ""
}`}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => handleItemDrop(event, item.id)}
>
<button
type="button"
draggable
className="retro-btn text-xs cursor-grab px-2"
aria-label={`Drag ${item.label}`}
onDragStart={(event) => {
setDraggedItemId(item.id);
event.dataTransfer.effectAllowed = "move";
}}
onDragEnd={() => setDraggedItemId(null)}
>
</button>
<select <select
className="retro-window-inset p-1 text-xs" className="retro-window-inset p-1 text-xs"
value={item.type} value={item.type}

View File

@@ -0,0 +1,158 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import {
buildDisplayMessages,
createPendingStateOnError,
createPendingStateOnSend,
type MentorChatMessage,
type MentorPendingState,
shouldBlockSend,
} from "@/lib/mentor-chat-utils";
const DELETE_CONFIRM_MESSAGE =
"Delete this chat? Your saved AI memories will not be affected.";
type ChatSessionData = {
session: { id: string; title: string };
messages: MentorChatMessage[];
};
export function useMentorChat(sessionId: string | null) {
const queryClient = useQueryClient();
const bottomRef = useRef<HTMLDivElement>(null);
const [input, setInput] = useState("");
const [pendingState, setPendingState] = useState<MentorPendingState | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const { data: chatData } = useQuery<ChatSessionData>({
queryKey: ["chat-session", sessionId],
queryFn: async () => {
const res = await fetch(`/api/ai/chat/sessions/${sessionId}`);
if (!res.ok) throw new Error("Failed to load chat");
return res.json();
},
enabled: !!sessionId,
});
const serverMessages = chatData?.messages ?? [];
const displayMessages = buildDisplayMessages(serverMessages, pendingState);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [displayMessages.length, pendingState]);
const send = useMutation({
mutationFn: async (content: string) => {
const res = await fetch(`/api/ai/chat/sessions/${sessionId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Send failed");
}
return res.json();
},
onMutate: (content) => {
setInput("");
setPendingState(createPendingStateOnSend(content));
},
onSuccess: () => {
setPendingState(null);
queryClient.invalidateQueries({ queryKey: ["chat-session", sessionId] });
queryClient.invalidateQueries({ queryKey: ["chat-sessions"] });
},
onError: (_err, content) => {
setPendingState(createPendingStateOnError(content));
},
});
const archive = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/ai/chat/sessions/${sessionId}`, { method: "DELETE" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Delete failed");
}
return res.json();
},
onMutate: () => {
setDeleteError(null);
},
onSuccess: () => {
setPendingState(null);
setInput("");
queryClient.invalidateQueries({ queryKey: ["chat-sessions"] });
},
onError: (err: Error) => {
setDeleteError(err.message || "Could not delete chat. Please try again.");
},
});
const sendMessage = useCallback(
(content: string) => {
if (!sessionId || shouldBlockSend(send.isPending, content)) return;
send.mutate(content.trim());
},
[sessionId, send]
);
const retry = useCallback(() => {
const content = pendingState?.lastFailedContent;
if (!content || send.isPending) return;
setPendingState(null);
send.mutate(content);
}, [pendingState?.lastFailedContent, send]);
const deleteChat = useCallback(() => {
if (!sessionId || archive.isPending) return;
if (!confirm(DELETE_CONFIRM_MESSAGE)) return;
archive.mutate();
}, [sessionId, archive]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && input.trim()) {
e.preventDefault();
sendMessage(input);
}
},
[input, sendMessage]
);
return {
input,
setInput,
displayMessages,
sendMessage,
retry,
deleteChat,
handleKeyDown,
bottomRef,
isSendPending: send.isPending,
isDeletePending: archive.isPending,
canRetry: !!pendingState?.lastFailedContent,
deleteError,
archiveSuccess: archive.isSuccess,
resetArchiveSuccess: archive.reset,
};
}
export async function createChatSession(title?: string): Promise<{ id: string }> {
const res = await fetch("/api/ai/chat/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(title ? { title } : {}),
});
if (!res.ok) throw new Error("Failed to create session");
return res.json();
}
export async function fetchChatSessions(): Promise<{ sessions: { id: string; title: string }[] }> {
const res = await fetch("/api/ai/chat/sessions");
if (!res.ok) throw new Error("Failed to load sessions");
return res.json();
}

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 { db, adventureTemplates, adventureItems } from "@/lib/db";
import { recordAction } from "@/lib/services/action-events"; import { recordAction } from "@/lib/services/action-events";
import { ACTION_TYPES } from "@/lib/config"; import { ACTION_TYPES } from "@/lib/config";
@@ -27,7 +27,8 @@ export async function getTemplates(userId: string) {
const templates = await db const templates = await db
.select() .select()
.from(adventureTemplates) .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 = []; const result = [];
for (const t of templates) { for (const t of templates) {