auto-git:
[add] src/App.tsx
This commit is contained in:
893
src/App.tsx
Normal file
893
src/App.tsx
Normal file
@@ -0,0 +1,893 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrent } from "@tauri-apps/api/webviewWindow";
|
||||
|
||||
const IMAGE_PROMPT_PLACEHOLDER = "Generated image prompt will appear here.";
|
||||
|
||||
type FileEntry = {
|
||||
kind: "file";
|
||||
path: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: string;
|
||||
include: boolean;
|
||||
};
|
||||
|
||||
type UrlEntry = {
|
||||
kind: "url";
|
||||
url: string;
|
||||
name: string;
|
||||
type: "url";
|
||||
size: string;
|
||||
include: boolean;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [status, setStatus] = useState("Ready");
|
||||
const [files, setFiles] = useState<FileEntry[]>([]);
|
||||
const [websites, setWebsites] = useState<UrlEntry[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||
const [notes, setNotes] = useState("");
|
||||
const [rephraseVariants, setRephraseVariants] = useState<RephraseVariant[]>([]);
|
||||
const [rephraseSelected, setRephraseSelected] = useState<string | null>(null);
|
||||
const [concept, setConcept] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [imagePrompt, setImagePrompt] = useState(IMAGE_PROMPT_PLACEHOLDER);
|
||||
const [ollamaHost, setOllamaHost] = useState("http://localhost:11434");
|
||||
const [ollamaModel, setOllamaModel] = useState("Select model...");
|
||||
const [gitRemote, setGitRemote] = useState("");
|
||||
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[]>([]);
|
||||
|
||||
const [priorModalOpen, setPriorModalOpen] = useState(false);
|
||||
const [priorData, setPriorData] = useState<PriorArtResponse | null>(null);
|
||||
|
||||
const rows = useMemo<RowEntry[]>(() => [...files, ...websites], [files, websites]);
|
||||
|
||||
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.git_remote_url) setGitRemote(settings.git_remote_url);
|
||||
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,
|
||||
git_remote_url: gitRemote,
|
||||
searx_url: searxUrl,
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [ollamaHost, ollamaModel, gitRemote, searxUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
const attach = async () => {
|
||||
try {
|
||||
const current = getCurrent();
|
||||
unlisten = await current.onFileDropEvent((event) => {
|
||||
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 includeMap = useMemo(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
files.forEach((f) => (map[f.path] = f.include));
|
||||
websites.forEach((w) => (map[w.url] = w.include));
|
||||
return map;
|
||||
}, [files, websites]);
|
||||
|
||||
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,
|
||||
include: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
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 onAddFolder = async () => {
|
||||
const selection = await open({ directory: true, title: "Select folder" });
|
||||
if (!selection) return;
|
||||
const paths = Array.isArray(selection) ? selection : [selection];
|
||||
await addFilesByPath(paths as string[], true);
|
||||
};
|
||||
|
||||
const onAddWebsite = () => {
|
||||
const url = window.prompt("Enter a URL (starting with http:// or https://):");
|
||||
if (!url) return;
|
||||
const trimmed = url.trim();
|
||||
if (!/^https?:\/\//i.test(trimmed)) {
|
||||
window.alert("Please enter a valid http(s) URL.");
|
||||
return;
|
||||
}
|
||||
setWebsites((prev) => {
|
||||
if (prev.some((w) => w.url === trimmed)) return prev;
|
||||
const name = trimmed.replace(/^https?:\/\//, "").slice(0, 60);
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
kind: "url",
|
||||
url: trimmed,
|
||||
name,
|
||||
type: "url",
|
||||
size: "web",
|
||||
include: true,
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
const onRemoveSelected = () => {
|
||||
if (!selectedRows.size) return;
|
||||
setFiles((prev) => prev.filter((f) => !selectedRows.has(rowId(f))));
|
||||
setWebsites((prev) => prev.filter((w) => !selectedRows.has(rowId(w))));
|
||||
setSelectedRows(new Set());
|
||||
};
|
||||
|
||||
const onClearAll = () => {
|
||||
setFiles([]);
|
||||
setWebsites([]);
|
||||
setSelectedRows(new Set());
|
||||
};
|
||||
|
||||
const toggleRowSelection = (row: RowEntry) => {
|
||||
const id = rowId(row);
|
||||
setSelectedRows((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleInclude = (row: RowEntry) => {
|
||||
if (row.kind === "file") {
|
||||
setFiles((prev) =>
|
||||
prev.map((f) => (f.path === row.path ? { ...f, include: !f.include } : f))
|
||||
);
|
||||
} else {
|
||||
setWebsites((prev) =>
|
||||
prev.map((w) => (w.url === row.url ? { ...w, include: !w.include } : w))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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),
|
||||
include_map: includeMap,
|
||||
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 onGenerateImagePrompt = async () => {
|
||||
if (!ollamaModel || ollamaModel === "Select model...") {
|
||||
window.alert("Please select a model first.");
|
||||
return;
|
||||
}
|
||||
const ideaText = [title ? `Title: ${title}` : "", description ? `Description: ${description}` : "", concept || notes]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
if (!ideaText.trim()) {
|
||||
window.alert("Add concept text or notes first.");
|
||||
return;
|
||||
}
|
||||
setBusy((prev) => ({ ...prev, imagePrompt: true }));
|
||||
setStatus("Generating image prompt...");
|
||||
try {
|
||||
const promptText = await runBackend<string>("generate_image_prompt", {
|
||||
idea_text: ideaText,
|
||||
ollama_host: ollamaHost,
|
||||
model: ollamaModel,
|
||||
});
|
||||
setImagePrompt(promptText);
|
||||
setStatus("Image prompt ready");
|
||||
} catch (err) {
|
||||
setStatus("Image prompt failed");
|
||||
console.error(err);
|
||||
window.alert("Image prompt failed. Check console for details.");
|
||||
} finally {
|
||||
setBusy((prev) => ({ ...prev, imagePrompt: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const onGenerateImage = async () => {
|
||||
if (!imagePrompt.trim() || imagePrompt === IMAGE_PROMPT_PLACEHOLDER) {
|
||||
window.alert("Generate an image prompt first.");
|
||||
return;
|
||||
}
|
||||
const output = await open({ directory: true, title: "Select output folder" });
|
||||
if (!output) return;
|
||||
const outputDir = Array.isArray(output) ? output[0] : output;
|
||||
setBusy((prev) => ({ ...prev, imageGen: true }));
|
||||
setStatus("Generating image...");
|
||||
try {
|
||||
const result = await runBackend<{ output_path: string }>("generate_image", {
|
||||
prompt: imagePrompt,
|
||||
output_dir: outputDir,
|
||||
title,
|
||||
});
|
||||
setStatus(`Image saved: ${result.output_path}`);
|
||||
window.alert(`Image saved to:\n${result.output_path}`);
|
||||
} catch (err) {
|
||||
setStatus("Image generation failed");
|
||||
console.error(err);
|
||||
window.alert("Image generation failed. Check console for details.");
|
||||
} finally {
|
||||
setBusy((prev) => ({ ...prev, imageGen: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const onPriorArt = async () => {
|
||||
if (!ollamaModel || ollamaModel === "Select model...") {
|
||||
window.alert("Please select a model first.");
|
||||
return;
|
||||
}
|
||||
setBusy((prev) => ({ ...prev, prior: true }));
|
||||
setStatus("Searching prior art...");
|
||||
try {
|
||||
const data = await runBackend<PriorArtResponse>("prior_art", {
|
||||
notes,
|
||||
files: files.map((f) => f.path),
|
||||
websites: websites.map((w) => w.url),
|
||||
include_map: includeMap,
|
||||
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 onPreview = async () => {
|
||||
if (!concept.trim()) {
|
||||
window.alert("Generate or paste concept text first.");
|
||||
return;
|
||||
}
|
||||
setBusy((prev) => ({ ...prev, preview: true }));
|
||||
setStatus("Preparing preview...");
|
||||
try {
|
||||
const result = await runBackend<{ ok: boolean; pdf_path: string; log_path: string }>("preview_pdf", {
|
||||
concept,
|
||||
title,
|
||||
files: files.map((f) => f.path),
|
||||
include_map: includeMap,
|
||||
});
|
||||
if (result.ok) {
|
||||
setStatus("Preview PDF ready");
|
||||
window.alert(`Preview PDF saved to:\n${result.pdf_path}`);
|
||||
} else {
|
||||
setStatus("Preview failed");
|
||||
window.alert(`Preview failed. Log: ${result.log_path}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus("Preview failed");
|
||||
console.error(err);
|
||||
window.alert("Preview failed. Check console for details.");
|
||||
} finally {
|
||||
setBusy((prev) => ({ ...prev, preview: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const onPush = async () => {
|
||||
if (!title.trim() || !description.trim()) {
|
||||
window.alert("Title and Description are required.");
|
||||
return;
|
||||
}
|
||||
if (!concept.trim()) {
|
||||
window.alert("Generate or paste concept text first.");
|
||||
return;
|
||||
}
|
||||
const repoDir = await open({ directory: true, title: "Select concepts repo folder" });
|
||||
if (!repoDir) return;
|
||||
const repoPath = Array.isArray(repoDir) ? repoDir[0] : repoDir;
|
||||
setBusy((prev) => ({ ...prev, push: true }));
|
||||
setStatus("Pushing to repo...");
|
||||
try {
|
||||
const result = await runBackend<{
|
||||
repo_dir: string;
|
||||
concept_dir: string;
|
||||
pdf_ok: boolean;
|
||||
pdf_log: string;
|
||||
pushed: boolean;
|
||||
}>("push_repo", {
|
||||
title,
|
||||
description,
|
||||
concept,
|
||||
files: files.map((f) => f.path),
|
||||
include_map: includeMap,
|
||||
git_remote_url: gitRemote,
|
||||
repo_dir: repoPath,
|
||||
});
|
||||
if (!result.pdf_ok) {
|
||||
window.alert(`PDF export failed. Log: ${result.pdf_log}`);
|
||||
}
|
||||
setStatus(result.pushed ? "Pushed to remote" : "Committed locally");
|
||||
window.alert(`Concept saved to:\n${result.concept_dir}`);
|
||||
} catch (err) {
|
||||
setStatus("Push failed");
|
||||
console.error(err);
|
||||
window.alert("Push failed. Check console for details.");
|
||||
} finally {
|
||||
setBusy((prev) => ({ ...prev, push: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const onNewSession = () => {
|
||||
if (notes || concept || files.length || websites.length) {
|
||||
const ok = window.confirm("Clear the current session?");
|
||||
if (!ok) return;
|
||||
}
|
||||
setFiles([]);
|
||||
setWebsites([]);
|
||||
setSelectedRows(new Set());
|
||||
setNotes("");
|
||||
setRephraseVariants([]);
|
||||
setRephraseSelected(null);
|
||||
setConcept("");
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setImagePrompt(IMAGE_PROMPT_PLACEHOLDER);
|
||||
};
|
||||
|
||||
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,
|
||||
files: files.map((f) => ({ path: f.path, include: f.include })),
|
||||
websites: websites.map((w) => ({ url: w.url, include: w.include })),
|
||||
rephrase_variants: rephraseVariants,
|
||||
rephrase_selected_key: rephraseSelected,
|
||||
image_prompt: imagePrompt === IMAGE_PROMPT_PLACEHOLDER ? "" : imagePrompt,
|
||||
},
|
||||
allow_overwrite: true,
|
||||
});
|
||||
setStatus("Session saved");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
window.alert("Failed to save session.");
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenSession = async () => {
|
||||
try {
|
||||
const list = await runBackend<SessionSummary[]>("list_sessions", {});
|
||||
setSessions(list.sort((a, b) => (b.saved_at || 0) - (a.saved_at || 0)));
|
||||
setSessionModalOpen(true);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
window.alert("Failed to load sessions.");
|
||||
}
|
||||
};
|
||||
|
||||
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: "?",
|
||||
include: f.include ?? true,
|
||||
}))
|
||||
);
|
||||
setWebsites(
|
||||
(data.websites || []).map((w: any) => ({
|
||||
kind: "url",
|
||||
url: w.url,
|
||||
name: w.url.replace(/^https?:\/\//, "").slice(0, 60),
|
||||
type: "url",
|
||||
size: "web",
|
||||
include: w.include ?? true,
|
||||
}))
|
||||
);
|
||||
setRephraseVariants(data.rephrase_variants || []);
|
||||
setRephraseSelected(data.rephrase_selected_key || null);
|
||||
setImagePrompt(data.image_prompt || IMAGE_PROMPT_PLACEHOLDER);
|
||||
setSessionModalOpen(false);
|
||||
setStatus("Session loaded");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
window.alert("Failed to open session.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="top-bar">
|
||||
<div className="top-actions">
|
||||
<button className="ghost" onClick={onNewSession}>New</button>
|
||||
<button className="ghost" onClick={onOpenSession}>Open</button>
|
||||
<button className="ghost" onClick={onSaveSession}>Save</button>
|
||||
</div>
|
||||
<div className="status-pill">{status}</div>
|
||||
</div>
|
||||
|
||||
<div className="panes">
|
||||
<div className="column">
|
||||
<section className="panel">
|
||||
<div className="panel-title">
|
||||
<h2>Files & Websites</h2>
|
||||
<span>Drag & drop files; add URLs</span>
|
||||
</div>
|
||||
<div className="table">
|
||||
<div className="table-header">
|
||||
<div>Name</div>
|
||||
<div>Path</div>
|
||||
<div>Type</div>
|
||||
<div>Size</div>
|
||||
<div>Add to Repo</div>
|
||||
</div>
|
||||
<div className="table-body">
|
||||
{rows.length === 0 && (
|
||||
<div className="table-row" style={{ gridTemplateColumns: "1fr" }}>
|
||||
<div style={{ color: "#8b7a6a" }}>Drop files here or use the buttons below.</div>
|
||||
</div>
|
||||
)}
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={rowId(row)}
|
||||
className={`table-row ${selectedRows.has(rowId(row)) ? "selected" : ""}`}
|
||||
onClick={() => toggleRowSelection(row)}
|
||||
>
|
||||
<div>{row.name}</div>
|
||||
<div>{row.kind === "file" ? row.path : row.url}</div>
|
||||
<div>{row.type}</div>
|
||||
<div>{row.size}</div>
|
||||
<div
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleInclude(row);
|
||||
}}
|
||||
>
|
||||
<div className={`checkbox ${row.include ? "checked" : ""}`}>{row.include ? "✓" : ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="controls">
|
||||
<button onClick={onAddFiles}>Add Files</button>
|
||||
<button onClick={onAddFolder}>Add Folder</button>
|
||||
<button onClick={onAddWebsite}>Add Website</button>
|
||||
<button onClick={onRemoveSelected} className="danger">Remove Selected</button>
|
||||
<button onClick={onClearAll} className="danger">Clear All</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel" style={{ flex: 1 }}>
|
||||
<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: "#5b4c41" }}>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: "#6d5f52" }}>
|
||||
{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>
|
||||
<button className="primary" onClick={onGenerateConcept} disabled={busy.generate}>Generate Concept</button>
|
||||
<button className="secondary" onClick={onPriorArt} disabled={busy.prior}>Find Prior Art</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="column">
|
||||
<section className="panel" style={{ flex: 1 }}>
|
||||
<div className="panel-title">
|
||||
<h2>Concept (Editable)</h2>
|
||||
<span>Refine and publish</span>
|
||||
</div>
|
||||
<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
|
||||
value={concept}
|
||||
onChange={(e) => setConcept(e.target.value)}
|
||||
placeholder="Generated concept markdown appears here..."
|
||||
style={{ flex: 1, minHeight: 240 }}
|
||||
/>
|
||||
<div className="controls">
|
||||
<button onClick={onPreview} disabled={busy.preview}>Preview PDF</button>
|
||||
<button className="primary" onClick={onPush} disabled={busy.push}>Push to Repo</button>
|
||||
<button onClick={onGenerateImagePrompt} disabled={busy.imagePrompt}>Generate image prompt</button>
|
||||
<button onClick={onGenerateImage} disabled={busy.imageGen}>Generate Image</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="image-prompt"
|
||||
value={imagePrompt}
|
||||
onChange={(e) => setImagePrompt(e.target.value)}
|
||||
placeholder={IMAGE_PROMPT_PLACEHOLDER}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bottom-bar">
|
||||
<div className="input-group">
|
||||
<label>Ollama host</label>
|
||||
<input value={ollamaHost} onChange={(e) => setOllamaHost(e.target.value)} />
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>Model</label>
|
||||
<select 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>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>Remote repo</label>
|
||||
<input value={gitRemote} onChange={(e) => setGitRemote(e.target.value)} placeholder="https://..." />
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>SearXNG URL</label>
|
||||
<input value={searxUrl} onChange={(e) => setSearxUrl(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sessionModalOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(31, 26, 22, 0.45)",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#fffaf3",
|
||||
padding: "20px",
|
||||
borderRadius: "16px",
|
||||
width: "min(560px, 90vw)",
|
||||
maxHeight: "80vh",
|
||||
overflow: "auto",
|
||||
boxShadow: "0 20px 50px rgba(0,0,0,0.2)",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0 }}>Open Session</h3>
|
||||
{sessions.length === 0 && <p>No saved sessions yet.</p>}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||
{sessions.map((s) => (
|
||||
<button
|
||||
key={s.title}
|
||||
className="ghost"
|
||||
style={{ textAlign: "left", borderRadius: "12px" }}
|
||||
onClick={() => applySession(s.title)}
|
||||
>
|
||||
<strong>{s.title}</strong>
|
||||
<div style={{ fontSize: "12px", color: "#6d5f52" }}>{s.description}</div>
|
||||
<div style={{ fontSize: "11px", color: "#8b7a6a" }}>{formatTime(s.saved_at)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: "16px", textAlign: "right" }}>
|
||||
<button onClick={() => setSessionModalOpen(false)}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{priorModalOpen && priorData && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(31, 26, 22, 0.45)",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#fffaf3",
|
||||
padding: "20px",
|
||||
borderRadius: "16px",
|
||||
width: "min(680px, 92vw)",
|
||||
maxHeight: "85vh",
|
||||
overflow: "auto",
|
||||
boxShadow: "0 20px 50px rgba(0,0,0,0.2)",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0 }}>Prior Art Results</h3>
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
<strong>Queries</strong>
|
||||
<ul style={{ marginTop: "6px" }}>
|
||||
{priorData.queries.map((q) => (
|
||||
<li key={q} style={{ fontSize: "12px", color: "#5b4c41" }}>
|
||||
{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) => (
|
||||
<div key={r.url} style={{ border: "1px solid #eadfce", padding: "12px", borderRadius: "12px" }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: "4px" }}>{r.title || r.url}</div>
|
||||
<div style={{ fontSize: "12px", color: "#6d5f52" }}>{r.url}</div>
|
||||
{r.snippet && (
|
||||
<div style={{ marginTop: "6px", fontSize: "12px", color: "#5b4c41" }}>{r.snippet}</div>
|
||||
)}
|
||||
{typeof r.score === "number" && (
|
||||
<div style={{ marginTop: "4px", fontSize: "11px", color: "#8b7a6a" }}>Score: {r.score.toFixed(3)}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: "16px", textAlign: "right" }}>
|
||||
<button onClick={() => setPriorModalOpen(false)}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user