Skip to content

feat(ai): turn raw text into Markdown notes with a local Ollama model - #20

Open
cygmris wants to merge 1 commit into
shuaiplus:mainfrom
cygmris:feat/local-ollama-markdown
Open

feat(ai): turn raw text into Markdown notes with a local Ollama model#20
cygmris wants to merge 1 commit into
shuaiplus:mainfrom
cygmris:feat/local-ollama-markdown

Conversation

@cygmris

@cygmris cygmris commented Aug 22, 2026

Copy link
Copy Markdown

What this adds

An opt-in Local AI settings section, plus two entry points that turn messy text into clean Markdown using the user's own Ollama:

  • Command palette → "AI to Markdown" — paste or drop a plain-text file, get a new note.
  • Editor toolbar ✨ / command palette → "Tidy the current note with AI" — reformats the current note, or just the selection if there is one.

Requests go straight from the browser to the user's own Ollama. Note text never reaches the server. Nothing is enabled by default; the section is empty until a user configures it.

Why the CSP change is unavoidable

The Worker runs at the edge and cannot reach a user's loopback address, so there is no server-side proxy option for this path. connect-src is widened to exactly two hardcoded loopback endpoints (http://127.0.0.1:11434, http://localhost:11434) and nothing else — no config value or user input is ever interpolated into that header.

Two failure modes that shaped the design

1. Ollama silently truncates over-long input. When a prompt exceeds the context window, Ollama drops the front of it and the model then stops cleanly on the part it did see, reporting finish_reason: "stop". Nothing in the API surfaces this. detectLoss therefore uses a second, independent heuristic — output far shorter than input on a first-turn conversion — and long input is chunked paragraph-first, then by line, then hard-cut.

2. Offsets go stale while the model works. With realtime sync on, the note can change underneath a long generation. A selection edit would then land in the wrong place and a whole-note edit would clobber the concurrent change — both silently. Writes carry the original text and are refused if it no longer matches, keeping the result on screen so it can be copied instead.

Browser support, stated in the UI

  • Safari blocks an HTTPS page from calling http://localhost, so this is Chrome/Firefox only. That limit is shown in the settings panel, not just in docs.
  • Chrome 138+ additionally gates loopback access behind a permission prompt, and an unanswered prompt leaves the request hanging rather than failing. The connection test therefore carries its own 12s timeout and explains that specific case.

Security

Note content is treated as untrusted data: the system prompt instructs the model to reformat rather than answer, and that instruction follows the user's UI language so weaker local models weight it properly. No new credentials, routes, or storage.

Checks run

npm run typecheck      PASS
npm run i18n:check     PASS   (all new strings in both locales)
npm run comments:check PASS   (no comments added)
npm run test:unit      PASS   (138 tests)
npm run build          PASS

Assertions were reverse-verified: each implementation was broken in turn to confirm the matching test goes red, then restored.

npm run test:e2e does not complete on my machine, and neither does it on an unmodified checkout of main — wrangler crashes partway through with its own "please create an issue at workers-sdk" message and a note that 4.125.0 may fix it. I did not upgrade wrangler here since that would be an unrelated change to package-lock.json. Happy to re-run if you have a known-good version.

Notes for review

  • The default model string is whatever tag the user has locally; there is no bundled default that will work everywhere.
  • EditorToolbar.tsx gains one button; the shared trigger logic lives in features/ai/open-tidy.ts so the palette and the toolbar cannot drift apart.
  • No changes to wrangler.toml, persistence, sync, import/export, or backups.

Adds an opt-in "Local AI" settings section plus two entry points — a command
palette action to convert pasted text into a new note, and a toolbar button to
tidy the current note (or just the selection). Requests go straight from the
browser to the user's own Ollama; note text never reaches the server.

Because the Worker runs at the edge and cannot reach the user's loopback
address, there is no server-side proxy option here; the CSP `connect-src` is
widened to exactly two hardcoded loopback endpoints and nothing else.

Two failure modes drove most of the design:

1. Ollama silently drops the front of an over-long prompt and the model then
   stops cleanly on the part it did see, reporting `finish_reason: "stop"`.
   Nothing in the API surfaces this. `detectLoss` therefore uses a second,
   independent heuristic — output far shorter than input on a first-turn
   conversion — and long input is chunked paragraph-first, then by line, then
   hard-cut.

2. Offsets captured when the command fires go stale while the model works.
   With realtime sync enabled the note can change underneath, so a selection
   edit would land in the wrong place and a whole-note edit would clobber a
   concurrent change — both silently. Writes now carry the original text and
   are refused if it no longer matches, keeping the result on screen so it can
   be copied instead.

Browser support is Chrome/Firefox only, stated in the UI rather than only in
docs: Safari blocks an HTTPS page from calling http://localhost. Chrome 138+
additionally gates loopback access behind a permission prompt, and an
unanswered prompt leaves the request hanging rather than failing, so the
connection test carries its own timeout and says so.

Note content is treated as untrusted data: the system prompt tells the model to
reformat rather than answer, and that instruction follows the user's UI
language so weaker local models weight it properly.

Tests: 138 passing. Assertions were reverse-verified by breaking each
implementation in turn and confirming the matching test goes red.
@cygmris

cygmris commented Aug 24, 2026

Copy link
Copy Markdown
Author

Small update rather than a ping: this has now been running on a live deployment for a few days, and I re-ran the full acceptance on it today rather than trusting the earlier local run.

Verified end to end against a real site and a real local model: streaming genuinely arrives incrementally (sampled 17 distinct lengths over one generation), chunking shows Part 1 of 2 and loses nothing across the split (checked with per-section sentinels), tidy replaces only the selection and leaves the rest byte-identical, Ctrl+Z restores the original exactly, and with Ollama stopped the error names both possible causes and gives a working link to the settings.

One thing worth knowing for anyone testing this on a deployed instance rather than locally: Chrome 138+ requires the Local Network Access permission for a public HTTPS page to reach 127.0.0.1, and until it is granted the request hangs rather than failing — no console output, no CORS error. (await navigator.permissions.query({name: 'local-network-access'})).state returns 'prompt' in that state. That is why the connection test in this PR has a timeout and its own message instead of spinning forever.

I have follow-up work stacked on this (#25, and #26 for a larger direction question), so no rush from my side — happy to rebase or split this differently if that makes it easier to review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant