fix: resolve Gitea issues #1–#5 (mobile layout, suggestion refresh, import/export icons, context rail default/icon, manual save)
CI / build (push) Waiting to run
CI / build (push) Waiting to run
Closes #1 Closes #2 Closes #3 Closes #4 Closes #5
This commit is contained in:
+74
-3
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { Routes, Route, useLocation } from "react-router-dom";
|
||||
import { Sidebar, Topbar, CommandPalette, SplashScreen } from "./components/index.js";
|
||||
import { AuthGate } from "./components/AuthGate.js";
|
||||
import { ExportDialog } from "./features/export/ExportDialog.js";
|
||||
@@ -29,12 +29,49 @@ const NAV = [
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
const location = useLocation();
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const cookieMobile =
|
||||
typeof document !== "undefined" && document.cookie.includes("ps_client_device=mobile");
|
||||
const uaMobile =
|
||||
typeof navigator !== "undefined" &&
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
|
||||
navigator.userAgent,
|
||||
);
|
||||
return cookieMobile || uaMobile || window.innerWidth <= 768;
|
||||
});
|
||||
|
||||
const navCollapsed = useSettings((s) => s.navCollapsed);
|
||||
const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen);
|
||||
const focusMode = useSettings((s) => s.focusMode);
|
||||
const setSettings = useSettings((s) => s.set);
|
||||
|
||||
// Detect mobile viewport changes + server ps_client_device cookie
|
||||
useEffect(() => {
|
||||
const updateMobile = () => {
|
||||
const cookieMobile =
|
||||
typeof document !== "undefined" && document.cookie.includes("ps_client_device=mobile");
|
||||
const uaMobile =
|
||||
typeof navigator !== "undefined" &&
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
|
||||
navigator.userAgent,
|
||||
);
|
||||
setIsMobile(cookieMobile || uaMobile || window.innerWidth <= 768);
|
||||
};
|
||||
updateMobile();
|
||||
window.addEventListener("resize", updateMobile);
|
||||
return () => window.removeEventListener("resize", updateMobile);
|
||||
}, []);
|
||||
|
||||
// Automatically close the mobile navigation drawer when navigating to a route
|
||||
useEffect(() => {
|
||||
setMobileNavOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -49,13 +86,24 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<AuthGate>
|
||||
<div className={`ps-app${navCollapsed ? " is-nav-collapsed" : ""}${aiSidebarOpen ? " is-ai-open" : ""}${focusMode ? " is-focus-mode" : ""}`}>
|
||||
<div
|
||||
className={`ps-app${navCollapsed ? " is-nav-collapsed" : ""}${aiSidebarOpen ? " is-ai-open" : ""}${focusMode ? " is-focus-mode" : ""}${isMobile ? " is-mobile" : ""}${mobileNavOpen ? " is-mobile-nav-open" : ""}`}
|
||||
data-mobile={isMobile ? "true" : "false"}
|
||||
>
|
||||
<SplashScreen />
|
||||
{mobileNavOpen && (
|
||||
<div
|
||||
className="ps-mobile-nav-backdrop"
|
||||
onClick={() => setMobileNavOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<Sidebar nav={NAV} />
|
||||
<Topbar
|
||||
onOpenPalette={() => setPaletteOpen(true)}
|
||||
onOpenExport={() => setExportOpen(true)}
|
||||
onOpenImport={() => setImportOpen(true)}
|
||||
onToggleMobileNav={() => setMobileNavOpen((o) => !o)}
|
||||
/>
|
||||
<div className="ps-workspace-row">
|
||||
<main className="ps-main">
|
||||
@@ -72,9 +120,32 @@ export default function App() {
|
||||
<Route path="*" element={<Write />} />
|
||||
</Routes>
|
||||
</main>
|
||||
{aiSidebarOpen && <AISidebar />}
|
||||
{aiSidebarOpen && (
|
||||
<>
|
||||
<div
|
||||
className="ps-mobile-ai-backdrop"
|
||||
onClick={() => setSettings({ aiSidebarOpen: false })}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<AISidebar />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dedicated bottom-of-screen AI home bar for mobile devices */}
|
||||
{isMobile && !aiSidebarOpen && (
|
||||
<div className="ps-mobile-ai-bar">
|
||||
<button
|
||||
type="button"
|
||||
className="ps-mobile-ai-bar-btn"
|
||||
onClick={() => setSettings({ aiSidebarOpen: true })}
|
||||
>
|
||||
<span>✨ AI Studio · Chat, Inline Edits & Notes</span>
|
||||
<span style={{ fontSize: 11, opacity: 0.85 }}>▲ Open</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} nav={NAV} />
|
||||
<ImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
|
||||
<ExportDialog open={exportOpen} onClose={() => setExportOpen(false)} />
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useLocation, NavLink, useNavigate } from "react-router-dom";
|
||||
import { useSettings } from "../store/settings.js";
|
||||
import { useProjectStore } from "../store/project-store.js";
|
||||
import { useAuth } from "./AuthGate.js";
|
||||
import { Badge } from "@pubscript/ui";
|
||||
import { Badge, toast } from "@pubscript/ui";
|
||||
import type { Entity } from "@pubscript/core";
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
@@ -22,6 +22,7 @@ export interface TopbarProps {
|
||||
onOpenPalette?: () => void;
|
||||
onOpenExport: () => void;
|
||||
onOpenImport?: () => void;
|
||||
onToggleMobileNav?: () => void;
|
||||
}
|
||||
|
||||
function entityName(e: Entity): string {
|
||||
@@ -29,7 +30,7 @@ function entityName(e: Entity): string {
|
||||
return (e.body || "Untitled note").slice(0, 48);
|
||||
}
|
||||
|
||||
export function Topbar({ onOpenExport, onOpenImport }: TopbarProps) {
|
||||
export function Topbar({ onOpenExport, onOpenImport, onToggleMobileNav }: TopbarProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuth();
|
||||
@@ -41,6 +42,8 @@ export function Topbar({ onOpenExport, onOpenImport }: TopbarProps) {
|
||||
const selectChapter = useProjectStore((s) => s.selectChapter);
|
||||
const entities = useProjectStore((s) => s.entities);
|
||||
const plotBeats = useProjectStore((s) => s.plotBeats);
|
||||
const autosave = useProjectStore((s) => s.autosave);
|
||||
const saveChapterNow = useProjectStore((s) => s.saveChapterNow);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchFocused, setSearchFocused] = useState(false);
|
||||
@@ -109,13 +112,64 @@ export function Topbar({ onOpenExport, onOpenImport }: TopbarProps) {
|
||||
return items.slice(0, 10);
|
||||
}, [searchQuery, chapters, entities, plotBeats, selectChapter, navigate]);
|
||||
|
||||
const handleManualSave = async () => {
|
||||
await saveChapterNow();
|
||||
toast.success("Chapter saved");
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="ps-topbar">
|
||||
{onToggleMobileNav && (
|
||||
<button
|
||||
type="button"
|
||||
className="ps-icon-btn ps-mobile-nav-btn"
|
||||
onClick={onToggleMobileNav}
|
||||
aria-label="Toggle Navigation Menu"
|
||||
title="Navigation Menu"
|
||||
>
|
||||
<svg viewBox="0 0 18 18" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<line x1="2.5" y1="4.5" x2="15.5" y2="4.5" strokeLinecap="round" />
|
||||
<line x1="2.5" y1="9" x2="15.5" y2="9" strokeLinecap="round" />
|
||||
<line x1="2.5" y1="13.5" x2="15.5" y2="13.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<h1 style={{ margin: 0, fontSize: 15, fontWeight: 600, display: "inline-flex", verticalAlign: "middle" }}>
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{autoSave && <Badge variant="success">✓ autosave on</Badge>}
|
||||
{autoSave ? (
|
||||
<Badge variant="success">✓ autosave on</Badge>
|
||||
) : (
|
||||
<div style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`ps-nav-item${autosave.dirty ? " is-active" : ""}`}
|
||||
onClick={() => void handleManualSave()}
|
||||
disabled={autosave.saving}
|
||||
style={{
|
||||
width: "auto",
|
||||
padding: "4px 10px",
|
||||
fontSize: 12.5,
|
||||
fontWeight: 600,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
}}
|
||||
title="Manual Save (Ctrl+S)"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||
<path d="M3 2.5h8.2L13.5 4.8v8.2a.5.5 0 0 1-.5.5H3a.5.5 0 0 1-.5-.5V3a.5.5 0 0 1 .5-.5z" strokeLinejoin="round" />
|
||||
<path d="M5 2.5v3.5h5V2.5M4.5 13.5V9h7v4.5" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span>{autosave.saving ? "Saving…" : autosave.dirty ? "Save *" : "Save"}</span>
|
||||
</button>
|
||||
<Badge variant={autosave.dirty ? "warning" : "neutral"}>
|
||||
{autosave.dirty ? "unsaved" : "saved"}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
@@ -180,22 +234,29 @@ export function Topbar({ onOpenExport, onOpenImport }: TopbarProps) {
|
||||
{onOpenImport && (
|
||||
<button
|
||||
type="button"
|
||||
className="ps-nav-item"
|
||||
className="ps-icon-btn"
|
||||
onClick={onOpenImport}
|
||||
style={{ width: "auto", padding: "6px 10px" }}
|
||||
aria-label="Import manuscript"
|
||||
title="Import manuscript (.docx, .odt, .epub, .md, .txt, .html, .rtf, .json)"
|
||||
>
|
||||
<span>Import</span>
|
||||
<svg viewBox="0 0 18 18" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.65">
|
||||
<path d="M9 2.5v8.5M5.8 7.8L9 11l3.2-3.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M3 11.5v2.2a1.3 1.3 0 0 0 1.3 1.3h9.4a1.3 1.3 0 0 0 1.3-1.3v-2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="ps-nav-item"
|
||||
className="ps-icon-btn"
|
||||
onClick={onOpenExport}
|
||||
style={{ width: "auto", padding: "6px 10px" }}
|
||||
aria-label="Export manuscript"
|
||||
title="Export manuscript (.epub, .docx, .odt, .pdf, .html, .md)"
|
||||
>
|
||||
<span>Export</span>
|
||||
<svg viewBox="0 0 18 18" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.65">
|
||||
<path d="M9 11V2.5M5.8 5.7L9 2.5l3.2 3.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M3 11.5v2.2a1.3 1.3 0 0 0 1.3 1.3h9.4a1.3 1.3 0 0 0 1.3-1.3v-2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<NavLink to="/settings" className="ps-icon-btn" style={{ fontSize: 18 }} aria-label="Settings" title="Settings">
|
||||
|
||||
@@ -76,6 +76,7 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
||||
const renameChapter = useProjectStore((s) => s.renameChapter);
|
||||
const deleteChapter = useProjectStore((s) => s.deleteChapter);
|
||||
const scheduleSave = useProjectStore((s) => s.scheduleSave);
|
||||
const saveChapterNow = useProjectStore((s) => s.saveChapterNow);
|
||||
const flushAutosave = useProjectStore((s) => s.flushAutosave);
|
||||
const autosave = useProjectStore((s) => s.autosave);
|
||||
const entities = useProjectStore((s) => s.entities);
|
||||
@@ -84,6 +85,7 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
||||
const highlightSeq = useProjectStore((s) => s.highlightSeq);
|
||||
const docExternalRev = useProjectStore((s) => s.docExternalRev);
|
||||
|
||||
const autoSave = useSettings((st) => st.autoSave);
|
||||
const fontFamily = useSettings((st) => st.fontFamily);
|
||||
const fontSize = useSettings((st) => st.fontSize);
|
||||
const leading = useSettings((st) => st.leading);
|
||||
@@ -93,7 +95,10 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
||||
const setSettings = useSettings((st) => st.set);
|
||||
|
||||
const [firstLineIndent, setFirstLineIndent] = useState(false);
|
||||
const [showContextRail, setShowContextRail] = useState(true);
|
||||
const [showContextRail, setShowContextRail] = useState(false);
|
||||
const [formattingOpen, setFormattingOpen] = useState(() =>
|
||||
typeof window !== "undefined" ? window.innerWidth > 768 : true,
|
||||
);
|
||||
const [renamingChapter, setRenamingChapter] = useState(false);
|
||||
const [chapterTitleDraft, setChapterTitleDraft] = useState("");
|
||||
const [resolvedDomChildIdx, setResolvedDomChildIdx] = useState<number | null>(null);
|
||||
@@ -253,19 +258,22 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
||||
return clearCustomHighlight;
|
||||
}, [editor, highlightedText, highlightedLine, highlightSeq, docExternalRev, chapter?.id]);
|
||||
|
||||
// Keyboard shortcut: F2 toggles focus mode.
|
||||
// Keyboard shortcuts: F2 toggles focus mode; Ctrl+S / Cmd+S saves chapter immediately.
|
||||
useEffect(() => {
|
||||
const dom = editorDomRef.current;
|
||||
if (!dom) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "F2") {
|
||||
e.preventDefault();
|
||||
setSettings({ focusMode: !focusMode });
|
||||
} else if ((e.metaKey || e.ctrlKey) && typeof e.key === "string" && e.key.toLowerCase() === "s") {
|
||||
e.preventDefault();
|
||||
void saveChapterNow().then(() => {
|
||||
toast.success("Chapter saved");
|
||||
});
|
||||
}
|
||||
};
|
||||
dom.addEventListener("keydown", onKey);
|
||||
return () => dom.removeEventListener("keydown", onKey);
|
||||
}, [focusMode, setSettings]);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [focusMode, setSettings, saveChapterNow]);
|
||||
|
||||
// Flush pending edits when the user closes or navigates away.
|
||||
useEffect(() => {
|
||||
@@ -421,12 +429,41 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`ps-tool-icon${!autoSave && autosave.dirty ? " is-active" : ""}`}
|
||||
title="Save Chapter Now (Ctrl+S)"
|
||||
aria-label="Save Chapter Now"
|
||||
onClick={() => {
|
||||
void saveChapterNow().then(() => {
|
||||
toast.success("Chapter saved");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 2.5h8.2L13.5 4.8v8.2a.5.5 0 0 1-.5.5H3a.5.5 0 0 1-.5-.5V3a.5.5 0 0 1 .5-.5z" strokeLinejoin="round" />
|
||||
<path d="M5 2.5v3.5h5V2.5M4.5 13.5V9h7v4.5" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ps-tool-icon ps-formatting-toggle-btn${formattingOpen ? " is-active" : ""}`}
|
||||
title={formattingOpen ? "Minimize Formatting Tools" : "Show Formatting Tools"}
|
||||
aria-label="Toggle Formatting Tools"
|
||||
aria-expanded={formattingOpen}
|
||||
onClick={() => setFormattingOpen((o) => !o)}
|
||||
>
|
||||
<span style={{ fontSize: 11.5, fontWeight: 700, letterSpacing: "-0.02em" }}>
|
||||
Aa {formattingOpen ? "▴" : "▾"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="ps-toolbar-sep" />
|
||||
<div className={`ps-toolbar-collapsible${formattingOpen ? " is-open" : " is-minimized"}`}>
|
||||
<span className="ps-toolbar-sep" />
|
||||
|
||||
{/* History: Undo / Redo */}
|
||||
<div className="ps-toolbar-group">
|
||||
{/* History: Undo / Redo */}
|
||||
<div className="ps-toolbar-group">
|
||||
<button
|
||||
type="button"
|
||||
className="ps-tool-icon"
|
||||
@@ -861,17 +898,22 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
||||
<path d="M3 10l2.5-6L8 10M3.8 8.2h3.4M9.5 10.5l2 2 3-3.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ps-toolbar-group" style={{ marginLeft: "auto" }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`ps-tool-icon${showContextRail ? " is-active" : ""}`}
|
||||
title="Toggle Story Context Rail"
|
||||
title="Toggle Story Context Rail (Info)"
|
||||
aria-label="Toggle Story Context Rail"
|
||||
aria-pressed={showContextRail}
|
||||
onClick={() => setShowContextRail((r) => !r)}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="2" y="2.5" width="12" height="11" rx="1.5" />
|
||||
<line x1="10" y1="2.5" x2="10" y2="13.5" />
|
||||
<circle cx="8" cy="8" r="6" />
|
||||
<line x1="8" y1="7.2" x2="8" y2="11.2" strokeLinecap="round" />
|
||||
<circle cx="8" cy="4.8" r="0.85" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -879,9 +921,16 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
||||
<div
|
||||
className="ps-autosave-indicator"
|
||||
data-status={autosave.saving ? "saving" : autosave.dirty ? "dirty" : "saved"}
|
||||
onClick={() => {
|
||||
if (!autoSave && autosave.dirty) {
|
||||
void saveChapterNow().then(() => toast.success("Chapter saved"));
|
||||
}
|
||||
}}
|
||||
style={!autoSave && autosave.dirty ? { cursor: "pointer" } : undefined}
|
||||
title={!autoSave ? "Click to save manually (Ctrl+S)" : undefined}
|
||||
>
|
||||
<span className="ps-autosave-dot" />
|
||||
<span>{autosaveLabel(autosave)}</span>
|
||||
<span>{!autoSave && autosave.dirty ? "Unsaved · Click to save" : autosaveLabel(autosave)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -924,11 +973,18 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
||||
</div>
|
||||
|
||||
{showContextRail && (
|
||||
<ContextRail
|
||||
chapter={chapter}
|
||||
entities={entities}
|
||||
onOpenEntity={(id) => navigate(`/characters?entity=${encodeURIComponent(id)}`)}
|
||||
/>
|
||||
<>
|
||||
<div
|
||||
className="ps-mobile-rail-backdrop"
|
||||
onClick={() => setShowContextRail(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ContextRail
|
||||
chapter={chapter}
|
||||
entities={entities}
|
||||
onOpenEntity={(id) => navigate(`/characters?entity=${encodeURIComponent(id)}`)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{check.open && (
|
||||
|
||||
@@ -113,55 +113,90 @@ function parseSuggestionBlocks(
|
||||
chapterLines: ChapterLine[],
|
||||
): { cleanText: string; parsed: ParsedLineSuggestion[] } {
|
||||
const parsed: ParsedLineSuggestion[] = [];
|
||||
const blockRegex = /<<<SUGGESTION([\s\S]*?)SUGGESTION>>>/gi;
|
||||
const norm = (s: string) =>
|
||||
s.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'").toLowerCase();
|
||||
|
||||
const cleanText = rawText
|
||||
const addParsed = (rawOrig: string, newText: string, title?: string, summary?: string) => {
|
||||
const cleanOrig = rawOrig.replace(/^["“'`*]+|["”'`*]+$/g, "").trim();
|
||||
const cleanNew = newText.replace(/^["“'`*]+|["”'`*]+$/g, "").trim();
|
||||
if (!cleanOrig || !cleanNew || cleanOrig === cleanNew) return;
|
||||
if (parsed.some((p) => norm(p.originalText) === norm(cleanOrig))) return;
|
||||
|
||||
const matchedLine =
|
||||
chapterLines.find((l) => norm(l.text).includes(norm(cleanOrig))) ??
|
||||
chapterLines[0];
|
||||
const lineNumber = matchedLine?.lineNumber ?? 1;
|
||||
|
||||
parsed.push({
|
||||
lineNumber,
|
||||
kind: "review",
|
||||
title: title?.replace(/^\*+|\*+$/g, "").trim() || "Granular Prose Edit",
|
||||
originalText: cleanOrig,
|
||||
newText: cleanNew,
|
||||
summary:
|
||||
summary?.replace(/^\*+|\*+$/g, "").trim() ||
|
||||
"Targeted phrase-level revision pegged to manuscript text.",
|
||||
});
|
||||
};
|
||||
|
||||
// 1. Standard <<<SUGGESTION ... SUGGESTION>>> or <<<SUGGESTION ... >>> blocks
|
||||
const blockRegex = /<<<SUGGESTION\b([\s\S]*?)(?:SUGGESTION>>>|>>>)/gi;
|
||||
let cleanText = rawText
|
||||
.replace(blockRegex, (_match, body: string) => {
|
||||
const getField = (name: string): string => {
|
||||
const m = body.match(new RegExp(`^\\s*${name}\\s*:\\s*(.+)$`, "im"));
|
||||
const m = body.match(new RegExp(`^\\s*[*_]*${name}[*_]*\\s*:\\s*(.+)$`, "im"));
|
||||
return m?.[1]?.trim() ?? "";
|
||||
};
|
||||
const rawOrig = getField("ORIGINAL").replace(/^["“]|["”]$/g, "").trim();
|
||||
const newText = (getField("REPLACEMENT") || getField("NEW"))
|
||||
.replace(/^["“]|["”]$/g, "")
|
||||
.trim();
|
||||
if (!rawOrig || !newText) return "";
|
||||
|
||||
const matchedLine =
|
||||
chapterLines.find((l) => norm(l.text).includes(norm(rawOrig))) ??
|
||||
chapterLines[0];
|
||||
const lineNumber = matchedLine?.lineNumber ?? 1;
|
||||
const title = getField("TITLE") || "Granular Prose Edit";
|
||||
const summary =
|
||||
getField("SUMMARY") || "Targeted phrase-level revision pegged to manuscript text.";
|
||||
|
||||
parsed.push({
|
||||
lineNumber,
|
||||
kind: "review",
|
||||
title,
|
||||
originalText: rawOrig,
|
||||
newText,
|
||||
summary,
|
||||
});
|
||||
const rawOrig = getField("ORIGINAL");
|
||||
const newText = getField("REPLACEMENT") || getField("NEW") || getField("SUGGESTED");
|
||||
const title = getField("TITLE");
|
||||
const summary = getField("SUMMARY") || getField("RATIONALE") || getField("REASON");
|
||||
addParsed(rawOrig, newText, title, summary);
|
||||
return "";
|
||||
})
|
||||
.trim();
|
||||
|
||||
// 2. Fallback parser for models that emit ORIGINAL: ... / REPLACEMENT: ... without <<<SUGGESTION delimiters
|
||||
if (parsed.length === 0) {
|
||||
const looseRegex =
|
||||
/(?:^|\n)\s*(?:[-*•]|\d+\.)?\s*[*_]*(?:TITLE\s*:\s*([^\n]+)\n\s*)?[*_]*ORIGINAL[*_]*\s*:\s*["“]?([^\n"”]+)["”]?\s*\n\s*[*_]*(?:REPLACEMENT|NEW|SUGGESTED)[*_]*\s*:\s*["“]?([^\n"”]+)["”]?(?:\s*\n\s*[*_]*(?:SUMMARY|RATIONALE|REASON)[*_]*\s*:\s*([^\n]+))?/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = looseRegex.exec(rawText)) !== null) {
|
||||
addParsed(m[2] ?? "", m[3] ?? "", m[1], m[4]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback parser for models that emit `"original phrase" -> "replacement phrase"`
|
||||
if (parsed.length === 0) {
|
||||
const arrowRegex = /["“]([^"”\n]{10,160})["”]\s*(?:->|→|=>)\s*["“]([^"”\n]{4,200})["”]/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = arrowRegex.exec(rawText)) !== null) {
|
||||
const candidateOrig = (m[1] ?? "").trim();
|
||||
if (chapterLines.some((l) => norm(l.text).includes(norm(candidateOrig)))) {
|
||||
addParsed(candidateOrig, m[2] ?? "", "Inline Phrase Revision");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!cleanText && parsed.length > 0) {
|
||||
cleanText = `Generated ${parsed.length} inline edit suggestion${parsed.length === 1 ? "" : "s"} in the Suggestions tab.`;
|
||||
}
|
||||
|
||||
return { cleanText, parsed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract granular phrase, clause, and single-sentence edit candidates from
|
||||
* the chapter's non-empty paragraphs so suggestions are surgical and never
|
||||
* overwrite entire multi-sentence paragraphs.
|
||||
* the chapter's non-empty paragraphs so offline/test suggestions are surgical,
|
||||
* prompt-sensitive, and never repeat static clichés.
|
||||
*/
|
||||
function buildGranularSuggestionsFromLines(
|
||||
nonEmptyLines: ChapterLine[],
|
||||
role: string,
|
||||
userPrompt = "",
|
||||
): ParsedLineSuggestion[] {
|
||||
const results: ParsedLineSuggestion[] = [];
|
||||
const promptLower = userPrompt.toLowerCase();
|
||||
|
||||
const rules: Array<{
|
||||
pattern: RegExp;
|
||||
@@ -172,7 +207,10 @@ function buildGranularSuggestionsFromLines(
|
||||
{
|
||||
pattern: /left the mainland before dawn,\s*its iron hull shuddering against the black chop/i,
|
||||
title: "Tighten Opening Clause",
|
||||
replace: () => "cleared the mainland before dawn, iron hull shuddering through the black chop",
|
||||
replace: () =>
|
||||
promptLower.includes("pacing") || role === "publisher"
|
||||
? "cleared the mainland before dawn, iron hull driving hard into the black chop"
|
||||
: "cleared the mainland before dawn, iron hull shuddering through the black chop",
|
||||
summary: "Replace generic 'left' with active 'cleared' and tighten the participial phrase.",
|
||||
},
|
||||
{
|
||||
@@ -227,56 +265,46 @@ function buildGranularSuggestionsFromLines(
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length < 4) {
|
||||
for (const line of nonEmptyLines) {
|
||||
for (const line of nonEmptyLines) {
|
||||
if (results.length >= 5) break;
|
||||
const sentences = line.text
|
||||
.split(/(?<=[.!?])\s+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length >= 12);
|
||||
|
||||
for (const sentence of sentences) {
|
||||
if (results.length >= 5) break;
|
||||
const sentences = line.text
|
||||
.split(/(?<=[.!?])\s+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length >= 12);
|
||||
if (results.some((r) => r.originalText.includes(sentence) || sentence.includes(r.originalText))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const sentence of sentences) {
|
||||
if (results.length >= 5) break;
|
||||
if (results.some((r) => r.originalText.includes(sentence) || sentence.includes(r.originalText))) {
|
||||
continue;
|
||||
}
|
||||
const clauseMatch = sentence.match(/^([^,]{18,85}),\s+(.{12,})$/);
|
||||
const targetSpan = clauseMatch ? clauseMatch[1]!.trim() : sentence;
|
||||
|
||||
const clauseMatch = sentence.match(/^([^,]{18,85}),\s+(.{12,})$/);
|
||||
const targetSpan = clauseMatch ? clauseMatch[1]!.trim() : sentence;
|
||||
|
||||
let revisedSpan = targetSpan
|
||||
.replace(/\bseemed to\s+/gi, "")
|
||||
.replace(/\bstarted to\s+/gi, "")
|
||||
.replace(/\bbegan to\s+/gi, "")
|
||||
.replace(/\bwatched the\b/gi, "saw the")
|
||||
.replace(/\bfelt the\b/gi, "knew the")
|
||||
.replace(/\bvery\s+/gi, "")
|
||||
.trim();
|
||||
|
||||
if (revisedSpan === targetSpan) {
|
||||
if (targetSpan.endsWith(".")) {
|
||||
revisedSpan =
|
||||
role === "reader"
|
||||
? `${targetSpan.slice(0, -1)}—and the silence that followed held its breath.`
|
||||
: role === "publisher"
|
||||
? `${targetSpan.slice(0, -1)}, raising the stakes of what waited ahead.`
|
||||
: `${targetSpan.slice(0, -1)}—sharp and unmistakable.`;
|
||||
} else {
|
||||
revisedSpan = `${targetSpan}—clear and unsparing`;
|
||||
}
|
||||
}
|
||||
const revisedSpan = targetSpan
|
||||
.replace(/\bseemed to\s+/gi, "")
|
||||
.replace(/\bstarted to\s+/gi, "")
|
||||
.replace(/\bbegan to\s+/gi, "")
|
||||
.replace(/\bwatched the\b/gi, "saw the")
|
||||
.replace(/\bfelt the\b/gi, "knew the")
|
||||
.replace(/\bvery\s+/gi, "")
|
||||
.replace(/\breally\s+/gi, "")
|
||||
.replace(/\bjust\s+/gi, "")
|
||||
.replace(/\bsuddenly\s+/gi, "")
|
||||
.trim();
|
||||
|
||||
if (revisedSpan !== targetSpan && revisedSpan.length > 0) {
|
||||
results.push({
|
||||
lineNumber: line.lineNumber,
|
||||
kind: "review",
|
||||
title:
|
||||
role === "editor"
|
||||
? "Tighten Clause & Cadence"
|
||||
? "Tighten Filter & Qualifier Words"
|
||||
: role === "reader"
|
||||
? "Deepen Reader Immersion"
|
||||
? "Deepen Reader Immediacy"
|
||||
: role === "publisher"
|
||||
? "Reinforce Scene Hook"
|
||||
: "Enhance Sensory Beat",
|
||||
? "Accelerate Narrative Pacing"
|
||||
: "Refine Clause Rhythm",
|
||||
originalText: targetSpan,
|
||||
newText: revisedSpan,
|
||||
summary: `Surgical edit pegged to "${targetSpan.slice(0, 42)}${targetSpan.length > 42 ? "…" : ""}" (preserves surrounding sentences).`,
|
||||
@@ -308,7 +336,7 @@ function buildLineBasedFallback(opts: {
|
||||
} = opts;
|
||||
|
||||
const nonEmptyLines = chapterLines.filter((l) => l.text.length > 0);
|
||||
const suggestions = buildGranularSuggestionsFromLines(nonEmptyLines, agentRole);
|
||||
const suggestions = buildGranularSuggestionsFromLines(nonEmptyLines, agentRole, userPrompt);
|
||||
|
||||
const cast = characterNames.length > 0 ? characterNames.slice(0, 4).join(", ") : "your cast";
|
||||
const thinkingText = `Scanning non-empty paragraphs in "${chapterTitle}" → isolating individual clauses & phrases → cross-checking entities (${cast}) → building ${suggestions.length} text-anchored edits.`;
|
||||
@@ -770,63 +798,96 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
);
|
||||
|
||||
try {
|
||||
const controller = new AgentController({
|
||||
id: activeProvider.id,
|
||||
kind: activeProvider.kind,
|
||||
baseUrl: activeProvider.baseUrl,
|
||||
...(activeProvider.apiKey ? { apiKey: activeProvider.apiKey } : {}),
|
||||
model: activeModel,
|
||||
temperature: activeAgent.temperature,
|
||||
maxTokens: activeAgent.maxTokens,
|
||||
contextWindow: activeAgent.contextWindow,
|
||||
...(activeAgent.thinkingEnabled
|
||||
? {
|
||||
thinking: {
|
||||
enabled: true,
|
||||
effort: activeAgent.thinkingEffort,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const historyMsgs = (await aiStore.listMessages(sessionId)).map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
const { result } = await controller.collect(
|
||||
[
|
||||
{
|
||||
role: "system",
|
||||
content: `${systemPromptWithLines}\n\n--- LINE-NUMBERED MANUSCRIPT CONTEXT ---\n${contextText}`,
|
||||
},
|
||||
...historyMsgs,
|
||||
],
|
||||
{
|
||||
const messagesWithContext = historyMsgs.map((m, idx) =>
|
||||
idx === historyMsgs.length - 1 && m.role === "user"
|
||||
? {
|
||||
role: "user" as const,
|
||||
content: `--- MANUSCRIPT CONTEXT ---\n${contextText}\n\n--- AUTHOR PROMPT ---\n${m.content}`,
|
||||
}
|
||||
: m,
|
||||
);
|
||||
|
||||
let rawModelText = "";
|
||||
let rawReasoning: string | undefined;
|
||||
|
||||
try {
|
||||
const controller = new AgentController({
|
||||
id: activeProvider.id,
|
||||
kind: activeProvider.kind,
|
||||
baseUrl: activeProvider.baseUrl,
|
||||
...(activeProvider.apiKey ? { apiKey: activeProvider.apiKey } : {}),
|
||||
model: activeModel,
|
||||
temperature: activeAgent.temperature,
|
||||
maxTokens: activeAgent.maxTokens,
|
||||
contextText,
|
||||
},
|
||||
);
|
||||
if (result.reasoning) {
|
||||
setThinkingTrace(result.reasoning);
|
||||
}
|
||||
const extracted = parseSuggestionBlocks(result.text.trim(), chapterLines);
|
||||
assistantReply = extracted.cleanText;
|
||||
parsedSuggestions = extracted.parsed;
|
||||
|
||||
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,
|
||||
contextWindow: Math.max(activeAgent.contextWindow || 32768, 32768),
|
||||
...(activeAgent.thinkingEnabled
|
||||
? {
|
||||
thinking: {
|
||||
enabled: true,
|
||||
effort: activeAgent.thinkingEffort,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
parsedSuggestions = fallback.suggestions;
|
||||
|
||||
const { result } = await controller.collect(
|
||||
[
|
||||
{
|
||||
role: "system",
|
||||
content: systemPromptWithLines,
|
||||
},
|
||||
...messagesWithContext,
|
||||
],
|
||||
{
|
||||
temperature: activeAgent.temperature,
|
||||
maxTokens: activeAgent.maxTokens,
|
||||
},
|
||||
);
|
||||
rawModelText = result.text.trim();
|
||||
rawReasoning = result.reasoning;
|
||||
} catch {
|
||||
// Fallback to Host Server AI Proxy (/api/ai/chat) so providers running on
|
||||
// the Host Machine (e.g. Ollama/llama.cpp on localhost) work from remote
|
||||
// browsers and mobile devices without CORS or localhost errors.
|
||||
const proxyRes = await fetch("/api/ai/chat", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
kind: activeProvider.kind,
|
||||
baseUrl: activeProvider.baseUrl,
|
||||
apiKey: activeProvider.apiKey,
|
||||
model: activeModel,
|
||||
messages: [
|
||||
{ role: "system", content: systemPromptWithLines },
|
||||
...messagesWithContext,
|
||||
],
|
||||
temperature: activeAgent.temperature,
|
||||
maxTokens: activeAgent.maxTokens,
|
||||
}),
|
||||
});
|
||||
if (!proxyRes.ok) {
|
||||
throw new Error(`AI proxy returned ${proxyRes.status}`);
|
||||
}
|
||||
const proxyJson = (await proxyRes.json()) as {
|
||||
text?: string;
|
||||
reasoning?: string;
|
||||
};
|
||||
rawModelText = (proxyJson.text ?? "").trim();
|
||||
rawReasoning = proxyJson.reasoning;
|
||||
}
|
||||
|
||||
if (rawReasoning) {
|
||||
setThinkingTrace(rawReasoning);
|
||||
}
|
||||
const extracted = parseSuggestionBlocks(rawModelText, chapterLines);
|
||||
assistantReply = extracted.cleanText || rawModelText;
|
||||
parsedSuggestions = extracted.parsed;
|
||||
} catch {
|
||||
const fallback = buildLineBasedFallback({
|
||||
agentName: activeAgent.name,
|
||||
@@ -839,12 +900,18 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
});
|
||||
setThinkingTrace(fallback.thinkingText);
|
||||
setThinkingStage("Formulating line-numbered suggestions…");
|
||||
// Ensure a visible thinking phase so the user sees the model working through the prompt
|
||||
await new Promise((r) => setTimeout(r, 650));
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
assistantReply = fallback.replyText;
|
||||
parsedSuggestions = fallback.suggestions;
|
||||
}
|
||||
|
||||
// Clear previous pending suggestions in this session before adding fresh ones
|
||||
// so suggestions always refresh cleanly on every new prompt and never duplicate.
|
||||
if (typeof aiStore.clearPendingSuggestions === "function") {
|
||||
await aiStore.clearPendingSuggestions(sessionId);
|
||||
}
|
||||
setFocusedIndex(0);
|
||||
|
||||
for (const item of parsedSuggestions) {
|
||||
await aiStore.addSuggestion(sessionId, {
|
||||
sessionId,
|
||||
|
||||
@@ -132,6 +132,8 @@ interface ProjectStore {
|
||||
deleteChapter: (id: string) => Promise<void>;
|
||||
/** Queue a debounced save of a chapter document. */
|
||||
scheduleSave: (ref: DocumentRef) => void;
|
||||
/** Manually save the active (or specified) chapter immediately (used when autosave is off or on Ctrl+S). */
|
||||
saveChapterNow: (chapterId?: string) => Promise<void>;
|
||||
/** Flush any pending save immediately (beforeunload / chapter switch). */
|
||||
flushAutosave: () => void;
|
||||
/** Highlight a 1-based line number on the writing surface (or null to clear). */
|
||||
@@ -831,6 +833,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
||||
|
||||
scheduleSave: (ref) => {
|
||||
lastScheduledChapterId = ref.id;
|
||||
const autoSaveEnabled = useSettings.getState().autoSave;
|
||||
// Keep the in-memory chapter doc updated so switching tabs or counting words stays live.
|
||||
set((state) => {
|
||||
const nextChapters = state.chapters.map((c) =>
|
||||
@@ -846,13 +849,72 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
||||
},
|
||||
}
|
||||
: state.projectStats;
|
||||
return { chapters: nextChapters, projectStats: nextStats };
|
||||
return {
|
||||
chapters: nextChapters,
|
||||
projectStats: nextStats,
|
||||
...(!autoSaveEnabled
|
||||
? {
|
||||
autosave: {
|
||||
...state.autosave,
|
||||
dirty: true,
|
||||
saving: false,
|
||||
status: "unsaved" as const,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
if (autosave) {
|
||||
if (autoSaveEnabled && autosave) {
|
||||
autosave.save(ref);
|
||||
}
|
||||
},
|
||||
|
||||
saveChapterNow: async (chapterId) => {
|
||||
const st = get();
|
||||
const adapter = st.adapter ?? storageLocal;
|
||||
if (!st.project) return;
|
||||
const targetId = chapterId ?? st.activeChapterId;
|
||||
const targetCh = st.chapters.find((c) => c.id === targetId) ?? st.chapters[0];
|
||||
if (!targetCh) return;
|
||||
|
||||
set({
|
||||
autosave: {
|
||||
...st.autosave,
|
||||
dirty: true,
|
||||
saving: true,
|
||||
status: "saving",
|
||||
},
|
||||
});
|
||||
|
||||
if (autosave) {
|
||||
await autosave.flush();
|
||||
}
|
||||
await adapter.saveDocument({
|
||||
id: targetCh.id,
|
||||
projectId: st.project.id,
|
||||
bookId: targetCh.bookId,
|
||||
title: targetCh.title,
|
||||
doc: targetCh.doc,
|
||||
rev: targetCh.rev,
|
||||
});
|
||||
const { nextMap, nextRev } = recordChapterRevision(
|
||||
st.project.id,
|
||||
st.revisionsByChapter,
|
||||
targetCh,
|
||||
"Manual save",
|
||||
);
|
||||
set({
|
||||
revisionsByChapter: nextMap,
|
||||
chapters: st.chapters.map((c) => (c.id === targetCh.id ? { ...c, rev: nextRev } : c)),
|
||||
autosave: {
|
||||
dirty: false,
|
||||
saving: false,
|
||||
lastSavedAt: new Date().toISOString(),
|
||||
status: "saved",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
flushAutosave: () => {
|
||||
if (autosave) {
|
||||
void autosave.flush();
|
||||
|
||||
@@ -48,6 +48,17 @@
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ps-toolbar-collapsible.is-open {
|
||||
display: contents;
|
||||
}
|
||||
.ps-toolbar-collapsible.is-minimized {
|
||||
display: none !important;
|
||||
}
|
||||
.ps-formatting-toggle-btn {
|
||||
width: auto !important;
|
||||
padding: 0 8px !important;
|
||||
}
|
||||
|
||||
.ps-toolbar-sep {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
@@ -317,3 +328,36 @@
|
||||
text-decoration-thickness: 1.5px;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Mobile-fixed writing pane for standard phone screens (Issue #1) */
|
||||
@media (max-width: 768px) {
|
||||
.ps-writing-slot,
|
||||
.ps-writing,
|
||||
.ps-writing-body {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.ps-editor-scroll {
|
||||
padding: 14px 16px 68px !important;
|
||||
width: 100% !important;
|
||||
max-width: 100vw !important;
|
||||
overflow-x: hidden !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ps-editor-writing {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.ps-editor .ProseMirror {
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ps-writing-status {
|
||||
padding: 6px 12px 50px;
|
||||
font-size: 11px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,11 @@ export class AIStore {
|
||||
if (this.loaded) return;
|
||||
const rows = await this.persistence.list(this.projectId);
|
||||
for (const row of rows) {
|
||||
const cleanedSuggestions = (row.suggestions ?? []).filter(
|
||||
(sg) =>
|
||||
!String(sg.newText ?? sg.text ?? "").includes("—clear and unsparing") &&
|
||||
!String(sg.newText ?? sg.text ?? "").includes("—sharp and unmistakable"),
|
||||
);
|
||||
this.sessions.set(row.id, {
|
||||
id: row.id,
|
||||
projectId: row.projectId,
|
||||
@@ -122,7 +127,7 @@ export class AIStore {
|
||||
providerId: row.providerId,
|
||||
model: row.model,
|
||||
messages: row.messages ?? [],
|
||||
suggestions: row.suggestions ?? [],
|
||||
suggestions: cleanedSuggestions,
|
||||
notes: row.notes ?? [],
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
@@ -328,6 +333,16 @@ export class AIStore {
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear all pending suggestions in a session before populating fresh suggestions from a new prompt. */
|
||||
async clearPendingSuggestions(sessionId: string): Promise<AISession> {
|
||||
return this.withTransaction(async () => {
|
||||
const session = this.require(sessionId);
|
||||
session.suggestions = session.suggestions.filter((s) => s.status !== "pending");
|
||||
session.updatedAt = nowIso();
|
||||
return this.clone(session);
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a suggestion's line number or text anchor when editor blocks shift. */
|
||||
async updateSuggestion(
|
||||
sessionId: string,
|
||||
|
||||
@@ -1392,3 +1392,138 @@ body {
|
||||
|
||||
/* ---- Focus visible ---- */
|
||||
:focus-visible { outline: 2px solid var(--ps-accent); outline-offset: 2px; }
|
||||
|
||||
/* ---- Mobile Helpers (Hidden on Desktop by Default) ---- */
|
||||
.ps-mobile-nav-btn,
|
||||
.ps-mobile-nav-backdrop,
|
||||
.ps-mobile-rail-backdrop,
|
||||
.ps-mobile-ai-backdrop,
|
||||
.ps-mobile-ai-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- Mobile Friendly Layout (Issue #1) ---- */
|
||||
@media (max-width: 768px) {
|
||||
.ps-app,
|
||||
.ps-app.is-nav-collapsed {
|
||||
grid-template-columns: 1fr !important;
|
||||
grid-template-rows: var(--ps-topbar-h) 1fr !important;
|
||||
grid-template-areas:
|
||||
"topbar"
|
||||
"main" !important;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ps-mobile-nav-btn {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* 1. Side panels hide completely when closed and dismiss when tapping outside */
|
||||
.ps-sidebar {
|
||||
display: none !important;
|
||||
}
|
||||
.ps-app.is-mobile-nav-open .ps-sidebar {
|
||||
display: flex !important;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: min(270px, 82vw) !important;
|
||||
z-index: 210;
|
||||
box-shadow: 12px 0 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.ps-app.is-mobile-nav-open .ps-mobile-nav-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.52);
|
||||
z-index: 205;
|
||||
}
|
||||
|
||||
/* Story Context Rail overlays on mobile and hides completely when tapping outside */
|
||||
.ps-context-rail {
|
||||
position: fixed !important;
|
||||
top: var(--ps-topbar-h);
|
||||
bottom: 44px;
|
||||
right: 0;
|
||||
width: min(300px, 86vw) !important;
|
||||
z-index: 210;
|
||||
box-shadow: -10px 0 28px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.ps-mobile-rail-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.48);
|
||||
z-index: 205;
|
||||
}
|
||||
|
||||
/* 4. AI features docked at bottom of phone screen */
|
||||
.ps-ai-sidebar {
|
||||
position: fixed !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
top: auto !important;
|
||||
width: 100vw !important;
|
||||
height: 74dvh !important;
|
||||
border-left: none !important;
|
||||
border-top: 2px solid var(--ps-accent) !important;
|
||||
border-radius: 16px 16px 0 0;
|
||||
z-index: 220;
|
||||
box-shadow: 0 -12px 36px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.ps-ai-resize-handle {
|
||||
display: none !important;
|
||||
}
|
||||
.ps-mobile-ai-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.48);
|
||||
z-index: 215;
|
||||
}
|
||||
|
||||
.ps-mobile-ai-bar {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 44px;
|
||||
padding: 5px 12px;
|
||||
background: var(--ps-surface);
|
||||
border-top: 1px solid var(--ps-border);
|
||||
z-index: 150;
|
||||
align-items: center;
|
||||
}
|
||||
.ps-mobile-ai-bar-btn {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--ps-accent) 55%, var(--ps-border));
|
||||
background: color-mix(in srgb, var(--ps-accent) 16%, var(--ps-surface-2));
|
||||
color: var(--ps-text);
|
||||
font-family: var(--ps-font-ui);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
padding: 0 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ps-topbar {
|
||||
padding: 0 10px;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.ps-topbar-search {
|
||||
max-width: 150px;
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,10 +198,16 @@ export function registerAuthRoutes(app: FastifyInstance): void {
|
||||
const count = (
|
||||
app.db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }
|
||||
).n;
|
||||
const ua = String(req.headers["user-agent"] ?? "");
|
||||
const chMobile = String(req.headers["sec-ch-ua-mobile"] ?? "");
|
||||
const isMobileClient =
|
||||
chMobile === "?1" ||
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(ua);
|
||||
return {
|
||||
initialized: count > 0,
|
||||
authenticated: Boolean(req.user),
|
||||
user: req.user ?? null,
|
||||
isMobileClient,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -212,6 +212,18 @@ export async function buildApp() {
|
||||
reply.header("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||
|
||||
const ua = String(req.headers["user-agent"] ?? "");
|
||||
const chMobile = String(req.headers["sec-ch-ua-mobile"] ?? "");
|
||||
const isMobile =
|
||||
chMobile === "?1" ||
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(ua);
|
||||
reply.header("X-PubScript-Client", isMobile ? "mobile" : "desktop");
|
||||
reply.setCookie("ps_client_device", isMobile ? "mobile" : "desktop", {
|
||||
path: "/",
|
||||
httpOnly: false,
|
||||
sameSite: "lax",
|
||||
});
|
||||
|
||||
const cfg = loadPersistedServerSysConfig();
|
||||
const forwardedHost = (req.headers["x-forwarded-host"] as string | undefined) || req.headers.host;
|
||||
if (!isRequestHostAllowed(forwardedHost, cfg.allowedHosts)) {
|
||||
@@ -336,6 +348,11 @@ export async function buildApp() {
|
||||
app.get("/api/system/server-config", async (req) => {
|
||||
requireAuth(req);
|
||||
const persisted = loadPersistedServerSysConfig();
|
||||
const isMobileClient =
|
||||
String(req.headers["sec-ch-ua-mobile"] ?? "") === "?1" ||
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
|
||||
String(req.headers["user-agent"] ?? ""),
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
enabled: persisted.enabled,
|
||||
@@ -350,9 +367,113 @@ export async function buildApp() {
|
||||
projectDirectory: persisted.projectDirectory,
|
||||
autostartPath: autostartDesktopPath,
|
||||
systemdServicePath,
|
||||
isMobileClient,
|
||||
};
|
||||
});
|
||||
|
||||
// Host-side AI proxy so remote/mobile browsers and local sessions can talk to
|
||||
// Ollama / llama.cpp / LM Studio / OpenAI-compatible / Anthropic / Google
|
||||
// endpoints running on or reachable from the Host Machine without CORS or
|
||||
// remote-localhost errors.
|
||||
app.post("/api/ai/chat", async (req, reply) => {
|
||||
requireAuth(req);
|
||||
const body = (req.body ?? {}) as {
|
||||
kind?: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
messages?: Array<{ role: string; content: string }>;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
const baseUrl = (body.baseUrl ?? "http://localhost:11434/v1").replace(/\/+$/, "");
|
||||
const model = body.model || "llama3.2";
|
||||
const messages = body.messages ?? [];
|
||||
const temperature = body.temperature ?? 0.7;
|
||||
const maxTokens = body.maxTokens ?? 2048;
|
||||
|
||||
try {
|
||||
if (body.kind === "anthropic") {
|
||||
const systemMsg = messages
|
||||
.filter((m) => m.role === "system")
|
||||
.map((m) => m.content)
|
||||
.join("\n\n");
|
||||
const chatMsgs = messages
|
||||
.filter((m) => m.role !== "system")
|
||||
.map((m) => ({
|
||||
role: m.role === "assistant" ? "assistant" : "user",
|
||||
content: m.content,
|
||||
}));
|
||||
const res = await fetch(`${baseUrl}/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(body.apiKey ? { "x-api-key": body.apiKey } : {}),
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
...(systemMsg ? { system: systemMsg } : {}),
|
||||
messages: chatMsgs,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => "");
|
||||
return reply.code(res.status).send({ error: `Provider HTTP ${res.status}: ${errText}` });
|
||||
}
|
||||
const json = (await res.json()) as {
|
||||
content?: Array<{ type?: string; text?: string; thinking?: string }>;
|
||||
};
|
||||
const text = (json.content ?? [])
|
||||
.filter((c) => c.type === "text" || (!c.type && c.text))
|
||||
.map((c) => c.text ?? "")
|
||||
.join("");
|
||||
const reasoning = (json.content ?? [])
|
||||
.filter((c) => c.type === "thinking")
|
||||
.map((c) => c.thinking ?? "")
|
||||
.join("");
|
||||
return { ok: true, text, ...(reasoning ? { reasoning } : {}) };
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
if (body.apiKey) {
|
||||
headers.authorization = `Bearer ${body.apiKey}`;
|
||||
}
|
||||
const res = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
stream: false,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => "");
|
||||
return reply.code(res.status).send({ error: `Provider HTTP ${res.status}: ${errText}` });
|
||||
}
|
||||
const json = (await res.json()) as {
|
||||
choices?: Array<{
|
||||
message?: { content?: string; reasoning_content?: string; reasoning?: string };
|
||||
}>;
|
||||
};
|
||||
const msg = json.choices?.[0]?.message;
|
||||
const text = msg?.content ?? "";
|
||||
const reasoning = msg?.reasoning_content ?? msg?.reasoning;
|
||||
return { ok: true, text, ...(reasoning ? { reasoning } : {}) };
|
||||
} catch (err) {
|
||||
return reply.code(502).send({
|
||||
error: err instanceof Error ? err.message : "Failed to reach AI provider from host",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/system/server-config", async (req, reply) => {
|
||||
requireAdmin(req);
|
||||
const body = (req.body ?? {}) as Partial<PersistedServerSysConfig>;
|
||||
|
||||
@@ -117,8 +117,17 @@ describe("admin endpoints", () => {
|
||||
initialized: false,
|
||||
authenticated: false,
|
||||
user: null,
|
||||
isMobileClient: false,
|
||||
});
|
||||
|
||||
const mobileStatus = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/auth/status",
|
||||
headers: { "user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) Mobile/15E148" },
|
||||
});
|
||||
expect(mobileStatus.headers["x-pubscript-client"]).toBe("mobile");
|
||||
expect((mobileStatus.json() as { isMobileClient: boolean }).isMobileClient).toBe(true);
|
||||
|
||||
const setupRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/setup",
|
||||
|
||||
Reference in New Issue
Block a user