Changeset 0.17.3 (#78)

This commit is contained in:
2026-02-28 15:20:23 +00:00
parent a008dac488
commit 12e316c234
29 changed files with 3347 additions and 65 deletions

View File

@@ -94,8 +94,15 @@ These invariants keep the workflow path open without building it prematurely:
- **Channels**: Don't assume single-owner. If touching channel queries, keep
room for `team_id`, `type` (beyond `direct`), and multi-participant access
patterns alongside `user_id`.
- **Notes**: Currently user-scoped. Future channel-scoped notes (attached to
a conversation, not a personal notebook) need a `channel_id` FK option.
- **Notes**: User-scoped with `[[wikilink]]` bi-directional linking (v0.17.3).
The `note_links` junction table tracks directed edges between notes, with
`target_note_id` nullable for dangling links (unresolved references). Links
are extracted from content on save via regex, resolved by title match, and
re-resolved when new notes are created. The graph endpoint returns all nodes,
edges, and unresolved links for Canvas-based force-directed visualization.
Future channel-scoped notes (attached to a conversation) need a `channel_id`
FK option. `source_message_id` enables jump-to-source provenance from notes
back to the originating chat message.
- **Personas**: Already team-scoped. Don't couple to "user picks from dropdown"
— a workflow stage references a persona programmatically.
- **Tool ExecutionContext**: Already carries `UserID` + `ChannelID`. Will need
@@ -153,9 +160,10 @@ server/
│ ├── presets.go # Persona CRUD (all scopes)
│ ├── apiconfigs.go # User provider config CRUD (BYOK)
│ ├── teams.go # Team management
│ ├── notes.go # Notes CRUD + search
│ ├── notes.go # Notes CRUD + search + graph + backlinks
│ ├── knowledge.go # Knowledge base management
│ └── ...
├── notelinks/ # Wikilink extraction (regex → NoteLink structs)
├── knowledge/ # KB chunking, embedding, search
├── providers/ # LLM provider adapters
│ ├── provider.go # Provider interface
@@ -257,7 +265,8 @@ src/
│ ├── settings-handlers.js # User settings CRUD, command palette
│ ├── tokens.js # Input token counting + context budget
│ ├── attachments.js # File upload, paste-to-file, lightbox
│ ├── notes.js # Notes panel CRUD
│ ├── notes.js # Notes panel CRUD + graph + daily notes
│ ├── note-graph.js # Canvas force-directed graph visualization
│ ├── knowledge.js # Knowledge base UI
│ └── __tests__/ # Node.js test suite (node --test)
├── editor/ # CM6 source (compiled at build time)
@@ -267,7 +276,9 @@ src/
│ ├── index.mjs # Bundle entrypoint (window.CM)
│ ├── chat-input.mjs # Chat input factory (markdown, code blocks)
│ ├── code-editor.mjs # Code editor factory (syntax, keybindings)
── theme.mjs # Switchboard + chat input themes
── note-editor.mjs # Note editor factory (markdown + wikilinks)
│ ├── wikilink.mjs # CM6 wikilink extension (parse, decorate, autocomplete)
│ └── theme.mjs # Switchboard + chat input + note editor themes
└── vendor/ # Vendored libraries (local + CDN fallback)
├── marked/ # Markdown renderer
├── purify/ # DOMPurify (XSS protection)
@@ -279,12 +290,14 @@ src/
### CM6 Integration
The CodeMirror 6 bundle provides two factory functions exposed on `window.CM`:
The CodeMirror 6 bundle provides three factory functions exposed on `window.CM`:
**`CM.chatInput(target, opts)`** — Markdown-mode editor for the chat input area. Features: auto-growing height, Enter=send / Shift+Enter=newline, WYSIWYG fenced code block decorations (visual container with monospace font and accent border), inline code shortcut (Ctrl/Cmd+E), spell check. Always uses standard keybindings.
**`CM.codeEditor(target, opts)`** — Full-featured code editor for the admin extension panel (JSON manifests, JavaScript scripts). Features: line numbers, bracket matching, search/replace, fold gutter, 10 bundled language modes. Supports Vim and Emacs keybindings via user preference (reconfigurable at runtime via compartments).
**`CM.noteEditor(target, opts)`** — Rich markdown editor for the notes panel. Features: heading size rendering (h1h3), blockquote styling, fenced code block decorations, `[[wikilink]]` autocomplete (triggered by `[[`), wikilink chip rendering (clickable, styled by link/transclusion type), search/replace. `onLink` callback for navigating to linked notes; `linkCompleter` async callback for autocomplete results.
Both factories return a clean API: `getValue()`, `setValue()`, `focus()`, `destroy()`. The `ChatInput` abstraction in `chat.js` wraps the CM6 instance with a textarea fallback — all callsites use `ChatInput.getValue()` etc., never raw DOM access.
**Graceful degradation**: Every integration point checks `window.CM` before using CM6. If the bundle fails to load (air-gapped without the build step, CDN issues), the app falls back to native `<textarea>` with zero breakage.

View File

@@ -1,10 +1,11 @@
# DESIGN: Notes Rich Text Editor + Obsidian-Style Linking
# DESIGN: Notes Rich Text Editor, Obsidian-Style Linking & Knowledge Graph
**Version:** v0.17.3
**Status:** Draft
**Scope:** Upgrade the notes editor from a plain `<textarea>` to a CM6-powered
markdown editor with live preview, `[[wikilink]]` resolution, and a backlinks
system.
markdown editor with live preview, `[[wikilink]]` resolution, a backlinks
system, Canvas-rendered force-directed graph visualization, transclusion,
note-from-selection (chat → notes bridge), and daily notes.
**Depends on:** v0.17.2 (CodeMirror 6 bundle provides `CM.codeEditor()` and
the markdown language mode).
@@ -24,6 +25,12 @@ Our advantage over Obsidian: we already have **semantic search via embeddings**
(v0.14.0 KB infrastructure), so link autocomplete can be smarter than
filename matching.
Beyond linking, the notes system sits adjacent to the chat system but has no
bridge between them. Users accumulate valuable AI responses in conversations
that they then manually copy into notes. Note-from-selection closes that gap.
A visual graph makes the link topology navigable and discoverable, turning
notes from a flat list into a connected knowledge base.
---
## Architecture
@@ -50,6 +57,8 @@ CM.noteEditor(target, {
highlighted — the document is still markdown but *looks* formatted
- `[[` triggers autocomplete dropdown of existing notes (fuzzy search)
- `[[Note Title]]` tokens rendered as clickable chips (CM6 `Decoration.widget`)
- `![[Note Title]]` recognized as transclusion markers (decoration differs from
regular links — see Transclusion section)
- Line numbers off (it's a note, not code)
- Spell check enabled (`EditorView.contentAttributes: { spellcheck: 'true' }`)
- Vim/Emacs keybindings respected (user preference from v0.17.2)
@@ -61,6 +70,7 @@ Standard double-bracket syntax with optional display text:
```
[[Note Title]] → links to note titled "Note Title"
[[Note Title|display text]] → shows "display text", links to "Note Title"
![[Note Title]] → embeds target note content inline (transclusion)
```
Resolution order:
@@ -105,6 +115,7 @@ CREATE TABLE IF NOT EXISTS note_links (
target_note_id UUID REFERENCES notes(id) ON DELETE CASCADE,
target_title TEXT NOT NULL, -- raw [[title]] text, for unresolved links
display_text TEXT, -- optional |alias
is_transclusion BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (source_note_id, target_title)
);
@@ -118,6 +129,7 @@ CREATE TABLE IF NOT EXISTS note_links (
target_note_id TEXT REFERENCES notes(id) ON DELETE CASCADE,
target_title TEXT NOT NULL,
display_text TEXT,
is_transclusion INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (source_note_id, target_title)
);
@@ -135,12 +147,15 @@ CREATE INDEX IF NOT EXISTS idx_note_links_target ON note_links(target_note_id)
the same title once (last one wins if duplicated).
- `target_title` is always stored for human readability and re-resolution
after renames.
- `is_transclusion` distinguishes `![[embed]]` from `[[link]]` — useful for
graph rendering (different edge style) and for knowing which links trigger
content embedding in read mode.
### Link Extraction (on save)
When a note is saved, the backend:
1. Parses content for `[[...]]` patterns (simple regex: `\[\[([^\]|]+)(?:\|([^\]]+))?\]\]`)
1. Parses content for `[[...]]` and `![[...]]` patterns
2. Resolves each title to a note ID: `SELECT id FROM notes WHERE LOWER(title) = LOWER($1) AND user_id = $2`
3. Deletes existing links for this source note: `DELETE FROM note_links WHERE source_note_id = $1`
4. Inserts fresh links with resolved (or NULL) target IDs
@@ -173,6 +188,33 @@ AND source_note_id IN (SELECT id FROM notes WHERE user_id = $3);
This runs once on note creation — cheap and ensures links "light up"
retroactively.
### Graph Data Query
The graph endpoint returns all notes and resolved links for a user in a
single payload. This is the full graph — filtering/clustering happens
client-side.
```sql
-- Nodes: all user's notes (lightweight — no content)
SELECT id, title, folder_path, tags, updated_at
FROM notes
WHERE user_id = $1
ORDER BY updated_at DESC;
-- Edges: all resolved links
SELECT nl.source_note_id, nl.target_note_id, nl.target_title,
nl.display_text, nl.is_transclusion
FROM note_links nl
JOIN notes n ON n.id = nl.source_note_id
WHERE n.user_id = $1
AND nl.target_note_id IS NOT NULL;
```
For users with large note collections (1000+), the graph endpoint supports
pagination via `limit`/`offset` on nodes, though the initial implementation
fetches all. The payload is small — ~200 bytes per node, ~100 bytes per edge,
so 1000 notes with 2000 links is ~400KB uncompressed.
---
## API Changes
@@ -183,7 +225,9 @@ retroactively.
No API signature change; link extraction is a server-side side effect.
**`POST /api/v1/notes`** — after creating a note, extract links AND resolve
dangling links from other notes that reference this title.
dangling links from other notes that reference this title. Accepts optional
`source_channel_id` and `source_message_id` for provenance tracking (used by
note-from-selection).
### New Endpoints
@@ -223,6 +267,43 @@ Uses `ILIKE` on Postgres, `LIKE` on SQLite (case-insensitive via COLLATE).
The semantic search endpoint (`GET /api/v1/notes/search`) remains available
for deeper searches but is overkill for autocomplete.
**`GET /api/v1/notes/graph`**
Returns the full graph topology for the current user.
```json
{
"nodes": [
{
"id": "uuid",
"title": "Project Architecture",
"folder_path": "/work",
"tags": ["architecture", "design"],
"updated_at": "2026-02-28T10:00:00Z",
"link_count": 5
}
],
"edges": [
{
"source": "uuid-1",
"target": "uuid-2",
"title": "Project Architecture",
"is_transclusion": false
}
],
"unresolved": [
{
"source": "uuid-1",
"title": "Nonexistent Note"
}
]
}
```
`link_count` is `inbound + outbound` — used for node sizing in the graph.
`unresolved` is included so the graph can optionally show dangling references
as ghost nodes.
---
## Frontend Integration
@@ -310,6 +391,414 @@ The existing note list view shows raw markdown. After this release,
clickable links. A small marked.js extension or post-render pass
converts `[[...]]` to `<a>` tags with `onclick` handlers.
### Transclusion in Read Mode
`![[Note Title]]` in rendered markdown embeds the target note's content
inline. Implementation is a post-render pass in `_showNoteReadMode()` and
`_showNotePreview()`:
```javascript
async function _renderTransclusions(containerEl) {
const markers = containerEl.querySelectorAll('.wikilink-transclusion');
for (const marker of markers) {
const title = marker.dataset.title;
try {
const note = await _findNoteByTitle(title);
if (!note) {
marker.innerHTML = `<div class="transclusion-missing">
Note not found: ${esc(title)}</div>`;
continue;
}
const content = (await API.getNote(note.id)).content || '';
marker.innerHTML = `
<div class="transclusion-embed" data-note-id="${note.id}">
<div class="transclusion-header" onclick="openNoteEditor('${note.id}')">
<span class="transclusion-icon">↗</span>
<span class="transclusion-title">${esc(note.title)}</span>
</div>
<div class="transclusion-content">${formatMessage(content)}</div>
</div>`;
} catch (e) {
marker.innerHTML = `<div class="transclusion-error">
Failed to load: ${esc(title)}</div>`;
}
}
}
```
The marked.js extension (or post-render regex pass) converts `![[Title]]`
to `<span class="wikilink-transclusion" data-title="Title"></span>`
placeholder elements, then `_renderTransclusions()` fills them
asynchronously.
**Recursion guard:** Transclusions do not recurse. If note A embeds note B
and note B embeds note A, the second-level embed renders as a link chip, not
a nested embed. The render function tracks a `Set` of already-embedded IDs.
**Edit mode:** In the CM6 editor, `![[Title]]` renders as a distinct chip
(e.g., with an embed icon) but does *not* inline the content. Inline
expansion is read-mode only — editing transcluded content requires navigating
to the source note.
---
## Graph Visualization
### Approach: Canvas + Force Simulation
The graph uses HTML5 Canvas for rendering with a custom force-directed layout.
No external dependencies — the force simulation is straightforward to
implement in ~200 lines and avoids adding d3-force (~30KB) for a single
use case.
The graph is **lazy-loaded**: the module (`src/js/note-graph.js`) and its
Canvas setup only execute when the user opens the graph view.
### Force Simulation
A simple velocity Verlet integration with three forces:
```javascript
// Force parameters (tunable)
const SIM = {
repulsion: 1000, // charge repulsion constant
attraction: 0.005, // spring constant for linked nodes
edgeLength: 120, // rest length of link springs
damping: 0.9, // velocity damping per tick
centerGravity: 0.01, // pull toward canvas center
maxVelocity: 10, // velocity cap prevents explosions
ticksPerFrame: 3, // sub-steps per rAF for convergence speed
};
```
**Forces applied per tick:**
1. **Repulsion** (all pairs): Coulomb-like `F = k / d²` between every
node pair. For graphs under ~500 nodes, brute-force O(n²) is fine at
60fps. If performance becomes an issue, a Barnes-Hut approximation
(quadtree spatial index) can be added later.
2. **Attraction** (linked pairs): Hooke's law spring along each edge.
`F = k * (d - restLength)` pulls linked nodes toward `edgeLength`.
3. **Center gravity**: Gentle pull toward canvas center prevents drift.
`F = centerGravity * (center - position)`.
4. **Damping**: Multiply velocity by `damping` each tick for convergence.
```javascript
function tick(nodes, edges) {
// Reset accelerations
for (const n of nodes) { n.ax = 0; n.ay = 0; }
// 1. Repulsion (all pairs)
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x;
const dy = nodes[j].y - nodes[i].y;
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const f = SIM.repulsion / (dist * dist);
const fx = (dx / dist) * f;
const fy = (dy / dist) * f;
nodes[i].ax -= fx; nodes[i].ay -= fy;
nodes[j].ax += fx; nodes[j].ay += fy;
}
}
// 2. Attraction (edges)
for (const e of edges) {
const s = e.source, t = e.target;
const dx = t.x - s.x, dy = t.y - s.y;
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const f = SIM.attraction * (dist - SIM.edgeLength);
const fx = (dx / dist) * f;
const fy = (dy / dist) * f;
s.ax += fx; s.ay += fy;
t.ax -= fx; t.ay -= fy;
}
// 3. Center gravity + 4. Integration
const cx = canvas.width / 2, cy = canvas.height / 2;
for (const n of nodes) {
if (n.pinned) continue; // dragged nodes don't move
n.ax += (cx - n.x) * SIM.centerGravity;
n.ay += (cy - n.y) * SIM.centerGravity;
n.vx = clamp((n.vx + n.ax) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity);
n.vy = clamp((n.vy + n.ay) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity);
n.x += n.vx;
n.y += n.vy;
}
}
```
The simulation runs in `requestAnimationFrame`. Once total kinetic energy
drops below a threshold, the loop switches to on-demand rendering (redraw
only on interaction) to save battery.
### Canvas Rendering
```javascript
function render(ctx, nodes, edges, state) {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
// Apply zoom/pan transform
ctx.save();
ctx.translate(state.panX, state.panY);
ctx.scale(state.zoom, state.zoom);
// Edges
for (const e of edges) {
ctx.beginPath();
ctx.moveTo(e.source.x, e.source.y);
ctx.lineTo(e.target.x, e.target.y);
ctx.strokeStyle = e.is_transclusion
? 'rgba(var(--accent-rgb), 0.6)' // dashed for embeds
: 'rgba(150, 150, 150, 0.4)';
if (e.is_transclusion) ctx.setLineDash([4, 4]);
else ctx.setLineDash([]);
ctx.lineWidth = e.highlighted ? 2 : 1;
ctx.stroke();
}
// Nodes
for (const n of nodes) {
const r = baseRadius + Math.sqrt(n.link_count) * 2; // size by connectivity
ctx.beginPath();
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
ctx.fillStyle = _folderColor(n.folder_path); // color by folder
if (n.hovered || n.selected) {
ctx.fillStyle = 'var(--accent)';
}
ctx.fill();
ctx.strokeStyle = n.selected ? '#fff' : 'rgba(0,0,0,0.2)';
ctx.lineWidth = n.selected ? 2 : 1;
ctx.stroke();
// Labels (only at sufficient zoom)
if (state.zoom > 0.6) {
ctx.fillStyle = 'var(--text-primary)';
ctx.font = `${11 / state.zoom}px var(--font-family)`;
ctx.textAlign = 'center';
ctx.fillText(n.title, n.x, n.y + r + 12);
}
}
ctx.restore();
}
```
### Interaction
- **Pan**: Mouse drag on background / touch drag
- **Zoom**: Scroll wheel / pinch — clamped to `[0.2, 3.0]`
- **Drag node**: Click-and-drag pins the node; release unpins
- **Hover**: Highlight node + its direct edges/neighbors (dim others)
- **Click node**: Open that note in the editor
(`openNoteEditor(node.id)` — graph stays open in background)
- **Right-click / long-press**: Context menu with "Open note",
"Show backlinks", "Focus neighborhood"
- **Tag coloring**: Tag pills in the legend toggle folder-based or
tag-based coloring mode
Hit detection uses simple distance-from-center checks (cheaper than
path-based detection and sufficient for circular nodes).
### Graph UI Placement
The graph opens as a **full side-panel view** (replacing the notes list),
toggled by a new "Graph" button in the notes panel toolbar:
```html
<div id="notesGraphView" class="notes-graph-view" style="display:none">
<div class="notes-graph-toolbar">
<button onclick="showNotesList()" title="Back to list">← List</button>
<span class="notes-graph-stats">
<span id="graphNodeCount">0</span> notes ·
<span id="graphEdgeCount">0</span> links
</span>
<button onclick="_graphResetZoom()" title="Reset view"></button>
</div>
<canvas id="noteGraphCanvas"></canvas>
</div>
```
The Canvas fills the panel and resizes with it (`ResizeObserver`). The graph
fetches data from `GET /api/v1/notes/graph` on open and caches it for the
session (invalidated on note save/delete).
### Orphan Detection
Nodes with zero inbound and zero outbound links are rendered as smaller, muted
circles at the periphery. The graph toolbar includes a filter toggle:
"Show orphans" (default on) to declutter dense graphs.
---
## Note-from-Selection (Chat → Notes Bridge)
### UX Flow
A new "Save to Note" button is added to the message action bar alongside
the existing Copy/Edit/Regen buttons:
```javascript
// In ui-core.js, message template
const saveNoteBtn = `<button class="msg-action-btn"
onclick="saveMessageToNote(${index})" title="Save to note">Note</button>`;
```
When clicked:
1. **Full message**: If no text is selected within the message, the entire
message content is used.
2. **Selection**: If the user has text selected within the message, only the
selected text is captured (via `window.getSelection()`).
A small modal/popover appears with:
- **Title** field (pre-filled: first line of content, truncated to 60 chars)
- **Folder** dropdown (existing folders + "new folder" option)
- **Tags** input
- **Append to existing note** toggle — when enabled, shows a note picker
(reuses `searchNoteTitles` autocomplete)
- **Create** / **Append** button
```javascript
async function saveMessageToNote(msgIndex) {
const chat = App.chats.find(c => c.id === App.currentChatId);
const msg = chat?.messages[msgIndex];
if (!msg) return;
// Check for text selection within the message element
const sel = window.getSelection();
let content = msg.content;
const msgEl = document.querySelector(
`.message[data-msg-id="${msg.id}"] .msg-text`);
if (sel && sel.rangeCount > 0 && msgEl?.contains(sel.anchorNode)) {
content = sel.toString().trim() || msg.content;
}
_showSaveToNoteModal({
content,
sourceChannelId: App.currentChatId,
sourceMessageId: msg.id,
defaultTitle: _extractTitle(content),
});
}
function _extractTitle(content) {
const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim();
return firstLine.slice(0, 60) || 'Chat excerpt';
}
```
### Backend: Provenance Tracking
The `source_channel_id` field already exists on the Note model. We add
`source_message_id` for finer provenance:
```sql
-- Migration (Postgres)
ALTER TABLE notes ADD COLUMN IF NOT EXISTS source_message_id UUID;
CREATE INDEX IF NOT EXISTS idx_notes_source_msg ON notes(source_message_id)
WHERE source_message_id IS NOT NULL;
-- Migration (SQLite)
ALTER TABLE notes ADD COLUMN source_message_id TEXT;
```
The create note request body gains optional `source_message_id`:
```go
type createNoteRequest struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
FolderPath string `json:"folder_path"`
Tags []string `json:"tags"`
SourceChannelID string `json:"source_channel_id"`
SourceMessageID string `json:"source_message_id"`
}
```
This enables future "jump to source" — from a note, navigate back to the
exact chat message it was extracted from.
### Append Mode
When the user chooses "Append to existing note", the frontend calls
the existing `PUT /api/v1/notes/:id` with `mode: "append"`:
```javascript
await API.updateNote(targetNoteId, {
content: '\n\n---\n\n' + content,
mode: 'append',
});
```
The separator (`---`) visually distinguishes appended excerpts. The append
mode already exists in the backend.
---
## Daily Notes
### UX
A "Today" button in the notes panel toolbar creates or opens today's daily
note:
```html
<button id="notesTodayBtn" onclick="openDailyNote()" title="Today's note">
📅 Today
</button>
```
```javascript
async function openDailyNote() {
const today = new Date().toISOString().slice(0, 10); // "2026-02-28"
const title = `Daily — ${today}`;
const folder = '/daily';
// Try to find existing daily note
const resp = await API.searchNoteTitles(title, 1);
const existing = (resp.data || []).find(
n => n.title.toLowerCase() === title.toLowerCase()
);
if (existing) {
await openNoteEditor(existing.id);
} else {
// Create with template
const content = _dailyTemplate(today);
const created = await API.createNote(title, content, folder, ['daily']);
await openNoteEditor(created.id);
}
}
function _dailyTemplate(dateStr) {
return `# ${dateStr}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
}
```
Daily notes live in the `/daily` folder by convention. The template is
intentionally minimal — users customize by editing. A future enhancement
could allow configurable daily note templates via settings.
### Linking Daily Notes
Daily notes are regular notes and participate fully in `[[wikilinks]]`. A
user can link from any note to a daily note:
`[[Daily — 2026-02-28]]` and vice versa.
Previous/next day navigation buttons appear in the editor toolbar when
viewing a daily note (detected by folder = `/daily` and title pattern):
```javascript
function _isDailyNote(note) {
return note.folder_path === '/daily' &&
/^Daily — \d{4}-\d{2}-\d{2}$/.test(note.title);
}
```
---
## Access Control
@@ -318,6 +807,7 @@ converts `[[...]]` to `<a>` tags with `onclick` handlers.
- Create links to notes they own (their own notes)
- See backlinks from notes they own
- Autocomplete searches their own notes
- View their own graph
Team-scoped notes (via `team_id` on the notes table) follow the same
grant model as other team resources. When team notes ship (potentially
@@ -344,12 +834,16 @@ type NoteLinkStore interface {
// Backlinks returns notes that link to the given note.
Backlinks(ctx context.Context, noteID string) ([]NoteLinkResult, error)
// Graph returns all nodes and edges for a user's note graph.
Graph(ctx context.Context, userID string) (*NoteGraph, error)
}
type NoteLink struct {
TargetNoteID *string // nil for unresolved
TargetTitle string
DisplayText string
TargetNoteID *string // nil for unresolved
TargetTitle string
DisplayText string
IsTransclusion bool
}
type NoteLinkResult struct {
@@ -359,6 +853,33 @@ type NoteLinkResult struct {
UpdatedAt time.Time
DisplayText string
}
type NoteGraph struct {
Nodes []NoteGraphNode
Edges []NoteGraphEdge
Unresolved []NoteGraphDangling
}
type NoteGraphNode struct {
ID string `json:"id"`
Title string `json:"title"`
FolderPath string `json:"folder_path"`
Tags []string `json:"tags"`
UpdatedAt time.Time `json:"updated_at"`
LinkCount int `json:"link_count"`
}
type NoteGraphEdge struct {
Source string `json:"source"`
Target string `json:"target"`
Title string `json:"title"`
IsTransclusion bool `json:"is_transclusion"`
}
type NoteGraphDangling struct {
Source string `json:"source"`
Title string `json:"title"`
}
```
Implementations in both `store/postgres/note_link.go` and
@@ -367,19 +888,23 @@ Implementations in both `store/postgres/note_link.go` and
### Link Extraction Helper
```go
// ExtractWikilinks parses [[Title]] and [[Title|Display]] from markdown content.
// ExtractWikilinks parses [[Title]], [[Title|Display]], and ![[Title]] from markdown.
func ExtractWikilinks(content string) []NoteLink {
re := regexp.MustCompile(`\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]`)
re := regexp.MustCompile(`(!?)\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]`)
matches := re.FindAllStringSubmatch(content, -1)
seen := make(map[string]bool)
var links []NoteLink
for _, m := range matches {
title := strings.TrimSpace(m[1])
if seen[strings.ToLower(title)] { continue }
seen[strings.ToLower(title)] = true
link := NoteLink{TargetTitle: title}
if len(m) > 2 && m[2] != "" {
link.DisplayText = strings.TrimSpace(m[2])
title := strings.TrimSpace(m[2])
key := strings.ToLower(title)
if seen[key] { continue }
seen[key] = true
link := NoteLink{
TargetTitle: title,
IsTransclusion: m[1] == "!",
}
if len(m) > 3 && m[3] != "" {
link.DisplayText = strings.TrimSpace(m[3])
}
links = append(links, link)
}
@@ -394,12 +919,16 @@ string parsing with no DB dependency.
## Migration
**Migration 002** (both Postgres and SQLite):
**Migration file** (both Postgres and SQLite):
```sql
-- 002_note_links.sql
-- note_links table
CREATE TABLE IF NOT EXISTS note_links ( ... );
CREATE INDEX ...;
-- source_message_id on notes (for note-from-selection provenance)
ALTER TABLE notes ADD COLUMN IF NOT EXISTS source_message_id UUID;
-- (SQLite: ALTER TABLE notes ADD COLUMN source_message_id TEXT;)
```
No backfill needed — existing notes have no `[[links]]` in their content,
@@ -408,16 +937,43 @@ with the new editor.
---
## New Frontend Files
```
src/js/
notes.js # MODIFIED — editor swap, backlinks, daily notes,
# save-to-note modal, graph view toggle
note-graph.js # NEW — Canvas graph renderer, force simulation,
# interaction handling, graph data fetching
src/editor/
note-editor.mjs # NEW — CM.noteEditor() factory
wikilink.mjs # NEW — CM6 extension (parse, decorate, autocomplete)
index.mjs # MODIFIED — exports noteEditor
src/css/
styles.css # MODIFIED — graph, transclusion, backlink, daily note styles
```
`note-graph.js` is loaded as a regular `<script>` (like other modules) but
initializes lazily — it defines functions but doesn't execute until the graph
view is opened.
---
## Implementation Checklist
### Phase 1: Backend (links infrastructure)
- [ ] Migration 002: `note_links` table (Postgres + SQLite)
- [ ] Migration: `note_links` table (Postgres + SQLite)
- [ ] Migration: `source_message_id` column on notes (Postgres + SQLite)
- [ ] `NoteLinkStore` interface + Postgres/SQLite implementations
- [ ] `ExtractWikilinks()` helper with tests
- [ ] Link extraction on note create/update in handlers
- [ ] Dangling link resolution on note create
- [ ] `GET /api/v1/notes/:id/backlinks` endpoint
- [ ] `GET /api/v1/notes/search-titles?q=` endpoint
- [ ] `GET /api/v1/notes/graph` endpoint
- [ ] `source_message_id` support in create handler
- [ ] API client additions in `api.js`
### Phase 2: Frontend (CM6 note editor)
@@ -428,24 +984,78 @@ with the new editor.
- [ ] `saveNote()` reads from CM6 `.getValue()`
- [ ] Graceful fallback if CM6 unavailable
- [ ] Backlinks panel UI below editor
- [ ] `[[link]]` rendering in note list preview (marked.js extension)
- [ ] `[[link]]` rendering in note read mode (marked.js post-render pass)
- [ ] `![[transclusion]]` rendering in read mode with recursion guard
### Phase 3: Polish
### Phase 3: Graph view
- [ ] `src/js/note-graph.js` — force simulation engine
- [ ] Canvas rendering (nodes, edges, labels)
- [ ] Interaction: pan, zoom (wheel + pinch), drag nodes
- [ ] Interaction: hover highlight, click to open note
- [ ] Node sizing by link count, coloring by folder/tag
- [ ] Transclusion edges rendered as dashed lines
- [ ] Orphan detection and filter toggle
- [ ] Unresolved links as ghost nodes (optional display)
- [ ] Graph toolbar (back to list, stats, reset zoom)
- [ ] `ResizeObserver` for panel resize tracking
- [ ] Energy-based simulation pause (stop rAF when converged)
### Phase 4: Note-from-selection + daily notes
- [ ] "Note" button in message action bar (`ui-core.js`)
- [ ] Save-to-note modal (title, folder, tags, append toggle)
- [ ] Selection detection (`window.getSelection()` within message)
- [ ] Append-to-existing-note flow (note picker + `mode: append`)
- [ ] "Today" button in notes toolbar
- [ ] `openDailyNote()` — find-or-create with template
- [ ] Previous/next day navigation for daily notes
- [ ] `/daily` folder convention
### Phase 5: Polish
- [ ] Wikilink chips styled to match app theme (accent color, hover state)
- [ ] Unresolved links styled distinctly (red/dashed, "click to create")
- [ ] Transclusion embeds: border, header bar, expand/collapse
- [ ] Autocomplete dropdown styled (note title + folder path hint)
- [ ] Keyboard navigation in autocomplete (↑/↓, Enter to select, Esc to dismiss)
- [ ] Mobile/touch testing for link taps
- [ ] Keyboard navigation in autocomplete (↑/↓, Enter, Esc)
- [ ] Graph: smooth zoom transitions, node label fade at distance
- [ ] Graph: dark/light theme support (`_folderColor` palette)
- [ ] Mobile/touch: link taps, graph pinch-zoom, note-from-selection
- [ ] Update ARCHITECTURE.md
- [ ] Update ROADMAP.md (move graph/transclusion/daily out of Future)
---
## Performance Considerations
**Graph rendering**: The O(n²) repulsion loop is acceptable for up to ~500
nodes at 60fps on modern hardware. If users report performance issues with
large collections:
- Add Barnes-Hut quadtree spatial partitioning (~100 lines, reduces to
O(n log n))
- WebWorker the simulation (compute positions off-main-thread, post node
coordinates back for rendering)
- Neither is needed for initial release
**Transclusion API calls**: Each `![[embed]]` triggers a `getNote()` fetch.
For a note with many embeds, this could cause a burst of requests. Mitigated
by:
- Client-side cache of fetched notes (already partially in place via
`_currentNote`)
- A batch endpoint could be added later (`POST /api/v1/notes/batch` with
a list of IDs) but is premature for v0.17.3
**Graph data caching**: The graph payload is cached client-side for the
session and invalidated on note save/delete. Avoids re-fetching on every
graph open.
---
## Future (not in v0.17.3)
- **Graph view**: visual node graph of note connections (d3 force layout)
- **Orphan detection**: notes with no inbound or outbound links
- **Link suggestions**: "notes that might be related" via embedding similarity
(reuse KB infrastructure)
- **Transclusion**: `![[Note Title]]` embeds the target note's content inline
- **Tag linking**: `#tag` syntax as an alternative navigation path
- **Team note links**: cross-user linking when team/channel-scoped notes ship
- **Configurable daily note templates**: user-defined template in settings
- **Graph clustering**: visual grouping by folder with hull/boundary rendering
- **Graph search**: highlight matching nodes, zoom to fit results
- **Graph export**: SVG or PNG snapshot of current graph view

View File

@@ -61,12 +61,15 @@ v0.16.0 User Groups v0.17.0 Persona-KB Binding
┌───────┴──────────────┐
│ │
v0.17.1 SQLite Backend ✅ v0.17.2 CodeMirror 6 ✅
v0.17.3 Notes Graph + Wikilinks
(dual DB) (editor bundle, chat
│ input, ext editor)
└───────┬──────────────┘
v0.18.0 Memory (user + persona scopes, review pipeline)
v0.18.1 Side Panel Architecture (independent panels, dual-view)
v0.19.0 Projects / Workspaces + Notifications
v0.20.0 @mention Routing + Multi-model
@@ -337,6 +340,57 @@ Depends on: nothing (frontend-only). Prerequisite for: extension surfaces
---
## v0.17.3 — Notes Graph + Wikilinks
Knowledge graph and bi-directional linking for the notes system. Transforms
notes from flat documents into an interconnected knowledge base with
Obsidian-style `[[wikilinks]]`, backlinks, graph visualization, and
transclusion.
Depends on: CodeMirror 6 (v0.17.2), Notes CRUD (v0.13.x). See
[DESIGN-0.17.3.md](DESIGN-0.17.3.md) for full spec.
**Backend: Note Links**
- [x] `note_links` table (Postgres + SQLite migrations)
- [x] `NoteLinkStore` interface + both implementations
- [x] `ExtractWikilinks()` regex parser + tests
- [x] Link extraction on note create/update
- [x] Dangling link resolution on note create
- [x] `notes.source_message_id` column for chat-to-note provenance
**API Endpoints**
- [x] `GET /notes/:id/backlinks` — notes linking to this note
- [x] `GET /notes/search-titles?q=` — lightweight fuzzy search for autocomplete
- [x] `GET /notes/graph` — full graph topology (nodes + edges + unresolved)
**CM6 Note Editor**
- [x] `note-editor.mjs` factory: markdown live preview (heading sizes, blockquotes, code blocks)
- [x] `wikilink.mjs` CM6 extension: parse, decorate as clickable chips, autocomplete on `[[`
- [x] Textarea → CM6 swap in `notes.js` with graceful fallback
- [x] `noteEditorTheme` in `theme.mjs`
**Graph Visualization**
- [x] Canvas-based force-directed graph (~200 lines, no d3 dependency)
- [x] O(n²) Coulomb repulsion + Hooke spring + center gravity
- [x] Pan, zoom (0.154.0x), drag nodes, hover highlight, click to open
- [x] Node sizing by link_count, folder-based coloring
- [x] Ghost nodes for unresolved `[[links]]` with toggle
- [x] Energy-based pause (stop rAF below KE threshold)
- [x] ResizeObserver for responsive canvas
**Note-from-Selection + Daily Notes**
- [x] "Note" button in message action bar
- [x] Selection-aware capture (`window.getSelection()` within message)
- [x] Provenance tracking (`source_channel_id` + `source_message_id`)
- [x] "Today" button → find-or-create daily note in `/daily/`
**Read Mode**
- [x] `[[link]]` rendered as clickable wikilink chips
- [x] `![[embed]]` transclusion with async content fetch, recursion guard
- [x] Backlinks panel (collapsible, with count badge)
---
## v0.18.0 — Memory (User + Persona Scopes)
Long-term memory across conversations, with scope-aware isolation.
@@ -398,6 +452,37 @@ Depends on: compaction (v0.15.0), knowledge bases (v0.14.0), persona-KB binding
---
## v0.18.1 — Side Panel Architecture
Refactor the side panel system so Notes, HTML/code previews, and future
panels (knowledge base browser, search results) operate independently
rather than sharing a single slot. Currently, opening Notes hides the
preview panel and vice versa — this is confusing when reviewing AI-
generated HTML/documents while also managing notes.
Depends on: Notes graph + wikilinks (v0.17.3). Prerequisite for: live
collaboration (v0.23.0) where multiple panels need simultaneous visibility.
**Panel System**
- [ ] Panel registry: named panels with independent open/close state
- [ ] Dual-view mode: two panels side-by-side (configurable split ratio)
- [ ] Tab-persistent state: each panel remembers scroll position, open note, etc.
- [ ] Panel priority: when space is tight (mobile), most-recently-opened wins
- [ ] Keyboard shortcut: Ctrl/Cmd+\ cycles panels, Ctrl/Cmd+Shift+\ toggles dual
**Preview Panel**
- [ ] Dedicated preview panel (independent of Notes)
- [ ] HTML/document preview with iframe sandbox
- [ ] Code output preview (extension-rendered blocks)
- [ ] Live-updating during streaming responses
**Mobile**
- [ ] Swipe navigation between panels
- [ ] Bottom sheet mode for secondary panel
- [ ] Graceful collapse to single-panel on narrow viewports
---
## v0.19.0 — Projects / Workspaces + Notifications
Organizational containers that group related conversations, KBs, and