import { useEffect, useMemo, useRef, useState } from "react"; import { listen } from "@tauri-apps/api/event"; import { open, save } from "@tauri-apps/plugin-dialog"; import { invoke } from "@tauri-apps/api/core"; import { getCurrentWebview } from "@tauri-apps/api/webview"; import { markdownToHTML } from "./markdown/markdown"; import "./markdown/markdown-render.css"; 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(action: string, payload: Record): Promise { return (await invoke("run_python_action", { action, payload })) as T; } async function openPath(path: string): Promise { await invoke("open_path", { path }); } 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}`; } 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`; } 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"); } export default function App() { const [status, setStatusMessage] = useState(""); const [statusVersion, setStatusVersion] = useState(0); const [statusVisible, setStatusVisible] = useState(false); const [files, setFiles] = useState([]); const [websites, setWebsites] = useState([]); const [notes, setNotes] = useState(""); const [rephraseVariants, setRephraseVariants] = useState([]); const [rephraseSelected, setRephraseSelected] = useState(null); const [concept, setConcept] = useState(""); const [markdownPreview, setMarkdownPreview] = useState(false); 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([]); const [busy, setBusy] = useState>({}); const [sessionModalOpen, setSessionModalOpen] = useState(false); const [sessions, setSessions] = useState([]); const [sessionsLoading, setSessionsLoading] = useState(false); const [priorModalOpen, setPriorModalOpen] = useState(false); const [priorData, setPriorData] = useState(null); const [websiteModalOpen, setWebsiteModalOpen] = useState(false); const [websiteInput, setWebsiteInput] = useState(""); const [websiteError, setWebsiteError] = useState(""); const [settingsModalOpen, setSettingsModalOpen] = useState(false); const menuActionRef = useRef<(action: string) => void>(() => undefined); const rows = useMemo(() => [...files, ...websites], [files, websites]); const markdownPreviewHtml = useMemo( () => (markdownPreview ? markdownToHTML(concept) : ""), [concept, markdownPreview] ); 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]); useEffect(() => { let mounted = true; const bootstrap = async () => { try { const settings = await runBackend>("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("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); }, [ollamaHost, ollamaModel, searxUrl]); useEffect(() => { let unlisten: (() => void) | undefined; const attach = async () => { try { const current = getCurrentWebview() as any; unlisten = await current.onDragDropEvent((event: any) => { if (event.payload?.type === "drop") { 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 = () => { setWebsiteInput(""); setWebsiteError(""); setWebsiteModalOpen(true); }; const onSubmitWebsite = () => { const trimmed = websiteInput.trim(); if (!/^https?:\/\//i.test(trimmed)) { setWebsiteError("Enter a valid http(s) URL."); return; } 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(""); }; 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}`); }; 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("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("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; } const priorArtNotes = notes.trim() ? notes : fallbackIdeaText(title, description, concept); setBusy((prev) => ({ ...prev, prior: true })); setStatus("Searching prior art..."); try { const data = await runBackend("prior_art", { notes: priorArtNotes, concept, title, description, 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 })); } }; const onExportPdf = async () => { if (!concept.trim()) { window.alert("Generate or paste concept text first."); return; } setBusy((prev) => ({ ...prev, preview: true })); setStatus("Choose where to save the PDF..."); try { 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..."); const result = await runBackend<{ ok: boolean; pdf_path: string; log_path: string }>("preview_pdf", { concept, title, files: files.map((f) => f.path), output_path: ensurePdfExtension(selectedPath), }); if (result.ok) { setStatus("Opening exported PDF..."); try { await openPath(result.pdf_path); setStatus("PDF exported and opened"); } catch (openErr) { setStatus("PDF exported"); console.error(openErr); window.alert(`PDF exported to:\n${result.pdf_path}\n\nCould not open it automatically.`); } } else { setStatus("Export failed"); window.alert(`Export failed. Log: ${result.log_path}`); } } catch (err) { setStatus("Export failed"); console.error(err); window.alert("Export failed. Check console for details."); } 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("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, files: files.map((f) => ({ path: f.path })), websites: websites.map((w) => ({ url: w.url })), 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 () => { setSessionModalOpen(true); setSessionsLoading(true); setSessions([]); try { const list = await runBackend("list_sessions", {}); setSessions(list.sort((a, b) => (b.saved_at || 0) - (a.saved_at || 0))); } catch (err) { console.error(err); setSessionModalOpen(false); window.alert("Failed to load sessions."); } finally { setSessionsLoading(false); } }; const applySession = async (sessionTitle: string) => { try { const data = await runBackend("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."); } }; 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("app-menu-action", (event) => { menuActionRef.current(event.payload); }) .then((cleanup) => { if (disposed) { cleanup(); } else { unlisten = cleanup; } }) .catch(() => undefined); return () => { disposed = true; if (unlisten) unlisten(); }; }, []); return (

Notes / Thoughts

Scratchpad for raw ideas