From f4be61061655e469501955efc45522209a96d28e Mon Sep 17 00:00:00 2001 From: Aditya Date: Sat, 20 Jun 2026 14:25:16 +0530 Subject: [PATCH] fix: updated openrouter defaults and made settings window track main window bounds --- README.md | 6 +++- electron/main.ts | 14 ++++++-- fix_ts.py | 70 +++++++++++++++++++++++++++++++++++++++ src/App.css | 18 ++++++++++ src/App.tsx | 54 +++++++++++++++++++++++++----- src/Settings.css | 23 ++++++++++--- src/Settings.tsx | 59 +++++++++++++++++++++++---------- src/lib/editor/plugins.ts | 28 +++++++++++++++- src/lib/editor/widgets.ts | 9 +++++ src/store/useAIStore.ts | 5 +-- 10 files changed, 251 insertions(+), 35 deletions(-) create mode 100644 fix_ts.py diff --git a/README.md b/README.md index 73ad1ef..f19ede9 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,11 @@ Built with Electron, React, TypeScript, and Vite. ## AI setup -PaperCache uses your own OpenAI API key — no subscription, no middleman. Configure your key, model, and optionally a custom endpoint (works with local LLMs like Ollama) in Settings (`Cmd+K` → Settings). +PaperCache brings AI right into your note — no subscription, no middleman. You can configure your API key, model, and endpoint in Settings (`Cmd+Shift+S`). + +- **OpenAI:** Works out-of-the-box with your OpenAI API key and models like `gpt-4o`. +- **Free Models (OpenRouter):** Don't have an AI subscription? Get a free key at [OpenRouter](https://openrouter.ai/keys), set your Base URL to `https://openrouter.ai/api/v1`, and try a powerful free model like `nvidia/nemotron-3-super-120b-a12b:free`. +- **Local LLMs:** Works seamlessly with local models like Ollama by pointing the Base URL to your local instance. --- diff --git a/electron/main.ts b/electron/main.ts index 8d855eb..2bddb62 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -571,9 +571,16 @@ ipcMain.on('open-settings', () => { return } + let bounds: any = { width: 900, height: 700 } + if (win && !win.isDestroyed()) { + bounds = win.getBounds() + } + settingsWin = new BrowserWindow({ - width: 900, - height: 700, + width: bounds.width, + height: bounds.height, + x: bounds.x, + y: bounds.y, titleBarStyle: 'hiddenInset', icon: path.join(__dirname, '../public/icon.png'), webPreferences: { @@ -592,6 +599,9 @@ ipcMain.on('open-settings', () => { settingsWin.on('closed', () => { settingsWin = null + if (win && !win.isDestroyed()) { + win.show() + } }) }) diff --git a/fix_ts.py b/fix_ts.py new file mode 100644 index 0000000..7a61ec5 --- /dev/null +++ b/fix_ts.py @@ -0,0 +1,70 @@ +import os + +def fix_file(filepath, replacements): + with open(filepath, "r") as f: + code = f.read() + for old, new in replacements: + code = code.replace(old, new) + with open(filepath, "w") as f: + f.write(code) + +fix_file("src/hooks/useNoteStorage.ts", [ + ("import { Note } from '../store/useAppStore'", "import type { Note } from '../store/useAppStore'"), + ("note.content", "note?.content || ''") +]) + +fix_file("src/hooks/useReminders.ts", [ + ("body: label,", "body: label || '',") +]) + +fix_file("src/hooks/useVariables.ts", [ + ("const name = varMatch[1]", "const name = varMatch[1]!"), + ("globals[name] = mathjs.evaluate(varMatch[2]", "globals[name] = mathjs.evaluate(varMatch[2]!"), + ("globals[name] = varMatch[2].trim()", "globals[name] = varMatch[2]!.trim()") +]) + +fix_file("src/lib/editor/plugins.ts", [ + ("const name = match[1]", "const name = match[1]!"), + ("mathjs.evaluate(match[2]", "mathjs.evaluate(match[2]!"), + ("scope[name] = match[2].trim()", "scope[name] = match[2]!.trim()"), + ("const targetStr = match[4]", "const targetStr = match[4] || ''"), + ("const targetMs = new Date(targetStr).getTime()", "const targetMs = targetStr ? new Date(targetStr).getTime() : 0"), + ("const label = match[3]", "const label = match[3] || ''"), + ("const exprPart = calcMatch[1]", "const exprPart = calcMatch[1]!"), + ("const oldResult = calcMatch[2]", "const oldResult = calcMatch[2]!"), + ("console.log(e)", ""), + ("const url = match[1]", "const url = match[1]!"), + ("const path = match[1]", "const path = match[1]!"), + ("const isDone = match[1] ===", "const isDone = match[1]! ==="), + ("text.slice(match.index + 2, match.index + match[0].length - 2)", "text.slice(match.index + 2, match.index + match[0]!.length - 2)") +]) + +fix_file("src/utils.ts", [ + ("const lastPart = parts.pop()", "const lastPart = parts.pop() || ''"), + ("parts.length > 0", "parts && parts.length > 0") +]) + +fix_file("src/utils.test.ts", [ + ("const lastPart = parts.pop()", "const lastPart = parts.pop() || ''") +]) + +fix_file("src/App.tsx", [ + ("const name = match[1]", "const name = match[1]!"), + ("mathjs.evaluate(match[2]", "mathjs.evaluate(match[2]!"), + ("scope[name] = match[2].trim()", "scope[name] = match[2]!.trim()"), + ("const exprPart = calcMatch[1]", "const exprPart = calcMatch[1]!"), + ("const oldResult = calcMatch[2]", "const oldResult = calcMatch[2]!"), + ("const filename = note.id.replace", "const filename = note?.id.replace"), + ("note.content.split", "note?.content.split"), + ("selNote.id", "selNote?.id"), + ("selNote.content", "selNote?.content") +]) + +fix_file("src/components/RemindersPage.tsx", [ + ("const label = match[3]", "const label = match[3] || ''") +]) + +fix_file("src/GraphView.tsx", [ + ("targetId: match[1]", "targetId: match[1]!") +]) + diff --git a/src/App.css b/src/App.css index 40df502..554cde1 100644 --- a/src/App.css +++ b/src/App.css @@ -704,3 +704,21 @@ body { .cm-overdue-line-text { color: #ff3b30 !important; } + +.cm-ctx-pill { + display: inline-block; + padding: 0 6px; + border-radius: 4px; + background-color: rgba(139, 92, 246, 0.15); + color: #8b5cf6; + font-size: 0.9em; + font-weight: bold; + border: 1px solid rgba(139, 92, 246, 0.3); + margin-right: 6px; + vertical-align: middle; +} + +.cm-ctx-highlight { + color: #8b5cf6; + background-color: rgba(139, 92, 246, 0.1); +} diff --git a/src/App.tsx b/src/App.tsx index 46355db..06cbc51 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -130,8 +130,10 @@ function App() { }) // Refresh AI Store (API Key handled securely, we don't listen to localStorage for it directly) useAIStore.setState({ - apiBaseUrl: localStorage.getItem('papercache-api-base-url') || 'https://api.openai.com/v1', - apiModel: localStorage.getItem('papercache-api-model') || 'gpt-4o', + apiBaseUrl: + localStorage.getItem('papercache-api-base-url') || 'https://openrouter.ai/api/v1', + apiModel: + localStorage.getItem('papercache-api-model') || 'nvidia/nemotron-3-super-120b-a12b:free', aiSystemPrompt: localStorage.getItem('papercache-ai-system-prompt') || 'You are a helpful assistant directly inside a markdown note. You can format your responses with markdown.', @@ -366,8 +368,18 @@ function App() { const line = view.state.doc.lineAt(pos) const lineText = line.text.trim() const lowerLine = lineText.toLowerCase() - if (lowerLine.startsWith('/ai')) { - const prompt = lineText.substring(3).trim() + if ( + lowerLine.startsWith('/ai') || + lowerLine.startsWith('/ctx') || + lowerLine.startsWith('/context') + ) { + const isCtx = lowerLine.startsWith('/ctx') || lowerLine.startsWith('/context') + const prefixLength = lowerLine.startsWith('/context') + ? 8 + : lowerLine.startsWith('/ctx') + ? 4 + : 3 + const prompt = lineText.substring(prefixLength).trim() if (!apiKey) { const errorText = '\n\u200BError - Set your OpenAI API key in settings\u200C\n' view.dispatch({ changes: { from: line.to, insert: errorText } }) @@ -390,6 +402,10 @@ function App() { apiKey: apiKey.trim() || 'dummy', baseURL: finalBaseUrl || undefined, dangerouslyAllowBrowser: true, + defaultHeaders: { + 'HTTP-Referer': 'https://github.com/papercache/papercache', + 'X-Title': 'PaperCache', + }, }) const systemContent = aiSystemPrompt.trim() @@ -397,15 +413,37 @@ function App() { if (systemContent) { messages.push({ role: 'system', content: systemContent }) } - messages.push({ role: 'user', content: prompt }) + + let finalPrompt = prompt + if (isCtx) { + const fullNoteText = view.state.doc.toString() + const MAX_CONTEXT_LENGTH = 50000 + let contextText = fullNoteText + if (contextText.length > MAX_CONTEXT_LENGTH) { + contextText = + contextText.substring(0, MAX_CONTEXT_LENGTH) + + '\n...[Context truncated due to length]' + } + finalPrompt = `Context:\n${contextText}\n\nPrompt:\n${prompt}` + } + + messages.push({ role: 'user', content: finalPrompt }) openai.chat.completions .create({ - model: apiModel.trim() || 'gpt-4o', + model: apiModel.trim() || 'nvidia/nemotron-3-super-120b-a12b:free', messages: messages, }) - .then((completion) => { - const response = completion.choices[0].message.content + .then((completion: any) => { + let response: string + if (completion.choices && completion.choices.length > 0) { + response = completion.choices[0].message?.content || '' + } else if (completion.error) { + throw new Error(completion.error.message || 'Unknown API Error') + } else { + throw new Error('Unexpected response format: ' + JSON.stringify(completion)) + } + const docStr = view.state.doc.toString() const finalVal = docStr.replace( '\n\u200B...\u200C\n', diff --git a/src/Settings.css b/src/Settings.css index e867539..f6f90e1 100644 --- a/src/Settings.css +++ b/src/Settings.css @@ -111,7 +111,7 @@ section h3 { } .save-btn { - width: 100%; + flex: 1; padding: 12px; background: #fcfcfc; color: #000; @@ -119,8 +119,6 @@ section h3 { border-radius: 6px; font-weight: bold; cursor: pointer; - margin-top: 10px; - margin-bottom: 24px; } .save-btn:hover { @@ -130,10 +128,12 @@ section h3 { .settings-footer { margin-top: auto; -webkit-app-region: no-drag; + display: flex; + gap: 10px; } .quit-btn { - width: 100%; + flex: 1; padding: 12px; background: #2a2a2a; color: #ff4444; @@ -146,3 +146,18 @@ section h3 { .quit-btn:hover { background: rgba(255, 68, 68, 0.1); } + +.close-btn { + flex: 1; + padding: 12px; + background: #2a2a2a; + color: #fff; + border: 1px solid #666; + border-radius: 6px; + font-weight: bold; + cursor: pointer; +} + +.close-btn:hover { + background: #333; +} diff --git a/src/Settings.tsx b/src/Settings.tsx index 16d94d6..b2a6f01 100644 --- a/src/Settings.tsx +++ b/src/Settings.tsx @@ -1,14 +1,32 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' +import { getSecure, setSecure } from './lib/safeStorage' import './Settings.css' export default function Settings() { - const [apiKey, setApiKey] = useState(localStorage.getItem('papercache-apikey') || '') + const [apiKey, setApiKey] = useState('') + + useEffect(() => { + getSecure('papercache-apikey').then((key) => { + if (key) setApiKey(key) + }) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + window.electronAPI.closeWindow() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, []) const [apiBaseUrl, setApiBaseUrl] = useState( - localStorage.getItem('papercache-baseurl') || 'https://api.openai.com/v1' + localStorage.getItem('papercache-api-base-url') || 'https://openrouter.ai/api/v1' + ) + const [apiModel, setApiModel] = useState( + localStorage.getItem('papercache-api-model') || 'nvidia/nemotron-3-super-120b-a12b:free' ) - const [apiModel, setApiModel] = useState(localStorage.getItem('papercache-model') || 'gpt-4o') const [aiSystemPrompt, setAiSystemPrompt] = useState( - localStorage.getItem('papercache-system-prompt') || 'Please provide a short and concise answer.' + localStorage.getItem('papercache-ai-system-prompt') || + 'Please provide a short and concise answer.' ) // Shortcuts @@ -52,11 +70,12 @@ export default function Settings() { localStorage.getItem('papercache-color-math') || '#f59e0b' ) - const saveSettings = () => { - localStorage.setItem('papercache-apikey', apiKey) - localStorage.setItem('papercache-baseurl', apiBaseUrl) - localStorage.setItem('papercache-model', apiModel) - localStorage.setItem('papercache-system-prompt', aiSystemPrompt) + const saveSettings = async () => { + await setSecure('papercache-apikey', apiKey) + localStorage.removeItem('papercache-apikey') + localStorage.setItem('papercache-api-base-url', apiBaseUrl) + localStorage.setItem('papercache-api-model', apiModel) + localStorage.setItem('papercache-ai-system-prompt', aiSystemPrompt) localStorage.setItem('papercache-font', fontFamily) localStorage.setItem('papercache-show-rulings', showRulings.toString()) @@ -98,6 +117,10 @@ export default function Settings() { window.electronAPI.closeWindow() // actually closes settings window } + const closeSettings = () => { + window.electronAPI.closeWindow() + } + const quitApp = () => { window.electronAPI.quitApp() } @@ -127,7 +150,7 @@ export default function Settings() { type="text" value={apiBaseUrl} onChange={(e) => setApiBaseUrl(e.target.value)} - placeholder="https://api.openai.com/v1" + placeholder="https://openrouter.ai/api/v1" /> @@ -137,7 +160,7 @@ export default function Settings() { type="text" value={apiModel} onChange={(e) => setApiModel(e.target.value)} - placeholder="gpt-4o" + placeholder="nvidia/nemotron-3-super-120b-a12b:free" /> @@ -291,15 +314,17 @@ export default function Settings() { setAiColor(e.target.value)} /> - -
+ +
diff --git a/src/lib/editor/plugins.ts b/src/lib/editor/plugins.ts index 42a42c4..a2ca551 100644 --- a/src/lib/editor/plugins.ts +++ b/src/lib/editor/plugins.ts @@ -2,7 +2,13 @@ import { ViewPlugin, Decoration, EditorView, ViewUpdate, WidgetType } from '@cod import { syntaxTree } from '@codemirror/language' import * as mathjs from 'mathjs' import { numberMatcher, symbolMatcher, aiMatcher, mathMatcher } from './matchers' -import { CopyWidget, CheckboxWidget, VariableWidget, ReminderWidget } from './widgets' +import { + CopyWidget, + CheckboxWidget, + VariableWidget, + ReminderWidget, + ContextWidget, +} from './widgets' export const numberPlugin = ViewPlugin.fromClass( class { @@ -368,6 +374,26 @@ export const hideMarkdownPlugin = ViewPlugin.fromClass( } } + // Context Command (/ctx, /context) + const reCtx = /^\/(ctx|context)\b/gm + while ((match = reCtx.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.replace({ widget: new ContextWidget() }), + }) + } else { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-ctx-highlight' }), + }) + } + } + // Tasks (/task, /task-done) const reRem = /\/(task(?:-done)?)(?:\s+\((\d{4}-\d{2}-\d{2} \d{2}:\d{2})\))?\s+/g while ((match = reRem.exec(text)) !== null) { diff --git a/src/lib/editor/widgets.ts b/src/lib/editor/widgets.ts index d545a89..bb8cb65 100644 --- a/src/lib/editor/widgets.ts +++ b/src/lib/editor/widgets.ts @@ -158,3 +158,12 @@ export class ReminderWidget extends WidgetType { return true } } + +export class ContextWidget extends WidgetType { + toDOM() { + const span = document.createElement('span') + span.textContent = 'Context Attached' + span.className = 'cm-ctx-pill' + return span + } +} diff --git a/src/store/useAIStore.ts b/src/store/useAIStore.ts index f04d966..9109a1a 100644 --- a/src/store/useAIStore.ts +++ b/src/store/useAIStore.ts @@ -14,8 +14,9 @@ export interface AIState { export const useAIStore = create((set) => ({ apiKey: '', - apiBaseUrl: localStorage.getItem('papercache-api-base-url') || 'https://api.openai.com/v1', - apiModel: localStorage.getItem('papercache-api-model') || 'gpt-4o', + apiBaseUrl: localStorage.getItem('papercache-api-base-url') || 'https://openrouter.ai/api/v1', + apiModel: + localStorage.getItem('papercache-api-model') || 'nvidia/nemotron-3-super-120b-a12b:free', aiSystemPrompt: localStorage.getItem('papercache-ai-system-prompt') || 'You are a helpful assistant directly inside a markdown note. You can format your responses with markdown.',