Skip to content

feat(ai): generate a note summary and a title with the local model - #25

Open
cygmris wants to merge 2 commits into
shuaiplus:mainfrom
cygmris:feat/ai-summary-and-title
Open

feat(ai): generate a note summary and a title with the local model#25
cygmris wants to merge 2 commits into
shuaiplus:mainfrom
cygmris:feat/ai-summary-and-title

Conversation

@cygmris

@cygmris cygmris commented Aug 24, 2026

Copy link
Copy Markdown

Stacked on #20 — this branch contains that PR's commit plus one more, so please read #20 first. If #20 lands, this one rebases to a single commit.

What this adds

Two more actions on the AI panel from #20: Summarise and Suggest title, both running on the same local Ollama engine. No new AI infrastructure, no new settings, no new provider.

Why the summary is a plain blockquote and not a callout

The point of the summary is the note list preview. The list shows the start of the body, which is often "let me give some background first…" — you cannot tell what the note is about at a glance.

The summary is inserted before the body (after frontmatter, and after an H1 if there is one) so the preview shows it instead.

That only works with a plain > blockquote. > [!NOTE] leaks the literal [!NOTE] into the excerpt, because toPlainText does not strip the marker. This is measured, not assumed, and there is a unit test that runs deriveExcerpt over the inserted result and asserts the excerpt starts with the summary and contains neither > nor [! — so switching to callout syntax turns the suite red rather than silently making list previews worse.

Before / after on a real note:

  • before: 先说点背景。这份笔记是为了验证 AI 摘要功能而写的,开头这几句其实没什么信息量…
  • after: 这篇笔记是关于发布流程的改造,将原本需要人工执行六条命令的流程改为一个脚本…

Title normalisation

normalizeTitle takes the first non-empty line, then strips heading markers, paired quotes (straight, curly and the CJK 「」 『』 《》 pairs), emphasis markers and a trailing full stop, then truncates to LIMITS.titleMaxLength. If the result is empty it refuses to write rather than clearing the title the user already has.

Shape of the change

The four panel modes now differ only by three lookup tables — system prompt, dialog width, accept action — instead of nested ternaries. Summarise reuses the existing isTargetUnchanged staleness guard; the title does not need it because it replaces the whole field.

The toolbar sparkle button becomes a menu (Tidy / Summarise / Suggest title) built from the toolbar's own MenuButton, and the command palette gets the two matching commands inside the existing activeNote block, so nothing new shows up when no note is open.

Verification

  • typecheck, test:unit, i18n:check, comments:check, build all pass
  • Reverse-verified every assertion in summary.ts by breaking the implementation one mutation at a time — 13/13 turn red, including the "switch to callout syntax" mutation
  • Ran on a real deployment: list preview genuinely changes, Ctrl+Z undoes the insert, the title field updates, and with no note open neither the menu items nor the commands appear

Known gap, stated rather than glossed over: I verified that applying a title writes through and updates the field, but I did not separately verify that applying a title is undoable.

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.
Two more actions on the existing AI panel, on top of the local Ollama
engine: a one-or-two sentence summary and a short title.

The summary is inserted as a plain blockquote before the body (after
frontmatter and after an H1 if present), so the note list preview shows
the summary instead of whatever the first paragraph happens to be. That
placement is the whole point of the feature, and it is why the summary
is NOT a callout: `> [!NOTE]` leaks the literal `[!NOTE]` marker into
the excerpt, because `toPlainText` does not strip it. A unit test runs
`deriveExcerpt` over the inserted result and asserts the excerpt starts
with the summary and contains neither `>` nor `[!`, so a later switch to
callout syntax turns the suite red.

The title goes through `normalizeTitle`, which takes the first non-empty
line and strips heading markers, paired quotes (straight, curly, and the
CJK 「」『』《》 pairs), emphasis markers and a trailing full stop before
truncating to `LIMITS.titleMaxLength`. An empty result is refused rather
than clearing the existing title.

Both reuse the existing panel, engine and staleness guard. The four
modes differ only by three lookup tables: system prompt, dialog width
and the accept action. No new AI infrastructure, no new settings.

The toolbar sparkle button becomes a menu (Tidy / Summarise / Suggest
title) reusing the toolbar's own MenuButton, and the command palette
gains the matching two commands inside the existing activeNote block.
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