This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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 items-center justify-between gap-2 px-3 py-2 border-b">
|
||||||
|
<span className="text-xs text-[var(--warm-grey)] truncate">
|
||||||
|
{sessionsData?.sessions?.find((s: { id: string }) => s.id === sessionId)?.title ?? "Chat"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="retro-btn text-[10px] shrink-0 opacity-80"
|
||||||
|
onClick={chat.deleteChat}
|
||||||
|
disabled={!sessionId || chat.isDeletePending || chat.isSendPending}
|
||||||
|
>
|
||||||
|
{chat.isDeletePending ? "..." : "Delete Chat"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{chat.deleteError && (
|
||||||
|
<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">
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
{messages.map((m: { id: string; role: string; content: string; metadata?: { offline?: boolean } }) => (
|
<MentorChatMessages
|
||||||
<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"}`}>
|
messages={chat.displayMessages}
|
||||||
<span className="text-xs font-bold">{m.role === "user" ? "You" : "Mentor"}</span>
|
onRetry={chat.canRetry ? chat.retry : undefined}
|
||||||
{m.metadata?.offline && (
|
retryPending={chat.isSendPending}
|
||||||
<span className="text-[10px] ml-2 opacity-60">(offline/local)</span>
|
bottomRef={chat.bottomRef}
|
||||||
)}
|
/>
|
||||||
<p className="mt-1 whitespace-pre-wrap">{m.content}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div ref={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
|
||||||
|
|||||||
128
apps/web/src/components/features/mentor-chat-messages.tsx
Normal file
128
apps/web/src/components/features/mentor-chat-messages.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
{chat.deleteError && (
|
||||||
|
<p className="px-2 py-1 text-[10px] text-[var(--color-danger)]">{chat.deleteError}</p>
|
||||||
|
)}
|
||||||
<div className="flex-1 overflow-y-auto p-3 space-y-2 text-sm min-h-[200px] max-h-[50vh]">
|
<div className="flex-1 overflow-y-auto p-3 space-y-2 text-sm min-h-[200px] max-h-[50vh]">
|
||||||
{messages.length === 0 && (
|
<MentorChatMessages
|
||||||
<p className="text-xs text-[var(--warm-grey)] italic">
|
messages={chat.displayMessages}
|
||||||
Ask anything about your journey. I use your saved memories — nothing hidden.
|
emptyMessage="Ask anything about your journey. I use your saved memories — nothing hidden."
|
||||||
</p>
|
onRetry={chat.canRetry ? chat.retry : undefined}
|
||||||
)}
|
retryPending={chat.isSendPending}
|
||||||
{messages.map((m: { id: string; role: string; content: string }) => (
|
compact
|
||||||
<div
|
bottomRef={chat.bottomRef}
|
||||||
key={m.id}
|
/>
|
||||||
className={`p-2 rounded ${m.role === "user" ? "bg-white/50 ml-4" : "bg-[var(--xp-blue)]/10 mr-4"}`}
|
|
||||||
>
|
|
||||||
<span className="text-[10px] font-bold block mb-1">{m.role === "user" ? "You" : "Mentor"}</span>
|
|
||||||
{m.content}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
158
apps/web/src/hooks/useMentorChat.ts
Normal file
158
apps/web/src/hooks/useMentorChat.ts
Normal 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();
|
||||||
|
}
|
||||||
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 { 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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user