From f8b486a285734f9d2db560375ae734eb9c51ac89 Mon Sep 17 00:00:00 2001 From: Aditya Gupta Date: Thu, 16 Apr 2026 16:29:27 +0530 Subject: [PATCH] feat: Make LLM features toggleable --- app/not-found.tsx | 1 + app/page.tsx | 91 +++++++++++++++++++++++----------- components/kanban-area.tsx | 3 ++ components/project-sidebar.tsx | 34 +++++++++++-- components/status-bar.tsx | 51 ++++++++++++++++--- components/tile-card.tsx | 4 +- components/tiling-area.tsx | 4 ++ lib/ai-settings.ts | 16 ++++-- 8 files changed, 161 insertions(+), 43 deletions(-) diff --git a/app/not-found.tsx b/app/not-found.tsx index 0b38538..c73534e 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -2,6 +2,7 @@ import Link from "next/link" + export default function NotFound() { return (
diff --git a/app/page.tsx b/app/page.tsx index 7662c6a..39c5d16 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -65,6 +65,7 @@ export default function Page() { const [showHelpTooltip, setShowHelpTooltip] = useState(false) const helpTooltipTimer = useRef(null) const { settings, updateSettings, currentModel, isHydrated } = useAISettings() + const aiEnabled = settings.aiEnabled const debounceTimers = useRef>>({}) useEffect(() => { @@ -124,6 +125,12 @@ export default function Page() { if (helpTooltipTimer.current) clearTimeout(helpTooltipTimer.current) }, []) + useEffect(() => { + if (!aiEnabled) { + setIsGhostPanelOpen(false) + } + }, [aiEnabled]) + const undo = useCallback(() => { const stack = blockHistoryRef.current[activeProjectId] if (!stack || stack.length === 0) { @@ -155,7 +162,7 @@ export default function Page() { const activeProject = useMemo(() => projects.find(p => p.id === activeProjectId) || projects[0], - [projects, activeProjectId]) + [projects, activeProjectId]) const blocks = activeProject?.blocks || [] const ghostNotes = activeProject?.ghostNotes || [] @@ -441,6 +448,7 @@ export default function Page() { } const generateGhostNote = useCallback(async (projectId: string) => { + if (!aiEnabled) return const targetProject = projectsRef.current.find(p => p.id === projectId) if (!targetProject) return @@ -510,9 +518,10 @@ export default function Page() { } finally { generatingRef.current.delete(projectId) } - }, []) + }, [aiEnabled]) const enrichBlock = useCallback(async (projectId: string, id: string, text: string, category?: string, forcedType?: string) => { + if (!aiEnabled) return // Read context directly from the ref — avoids wrapping in setProjects() which // React StrictMode double-invokes in development, causing two concurrent // enrichment requests and a visible category flicker. @@ -541,8 +550,8 @@ export default function Page() { // the original block IDs so we get exact, rename-proof references. const influencedBy = data.influencedByIndices ? (data.influencedByIndices as number[]) - .map((idx) => context[idx]?.id) - .filter(Boolean) as string[] + .map((idx) => context[idx]?.id) + .filter(Boolean) as string[] : [] setProjects((current: Project[]) => { @@ -644,9 +653,10 @@ export default function Page() { blocks: proj.blocks.map(b => b.id === id ? { ...b, isEnriching: false, isError: true, statusText: errorStatus } : b) } : proj)) } - }, [generateGhostNote]) + }, [generateGhostNote, aiEnabled]) const claimGhostNote = useCallback((id: string) => { + if (!aiEnabled) return const note = (activeProject?.ghostNotes || []).find(n => n.id === id) if (!note || note.isGenerating) return const newId = generateId() @@ -668,7 +678,7 @@ export default function Page() { enrichBlock(p.id, newId, text, category, "thesis") return updatedProject }) - }, [activeProject, updateActiveProject, enrichBlock]) + }, [activeProject, updateActiveProject, enrichBlock, aiEnabled]) const dismissGhostNote = useCallback((id: string) => { updateActiveProject(p => ({ @@ -749,14 +759,16 @@ export default function Page() { text: resolvedText, timestamp: Date.now(), contentType: initialDisplayType, - isEnriching: true, + isEnriching: aiEnabled, }] })) setIsCommandKOpen(false) - enrichBlock(activeProjectId, newId, resolvedText, undefined, enrichForcedType).catch(console.error) + if (aiEnabled) { + enrichBlock(activeProjectId, newId, resolvedText, undefined, enrichForcedType).catch(console.error) + } }, - [activeProjectId, pushHistory, updateActiveProject, enrichBlock] + [activeProjectId, pushHistory, updateActiveProject, enrichBlock, aiEnabled] ) const deleteBlock = useCallback((id: string) => { @@ -791,29 +803,39 @@ export default function Page() { clearTimeout(debounceTimers.current[activeProjectId][id]) } - debounceTimers.current[activeProjectId][id] = setTimeout(() => { - enrichBlock(activeProjectId, id, newText, block.category).catch(console.error) - delete debounceTimers.current[activeProjectId][id] - }, 800) + if (aiEnabled) { + debounceTimers.current[activeProjectId][id] = setTimeout(() => { + enrichBlock(activeProjectId, id, newText, block.category).catch(console.error) + delete debounceTimers.current[activeProjectId][id] + }, 800) + } return prev.map(p => p.id === activeProjectId ? { ...p, - blocks: p.blocks.map(b => b.id === id ? { ...b, text: newText, isEnriching: true, isError: false } : b) + blocks: p.blocks.map(b => b.id === id ? { ...b, text: newText, isEnriching: aiEnabled, isError: false } : b) } : p) }) - }, [activeProjectId, enrichBlock, pushHistory]) + }, [activeProjectId, enrichBlock, pushHistory, aiEnabled]) const reEnrichBlock = useCallback((id: string, newCategory?: string) => { const block = blocksRef.current.find(b => b.id === id) if (!block) return + if (!aiEnabled) { + updateActiveProject(p => ({ + ...p, + blocks: p.blocks.map(b => b.id === id ? { ...b, category: newCategory } : b) + })) + return + } + updateActiveProject(p => ({ ...p, blocks: p.blocks.map(b => b.id === id ? { ...b, category: newCategory, isEnriching: true } : b) })) enrichBlock(activeProjectId, id, block.text, newCategory || block.category, block.contentType).catch(console.error) - }, [activeProjectId, updateActiveProject, enrichBlock]) + }, [activeProjectId, updateActiveProject, enrichBlock, aiEnabled]) const editAnnotation = useCallback((id: string, newAnnotation: string) => { updateActiveProject(p => ({ @@ -862,12 +884,19 @@ export default function Page() { const block = blocksRef.current.find(b => b.id === id) if (!block) return pushHistory(activeProjectId, blocksRef.current) + if (!aiEnabled) { + updateActiveProject(p => ({ + ...p, + blocks: p.blocks.map(b => b.id === id ? { ...b, contentType: newType } : b) + })) + return + } updateActiveProject(p => ({ ...p, blocks: p.blocks.map(b => b.id === id ? { ...b, contentType: newType, isEnriching: true } : b) })) enrichBlock(activeProjectId, id, block.text, block.category, newType).catch(console.error) - }, [activeProjectId, pushHistory, updateActiveProject, enrichBlock]) + }, [activeProjectId, pushHistory, updateActiveProject, enrichBlock, aiEnabled]) const clearBlocks = useCallback(() => { pushHistory(activeProjectId, blocksRef.current) @@ -903,7 +932,7 @@ export default function Page() { const handleCommand = useCallback((cmd: string, text?: string) => { setIsCommandKOpen(false) - + // Handle view switches if (cmd === "kanban") { setViewMode("kanban") @@ -925,12 +954,13 @@ export default function Page() { setIsGhostPanelOpen(false) setIsIndexOpen(prev => !prev) } else if (cmd === "open-synthesis") { + if (!aiEnabled) return setIsSidebarOpen(false) setIsIndexOpen(false) setIsGhostPanelOpen(prev => !prev) } else if (cmd === "clear") clearBlocks() else if (cmd === "help") window.open("https://github.com/albingroen/react-cmdk", "_blank") - + // .nodepad export / import else if (cmd === "export-nodepad") { setProjects(prev => { @@ -963,13 +993,13 @@ export default function Page() { return prev }) } - + // Handle type overrides else if (cmd === "task" && text) addBlock(text, "task") else if (cmd === "thesis" && text) addBlock(text, "thesis") - + setIsCommandKOpen(false) - }, [clearBlocks, addBlock, activeProjectId]) + }, [clearBlocks, addBlock, activeProjectId, aiEnabled]) if (!isAuthReady) { return
@@ -1023,22 +1053,23 @@ export default function Page() { isSidebarOpen={isSidebarOpen} isIndexOpen={isIndexOpen} isGhostPanelOpen={isGhostPanelOpen} - ghostNoteCount={ghostNotes.filter(n => !n.isGenerating).length} + ghostNoteCount={aiEnabled ? ghostNotes.filter(n => !n.isGenerating).length : 0} activeProjectName={activeProject?.name || ""} onMenuClick={() => setIsSidebarOpen(!isSidebarOpen)} onIndexToggle={() => setIsIndexOpen(!isIndexOpen)} onGhostPanelToggle={() => setIsGhostPanelOpen(prev => !prev)} - modelLabel={isHydrated && settings.apiKey ? currentModel.shortLabel : undefined} + modelLabel={isHydrated && aiEnabled && settings.apiKey ? currentModel.shortLabel : undefined} showHelpTooltip={showHelpTooltip} sessionUsername={sessionUser.username} onLogout={handleLogout} + aiEnabled={aiEnabled} onHelpTooltipDismiss={() => { setShowHelpTooltip(false) if (helpTooltipTimer.current) clearTimeout(helpTooltipTimer.current) }} /> - {isHydrated && !settings.apiKey && ( + {isHydrated && aiEnabled && !settings.apiKey && (
⚡ AI enrichment requires an OpenRouter API key — use a free model (no credits needed) or add credits for GPT-4o, Claude, and more. Configure in the ☰ left panel.
@@ -1068,6 +1099,7 @@ export default function Page() { key={`tiling-${activeProjectId}`} blocks={activeProject.blocks} collapsedIds={new Set(activeProject.collapsedIds)} + aiEnabled={aiEnabled} onDelete={deleteBlock} onEdit={editBlock} onEditAnnotation={editAnnotation} @@ -1084,6 +1116,7 @@ export default function Page() {
- setIsIndexOpen(false)} isOpen={isIndexOpen} diff --git a/components/kanban-area.tsx b/components/kanban-area.tsx index 7ff762b..bdd2269 100644 --- a/components/kanban-area.tsx +++ b/components/kanban-area.tsx @@ -10,6 +10,7 @@ import { KanbanMinimap } from "./kanban-minimap" interface KanbanAreaProps { blocks: TextBlock[] + aiEnabled: boolean onDelete: (id: string) => void onEdit: (id: string, newText: string) => void onEditAnnotation: (id: string, newAnnotation: string) => void @@ -24,6 +25,7 @@ interface KanbanAreaProps { export function KanbanArea({ blocks, + aiEnabled, onDelete, onEdit, onEditAnnotation, @@ -162,6 +164,7 @@ export function KanbanArea({ onConnectionLock={handleConnectionLock} isConnectionLocked={lockedConnectionId === block.id} allBlocks={blocks} + aiEnabled={aiEnabled} />
) diff --git a/components/project-sidebar.tsx b/components/project-sidebar.tsx index 46f7ada..350fbeb 100644 --- a/components/project-sidebar.tsx +++ b/components/project-sidebar.tsx @@ -17,6 +17,7 @@ import { Eye, EyeOff, Save, + Sparkles, FolderInput, } from "lucide-react" import { @@ -358,6 +359,29 @@ export function ProjectSidebar({
+ {/* AI Enabled */} +
+
+ +
+
AI Features
+
+ Toggle all AI enrichment and synthesis features. +
+
+
+ +
+ {/* API Key */}
) : (
- {block.isError && ( + {block.isError && aiEnabled && (
{block.statusText === "no-api-key" diff --git a/components/tiling-area.tsx b/components/tiling-area.tsx index 43c41e6..4edf61e 100644 --- a/components/tiling-area.tsx +++ b/components/tiling-area.tsx @@ -41,6 +41,7 @@ interface BSPNode { interface TilingAreaProps { blocks: TextBlock[] collapsedIds: Set + aiEnabled: boolean onDelete: (id: string) => void onEdit: (id: string, newText: string) => void onEditAnnotation: (id: string, newAnnotation: string) => void @@ -57,6 +58,7 @@ interface TilingAreaProps { export function TilingArea({ blocks, collapsedIds, + aiEnabled, onDelete, onEdit, onEditAnnotation, @@ -205,6 +207,7 @@ export function TilingArea({ onConnectionLock={handleConnectionLock} isConnectionLocked={lockedConnectionId === block.id} allBlocks={blocks} + aiEnabled={aiEnabled} />
@@ -252,6 +255,7 @@ export function TilingArea({ onConnectionLock={handleConnectionLock} isConnectionLocked={lockedConnectionId === taskBlock.id} allBlocks={blocks} + aiEnabled={aiEnabled} /> )} diff --git a/lib/ai-settings.ts b/lib/ai-settings.ts index 02dc657..ce32c7f 100644 --- a/lib/ai-settings.ts +++ b/lib/ai-settings.ts @@ -186,6 +186,7 @@ export const DEFAULT_MODEL_ID = "openai/gpt-4o" export const DEFAULT_PROVIDER: AIProvider = "openrouter" export interface AISettings { + aiEnabled: boolean apiKey: string modelId: string webGrounding: boolean @@ -201,6 +202,7 @@ const STORAGE_KEY = "nodepad-ai-settings" function loadSettings(): AISettings { if (typeof window === "undefined") { return { + aiEnabled: true, apiKey: "", modelId: DEFAULT_MODEL_ID, webGrounding: false, @@ -213,6 +215,7 @@ function loadSettings(): AISettings { const raw = localStorage.getItem(STORAGE_KEY) if (!raw) { return { + aiEnabled: true, apiKey: "", modelId: DEFAULT_MODEL_ID, webGrounding: false, @@ -222,6 +225,7 @@ function loadSettings(): AISettings { } } return { + aiEnabled: true, apiKey: "", modelId: DEFAULT_MODEL_ID, webGrounding: false, @@ -232,6 +236,7 @@ function loadSettings(): AISettings { } } catch { return { + aiEnabled: true, apiKey: "", modelId: DEFAULT_MODEL_ID, webGrounding: false, @@ -252,7 +257,7 @@ export interface AIConfig { export function loadAIConfig(): AIConfig | null { const s = loadSettings() - if (!s.apiKey) return null + if (!s.aiEnabled || !s.apiKey) return null const models = getModelsForProvider(s.provider) const model = models.find(m => m.id === s.modelId) const isCustomOpenRouter = s.provider === "openrouter" && s.modelId === CUSTOM_OPENROUTER_MODEL_ID @@ -311,8 +316,13 @@ export function useAISettings() { // caused by settings.apiKey toggling conditional DOM blocks (API key banner, // modelLabel prop, etc.) between the server render and client hydration. const [settings, setSettings] = useState({ - apiKey: "", modelId: DEFAULT_MODEL_ID, webGrounding: false, - provider: DEFAULT_PROVIDER, customBaseUrl: "", openrouterCustomModelId: "", + aiEnabled: true, + apiKey: "", + modelId: DEFAULT_MODEL_ID, + webGrounding: false, + provider: DEFAULT_PROVIDER, + customBaseUrl: "", + openrouterCustomModelId: "", }) const [isHydrated, setIsHydrated] = useState(false)