2026-05-07 22:55:45 +02:00
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
|
import { listen } from "@tauri-apps/api/event";
|
2026-05-08 03:10:43 +02:00
|
|
|
import { open, save } from "@tauri-apps/plugin-dialog";
|
2026-02-04 06:56:56 +01:00
|
|
|
import { invoke } from "@tauri-apps/api/core";
|
2026-02-04 07:19:00 +01:00
|
|
|
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
2026-05-07 19:42:56 +02:00
|
|
|
import { markdownToHTML } from "./markdown/markdown";
|
|
|
|
|
import "./markdown/markdown-render.css";
|
2026-02-04 06:56:56 +01:00
|
|
|
|
|
|
|
|
type FileEntry = {
|
|
|
|
|
kind: "file";
|
|
|
|
|
path: string;
|
|
|
|
|
name: string;
|
|
|
|
|
type: string;
|
|
|
|
|
size: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type UrlEntry = {
|
|
|
|
|
kind: "url";
|
|
|
|
|
url: string;
|
|
|
|
|
name: string;
|
|
|
|
|
type: "url";
|
|
|
|
|
size: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type RowEntry = FileEntry | UrlEntry;
|
|
|
|
|
|
|
|
|
|
type RephraseVariant = {
|
|
|
|
|
key: string;
|
|
|
|
|
label: string;
|
|
|
|
|
text: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type SessionSummary = {
|
|
|
|
|
title: string;
|
|
|
|
|
description: string;
|
|
|
|
|
saved_at: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type PriorArtResult = {
|
|
|
|
|
url: string;
|
|
|
|
|
title?: string;
|
|
|
|
|
score?: number;
|
|
|
|
|
snippet?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type PriorArtResponse = {
|
|
|
|
|
queries: string[];
|
|
|
|
|
lang?: string;
|
|
|
|
|
results: PriorArtResult[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
async function runBackend<T>(action: string, payload: Record<string, unknown>): Promise<T> {
|
|
|
|
|
return (await invoke("run_python_action", { action, payload })) as T;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 19:11:26 +02:00
|
|
|
async function openPath(path: string): Promise<void> {
|
|
|
|
|
await invoke("open_path", { path });
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 06:56:56 +01:00
|
|
|
function formatTime(ts: number): string {
|
|
|
|
|
if (!ts) return "";
|
|
|
|
|
const d = new Date(ts * 1000);
|
|
|
|
|
return d.toLocaleString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rowId(row: RowEntry): string {
|
|
|
|
|
return row.kind === "file" ? `file:${row.path}` : `url:${row.url}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 03:10:43 +02:00
|
|
|
function pdfFileNameFromTitle(title: string, conceptText: string): string {
|
|
|
|
|
const heading = conceptText.match(/^#\s+(.+)$/m)?.[1] ?? "";
|
|
|
|
|
const rawName = (title || heading || "concept").trim();
|
|
|
|
|
const safeName = rawName
|
|
|
|
|
.replace(/\s+/g, "-")
|
|
|
|
|
.replace(/[^a-zA-Z0-9._-]/g, "-")
|
|
|
|
|
.replace(/-+/g, "-")
|
|
|
|
|
.replace(/^[-_.]+|[-_.]+$/g, "");
|
|
|
|
|
|
|
|
|
|
return `${safeName || "concept"}.pdf`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ensurePdfExtension(path: string): string {
|
|
|
|
|
return /\.pdf$/i.test(path) ? path : `${path}.pdf`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 03:14:31 +02:00
|
|
|
function fallbackIdeaText(title: string, description: string, concept: string): string {
|
|
|
|
|
return [
|
|
|
|
|
title.trim() ? `Title: ${title.trim()}` : "",
|
|
|
|
|
description.trim() ? `Description: ${description.trim()}` : "",
|
|
|
|
|
concept.trim(),
|
|
|
|
|
]
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join("\n\n");
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 06:56:56 +01:00
|
|
|
export default function App() {
|
2026-05-07 22:59:35 +02:00
|
|
|
const [status, setStatusMessage] = useState("");
|
|
|
|
|
const [statusVersion, setStatusVersion] = useState(0);
|
|
|
|
|
const [statusVisible, setStatusVisible] = useState(false);
|
2026-02-04 06:56:56 +01:00
|
|
|
const [files, setFiles] = useState<FileEntry[]>([]);
|
|
|
|
|
const [websites, setWebsites] = useState<UrlEntry[]>([]);
|
|
|
|
|
const [notes, setNotes] = useState("");
|
|
|
|
|
const [rephraseVariants, setRephraseVariants] = useState<RephraseVariant[]>([]);
|
|
|
|
|
const [rephraseSelected, setRephraseSelected] = useState<string | null>(null);
|
|
|
|
|
const [concept, setConcept] = useState("");
|
2026-05-07 19:42:56 +02:00
|
|
|
const [markdownPreview, setMarkdownPreview] = useState(false);
|
2026-02-04 06:56:56 +01:00
|
|
|
const [title, setTitle] = useState("");
|
|
|
|
|
const [description, setDescription] = useState("");
|
|
|
|
|
const [ollamaHost, setOllamaHost] = useState("http://localhost:11434");
|
|
|
|
|
const [ollamaModel, setOllamaModel] = useState("Select model...");
|
|
|
|
|
const [searxUrl, setSearxUrl] = useState("http://localhost:8888");
|
|
|
|
|
const [models, setModels] = useState<string[]>([]);
|
|
|
|
|
const [busy, setBusy] = useState<Record<string, boolean>>({});
|
|
|
|
|
|
|
|
|
|
const [sessionModalOpen, setSessionModalOpen] = useState(false);
|
|
|
|
|
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
2026-05-08 01:22:16 +02:00
|
|
|
const [sessionsLoading, setSessionsLoading] = useState(false);
|
2026-02-04 06:56:56 +01:00
|
|
|
|
|
|
|
|
const [priorModalOpen, setPriorModalOpen] = useState(false);
|
|
|
|
|
const [priorData, setPriorData] = useState<PriorArtResponse | null>(null);
|
2026-05-07 23:57:45 +02:00
|
|
|
const [websiteModalOpen, setWebsiteModalOpen] = useState(false);
|
|
|
|
|
const [websiteInput, setWebsiteInput] = useState("");
|
|
|
|
|
const [websiteError, setWebsiteError] = useState("");
|
2026-05-07 22:55:45 +02:00
|
|
|
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
|
|
|
|
const menuActionRef = useRef<(action: string) => void>(() => undefined);
|
2026-02-04 06:56:56 +01:00
|
|
|
|
|
|
|
|
const rows = useMemo<RowEntry[]>(() => [...files, ...websites], [files, websites]);
|
2026-05-07 19:42:56 +02:00
|
|
|
const markdownPreviewHtml = useMemo(
|
|
|
|
|
() => (markdownPreview ? markdownToHTML(concept) : ""),
|
|
|
|
|
[concept, markdownPreview]
|
|
|
|
|
);
|
2026-02-04 06:56:56 +01:00
|
|
|
|
2026-05-07 22:59:35 +02:00
|
|
|
const setStatus = (message: string) => {
|
|
|
|
|
setStatusMessage(message);
|
|
|
|
|
setStatusVersion((value) => value + 1);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!status) {
|
|
|
|
|
setStatusVisible(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setStatusVisible(true);
|
|
|
|
|
const timer = window.setTimeout(() => {
|
|
|
|
|
setStatusVisible(false);
|
|
|
|
|
}, 5000);
|
|
|
|
|
|
|
|
|
|
return () => window.clearTimeout(timer);
|
|
|
|
|
}, [status, statusVersion]);
|
|
|
|
|
|
2026-02-04 06:56:56 +01:00
|
|
|
useEffect(() => {
|
|
|
|
|
let mounted = true;
|
|
|
|
|
const bootstrap = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const settings = await runBackend<Record<string, string>>("load_settings", {});
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
if (settings.ollama_host) setOllamaHost(settings.ollama_host);
|
|
|
|
|
if (settings.ollama_model) setOllamaModel(settings.ollama_model);
|
|
|
|
|
if (settings.searx_url) setSearxUrl(settings.searx_url);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const list = await runBackend<string[]>("list_models", {});
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setModels(list);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
void bootstrap();
|
|
|
|
|
return () => {
|
|
|
|
|
mounted = false;
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
runBackend("save_settings", {
|
|
|
|
|
settings: {
|
|
|
|
|
ollama_host: ollamaHost,
|
|
|
|
|
ollama_model: ollamaModel,
|
|
|
|
|
searx_url: searxUrl,
|
|
|
|
|
},
|
|
|
|
|
}).catch(() => undefined);
|
|
|
|
|
}, 400);
|
|
|
|
|
return () => clearTimeout(timer);
|
2026-05-07 23:26:23 +02:00
|
|
|
}, [ollamaHost, ollamaModel, searxUrl]);
|
2026-02-04 06:56:56 +01:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let unlisten: (() => void) | undefined;
|
|
|
|
|
const attach = async () => {
|
|
|
|
|
try {
|
2026-02-04 07:19:00 +01:00
|
|
|
const current = getCurrentWebview() as any;
|
|
|
|
|
unlisten = await current.onDragDropEvent((event: any) => {
|
|
|
|
|
if (event.payload?.type === "drop") {
|
2026-02-04 06:56:56 +01:00
|
|
|
const paths = event.payload.paths ?? [];
|
|
|
|
|
if (paths.length) {
|
|
|
|
|
void addFilesByPath(paths, true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
} catch {
|
|
|
|
|
// optional
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
void attach();
|
|
|
|
|
return () => {
|
|
|
|
|
if (unlisten) unlisten();
|
|
|
|
|
};
|
|
|
|
|
}, [files]);
|
|
|
|
|
|
|
|
|
|
const addFilesByPath = async (paths: string[], expandDirs: boolean) => {
|
|
|
|
|
if (!paths.length) return;
|
|
|
|
|
setStatus("Indexing files...");
|
|
|
|
|
try {
|
|
|
|
|
const stats = await runBackend<{ name: string; path: string; type: string; size: string }[]>(
|
|
|
|
|
"stat_paths",
|
|
|
|
|
{ paths, expand_dirs: expandDirs }
|
|
|
|
|
);
|
|
|
|
|
setFiles((prev) => {
|
|
|
|
|
const existing = new Map(prev.map((f) => [f.path, f]));
|
|
|
|
|
stats.forEach((item) => {
|
|
|
|
|
if (!existing.has(item.path)) {
|
|
|
|
|
existing.set(item.path, {
|
|
|
|
|
kind: "file",
|
|
|
|
|
path: item.path,
|
|
|
|
|
name: item.name,
|
|
|
|
|
type: item.type,
|
|
|
|
|
size: item.size,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return Array.from(existing.values());
|
|
|
|
|
});
|
|
|
|
|
setStatus(`Added ${stats.length} item(s)`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setStatus("Failed to add files");
|
|
|
|
|
console.error(err);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onAddFiles = async () => {
|
|
|
|
|
const selection = await open({ multiple: true, title: "Select files" });
|
|
|
|
|
if (!selection) return;
|
|
|
|
|
const paths = Array.isArray(selection) ? selection : [selection];
|
|
|
|
|
await addFilesByPath(paths as string[], false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onAddWebsite = () => {
|
2026-05-07 23:57:45 +02:00
|
|
|
setWebsiteInput("");
|
|
|
|
|
setWebsiteError("");
|
|
|
|
|
setWebsiteModalOpen(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onSubmitWebsite = () => {
|
|
|
|
|
const trimmed = websiteInput.trim();
|
2026-02-04 06:56:56 +01:00
|
|
|
if (!/^https?:\/\//i.test(trimmed)) {
|
2026-05-07 23:57:45 +02:00
|
|
|
setWebsiteError("Enter a valid http(s) URL.");
|
2026-02-04 06:56:56 +01:00
|
|
|
return;
|
|
|
|
|
}
|
2026-05-07 23:57:45 +02:00
|
|
|
if (websites.some((w) => w.url === trimmed)) {
|
|
|
|
|
setWebsiteError("This website is already in the table.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const name = trimmed.replace(/^https?:\/\//, "").slice(0, 60);
|
|
|
|
|
setWebsites((prev) => [
|
|
|
|
|
...prev,
|
|
|
|
|
{
|
|
|
|
|
kind: "url",
|
|
|
|
|
url: trimmed,
|
|
|
|
|
name,
|
|
|
|
|
type: "url",
|
|
|
|
|
size: "web",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
setWebsiteModalOpen(false);
|
|
|
|
|
setWebsiteInput("");
|
|
|
|
|
setWebsiteError("");
|
|
|
|
|
setStatus(`Added ${name}`);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const closeWebsiteModal = () => {
|
|
|
|
|
setWebsiteModalOpen(false);
|
|
|
|
|
setWebsiteInput("");
|
|
|
|
|
setWebsiteError("");
|
|
|
|
|
};
|
2026-02-04 06:56:56 +01:00
|
|
|
|
2026-05-07 23:39:45 +02:00
|
|
|
const onRemoveRow = (row: RowEntry) => {
|
|
|
|
|
const label = row.kind === "file" ? row.path : row.url;
|
|
|
|
|
if (!window.confirm(`Remove "${row.name}" from Files & Websites?\n\n${label}`)) return;
|
|
|
|
|
if (row.kind === "file") {
|
|
|
|
|
setFiles((prev) => prev.filter((file) => file.path !== row.path));
|
|
|
|
|
} else {
|
|
|
|
|
setWebsites((prev) => prev.filter((website) => website.url !== row.url));
|
|
|
|
|
}
|
|
|
|
|
setStatus(`Removed ${row.name}`);
|
2026-02-04 06:56:56 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onRephrase = async () => {
|
|
|
|
|
if (!ollamaModel || ollamaModel === "Select model...") {
|
|
|
|
|
window.alert("Please select a model first.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!notes.trim()) {
|
|
|
|
|
window.alert("Add some notes to rephrase.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setBusy((prev) => ({ ...prev, rephrase: true }));
|
|
|
|
|
setStatus("Rephrasing...");
|
|
|
|
|
try {
|
|
|
|
|
const variants = await runBackend<RephraseVariant[]>("rephrase", {
|
|
|
|
|
note: notes,
|
|
|
|
|
ollama_host: ollamaHost,
|
|
|
|
|
model: ollamaModel,
|
|
|
|
|
});
|
|
|
|
|
setRephraseVariants(variants);
|
|
|
|
|
setRephraseSelected(variants[0]?.key ?? null);
|
|
|
|
|
setStatus("Rephrase complete");
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setStatus("Rephrase failed");
|
|
|
|
|
console.error(err);
|
|
|
|
|
window.alert("Rephrase failed. Check console for details.");
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy((prev) => ({ ...prev, rephrase: false }));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onExtend = async () => {
|
|
|
|
|
if (!ollamaModel || ollamaModel === "Select model...") {
|
|
|
|
|
window.alert("Please select a model first.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!notes.trim()) {
|
|
|
|
|
window.alert("Add some notes to extend.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setBusy((prev) => ({ ...prev, extend: true }));
|
|
|
|
|
setStatus("Extending...");
|
|
|
|
|
try {
|
|
|
|
|
const extension = await runBackend<string>("extend", {
|
|
|
|
|
note: notes,
|
|
|
|
|
ollama_host: ollamaHost,
|
|
|
|
|
model: ollamaModel,
|
|
|
|
|
});
|
|
|
|
|
setNotes((prev) => (prev.trim() ? `${prev}\n${extension}` : extension));
|
|
|
|
|
setStatus("Extension ready");
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setStatus("Extend failed");
|
|
|
|
|
console.error(err);
|
|
|
|
|
window.alert("Extend failed. Check console for details.");
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy((prev) => ({ ...prev, extend: false }));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onGenerateConcept = async () => {
|
|
|
|
|
if (!ollamaModel || ollamaModel === "Select model...") {
|
|
|
|
|
window.alert("Please select a model first.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!notes.trim() && !files.length && !websites.length) {
|
|
|
|
|
window.alert("Add files, websites, or notes first.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setBusy((prev) => ({ ...prev, generate: true }));
|
|
|
|
|
setStatus("Generating concept...");
|
|
|
|
|
try {
|
|
|
|
|
const result = await runBackend<{ concept: string; title: string; description: string }>(
|
|
|
|
|
"generate_concept",
|
|
|
|
|
{
|
|
|
|
|
notes,
|
|
|
|
|
files: files.map((f) => f.path),
|
|
|
|
|
websites: websites.map((w) => w.url),
|
|
|
|
|
ollama_host: ollamaHost,
|
|
|
|
|
model: ollamaModel,
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
setConcept(result.concept);
|
|
|
|
|
setTitle(result.title);
|
|
|
|
|
setDescription(result.description);
|
|
|
|
|
setStatus("Concept generated");
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setStatus("Generation failed");
|
|
|
|
|
console.error(err);
|
|
|
|
|
window.alert("Generation failed. Check console for details.");
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy((prev) => ({ ...prev, generate: false }));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onPriorArt = async () => {
|
|
|
|
|
if (!ollamaModel || ollamaModel === "Select model...") {
|
|
|
|
|
window.alert("Please select a model first.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-08 03:14:31 +02:00
|
|
|
const priorArtNotes = notes.trim() ? notes : fallbackIdeaText(title, description, concept);
|
2026-02-04 06:56:56 +01:00
|
|
|
setBusy((prev) => ({ ...prev, prior: true }));
|
|
|
|
|
setStatus("Searching prior art...");
|
|
|
|
|
try {
|
|
|
|
|
const data = await runBackend<PriorArtResponse>("prior_art", {
|
2026-05-08 03:14:31 +02:00
|
|
|
notes: priorArtNotes,
|
|
|
|
|
concept,
|
|
|
|
|
title,
|
|
|
|
|
description,
|
2026-02-04 06:56:56 +01:00
|
|
|
files: files.map((f) => f.path),
|
|
|
|
|
websites: websites.map((w) => w.url),
|
|
|
|
|
ollama_host: ollamaHost,
|
|
|
|
|
model: ollamaModel,
|
|
|
|
|
searx_url: searxUrl,
|
|
|
|
|
});
|
|
|
|
|
setPriorData(data);
|
|
|
|
|
setPriorModalOpen(true);
|
|
|
|
|
setStatus("Prior art ready");
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setStatus("Prior art failed");
|
|
|
|
|
console.error(err);
|
|
|
|
|
window.alert("Prior art search failed. Check console for details.");
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy((prev) => ({ ...prev, prior: false }));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-08 03:10:43 +02:00
|
|
|
const onExportPdf = async () => {
|
2026-02-04 06:56:56 +01:00
|
|
|
if (!concept.trim()) {
|
|
|
|
|
window.alert("Generate or paste concept text first.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setBusy((prev) => ({ ...prev, preview: true }));
|
2026-05-08 03:10:43 +02:00
|
|
|
setStatus("Choose where to save the PDF...");
|
2026-02-04 06:56:56 +01:00
|
|
|
try {
|
2026-05-08 03:10:43 +02:00
|
|
|
const selectedPath = await save({
|
|
|
|
|
title: "Export PDF",
|
|
|
|
|
defaultPath: pdfFileNameFromTitle(title, concept),
|
|
|
|
|
filters: [{ name: "PDF", extensions: ["pdf"] }],
|
|
|
|
|
canCreateDirectories: true,
|
|
|
|
|
});
|
|
|
|
|
if (!selectedPath) {
|
|
|
|
|
setStatus("PDF export cancelled");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setStatus("Exporting PDF...");
|
2026-02-04 06:56:56 +01:00
|
|
|
const result = await runBackend<{ ok: boolean; pdf_path: string; log_path: string }>("preview_pdf", {
|
|
|
|
|
concept,
|
|
|
|
|
title,
|
|
|
|
|
files: files.map((f) => f.path),
|
2026-05-08 03:10:43 +02:00
|
|
|
output_path: ensurePdfExtension(selectedPath),
|
2026-02-04 06:56:56 +01:00
|
|
|
});
|
|
|
|
|
if (result.ok) {
|
2026-05-08 03:10:43 +02:00
|
|
|
setStatus("Opening exported PDF...");
|
2026-05-07 19:11:26 +02:00
|
|
|
try {
|
|
|
|
|
await openPath(result.pdf_path);
|
2026-05-08 03:10:43 +02:00
|
|
|
setStatus("PDF exported and opened");
|
2026-05-07 19:11:26 +02:00
|
|
|
} catch (openErr) {
|
2026-05-08 03:10:43 +02:00
|
|
|
setStatus("PDF exported");
|
2026-05-07 19:11:26 +02:00
|
|
|
console.error(openErr);
|
2026-05-08 03:10:43 +02:00
|
|
|
window.alert(`PDF exported to:\n${result.pdf_path}\n\nCould not open it automatically.`);
|
2026-05-07 19:11:26 +02:00
|
|
|
}
|
2026-02-04 06:56:56 +01:00
|
|
|
} else {
|
2026-05-08 03:10:43 +02:00
|
|
|
setStatus("Export failed");
|
|
|
|
|
window.alert(`Export failed. Log: ${result.log_path}`);
|
2026-02-04 06:56:56 +01:00
|
|
|
}
|
|
|
|
|
} catch (err) {
|
2026-05-08 03:10:43 +02:00
|
|
|
setStatus("Export failed");
|
2026-02-04 06:56:56 +01:00
|
|
|
console.error(err);
|
2026-05-08 03:10:43 +02:00
|
|
|
window.alert("Export failed. Check console for details.");
|
2026-02-04 06:56:56 +01:00
|
|
|
} finally {
|
|
|
|
|
setBusy((prev) => ({ ...prev, preview: false }));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onNewSession = () => {
|
|
|
|
|
if (notes || concept || files.length || websites.length) {
|
|
|
|
|
const ok = window.confirm("Clear the current session?");
|
|
|
|
|
if (!ok) return;
|
|
|
|
|
}
|
|
|
|
|
setFiles([]);
|
|
|
|
|
setWebsites([]);
|
|
|
|
|
setNotes("");
|
|
|
|
|
setRephraseVariants([]);
|
|
|
|
|
setRephraseSelected(null);
|
|
|
|
|
setConcept("");
|
|
|
|
|
setTitle("");
|
|
|
|
|
setDescription("");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onSaveSession = async () => {
|
|
|
|
|
if (!title.trim()) {
|
|
|
|
|
window.alert("Please enter a Title before saving.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const existing = await runBackend<SessionSummary[]>("list_sessions", {});
|
|
|
|
|
const exists = existing.some((s) => s.title === title.trim());
|
|
|
|
|
if (exists) {
|
|
|
|
|
const ok = window.confirm(`A session titled "${title}" exists. Overwrite it?`);
|
|
|
|
|
if (!ok) return;
|
|
|
|
|
}
|
|
|
|
|
await runBackend("save_session", {
|
|
|
|
|
payload: {
|
|
|
|
|
title,
|
|
|
|
|
description,
|
|
|
|
|
notes,
|
|
|
|
|
concept,
|
2026-05-07 23:28:40 +02:00
|
|
|
files: files.map((f) => ({ path: f.path })),
|
|
|
|
|
websites: websites.map((w) => ({ url: w.url })),
|
2026-02-04 06:56:56 +01:00
|
|
|
rephrase_variants: rephraseVariants,
|
|
|
|
|
rephrase_selected_key: rephraseSelected,
|
|
|
|
|
},
|
|
|
|
|
allow_overwrite: true,
|
|
|
|
|
});
|
|
|
|
|
setStatus("Session saved");
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
window.alert("Failed to save session.");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onOpenSession = async () => {
|
2026-05-08 01:22:16 +02:00
|
|
|
setSessionModalOpen(true);
|
|
|
|
|
setSessionsLoading(true);
|
|
|
|
|
setSessions([]);
|
2026-02-04 06:56:56 +01:00
|
|
|
try {
|
|
|
|
|
const list = await runBackend<SessionSummary[]>("list_sessions", {});
|
|
|
|
|
setSessions(list.sort((a, b) => (b.saved_at || 0) - (a.saved_at || 0)));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
2026-05-08 01:22:16 +02:00
|
|
|
setSessionModalOpen(false);
|
2026-02-04 06:56:56 +01:00
|
|
|
window.alert("Failed to load sessions.");
|
2026-05-08 01:22:16 +02:00
|
|
|
} finally {
|
|
|
|
|
setSessionsLoading(false);
|
2026-02-04 06:56:56 +01:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const applySession = async (sessionTitle: string) => {
|
|
|
|
|
try {
|
|
|
|
|
const data = await runBackend<any>("load_session", { title: sessionTitle });
|
|
|
|
|
if (!data) return;
|
|
|
|
|
setTitle(data.title || "");
|
|
|
|
|
setDescription(data.description || "");
|
|
|
|
|
setNotes(data.notes || "");
|
|
|
|
|
setConcept(data.concept || "");
|
|
|
|
|
setFiles(
|
|
|
|
|
(data.files || []).map((f: any) => ({
|
|
|
|
|
kind: "file",
|
|
|
|
|
path: f.path,
|
|
|
|
|
name: f.path.split(/[\\/]/).pop() || f.path,
|
|
|
|
|
type: (f.path.split(".").pop() || "file").toLowerCase(),
|
|
|
|
|
size: "?",
|
|
|
|
|
}))
|
|
|
|
|
);
|
|
|
|
|
setWebsites(
|
|
|
|
|
(data.websites || []).map((w: any) => ({
|
|
|
|
|
kind: "url",
|
|
|
|
|
url: w.url,
|
|
|
|
|
name: w.url.replace(/^https?:\/\//, "").slice(0, 60),
|
|
|
|
|
type: "url",
|
|
|
|
|
size: "web",
|
|
|
|
|
}))
|
|
|
|
|
);
|
|
|
|
|
setRephraseVariants(data.rephrase_variants || []);
|
|
|
|
|
setRephraseSelected(data.rephrase_selected_key || null);
|
|
|
|
|
setSessionModalOpen(false);
|
|
|
|
|
setStatus("Session loaded");
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
window.alert("Failed to open session.");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-07 22:55:45 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
menuActionRef.current = (action: string) => {
|
|
|
|
|
switch (action) {
|
|
|
|
|
case "file-new":
|
|
|
|
|
onNewSession();
|
|
|
|
|
break;
|
|
|
|
|
case "file-open":
|
|
|
|
|
void onOpenSession();
|
|
|
|
|
break;
|
|
|
|
|
case "file-save":
|
|
|
|
|
void onSaveSession();
|
|
|
|
|
break;
|
|
|
|
|
case "settings-open":
|
|
|
|
|
setSettingsModalOpen(true);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let unlisten: (() => void) | undefined;
|
|
|
|
|
let disposed = false;
|
|
|
|
|
|
|
|
|
|
listen<string>("app-menu-action", (event) => {
|
|
|
|
|
menuActionRef.current(event.payload);
|
|
|
|
|
})
|
|
|
|
|
.then((cleanup) => {
|
|
|
|
|
if (disposed) {
|
|
|
|
|
cleanup();
|
|
|
|
|
} else {
|
|
|
|
|
unlisten = cleanup;
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch(() => undefined);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
disposed = true;
|
|
|
|
|
if (unlisten) unlisten();
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-04 06:56:56 +01:00
|
|
|
return (
|
|
|
|
|
<div className="app">
|
|
|
|
|
<div className="panes">
|
|
|
|
|
<div className="column">
|
2026-05-07 23:47:14 +02:00
|
|
|
<section className="panel notes-panel">
|
|
|
|
|
<div className="panel-title">
|
|
|
|
|
<h2>Notes / Thoughts</h2>
|
|
|
|
|
<span>Scratchpad for raw ideas</span>
|
|
|
|
|
</div>
|
|
|
|
|
<textarea
|
|
|
|
|
className="notes-area"
|
|
|
|
|
value={notes}
|
|
|
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
|
|
|
placeholder="Drop in fragments, problem statements, or constraints..."
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{rephraseVariants.length > 0 && (
|
|
|
|
|
<div className="rephrase-panel">
|
|
|
|
|
<strong style={{ fontSize: "12px", color: "var(--accent)" }}>Rephrase variants</strong>
|
|
|
|
|
<div className="rephrase-list">
|
|
|
|
|
{rephraseVariants.map((variant) => (
|
|
|
|
|
<div
|
|
|
|
|
key={variant.key}
|
|
|
|
|
className={`rephrase-item ${rephraseSelected === variant.key ? "active" : ""}`}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setRephraseSelected(variant.key);
|
|
|
|
|
setNotes(variant.text);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div style={{ fontWeight: 600 }}>{variant.label}</div>
|
|
|
|
|
<div style={{ fontSize: "11px", color: "var(--subtle)" }}>
|
|
|
|
|
{variant.text.slice(0, 120)}{variant.text.length > 120 ? "..." : ""}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="controls">
|
|
|
|
|
<button onClick={onRephrase} disabled={busy.rephrase}>Rephrase</button>
|
|
|
|
|
<button onClick={onExtend} disabled={busy.extend}>Extend</button>
|
2026-05-07 23:51:21 +02:00
|
|
|
<button onClick={onPriorArt} disabled={busy.prior}>Find Prior Art</button>
|
2026-05-07 23:47:14 +02:00
|
|
|
<button className="primary" onClick={onGenerateConcept} disabled={busy.generate}>Generate Concept</button>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section className="panel files-panel">
|
2026-02-04 06:56:56 +01:00
|
|
|
<div className="panel-title">
|
|
|
|
|
<h2>Files & Websites</h2>
|
2026-05-08 03:11:49 +02:00
|
|
|
<div className="panel-title-actions">
|
|
|
|
|
<button onClick={onAddFiles}>Add Files</button>
|
|
|
|
|
<button onClick={onAddWebsite}>Add URL</button>
|
|
|
|
|
</div>
|
2026-02-04 06:56:56 +01:00
|
|
|
</div>
|
|
|
|
|
<div className="table">
|
|
|
|
|
<div className="table-header">
|
|
|
|
|
<div>Name</div>
|
|
|
|
|
<div>Path</div>
|
|
|
|
|
<div>Type</div>
|
|
|
|
|
<div>Size</div>
|
2026-05-07 23:41:03 +02:00
|
|
|
<div>Remove</div>
|
2026-02-04 06:56:56 +01:00
|
|
|
</div>
|
|
|
|
|
<div className="table-body">
|
|
|
|
|
{rows.length === 0 && (
|
|
|
|
|
<div className="table-row" style={{ gridTemplateColumns: "1fr" }}>
|
2026-05-08 03:12:25 +02:00
|
|
|
<div style={{ color: "var(--muted)" }}>Drop files here or use Add Files / Add URL.</div>
|
2026-02-04 06:56:56 +01:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{rows.map((row) => (
|
2026-05-07 23:41:03 +02:00
|
|
|
<div key={rowId(row)} className="table-row">
|
2026-02-04 06:56:56 +01:00
|
|
|
<div>{row.name}</div>
|
|
|
|
|
<div>{row.kind === "file" ? row.path : row.url}</div>
|
|
|
|
|
<div>{row.type}</div>
|
|
|
|
|
<div>{row.size}</div>
|
2026-05-07 23:41:03 +02:00
|
|
|
<div>
|
|
|
|
|
<button
|
|
|
|
|
className="remove-row-button danger"
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => onRemoveRow(row)}
|
|
|
|
|
aria-label={`Remove ${row.name}`}
|
|
|
|
|
title={`Remove ${row.name}`}
|
|
|
|
|
>
|
|
|
|
|
X
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-02-04 06:56:56 +01:00
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="column">
|
|
|
|
|
<section className="panel" style={{ flex: 1 }}>
|
|
|
|
|
<div className="panel-title">
|
2026-05-07 19:42:56 +02:00
|
|
|
<h2>{markdownPreview ? "Concept (Preview)" : "Concept (Editable)"}</h2>
|
|
|
|
|
<span>{markdownPreview ? "Rendered Markdown" : "Refine and publish"}</span>
|
2026-02-04 06:56:56 +01:00
|
|
|
</div>
|
2026-05-07 19:42:56 +02:00
|
|
|
{markdownPreview ? (
|
|
|
|
|
<div
|
|
|
|
|
className="markdown-preview md-root"
|
|
|
|
|
role="textbox"
|
|
|
|
|
aria-readonly="true"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
dangerouslySetInnerHTML={{
|
|
|
|
|
__html:
|
|
|
|
|
markdownPreviewHtml ||
|
|
|
|
|
'<p class="md-paragraph markdown-preview__empty">No concept markdown to preview.</p>',
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div className="concept-meta">
|
|
|
|
|
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title" />
|
|
|
|
|
<input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description" />
|
|
|
|
|
</div>
|
|
|
|
|
<textarea
|
|
|
|
|
className="concept-editor"
|
|
|
|
|
value={concept}
|
|
|
|
|
onChange={(e) => setConcept(e.target.value)}
|
|
|
|
|
placeholder="Generated concept markdown appears here..."
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-05-08 03:10:43 +02:00
|
|
|
<div className="controls concept-actions">
|
|
|
|
|
<button onClick={onExportPdf} disabled={busy.preview}>Export PDF</button>
|
2026-05-07 19:42:56 +02:00
|
|
|
<button onClick={() => setMarkdownPreview((value) => !value)}>
|
|
|
|
|
{markdownPreview ? "Edit Markdown" : "Preview Markdown"}
|
|
|
|
|
</button>
|
2026-02-04 06:56:56 +01:00
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-07 23:58:12 +02:00
|
|
|
{websiteModalOpen && (
|
|
|
|
|
<div
|
|
|
|
|
className="modal-backdrop"
|
|
|
|
|
role="presentation"
|
|
|
|
|
onMouseDown={(event) => {
|
|
|
|
|
if (event.target === event.currentTarget) {
|
|
|
|
|
closeWebsiteModal();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<form
|
|
|
|
|
className="settings-window website-window"
|
|
|
|
|
role="dialog"
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
aria-labelledby="website-title"
|
|
|
|
|
onSubmit={(event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
onSubmitWebsite();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div className="settings-header">
|
|
|
|
|
<h3 id="website-title">Add URL</h3>
|
|
|
|
|
<button className="ghost" type="button" onClick={closeWebsiteModal}>
|
|
|
|
|
Close
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="website-form">
|
|
|
|
|
<label htmlFor="website-url">URL</label>
|
|
|
|
|
<input
|
|
|
|
|
id="website-url"
|
|
|
|
|
autoFocus
|
|
|
|
|
value={websiteInput}
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
setWebsiteInput(event.target.value);
|
|
|
|
|
if (websiteError) setWebsiteError("");
|
|
|
|
|
}}
|
|
|
|
|
placeholder="https://example.com"
|
|
|
|
|
type="url"
|
|
|
|
|
/>
|
|
|
|
|
{websiteError && <div className="form-error">{websiteError}</div>}
|
|
|
|
|
<div className="form-actions">
|
|
|
|
|
<button type="button" onClick={closeWebsiteModal}>Cancel</button>
|
|
|
|
|
<button className="primary" type="submit">Add URL</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-04 06:56:56 +01:00
|
|
|
{sessionModalOpen && (
|
|
|
|
|
<div
|
2026-05-08 01:26:56 +02:00
|
|
|
className="modal-backdrop"
|
|
|
|
|
role="presentation"
|
|
|
|
|
onMouseDown={(event) => {
|
|
|
|
|
if (event.target === event.currentTarget) {
|
|
|
|
|
setSessionModalOpen(false);
|
|
|
|
|
}
|
2026-02-04 06:56:56 +01:00
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div
|
2026-05-08 01:26:56 +02:00
|
|
|
className="settings-window session-window"
|
|
|
|
|
role="dialog"
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
aria-labelledby="session-title"
|
2026-02-04 06:56:56 +01:00
|
|
|
>
|
2026-05-08 01:26:56 +02:00
|
|
|
<div className="settings-header">
|
|
|
|
|
<h3 id="session-title">Open Session</h3>
|
|
|
|
|
<button className="ghost" onClick={() => setSessionModalOpen(false)}>
|
|
|
|
|
Close
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-05-08 01:22:16 +02:00
|
|
|
{sessionsLoading && <p>Loading sessions...</p>}
|
|
|
|
|
{!sessionsLoading && sessions.length === 0 && <p>No saved sessions yet.</p>}
|
2026-05-08 01:26:56 +02:00
|
|
|
<div className="session-list">
|
|
|
|
|
{!sessionsLoading &&
|
|
|
|
|
sessions.map((s) => (
|
|
|
|
|
<button
|
|
|
|
|
key={s.title}
|
|
|
|
|
className="ghost session-item"
|
|
|
|
|
onClick={() => applySession(s.title)}
|
|
|
|
|
>
|
|
|
|
|
<strong>{s.title}</strong>
|
|
|
|
|
<div className="session-description">{s.description}</div>
|
|
|
|
|
<div className="session-time">{formatTime(s.saved_at)}</div>
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
2026-02-04 06:56:56 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-05-07 22:55:45 +02:00
|
|
|
{settingsModalOpen && (
|
|
|
|
|
<div
|
|
|
|
|
className="modal-backdrop"
|
|
|
|
|
role="presentation"
|
|
|
|
|
onMouseDown={(event) => {
|
|
|
|
|
if (event.target === event.currentTarget) {
|
|
|
|
|
setSettingsModalOpen(false);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className="settings-window"
|
|
|
|
|
role="dialog"
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
aria-labelledby="settings-title"
|
|
|
|
|
>
|
|
|
|
|
<div className="settings-header">
|
|
|
|
|
<h3 id="settings-title">Settings</h3>
|
|
|
|
|
<button className="ghost" onClick={() => setSettingsModalOpen(false)}>
|
|
|
|
|
Close
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="settings-grid">
|
|
|
|
|
<label htmlFor="settings-ollama-host">Ollama host</label>
|
|
|
|
|
<input
|
|
|
|
|
id="settings-ollama-host"
|
|
|
|
|
value={ollamaHost}
|
|
|
|
|
onChange={(e) => setOllamaHost(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<label htmlFor="settings-model">Model</label>
|
|
|
|
|
<select
|
|
|
|
|
id="settings-model"
|
|
|
|
|
value={ollamaModel}
|
|
|
|
|
onChange={(e) => setOllamaModel(e.target.value)}
|
|
|
|
|
>
|
|
|
|
|
<option value="Select model...">Select model...</option>
|
|
|
|
|
{models.map((m) => (
|
|
|
|
|
<option key={m} value={m}>
|
|
|
|
|
{m}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
|
|
|
|
|
<label htmlFor="settings-searx-url">SearXNG URL</label>
|
|
|
|
|
<input
|
|
|
|
|
id="settings-searx-url"
|
|
|
|
|
value={searxUrl}
|
|
|
|
|
onChange={(e) => setSearxUrl(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-04 06:56:56 +01:00
|
|
|
{priorModalOpen && priorData && (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
position: "fixed",
|
|
|
|
|
inset: 0,
|
2026-05-07 19:22:41 +02:00
|
|
|
background: "rgba(69, 10, 10, 0.28)",
|
2026-02-04 06:56:56 +01:00
|
|
|
display: "grid",
|
|
|
|
|
placeItems: "center",
|
|
|
|
|
zIndex: 10,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
2026-05-07 19:22:41 +02:00
|
|
|
background: "var(--panel)",
|
|
|
|
|
border: "1px solid var(--line)",
|
2026-02-04 06:56:56 +01:00
|
|
|
padding: "20px",
|
2026-05-07 19:22:41 +02:00
|
|
|
borderRadius: "6px",
|
2026-02-04 06:56:56 +01:00
|
|
|
width: "min(680px, 92vw)",
|
|
|
|
|
maxHeight: "85vh",
|
|
|
|
|
overflow: "auto",
|
2026-05-07 19:22:41 +02:00
|
|
|
boxShadow: "var(--dialog-shadow)",
|
2026-02-04 06:56:56 +01:00
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<h3 style={{ marginTop: 0 }}>Prior Art Results</h3>
|
|
|
|
|
<div style={{ marginBottom: "12px" }}>
|
|
|
|
|
<strong>Queries</strong>
|
|
|
|
|
<ul style={{ marginTop: "6px" }}>
|
|
|
|
|
{priorData.queries.map((q) => (
|
2026-05-07 19:22:41 +02:00
|
|
|
<li key={q} style={{ fontSize: "12px", color: "var(--ink)" }}>
|
2026-02-04 06:56:56 +01:00
|
|
|
{q}
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
{priorData.results.length === 0 && <p>No results found.</p>}
|
|
|
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
|
|
|
|
{priorData.results.map((r) => (
|
2026-05-07 19:22:41 +02:00
|
|
|
<div key={r.url} style={{ border: "1px solid var(--line)", padding: "12px", borderRadius: "4px", background: "var(--surface)" }}>
|
2026-02-04 06:56:56 +01:00
|
|
|
<div style={{ fontWeight: 600, marginBottom: "4px" }}>{r.title || r.url}</div>
|
2026-05-07 19:22:41 +02:00
|
|
|
<div style={{ fontSize: "12px", color: "var(--muted)" }}>{r.url}</div>
|
2026-02-04 06:56:56 +01:00
|
|
|
{r.snippet && (
|
2026-05-07 19:22:41 +02:00
|
|
|
<div style={{ marginTop: "6px", fontSize: "12px", color: "var(--ink)" }}>{r.snippet}</div>
|
2026-02-04 06:56:56 +01:00
|
|
|
)}
|
|
|
|
|
{typeof r.score === "number" && (
|
2026-05-07 19:22:41 +02:00
|
|
|
<div style={{ marginTop: "4px", fontSize: "11px", color: "var(--subtle)" }}>Score: {r.score.toFixed(3)}</div>
|
2026-02-04 06:56:56 +01:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ marginTop: "16px", textAlign: "right" }}>
|
|
|
|
|
<button onClick={() => setPriorModalOpen(false)}>Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-05-07 22:59:35 +02:00
|
|
|
|
|
|
|
|
{statusVisible && status && (
|
|
|
|
|
<div className="status-toast" role="status" aria-live="polite">
|
|
|
|
|
{status}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-04 06:56:56 +01:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|