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

Closes #1
Closes #2
Closes #3
Closes #4
Closes #5
This commit is contained in:
Schwitzz
2026-11-02 14:00:48 -06:00
parent 020563ed85
commit b01073eac0
11 changed files with 799 additions and 152 deletions
+74 -3
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; 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 { Sidebar, Topbar, CommandPalette, SplashScreen } from "./components/index.js";
import { AuthGate } from "./components/AuthGate.js"; import { AuthGate } from "./components/AuthGate.js";
import { ExportDialog } from "./features/export/ExportDialog.js"; import { ExportDialog } from "./features/export/ExportDialog.js";
@@ -29,12 +29,49 @@ const NAV = [
]; ];
export default function App() { export default function App() {
const location = useLocation();
const [paletteOpen, setPaletteOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false);
const [importOpen, setImportOpen] = 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 navCollapsed = useSettings((s) => s.navCollapsed);
const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen); const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen);
const focusMode = useSettings((s) => s.focusMode); 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(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -49,13 +86,24 @@ export default function App() {
return ( return (
<AuthGate> <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 /> <SplashScreen />
{mobileNavOpen && (
<div
className="ps-mobile-nav-backdrop"
onClick={() => setMobileNavOpen(false)}
aria-hidden="true"
/>
)}
<Sidebar nav={NAV} /> <Sidebar nav={NAV} />
<Topbar <Topbar
onOpenPalette={() => setPaletteOpen(true)} onOpenPalette={() => setPaletteOpen(true)}
onOpenExport={() => setExportOpen(true)} onOpenExport={() => setExportOpen(true)}
onOpenImport={() => setImportOpen(true)} onOpenImport={() => setImportOpen(true)}
onToggleMobileNav={() => setMobileNavOpen((o) => !o)}
/> />
<div className="ps-workspace-row"> <div className="ps-workspace-row">
<main className="ps-main"> <main className="ps-main">
@@ -72,9 +120,32 @@ export default function App() {
<Route path="*" element={<Write />} /> <Route path="*" element={<Write />} />
</Routes> </Routes>
</main> </main>
{aiSidebarOpen && <AISidebar />} {aiSidebarOpen && (
<>
<div
className="ps-mobile-ai-backdrop"
onClick={() => setSettings({ aiSidebarOpen: false })}
aria-hidden="true"
/>
<AISidebar />
</>
)}
</div> </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 &amp; Notes</span>
<span style={{ fontSize: 11, opacity: 0.85 }}>▲ Open</span>
</button>
</div>
)}
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} nav={NAV} /> <CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} nav={NAV} />
<ImportDialog open={importOpen} onClose={() => setImportOpen(false)} /> <ImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
<ExportDialog open={exportOpen} onClose={() => setExportOpen(false)} /> <ExportDialog open={exportOpen} onClose={() => setExportOpen(false)} />
+70 -9
View File
@@ -3,7 +3,7 @@ import { useLocation, NavLink, useNavigate } from "react-router-dom";
import { useSettings } from "../store/settings.js"; import { useSettings } from "../store/settings.js";
import { useProjectStore } from "../store/project-store.js"; import { useProjectStore } from "../store/project-store.js";
import { useAuth } from "./AuthGate.js"; import { useAuth } from "./AuthGate.js";
import { Badge } from "@pubscript/ui"; import { Badge, toast } from "@pubscript/ui";
import type { Entity } from "@pubscript/core"; import type { Entity } from "@pubscript/core";
const TITLES: Record<string, string> = { const TITLES: Record<string, string> = {
@@ -22,6 +22,7 @@ export interface TopbarProps {
onOpenPalette?: () => void; onOpenPalette?: () => void;
onOpenExport: () => void; onOpenExport: () => void;
onOpenImport?: () => void; onOpenImport?: () => void;
onToggleMobileNav?: () => void;
} }
function entityName(e: Entity): string { function entityName(e: Entity): string {
@@ -29,7 +30,7 @@ function entityName(e: Entity): string {
return (e.body || "Untitled note").slice(0, 48); 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 location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
@@ -41,6 +42,8 @@ export function Topbar({ onOpenExport, onOpenImport }: TopbarProps) {
const selectChapter = useProjectStore((s) => s.selectChapter); const selectChapter = useProjectStore((s) => s.selectChapter);
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 autosave = useProjectStore((s) => s.autosave);
const saveChapterNow = useProjectStore((s) => s.saveChapterNow);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [searchFocused, setSearchFocused] = useState(false); const [searchFocused, setSearchFocused] = useState(false);
@@ -109,13 +112,64 @@ export function Topbar({ onOpenExport, onOpenImport }: TopbarProps) {
return items.slice(0, 10); return items.slice(0, 10);
}, [searchQuery, chapters, entities, plotBeats, selectChapter, navigate]); }, [searchQuery, chapters, entities, plotBeats, selectChapter, navigate]);
const handleManualSave = async () => {
await saveChapterNow();
toast.success("Chapter saved");
};
return ( return (
<header className="ps-topbar"> <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" }}> <h1 style={{ margin: 0, fontSize: 15, fontWeight: 600, display: "inline-flex", verticalAlign: "middle" }}>
{title} {title}
</h1> </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 }} /> <div style={{ flex: 1 }} />
@@ -180,22 +234,29 @@ export function Topbar({ onOpenExport, onOpenImport }: TopbarProps) {
{onOpenImport && ( {onOpenImport && (
<button <button
type="button" type="button"
className="ps-nav-item" className="ps-icon-btn"
onClick={onOpenImport} onClick={onOpenImport}
style={{ width: "auto", padding: "6px 10px" }} aria-label="Import manuscript"
title="Import manuscript (.docx, .odt, .epub, .md, .txt, .html, .rtf, .json)" 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>
)} )}
<button <button
type="button" type="button"
className="ps-nav-item" className="ps-icon-btn"
onClick={onOpenExport} 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> </button>
<NavLink to="/settings" className="ps-icon-btn" style={{ fontSize: 18 }} aria-label="Settings" title="Settings"> <NavLink to="/settings" className="ps-icon-btn" style={{ fontSize: 18 }} aria-label="Settings" title="Settings">
+75 -19
View File
@@ -76,6 +76,7 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
const renameChapter = useProjectStore((s) => s.renameChapter); const renameChapter = useProjectStore((s) => s.renameChapter);
const deleteChapter = useProjectStore((s) => s.deleteChapter); const deleteChapter = useProjectStore((s) => s.deleteChapter);
const scheduleSave = useProjectStore((s) => s.scheduleSave); const scheduleSave = useProjectStore((s) => s.scheduleSave);
const saveChapterNow = useProjectStore((s) => s.saveChapterNow);
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);
@@ -84,6 +85,7 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
const highlightSeq = useProjectStore((s) => s.highlightSeq); const highlightSeq = useProjectStore((s) => s.highlightSeq);
const docExternalRev = useProjectStore((s) => s.docExternalRev); const docExternalRev = useProjectStore((s) => s.docExternalRev);
const autoSave = useSettings((st) => st.autoSave);
const fontFamily = useSettings((st) => st.fontFamily); const fontFamily = useSettings((st) => st.fontFamily);
const fontSize = useSettings((st) => st.fontSize); const fontSize = useSettings((st) => st.fontSize);
const leading = useSettings((st) => st.leading); const leading = useSettings((st) => st.leading);
@@ -93,7 +95,10 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
const setSettings = useSettings((st) => st.set); const setSettings = useSettings((st) => st.set);
const [firstLineIndent, setFirstLineIndent] = useState(false); 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 [renamingChapter, setRenamingChapter] = useState(false);
const [chapterTitleDraft, setChapterTitleDraft] = useState(""); const [chapterTitleDraft, setChapterTitleDraft] = useState("");
const [resolvedDomChildIdx, setResolvedDomChildIdx] = useState<number | null>(null); const [resolvedDomChildIdx, setResolvedDomChildIdx] = useState<number | null>(null);
@@ -253,19 +258,22 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
return clearCustomHighlight; return clearCustomHighlight;
}, [editor, highlightedText, highlightedLine, highlightSeq, docExternalRev, chapter?.id]); }, [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(() => { useEffect(() => {
const dom = editorDomRef.current;
if (!dom) return;
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === "F2") { if (e.key === "F2") {
e.preventDefault(); e.preventDefault();
setSettings({ focusMode: !focusMode }); 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); window.addEventListener("keydown", onKey);
return () => dom.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [focusMode, setSettings]); }, [focusMode, setSettings, saveChapterNow]);
// Flush pending edits when the user closes or navigates away. // Flush pending edits when the user closes or navigates away.
useEffect(() => { useEffect(() => {
@@ -421,12 +429,41 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
</svg> </svg>
</button> </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> </div>
<span className="ps-toolbar-sep" /> <div className={`ps-toolbar-collapsible${formattingOpen ? " is-open" : " is-minimized"}`}>
<span className="ps-toolbar-sep" />
{/* History: Undo / Redo */} {/* History: Undo / Redo */}
<div className="ps-toolbar-group"> <div className="ps-toolbar-group">
<button <button
type="button" type="button"
className="ps-tool-icon" 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" /> <path d="M3 10l2.5-6L8 10M3.8 8.2h3.4M9.5 10.5l2 2 3-3.5" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
</button> </button>
</div>
</div>
<div className="ps-toolbar-group" style={{ marginLeft: "auto" }}>
<button <button
type="button" type="button"
className={`ps-tool-icon${showContextRail ? " is-active" : ""}`} 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-label="Toggle Story Context Rail"
aria-pressed={showContextRail}
onClick={() => setShowContextRail((r) => !r)} onClick={() => setShowContextRail((r) => !r)}
> >
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5"> <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" /> <circle cx="8" cy="8" r="6" />
<line x1="10" y1="2.5" x2="10" y2="13.5" /> <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> </svg>
</button> </button>
</div> </div>
@@ -879,9 +921,16 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
<div <div
className="ps-autosave-indicator" className="ps-autosave-indicator"
data-status={autosave.saving ? "saving" : autosave.dirty ? "dirty" : "saved"} 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 className="ps-autosave-dot" />
<span>{autosaveLabel(autosave)}</span> <span>{!autoSave && autosave.dirty ? "Unsaved · Click to save" : autosaveLabel(autosave)}</span>
</div> </div>
</div> </div>
@@ -924,11 +973,18 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
</div> </div>
{showContextRail && ( {showContextRail && (
<ContextRail <>
chapter={chapter} <div
entities={entities} className="ps-mobile-rail-backdrop"
onOpenEntity={(id) => navigate(`/characters?entity=${encodeURIComponent(id)}`)} onClick={() => setShowContextRail(false)}
/> aria-hidden="true"
/>
<ContextRail
chapter={chapter}
entities={entities}
onOpenEntity={(id) => navigate(`/characters?entity=${encodeURIComponent(id)}`)}
/>
</>
)} )}
{check.open && ( {check.open && (
+185 -118
View File
@@ -113,55 +113,90 @@ function parseSuggestionBlocks(
chapterLines: ChapterLine[], chapterLines: ChapterLine[],
): { cleanText: string; parsed: ParsedLineSuggestion[] } { ): { cleanText: string; parsed: ParsedLineSuggestion[] } {
const parsed: ParsedLineSuggestion[] = []; const parsed: ParsedLineSuggestion[] = [];
const blockRegex = /<<<SUGGESTION([\s\S]*?)SUGGESTION>>>/gi;
const norm = (s: string) => const norm = (s: string) =>
s.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'").toLowerCase(); 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) => { .replace(blockRegex, (_match, body: string) => {
const getField = (name: string): 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() ?? ""; return m?.[1]?.trim() ?? "";
}; };
const rawOrig = getField("ORIGINAL").replace(/^["“]|["”]$/g, "").trim(); const rawOrig = getField("ORIGINAL");
const newText = (getField("REPLACEMENT") || getField("NEW")) const newText = getField("REPLACEMENT") || getField("NEW") || getField("SUGGESTED");
.replace(/^["“]|["”]$/g, "") const title = getField("TITLE");
.trim(); const summary = getField("SUMMARY") || getField("RATIONALE") || getField("REASON");
if (!rawOrig || !newText) return ""; addParsed(rawOrig, newText, title, summary);
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,
});
return ""; return "";
}) })
.trim(); .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 }; return { cleanText, parsed };
} }
/** /**
* Extract granular phrase, clause, and single-sentence edit candidates from * Extract granular phrase, clause, and single-sentence edit candidates from
* the chapter's non-empty paragraphs so suggestions are surgical and never * the chapter's non-empty paragraphs so offline/test suggestions are surgical,
* overwrite entire multi-sentence paragraphs. * prompt-sensitive, and never repeat static clichés.
*/ */
function buildGranularSuggestionsFromLines( function buildGranularSuggestionsFromLines(
nonEmptyLines: ChapterLine[], nonEmptyLines: ChapterLine[],
role: string, role: string,
userPrompt = "",
): ParsedLineSuggestion[] { ): ParsedLineSuggestion[] {
const results: ParsedLineSuggestion[] = []; const results: ParsedLineSuggestion[] = [];
const promptLower = userPrompt.toLowerCase();
const rules: Array<{ const rules: Array<{
pattern: RegExp; pattern: RegExp;
@@ -172,7 +207,10 @@ function buildGranularSuggestionsFromLines(
{ {
pattern: /left the mainland before dawn,\s*its iron hull shuddering against the black chop/i, pattern: /left the mainland before dawn,\s*its iron hull shuddering against the black chop/i,
title: "Tighten Opening Clause", 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.", 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; if (results.length >= 5) break;
const sentences = line.text if (results.some((r) => r.originalText.includes(sentence) || sentence.includes(r.originalText))) {
.split(/(?<=[.!?])\s+/) continue;
.map((s) => s.trim()) }
.filter((s) => s.length >= 12);
for (const sentence of sentences) { const clauseMatch = sentence.match(/^([^,]{18,85}),\s+(.{12,})$/);
if (results.length >= 5) break; const targetSpan = clauseMatch ? clauseMatch[1]!.trim() : sentence;
if (results.some((r) => r.originalText.includes(sentence) || sentence.includes(r.originalText))) {
continue;
}
const clauseMatch = sentence.match(/^([^,]{18,85}),\s+(.{12,})$/); const revisedSpan = targetSpan
const targetSpan = clauseMatch ? clauseMatch[1]!.trim() : sentence; .replace(/\bseemed to\s+/gi, "")
.replace(/\bstarted to\s+/gi, "")
let revisedSpan = targetSpan .replace(/\bbegan to\s+/gi, "")
.replace(/\bseemed to\s+/gi, "") .replace(/\bwatched the\b/gi, "saw the")
.replace(/\bstarted to\s+/gi, "") .replace(/\bfelt the\b/gi, "knew the")
.replace(/\bbegan to\s+/gi, "") .replace(/\bvery\s+/gi, "")
.replace(/\bwatched the\b/gi, "saw the") .replace(/\breally\s+/gi, "")
.replace(/\bfelt the\b/gi, "knew the") .replace(/\bjust\s+/gi, "")
.replace(/\bvery\s+/gi, "") .replace(/\bsuddenly\s+/gi, "")
.trim(); .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`;
}
}
if (revisedSpan !== targetSpan && revisedSpan.length > 0) {
results.push({ results.push({
lineNumber: line.lineNumber, lineNumber: line.lineNumber,
kind: "review", kind: "review",
title: title:
role === "editor" role === "editor"
? "Tighten Clause & Cadence" ? "Tighten Filter & Qualifier Words"
: role === "reader" : role === "reader"
? "Deepen Reader Immersion" ? "Deepen Reader Immediacy"
: role === "publisher" : role === "publisher"
? "Reinforce Scene Hook" ? "Accelerate Narrative Pacing"
: "Enhance Sensory Beat", : "Refine Clause Rhythm",
originalText: targetSpan, originalText: targetSpan,
newText: revisedSpan, newText: revisedSpan,
summary: `Surgical edit pegged to "${targetSpan.slice(0, 42)}${targetSpan.length > 42 ? "…" : ""}" (preserves surrounding sentences).`, summary: `Surgical edit pegged to "${targetSpan.slice(0, 42)}${targetSpan.length > 42 ? "…" : ""}" (preserves surrounding sentences).`,
@@ -308,7 +336,7 @@ function buildLineBasedFallback(opts: {
} = opts; } = opts;
const nonEmptyLines = chapterLines.filter((l) => l.text.length > 0); 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 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.`; 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 { 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) => ({ const historyMsgs = (await aiStore.listMessages(sessionId)).map((m) => ({
role: m.role, role: m.role,
content: m.content, content: m.content,
})); }));
const { result } = await controller.collect( const messagesWithContext = historyMsgs.map((m, idx) =>
[ idx === historyMsgs.length - 1 && m.role === "user"
{ ? {
role: "system", role: "user" as const,
content: `${systemPromptWithLines}\n\n--- LINE-NUMBERED MANUSCRIPT CONTEXT ---\n${contextText}`, content: `--- MANUSCRIPT CONTEXT ---\n${contextText}\n\n--- AUTHOR PROMPT ---\n${m.content}`,
}, }
...historyMsgs, : 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, temperature: activeAgent.temperature,
maxTokens: activeAgent.maxTokens, maxTokens: activeAgent.maxTokens,
contextText, contextWindow: Math.max(activeAgent.contextWindow || 32768, 32768),
}, ...(activeAgent.thinkingEnabled
); ? {
if (result.reasoning) { thinking: {
setThinkingTrace(result.reasoning); enabled: true,
} effort: activeAgent.thinkingEffort,
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,
}); });
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 { } catch {
const fallback = buildLineBasedFallback({ const fallback = buildLineBasedFallback({
agentName: activeAgent.name, agentName: activeAgent.name,
@@ -839,12 +900,18 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
}); });
setThinkingTrace(fallback.thinkingText); setThinkingTrace(fallback.thinkingText);
setThinkingStage("Formulating line-numbered suggestions…"); 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, 400));
await new Promise((r) => setTimeout(r, 650));
assistantReply = fallback.replyText; assistantReply = fallback.replyText;
parsedSuggestions = fallback.suggestions; 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) { for (const item of parsedSuggestions) {
await aiStore.addSuggestion(sessionId, { await aiStore.addSuggestion(sessionId, {
sessionId, sessionId,
+64 -2
View File
@@ -132,6 +132,8 @@ interface ProjectStore {
deleteChapter: (id: string) => Promise<void>; deleteChapter: (id: string) => Promise<void>;
/** Queue a debounced save of a chapter document. */ /** Queue a debounced save of a chapter document. */
scheduleSave: (ref: DocumentRef) => void; 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). */ /** 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). */ /** 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) => { scheduleSave: (ref) => {
lastScheduledChapterId = ref.id; lastScheduledChapterId = ref.id;
const autoSaveEnabled = useSettings.getState().autoSave;
// Keep the in-memory chapter doc updated so switching tabs or counting words stays live. // Keep the in-memory chapter doc updated so switching tabs or counting words stays live.
set((state) => { set((state) => {
const nextChapters = state.chapters.map((c) => const nextChapters = state.chapters.map((c) =>
@@ -846,13 +849,72 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
}, },
} }
: state.projectStats; : 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); 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: () => { flushAutosave: () => {
if (autosave) { if (autosave) {
void autosave.flush(); void autosave.flush();
+44
View File
@@ -48,6 +48,17 @@
gap: 2px; 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 { .ps-toolbar-sep {
width: 1px; width: 1px;
height: 20px; height: 20px;
@@ -317,3 +328,36 @@
text-decoration-thickness: 1.5px; text-decoration-thickness: 1.5px;
text-underline-offset: 3px; 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;
}
}
+16 -1
View File
@@ -112,6 +112,11 @@ export class AIStore {
if (this.loaded) return; if (this.loaded) return;
const rows = await this.persistence.list(this.projectId); const rows = await this.persistence.list(this.projectId);
for (const row of rows) { 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, { this.sessions.set(row.id, {
id: row.id, id: row.id,
projectId: row.projectId, projectId: row.projectId,
@@ -122,7 +127,7 @@ export class AIStore {
providerId: row.providerId, providerId: row.providerId,
model: row.model, model: row.model,
messages: row.messages ?? [], messages: row.messages ?? [],
suggestions: row.suggestions ?? [], suggestions: cleanedSuggestions,
notes: row.notes ?? [], notes: row.notes ?? [],
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, 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. */ /** Update a suggestion's line number or text anchor when editor blocks shift. */
async updateSuggestion( async updateSuggestion(
sessionId: string, sessionId: string,
+135
View File
@@ -1392,3 +1392,138 @@ body {
/* ---- Focus visible ---- */ /* ---- Focus visible ---- */
:focus-visible { outline: 2px solid var(--ps-accent); outline-offset: 2px; } :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;
}
}
+6
View File
@@ -198,10 +198,16 @@ export function registerAuthRoutes(app: FastifyInstance): void {
const count = ( const count = (
app.db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number } app.db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }
).n; ).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 { return {
initialized: count > 0, initialized: count > 0,
authenticated: Boolean(req.user), authenticated: Boolean(req.user),
user: req.user ?? null, user: req.user ?? null,
isMobileClient,
}; };
}); });
+121
View File
@@ -212,6 +212,18 @@ export async function buildApp() {
reply.header("Referrer-Policy", "strict-origin-when-cross-origin"); reply.header("Referrer-Policy", "strict-origin-when-cross-origin");
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); 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 cfg = loadPersistedServerSysConfig();
const forwardedHost = (req.headers["x-forwarded-host"] as string | undefined) || req.headers.host; const forwardedHost = (req.headers["x-forwarded-host"] as string | undefined) || req.headers.host;
if (!isRequestHostAllowed(forwardedHost, cfg.allowedHosts)) { if (!isRequestHostAllowed(forwardedHost, cfg.allowedHosts)) {
@@ -336,6 +348,11 @@ export async function buildApp() {
app.get("/api/system/server-config", async (req) => { app.get("/api/system/server-config", async (req) => {
requireAuth(req); requireAuth(req);
const persisted = loadPersistedServerSysConfig(); 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 { return {
ok: true, ok: true,
enabled: persisted.enabled, enabled: persisted.enabled,
@@ -350,9 +367,113 @@ export async function buildApp() {
projectDirectory: persisted.projectDirectory, projectDirectory: persisted.projectDirectory,
autostartPath: autostartDesktopPath, autostartPath: autostartDesktopPath,
systemdServicePath, 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) => { app.post("/api/system/server-config", async (req, reply) => {
requireAdmin(req); requireAdmin(req);
const body = (req.body ?? {}) as Partial<PersistedServerSysConfig>; const body = (req.body ?? {}) as Partial<PersistedServerSysConfig>;
+9
View File
@@ -117,8 +117,17 @@ describe("admin endpoints", () => {
initialized: false, initialized: false,
authenticated: false, authenticated: false,
user: null, 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({ const setupRes = await app.inject({
method: "POST", method: "POST",
url: "/api/auth/setup", url: "/api/auth/setup",