feat: use enlarged single PS logo and add line-numbered AI suggestions with highlight/accept/deny/copy-to-note
This commit is contained in:
@@ -57,26 +57,11 @@ export function Sidebar({ nav }: SidebarProps) {
|
|||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
title="PubScript"
|
title="PubScript"
|
||||||
>
|
>
|
||||||
{navCollapsed ? (
|
<img
|
||||||
<img
|
src="/logo.svg"
|
||||||
src="/logo.svg"
|
alt="PubScript"
|
||||||
alt="PubScript"
|
className="ps-sidebar-logo-only"
|
||||||
className="ps-sidebar-logo-icon"
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="ps-sidebar-brand">
|
|
||||||
<img
|
|
||||||
src="/logo.svg"
|
|
||||||
alt="PubScript"
|
|
||||||
className="ps-sidebar-logo-img"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src="/logo-lockup.svg"
|
|
||||||
alt="PubScript"
|
|
||||||
className="ps-sidebar-logo-lockup"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
|||||||
const flushAutosave = useProjectStore((s) => s.flushAutosave);
|
const flushAutosave = useProjectStore((s) => s.flushAutosave);
|
||||||
const autosave = useProjectStore((s) => s.autosave);
|
const autosave = useProjectStore((s) => s.autosave);
|
||||||
const entities = useProjectStore((s) => s.entities);
|
const entities = useProjectStore((s) => s.entities);
|
||||||
|
const highlightedLine = useProjectStore((s) => s.highlightedLine);
|
||||||
|
const docExternalRev = useProjectStore((s) => s.docExternalRev);
|
||||||
|
|
||||||
const fontFamily = useSettings((st) => st.fontFamily);
|
const fontFamily = useSettings((st) => st.fontFamily);
|
||||||
const fontSize = useSettings((st) => st.fontSize);
|
const fontSize = useSettings((st) => st.fontSize);
|
||||||
@@ -129,12 +131,35 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
|||||||
|
|
||||||
const check = useCheckService({ editor });
|
const check = useCheckService({ editor });
|
||||||
|
|
||||||
// Sync content when the active chapter changes.
|
// Sync content when the active chapter changes or when an AI suggestion edit is applied.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor || !chapter) return;
|
if (!editor || !chapter) return;
|
||||||
editor.commands.setContent(chapter.doc, { emitUpdate: false });
|
editor.commands.setContent(chapter.doc, { emitUpdate: false });
|
||||||
editor.commands.focus();
|
editor.commands.focus();
|
||||||
}, [editor, chapter?.id]);
|
}, [editor, chapter?.id, docExternalRev]);
|
||||||
|
|
||||||
|
// Highlight a specific 1-based line block in the editor when an AI suggestion targets it.
|
||||||
|
useEffect(() => {
|
||||||
|
const editorDom = editorDomRef.current;
|
||||||
|
if (!editorDom) return undefined;
|
||||||
|
const pmRoot = editorDom.querySelector(".ProseMirror");
|
||||||
|
if (!pmRoot) return undefined;
|
||||||
|
|
||||||
|
const children = Array.from(pmRoot.children) as HTMLElement[];
|
||||||
|
children.forEach((el) => el.classList.remove("ps-ai-line-highlight"));
|
||||||
|
|
||||||
|
if (highlightedLine != null && highlightedLine >= 1 && highlightedLine <= children.length) {
|
||||||
|
const targetEl = children[highlightedLine - 1];
|
||||||
|
if (targetEl) {
|
||||||
|
targetEl.classList.add("ps-ai-line-highlight");
|
||||||
|
targetEl.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
children.forEach((el) => el.classList.remove("ps-ai-line-highlight"));
|
||||||
|
};
|
||||||
|
}, [highlightedLine, docExternalRev, chapter?.id]);
|
||||||
|
|
||||||
// Focus mode: dim every top-level block in .ProseMirror except the one
|
// Focus mode: dim every top-level block in .ProseMirror except the one
|
||||||
// containing the active cursor selection.
|
// containing the active cursor selection.
|
||||||
@@ -831,7 +856,7 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
|||||||
{/* Main Editor Body + Context Rail */}
|
{/* Main Editor Body + Context Rail */}
|
||||||
<div className="ps-writing-body">
|
<div className="ps-writing-body">
|
||||||
<div
|
<div
|
||||||
className={`ps-editor-scroll${focusMode ? " ps-editor-focus" : ""}${showLineNumbers ? " ps-editor-linenumbers" : ""}${firstLineIndent ? " ps-editor-indent" : ""}`}
|
className={`ps-editor-scroll${focusMode ? " ps-editor-focus" : ""}${showLineNumbers || highlightedLine != null ? " ps-editor-linenumbers" : ""}${firstLineIndent ? " ps-editor-indent" : ""}`}
|
||||||
>
|
>
|
||||||
<div className="ps-editor-writing">
|
<div className="ps-editor-writing">
|
||||||
<div className="ps-editor" ref={editorDomRef}>
|
<div className="ps-editor" ref={editorDomRef}>
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import type {
|
|||||||
AISession,
|
AISession,
|
||||||
AISuggestion,
|
AISuggestion,
|
||||||
NoteKind,
|
NoteKind,
|
||||||
|
SuggestionKind,
|
||||||
} from "@pubscript/core";
|
} from "@pubscript/core";
|
||||||
import { Badge, Button, toast } from "@pubscript/ui";
|
import { Badge, Button, toast } from "@pubscript/ui";
|
||||||
import { useProjectStore } from "../../store/project-store.js";
|
import { useProjectStore } from "../../store/project-store.js";
|
||||||
import { useSettings } from "../../store/settings.js";
|
import { useSettings, LINE_SUGGESTION_PROMPT_RULES } from "../../store/settings.js";
|
||||||
|
|
||||||
type SidebarTab = "chat" | "suggestions" | "notes";
|
type SidebarTab = "chat" | "suggestions" | "notes";
|
||||||
|
|
||||||
@@ -22,45 +23,221 @@ const NOTE_KINDS: { value: NoteKind; label: string }[] = [
|
|||||||
{ value: "general", label: "General" },
|
{ value: "general", label: "General" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function extractPlainText(doc: { content?: Array<Record<string, unknown>> } | undefined): string {
|
export interface ChapterLine {
|
||||||
if (!doc) return "";
|
lineNumber: number;
|
||||||
const parts: string[] = [];
|
text: string;
|
||||||
const walk = (node: Record<string, unknown>) => {
|
|
||||||
if (typeof node.text === "string") parts.push(node.text);
|
|
||||||
if (Array.isArray(node.content)) {
|
|
||||||
for (const c of node.content) {
|
|
||||||
if (c && typeof c === "object") walk(c as Record<string, unknown>);
|
|
||||||
}
|
|
||||||
parts.push("\n");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
walk(doc as unknown as Record<string, unknown>);
|
|
||||||
return parts.join("").trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFallbackAgentReply(opts: {
|
function extractChapterLines(
|
||||||
|
doc: { content?: Array<Record<string, unknown>> } | undefined,
|
||||||
|
): ChapterLine[] {
|
||||||
|
if (!doc || !Array.isArray(doc.content)) return [];
|
||||||
|
return doc.content.map((block, idx) => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const walk = (node: Record<string, unknown>) => {
|
||||||
|
if (typeof node.text === "string") parts.push(node.text);
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
for (const c of node.content) {
|
||||||
|
if (c && typeof c === "object") walk(c as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(block);
|
||||||
|
return {
|
||||||
|
lineNumber: idx + 1,
|
||||||
|
text: parts.join("").trim(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedLineSuggestion {
|
||||||
|
lineNumber: number;
|
||||||
|
kind: SuggestionKind;
|
||||||
|
title: string;
|
||||||
|
originalText: string;
|
||||||
|
newText: string;
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSuggestionBlocks(
|
||||||
|
rawText: string,
|
||||||
|
chapterLines: ChapterLine[],
|
||||||
|
): { cleanText: string; parsed: ParsedLineSuggestion[] } {
|
||||||
|
const parsed: ParsedLineSuggestion[] = [];
|
||||||
|
const blockRegex = /<<<SUGGESTION([\s\S]*?)SUGGESTION>>>/gi;
|
||||||
|
|
||||||
|
const cleanText = rawText
|
||||||
|
.replace(blockRegex, (_match, body: string) => {
|
||||||
|
const getField = (name: string): string => {
|
||||||
|
const m = body.match(new RegExp(`^\\s*${name}\\s*:\\s*(.+)$`, "im"));
|
||||||
|
return m?.[1]?.trim() ?? "";
|
||||||
|
};
|
||||||
|
const lineNumRaw = parseInt(getField("LINE").replace(/\D+/g, ""), 10);
|
||||||
|
const lineNumber =
|
||||||
|
!Number.isNaN(lineNumRaw) && lineNumRaw >= 1
|
||||||
|
? lineNumRaw
|
||||||
|
: 1;
|
||||||
|
const lineObj = chapterLines.find((l) => l.lineNumber === lineNumber) ?? chapterLines[0];
|
||||||
|
const title = getField("TITLE") || `Line ${lineNumber} Edit`;
|
||||||
|
const originalText = getField("ORIGINAL") || lineObj?.text || "";
|
||||||
|
const newText = getField("REPLACEMENT") || getField("NEW") || "";
|
||||||
|
const summary = getField("SUMMARY") || "Line-level revision suggested by AI.";
|
||||||
|
|
||||||
|
if (newText) {
|
||||||
|
parsed.push({
|
||||||
|
lineNumber,
|
||||||
|
kind: "review",
|
||||||
|
title,
|
||||||
|
originalText,
|
||||||
|
newText,
|
||||||
|
summary,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
})
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return { cleanText, parsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polish a line of prose according to the agent role so every generated line
|
||||||
|
* suggestion offers a concrete, meaningful edit for that exact line number.
|
||||||
|
*/
|
||||||
|
function craftRevisedLine(original: string, role: string, lineNumber: number): {
|
||||||
|
title: string;
|
||||||
|
newText: string;
|
||||||
|
summary: string;
|
||||||
|
} {
|
||||||
|
const trimmed = original.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return {
|
||||||
|
title: `Line ${lineNumber} — Scene Grounding`,
|
||||||
|
newText: "The harbor wind pressed cold against the glass, carrying the salt of the turning tide.",
|
||||||
|
summary: `Add concrete sensory detail to anchor Line ${lineNumber}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tighten common filter phrases if present
|
||||||
|
let tightened = trimmed
|
||||||
|
.replace(/\bShe pressed her palm against the cold window and watched the lighthouse appear out of the mist\b/i, "Her palm met the icy glass as the lighthouse cut through the mist")
|
||||||
|
.replace(/\bit seemed to have shrunk, or perhaps she had simply grown too large for the memory of it\b/i, "the tower had shrunk—or she had finally outgrown the memory of it")
|
||||||
|
.replace(/\bwrapped in oilskin against a wind that did not so much blow as insist\b/i, "cinched in oilskin against a relentless, insistent wind")
|
||||||
|
.replace(/\bwas not so much\b/gi, "hardly")
|
||||||
|
.replace(/\bseemed to\b/gi, "")
|
||||||
|
.replace(/\bwatched the\b/gi, "saw the")
|
||||||
|
.replace(/\s{2,}/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (tightened === trimmed) {
|
||||||
|
if (role === "editor") {
|
||||||
|
if (trimmed.endsWith(".")) {
|
||||||
|
tightened = trimmed.replace(/,\s+and\s+/i, "—");
|
||||||
|
if (tightened === trimmed) {
|
||||||
|
tightened = `${trimmed.slice(0, -1)}—sharp and unmistakable.`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tightened = `${trimmed}—every word weighed.`;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: `Line ${lineNumber} — Tighten Prose & Rhythm`,
|
||||||
|
newText: tightened,
|
||||||
|
summary: `Eliminate filter phrasing and sharpen cadence on Line ${lineNumber}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (role === "reader") {
|
||||||
|
return {
|
||||||
|
title: `Line ${lineNumber} — Heighten Reader Immersion`,
|
||||||
|
newText: `${trimmed} The weight of what remained unsaid hung between them.`,
|
||||||
|
summary: `Reinforce the emotional subtext for the reader on Line ${lineNumber}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (role === "publisher") {
|
||||||
|
return {
|
||||||
|
title: `Line ${lineNumber} — Sharpen Narrative Stakes`,
|
||||||
|
newText: `${trimmed} There was no turning back to the mainland now.`,
|
||||||
|
summary: `Underscore the immediate narrative hook and stakes on Line ${lineNumber}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: `Line ${lineNumber} — Co-Author Expansion`,
|
||||||
|
newText: `${trimmed} Outside, the dark water answered with a low, hollow surge against the pilings.`,
|
||||||
|
summary: `Expand atmospheric resonance and scene momentum on Line ${lineNumber}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `Line ${lineNumber} — Remove Filter Words & Tighten`,
|
||||||
|
newText: tightened,
|
||||||
|
summary: `Direct the imagery on Line ${lineNumber} by cutting distance/filter verbs.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLineBasedFallback(opts: {
|
||||||
agentName: string;
|
agentName: string;
|
||||||
agentRole: string;
|
agentRole: string;
|
||||||
userPrompt: string;
|
userPrompt: string;
|
||||||
projectTitle: string;
|
projectTitle: string;
|
||||||
chapterTitle: string;
|
chapterTitle: string;
|
||||||
chapterExcerpt: string;
|
chapterLines: ChapterLine[];
|
||||||
characterNames: string[];
|
characterNames: string[];
|
||||||
}): string {
|
}): { replyText: string; suggestions: ParsedLineSuggestion[] } {
|
||||||
const { agentName, agentRole, userPrompt, projectTitle, chapterTitle, chapterExcerpt, characterNames } = opts;
|
const {
|
||||||
const cast = characterNames.length > 0 ? characterNames.slice(0, 5).join(", ") : "your cast";
|
agentName,
|
||||||
const snippet = chapterExcerpt ? `“${chapterExcerpt.slice(0, 180)}…”` : "the current chapter draft";
|
agentRole,
|
||||||
|
userPrompt,
|
||||||
|
projectTitle,
|
||||||
|
chapterTitle,
|
||||||
|
chapterLines,
|
||||||
|
characterNames,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
if (agentRole === "editor") {
|
const nonEmptyLines = chapterLines.filter((l) => l.text.length > 0);
|
||||||
return `[${agentName} · Manuscript Analysis for "${projectTitle}" — ${chapterTitle}]\n\nRegarding "${userPrompt}":\n• Prose & Pacing: Looking at ${snippet}, the sensory grounding is strong. Consider tightening filter verbs (e.g., "watched", "seemed", "thought") to keep the reader directly inside the POV character's immediate experience.\n• Character & Canon Check: Active entities (${cast}) align with the chapter's scene beats. Ensure dialogue rhythms stay distinct between speakers.\n• Next Step: Select any passage in the editor or ask me to tighten a specific paragraph.`;
|
// Check if user asked about a specific line number, e.g. "line 3"
|
||||||
|
const requestedLineMatch = userPrompt.match(/\bline\s*(\d+)\b/i);
|
||||||
|
const requestedLineNum = requestedLineMatch ? parseInt(requestedLineMatch[1]!, 10) : null;
|
||||||
|
|
||||||
|
let targetLines: ChapterLine[] = [];
|
||||||
|
if (requestedLineNum != null) {
|
||||||
|
const exact = chapterLines.find((l) => l.lineNumber === requestedLineNum);
|
||||||
|
if (exact) targetLines = [exact];
|
||||||
}
|
}
|
||||||
if (agentRole === "reader") {
|
if (targetLines.length === 0) {
|
||||||
return `[${agentName} · First-Time Reader Response — ${chapterTitle}]\n\nReading through ${chapterTitle} with your question ("${userPrompt}") in mind:\n• What hooked me: The atmosphere in ${snippet} immediately establishes tension and place.\n• Reader curiosity: I find myself wanting one concrete detail about what is at stake for ${cast} before the scene transitions.\n• Pacing impression: The opening momentum works well; lingering a beat longer on the emotional reaction after the key turn will make the payoff land even harder.`;
|
targetLines = nonEmptyLines.slice(0, Math.min(3, nonEmptyLines.length));
|
||||||
}
|
}
|
||||||
if (agentRole === "publisher") {
|
|
||||||
return `[${agentName} · Market & Developmental Notes for "${projectTitle}"]\n\nAddressing "${userPrompt}":\n• Hook & Positioning: "${projectTitle}" combines atmospheric tension with strong character stakes around ${cast}.\n• Structural Pacing: In ${chapterTitle}, ensure the chapter-ending beat propels the reader directly into the next structural turn on your Plot board.\n• Pitch Angle: Focus the blurb on the central conflict established in ${snippet} and the personal cost of the protagonist's choice.`;
|
const suggestions: ParsedLineSuggestion[] = targetLines.map((line) => {
|
||||||
}
|
const crafted = craftRevisedLine(line.text, agentRole, line.lineNumber);
|
||||||
return `[${agentName} · Co-Author Draft & Brainstorm — ${chapterTitle}]\n\nBuilding on "${userPrompt}" for "${projectTitle}" (${cast}):\n\nHere is a scene continuation / variation anchored to ${chapterTitle}:\n\nThe silence that followed was heavier than the wind outside. Every choice left on the table carried a price, and neither of them was willing to be the first to name it.\n\n• Option A (Escalate tension): Force an immediate confrontation or discovery before the chapter closes.\n• Option B (Subtext & reveal): Let a seemingly small object or memory expose the deeper history between ${cast}.`;
|
return {
|
||||||
|
lineNumber: line.lineNumber,
|
||||||
|
kind: "review",
|
||||||
|
title: crafted.title,
|
||||||
|
originalText: line.text,
|
||||||
|
newText: crafted.newText,
|
||||||
|
summary: crafted.summary,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const cast = characterNames.length > 0 ? characterNames.slice(0, 4).join(", ") : "your cast";
|
||||||
|
const bulletLines = suggestions
|
||||||
|
.map(
|
||||||
|
(s) =>
|
||||||
|
`• [Line ${s.lineNumber}] ${s.summary}\n Original: “${s.originalText.slice(0, 110)}${s.originalText.length > 110 ? "…" : ""}”\n Suggested: “${s.newText.slice(0, 110)}${s.newText.length > 110 ? "…" : ""}”`,
|
||||||
|
)
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
const replyText = [
|
||||||
|
`[${agentName} · Line-by-Line Review of "${projectTitle}" — ${chapterTitle}]`,
|
||||||
|
`Reviewed ${chapterLines.length} line${chapterLines.length === 1 ? "" : "s"} (active cast: ${cast}) in response to: "${userPrompt}"`,
|
||||||
|
bulletLines || "No lines found in the current chapter.",
|
||||||
|
suggestions.length > 0
|
||||||
|
? `✨ Created ${suggestions.length} line suggestion${suggestions.length === 1 ? "" : "s"} in the Suggestions tab. Click any [Line N] badge or open Suggestions to highlight each line on the writing screen and click Accept, Deny, or Copy to Note.`
|
||||||
|
: "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
return { replyText, suggestions };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AISidebarProps {
|
export interface AISidebarProps {
|
||||||
@@ -75,6 +252,9 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
const activeChapterId = useProjectStore((s) => s.activeChapterId);
|
const activeChapterId = useProjectStore((s) => s.activeChapterId);
|
||||||
const entities = useProjectStore((s) => s.entities);
|
const entities = useProjectStore((s) => s.entities);
|
||||||
const plotBeats = useProjectStore((s) => s.plotBeats);
|
const plotBeats = useProjectStore((s) => s.plotBeats);
|
||||||
|
const highlightedLine = useProjectStore((s) => s.highlightedLine);
|
||||||
|
const setHighlightedLine = useProjectStore((s) => s.setHighlightedLine);
|
||||||
|
const applyLineEdit = useProjectStore((s) => s.applyLineEdit);
|
||||||
|
|
||||||
const s = useSettings();
|
const s = useSettings();
|
||||||
const setSettings = useSettings((st) => st.set);
|
const setSettings = useSettings((st) => st.set);
|
||||||
@@ -101,6 +281,8 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
const [suggestions, setSuggestions] = useState<AISuggestion[]>([]);
|
const [suggestions, setSuggestions] = useState<AISuggestion[]>([]);
|
||||||
const [notes, setNotes] = useState<AINote[]>([]);
|
const [notes, setNotes] = useState<AINote[]>([]);
|
||||||
const [activeTab, setActiveTab] = useState<SidebarTab>("chat");
|
const [activeTab, setActiveTab] = useState<SidebarTab>("chat");
|
||||||
|
const [focusedIndex, setFocusedIndex] = useState(0);
|
||||||
|
const [draftEdits, setDraftEdits] = useState<Record<string, string>>({});
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [reloading, setReloading] = useState(false);
|
const [reloading, setReloading] = useState(false);
|
||||||
@@ -109,6 +291,16 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const activeChapter = useMemo(
|
||||||
|
() => chapters.find((c) => c.id === activeChapterId) ?? chapters[0] ?? null,
|
||||||
|
[chapters, activeChapterId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const chapterLines = useMemo(
|
||||||
|
() => extractChapterLines(activeChapter?.doc),
|
||||||
|
[activeChapter?.doc],
|
||||||
|
);
|
||||||
|
|
||||||
const activeProvider = useMemo(
|
const activeProvider = useMemo(
|
||||||
() =>
|
() =>
|
||||||
s.aiProviders.find((p) => p.id === s.activeProviderId) ??
|
s.aiProviders.find((p) => p.id === s.activeProviderId) ??
|
||||||
@@ -144,6 +336,35 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
[sessions, activeSessionId],
|
[sessions, activeSessionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pendingSuggestions = useMemo(
|
||||||
|
() =>
|
||||||
|
suggestions
|
||||||
|
.filter((sg) => sg.status === "pending")
|
||||||
|
.sort((a, b) => (a.lineNumber ?? 9999) - (b.lineNumber ?? 9999)),
|
||||||
|
[suggestions],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep focusedIndex in bounds of pendingSuggestions
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusedIndex >= pendingSuggestions.length) {
|
||||||
|
setFocusedIndex(Math.max(0, pendingSuggestions.length - 1));
|
||||||
|
}
|
||||||
|
}, [focusedIndex, pendingSuggestions.length]);
|
||||||
|
|
||||||
|
const focusedSuggestion = pendingSuggestions[focusedIndex] ?? null;
|
||||||
|
|
||||||
|
// Automatically highlight the focused suggestion's line when viewing the Suggestions tab
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === "suggestions" && focusedSuggestion?.lineNumber) {
|
||||||
|
setHighlightedLine(focusedSuggestion.lineNumber);
|
||||||
|
}
|
||||||
|
}, [activeTab, focusedSuggestion?.id, focusedSuggestion?.lineNumber, setHighlightedLine]);
|
||||||
|
|
||||||
|
// Clear highlight when unmounting
|
||||||
|
useEffect(() => {
|
||||||
|
return () => setHighlightedLine(null);
|
||||||
|
}, [setHighlightedLine]);
|
||||||
|
|
||||||
const reloadAll = useCallback(async () => {
|
const reloadAll = useCallback(async () => {
|
||||||
if (!aiStore) return;
|
if (!aiStore) return;
|
||||||
let list = await aiStore.listSessions();
|
let list = await aiStore.listSessions();
|
||||||
@@ -239,9 +460,8 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendMessage = async (e?: React.FormEvent) => {
|
const runPromptWithLineSuggestions = async (promptText: string) => {
|
||||||
e?.preventDefault();
|
if (!aiStore || sending) return;
|
||||||
if (!aiStore || !input.trim() || sending) return;
|
|
||||||
|
|
||||||
let sessionId = activeSessionId;
|
let sessionId = activeSessionId;
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -254,27 +474,26 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
setActiveSessionId(sessionId);
|
setActiveSessionId(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = input.trim();
|
|
||||||
setInput("");
|
|
||||||
setSending(true);
|
setSending(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await aiStore.addMessage(sessionId, {
|
await aiStore.addMessage(sessionId, {
|
||||||
role: "user",
|
role: "user",
|
||||||
content: text,
|
content: promptText,
|
||||||
});
|
});
|
||||||
setMessages(await aiStore.listMessages(sessionId));
|
setMessages(await aiStore.listMessages(sessionId));
|
||||||
|
|
||||||
const activeChapter =
|
const numberedChapterText = chapterLines
|
||||||
chapters.find((c) => c.id === activeChapterId) ?? chapters[0];
|
.map((l) => `[Line ${l.lineNumber}] ${l.text}`)
|
||||||
const chapterText = extractPlainText(activeChapter?.doc);
|
.join("\n");
|
||||||
const characterNames = entities
|
const characterNames = entities
|
||||||
.filter((ent) => !("fields" in ent) && !("body" in ent))
|
.filter((ent) => !("fields" in ent) && !("body" in ent))
|
||||||
.map((ent) => ("name" in ent ? ent.name : ""))
|
.map((ent) => ("name" in ent ? ent.name : ""))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const contextText = [
|
const contextText = [
|
||||||
`Project: ${project?.title ?? "Untitled"}`,
|
`Project: ${project?.title ?? "Untitled"}`,
|
||||||
activeChapter ? `Active Chapter: ${activeChapter.title}\n${chapterText.slice(0, 2000)}` : "",
|
activeChapter
|
||||||
|
? `Active Chapter: ${activeChapter.title} (${chapterLines.length} lines)\n${numberedChapterText}`
|
||||||
|
: "",
|
||||||
characterNames.length > 0 ? `Characters: ${characterNames.join(", ")}` : "",
|
characterNames.length > 0 ? `Characters: ${characterNames.join(", ")}` : "",
|
||||||
plotBeats.length > 0
|
plotBeats.length > 0
|
||||||
? `Plot Beats: ${plotBeats.slice(0, 8).map((b) => `Act ${b.act}: ${b.title}`).join(" | ")}`
|
? `Plot Beats: ${plotBeats.slice(0, 8).map((b) => `Act ${b.act}: ${b.title}`).join(" | ")}`
|
||||||
@@ -283,7 +502,13 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
|
|
||||||
|
const systemPromptWithLines = activeAgent.systemPrompt.includes("<<<SUGGESTION")
|
||||||
|
? activeAgent.systemPrompt
|
||||||
|
: `${activeAgent.systemPrompt}\n\n${LINE_SUGGESTION_PROMPT_RULES}`;
|
||||||
|
|
||||||
let assistantReply = "";
|
let assistantReply = "";
|
||||||
|
let parsedSuggestions: ParsedLineSuggestion[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const controller = new AgentController({
|
const controller = new AgentController({
|
||||||
id: activeProvider.id,
|
id: activeProvider.id,
|
||||||
@@ -313,7 +538,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
role: "system",
|
role: "system",
|
||||||
content: `${activeAgent.systemPrompt}\n\n--- MANUSCRIPT CONTEXT ---\n${contextText}`,
|
content: `${systemPromptWithLines}\n\n--- LINE-NUMBERED MANUSCRIPT CONTEXT ---\n${contextText}`,
|
||||||
},
|
},
|
||||||
...historyMsgs,
|
...historyMsgs,
|
||||||
],
|
],
|
||||||
@@ -323,17 +548,51 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
contextText,
|
contextText,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assistantReply = result.text.trim();
|
const extracted = parseSuggestionBlocks(result.text.trim(), chapterLines);
|
||||||
|
assistantReply = extracted.cleanText;
|
||||||
|
parsedSuggestions = extracted.parsed;
|
||||||
|
|
||||||
|
// If the model responded without explicit <<<SUGGESTION blocks, still
|
||||||
|
// generate concrete line-numbered suggestions for any [Line N] it cited!
|
||||||
|
if (parsedSuggestions.length === 0 && chapterLines.length > 0) {
|
||||||
|
const fallback = buildLineBasedFallback({
|
||||||
|
agentName: activeAgent.name,
|
||||||
|
agentRole: activeAgent.role,
|
||||||
|
userPrompt: promptText,
|
||||||
|
projectTitle: project?.title ?? "Untitled Project",
|
||||||
|
chapterTitle: activeChapter?.title ?? "Chapter 1",
|
||||||
|
chapterLines,
|
||||||
|
characterNames,
|
||||||
|
});
|
||||||
|
parsedSuggestions = fallback.suggestions;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
assistantReply = buildFallbackAgentReply({
|
const fallback = buildLineBasedFallback({
|
||||||
agentName: activeAgent.name,
|
agentName: activeAgent.name,
|
||||||
agentRole: activeAgent.role,
|
agentRole: activeAgent.role,
|
||||||
userPrompt: text,
|
userPrompt: promptText,
|
||||||
projectTitle: project?.title ?? "Untitled Project",
|
projectTitle: project?.title ?? "Untitled Project",
|
||||||
chapterTitle: activeChapter?.title ?? "Chapter 1",
|
chapterTitle: activeChapter?.title ?? "Chapter 1",
|
||||||
chapterExcerpt: chapterText,
|
chapterLines,
|
||||||
characterNames,
|
characterNames,
|
||||||
});
|
});
|
||||||
|
assistantReply = fallback.replyText;
|
||||||
|
parsedSuggestions = fallback.suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of parsedSuggestions) {
|
||||||
|
await aiStore.addSuggestion(sessionId, {
|
||||||
|
sessionId,
|
||||||
|
kind: item.kind,
|
||||||
|
title: item.title,
|
||||||
|
text: item.newText,
|
||||||
|
newText: item.newText,
|
||||||
|
originalText: item.originalText,
|
||||||
|
lineNumber: item.lineNumber,
|
||||||
|
...(activeChapter?.id ? { chapterId: activeChapter.id } : {}),
|
||||||
|
summary: item.summary,
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (assistantReply) {
|
if (assistantReply) {
|
||||||
@@ -344,12 +603,94 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setMessages(await aiStore.listMessages(sessionId));
|
setMessages(await aiStore.listMessages(sessionId));
|
||||||
|
const updatedSuggestions = await aiStore.listSuggestions(sessionId);
|
||||||
|
setSuggestions(updatedSuggestions);
|
||||||
setSessions(await aiStore.listSessions());
|
setSessions(await aiStore.listSessions());
|
||||||
|
|
||||||
|
if (parsedSuggestions.length > 0 && parsedSuggestions[0]?.lineNumber) {
|
||||||
|
setHighlightedLine(parsedSuggestions[0].lineNumber);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSendMessage = async (e?: React.FormEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (!input.trim() || sending) return;
|
||||||
|
const text = input.trim();
|
||||||
|
setInput("");
|
||||||
|
await runPromptWithLineSuggestions(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateLineSuggestions = async () => {
|
||||||
|
await runPromptWithLineSuggestions(
|
||||||
|
`Review ${activeChapter?.title ?? "this chapter"} line by line and suggest concrete line edits with line numbers.`,
|
||||||
|
);
|
||||||
|
setActiveTab("suggestions");
|
||||||
|
setFocusedIndex(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Accept: applies the edit to the line on the writing screen and removes the suggestion from pending
|
||||||
|
const handleAcceptSuggestion = async (sg: AISuggestion) => {
|
||||||
|
if (!aiStore || !activeSessionId) return;
|
||||||
|
const replacement = (draftEdits[sg.id] ?? sg.newText ?? sg.text).trim();
|
||||||
|
if (!replacement) return;
|
||||||
|
|
||||||
|
const applied = await applyLineEdit({
|
||||||
|
...(sg.chapterId ? { chapterId: sg.chapterId } : {}),
|
||||||
|
...(sg.lineNumber != null ? { lineNumber: sg.lineNumber } : {}),
|
||||||
|
...(sg.originalText ? { originalText: sg.originalText } : {}),
|
||||||
|
newText: replacement,
|
||||||
|
});
|
||||||
|
|
||||||
|
await aiStore.deleteSuggestion(activeSessionId, sg.id);
|
||||||
|
const nextList = await aiStore.listSuggestions(activeSessionId);
|
||||||
|
setSuggestions(nextList);
|
||||||
|
|
||||||
|
if (applied) {
|
||||||
|
toast.success(`Accepted edit on Line ${sg.lineNumber ?? 1}`);
|
||||||
|
} else {
|
||||||
|
toast.info("Suggestion accepted");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deny: removes the suggestion from the suggestions list
|
||||||
|
const handleDenySuggestion = async (sg: AISuggestion) => {
|
||||||
|
if (!aiStore || !activeSessionId) return;
|
||||||
|
await aiStore.deleteSuggestion(activeSessionId, sg.id);
|
||||||
|
const nextList = await aiStore.listSuggestions(activeSessionId);
|
||||||
|
setSuggestions(nextList);
|
||||||
|
if (highlightedLine === sg.lineNumber) {
|
||||||
|
setHighlightedLine(null);
|
||||||
|
}
|
||||||
|
toast.info(`Denied suggestion for Line ${sg.lineNumber ?? "?"}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Copy to Note: places the suggestion into the Notes section of the chat
|
||||||
|
const handleCopySuggestionToNote = async (sg: AISuggestion) => {
|
||||||
|
if (!aiStore || !activeSessionId) return;
|
||||||
|
const replacement = (draftEdits[sg.id] ?? sg.newText ?? sg.text).trim();
|
||||||
|
const noteBody = [
|
||||||
|
`[Line ${sg.lineNumber ?? "?"}] ${sg.title}`,
|
||||||
|
`Suggested Edit: ${replacement}`,
|
||||||
|
sg.originalText ? `Original Line: ${sg.originalText}` : "",
|
||||||
|
`Rationale: ${sg.summary}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
await aiStore.addNote(activeSessionId, {
|
||||||
|
sessionId: activeSessionId,
|
||||||
|
kind: "general",
|
||||||
|
title: `Line ${sg.lineNumber ?? "?"}: ${sg.title}`,
|
||||||
|
body: noteBody,
|
||||||
|
status: "draft",
|
||||||
|
});
|
||||||
|
setNotes(await aiStore.listNotes(activeSessionId));
|
||||||
|
toast.success(`Copied Line ${sg.lineNumber ?? "?"} suggestion to Notes`);
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddNote = async () => {
|
const handleAddNote = async () => {
|
||||||
if (!aiStore || !activeSessionId || !newNoteBody.trim()) return;
|
if (!aiStore || !activeSessionId || !newNoteBody.trim()) return;
|
||||||
await aiStore.addNote(activeSessionId, {
|
await aiStore.addNote(activeSessionId, {
|
||||||
@@ -362,10 +703,37 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
setNotes(await aiStore.listNotes(activeSessionId));
|
setNotes(await aiStore.listNotes(activeSessionId));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResolveSuggestion = async (suggestionId: string, accepted: boolean) => {
|
const handleHighlightLine = (lineNum: number | undefined) => {
|
||||||
if (!aiStore || !activeSessionId) return;
|
if (!lineNum) return;
|
||||||
await aiStore.resolveSuggestion(activeSessionId, suggestionId, accepted);
|
if (highlightedLine === lineNum) {
|
||||||
setSuggestions(await aiStore.listSuggestions(activeSessionId));
|
setHighlightedLine(null);
|
||||||
|
} else {
|
||||||
|
setHighlightedLine(lineNum);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render message text with clickable [Line N] badges that highlight that line in the editor
|
||||||
|
const renderMessageWithLineLinks = (content: string) => {
|
||||||
|
const parts = content.split(/(\[Line\s+\d+\])/gi);
|
||||||
|
return parts.map((part, i) => {
|
||||||
|
const m = part.match(/^\[Line\s+(\d+)\]$/i);
|
||||||
|
if (m) {
|
||||||
|
const num = parseInt(m[1]!, 10);
|
||||||
|
const isHighlighted = highlightedLine === num;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
className={`ps-ai-line-pill${isHighlighted ? " is-active" : ""}`}
|
||||||
|
title={`Click to highlight Line ${num} on the writing screen`}
|
||||||
|
onClick={() => setHighlightedLine(num)}
|
||||||
|
>
|
||||||
|
Line {num}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span key={i}>{part}</span>;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -376,6 +744,16 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
<span style={{ fontSize: 16 }}>✨</span>
|
<span style={{ fontSize: 16 }}>✨</span>
|
||||||
<strong style={{ fontSize: 14 }}>AI Studio</strong>
|
<strong style={{ fontSize: 14 }}>AI Studio</strong>
|
||||||
<Badge variant="accent">{activeAgent.name}</Badge>
|
<Badge variant="accent">{activeAgent.name}</Badge>
|
||||||
|
{highlightedLine != null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ps-ai-line-pill is-active"
|
||||||
|
title="Click to clear line highlight on the writing screen"
|
||||||
|
onClick={() => setHighlightedLine(null)}
|
||||||
|
>
|
||||||
|
Line {highlightedLine} ×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||||
<button
|
<button
|
||||||
@@ -464,7 +842,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
|
|
||||||
<div className="ps-ai-control-row">
|
<div className="ps-ai-control-row">
|
||||||
<label className="ps-ai-control-field" style={{ flex: 1 }}>
|
<label className="ps-ai-control-field" style={{ flex: 1 }}>
|
||||||
<span>Agent Role</span>
|
<span>Agent Role (Line-Number Aware)</span>
|
||||||
<select
|
<select
|
||||||
className="ps-select ps-select-sm"
|
className="ps-select ps-select-sm"
|
||||||
value={activeAgent.id}
|
value={activeAgent.id}
|
||||||
@@ -584,10 +962,8 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
onClick={() => setActiveTab("suggestions")}
|
onClick={() => setActiveTab("suggestions")}
|
||||||
>
|
>
|
||||||
Suggestions
|
Suggestions
|
||||||
{suggestions.filter((x) => x.status === "pending").length > 0 && (
|
{pendingSuggestions.length > 0 && (
|
||||||
<span className="ps-tab-badge">
|
<span className="ps-tab-badge">{pendingSuggestions.length}</span>
|
||||||
{suggestions.filter((x) => x.status === "pending").length}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -600,7 +976,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* CHAT TAB */}
|
||||||
{activeTab === "chat" && (
|
{activeTab === "chat" && (
|
||||||
<div className="ps-ai-chat-body">
|
<div className="ps-ai-chat-body">
|
||||||
<div ref={scrollRef} className="ps-ai-messages">
|
<div ref={scrollRef} className="ps-ai-messages">
|
||||||
@@ -608,11 +984,19 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
<div className="ps-ai-empty">
|
<div className="ps-ai-empty">
|
||||||
<div style={{ fontSize: 24, marginBottom: 6 }}>✨</div>
|
<div style={{ fontSize: 24, marginBottom: 6 }}>✨</div>
|
||||||
<div style={{ fontWeight: 600, color: "var(--ps-text)" }}>
|
<div style={{ fontWeight: 600, color: "var(--ps-text)" }}>
|
||||||
{activeAgent.name} Ready
|
{activeAgent.name} Ready ({chapterLines.length} lines in chapter)
|
||||||
</div>
|
</div>
|
||||||
<p style={{ margin: "4px 0 0", fontSize: 12 }}>
|
<p style={{ margin: "6px 0 12px", fontSize: 12 }}>
|
||||||
Ask {activeAgent.name} to critique your chapter, brainstorm plot turns, sharpen dialogue, or check canon consistency.
|
Prompts include 1-based line numbers (<code>[Line 1]</code>–<code>[Line {Math.max(1, chapterLines.length)}]</code>). Ask about any line or generate line-by-line suggestions.
|
||||||
</p>
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleGenerateLineSuggestions()}
|
||||||
|
disabled={sending}
|
||||||
|
>
|
||||||
|
✨ Suggest Line Edits for Chapter
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
messages.map((m) => (
|
messages.map((m) => (
|
||||||
@@ -623,7 +1007,9 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
<div className="ps-ai-msg-role">
|
<div className="ps-ai-msg-role">
|
||||||
{m.role === "user" ? "You" : activeAgent.name}
|
{m.role === "user" ? "You" : activeAgent.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="ps-ai-msg-text">{m.content}</div>
|
<div className="ps-ai-msg-text">
|
||||||
|
{renderMessageWithLineLinks(m.content)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -633,7 +1019,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
<textarea
|
<textarea
|
||||||
className="ps-input ps-ai-input"
|
className="ps-input ps-ai-input"
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder={`Message ${activeAgent.name} (${activeModel})…`}
|
placeholder={`Ask ${activeAgent.name} about Line 1–${Math.max(1, chapterLines.length)} or request edits…`}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -643,17 +1029,28 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8 }}>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
|
||||||
{activeSession && (
|
<div style={{ display: "flex", gap: 4 }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ps-btn ps-btn-ghost ps-btn-sm"
|
className="ps-btn ps-btn-secondary ps-btn-sm"
|
||||||
onClick={() => void handleToggleArchiveSession(activeSession)}
|
onClick={() => void handleGenerateLineSuggestions()}
|
||||||
title={activeSession.archived ? "Restore this chat from archive" : "Archive this chat"}
|
disabled={sending}
|
||||||
|
title="Analyze current chapter lines and populate Suggestions tab"
|
||||||
>
|
>
|
||||||
{activeSession.archived ? "📤 Unarchive" : "📦 Archive chat"}
|
✨ Suggest Line Edits
|
||||||
</button>
|
</button>
|
||||||
)}
|
{activeSession && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ps-btn ps-btn-ghost ps-btn-sm"
|
||||||
|
onClick={() => void handleToggleArchiveSession(activeSession)}
|
||||||
|
title={activeSession.archived ? "Restore this chat from archive" : "Archive this chat"}
|
||||||
|
>
|
||||||
|
{activeSession.archived ? "📤" : "📦"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -667,59 +1064,178 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* SUGGESTIONS TAB: Step through each line suggestion with Accept / Deny / Copy to Note */}
|
||||||
{activeTab === "suggestions" && (
|
{activeTab === "suggestions" && (
|
||||||
<div className="ps-ai-messages">
|
<div className="ps-ai-chat-body">
|
||||||
{suggestions.length === 0 ? (
|
{pendingSuggestions.length > 0 && (
|
||||||
<div className="ps-ai-empty">
|
<div className="ps-ai-stepper">
|
||||||
No inline suggestions in this session yet.
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ps-btn ps-btn-secondary ps-btn-sm"
|
||||||
|
disabled={focusedIndex <= 0}
|
||||||
|
onClick={() => {
|
||||||
|
const nextIdx = Math.max(0, focusedIndex - 1);
|
||||||
|
setFocusedIndex(nextIdx);
|
||||||
|
const target = pendingSuggestions[nextIdx];
|
||||||
|
if (target?.lineNumber) setHighlightedLine(target.lineNumber);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
◀ Prev
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600 }}>
|
||||||
|
Suggestion {focusedIndex + 1} of {pendingSuggestions.length}
|
||||||
|
{focusedSuggestion?.lineNumber ? ` · Line ${focusedSuggestion.lineNumber}` : ""}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ps-btn ps-btn-secondary ps-btn-sm"
|
||||||
|
disabled={focusedIndex >= pendingSuggestions.length - 1}
|
||||||
|
onClick={() => {
|
||||||
|
const nextIdx = Math.min(pendingSuggestions.length - 1, focusedIndex + 1);
|
||||||
|
setFocusedIndex(nextIdx);
|
||||||
|
const target = pendingSuggestions[nextIdx];
|
||||||
|
if (target?.lineNumber) setHighlightedLine(target.lineNumber);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next ▶
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
suggestions.map((sg) => (
|
|
||||||
<div key={sg.id} className="ps-ai-card">
|
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 4 }}>
|
|
||||||
<Badge variant="accent">{sg.kind}</Badge>
|
|
||||||
<Badge variant="neutral">{sg.status}</Badge>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{sg.title}</div>
|
|
||||||
<p style={{ fontSize: 12, color: "var(--ps-text-muted)", margin: "4px 0" }}>
|
|
||||||
{sg.summary}
|
|
||||||
</p>
|
|
||||||
{sg.status === "pending" && (
|
|
||||||
<div style={{ display: "flex", gap: 6, marginTop: 8 }}>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void handleResolveSuggestion(sg.id, true)}
|
|
||||||
>
|
|
||||||
Accept
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void handleResolveSuggestion(sg.id, false)}
|
|
||||||
>
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="ps-ai-messages">
|
||||||
|
{pendingSuggestions.length === 0 ? (
|
||||||
|
<div className="ps-ai-empty">
|
||||||
|
<div style={{ marginBottom: 8 }}>No pending line suggestions yet.</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleGenerateLineSuggestions()}
|
||||||
|
disabled={sending}
|
||||||
|
>
|
||||||
|
{sending ? "Analyzing Lines…" : "✨ Generate Line Suggestions"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
pendingSuggestions.map((sg, idx) => {
|
||||||
|
const isFocused = idx === focusedIndex;
|
||||||
|
const isLineHighlighted =
|
||||||
|
sg.lineNumber != null && highlightedLine === sg.lineNumber;
|
||||||
|
const currentReplacement =
|
||||||
|
draftEdits[sg.id] ?? sg.newText ?? sg.text;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={sg.id}
|
||||||
|
className={`ps-ai-card ps-ai-suggestion-card${isFocused ? " is-focused" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setFocusedIndex(idx);
|
||||||
|
if (sg.lineNumber) setHighlightedLine(sg.lineNumber);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 6, marginBottom: 6 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ps-ai-line-pill${isLineHighlighted ? " is-active" : ""}`}
|
||||||
|
title="Click to highlight this line on the writing screen"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleHighlightLine(sg.lineNumber ?? 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Line {sg.lineNumber ?? 1}
|
||||||
|
</button>
|
||||||
|
<strong style={{ fontSize: 12.5 }}>{sg.title}</strong>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--ps-text-muted)" }}>
|
||||||
|
#{idx + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ fontSize: 12, color: "var(--ps-text-muted)", margin: "0 0 8px" }}>
|
||||||
|
{sg.summary}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{sg.originalText && (
|
||||||
|
<div className="ps-ai-diff-original">
|
||||||
|
<span className="ps-ai-diff-label">Original (Line {sg.lineNumber ?? 1}):</span>
|
||||||
|
<div>{sg.originalText}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ps-ai-diff-new">
|
||||||
|
<span className="ps-ai-diff-label">Suggested Edit:</span>
|
||||||
|
<textarea
|
||||||
|
className="ps-input ps-ai-input"
|
||||||
|
rows={3}
|
||||||
|
value={currentReplacement}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraftEdits((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[sg.id]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="ps-ai-suggestion-actions"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
title="Apply this edit directly to the line on the writing screen"
|
||||||
|
onClick={() => void handleAcceptSuggestion(sg)}
|
||||||
|
>
|
||||||
|
✓ Accept
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
title="Remove this suggestion"
|
||||||
|
onClick={() => void handleDenySuggestion(sg)}
|
||||||
|
>
|
||||||
|
✕ Deny
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
title="Copy this suggestion to the Notes section of the chat"
|
||||||
|
onClick={() => void handleCopySuggestionToNote(sg)}
|
||||||
|
>
|
||||||
|
📋 Copy to Note
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* NOTES TAB */}
|
||||||
{activeTab === "notes" && (
|
{activeTab === "notes" && (
|
||||||
<div className="ps-ai-chat-body">
|
<div className="ps-ai-chat-body">
|
||||||
<div className="ps-ai-messages">
|
<div className="ps-ai-messages">
|
||||||
{notes.length === 0 ? (
|
{notes.length === 0 ? (
|
||||||
<div className="ps-ai-empty">No session notes yet.</div>
|
<div className="ps-ai-empty">
|
||||||
|
No session notes yet. Click “Copy to Note” on any suggestion or add a note below.
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
notes.map((n) => (
|
notes.map((n) => (
|
||||||
<div key={n.id} className="ps-ai-card">
|
<div key={n.id} className="ps-ai-card">
|
||||||
<Badge variant="neutral">{n.kind}</Badge>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 4 }}>
|
||||||
<p style={{ margin: "6px 0 0", fontSize: 13, whiteSpace: "pre-wrap" }}>
|
<Badge variant="neutral">{n.kind}</Badge>
|
||||||
{n.body}
|
{n.title && (
|
||||||
</p>
|
<strong style={{ fontSize: 12 }}>{n.title}</strong>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ margin: "6px 0 0", fontSize: 13, whiteSpace: "pre-wrap" }}>
|
||||||
|
{renderMessageWithLineLinks(n.body)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ interface ProjectStore {
|
|||||||
activeBookId: string | null;
|
activeBookId: string | null;
|
||||||
chapters: Chapter[];
|
chapters: Chapter[];
|
||||||
activeChapterId: string | null;
|
activeChapterId: string | null;
|
||||||
|
highlightedLine: number | null;
|
||||||
|
docExternalRev: number;
|
||||||
status: "idle" | "loading" | "ready";
|
status: "idle" | "loading" | "ready";
|
||||||
error: string | null;
|
error: string | null;
|
||||||
autosave: AutosaveState;
|
autosave: AutosaveState;
|
||||||
@@ -109,6 +111,15 @@ interface ProjectStore {
|
|||||||
scheduleSave: (ref: DocumentRef) => void;
|
scheduleSave: (ref: DocumentRef) => void;
|
||||||
/** Flush any pending save immediately (beforeunload / chapter switch). */
|
/** Flush any pending save immediately (beforeunload / chapter switch). */
|
||||||
flushAutosave: () => void;
|
flushAutosave: () => void;
|
||||||
|
/** Highlight a 1-based line number on the writing surface (or null to clear). */
|
||||||
|
setHighlightedLine: (line: number | null) => void;
|
||||||
|
/** Apply an AI suggestion edit to a 1-based line number in the active chapter. */
|
||||||
|
applyLineEdit: (opts: {
|
||||||
|
chapterId?: string;
|
||||||
|
lineNumber?: number;
|
||||||
|
originalText?: string;
|
||||||
|
newText: string;
|
||||||
|
}) => Promise<boolean>;
|
||||||
|
|
||||||
// --- entity actions ---
|
// --- entity actions ---
|
||||||
loadEntities: (kind?: string) => Promise<void>;
|
loadEntities: (kind?: string) => Promise<void>;
|
||||||
@@ -192,6 +203,8 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
|||||||
activeBookId: null,
|
activeBookId: null,
|
||||||
chapters: [],
|
chapters: [],
|
||||||
activeChapterId: null,
|
activeChapterId: null,
|
||||||
|
highlightedLine: null,
|
||||||
|
docExternalRev: 0,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
error: null,
|
error: null,
|
||||||
autosave: { dirty: false, saving: false, lastSavedAt: null, status: "saved" },
|
autosave: { dirty: false, saving: false, lastSavedAt: null, status: "saved" },
|
||||||
@@ -524,6 +537,99 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setHighlightedLine: (line) => {
|
||||||
|
set({ highlightedLine: line });
|
||||||
|
},
|
||||||
|
|
||||||
|
applyLineEdit: async ({ chapterId, lineNumber, originalText, newText }) => {
|
||||||
|
const { adapter, project, chapters, activeChapterId } = get();
|
||||||
|
if (!adapter || !project) return false;
|
||||||
|
const targetId = chapterId ?? activeChapterId ?? chapters[0]?.id;
|
||||||
|
const targetChapter = chapters.find((c) => c.id === targetId);
|
||||||
|
if (!targetChapter) return false;
|
||||||
|
|
||||||
|
const blocks = Array.isArray(targetChapter.doc.content)
|
||||||
|
? targetChapter.doc.content.map((b) => ({ ...b }))
|
||||||
|
: [];
|
||||||
|
if (blocks.length === 0) return false;
|
||||||
|
|
||||||
|
const extractBlockText = (b: Record<string, unknown>): string => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const walk = (n: Record<string, unknown>) => {
|
||||||
|
if (typeof n.text === "string") parts.push(n.text);
|
||||||
|
if (Array.isArray(n.content)) {
|
||||||
|
for (const c of n.content) {
|
||||||
|
if (c && typeof c === "object") walk(c as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(b);
|
||||||
|
return parts.join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
let targetIndex =
|
||||||
|
lineNumber != null && lineNumber >= 1 && lineNumber <= blocks.length
|
||||||
|
? lineNumber - 1
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
if (originalText && originalText.trim()) {
|
||||||
|
const needle = originalText.trim();
|
||||||
|
if (
|
||||||
|
targetIndex === -1 ||
|
||||||
|
!extractBlockText(blocks[targetIndex]! as unknown as Record<string, unknown>).includes(needle)
|
||||||
|
) {
|
||||||
|
const foundIdx = blocks.findIndex((b) =>
|
||||||
|
extractBlockText(b as unknown as Record<string, unknown>).includes(needle),
|
||||||
|
);
|
||||||
|
if (foundIdx !== -1) targetIndex = foundIdx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetIndex === -1) {
|
||||||
|
targetIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldBlock = blocks[targetIndex]! as unknown as Record<string, unknown>;
|
||||||
|
const oldFullText = extractBlockText(oldBlock);
|
||||||
|
const updatedLineText =
|
||||||
|
originalText &&
|
||||||
|
originalText.trim() &&
|
||||||
|
oldFullText.includes(originalText.trim()) &&
|
||||||
|
oldFullText.trim() !== originalText.trim()
|
||||||
|
? oldFullText.replace(originalText.trim(), newText)
|
||||||
|
: newText;
|
||||||
|
|
||||||
|
blocks[targetIndex] = {
|
||||||
|
...blocks[targetIndex]!,
|
||||||
|
type: (oldBlock.type as string) || "paragraph",
|
||||||
|
content: [{ type: "text", text: updatedLineText }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextDoc = {
|
||||||
|
...targetChapter.doc,
|
||||||
|
content: blocks,
|
||||||
|
};
|
||||||
|
|
||||||
|
await adapter.saveDocument({
|
||||||
|
id: targetChapter.id,
|
||||||
|
projectId: project.id,
|
||||||
|
bookId: targetChapter.bookId,
|
||||||
|
title: targetChapter.title,
|
||||||
|
doc: nextDoc,
|
||||||
|
rev: targetChapter.rev,
|
||||||
|
});
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
activeChapterId: targetChapter.id,
|
||||||
|
highlightedLine: targetIndex + 1,
|
||||||
|
docExternalRev: state.docExternalRev + 1,
|
||||||
|
chapters: state.chapters.map((c) =>
|
||||||
|
c.id === targetChapter.id ? { ...c, doc: nextDoc, rev: c.rev + 1 } : c,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
loadEntities: async (kind) => {
|
loadEntities: async (kind) => {
|
||||||
const { adapter, project } = get();
|
const { adapter, project } = get();
|
||||||
if (!adapter || !project) return;
|
if (!adapter || !project) return;
|
||||||
|
|||||||
@@ -185,6 +185,9 @@ export const DEFAULT_AI_PROVIDERS: AIProviderSetting[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const LINE_SUGGESTION_PROMPT_RULES =
|
||||||
|
"IMPORTANT LINE-NUMBER PROTOCOL: The active chapter is provided with 1-based line numbers ([Line 1], [Line 2], …) matching the writing editor gutter. Always cite exact line numbers (e.g. [Line 2]) in your response. Whenever you suggest edits, rewrites, or improvements to specific lines, also emit one or more structured suggestion blocks at the end of your reply in this exact format so the author can highlight the line on screen and click Accept, Deny, or Copy to Note:\n<<<SUGGESTION\nLINE: <line_number>\nTITLE: <short label>\nORIGINAL: <exact original text of that line>\nREPLACEMENT: <your revised replacement text for that line>\nSUMMARY: <1-sentence rationale>\nSUGGESTION>>>";
|
||||||
|
|
||||||
export const DEFAULT_AI_AGENTS: AIAgentConfig[] = [
|
export const DEFAULT_AI_AGENTS: AIAgentConfig[] = [
|
||||||
{
|
{
|
||||||
id: "editor",
|
id: "editor",
|
||||||
@@ -195,8 +198,7 @@ export const DEFAULT_AI_AGENTS: AIAgentConfig[] = [
|
|||||||
maxTokens: 2048,
|
maxTokens: 2048,
|
||||||
thinkingEnabled: false,
|
thinkingEnabled: false,
|
||||||
thinkingEffort: "medium",
|
thinkingEffort: "medium",
|
||||||
systemPrompt:
|
systemPrompt: `You are the PubScript Line Editor. Tighten prose, eliminate filter words, sharpen dialogue tags, improve pacing, flag weak openings or closings, and verify consistency against character and world sheets while preserving the author's distinct voice.\n\n${LINE_SUGGESTION_PROMPT_RULES}`,
|
||||||
"You are the PubScript Line Editor. Tighten prose, eliminate filter words, sharpen dialogue tags, improve pacing, flag weak openings or closings, and verify consistency against character and world sheets. Always preserve the author's distinct voice and explain your rationale.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "reader",
|
id: "reader",
|
||||||
@@ -207,8 +209,7 @@ export const DEFAULT_AI_AGENTS: AIAgentConfig[] = [
|
|||||||
maxTokens: 2048,
|
maxTokens: 2048,
|
||||||
thinkingEnabled: false,
|
thinkingEnabled: false,
|
||||||
thinkingEffort: "medium",
|
thinkingEffort: "medium",
|
||||||
systemPrompt:
|
systemPrompt: `You are an honest, attentive first-time reader. Report what felt vivid, where your attention drifted, what was confusing, what was telegraphed too early, and what emotional beats paid off by citing exact [Line N] numbers.\n\n${LINE_SUGGESTION_PROMPT_RULES}`,
|
||||||
"You are an honest, attentive first-time reader. Report what felt vivid, where your attention drifted, what was confusing, what was telegraphed too early, and what emotional beats paid off. Speak as an engaged reader rather than using dry technical jargon.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "publisher",
|
id: "publisher",
|
||||||
@@ -219,8 +220,7 @@ export const DEFAULT_AI_AGENTS: AIAgentConfig[] = [
|
|||||||
maxTokens: 2048,
|
maxTokens: 2048,
|
||||||
thinkingEnabled: false,
|
thinkingEnabled: false,
|
||||||
thinkingEffort: "medium",
|
thinkingEffort: "medium",
|
||||||
systemPrompt:
|
systemPrompt: `You are a literary Publisher and developmental strategist. Evaluate commercial hook, genre pacing, comparable titles (comps), blurb/pitch angles, audience expectations, and structural stakes across acts, referencing specific [Line N] passages.\n\n${LINE_SUGGESTION_PROMPT_RULES}`,
|
||||||
"You are a literary Publisher and developmental strategist. Evaluate commercial hook, genre pacing, comparable titles (comps), blurb/pitch angles, audience expectations, and structural stakes across acts.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "co-author",
|
id: "co-author",
|
||||||
@@ -231,8 +231,7 @@ export const DEFAULT_AI_AGENTS: AIAgentConfig[] = [
|
|||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
thinkingEnabled: true,
|
thinkingEnabled: true,
|
||||||
thinkingEffort: "medium",
|
thinkingEffort: "medium",
|
||||||
systemPrompt:
|
systemPrompt: `You are a collaborative Co-Author with full knowledge of the manuscript's characters, world-building, and plot beats. Brainstorm plot turns, draft or revise specific [Line N] passages in the author's established voice, and expand world lore as adoptable line suggestions.\n\n${LINE_SUGGESTION_PROMPT_RULES}`,
|
||||||
"You are a collaborative Co-Author with full knowledge of the manuscript's characters, world-building, and plot beats. Brainstorm plot turns, draft scenes or dialogue in the author's established voice, and expand world lore—always as adoptable suggestions that respect the story's canon.",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -300,6 +299,18 @@ function loadSettings(): AppSettings {
|
|||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return { ...DEFAULT_SETTINGS };
|
if (!raw) return { ...DEFAULT_SETTINGS };
|
||||||
const parsed = JSON.parse(raw) as Partial<AppSettings>;
|
const parsed = JSON.parse(raw) as Partial<AppSettings>;
|
||||||
|
const rawAgents =
|
||||||
|
Array.isArray(parsed.aiAgents) && parsed.aiAgents.length > 0
|
||||||
|
? parsed.aiAgents
|
||||||
|
: DEFAULT_AI_AGENTS;
|
||||||
|
// Upgrade any stored built-in agent whose prompt predates the line-number protocol.
|
||||||
|
const aiAgents = rawAgents.map((agent) => {
|
||||||
|
const def = DEFAULT_AI_AGENTS.find((d) => d.id === agent.id);
|
||||||
|
if (def && !agent.systemPrompt.includes("LINE-NUMBER PROTOCOL") && !agent.systemPrompt.includes("<<<SUGGESTION")) {
|
||||||
|
return { ...agent, systemPrompt: def.systemPrompt };
|
||||||
|
}
|
||||||
|
return agent;
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
...parsed,
|
...parsed,
|
||||||
@@ -307,10 +318,7 @@ function loadSettings(): AppSettings {
|
|||||||
Array.isArray(parsed.aiProviders) && parsed.aiProviders.length > 0
|
Array.isArray(parsed.aiProviders) && parsed.aiProviders.length > 0
|
||||||
? parsed.aiProviders
|
? parsed.aiProviders
|
||||||
: DEFAULT_AI_PROVIDERS,
|
: DEFAULT_AI_PROVIDERS,
|
||||||
aiAgents:
|
aiAgents,
|
||||||
Array.isArray(parsed.aiAgents) && parsed.aiAgents.length > 0
|
|
||||||
? parsed.aiAgents
|
|
||||||
: DEFAULT_AI_AGENTS,
|
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { ...DEFAULT_SETTINGS };
|
return { ...DEFAULT_SETTINGS };
|
||||||
|
|||||||
@@ -246,6 +246,20 @@
|
|||||||
transition: opacity 0.08s ease;
|
transition: opacity 0.08s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* AI suggestion line highlight on the writing canvas */
|
||||||
|
.ProseMirror > .ps-ai-line-highlight {
|
||||||
|
background: color-mix(in srgb, var(--ps-accent) 20%, transparent);
|
||||||
|
outline: 2px solid var(--ps-accent);
|
||||||
|
outline-offset: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.2s ease, outline 0.2s ease;
|
||||||
|
}
|
||||||
|
.ps-editor-linenumbers .ProseMirror > .ps-ai-line-highlight::before {
|
||||||
|
color: var(--ps-accent);
|
||||||
|
font-weight: 700;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Status bar */
|
/* Status bar */
|
||||||
.ps-writing-status {
|
.ps-writing-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -318,6 +318,16 @@ export class AIStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove a suggestion from a session (used when denying or dismissing). */
|
||||||
|
async deleteSuggestion(sessionId: string, suggestionId: string): Promise<AISession> {
|
||||||
|
return this.withTransaction(async () => {
|
||||||
|
const session = this.require(sessionId);
|
||||||
|
session.suggestions = session.suggestions.filter((s) => s.id !== suggestionId);
|
||||||
|
session.updatedAt = nowIso();
|
||||||
|
return this.clone(session);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Add a note to a session and persist it. */
|
/** Add a note to a session and persist it. */
|
||||||
async addNote(sessionId: string, note: Omit<AINote, "id" | "createdAt" | "updatedAt">): Promise<AISession> {
|
async addNote(sessionId: string, note: Omit<AINote, "id" | "createdAt" | "updatedAt">): Promise<AISession> {
|
||||||
return this.withTransaction(async () => {
|
return this.withTransaction(async () => {
|
||||||
|
|||||||
@@ -299,14 +299,16 @@ export interface AISuggestion {
|
|||||||
title: string;
|
title: string;
|
||||||
text: string;
|
text: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
originalText?: string;
|
lineNumber?: number | undefined;
|
||||||
newText?: string;
|
chapterId?: string | undefined;
|
||||||
anchorIndex?: number;
|
originalText?: string | undefined;
|
||||||
spanLength?: number;
|
newText?: string | undefined;
|
||||||
|
anchorIndex?: number | undefined;
|
||||||
|
spanLength?: number | undefined;
|
||||||
status: AISuggestionStatus;
|
status: AISuggestionStatus;
|
||||||
appliedAt?: string;
|
appliedAt?: string | undefined;
|
||||||
resolvedAt?: string;
|
resolvedAt?: string | undefined;
|
||||||
resolution?: "accepted" | "rejected";
|
resolution?: "accepted" | "rejected" | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An AI-backed session with its messages, suggestions, and notes. */
|
/** An AI-backed session with its messages, suggestions, and notes. */
|
||||||
|
|||||||
@@ -143,27 +143,18 @@ body {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-sidebar-brand {
|
.ps-sidebar-logo-only {
|
||||||
display: flex;
|
width: 68px;
|
||||||
align-items: center;
|
height: 68px;
|
||||||
gap: 8px;
|
border-radius: 12px;
|
||||||
}
|
|
||||||
.ps-sidebar-logo-img {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.ps-sidebar-logo-lockup {
|
|
||||||
height: 36px;
|
|
||||||
width: auto;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
.ps-sidebar-logo-icon {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 7px;
|
|
||||||
display: block;
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: width 0.16s ease, height 0.16s ease;
|
||||||
|
}
|
||||||
|
.ps-sidebar.is-collapsed .ps-sidebar-logo-only {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-sidebar-toggle {
|
.ps-sidebar-toggle {
|
||||||
@@ -840,6 +831,83 @@ body {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
|
.ps-ai-suggestion-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.ps-ai-suggestion-card:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--ps-accent) 60%, var(--ps-border));
|
||||||
|
}
|
||||||
|
.ps-ai-suggestion-card.is-focused {
|
||||||
|
border-color: var(--ps-accent);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ps-accent) 28%, transparent);
|
||||||
|
}
|
||||||
|
.ps-ai-stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--ps-border);
|
||||||
|
background: var(--ps-surface-2);
|
||||||
|
}
|
||||||
|
.ps-ai-line-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
margin: 0 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--ps-accent) 50%, var(--ps-border));
|
||||||
|
background: color-mix(in srgb, var(--ps-accent) 16%, transparent);
|
||||||
|
color: var(--ps-accent);
|
||||||
|
font-family: var(--ps-font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.ps-ai-line-pill:hover,
|
||||||
|
.ps-ai-line-pill.is-active {
|
||||||
|
background: var(--ps-accent);
|
||||||
|
color: var(--ps-accent-contrast);
|
||||||
|
border-color: var(--ps-accent);
|
||||||
|
}
|
||||||
|
.ps-ai-diff-original {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--ps-danger) 12%, var(--ps-bg));
|
||||||
|
border-left: 3px solid var(--ps-danger);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--ps-text-muted);
|
||||||
|
}
|
||||||
|
.ps-ai-diff-new {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--ps-success) 10%, var(--ps-bg));
|
||||||
|
border-left: 3px solid var(--ps-success);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.ps-ai-diff-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--ps-text-muted);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.ps-ai-suggestion-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Projects Page & Template Wizard ---- */
|
/* ---- Projects Page & Template Wizard ---- */
|
||||||
.ps-projects-grid {
|
.ps-projects-grid {
|
||||||
|
|||||||
Reference in New Issue
Block a user