Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "joplin-plugin-note-categorization",
"version": "0.1.6",
"version": "0.1.7",
"scripts": {
"dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && npm run copyAssets && webpack --env joplin-plugin-config=createArchive",
"prepare": "npm run dist",
Expand Down
2 changes: 1 addition & 1 deletion src/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"manifest_version": 1,
"id": "com.harsh16gupta.notecategorization",
"app_min_version": "3.5",
"version": "0.1.6",
"version": "0.1.7",
"name": "Note Categorization Plugin",
"description": "AI-based note categorisation: clusters notes semantically, suggests tags and notebook structures, and detects stale notes.",
"author": "Harsh Gupta",
Expand Down
24 changes: 17 additions & 7 deletions src/panel/setupPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export async function setupPanel(operationState: OperationState): Promise<string
strategies: BenchmarkResult[];
notes: PanelNote[];
selectedStrategyIndex: number;
isNativeAiUsed?: boolean;
isAiNamingUsed?: boolean;
} | null = null;

await joplin.views.panels.onMessage(panel, async (msg: WebviewMessage) => {
Expand All @@ -29,15 +31,21 @@ export async function setupPanel(operationState: OperationState): Promise<string
log('Panel: starting pipeline');

runPipeline(installDir, {
onStatus: (text) => {
panelState = { type: 'status', text };
onStatus: (text, isNativeAiUsed) => {
panelState = { type: 'status', text, isNativeAiUsed };
},
onProgress: (current, total, cached, skipped) => {
panelState = { type: 'progress', current, total, cached, skipped };
onProgress: (current, total, cached, skipped, isNativeAiUsed) => {
panelState = { type: 'progress', current, total, cached, skipped, isNativeAiUsed };
},
onComplete: (strategies, notes) => {
lastResultsState = { strategies, notes, selectedStrategyIndex: 0 };
panelState = { type: 'results', strategies, notes };
onComplete: (strategies, notes, isNativeAiUsed, isAiNamingUsed) => {
lastResultsState = {
strategies,
notes,
selectedStrategyIndex: 0,
isNativeAiUsed,
isAiNamingUsed,
};
panelState = { type: 'results', strategies, notes, isNativeAiUsed, isAiNamingUsed };
},
onError: (message) => {
panelState = { type: 'error', message };
Expand All @@ -59,6 +67,8 @@ export async function setupPanel(operationState: OperationState): Promise<string
strategies: lastResultsState.strategies,
notes: lastResultsState.notes,
selectedStrategyIndex: lastResultsState.selectedStrategyIndex,
isNativeAiUsed: lastResultsState.isNativeAiUsed,
isAiNamingUsed: lastResultsState.isAiNamingUsed,
};
}
return panelState;
Expand Down
12 changes: 11 additions & 1 deletion src/pipeline/clustering/aiNamingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,11 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
* in a single unchained expression as a defensive practice to prevent proxy path
* state accumulation across Joplin sandbox runtime versions.
*/
export async function upgradeClusterNamesWithAi(results: BenchmarkResult[], documents: DocumentText[]): Promise<void> {
export async function upgradeClusterNamesWithAi(
results: BenchmarkResult[],
documents: DocumentText[],
): Promise<boolean> {
let anyUpgraded = false;
await Promise.all(
results.map(async (result) => {
if (!result.clusterNames || Object.keys(result.clusterNames).length === 0) {
Expand Down Expand Up @@ -266,6 +270,10 @@ export async function upgradeClusterNamesWithAi(results: BenchmarkResult[], docu
// If AI didn't provide a name for this cluster, keep the TF-IDF name
}

if (upgradedCount > 0) {
anyUpgraded = true;
}

log(`AI naming: upgraded ${upgradedCount}/${clusterIds.length} cluster names`);

// Resolve name collisions (same logic pattern as postProcess.ts)
Expand All @@ -277,6 +285,8 @@ export async function upgradeClusterNamesWithAi(results: BenchmarkResult[], docu
}
}),
);

return anyUpgraded;
}

/**
Expand Down
47 changes: 31 additions & 16 deletions src/pipeline/runPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,14 @@ import { upgradeClusterNamesWithAi } from './clustering/aiNamingService';
import { EmbeddingWorkerOrchestrator } from './EmbeddingWorkerOrchestrator';

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;
onStatus: (text: string, isNativeAiUsed?: boolean) => void;
onProgress: (current: number, total: number, cached: number, skipped: number, isNativeAiUsed?: boolean) => void;
onComplete: (
strategies: import('../types/cluster').BenchmarkResult[],
notes: PanelNote[],
isNativeAiUsed?: boolean,
isAiNamingUsed?: boolean,
) => void;
onError: (message: string) => void;
}

Expand Down Expand Up @@ -52,7 +57,7 @@ export const runPipeline = async (installDir: string, callbacks: PipelineCallbac

if (await isNativeAiReady()) {
log('Native AI Search active: using native embeddings pipeline');
callbacks.onStatus('Fetching native embeddings...');
callbacks.onStatus('Fetching native embeddings...', true);

try {
const noteIds = notes.map((n) => n.id);
Expand Down Expand Up @@ -102,7 +107,7 @@ export const runPipeline = async (installDir: string, callbacks: PipelineCallbac
if (validNotes.length < 3) {
log('Too few indexed notes found in native DB. Falling back to local ONNX Web Worker.');
} else {
callbacks.onStatus('Clustering...');
callbacks.onStatus('Clustering...', true);
const clusterStart = performance.now();
const adaptiveConfig = createAdaptiveConfig(
nativeResult.dimension,
Expand All @@ -121,22 +126,23 @@ export const runPipeline = async (installDir: string, callbacks: PipelineCallbac

callbacks.onStatus(
`Extracting topics for ${results.reduce((sum, r) => sum + r.clusterCount, 0)} clusters...`,
true,
);
const enrichStart = performance.now();
await enrichResultsWithTags(results, allPipelineDocuments, 5, callbacks.onStatus);
await enrichResultsWithTags(results, allPipelineDocuments, 5, (t) => callbacks.onStatus(t, true));
log(`Topic extraction: ${Math.round(performance.now() - enrichStart)}ms`);

callbacks.onStatus('Generating AI cluster names...');
callbacks.onStatus('Generating AI cluster names...', true);
const aiStart = performance.now();
await upgradeClusterNamesWithAi(results, allPipelineDocuments);
const isAiNamingUsed = await upgradeClusterNamesWithAi(results, allPipelineDocuments);
log(`AI naming: ${Math.round(performance.now() - aiStart)}ms`);

const panelNotes: PanelNote[] = validNotes.map((n) => ({
noteId: n.id,
title: n.title,
}));

callbacks.onComplete(results, panelNotes);
callbacks.onComplete(results, panelNotes, true, isAiNamingUsed);
return;
}
} catch (err) {
Expand All @@ -161,7 +167,13 @@ export const runPipeline = async (installDir: string, callbacks: PipelineCallbac
await cache.beginUpdate();

const batchStartTime = performance.now();
const orchestrator = new EmbeddingWorkerOrchestrator(installDir, notes, cache, callbacks);
const fallbackCallbacks: PipelineCallbacks = {
...callbacks,
onStatus: (text) => callbacks.onStatus(text, false),
onProgress: (current, total, cached, skipped) =>
callbacks.onProgress(current, total, cached, skipped, false),
};
const orchestrator = new EmbeddingWorkerOrchestrator(installDir, notes, cache, fallbackCallbacks);

let result;
try {
Expand All @@ -184,7 +196,7 @@ export const runPipeline = async (installDir: string, callbacks: PipelineCallbac

await cache.endUpdate();

callbacks.onStatus('Clustering...');
callbacks.onStatus('Clustering...', false);

if (noteVectors.length < 3) {
callbacks.onError('Too few notes for clustering (need at least 3).');
Expand All @@ -207,22 +219,25 @@ export const runPipeline = async (installDir: string, callbacks: PipelineCallbac
};
});

callbacks.onStatus(`Extracting topics for ${results.reduce((sum, r) => sum + r.clusterCount, 0)} clusters...`);
callbacks.onStatus(
`Extracting topics for ${results.reduce((sum, r) => sum + r.clusterCount, 0)} clusters...`,
false,
);
const enrichStart = performance.now();
await enrichResultsWithTags(results, allPipelineDocuments, 5, callbacks.onStatus);
await enrichResultsWithTags(results, allPipelineDocuments, 5, (t) => callbacks.onStatus(t, false));
log(`Topic extraction: ${Math.round(performance.now() - enrichStart)}ms`);

callbacks.onStatus('Generating AI cluster names...');
callbacks.onStatus('Generating AI cluster names...', false);
const aiStart = performance.now();
await upgradeClusterNamesWithAi(results, allPipelineDocuments);
const isAiNamingUsed = await upgradeClusterNamesWithAi(results, allPipelineDocuments);
log(`AI naming: ${Math.round(performance.now() - aiStart)}ms`);

const panelNotes: PanelNote[] = noteVectors.map((nv) => ({
noteId: nv.noteId,
title: nv.title,
}));

callbacks.onComplete(results, panelNotes);
callbacks.onComplete(results, panelNotes, false, isAiNamingUsed);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logErr('Pipeline failed:', message);
Expand Down
14 changes: 11 additions & 3 deletions src/types/panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,17 @@ export interface ApplyMessage {

// 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[]; selectedStrategyIndex?: number }
| { type: 'status'; text: string; isNativeAiUsed?: boolean }
| { type: 'progress'; current: number; total: number; cached: number; skipped: number; isNativeAiUsed?: boolean }
| {
type: 'results';
strategies: BenchmarkResult[];
notes: PanelNote[];
selectedStrategyIndex?: number;
isNativeAiUsed?: boolean;
isAiNamingUsed?: boolean;
/* eslint-disable-next-line no-mixed-spaces-and-tabs */
}
| { type: 'error'; message: string }
| { type: 'apply_status'; text: string }
| { type: 'apply_progress'; current: number; total: number }
Expand Down
68 changes: 68 additions & 0 deletions src/webview/components/NoticeBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import * as React from 'react';

interface NoticeBannerProps {
variant?: 'info' | 'warning';
title: string;
message: string;
onClose?: () => void;
}

export const NoticeBanner: React.FC<NoticeBannerProps> = ({ variant = 'info', title, message, onClose }) => {
return (
<div className={`notice-banner ${variant}`}>
<div className="notice-icon">
{variant === 'info' ? (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 18h6" />
<path d="M10 22h4" />
<path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14" />
</svg>
) : (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" strokeWidth="2.5" />
</svg>
)}
</div>
<div className="notice-content">
<strong>{title}</strong>: {message}
</div>
{onClose && (
<button className="notice-close-btn" onClick={onClose} aria-label="Dismiss notice" title="Dismiss">
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
);
};
22 changes: 22 additions & 0 deletions src/webview/context/AppStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ interface AppStateContextType {
notes: PanelNote[];
selectedStrategyIndex: number;
activeView: ViewType;
isNativeAiUsed: boolean;
isAiNamingUsed: boolean;
runPipeline: () => void;
changeStrategy: (index: number) => void;
setView: (view: ViewType) => void;
Expand Down Expand Up @@ -96,6 +98,8 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil
notes,
selectedStrategyIndex,
activeView,
isNativeAiUsed,
isAiNamingUsed,
runPipeline,
changeStrategy,
setView,
Expand All @@ -110,6 +114,8 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil
setSelectedStrategyIndex,
setError,
setActiveView,
setIsNativeAiUsed,
setIsAiNamingUsed,
} = usePipelineState(() => startPolling(), resetApplyState);

const handlePollResponse = React.useCallback(
Expand All @@ -119,6 +125,9 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil
switch (msg.type) {
case 'status':
setStatusText(msg.text || '');
if (typeof msg.isNativeAiUsed === 'boolean') {
setIsNativeAiUsed(msg.isNativeAiUsed);
}
break;

case 'progress':
Expand All @@ -128,6 +137,9 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil
cached: msg.cached || 0,
skipped: msg.skipped || 0,
});
if (typeof msg.isNativeAiUsed === 'boolean') {
setIsNativeAiUsed(msg.isNativeAiUsed);
}
break;

case 'results': {
Expand All @@ -140,6 +152,12 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil
);
const defaultIdx = kmeansIdx !== -1 ? kmeansIdx : 0;
setSelectedStrategyIndex(msg.selectedStrategyIndex ?? defaultIdx);
if (typeof msg.isNativeAiUsed === 'boolean') {
setIsNativeAiUsed(msg.isNativeAiUsed);
}
if (typeof msg.isAiNamingUsed === 'boolean') {
setIsAiNamingUsed(msg.isAiNamingUsed);
}
setError(null);
setActiveView('dashboard');
break;
Expand Down Expand Up @@ -225,6 +243,8 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil
setUndoProgress,
setUndoError,
setUndoSuccess,
setIsNativeAiUsed,
setIsAiNamingUsed,
],
);

Expand Down Expand Up @@ -302,6 +322,8 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil
notes,
selectedStrategyIndex,
activeView,
isNativeAiUsed,
isAiNamingUsed,
runPipeline,
changeStrategy,
setView,
Expand Down
Loading
Loading