From 9ddac133dec68a3bbcdab5690e6405a9a04f1f1b Mon Sep 17 00:00:00 2001 From: Schwitzz Date: Thu, 29 Oct 2026 21:03:03 -0500 Subject: [PATCH] feat(web): wire AI feature view against AIStore - Add apps/web/src/features/ai/AI.tsx: session sidebar, messages/suggestions/notes tabs, full CRUD against @pubscript/ai AIStore - Route the /ai page to the new AI view; remove the static stub - Add @pubscript/ai workspace dependency to apps/web - Typecheck (7 packages) and tests (71 AI + 13 web) pass --- apps/web/package.json | 1 + apps/web/src/features/ai/AI.tsx | 849 ++++++++++++++++++++++++++++++++ apps/web/src/routes/AI.tsx | 31 +- pnpm-lock.yaml | 3 + 4 files changed, 855 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/features/ai/AI.tsx diff --git a/apps/web/package.json b/apps/web/package.json index e273cde..2a7928d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@pubscript/ai": "workspace:*", "@pubscript/core": "workspace:*", "@pubscript/editor": "workspace:*", "@pubscript/exporters": "workspace:*", diff --git a/apps/web/src/features/ai/AI.tsx b/apps/web/src/features/ai/AI.tsx new file mode 100644 index 0000000..eb0b0a4 --- /dev/null +++ b/apps/web/src/features/ai/AI.tsx @@ -0,0 +1,849 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { AIStore, createIndexedDBPersistence } from "@pubscript/ai"; +import type { + AINote, + AISession, + AISuggestion, + AISuggestionStatus, + AIMessage, + AIMessageRole, + NoteKind, +} from "@pubscript/core"; + +import { useProjectStore } from "../../store/project-store"; + +type ViewTab = "messages" | "suggestions" | "notes"; + +const NOTE_KINDS: { value: NoteKind; label: string }[] = [ + { value: "character", label: "Character" }, + { value: "world", label: "World" }, + { value: "plot", label: "Plot" }, + { value: "theme", label: "Theme" }, + { value: "general", label: "General" }, +]; + +/** + * Build (once per project) the AIStore that persists sessions, messages, + * suggestions, and notes through IndexedDB. See CHUNK#9.md §2. + */ +function useAiStore(projectId: string | undefined): AIStore | null { + const [store, setStore] = useState(null); + + useEffect(() => { + if (!projectId) return undefined; + const aiStore = new AIStore(projectId, createIndexedDBPersistence()); + setStore(aiStore); + return () => { + void aiStore.dispose(); + setStore(null); + }; + }, [projectId]); + + return store; +} + +function formatTime(iso: string | null | undefined): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + return d.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +export function AI() { + const project = useProjectStore((s) => s.project); + const projectId = project?.id; + const aiStore = useAiStore(projectId); + + const [sessions, setSessions] = useState([]); + const [activeSessionId, setActiveSessionId] = useState(null); + const [messages, setMessages] = useState([]); + const [suggestions, setSuggestions] = useState([]); + const [notes, setNotes] = useState([]); + const [loading, setLoading] = useState(true); + const [sending, setSending] = useState(false); + const [input, setInput] = useState(""); + const [activeTab, setActiveTab] = useState("messages"); + const [noteOpen, setNoteOpen] = useState(false); + const [merging, setMerging] = useState([]); + + // Transient "add note" form state. + const [newNoteKind, setNewNoteKind] = useState("general"); + const [newNoteBody, setNewNoteBody] = useState(""); + + // Note editor state (null = closed). + const [editingNoteId, setEditingNoteId] = useState(null); + const [editNoteBody, setEditNoteBody] = useState(""); + const [editNoteTitle, setEditNoteTitle] = useState(""); + + const activeSession = useMemo( + () => sessions.find((s) => s.id === activeSessionId) ?? null, + [sessions, activeSessionId], + ); + + const scrollRef = useRef(null); + + const reloadSessions = useCallback(async () => { + if (!aiStore) return; + const list = await aiStore.listSessions(); + setSessions(list); + }, [aiStore]); + + // Load the session list once the store is ready. + useEffect(() => { + let cancelled = false; + (async () => { + if (!aiStore) return; + const list = await aiStore.listSessions(); + if (cancelled) return; + setSessions(list); + if (list.length > 0 && !activeSessionId) { + setActiveSessionId(list[0]?.id ?? null); + } else { + setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [aiStore, activeSessionId]); + + // Load the active session's messages, suggestions, and notes. + useEffect(() => { + let cancelled = false; + (async () => { + if (!aiStore || !activeSessionId) return; + const [session, msgs, suggs, ns] = await Promise.all([ + aiStore.getSession(activeSessionId), + aiStore.listMessages(activeSessionId), + aiStore.listSuggestions(activeSessionId), + aiStore.listNotes(activeSessionId), + ]); + if (cancelled) return; + if (session) { + setSessions((prev) => + prev.map((s) => (s.id === session.id ? session : s)), + ); + } + setMessages(msgs); + setSuggestions(suggs); + setNotes(ns); + setLoading(false); + })(); + return () => { + cancelled = true; + }; + }, [aiStore, activeSessionId]); + + useEffect(() => { + scrollRef.current?.scrollTo({ + top: scrollRef.current.scrollHeight, + behavior: "smooth", + }); + }, [messages]); + + const handleCreateSession = useCallback(async () => { + if (!aiStore) return; + const session = await aiStore.createSession(); + setActiveSessionId(session.id); + await reloadSessions(); + }, [aiStore, reloadSessions]); + + const handleDeleteSession = useCallback( + async (id: string) => { + if (!aiStore) return; + await aiStore.deleteSession(id); + if (activeSessionId === id) setActiveSessionId(null); + await reloadSessions(); + }, + [aiStore, activeSessionId, reloadSessions], + ); + + const handleSendMessage = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (!aiStore || !activeSessionId || !input.trim() || sending) return; + setSending(true); + const text = input.trim(); + setInput(""); + try { + await aiStore.addMessage(activeSessionId, { + role: "user", + content: text, + }); + await reloadSessions(); + // Reload just the messages for the active session. + const msgs = await aiStore.listMessages(activeSessionId); + setMessages(msgs); + } finally { + setSending(false); + } + }, + [aiStore, activeSessionId, input, sending, reloadSessions], + ); + + const handleApplySuggestion = useCallback( + async (suggestionId: string) => { + if (!aiStore || !activeSessionId) return; + await aiStore.applySuggestion(activeSessionId, suggestionId); + setSuggestions((prev) => + prev.map((s) => + s.id === suggestionId ? { ...s, status: "applied", appliedAt: new Date().toISOString() } : s, + ), + ); + }, + [aiStore, activeSessionId], + ); + + const handleResolveSuggestion = useCallback( + async (suggestionId: string, accepted: boolean) => { + if (!aiStore || !activeSessionId) return; + await aiStore.resolveSuggestion(activeSessionId, suggestionId, accepted); + setSuggestions((prev) => + prev.map((s) => + s.id === suggestionId + ? { + ...s, + status: accepted ? "accepted" : "rejected", + resolvedAt: new Date().toISOString(), + resolution: accepted ? "accepted" : "rejected", + } + : s, + ), + ); + }, + [aiStore, activeSessionId], + ); + + const handleAddNote = useCallback(async () => { + if (!aiStore || !activeSessionId || !newNoteBody.trim()) return; + await aiStore.addNote(activeSessionId, { + sessionId: activeSessionId, + kind: newNoteKind, + body: newNoteBody.trim(), + status: "draft", + }); + setNewNoteBody(""); + setNoteOpen(false); + const ns = await aiStore.listNotes(activeSessionId); + setNotes(ns); + await reloadSessions(); + }, [aiStore, activeSessionId, newNoteKind, newNoteBody, reloadSessions]); + + const startEditNote = useCallback( + (note: AINote) => { + setEditingNoteId(note.id); + setEditNoteBody(note.body); + setEditNoteTitle(note.title ?? ""); + }, + [], + ); + + const handleSaveNote = useCallback(async () => { + if (!aiStore || !activeSessionId || !editingNoteId) return; + const patch: Partial = { + body: editNoteBody.trim(), + status: "draft", + }; + if (editNoteTitle.trim()) patch.title = editNoteTitle.trim(); + await aiStore.updateNote(activeSessionId, editingNoteId, patch); + setEditingNoteId(null); + setEditNoteBody(""); + setEditNoteTitle(""); + const ns = await aiStore.listNotes(activeSessionId); + setNotes(ns); + await reloadSessions(); + }, [aiStore, activeSessionId, editingNoteId, editNoteBody, editNoteTitle, reloadSessions]); + + const handleMergeNotes = useCallback( + async (ids: string[]) => { + if (!aiStore || !activeSessionId || ids.length < 2) return; + await aiStore.mergeNotes(activeSessionId, ids); + setMerging([]); + const ns = await aiStore.listNotes(activeSessionId); + setNotes(ns); + await reloadSessions(); + }, + [aiStore, activeSessionId, reloadSessions], + ); + + const pendingSuggestions = suggestions.filter((s) => s.status === "pending"); + const resolvedSuggestions = suggestions.filter( + (s) => s.status !== "pending", + ); + + if (!project) { + return ( +
+ Select or create a project to use AI sessions. +
+ ); + } + + return ( +
+ {/* Session sidebar */} + + + {/* Main content */} +
+ {activeSession ? ( + <> +
+
+

+ {activeSession.title} +

+

+ {formatTime(activeSession.lastMessageAt) || + `Created ${formatTime(activeSession.createdAt)}`} +

+
+
+ {activeSession.kind === "agent" ? "Agent" : "Session"} +
+
+ + {/* Tabs */} +
+ {(["messages", "suggestions", "notes"] as ViewTab[]).map((tab) => ( + + ))} +
+ +
+ {activeTab === "messages" && ( + <> +
+ {loading ? ( +

Loading…

+ ) : messages.length === 0 ? ( +
+ No messages yet. Send a message to begin this session. +
+ ) : ( +
    + {messages.map((m) => ( +
  • +
    +

    {m.content}

    +

    + {formatTime(m.createdAt)} +

    +
    +
  • + ))} +
+ )} +
+ +
+