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:
@@ -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:*",
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+3
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user