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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
14 changes: 12 additions & 2 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -592,6 +599,9 @@ ipcMain.on('open-settings', () => {

settingsWin.on('closed', () => {
settingsWin = null
if (win && !win.isDestroyed()) {
win.show()
}
})
})

Expand Down
70 changes: 70 additions & 0 deletions fix_ts.py
Original file line number Diff line number Diff line change
@@ -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]!")
])

18 changes: 18 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
54 changes: 46 additions & 8 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
fetchApiKey()
}, [setApiKey])

const editorRef = useRef<any>(null)

Check warning on line 93 in src/App.tsx

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
const searchInputRef = useRef<HTMLInputElement>(null)

useEffect(() => {}, [notes])
Expand Down Expand Up @@ -130,8 +130,10 @@
})
// 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.',
Expand Down Expand Up @@ -173,7 +175,7 @@
}

const handleEditorChange = useCallback(
(val: string, viewUpdate: any) => {

Check warning on line 178 in src/App.tsx

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
const updatedNotes = [...notes]
if (updatedNotes[currentNoteIndex]) {
updatedNotes[currentNoteIndex].content = val
Expand All @@ -181,7 +183,7 @@
window.electronAPI.saveNote(activeNote.id, val)
}

if (viewUpdate.transactions?.some((tr: any) => tr.docChanged)) {

Check warning on line 186 in src/App.tsx

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
let docStr = viewUpdate.state.doc.toString()
const head = viewUpdate.state.selection.main.head
const line = viewUpdate.state.doc.lineAt(head)
Expand All @@ -189,7 +191,7 @@
let modified = false

// Build variable scope (incorporate global variables)
const scope: any = Object.assign({}, (window as any).__globalVariables || {})

Check warning on line 194 in src/App.tsx

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type

Check warning on line 194 in src/App.tsx

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
const reVar = /^\/var\s+([a-zA-Z0-9_]+)\s*=\s*(.*)$/gm
let match
while ((match = reVar.exec(docStr)) !== null) {
Expand All @@ -214,7 +216,7 @@
docStr = before + newLineText + after
modified = true
}
} catch {}

Check warning on line 219 in src/App.tsx

View workflow job for this annotation

GitHub Actions / ci

Empty block statement
}

// Re-evaluate ALL existing calculations in the document
Expand All @@ -238,7 +240,7 @@
calcModified = true
continue
}
} catch {}

Check warning on line 243 in src/App.tsx

View workflow job for this annotation

GitHub Actions / ci

Empty block statement
}
}

Expand All @@ -258,7 +260,7 @@
[notes, currentNoteIndex, activeNote.id, setNotes]
)

const containerStyle: any = {

Check warning on line 263 in src/App.tsx

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
'--font-family': fontFamily,
'--text-color': textColor,
'--custom-color-num': numColor,
Expand Down Expand Up @@ -366,8 +368,18 @@
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 } })
Expand All @@ -390,22 +402,48 @@
apiKey: apiKey.trim() || 'dummy',
baseURL: finalBaseUrl || undefined,
dangerouslyAllowBrowser: true,
defaultHeaders: {
'HTTP-Referer': 'https://github.com/papercache/papercache',
'X-Title': 'PaperCache',
},
})

const systemContent = aiSystemPrompt.trim()
const messages: any[] = []

Check warning on line 412 in src/App.tsx

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
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) => {

Check warning on line 437 in src/App.tsx

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
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',
Expand Down
23 changes: 19 additions & 4 deletions src/Settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -111,16 +111,14 @@ section h3 {
}

.save-btn {
width: 100%;
flex: 1;
padding: 12px;
background: #fcfcfc;
color: #000;
border: none;
border-radius: 6px;
font-weight: bold;
cursor: pointer;
margin-top: 10px;
margin-bottom: 24px;
}

.save-btn:hover {
Expand All @@ -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;
Expand All @@ -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;
}
59 changes: 42 additions & 17 deletions src/Settings.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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"
/>
</div>

Expand All @@ -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"
/>
</div>

Expand Down Expand Up @@ -291,15 +314,17 @@ export default function Settings() {
<input type="color" value={aiColor} onChange={(e) => setAiColor(e.target.value)} />
</div>
</section>

<button className="save-btn" onClick={saveSettings}>
Save Settings
</button>
</div>

<div className="settings-footer">
<button className="quit-btn" onClick={quitApp}>
Quit PaperCache
Quit
</button>
<button className="close-btn" onClick={closeSettings}>
Close Settings
</button>
<button className="save-btn" onClick={saveSettings}>
Save Settings
</button>
</div>
</div>
Expand Down
Loading
Loading