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 { 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 & 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)} />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user