Route ideation conversations through Ollama
This commit is contained in:
+55
-12
@@ -631,24 +631,62 @@ fn test_provider(profile: ProviderProfile) -> CommandResult<ConnectionResult> {
|
||||
})?;
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(ConnectionResult {
|
||||
ok: true,
|
||||
message: format!("{} is ready with {}.", profile.name, profile.model),
|
||||
})
|
||||
}
|
||||
|
||||
fn pdf_bytes(title: &str, manuscript: &str) -> Vec<u8> {
|
||||
@@ -1145,6 +1183,7 @@ fn generate_text(
|
||||
prompt: String,
|
||||
context: String,
|
||||
cloud_approved: bool,
|
||||
purpose: Option<String>,
|
||||
) -> CommandResult<String> {
|
||||
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('/')),
|
||||
|
||||
+18
-5
@@ -63,6 +63,7 @@ function createEmptyProject(id = uid("project")): State {
|
||||
}
|
||||
|
||||
async function invokeNative<T>(command: string, args: Record<string, unknown> = {}): Promise<T | null> { const host = window as unknown as { __TAURI_INTERNALS__?: { invoke: (name: string, payload: Record<string, unknown>) => Promise<T> } }; 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 <span className={`mark mark-${tone}`}>{children}</span>; }
|
||||
function Empty({ eyebrow, title, body }: { eyebrow: string; title: string; body: string }) { return <div className="empty"><span className="eyebrow">{eyebrow}</span><h2>{title}</h2><p>{body}</p></div>; }
|
||||
@@ -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<string>("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<string>("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<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);
|
||||
};
|
||||
|
||||
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user