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
This commit is contained in:
Schwitzz
2026-10-29 21:03:03 -05:00
parent 5fb1db4de4
commit 9ddac133de
4 changed files with 855 additions and 29 deletions
+1
View File
@@ -12,6 +12,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@pubscript/ai": "workspace:*",
"@pubscript/core": "workspace:*", "@pubscript/core": "workspace:*",
"@pubscript/editor": "workspace:*", "@pubscript/editor": "workspace:*",
"@pubscript/exporters": "workspace:*", "@pubscript/exporters": "workspace:*",
+849
View File
@@ -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<AIStore | null>(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<AISession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<AIMessage[]>([]);
const [suggestions, setSuggestions] = useState<AISuggestion[]>([]);
const [notes, setNotes] = useState<AINote[]>([]);
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [input, setInput] = useState("");
const [activeTab, setActiveTab] = useState<ViewTab>("messages");
const [noteOpen, setNoteOpen] = useState(false);
const [merging, setMerging] = useState<string[]>([]);
// Transient "add note" form state.
const [newNoteKind, setNewNoteKind] = useState<NoteKind>("general");
const [newNoteBody, setNewNoteBody] = useState("");
// Note editor state (null = closed).
const [editingNoteId, setEditingNoteId] = useState<string | null>(null);
const [editNoteBody, setEditNoteBody] = useState("");
const [editNoteTitle, setEditNoteTitle] = useState("");
const activeSession = useMemo(
() => sessions.find((s) => s.id === activeSessionId) ?? null,
[sessions, activeSessionId],
);
const scrollRef = useRef<HTMLDivElement>(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<AINote> = {
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 (
<div className="p-10 text-center text-slate-400">
Select or create a project to use AI sessions.
</div>
);
}
return (
<div className="flex h-[calc(100vh-3rem)] bg-slate-950 text-slate-100">
{/* Session sidebar */}
<aside className="flex w-64 shrink-0 flex-col border-r border-slate-800 bg-slate-900">
<div className="flex items-center justify-between border-b border-slate-800 px-3 py-3">
<span className="text-sm font-semibold tracking-wide text-slate-200">
AI Sessions
</span>
<button
type="button"
onClick={handleCreateSession}
className="rounded bg-indigo-600 px-2 py-1 text-xs font-medium text-white hover:bg-indigo-500"
>
+ New
</button>
</div>
<div className="flex-1 overflow-y-auto p-2">
{sessions.length === 0 ? (
<p className="px-2 py-4 text-xs text-slate-500">
No sessions yet. Create one to start.
</p>
) : (
<ul className="space-y-1">
{sessions.map((s) => (
<li key={s.id}>
<div
className={`group flex cursor-pointer items-center justify-between rounded px-2 py-2 text-sm ${
activeSessionId === s.id
? "bg-indigo-600/20 text-white"
: "text-slate-300 hover:bg-slate-800"
}`}
onClick={() => setActiveSessionId(s.id)}
>
<span className="truncate">{s.title}</span>
<button
type="button"
aria-label={`Delete ${s.title}`}
onClick={(e) => {
e.stopPropagation();
void handleDeleteSession(s.id);
}}
className="shrink-0 rounded op-0 px-1 text-slate-400 hover:text-rose-400 group-hover:op-100"
>
✕
</button>
</div>
</li>
))}
</ul>
)}
</div>
<div className="border-t border-slate-800 px-3 py-2 text-xs text-slate-500">
{project.title}
</div>
</aside>
{/* Main content */}
<main className="flex min-w-0 flex-1 flex-col">
{activeSession ? (
<>
<header className="flex items-center justify-between border-b border-slate-800 px-5 py-3">
<div>
<h1 className="text-lg font-semibold text-slate-100">
{activeSession.title}
</h1>
<p className="text-xs text-slate-500">
{formatTime(activeSession.lastMessageAt) ||
`Created ${formatTime(activeSession.createdAt)}`}
</p>
</div>
<div className="rounded-full bg-slate-800 px-3 py-1 text-xs text-slate-300">
{activeSession.kind === "agent" ? "Agent" : "Session"}
</div>
</header>
{/* Tabs */}
<div className="flex border-b border-slate-800 px-5">
{(["messages", "suggestions", "notes"] as ViewTab[]).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={`-mb-px border-b-2 px-4 py-2.5 text-sm font-medium capitalize ${
activeTab === tab
? "border-indigo-500 text-indigo-300"
: "border-transparent text-slate-400 hover:text-slate-200"
}`}
>
{tab}
{tab === "suggestions" && pendingSuggestions.length > 0 && (
<span className="ml-2 rounded-full bg-indigo-600 px-1.5 text-xs">
{pendingSuggestions.length}
</span>
)}
{tab === "notes" && notes.length > 0 && (
<span className="ml-2 rounded-full bg-slate-700 px-1.5 text-xs">
{notes.length}
</span>
)}
</button>
))}
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{activeTab === "messages" && (
<>
<div ref={scrollRef} className="flex-1 overflow-y-auto p-5">
{loading ? (
<p className="text-sm text-slate-500">Loading…</p>
) : messages.length === 0 ? (
<div className="mt-12 text-center text-sm text-slate-500">
No messages yet. Send a message to begin this session.
</div>
) : (
<ul className="space-y-4">
{messages.map((m) => (
<li
key={m.id}
className={`flex ${
m.role === "user" ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-[75%] rounded-2xl px-4 py-2 text-sm leading-relaxed ${
m.role === "user"
? "bg-indigo-600 text-white"
: m.role === "system"
? "bg-slate-800 font-mono text-xs text-slate-400"
: "bg-slate-800 text-slate-100"
}`}
>
<p className="whitespace-pre-wrap">{m.content}</p>
<p
className={`mt-1 text-[10px] ${
m.role === "user"
? "text-indigo-200"
: "text-slate-500"
}`}
>
{formatTime(m.createdAt)}
</p>
</div>
</li>
))}
</ul>
)}
</div>
<form
onSubmit={handleSendMessage}
className="flex items-end gap-2 border-t border-slate-800 p-3"
>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSendMessage(e);
}
}}
rows={1}
placeholder={
activeSession.kind === "agent"
? "Instruct the agent…"
: "Send a message…"
}
className="max-h-40 flex-1 resize-none rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-500 focus:outline-none"
/>
<button
type="submit"
disabled={!input.trim() || sending}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:op-50 hover:bg-indigo-500"
>
{sending ? "Sending…" : "Send"}
</button>
</form>
</>
)}
{activeTab === "suggestions" && (
<div className="flex-1 overflow-y-auto p-5">
{loading ? (
<p className="text-sm text-slate-500">Loading…</p>
) : suggestions.length === 0 ? (
<div className="mt-12 text-center text-sm text-slate-500">
No suggestions have been generated for this session.
</div>
) : (
<div className="space-y-6">
{pendingSuggestions.length > 0 && (
<section>
<h2 className="mb-3 text-xs font-semibold tracking-wide text-slate-400 uppercase">
Pending ({pendingSuggestions.length})
</h2>
<div className="space-y-3">
{pendingSuggestions.map((s) => (
<SuggestionCard
key={s.id}
suggestion={s}
onApply={() => void handleApplySuggestion(s.id)}
onAccept={() =>
void handleResolveSuggestion(s.id, true)
}
onReject={() =>
void handleResolveSuggestion(s.id, false)
}
/>
))}
</div>
</section>
)}
{resolvedSuggestions.length > 0 && (
<section>
<h2 className="mb-3 text-xs font-semibold tracking-wide text-slate-500 uppercase">
Resolved ({resolvedSuggestions.length})
</h2>
<div className="space-y-3">
{resolvedSuggestions.map((s) => (
<SuggestionCard
key={s.id}
suggestion={s}
onApply={() => void handleApplySuggestion(s.id)}
/>
))}
</div>
</section>
)}
</div>
)}
</div>
)}
{activeTab === "notes" && (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto p-5">
{loading ? (
<p className="text-sm text-slate-500">Loading…</p>
) : notes.length === 0 ? (
<div className="mt-12 text-center text-sm text-slate-500">
No notes yet.
</div>
) : (
<div className="grid gap-3 md:grid-cols-2">
{notes.map((note) => (
<div
key={note.id}
className={`flex flex-col rounded-lg border p-4 ${
merging.includes(note.id)
? "border-indigo-500 ring-1 ring-indigo-500"
: "border-slate-800 bg-slate-900"
}`}
>
<div className="mb-2 flex items-center justify-between">
<span className="rounded bg-slate-800 px-2 py-0.5 text-[10px] font-medium uppercase text-slate-300">
{note.kind}
</span>
{merging.length >= 1 && (
<label className="flex items-center gap-1 text-xs text-slate-400">
<input
type="checkbox"
checked={merging.includes(note.id)}
onChange={(e) =>
setMerging((prev) =>
e.target.checked
? [...prev, note.id]
: prev.filter((id) => id !== note.id),
)
}
/>
Merge
</label>
)}
</div>
{editingNoteId === note.id ? (
<div className="space-y-2">
<input
value={editNoteTitle}
onChange={(e) =>
setEditNoteTitle(e.target.value)
}
placeholder="Title"
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-500 focus:outline-none"
/>
<textarea
value={editNoteBody}
onChange={(e) =>
setEditNoteBody(e.target.value)
}
rows={5}
className="w-full resize-none rounded border border-slate-700 bg-slate-950 px-2 py-1 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-500 focus:outline-none"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => void handleSaveNote()}
className="rounded bg-indigo-600 px-3 py-1 text-xs font-medium text-white hover:bg-indigo-500"
>
Save
</button>
<button
type="button"
onClick={() => setEditingNoteId(null)}
className="rounded bg-slate-800 px-3 py-1 text-xs text-slate-300 hover:bg-slate-700"
>
Cancel
</button>
</div>
</div>
) : (
<>
<p className="mb-2 min-h-[2rem] text-sm font-medium text-slate-100">
{note.title || "Untitled note"}
</p>
<p className="line-clamp-4 flex-1 text-sm text-slate-400 whitespace-pre-wrap">
{note.body}
</p>
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
<span>{formatTime(note.updatedAt)}</span>
<button
type="button"
onClick={() => startEditNote(note)}
className="text-indigo-300 hover:text-indigo-200"
>
Edit
</button>
</div>
</>
)}
</div>
))}
</div>
)}
</div>
{merging.length >= 2 && (
<div className="flex items-center justify-between border-t border-slate-800 p-3">
<span className="text-sm text-slate-400">
Merging {merging.length} notes…
</span>
<div className="flex gap-2">
<button
type="button"
onClick={() => void handleMergeNotes(merging)}
className="rounded bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500"
>
Merge notes
</button>
<button
type="button"
onClick={() => setMerging([])}
className="rounded bg-slate-800 px-3 py-1.5 text-sm text-slate-300 hover:bg-slate-700"
>
Cancel
</button>
</div>
</div>
)}
{!noteOpen && (
<div className="border-t border-slate-800 p-3">
<button
type="button"
onClick={() => setNoteOpen(true)}
className="w-full rounded-lg border border-dashed border-slate-700 py-2 text-sm text-slate-400 hover:border-indigo-500 hover:text-indigo-300"
>
+ Add note
</button>
</div>
)}
{noteOpen && (
<div className="border-t border-slate-800 p-3">
<div className="mb-2 flex gap-2">
<select
value={newNoteKind}
onChange={(e) =>
setNewNoteKind(e.target.value as NoteKind)
}
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs text-slate-200 focus:border-indigo-500 focus:outline-none"
>
{NOTE_KINDS.map((k) => (
<option key={k.value} value={k.value}>
{k.label}
</option>
))}
</select>
</div>
<textarea
value={newNoteBody}
onChange={(e) => setNewNoteBody(e.target.value)}
rows={3}
placeholder="Write a note…"
className="w-full resize-none rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-500 focus:outline-none"
/>
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={() => void handleAddNote()}
disabled={!newNoteBody.trim()}
className="rounded bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white disabled:cursor-not-allowed disabled:op-50 hover:bg-indigo-500"
>
Add note
</button>
<button
type="button"
onClick={() => {
setNoteOpen(false);
setNewNoteBody("");
}}
className="rounded bg-slate-800 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700"
>
Cancel
</button>
</div>
</div>
)}
</div>
)}
</div>
</>
) : (
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<p className="text-sm text-slate-500">
Choose a session on the left, or create a new one.
</p>
<button
type="button"
onClick={handleCreateSession}
className="mt-4 rounded bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500"
>
+ New AI session
</button>
</div>
</div>
)}
</main>
</div>
);
}
interface SuggestionCardProps {
suggestion: AISuggestion;
onApply?: () => void;
onAccept?: () => void;
onReject?: () => void;
}
function SuggestionCard({ suggestion, onApply, onAccept, onReject }: SuggestionCardProps) {
const resolved =
suggestion.status === "accepted" ||
suggestion.status === "rejected" ||
suggestion.status === "applied";
return (
<div className="rounded-lg border border-slate-800 bg-slate-900 p-4">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="rounded bg-slate-800 px-2 py-0.5 text-[10px] font-medium uppercase text-slate-300">
{suggestion.kind}
</span>
<StatusPill status={suggestion.status} />
</div>
</div>
<h3 className="mb-1 text-sm font-semibold text-slate-100">
{suggestion.title}
</h3>
{suggestion.summary && (
<p className="mb-3 text-sm text-slate-400">{suggestion.summary}</p>
)}
{(suggestion.newText || suggestion.originalText) && (
<div className="mb-3 grid gap-2 sm:grid-cols-2">
<DiffBlock label="Original" text={suggestion.originalText ?? ""} bad />
<DiffBlock label="Suggested" text={suggestion.newText ?? ""} bad />
</div>
)}
{!resolved && (
<div className="flex flex-wrap gap-2">
{onApply && (
<button
type="button"
onClick={onApply}
className="rounded bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500"
>
Apply
</button>
)}
{onAccept && (
<button
type="button"
onClick={onAccept}
className="rounded border border-emerald-600/50 px-3 py-1.5 text-xs text-emerald-300 hover:bg-emerald-500/10"
>
Accept
</button>
)}
{onReject && (
<button
type="button"
onClick={onReject}
className="rounded border border-rose-600/50 px-3 py-1.5 text-xs text-rose-300 hover:bg-rose-500/10"
>
Reject
</button>
)}
</div>
)}
</div>
);
}
function StatusPill({ status }: { status: AISuggestionStatus }) {
const map: Record<
AISuggestionStatus,
string
> = {
pending: "text-amber-300 bg-amber-500/10",
applied: "text-sky-300 bg-sky-500/10",
accepted: "text-emerald-300 bg-emerald-500/10",
rejected: "text-rose-300 bg-rose-500/10",
};
return (
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ${map[status] ?? ""}`}
>
{status}
</span>
);
}
function DiffBlock({
label,
text,
bad,
}: {
label: string;
text: string;
bad?: boolean;
}) {
return (
<div
className={`rounded border p-2 ${
bad
? "border-rose-900/50 bg-rose-950/20"
: "border-emerald-900/50 bg-emerald-950/20"
}`}
>
<p className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
{label}
</p>
<p className="line-clamp-3 text-xs text-slate-300 whitespace-pre-wrap">
{text || "—"}
</p>
</div>
);
}
// Re-export the role type for consumers that need it.
export type { AIMessageRole };
+2 -29
View File
@@ -1,32 +1,5 @@
import { Button, Input, toast } from "@pubscript/ui"; import { AI as AIView } from "../features/ai/AI.js";
export function AI() { export function AI() {
return ( return <AIView />;
<div className="ps-content">
<h1 style={{ margin: "0 0 16px", fontSize: 24 }}>AI Assistant</h1>
<p className="ps-settings-desc" style={{ marginBottom: 20 }}>
Local-first AI: prompts run against your configured provider. Nothing leaves your machine unless you allow it.
</p>
<Input
label="Prompt"
placeholder="Summarize Mara's arc so far…"
style={{ width: "100%", marginBottom: 12 }}
/>
<div style={{ display: "flex", gap: 10 }}>
<Button variant="primary" onClick={() => toast.success("Prompt queued locally.")}>Run locally</Button>
<Button variant="secondary">Refine character</Button>
<Button variant="secondary">Outline next chapter</Button>
</div>
<div style={{ marginTop: 24, padding: 16, background: "var(--ps-surface)", border: "1px solid var(--ps-border)", borderRadius: 8 }}>
<h3 style={{ marginTop: 0, fontSize: 14 }}>Privacy</h3>
<ul style={{ margin: 0, paddingLeft: 18, lineHeight: 1.8, fontSize: 14 }}>
<li>Documents are never uploaded without explicit consent.</li>
<li>Context is chunked to stay within the token budget.</li>
<li>Session history is stored locally and deletable.</li>
</ul>
</div>
</div>
);
} }
+3
View File
@@ -32,6 +32,9 @@ importers:
apps/web: apps/web:
dependencies: dependencies:
'@pubscript/ai':
specifier: workspace:*
version: link:../../packages/ai
'@pubscript/core': '@pubscript/core':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/core version: link:../../packages/core