diff --git a/package-lock.json b/package-lock.json index 538618a..f775d5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,11 +12,15 @@ "@huggingface/transformers": "^3.8.1", "hdbscan-ts": "^1.0.17", "js-tiktoken": "^1.0.21", + "react": "^19.2.7", + "react-dom": "^19.2.7", "umap-js": "^1.4.0", "vectra": "^0.12.3" }, "devDependencies": { "@types/node": "^18.7.13", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "chalk": "^4.1.0", @@ -929,6 +933,24 @@ "form-data": "^4.0.4" } }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, "node_modules/@types/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", @@ -1918,6 +1940,12 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4241,6 +4269,25 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, "node_modules/rechoir": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", @@ -4454,6 +4501,11 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, "node_modules/schema-utils": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", diff --git a/package.json b/package.json index 76fc5a0..72c705f 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,9 @@ "copyAssets": "node tools/copyAssets.js", "updateVersion": "webpack --env joplin-plugin-config=updateVersion", "update": "npm install -g generator-joplin && yo joplin --node-package-manager npm --update --force", - "lint": "eslint \"src/**/*.ts\"", - "format": "prettier --write \"src/**/*.ts\"", - "format:check": "prettier --check \"src/**/*.ts\"", + "lint": "eslint \"src/**/*.{ts,tsx}\"", + "format": "prettier --write \"src/**/*.{ts,tsx}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx}\"", "ci": "npm run lint && npm run format:check && npm run dist" }, "license": "MIT", @@ -21,6 +21,8 @@ ], "devDependencies": { "@types/node": "^18.7.13", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "chalk": "^4.1.0", @@ -39,6 +41,8 @@ "@huggingface/transformers": "^3.8.1", "hdbscan-ts": "^1.0.17", "js-tiktoken": "^1.0.21", + "react": "^19.2.7", + "react-dom": "^19.2.7", "umap-js": "^1.4.0", "vectra": "^0.12.3" } diff --git a/plugin.config.json b/plugin.config.json index 0034907..243a65a 100644 --- a/plugin.config.json +++ b/plugin.config.json @@ -1,3 +1,3 @@ { - "extraScripts": ["worker/embedWorker.ts"] + "extraScripts": ["worker/embedWorker.ts", "webview/panel.tsx"] } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 98b0277..6fc366d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ import joplin from 'api'; -import { MenuItemLocation } from 'api/types'; +import { MenuItemLocation, ToolbarButtonLocation } from 'api/types'; import { runTestEmbed } from './commands/testEmbed'; +import { runPipeline } from './pipeline/runPipeline'; +import { PanelMessage, WebviewMessage } from './types/panel'; import { log } from './utils/logger'; joplin.plugins.register({ @@ -21,6 +23,75 @@ joplin.plugins.register({ MenuItemLocation.Tools, ); + // Panel starts hidden; user opens via toolbar button or View menu + const panel = await joplin.views.panels.create('aiCategorise.panel'); + await joplin.views.panels.setHtml(panel, '
'); + await joplin.views.panels.addScript(panel, './webview/panel.css'); + await joplin.views.panels.addScript(panel, './webview/panel.js'); + await joplin.views.panels.show(panel, false); + + // Pipeline state shared between the onMessage handler and pipeline callbacks. + // The webview polls this state via { type: 'poll' } messages. + let panelState: PanelMessage | { type: 'idle' } = { type: 'idle' }; + + await joplin.views.panels.onMessage(panel, async (msg: WebviewMessage) => { + switch (msg.type) { + case 'run': + panelState = { type: 'status', text: 'Starting pipeline...' }; + log('Panel: starting pipeline'); + + // Fire-and-forget — pipeline updates panelState via callbacks + runPipeline(installDir, { + onStatus: (text) => { + panelState = { type: 'status', text }; + }, + onProgress: (current, total, cached, skipped) => { + panelState = { type: 'progress', current, total, cached, skipped }; + }, + onComplete: (strategies, notes) => { + panelState = { type: 'results', strategies, notes }; + }, + onError: (message) => { + panelState = { type: 'error', message }; + }, + }); + + return panelState; + + case 'poll': + return panelState; + + case 'openNote': + if (msg.noteId) { + await joplin.commands.execute('openNote', msg.noteId); + } + return; + } + }); + + await joplin.commands.register({ + name: 'aiCategorise.togglePanel', + label: 'AI Categorise: Toggle Panel', + iconName: 'fas fa-brain', + execute: async () => { + const visible = await joplin.views.panels.visible(panel); + await joplin.views.panels.show(panel, !visible); + }, + }); + + await joplin.views.menuItems.create( + 'aiCategorise.togglePanelMenuItem', + 'aiCategorise.togglePanel', + MenuItemLocation.View, + ); + + await joplin.views.toolbarButtons.create( + 'aiCategorise.togglePanelToolbar', + 'aiCategorise.togglePanel', + ToolbarButtonLocation.NoteToolbar, + ); + log('Test command registered under Tools menu'); + log('Panel registered'); }, }); diff --git a/src/pipeline/runPipeline.ts b/src/pipeline/runPipeline.ts new file mode 100644 index 0000000..0a67fe4 --- /dev/null +++ b/src/pipeline/runPipeline.ts @@ -0,0 +1,292 @@ +import { fetchAllNotes } from './noteReader'; +import { benchmark } from './clustering/benchmark'; +import { CategorizationConfig } from '../types/cluster'; +import { averageVectors, blendVectors, computeTitleWeight, cosineSimilarity } from './vectorAggregator'; +import { NoteVector, WorkerMessage } from '../types/embed'; +import { PanelNote } from '../types/panel'; +import { isGenericTitle } from '../utils/titleFilter'; +import { log, logErr } from '../utils/logger'; +import { getEncoding } from 'js-tiktoken'; +import { VectorCache } from './vectorCache'; + +// See testEmbed.ts for rationale on cl100k_base and the 200-token limit. +const enc = getEncoding('cl100k_base'); +const MAX_TOKENS = 200; + +const DEFAULT_CONFIG: CategorizationConfig = { + seed: 42, + metric: 'cosine', + intermediateDim: 10, + intermediateNeighbors: 15, + strategies: [ + { name: 'kmeans-5', algorithm: 'kmeans', K: 5 }, + { name: 'kmedoids-5', algorithm: 'kmedoids', K: 5 }, + { name: 'hdbscan-3', algorithm: 'hdbscan', minClusterSize: 3 }, + { name: 'hdbscan-3-ms2', algorithm: 'hdbscan', minClusterSize: 3, minSamples: 2 }, + { name: 'hdbscan-5-ms2', algorithm: 'hdbscan', minClusterSize: 5, minSamples: 2 }, + ], +}; + +export interface PipelineCallbacks { + onStatus: (text: string) => void; + onProgress: (current: number, total: number, cached: number, skipped: number) => void; + onComplete: (strategies: import('../types/cluster').BenchmarkResult[], notes: PanelNote[]) => void; + onError: (message: string) => void; +} + +/** + * Runs the full embedding + clustering pipeline, reporting progress via callbacks. + * + * This is the same logic as testEmbed.ts, but decoupled from console logging + * so the panel (or any other caller) can receive live updates. + */ +export const runPipeline = async (installDir: string, callbacks: PipelineCallbacks): Promise => { + try { + callbacks.onStatus('Fetching notes...'); + const notes = await fetchAllNotes(); + log(`Fetched ${notes.length} notes`); + + if (notes.length === 0) { + callbacks.onError('No notes found. Create some notes and try again.'); + return; + } + + const cache = await VectorCache.create(); + + // Remove notes from cache that are no longer in Joplin + const indexedIds = await cache.getIndexedIds(); + const joplinNoteIds = new Set(notes.map((n) => n.id)); + const idsToDelete = indexedIds.filter((id) => !joplinNoteIds.has(id)); + + if (idsToDelete.length > 0) { + log(`Removing ${idsToDelete.length} obsolete notes from cache`); + await cache.deleteItems(idsToDelete); + } + + await cache.beginUpdate(); + + callbacks.onStatus('Loading model...'); + const worker = new Worker(`${installDir}/worker/embedWorker.js`); + + let currentNoteIndex = 0; + let currentChunkIndex = 0; + let currentNoteChunks: string[] = []; + let currentChunkEmbeddings: number[][] = []; + let currentBodyVector: number[] = []; + let isEmbeddingTitle = false; + let totalInferenceTime = 0; + let skippedCount = 0; + let cachedCount = 0; + let currentNoteHash = ''; + const batchStartTime = performance.now(); + const noteVectors: NoteVector[] = []; + + const reportProgress = () => { + // current = notes finalized so far (embedded + cached + skipped) + const processed = noteVectors.length + skippedCount; + callbacks.onProgress(processed, notes.length, cachedCount, skippedCount); + }; + + const prepareNoteChunks = (text: string): string[] => { + const tokens = enc.encode(text); + const chunks: string[] = []; + if (tokens.length === 0) return []; + + for (let i = 0; i < tokens.length; i += MAX_TOKENS) { + const chunkTokens = tokens.slice(i, i + MAX_TOKENS); + chunks.push(enc.decode(chunkTokens)); + } + return chunks; + }; + + const finalizeNote = async (vector: number[], titleWeight: number, hash: string) => { + const note = notes[currentNoteIndex]; + noteVectors.push({ noteId: note.id, title: note.title, vector, titleWeight }); + + await cache.upsertItem(note.id, vector, { + title: note.title, + hash, + updatedTime: note.updated_time, + titleWeight, + }); + + reportProgress(); + + currentNoteIndex++; + await processNextNote(); + }; + + const processNextNote = async () => { + currentChunkIndex = 0; + currentNoteChunks = []; + currentChunkEmbeddings = []; + currentBodyVector = []; + isEmbeddingTitle = false; + + // Skip notes with empty body and generic title, and bypass cached notes + while (currentNoteIndex < notes.length) { + const note = notes[currentNoteIndex]; + + if (note.body.length === 0 && isGenericTitle(note.title)) { + log( + `[${currentNoteIndex + 1}/${notes.length}] skipped "${note.title.slice(0, 30)}" (empty body, generic title)`, + ); + skippedCount++; + currentNoteIndex++; + reportProgress(); + continue; + } + + currentNoteHash = cache.computeHash(note.title, note.body); + const cachedItem = await cache.getItem(note.id); + + if (cachedItem && cachedItem.metadata.hash === currentNoteHash) { + log(`[${currentNoteIndex + 1}/${notes.length}] cache hit for "${note.title.slice(0, 30)}"`); + noteVectors.push({ + noteId: note.id, + title: note.title, + vector: cachedItem.vector, + titleWeight: cachedItem.metadata.titleWeight ?? 0, + }); + cachedCount++; + currentNoteIndex++; + reportProgress(); + continue; + } + + break; + } + + if (currentNoteIndex >= notes.length) { + const totalTime = performance.now() - batchStartTime; + log( + `Batch complete: ${notes.length} notes, ${noteVectors.length - cachedCount} embedded, ` + + `${cachedCount} cached, ${skippedCount} skipped in ${Math.round(totalTime)}ms ` + + `(inference: ${Math.round(totalInferenceTime)}ms)`, + ); + + await cache.endUpdate(); + worker.terminate(); + + callbacks.onStatus('Clustering...'); + + if (noteVectors.length < 3) { + callbacks.onError('Too few notes for clustering (need at least 3).'); + return; + } + + const vectors = noteVectors.map((nv) => nv.vector); + const results = benchmark(vectors, DEFAULT_CONFIG); + + const panelNotes: PanelNote[] = noteVectors.map((nv) => ({ + noteId: nv.noteId, + title: nv.title, + })); + + callbacks.onComplete(results, panelNotes); + return; + } + + const note = notes[currentNoteIndex]; + callbacks.onStatus(`Embedding "${note.title.slice(0, 40)}"...`); + + if (note.body.length === 0) { + isEmbeddingTitle = true; + worker.postMessage({ type: 'embed', text: note.title, noteId: note.id }); + } else { + currentNoteChunks = prepareNoteChunks(note.body); + if (currentNoteChunks.length === 0) { + // Whitespace-only body — treat as title-only note + isEmbeddingTitle = true; + worker.postMessage({ type: 'embed', text: note.title, noteId: note.id }); + } else { + worker.postMessage({ + type: 'embed', + text: currentNoteChunks[0], + noteId: note.id, + }); + } + } + }; + + worker.onerror = (err: ErrorEvent) => { + logErr('Worker error:', err.message); + cache.cancelUpdate(); + worker.terminate(); + callbacks.onError('Embedding worker failed: ' + err.message); + }; + + worker.onmessage = async (event: MessageEvent) => { + const data = event.data; + + if (data.type === 'load-result') { + if (data.success) { + log(`Model loaded in ${(data.loadTime / 1000).toFixed(1)}s, device: ${data.device}`); + callbacks.onStatus('Embedding notes...'); + await processNextNote(); + } else { + logErr('Model load failed:', data.error); + cache.cancelUpdate(); + worker.terminate(); + callbacks.onError('Failed to load embedding model: ' + (data.error || 'unknown error')); + } + return; + } + + if (data.type === 'embed-result') { + const note = notes[currentNoteIndex]; + + if (!data.success) { + logErr(`Failed to embed note "${note.title.slice(0, 30)}":`, data.error); + currentNoteIndex++; + await processNextNote(); + return; + } + + totalInferenceTime += data.inferenceTime; + + if (isEmbeddingTitle) { + const titleEmbedding = data.embedding; + + if (currentBodyVector.length > 0) { + const sim = cosineSimilarity(currentBodyVector, titleEmbedding); + const alpha = computeTitleWeight(sim); + const finalVector = blendVectors(currentBodyVector, titleEmbedding, alpha); + await finalizeNote(finalVector, alpha, currentNoteHash); + } else { + await finalizeNote(titleEmbedding, 1.0, currentNoteHash); + } + } else { + currentChunkEmbeddings.push(data.embedding); + log( + `[${currentNoteIndex + 1}/${notes.length}] embedded chunk ${currentChunkIndex + 1}/${currentNoteChunks.length} of "${note.title.slice(0, 30)}"`, + ); + + currentChunkIndex++; + if (currentChunkIndex < currentNoteChunks.length) { + worker.postMessage({ + type: 'embed', + text: currentNoteChunks[currentChunkIndex], + noteId: note.id, + }); + } else { + currentBodyVector = averageVectors(currentChunkEmbeddings); + + if (!isGenericTitle(note.title)) { + isEmbeddingTitle = true; + worker.postMessage({ type: 'embed', text: note.title, noteId: note.id }); + } else { + await finalizeNote(currentBodyVector, 0, currentNoteHash); + } + } + } + } + }; + + worker.postMessage({ type: 'load' }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logErr('Pipeline failed:', message); + callbacks.onError('Pipeline failed: ' + message); + } +}; diff --git a/src/types/panel.ts b/src/types/panel.ts new file mode 100644 index 0000000..4dbf646 --- /dev/null +++ b/src/types/panel.ts @@ -0,0 +1,25 @@ +import { BenchmarkResult } from './cluster'; + +export type { BenchmarkResult }; + +export interface PanelNote { + noteId: string; + title: string; +} + +export interface ProgressState { + current: number; + total: number; + cached: number; + skipped: number; +} + +// Plugin → Webview +export type PanelMessage = + | { type: 'status'; text: string } + | { type: 'progress'; current: number; total: number; cached: number; skipped: number } + | { type: 'results'; strategies: BenchmarkResult[]; notes: PanelNote[] } + | { type: 'error'; message: string }; + +// Webview → Plugin +export type WebviewMessage = { type: 'run' } | { type: 'poll' } | { type: 'openNote'; noteId: string }; diff --git a/src/webview/components/ClusterCard.tsx b/src/webview/components/ClusterCard.tsx new file mode 100644 index 0000000..729e5db --- /dev/null +++ b/src/webview/components/ClusterCard.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { PanelNote } from '../../types/panel'; + +interface ClusterCardProps { + title: string; + noteIndices: number[]; + notes: PanelNote[]; + isNoise?: boolean; +} + +export const ClusterCard: React.FC = ({ title, noteIndices, notes, isNoise }) => { + const [isExpanded, setIsExpanded] = React.useState(false); + + const handleHeaderClick = () => { + setIsExpanded((prev) => !prev); + }; + + const handleNoteClick = (noteId: string) => { + webviewApi.postMessage({ type: 'openNote', noteId }); + }; + + const count = noteIndices.length; + const countLabel = count === 1 ? '1 note' : `${count} notes`; + + return ( +
+
+
+ {title} +
+ {countLabel} + +
+
+ {noteIndices.map((idx) => { + const note = notes[idx]; + if (!note) return null; + return ( +
handleNoteClick(note.noteId)}> + {note.title || 'Untitled'} +
+ ); + })} +
+
+ ); +}; diff --git a/src/webview/components/EmptyState.tsx b/src/webview/components/EmptyState.tsx new file mode 100644 index 0000000..e7004fd --- /dev/null +++ b/src/webview/components/EmptyState.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; + +export const EmptyState: React.FC = () => { + return ( +
+
No categories yet
+
Click Run to categorize your notes using on-device AI.
+
+ ); +}; diff --git a/src/webview/components/Header.tsx b/src/webview/components/Header.tsx new file mode 100644 index 0000000..de88e6c --- /dev/null +++ b/src/webview/components/Header.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; + +interface HeaderProps { + isRunning: boolean; + onRun: () => void; +} + +export const Header: React.FC = ({ isRunning, onRun }) => { + return ( +
+
Note Categorizer
+ +
+ ); +}; diff --git a/src/webview/components/Navigation.tsx b/src/webview/components/Navigation.tsx new file mode 100644 index 0000000..1df0ddd --- /dev/null +++ b/src/webview/components/Navigation.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { useAppState, ViewType } from '../context/AppStateContext'; + +export const Navigation: React.FC = () => { + const { activeView, setView, strategies } = useAppState(); + + const hasResults = strategies && strategies.length > 0; + + const handleTabClick = (view: ViewType) => { + setView(view); + }; + + return ( +
+ + {hasResults && ( + + )} + +
+ ); +}; diff --git a/src/webview/components/ProgressBar.tsx b/src/webview/components/ProgressBar.tsx new file mode 100644 index 0000000..f117fcf --- /dev/null +++ b/src/webview/components/ProgressBar.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { ProgressState } from '../../types/panel'; + +interface ProgressBarProps { + statusText: string; + progress: ProgressState; +} + +export const ProgressBar: React.FC = ({ statusText, progress }) => { + const percent = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0; + const progressLabelParts = []; + if (progress.cached > 0) progressLabelParts.push(`${progress.cached} cached`); + if (progress.skipped > 0) progressLabelParts.push(`${progress.skipped} skipped`); + + return ( +
+
{statusText}
+
+
+
+
+ {progress.total > 0 ? `${progress.current}/${progress.total} notes` : ''} + {progressLabelParts.join(' · ')} +
+
+ ); +}; diff --git a/src/webview/components/StrategySection.tsx b/src/webview/components/StrategySection.tsx new file mode 100644 index 0000000..c0711c5 --- /dev/null +++ b/src/webview/components/StrategySection.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { BenchmarkResult } from '../../types/panel'; + +interface StrategySectionProps { + strategies: BenchmarkResult[]; + selectedStrategyIndex: number; + onStrategyChange: (index: number) => void; +} + +/** Shortens 'hdbscan-5-ms2' → 'hdbscan-5' for pill labels */ +function abbreviateStrategy(name: string): string { + if (!name) return ''; + const parts = name.split('-'); + return parts.length > 2 ? parts[0] + '-' + parts[1] : name; +} + +export const StrategySection: React.FC = ({ + strategies, + selectedStrategyIndex, + onStrategyChange, +}) => { + const selectedStrategy = strategies[selectedStrategyIndex]; + if (!selectedStrategy) return null; + + const handleSelectChange = (e: React.ChangeEvent) => { + onStrategyChange(parseInt(e.target.value, 10)); + }; + + return ( +
+
+ Strategy: + +
+ +
+ Score: {selectedStrategy.silhouetteScore.toFixed(2)} · {selectedStrategy.clusterCount}{' '} + clusters + {selectedStrategy.outlierCount > 0 ? ` · ${selectedStrategy.outlierCount} noise` : ''} +
+ +
+ {strategies.map((s, idx) => ( + + {abbreviateStrategy(s.strategyName)}: {s.silhouetteScore.toFixed(2)} + + ))} +
+
+ ); +}; diff --git a/src/webview/context/AppStateContext.tsx b/src/webview/context/AppStateContext.tsx new file mode 100644 index 0000000..220b326 --- /dev/null +++ b/src/webview/context/AppStateContext.tsx @@ -0,0 +1,155 @@ +import * as React from 'react'; +import { PanelNote, BenchmarkResult, ProgressState } from '../../types/panel'; + +const POLL_INTERVAL_MS = 500; + +export type ViewType = 'idle' | 'dashboard' | 'history' | 'settings'; + +interface AppStateContextType { + isRunning: boolean; + statusText: string; + progress: ProgressState; + error: string | null; + strategies: BenchmarkResult[]; + notes: PanelNote[]; + selectedStrategyIndex: number; + activeView: ViewType; + runPipeline: () => void; + changeStrategy: (index: number) => void; + setView: (view: ViewType) => void; +} + +const AppStateContext = React.createContext(undefined); + +export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [isRunning, setIsRunning] = React.useState(false); + const [statusText, setStatusText] = React.useState(''); + const [progress, setProgress] = React.useState({ + current: 0, + total: 0, + cached: 0, + skipped: 0, + }); + const [error, setError] = React.useState(null); + const [strategies, setStrategies] = React.useState([]); + const [notes, setNotes] = React.useState([]); + const [selectedStrategyIndex, setSelectedStrategyIndex] = React.useState(0); + const [activeView, setActiveView] = React.useState('idle'); + + const pollIntervalRef = React.useRef(null); + + const stopPolling = React.useCallback(() => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + }, []); + + const handlePollResponse = React.useCallback( + (msg: any) => { + if (!msg || !msg.type) return; + + switch (msg.type) { + case 'status': + setStatusText(msg.text || ''); + break; + + case 'progress': + setProgress({ + current: msg.current || 0, + total: msg.total || 0, + cached: msg.cached || 0, + skipped: msg.skipped || 0, + }); + break; + + case 'results': + stopPolling(); + setIsRunning(false); + setStrategies(msg.strategies || []); + setNotes(msg.notes || []); + setSelectedStrategyIndex(0); + setError(null); + setActiveView('dashboard'); + break; + + case 'error': + stopPolling(); + setIsRunning(false); + setError(msg.message || 'An unknown error occurred.'); + break; + } + }, + [stopPolling], + ); + + const startPolling = React.useCallback(() => { + stopPolling(); + pollIntervalRef.current = setInterval(async () => { + const state = await webviewApi.postMessage({ type: 'poll' }); + if (state) { + handlePollResponse(state); + } + }, POLL_INTERVAL_MS); + }, [stopPolling, handlePollResponse]); + + React.useEffect(() => { + return () => { + stopPolling(); + }; + }, [stopPolling]); + + const runPipeline = async () => { + setIsRunning(true); + setStatusText('Starting pipeline...'); + setProgress({ current: 0, total: 0, cached: 0, skipped: 0 }); + setStrategies([]); + setNotes([]); + setError(null); + setActiveView('idle'); + try { + await webviewApi.postMessage({ type: 'run' }); + } catch (err) { + setError('Failed to start pipeline: ' + String(err)); + setIsRunning(false); + return; + } + startPolling(); + }; + + const changeStrategy = (index: number) => { + setSelectedStrategyIndex(index); + }; + + const setView = (view: ViewType) => { + setActiveView(view); + }; + + return ( + + {children} + + ); +}; + +export const useAppState = () => { + const context = React.useContext(AppStateContext); + if (context === undefined) { + throw new Error('useAppState must be used within an AppStateProvider'); + } + return context; +}; diff --git a/src/webview/globals.d.ts b/src/webview/globals.d.ts new file mode 100644 index 0000000..afc2125 --- /dev/null +++ b/src/webview/globals.d.ts @@ -0,0 +1,17 @@ +import type { WebviewMessage, PanelMessage } from '../types/panel'; + +/** + * Joplin injects this global into panel webviews at runtime. + * postMessage sends a WebviewMessage to the plugin's onMessage handler + * and returns the handler's response (PanelMessage or undefined). + */ +interface JoplinWebviewApi { + postMessage(message: WebviewMessage): Promise; +} + +declare global { + // eslint-disable-next-line no-var + var webviewApi: JoplinWebviewApi; +} + +export {}; diff --git a/src/webview/pages/DashboardPage.tsx b/src/webview/pages/DashboardPage.tsx new file mode 100644 index 0000000..3e84794 --- /dev/null +++ b/src/webview/pages/DashboardPage.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { useAppState } from '../context/AppStateContext'; +import { Header } from '../components/Header'; +import { StrategySection } from '../components/StrategySection'; +import { ClusterCard } from '../components/ClusterCard'; + +export const DashboardPage: React.FC = () => { + const { isRunning, runPipeline, strategies, selectedStrategyIndex, changeStrategy, notes } = useAppState(); + + const selectedStrategy = strategies[selectedStrategyIndex]; + + const clusters: { [key: number]: number[] } = {}; + const noise: number[] = []; + + if (selectedStrategy) { + selectedStrategy.assignments.forEach((clusterId, noteIndex) => { + if (clusterId === -1) { + noise.push(noteIndex); + } else { + if (!clusters[clusterId]) { + clusters[clusterId] = []; + } + clusters[clusterId].push(noteIndex); + } + }); + } + + const sortedClusterIds = Object.keys(clusters) + .map(Number) + .sort((a, b) => clusters[b].length - clusters[a].length); + + return ( +
+
+ + + +
+ {sortedClusterIds.map((id, idx) => ( + + ))} + {noise.length > 0 && ( + + )} +
+
+ ); +}; diff --git a/src/webview/pages/EmptyStatePage.tsx b/src/webview/pages/EmptyStatePage.tsx new file mode 100644 index 0000000..3e003f7 --- /dev/null +++ b/src/webview/pages/EmptyStatePage.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { useAppState } from '../context/AppStateContext'; +import { Header } from '../components/Header'; +import { ProgressBar } from '../components/ProgressBar'; +import { EmptyState } from '../components/EmptyState'; + +export const EmptyStatePage: React.FC = () => { + const { isRunning, runPipeline, statusText, progress } = useAppState(); + + return ( +
+
+ {isRunning ? : } +
+ ); +}; diff --git a/src/webview/pages/HistoryPage.tsx b/src/webview/pages/HistoryPage.tsx new file mode 100644 index 0000000..849d150 --- /dev/null +++ b/src/webview/pages/HistoryPage.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; + +export const HistoryPage: React.FC = () => { + return ( +
+
Change Log
+
+ A history of categorization runs and note changes will be displayed here once persistence is implemented + in a future update. +
+
+ ); +}; diff --git a/src/webview/pages/SettingsPage.tsx b/src/webview/pages/SettingsPage.tsx new file mode 100644 index 0000000..4b04715 --- /dev/null +++ b/src/webview/pages/SettingsPage.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; + +export const SettingsPage: React.FC = () => { + return ( +
+
Settings
+
+ Model settings and clustering parameters will be configurable here in a future version. +
+
+
Default Configuration:
+
+ • Model: ONNX BGE-Micro-v2 +
+
+ • Metric: Cosine Similarity +
+
+ • Limit: 200 Tokens/Chunk +
+
+ • Strategies: K-Means, K-Medoids, HDBSCAN +
+
+
+ ); +}; diff --git a/src/webview/panel.css b/src/webview/panel.css new file mode 100644 index 0000000..77de1f6 --- /dev/null +++ b/src/webview/panel.css @@ -0,0 +1,418 @@ +:root { + --accent: #4a90d9; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body, +#root { + height: 100vh; +} + +body { + font-family: var(--joplin-font-family); + font-size: var(--joplin-font-size); + color: var(--joplin-color); + background-color: var(--joplin-background-color); + line-height: 1.5; + overflow: hidden; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid var(--joplin-divider-color); + background: var(--joplin-background-color); + position: sticky; + top: 0; + z-index: 10; +} + +.panel-header-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 1.05em; +} + +.btn-run { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 6px 14px; + border: none; + border-radius: 6px; + background: var(--accent); + color: #fff; + font-size: 0.85em; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: opacity 0.2s, transform 0.15s; +} + +.btn-run:hover { + opacity: 0.88; + transform: translateY(-1px); +} + +.btn-run:active { + transform: translateY(0); +} + +.btn-run:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.status-bar { + padding: 10px 14px; + border-bottom: 1px solid var(--joplin-divider-color); + display: none; +} + +.status-bar.visible { + display: block; +} + +.status-text { + font-size: 0.85em; + opacity: 0.75; + margin-bottom: 6px; +} + +.progress-container { + width: 100%; + height: 6px; + background: var(--joplin-divider-color); + border-radius: 3px; + overflow: hidden; + margin-bottom: 6px; +} + +.progress-fill { + height: 100%; + background: var(--accent); + border-radius: 3px; + width: 0%; + transition: width 0.35s ease; +} + +.progress-label { + font-size: 0.8em; + opacity: 0.6; + display: flex; + justify-content: space-between; +} + +.strategy-section { + padding: 10px 14px; + border-bottom: 1px solid var(--joplin-divider-color); + display: none; +} + +.strategy-section.visible { + display: block; +} + +.strategy-selector-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.strategy-selector-label { + font-size: 0.82em; + font-weight: 600; + white-space: nowrap; +} + +.strategy-select { + flex: 1; + padding: 4px 8px; + border: 1px solid var(--joplin-divider-color); + border-radius: 5px; + background: var(--joplin-background-color); + color: var(--joplin-color); + font-size: 0.82em; + font-family: inherit; + cursor: pointer; + transition: border-color 0.2s; +} + +.strategy-select:hover, +.strategy-select:focus { + outline: none; + border-color: var(--accent); +} + +.strategy-score { + font-size: 0.82em; + opacity: 0.7; + margin-bottom: 6px; +} + +.strategy-score strong { + color: var(--accent); + opacity: 1; +} + +.strategy-pills { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.strategy-pill { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 0.75em; + background: var(--joplin-divider-color); + opacity: 0.8; + white-space: nowrap; +} + +.strategy-pill.active { + background: var(--accent); + color: #fff; + opacity: 1; + font-weight: 600; +} + +.cluster-list { + padding: 6px 0; + display: none; +} + +.cluster-list.visible { + display: block; +} + +.cluster-card { + border-bottom: 1px solid var(--joplin-divider-color); +} + +.cluster-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + cursor: pointer; + transition: background-color 0.15s; + user-select: none; +} + +.cluster-header:hover { + background: var(--joplin-background-color-hover); +} + +.cluster-header-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.cluster-title { + font-size: 0.9em; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cluster-count { + font-size: 0.78em; + opacity: 0.55; + white-space: nowrap; + flex-shrink: 0; +} + +.cluster-chevron { + width: 0; + height: 0; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 5px solid currentColor; + opacity: 0.5; + transition: transform 0.2s ease; + flex-shrink: 0; + margin-left: 4px; +} + +.cluster-card.expanded .cluster-chevron { + transform: rotate(90deg); +} + +.cluster-notes { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; +} + +.cluster-card.expanded .cluster-notes { + max-height: 5000px; +} + +.note-item { + display: flex; + align-items: center; + padding: 6px 14px 6px 28px; + cursor: pointer; + transition: background-color 0.12s; + font-size: 0.84em; +} + +.note-item:hover { + background: var(--joplin-background-color-hover); +} + +.note-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cluster-card.noise .cluster-title { + opacity: 0.7; +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + text-align: center; +} + +.empty-title { + font-size: 1em; + font-weight: 600; + margin-bottom: 6px; +} + +.empty-subtitle { + font-size: 0.84em; + opacity: 0.55; + line-height: 1.5; + max-width: 280px; +} + +.error-banner { + padding: 10px 14px; + background: var(--joplin-color-error); + color: #fff; + font-size: 0.84em; + display: none; +} + +.error-banner.visible { + display: block; +} + +/* --- Container & Navigation Layout --- */ + +.panel-container { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +.panel-main { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.panel-navigation { + display: flex; + background: var(--joplin-background-color); + border-bottom: 1px solid var(--joplin-divider-color); + width: 100%; +} + +.nav-tab { + flex: 1; + padding: 10px 14px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--joplin-color); + opacity: 0.65; + font-family: inherit; + font-size: 0.85em; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + text-align: center; +} + +.nav-tab:hover { + opacity: 0.9; + background: var(--joplin-background-color-hover); +} + +.nav-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); + font-weight: 600; +} + +/* --- Scrollbar Customization --- */ + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--joplin-divider-color); + border-radius: 3px; + transition: background-color 0.2s ease; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--accent); +} + +/* --- Config Card (Settings Page) --- */ + +.config-card { + text-align: left; + font-size: 0.82em; + opacity: 0.7; + border: 1px solid var(--joplin-divider-color); + border-radius: 6px; + padding: 12px; + width: 100%; + max-width: 300px; + background: var(--joplin-background-color-hover); +} + +.config-card-header { + font-weight: 600; + margin-bottom: 6px; + border-bottom: 1px solid var(--joplin-divider-color); + padding-bottom: 4px; +} + +.config-card-item { + margin: 4px 0; +} diff --git a/src/webview/panel.tsx b/src/webview/panel.tsx new file mode 100644 index 0000000..36f14e5 --- /dev/null +++ b/src/webview/panel.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { createRoot } from 'react-dom/client'; +import { AppStateProvider, useAppState } from './context/AppStateContext'; +import { Navigation } from './components/Navigation'; +import { DashboardPage } from './pages/DashboardPage'; +import { EmptyStatePage } from './pages/EmptyStatePage'; +import { HistoryPage } from './pages/HistoryPage'; +import { SettingsPage } from './pages/SettingsPage'; + +const AppContent: React.FC = () => { + const { activeView, error } = useAppState(); + + return ( +
+ + + {error &&
Error: {error}
} + +
+ {activeView === 'idle' && } + {activeView === 'dashboard' && } + {activeView === 'history' && } + {activeView === 'settings' && } +
+
+ ); +}; + +const App: React.FC = () => { + return ( + + + + ); +}; + +function init() { + const container = document.getElementById('root'); + if (container) { + const root = createRoot(container); + root.render(); + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +}