From 2b8a3be474285d7b26fd4d3a70c86f14907f6ce5 Mon Sep 17 00:00:00 2001 From: Labyricorn Date: Fri, 17 Jul 2026 11:07:40 -0700 Subject: [PATCH] Route ideation conversations through Ollama --- src-tauri/src/lib.rs | 67 ++++++++++++++++++++++++++++++++------- src/Thinkloom.tsx | 23 +++++++++++--- tests/native-app.test.mjs | 3 ++ 3 files changed, 76 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ce7a2d7..0b06856 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -631,24 +631,62 @@ fn test_provider(profile: ProviderProfile) -> CommandResult { })?; request = request.bearer_auth(secret); } - match request.send() { - Ok(response) if response.status().is_success() => Ok(ConnectionResult { - ok: true, - message: format!("{} is ready with {}.", profile.name, profile.model), - }), - Ok(response) => Ok(ConnectionResult { + let response = match request.send() { + Ok(response) => response, + Err(error) => { + return Ok(ConnectionResult { + ok: false, + message: format!("Could not reach {}: {}", profile.name, error), + }); + } + }; + if !response.status().is_success() { + return Ok(ConnectionResult { ok: false, message: format!( "{} responded with status {}.", profile.name, response.status() ), - }), - Err(error) => Ok(ConnectionResult { - ok: false, - message: format!("Could not reach {}: {}", profile.name, error), - }), + }); } + if profile.kind == "ollama" { + let value: Value = response + .json() + .map_err(|error| CommandError::new("PROVIDER_RESPONSE", error.to_string(), true))?; + let requested = profile.model.trim(); + let installed = value + .get("models") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|model| model.get("name").and_then(Value::as_str)) + .collect::>(); + let available = installed.iter().any(|name| { + *name == requested + || name.strip_suffix(":latest") == Some(requested) + || requested.strip_suffix(":latest") == Some(*name) + }); + if !available { + return Ok(ConnectionResult { + ok: false, + message: format!( + "Ollama is running, but model '{}' is not installed. Available: {}.", + requested, + installed + .iter() + .take(5) + .copied() + .collect::>() + .join(", ") + ), + }); + } + } + Ok(ConnectionResult { + ok: true, + message: format!("{} is ready with {}.", profile.name, profile.model), + }) } fn pdf_bytes(title: &str, manuscript: &str) -> Vec { @@ -1145,6 +1183,7 @@ fn generate_text( prompt: String, context: String, cloud_approved: bool, + purpose: Option, ) -> CommandResult { if profile.mode == "cloud" && !cloud_approved { return Err(CommandError::new( @@ -1157,7 +1196,11 @@ fn generate_text( .timeout(std::time::Duration::from_secs(90)) .build() .map_err(|e| CommandError::new("PROVIDER_ERROR", e.to_string(), true))?; - let system = "You are Thinkloom, a focused writing collaborator. Return only proposed prose; it will be staged for review."; + let system = if purpose.as_deref() == Some("conversation") { + "You are Thinkloom, a focused writing collaborator in an ideation conversation. Respond naturally to the writer's latest message, briefly reflect what is useful, and ask exactly one focused question. Do not force a stock interpretation, fabricate details, or draft prose unless asked." + } else { + "You are Thinkloom, a focused writing collaborator. Return only proposed prose; it will be staged for review." + }; let (url, body) = if profile.kind == "ollama" { ( format!("{}/api/chat", profile.endpoint.trim_end_matches('/')), diff --git a/src/Thinkloom.tsx b/src/Thinkloom.tsx index edd3977..80783ca 100644 --- a/src/Thinkloom.tsx +++ b/src/Thinkloom.tsx @@ -63,6 +63,7 @@ function createEmptyProject(id = uid("project")): State { } async function invokeNative(command: string, args: Record = {}): Promise { const host = window as unknown as { __TAURI_INTERNALS__?: { invoke: (name: string, payload: Record) => Promise } }; return host.__TAURI_INTERNALS__ ? host.__TAURI_INTERNALS__.invoke(command, args) : null; } +function nativeError(error: unknown): string { if (error && typeof error === "object") { const value = error as { message?: unknown; code?: unknown }; if (typeof value.message === "string") return value.code ? `${String(value.code)}: ${value.message}` : value.message; } return String(error); } function download(name: string, contents: string, type = "text/plain") { const href = URL.createObjectURL(new Blob([contents], { type })); const a = document.createElement("a"); a.href = href; a.download = name; a.click(); URL.revokeObjectURL(href); } function Mark({ children, tone = "neutral" }: { children: React.ReactNode; tone?: string }) { return {children}; } function Empty({ eyebrow, title, body }: { eyebrow: string; title: string; body: string }) { return
{eyebrow}

{title}

{body}

; } @@ -100,12 +101,24 @@ export default function Thinkloom() { const text = message.trim(); if (!text || busy) return; setMessage(""); setBusy(true); 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] })); - await new Promise((resolve) => setTimeout(resolve, 400)); + 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("generate_text", { profile: project.provider, prompt, 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 } })); + } catch (error) { + setProject((current) => ({ ...current, provider: { ...current.provider, connected: false } })); + setNotice(`${project.provider.name} did not reply: ${nativeError(error)} Check Settings → Model provider, then retry.`); + setBusy(false); return; + } const focus = text.replace(/[.!?].*$/, "").slice(0, 100); - const reply = project.challenge === "Gentle" ? `There is something worth staying with in “${focus}.” What personal moment makes that feel true?` : project.challenge === "Rigorous" ? `If a skeptical reader challenged “${focus},” what evidence or distinction would make it hold?` : `“${focus}” suggests a useful tension. What changes when you see it as a shared condition?`; const assistantTurn: Turn = { id: uid("turn"), speaker: "assistant", text: reply, createdAt: now() }; const suggestion: Idea = { id: uid("idea"), title: focus.length > 46 ? `${focus.slice(0, 43)}…` : focus, summary: text, detail: "Explore how this supports or complicates the central argument.", status: "suggested", sourceTurnIds: [userTurn.id], parentIdeaIds: [], tags: ["from conversation"], pinned: false, selected: false, createdBy: "mixed" }; - mutate("ASSISTANT_RESPONSE_GENERATED", "A response and suggested idea are ready", (current) => ({ ...current, turns: [...current.turns, assistantTurn], ideas: [...current.ideas, suggestion] }), "assistant"); + mutate("ASSISTANT_RESPONSE_GENERATED", `Response generated by ${project.provider.name}`, (current) => ({ ...current, turns: [...current.turns, assistantTurn], ideas: [...current.ideas, suggestion] }), "assistant"); if (project.spokenReplies && "speechSynthesis" in window) speechSynthesis.speak(new SpeechSynthesisUtterance(reply)); setBusy(false); requestAnimationFrame(() => conversationEnd.current?.scrollIntoView({ behavior: "smooth" })); }; const saveLastTurn = () => { const turn = [...project.turns].reverse().find((item) => item.speaker === "user"); if (!turn) return; const idea: Idea = { id: uid("idea"), title: turn.text.slice(0, 58), summary: turn.text, detail: "Saved directly from conversation.", status: "accepted", sourceTurnIds: [turn.id], parentIdeaIds: [], tags: ["saved directly"], pinned: false, selected: true, createdBy: "user" }; mutate("IDEA_ACCEPTED", `Saved “${idea.title}” as an idea`, (current) => ({ ...current, ideas: [...current.ideas, idea] })); }; @@ -118,8 +131,8 @@ export default function Thinkloom() { 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("generate_text", { profile: project.provider, prompt, context: source, cloudApproved: project.cloudApproved }); } - 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: ${String(error)}`); setBusy(false); return; } + try { nativeText = await invokeNative("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 = { 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); }; diff --git a/tests/native-app.test.mjs b/tests/native-app.test.mjs index 2623e4e..4519168 100644 --- a/tests/native-app.test.mjs +++ b/tests/native-app.test.mjs @@ -35,6 +35,9 @@ test("implements the control and privacy contracts", async () => { assert.match(source, /function createEmptyProject/); assert.match(source, /turns: \[\], ideas: \[\], manuscript: ""/); assert.match(source, /New project cancelled\. Your current project is unchanged\./); + assert.match(source, /purpose: "conversation"/); + assert.match(source, /did not reply/); + assert.doesNotMatch(source, /suggests a useful tension\. What changes when you see it as a shared condition/); assert.match(css, /prefers-reduced-motion/); assert.match(css, /:focus-visible/); assert.match(css, /html,body,#root\{[^}]*height:100%[^}]*overflow:hidden/);