This commit is contained in:
@@ -69,6 +69,30 @@ body::after {
|
||||
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 {
|
||||
background: linear-gradient(180deg, var(--color-surface-raised) 0%, var(--color-surface) 100%);
|
||||
border: 2px solid;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
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 { MentorChatMessages } from "@/components/features/mentor-chat-messages";
|
||||
import { createChatSession, fetchChatSessions, useMentorChat } from "@/hooks/useMentorChat";
|
||||
import Link from "next/link";
|
||||
|
||||
const SUGGESTED = [
|
||||
@@ -16,40 +18,37 @@ const SUGGESTED = [
|
||||
|
||||
export default function MentorPage() {
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [input, setInput] = useState("");
|
||||
const [tab, setTab] = useState<"chat" | "knows" | "preview">("chat");
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const [previewInput, setPreviewInput] = useState("");
|
||||
|
||||
const { data: sessionsData } = useQuery({
|
||||
queryKey: ["chat-sessions"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/ai/chat/sessions");
|
||||
return res.json();
|
||||
},
|
||||
queryFn: fetchChatSessions,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId && sessionsData?.sessions?.[0]) {
|
||||
setSessionId(sessionsData.sessions[0].id);
|
||||
} else if (!sessionId) {
|
||||
fetch("/api/ai/chat/sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => setSessionId(d.id));
|
||||
createChatSession().then((d) => setSessionId(d.id));
|
||||
}
|
||||
}, [sessionId, sessionsData]);
|
||||
|
||||
const { data: chatData, refetch } = useQuery({
|
||||
queryKey: ["chat-session", sessionId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/ai/chat/sessions/${sessionId}`);
|
||||
return res.json();
|
||||
},
|
||||
enabled: !!sessionId,
|
||||
});
|
||||
const chat = useMentorChat(sessionId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chat.archiveSuccess || !sessionId) return;
|
||||
chat.resetArchiveSuccess();
|
||||
|
||||
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({
|
||||
queryKey: ["ai-knows"],
|
||||
@@ -61,45 +60,20 @@ export default function MentorPage() {
|
||||
});
|
||||
|
||||
const { data: previewData, refetch: refetchPreview } = useQuery({
|
||||
queryKey: ["context-preview", input],
|
||||
queryKey: ["context-preview", previewInput],
|
||||
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}`);
|
||||
return res.json();
|
||||
},
|
||||
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({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/ai/chat/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
|
||||
return res.json();
|
||||
},
|
||||
mutationFn: () => createChatSession(),
|
||||
onSuccess: (d) => setSessionId(d.id),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [chatData?.messages?.length]);
|
||||
|
||||
const messages = chatData?.messages ?? [];
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="p-4 pb-20 md:pb-4 max-w-4xl mx-auto">
|
||||
@@ -141,17 +115,29 @@ export default function MentorPage() {
|
||||
))}
|
||||
</div>
|
||||
<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">
|
||||
{messages.map((m: { id: string; role: string; content: string; metadata?: { offline?: boolean } }) => (
|
||||
<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"}`}>
|
||||
<span className="text-xs font-bold">{m.role === "user" ? "You" : "Mentor"}</span>
|
||||
{m.metadata?.offline && (
|
||||
<span className="text-[10px] ml-2 opacity-60">(offline/local)</span>
|
||||
)}
|
||||
<p className="mt-1 whitespace-pre-wrap">{m.content}</p>
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
<MentorChatMessages
|
||||
messages={chat.displayMessages}
|
||||
onRetry={chat.canRetry ? chat.retry : undefined}
|
||||
retryPending={chat.isSendPending}
|
||||
bottomRef={chat.bottomRef}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 flex flex-wrap gap-1 border-t">
|
||||
{SUGGESTED.map((q) => (
|
||||
@@ -159,8 +145,8 @@ export default function MentorPage() {
|
||||
key={q}
|
||||
type="button"
|
||||
className="retro-btn text-[10px]"
|
||||
onClick={() => send.mutate(q)}
|
||||
disabled={send.isPending}
|
||||
onClick={() => chat.sendMessage(q)}
|
||||
disabled={!sessionId || chat.isSendPending}
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
@@ -169,18 +155,19 @@ export default function MentorPage() {
|
||||
<div className="p-3 flex gap-2 border-t">
|
||||
<input
|
||||
className="retro-window-inset flex-1 p-2 text-sm"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && input.trim() && send.mutate(input.trim())}
|
||||
value={chat.input}
|
||||
onChange={(e) => chat.setInput(e.target.value)}
|
||||
onKeyDown={chat.handleKeyDown}
|
||||
placeholder="Ask the mentor..."
|
||||
disabled={!sessionId}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="retro-btn retro-btn-primary"
|
||||
disabled={!input.trim() || send.isPending}
|
||||
onClick={() => send.mutate(input.trim())}
|
||||
disabled={!sessionId || !chat.input.trim() || chat.isSendPending}
|
||||
onClick={() => chat.sendMessage(chat.input)}
|
||||
>
|
||||
Send
|
||||
{chat.isSendPending ? "..." : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,8 +203,8 @@ export default function MentorPage() {
|
||||
<input
|
||||
className="retro-window-inset w-full p-2 text-sm"
|
||||
placeholder="Sample message to preview context..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
value={previewInput}
|
||||
onChange={(e) => setPreviewInput(e.target.value)}
|
||||
/>
|
||||
<button type="button" className="retro-btn text-xs" onClick={() => refetchPreview()}>
|
||||
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";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { MentorChatMessages } from "@/components/features/mentor-chat-messages";
|
||||
import { createChatSession, useMentorChat } from "@/hooks/useMentorChat";
|
||||
|
||||
const SUGGESTED = [
|
||||
"What should I focus on today?",
|
||||
@@ -14,58 +15,35 @@ const SUGGESTED = [
|
||||
|
||||
export function MentorPanel({ onClose }: { onClose?: () => void }) {
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [input, setInput] = useState("");
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
fetch("/api/ai/chat/sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "Quick chat" }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => setSessionId(d.id));
|
||||
createChatSession("Quick chat").then((d) => setSessionId(d.id));
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
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("");
|
||||
},
|
||||
});
|
||||
const chat = useMentorChat(sessionId);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [data?.messages?.length]);
|
||||
|
||||
const messages = data?.messages ?? [];
|
||||
if (!chat.archiveSuccess) return;
|
||||
chat.resetArchiveSuccess();
|
||||
setSessionId(null);
|
||||
createChatSession("Quick chat").then((d) => setSessionId(d.id));
|
||||
}, [chat.archiveSuccess, chat.resetArchiveSuccess]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="retro-titlebar flex items-center justify-between">
|
||||
<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}>
|
||||
Open full
|
||||
</Link>
|
||||
@@ -76,22 +54,18 @@ export function MentorPanel({ onClose }: { onClose?: () => void }) {
|
||||
)}
|
||||
</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]">
|
||||
{messages.length === 0 && (
|
||||
<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
|
||||
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} />
|
||||
<MentorChatMessages
|
||||
messages={chat.displayMessages}
|
||||
emptyMessage="Ask anything about your journey. I use your saved memories — nothing hidden."
|
||||
onRetry={chat.canRetry ? chat.retry : undefined}
|
||||
retryPending={chat.isSendPending}
|
||||
compact
|
||||
bottomRef={chat.bottomRef}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 border-t flex flex-wrap gap-1">
|
||||
{SUGGESTED.slice(0, 3).map((q) => (
|
||||
@@ -99,8 +73,8 @@ export function MentorPanel({ onClose }: { onClose?: () => void }) {
|
||||
key={q}
|
||||
type="button"
|
||||
className="retro-btn text-[10px]"
|
||||
onClick={() => send.mutate(q)}
|
||||
disabled={!sessionId || send.isPending}
|
||||
onClick={() => chat.sendMessage(q)}
|
||||
disabled={!sessionId || chat.isSendPending}
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
@@ -109,18 +83,19 @@ export function MentorPanel({ onClose }: { onClose?: () => void }) {
|
||||
<div className="p-2 flex gap-2">
|
||||
<input
|
||||
className="retro-window-inset flex-1 p-1 text-sm"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && input.trim() && send.mutate(input.trim())}
|
||||
value={chat.input}
|
||||
onChange={(e) => chat.setInput(e.target.value)}
|
||||
onKeyDown={chat.handleKeyDown}
|
||||
placeholder="Ask the mentor..."
|
||||
disabled={!sessionId}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="retro-btn retro-btn-primary text-xs"
|
||||
disabled={!input.trim() || send.isPending}
|
||||
onClick={() => send.mutate(input.trim())}
|
||||
disabled={!sessionId || !chat.input.trim() || chat.isSendPending}
|
||||
onClick={() => chat.sendMessage(chat.input)}
|
||||
>
|
||||
Send
|
||||
{chat.isSendPending ? "..." : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import type { DragEvent } from "react";
|
||||
import type { TemplateData, AdventureItemType } from "@adventureos/shared";
|
||||
|
||||
const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
@@ -14,10 +15,19 @@ const ITEM_TYPES: AdventureItemType[] = [
|
||||
"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() {
|
||||
const qc = useQueryClient();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [draggedTemplateId, setDraggedTemplateId] = useState<string | null>(null);
|
||||
const [draggedItemId, setDraggedItemId] = useState<string | null>(null);
|
||||
|
||||
const { data: templates = [], isLoading } = useQuery<TemplateData[]>({
|
||||
queryKey: ["templates"],
|
||||
@@ -60,6 +70,32 @@ export function TemplateEditor() {
|
||||
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({
|
||||
mutationFn: async (id: string) => {
|
||||
const res = await fetch(`/api/templates/${id}`, {
|
||||
@@ -102,6 +138,44 @@ export function TemplateEditor() {
|
||||
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({
|
||||
mutationFn: async ({ templateId, itemId }: { templateId: string; itemId: string }) => {
|
||||
const res = await fetch(
|
||||
@@ -115,6 +189,34 @@ export function TemplateEditor() {
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<h2 className="font-bold mb-3">Adventure Templates</h2>
|
||||
@@ -126,9 +228,20 @@ export function TemplateEditor() {
|
||||
{templates.map((t) => (
|
||||
<button
|
||||
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)}
|
||||
>
|
||||
<span aria-hidden="true">↕ </span>
|
||||
{t.name}
|
||||
{t.isSystem && " ★"}
|
||||
</button>
|
||||
@@ -201,7 +314,27 @@ export function TemplateEditor() {
|
||||
<p className="text-xs font-bold mb-2">Items</p>
|
||||
<div className="space-y-2 mb-4">
|
||||
{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
|
||||
className="retro-window-inset p-1 text-xs"
|
||||
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 { recordAction } from "@/lib/services/action-events";
|
||||
import { ACTION_TYPES } from "@/lib/config";
|
||||
@@ -27,7 +27,8 @@ export async function getTemplates(userId: string) {
|
||||
const templates = await db
|
||||
.select()
|
||||
.from(adventureTemplates)
|
||||
.where(and(eq(adventureTemplates.userId, userId), isNull(adventureTemplates.deletedAt)));
|
||||
.where(and(eq(adventureTemplates.userId, userId), isNull(adventureTemplates.deletedAt)))
|
||||
.orderBy(desc(adventureTemplates.sortPriority));
|
||||
|
||||
const result = [];
|
||||
for (const t of templates) {
|
||||
|
||||
Reference in New Issue
Block a user