Skip to content

Commit bb00585

Browse files
Fix the Companion on the phone
The phone asked the desktop for its model list exactly once, at page load. If Ollama happened to be asleep at that moment, `models` stayed empty for the life of the page and the Talk tab said "isn't reachable right now" forever — no retry, no explanation, and no way to fix it without walking to the desk, which is the exact situation the portal exists to avoid. Three changes: - **Re-check when you open Talk**, not once at mount. - **Say which thing is actually wrong.** "Not reachable" collapsed three different states into one useless sentence. Now: no model installed (fix it at the desk — it's a multi-gigabyte download), model server asleep (one button), or server up but no models pulled. Plus a model picker when there's more than one. - **`ai_start` is now reachable from the phone**, so it can wake the desktop's model server. It starts a loopback process the app already owns and installs nothing; `ai_install` and `ai_pull` stay desktop-only, as does everything else on the denied list. Verified against the real failure: with `ollama` stopped, the phone reports the server is asleep, the button starts it, the model list fills in, and a message sent from the phone comes back answered. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c0f15f0 commit bb00585

6 files changed

Lines changed: 86 additions & 19 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "field-notes",
3-
"version": "0.4.0",
3+
"version": "0.4.1",
44
"description": "Offline harm-reduction journal & trip-sitting workstation.",
55
"type": "module",
66
"private": true,

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "field_notes"
3-
version = "0.4.0"
3+
version = "0.4.1"
44
description = "Offline harm-reduction journal & trip-sitting workstation"
55
authors = ["sparkly-quasar"]
66
# Licensed under the PolyForm Noncommercial License 1.0.0 — see ../LICENSE.

src-tauri/src/portal.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ pub const EXPOSED: &[&str] = &[
316316
"ai_status",
317317
"ollama_up",
318318
"ollama_models",
319+
"ai_start",
319320
];
320321

321322
pub fn dispatch<R: Runtime>(app: &AppHandle<R>, command: &str, args: Value) -> Result<Value, DispatchError> {
@@ -384,6 +385,13 @@ pub fn dispatch<R: Runtime>(app: &AppHandle<R>, command: &str, args: Value) -> R
384385
"ai_status" => ok(commands::ai_status()),
385386
"ollama_up" => ok(commands::ollama_up()),
386387
"ollama_models" => ok(commands::ollama_models()),
388+
// Wake the desktop's local model server. Without this the phone's Companion is
389+
// dead whenever Ollama happens to be asleep, and the only cure is walking to the
390+
// desk — which is the exact situation the portal exists to avoid. It starts a
391+
// loopback process the app already owns; it does **not** install anything
392+
// (`ai_install`/`ai_pull` stay desktop-only). `commands::ai_start` is `async`
393+
// purely to keep Tauri's UI thread free, so we call the same inner function.
394+
"ai_start" => done(crate::ollama::ensure_serving()),
387395

388396
_ => Err(DispatchError::NotExposed),
389397
}
@@ -441,10 +449,11 @@ mod tests {
441449
"reveal_data_dir",
442450
// Destroys the journal.
443451
"wipe_all_data",
444-
// Installs software / mutates app state.
452+
// Installs software or downloads gigabytes. (`ai_start` *is* exposed: it only
453+
// wakes a local server the app already owns, and without it the phone's
454+
// Companion stays dead until someone walks to the desk.)
445455
"ai_install",
446456
"ai_pull",
447-
"ai_start",
448457
"pw_update",
449458
// The portal may not reconfigure or disable itself — including publishing
450459
// itself to the tailnet, which is a decision made at the desk.

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "Field Notes",
4-
"version": "0.4.0",
4+
"version": "0.4.1",
55
"identifier": "com.fieldnotes.journal",
66
"build": {
77
"beforeDevCommand": "npm run dev",

src/routes/m/+page.svelte

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
crisisScan,
3131
emergencyResources,
3232
companionChat,
33-
ollamaModels,
33+
aiStatus,
34+
aiStart,
3435
pwLookup,
3536
knowledgeSearch,
3637
type ExperienceSummary,
@@ -44,6 +45,7 @@
4445
type ChatMsg,
4546
type PwInfo,
4647
type KnowledgeHit,
48+
type AiStatus,
4749
} from "$lib/api";
4850
import { captureToken, hasToken, inTauri } from "$lib/portal";
4951
@@ -95,24 +97,59 @@
9597
let newSub = $state("");
9698
9799
// companion
100+
let ai = $state<AiStatus | null>(null);
98101
let models = $state<string[]>([]);
99102
let model = $state("");
100103
let chat = $state<ChatMsg[]>([]);
101104
let ask = $state("");
102105
let thinking = $state(false);
106+
let waking = $state(false);
103107
104108
onMount(async () => {
105109
captureToken();
106110
paired = inTauri() || hasToken();
107111
if (!paired) return;
108112
await refresh();
113+
await loadAi();
114+
});
115+
116+
/** Ask the desktop what its local model is doing *right now*.
117+
*
118+
* This used to run once, at mount. If Ollama happened to be asleep at that
119+
* moment, the Companion stayed dead for the life of the page — no retry, no
120+
* explanation — which is exactly what you don't want mid-session. So: re-check
121+
* whenever the Talk tab is opened, and say which of the three states we're in
122+
* rather than one vague "not reachable". */
123+
async function loadAi() {
109124
try {
110-
models = await ollamaModels();
111-
model = models[0] ?? "";
125+
ai = await aiStatus();
126+
models = ai.models;
127+
if (!models.includes(model)) model = models[0] ?? "";
112128
} catch {
113-
models = []; // The Companion needs the desktop's Ollama; it may simply be off.
129+
ai = null;
130+
models = [];
114131
}
115-
});
132+
}
133+
134+
/** Wake the desktop's model server from the phone. The alternative is walking to
135+
* the desk, which is the situation the portal exists to avoid. */
136+
const wakeAi = () =>
137+
run(async () => {
138+
waking = true;
139+
try {
140+
await aiStart();
141+
// `ollama serve` takes a moment to accept connections.
142+
await new Promise((r) => setTimeout(r, 1500));
143+
await loadAi();
144+
} finally {
145+
waking = false;
146+
}
147+
});
148+
149+
function goTo(v: View) {
150+
view = v;
151+
if (v === "companion") loadAi();
152+
}
116153
117154
/** Anything that fails does so out loud — a phone that silently drops a dose you
118155
* thought you logged is worse than a phone that says it couldn't. */
@@ -555,12 +592,33 @@
555592
{#if view === "companion"}
556593
<section class="pane">
557594
<h2>Companion</h2>
558-
{#if !models.length}
595+
{#if !ai}
596+
<p class="muted">Couldn't ask the desktop about its local model.</p>
597+
<button disabled={busy} onclick={loadAi}>Try again</button>
598+
{:else if !ai.installed}
559599
<p class="muted">
560-
The Companion runs on the desktop's local model, and it isn't reachable right now.
561-
Everything else on this screen still works.
600+
The Companion runs on a local model, and this desktop doesn't have one installed.
601+
Install it over there — it's a big download, so it isn't something to start from a phone.
562602
</p>
603+
{:else if !ai.running}
604+
<p class="muted">
605+
The local model isn't running on the desktop right now. Everything else on this screen
606+
still works.
607+
</p>
608+
<button class="primary" disabled={busy || waking} onclick={wakeAi}>
609+
{waking ? "Starting it…" : "Start it on the desktop"}
610+
</button>
611+
{:else if !models.length}
612+
<p class="muted">
613+
The model server is running, but no models are installed. Pull one on the desktop.
614+
</p>
615+
<button disabled={busy} onclick={loadAi}>Check again</button>
563616
{:else}
617+
{#if models.length > 1}
618+
<select bind:value={model}>
619+
{#each models as m}<option>{m}</option>{/each}
620+
</select>
621+
{/if}
564622
<div class="chat">
565623
{#each chat as m}
566624
<p class="msg {m.role}">{m.content}</p>
@@ -574,11 +632,11 @@
574632
{/if}
575633

576634
<nav>
577-
<button class:on={view === "now"} onclick={() => (view = "now")}>Now</button>
578-
<button class:on={view === "journal"} onclick={() => (view = "journal")}>Journal</button>
579-
<button class:on={view === "combo"} onclick={() => (view = "combo")}>Combo</button>
580-
<button class:on={view === "reference"} onclick={() => (view = "reference")}>Look up</button>
581-
<button class:on={view === "companion"} onclick={() => (view = "companion")}>Talk</button>
635+
<button class:on={view === "now"} onclick={() => goTo("now")}>Now</button>
636+
<button class:on={view === "journal"} onclick={() => goTo("journal")}>Journal</button>
637+
<button class:on={view === "combo"} onclick={() => goTo("combo")}>Combo</button>
638+
<button class:on={view === "reference"} onclick={() => goTo("reference")}>Look up</button>
639+
<button class:on={view === "companion"} onclick={() => goTo("companion")}>Talk</button>
582640
</nav>
583641
{/if}
584642
</main>

0 commit comments

Comments
 (0)