Add configurable model prompts
This commit is contained in:
+30
-13
@@ -88,9 +88,10 @@ const StructuredEditor = forwardRef<StructuredEditorHandle, { value: string; onC
|
||||
return <div className="manuscript-editor structured-editor"><div className="format-toolbar" aria-label="Text formatting"><button type="button" onClick={() => instance.chain().focus().toggleBold().run()} aria-pressed={instance.isActive("bold")}><strong>B</strong></button><button type="button" onClick={() => instance.chain().focus().toggleItalic().run()} aria-pressed={instance.isActive("italic")}><em>I</em></button><button type="button" onClick={() => instance.chain().focus().toggleHeading({ level: 2 }).run()} aria-pressed={instance.isActive("heading", { level: 2 })}>H2</button><button type="button" onClick={() => instance.chain().focus().toggleBulletList().run()} aria-pressed={instance.isActive("bulletList")}>List</button><button type="button" onClick={() => instance.chain().focus().undo().run()} disabled={!instance.can().undo()}>Undo</button><button type="button" onClick={() => instance.chain().focus().redo().run()} disabled={!instance.can().redo()}>Redo</button></div><EditorContent editor={instance} /></div>;
|
||||
});
|
||||
export default function Thinkloom() {
|
||||
const [project, setProject] = useState<State>(initial); const [hydrated, setHydrated] = useState(false); const [view, setView] = useState<View>("ideation"); const [message, setMessage] = useState(""); const [notice, setNotice] = useState("Project ready. History is being recorded."); const [busy, setBusy] = useState(false); const [listening, setListening] = useState(false); const [showRejected, setShowRejected] = useState(false); const [editingIdea, setEditingIdea] = useState<string | null>(null); const [newHabit, setNewHabit] = useState(""); const [sanitized, setSanitized] = useState(true); const [credential, setCredential] = useState(""); const [projectPath, setProjectPath] = useState(""); const editor = useRef<StructuredEditorHandle>(null); const finalEditor = useRef<HTMLTextAreaElement>(null); const conversationEnd = useRef<HTMLDivElement>(null);
|
||||
const [project, setProject] = useState<State>(initial); const [hydrated, setHydrated] = useState(false); const [view, setView] = useState<View>("ideation"); const [message, setMessage] = useState(""); const [notice, setNotice] = useState("Project ready. History is being recorded."); const [busy, setBusy] = useState(false); const [listening, setListening] = useState(false); const [showRejected, setShowRejected] = useState(false); const [editingIdea, setEditingIdea] = useState<string | null>(null); const [newHabit, setNewHabit] = useState(""); const [sanitized, setSanitized] = useState(true); const [credential, setCredential] = useState(""); const [projectPath, setProjectPath] = useState(""); const [promptPath, setPromptPath] = useState(""); const editor = useRef<StructuredEditorHandle>(null); const finalEditor = useRef<HTMLTextAreaElement>(null); const conversationEnd = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { queueMicrotask(() => { try { const raw = localStorage.getItem("thinkloom-project-v1"); if (raw) setProject({ ...initial, ...JSON.parse(raw) as State }); } catch { /* Ignore invalid local recovery state. */ } setHydrated(true); }); }, []);
|
||||
useEffect(() => { if (hydrated) localStorage.setItem("thinkloom-project-v1", JSON.stringify(project)); }, [project, hydrated]);
|
||||
useEffect(() => { void invokeNative<{ directory: string }>("ensure_prompt_files").then((info) => { if (info?.directory) setPromptPath(info.directory); }).catch((error) => setNotice(`Prompt configuration could not be prepared: ${nativeError(error)}`)); }, []);
|
||||
const mutate = useCallback((type: string, summary: string, update: (current: State) => State, actor: Actor = "user") => { setProject((current) => { const previousHash = current.events.at(-1)?.hash ?? null; const event: Event = { id: uid("event"), type, actor, at: now(), summary, previousHash, hash: hash(`${previousHash}|${type}|${summary}|${Date.now()}`), provider: actor === "assistant" ? current.provider.name : undefined }; const next = { ...update(current), events: [...current.events, event], updatedAt: event.at }; void invokeNative("persist_state", { appState: next }).catch(() => undefined); return next; }); setNotice(summary); }, []);
|
||||
const navigate = (next: View) => { setView(next); if (["ideation", "drafting", "finalization"].includes(next) && project.phase !== next) mutate("PHASE_CHANGED", `Moved to ${next}`, (current) => ({ ...current, phase: next as Phase })); };
|
||||
useEffect(() => { const shortcut = (e: KeyboardEvent) => { if (!e.altKey) return; if (e.key === "1") document.getElementById("ideas-panel")?.focus(); if (e.key === "2") editor.current?.focus(); if (e.key === "3") document.getElementById("assistant-panel")?.focus(); }; window.addEventListener("keydown", shortcut); return () => window.removeEventListener("keydown", shortcut); }, []);
|
||||
@@ -102,11 +103,9 @@ export default function Thinkloom() {
|
||||
const userTurn: Turn = { id: uid("turn"), speaker: "user", text, createdAt: now() };
|
||||
mutate("USER_TEXT_TURN_CREATED", "Saved your conversation turn", (current) => ({ ...current, turns: [...current.turns, userTurn] }));
|
||||
const conversation = [...project.turns, userTurn].slice(-10).map((turn) => `${turn.speaker === "user" ? "Writer" : "Thinkloom"}: ${turn.text}`).join("\n");
|
||||
const challengeGuidance = project.challenge === "Gentle" ? "Be warm and exploratory; help the writer locate a concrete personal example." : project.challenge === "Rigorous" ? "Test the claim respectfully; ask for evidence, a distinction, or a counterexample." : "Surface one useful tension or assumption without forcing a predetermined interpretation.";
|
||||
const prompt = `Continue this ideation conversation. Respond directly and naturally to the writer's latest message, then ask exactly one focused question that helps develop their writing. ${challengeGuidance}`;
|
||||
let reply: string;
|
||||
try {
|
||||
const generated = await invokeNative<string>("generate_text", { profile: project.provider, prompt, context: conversation, cloudApproved: project.cloudApproved, purpose: "conversation" });
|
||||
const generated = await invokeNative<string>("generate_text", { profile: project.provider, promptVariables: { challenge: project.challenge, context: conversation }, cloudApproved: project.cloudApproved, purpose: "conversation" });
|
||||
if (!generated?.trim()) throw new Error("The provider returned an empty response.");
|
||||
reply = generated.trim();
|
||||
setProject((current) => ({ ...current, provider: { ...current.provider, connected: true } }));
|
||||
@@ -128,14 +127,30 @@ export default function Thinkloom() {
|
||||
const mergeSelected = () => { if (selectedIdeas.length < 2) { setNotice("Select at least two ideas to merge."); return; } const merged: Idea = { id: uid("idea"), title: selectedIdeas.map((idea) => idea.title).join(" + ").slice(0, 70), summary: selectedIdeas.map((idea) => idea.summary).join(" "), detail: "A synthesis retaining both parent ideas.", status: "accepted", sourceTurnIds: [...new Set(selectedIdeas.flatMap((idea) => idea.sourceTurnIds))], parentIdeaIds: selectedIdeas.map((idea) => idea.id), tags: [...new Set(selectedIdeas.flatMap((idea) => idea.tags))], pinned: true, selected: true, createdBy: "mixed" }; mutate("IDEAS_MERGED", `Merged ${selectedIdeas.length} ideas`, (current) => ({ ...current, ideas: [...current.ideas.map((idea) => selectedIdeas.some((selected) => selected.id === idea.id) ? { ...idea, selected: false } : idea), merged] })); };
|
||||
|
||||
const startGeneration = async (action = "draft") => {
|
||||
if (busy) return; if (action === "draft" && !selectedIdeas.length) { setNotice("Select at least one accepted idea first."); return; } setBusy(true); const id = uid("generation"); const source = selectedIdeas.map((idea) => idea.summary).join(" ") || "the current passage"; const prompt = action === "draft" ? `Draft a passage that ${project.generation.relation}s the selected ideas.` : `${action} the selected passage.`;
|
||||
mutate("GENERATION_REQUESTED", `Started ${action.toLowerCase()} preview`, (current) => ({ ...current, generation: { ...current.generation, id, state: "streaming", text: "", prompt } })); await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
let nativeText: string | null = null;
|
||||
try { nativeText = await invokeNative<string>("generate_text", { profile: project.provider, prompt, context: source, cloudApproved: project.cloudApproved, purpose: "draft" }); }
|
||||
catch (error) { mutate("GENERATION_FAILED", "Provider request failed; your request is preserved for retry", (current) => ({ ...current, generation: { ...current.generation, id, state: "failed", text: "", prompt } }), "system"); setNotice(`Provider request failed: ${nativeError(error)}`); setBusy(false); return; }
|
||||
const variants: Record<string, string> = { draft: `The language of personal discipline obscures a public design problem. ${source} A community’s capacity for sustained thought depends on the expectations it builds into ordinary places: whether a classroom protects silence long enough for uncertainty, whether a meeting rewards listening before reaction, and whether public argument leaves room for ideas that cannot arrive as slogans.`, Clarity: "Attention is personal in experience but public in consequence. Institutions shape whether people can sustain it together.", Rewrite: "We guard attention as private property, yet its most important work happens between us—in classrooms, meetings, and the slow exchange of public argument.", Shorten: "Attention feels private, but its loss reshapes public life.", Expand: "Attention feels private, but its loss reshapes public life. The change appears first as friction: a classroom that cannot settle, a meeting that repeats itself, a debate that rewards instant response. Over time, those moments become an institutional condition.", Transition: "That is why the question must move beyond individual habit and toward the environments we share.", Repetition: "Consider trimming repeated uses of “private,” “shared,” and “attention” in the opening two paragraphs.", Consistency: "The draft consistently frames attention as infrastructure; keep the institutional examples parallel to maintain that logic.", Tone: "A measured version can make the claim firmly without blaming readers for systems they did not design.", "User voice": "In your reflective voice: the room matters because attention is never brought into it alone; it is invited, protected, or spent there.", Proofread: "The selected passage is mechanically clean. Consider an em dash instead of the current parenthetical pause." };
|
||||
mutate("GENERATION_COMPLETED", "Preview ready — your manuscript is unchanged", (current) => ({ ...current, generation: { ...current.generation, id, state: "staged", text: nativeText ?? variants[action] ?? variants.draft, prompt } }), "assistant"); setBusy(false);
|
||||
if (busy) return;
|
||||
if (action === "draft" && !selectedIdeas.length) { setNotice("Select at least one accepted idea first."); return; }
|
||||
setBusy(true);
|
||||
const id = uid("generation");
|
||||
const source = selectedIdeas.map((idea) => idea.summary).join(" ") || "the current passage";
|
||||
const prompt = action;
|
||||
mutate("GENERATION_REQUESTED", `Started ${action.toLowerCase()} preview`, (current) => ({ ...current, generation: { ...current.generation, id, state: "streaming", text: "", prompt } }));
|
||||
try {
|
||||
const nativeText = await invokeNative<string>("generate_text", {
|
||||
profile: project.provider,
|
||||
promptVariables: { action, relation: project.generation.relation, context: source },
|
||||
cloudApproved: project.cloudApproved,
|
||||
purpose: "drafting",
|
||||
});
|
||||
if (!nativeText?.trim()) throw new Error("The provider returned an empty response.");
|
||||
mutate("GENERATION_COMPLETED", "Preview ready — your manuscript is unchanged", (current) => ({ ...current, generation: { ...current.generation, id, state: "staged", text: nativeText.trim(), prompt } }), "assistant");
|
||||
} catch (error) {
|
||||
mutate("GENERATION_FAILED", "Provider request failed; your request is preserved for retry", (current) => ({ ...current, generation: { ...current.generation, id, state: "failed", text: "", prompt } }), "system");
|
||||
setNotice(`Provider request failed: ${nativeError(error)}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const acceptGeneration = (mode: "cursor" | "replace" | "append" | "section", partial = false) => { const full = project.generation.text; if (!full) return; const accepted = partial ? full.split(/(?<=[.!?])\s+/).slice(0, 2).join(" ") : full; let manuscript = project.manuscript; if ((mode === "replace" || mode === "cursor") && view === "drafting") { manuscript = editor.current?.insert(accepted, mode === "replace") ?? manuscript; } else if (mode === "replace") { const start = finalEditor.current?.selectionStart ?? manuscript.length; const end = finalEditor.current?.selectionEnd ?? start; manuscript = `${manuscript.slice(0, start)}${accepted}${manuscript.slice(end)}`; } else if (mode === "section") manuscript = `${manuscript.trimEnd()}\n\n## New section\n\n${accepted}\n`; else manuscript = `${manuscript.trimEnd()}\n\n${accepted}\n`; mutate(partial ? "GENERATION_PARTIALLY_ACCEPTED" : "GENERATION_ACCEPTED", `${partial ? "Partially accepted" : "Accepted"} generated text`, (current) => ({ ...current, manuscript, generation: { ...current.generation, state: "accepted" } })); };
|
||||
const discardGeneration = () => mutate("GENERATION_REJECTED", "Discarded generated preview", (current) => ({ ...current, generation: { ...current.generation, state: "rejected", text: "" } }));
|
||||
const saveCheckpoint = (name = `Version ${project.checkpoints.length + 1}`) => { const point: Checkpoint = { id: uid("version"), name, at: now(), manuscript: project.manuscript, words: countWords(project.manuscript) }; mutate("CHECKPOINT_CREATED", `Saved version “${name}”`, (current) => ({ ...current, checkpoints: [...current.checkpoints, point] })); void invokeNative("create_checkpoint", { name }); };
|
||||
@@ -162,6 +177,7 @@ export default function Thinkloom() {
|
||||
};
|
||||
const createNativeProject = async () => { try { const folder = await invokeNative<string>("choose_project_folder"); if (!folder) { setNotice("Choose a folder when you are ready to create the desktop project."); return; } const result = await invokeNative<{ path: string }>("create_project", { path: folder, title: project.title }); if (!result) { setNotice("Project folders are available in the installed desktop app."); return; } setProjectPath(result.path); await invokeNative("persist_state", { appState: project }); setNotice("Self-contained project created and ready."); } catch (error) { setNotice(`Project could not be created: ${String(error)}`); } };
|
||||
const openNativeProject = async () => { try { const folder = await invokeNative<string>("choose_project_folder"); if (!folder) return; const result = await invokeNative<{ path: string }>("open_project", { path: folder }); if (!result) { setNotice("Opening project folders is available in the installed desktop app."); return; } setProjectPath(result.path); const restored = await invokeNative<State>("load_project_state"); if (restored) setProject(restored); setNotice("Project reopened and its history verified."); } catch (error) { setNotice(`Project could not be opened: ${String(error)}`); } };
|
||||
const openPromptFolder = async () => { try { const path = await invokeNative<string>("open_prompt_folder"); if (path) { setPromptPath(path); setNotice("Prompt configuration folder opened."); } } catch (error) { setNotice(`Prompt configuration folder could not be opened: ${nativeError(error)}`); } };
|
||||
|
||||
if (!hydrated) return <main className="loading"><div className="brand-mark">T</div><p>Gathering your threads…</p></main>;
|
||||
return <main className="app-shell">
|
||||
@@ -200,9 +216,10 @@ export default function Thinkloom() {
|
||||
{view === "settings" && <section className="page-layout"><div className="page-heading"><span className="eyebrow">Private by design</span><h1>Settings</h1><p>Choose where thinking happens and how Thinkloom supports the work.</p></div><div className="settings-grid">
|
||||
<section className="settings-section panel"><div className="settings-heading"><span className="settings-number">01</span><div><h2>Model provider</h2><p>Your active writing assistant.</p></div></div><label>Provider<select value={project.provider.kind} onChange={(event) => { const kind = event.target.value as Provider["kind"]; const profile: Provider = kind === "ollama" ? { kind, name: "Ollama", endpoint: "http://127.0.0.1:11434", model: "llama3.2", mode: "local", connected: false } : kind === "openai" ? { kind, name: "OpenAI", endpoint: "https://api.openai.com/v1", model: "gpt-4.1-mini", mode: "cloud", connected: false } : { kind, name: "Compatible endpoint", endpoint: "http://127.0.0.1:1234/v1", model: "local-model", mode: "local", connected: false }; mutate("PROVIDER_CHANGED", `Changed provider to ${profile.name}`, (current) => ({ ...current, provider: profile, privacy: profile.mode === "local" ? "Local" : "Cloud" })); }}><option value="ollama">Ollama · Local</option><option value="openai">OpenAI · Cloud</option><option value="compatible">OpenAI-compatible</option></select></label><label>Endpoint<input value={project.provider.endpoint} onChange={(event) => setProject((current) => ({ ...current, provider: { ...current.provider, endpoint: event.target.value } }))} /></label><label>Model<input value={project.provider.model} onChange={(event) => setProject((current) => ({ ...current, provider: { ...current.provider, model: event.target.value } }))} /></label>{project.provider.kind !== "ollama" && <label>Credential<input type="password" autoComplete="new-password" value={credential} onChange={(event) => setCredential(event.target.value)} placeholder="Stored only in your system vault" /><button className="secondary-button" type="button" onClick={() => void invokeNative("store_provider_secret", { profileId: project.provider.kind, secret: credential }).then(() => { setCredential(""); setNotice("Credential saved in the operating-system vault."); }).catch((error) => setNotice(`Credential could not be saved: ${String(error)}`))}>Save securely</button></label>}{project.provider.mode === "cloud" && !project.cloudApproved && <div className="cloud-warning"><strong>Cloud approval required</strong><p>Your first cloud request sends only relevant context shown in preview.</p><button onClick={() => mutate("CLOUD_PROCESSING_APPROVED", "Approved cloud processing for this project", (current) => ({ ...current, cloudApproved: true }))}>Approve for this project</button></div>}<button className="secondary-button" onClick={() => void invokeNative<{ ok: boolean; message: string }>("test_provider", { profile: project.provider }).then((result) => { setProject((current) => ({ ...current, provider: { ...current.provider, connected: Boolean(result?.ok) } })); setNotice(result?.message ?? "Provider testing is available in the desktop app."); })}>Test connection</button></section>
|
||||
<section className="settings-section panel"><div className="settings-heading"><span className="settings-number">02</span><div><h2>Voice & listening</h2><p>Audio is processed in memory and never retained.</p></div></div><label className="toggle-row"><input type="checkbox" checked={project.spokenReplies} onChange={(event) => setProject((current) => ({ ...current, spokenReplies: event.target.checked }))} /><span><strong>Spoken assistant replies</strong><small>Always accompanied by visible text. Off by default.</small></span></label><button className="secondary-button" onClick={startVoice}>Test microphone</button></section>
|
||||
<section className="settings-section panel"><div className="settings-heading"><span className="settings-number">03</span><div><h2>Your writing voice</h2><p>Inspectable traits guide suggestions without impersonating you.</p></div></div><div className="trait-list">{project.styleTraits.map((trait, index) => <label key={`${index}-${trait}`}><span className="confidence">Developing</span><input value={trait} onChange={(event) => setProject((current) => ({ ...current, styleTraits: current.styleTraits.map((item, itemIndex) => itemIndex === index ? event.target.value : item) }))} /></label>)}</div><label>Habit to avoid<div className="inline-input"><input value={newHabit} onChange={(event) => setNewHabit(event.target.value)} placeholder="Add a pattern to avoid" /><button onClick={() => { if (!newHabit.trim()) return; mutate("STYLE_PROFILE_UPDATED", "Updated writing voice profile", (current) => ({ ...current, disallowedHabits: [...current.disallowedHabits, newHabit.trim()] })); setNewHabit(""); }}>Add</button></div></label><div className="tag-row">{project.disallowedHabits.map((habit) => <span key={habit}>{habit}</span>)}</div></section>
|
||||
<section className="settings-section panel wide"><div className="settings-heading"><span className="settings-number">03</span><div><h2>Your writing voice</h2><p>Inspectable traits guide suggestions without impersonating you.</p></div></div><div className="trait-list">{project.styleTraits.map((trait, index) => <label key={`${index}-${trait}`}><span className="confidence">Developing</span><input value={trait} onChange={(event) => setProject((current) => ({ ...current, styleTraits: current.styleTraits.map((item, itemIndex) => itemIndex === index ? event.target.value : item) }))} /></label>)}</div><label>Habit to avoid<div className="inline-input"><input value={newHabit} onChange={(event) => setNewHabit(event.target.value)} placeholder="Add a pattern to avoid" /><button onClick={() => { if (!newHabit.trim()) return; mutate("STYLE_PROFILE_UPDATED", "Updated writing voice profile", (current) => ({ ...current, disallowedHabits: [...current.disallowedHabits, newHabit.trim()] })); setNewHabit(""); }}>Add</button></div></label><div className="tag-row">{project.disallowedHabits.map((habit) => <span key={habit}>{habit}</span>)}</div></section>
|
||||
<section className="settings-section panel wide prompt-settings"><div className="settings-heading"><span className="settings-number">04</span><div><h2>Prompt configuration</h2><p>Technical users can tune every instruction sent to the model.</p></div></div><p><strong>conversation.json</strong> affects Ideation replies. <strong>drafting.json</strong> affects passage and editorial previews. Files reload before every model request, so no restart is needed.</p><code className="prompt-path">{promptPath || "Preparing prompt files…"}</code><div className="project-actions"><button className="secondary-button" onClick={() => void openPromptFolder()}>Open prompt folder</button></div><small>README.md in this folder documents every field, variable, effect, validation rule, and reset procedure.</small></section>
|
||||
</div></section>}
|
||||
</section>
|
||||
<footer className="app-footer"><span>Thinkloom 0.1.0</span><span>Local-first · audio retention always off</span><span>{project.events.at(-1)?.hash ?? "No history"}</span></footer>
|
||||
<footer className="app-footer"><span>Thinkloom 0.2.0</span><span>Local-first · audio retention always off</span><span>{project.events.at(-1)?.hash ?? "No history"}</span></footer>
|
||||
</main>;
|
||||
}
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user