summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/main/index.ts25
-rw-r--r--src/mcp/lattex.mjs14
-rw-r--r--src/preload/index.ts4
-rw-r--r--src/renderer/src/components/ProjectList.tsx72
4 files changed, 111 insertions, 4 deletions
diff --git a/src/main/index.ts b/src/main/index.ts
index 4239be5..2b7cc08 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -29,13 +29,20 @@ let mcpOnlineUsersWriteTimer: ReturnType<typeof setTimeout> | null = null
async function writeMcpState(): Promise<void> {
if (!mcpStateDir || !mcpProjectId) return
try {
- const state = {
+ // Read S2 API key if available
+ let s2Key: string | undefined
+ try {
+ const keys = JSON.parse(await readFile(apiKeysPath, 'utf-8'))
+ if (keys.semanticScholar) s2Key = keys.semanticScholar
+ } catch { /* ignore */ }
+ const state: Record<string, unknown> = {
projectId: mcpProjectId,
cookie: overleafSessionCookie,
csrf: overleafCsrfToken,
commentContexts: mcpCommentContexts,
pathDocMap: mcpPathDocMap
}
+ if (s2Key) state.semanticScholarApiKey = s2Key
await writeFile(join(mcpStateDir, '.lattex-mcp.json'), JSON.stringify(state, null, 2))
} catch { /* ignore */ }
}
@@ -91,6 +98,22 @@ ipcMain.handle('fs:readBinary', async (_e, filePath: string) => {
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
})
+// ── API Key Storage ─────────────────────────────────────────────
+
+const apiKeysPath = join(app.getPath('userData'), 'api-keys.json')
+
+ipcMain.handle('settings:getApiKeys', async () => {
+ try {
+ return JSON.parse(await readFile(apiKeysPath, 'utf-8'))
+ } catch {
+ return {}
+ }
+})
+
+ipcMain.handle('settings:setApiKeys', async (_e, keys: Record<string, string>) => {
+ await writeFile(apiKeysPath, JSON.stringify(keys, null, 2))
+})
+
// ── LaTeX Compilation ────────────────────────────────────────────
// Ensure TeX binaries are in PATH (Electron launched from Finder may miss them)
diff --git a/src/mcp/lattex.mjs b/src/mcp/lattex.mjs
index 65f6d87..64520f7 100644
--- a/src/mcp/lattex.mjs
+++ b/src/mcp/lattex.mjs
@@ -528,6 +528,13 @@ const TOOLS = [
// ── Semantic Scholar helpers ─────────────────────────────────
+function getSemanticScholarApiKey() {
+ try {
+ const state = readState()
+ return state.semanticScholarApiKey || null
+ } catch { return null }
+}
+
function semanticScholarSearch(query, limit) {
return new Promise((resolve) => {
const params = new URLSearchParams({
@@ -535,13 +542,14 @@ function semanticScholarSearch(query, limit) {
limit: String(limit),
fields: 'title,authors,year,externalIds,venue,publicationDate,abstract,citationCount'
})
+ const headers = { 'User-Agent': 'LatteX-MCP/1.0' }
+ const apiKey = getSemanticScholarApiKey()
+ if (apiKey) headers['x-api-key'] = apiKey
const options = {
hostname: 'api.semanticscholar.org',
path: `/graph/v1/paper/search?${params}`,
method: 'GET',
- headers: {
- 'User-Agent': 'LatteX-MCP/1.0'
- }
+ headers
}
const req = https.request(options, (res) => {
diff --git a/src/preload/index.ts b/src/preload/index.ts
index ea330c5..b1db391 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -229,6 +229,10 @@ const api = {
return () => ipcRenderer.removeListener('comments:initContexts', handler)
},
+ // API Keys
+ getApiKeys: () => ipcRenderer.invoke('settings:getApiKeys') as Promise<Record<string, string>>,
+ setApiKeys: (keys: Record<string, string>) => ipcRenderer.invoke('settings:setApiKeys', keys),
+
// Shell
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
showInFinder: (path: string) => ipcRenderer.invoke('shell:showInFinder', path),
diff --git a/src/renderer/src/components/ProjectList.tsx b/src/renderer/src/components/ProjectList.tsx
index 2fe23de..7a9fd7f 100644
--- a/src/renderer/src/components/ProjectList.tsx
+++ b/src/renderer/src/components/ProjectList.tsx
@@ -32,6 +32,9 @@ export default function ProjectList({ onOpenProject }: Props) {
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
const [showNewProject, setShowNewProject] = useState(false)
const [newProjectName, setNewProjectName] = useState('Untitled Project')
+ const [showApiKeys, setShowApiKeys] = useState(false)
+ const [apiKeys, setApiKeys] = useState<Record<string, string>>({})
+ const [apiKeysVisible, setApiKeysVisible] = useState<Record<string, boolean>>({})
const { setStatusMessage } = useAppStore()
const loadProjects = useCallback(async () => {
@@ -123,6 +126,32 @@ export default function ProjectList({ onOpenProject }: Props) {
useAppStore.getState().setScreen('login')
}
+ const openApiKeys = async () => {
+ const keys = await window.api.getApiKeys()
+ setApiKeys(keys)
+ setApiKeysVisible({})
+ setShowApiKeys(true)
+ }
+
+ const saveApiKeys = async () => {
+ // Strip empty keys before saving
+ const cleaned: Record<string, string> = {}
+ for (const [k, v] of Object.entries(apiKeys)) {
+ if (v.trim()) cleaned[k] = v.trim()
+ }
+ await window.api.setApiKeys(cleaned)
+ setShowApiKeys(false)
+ setStatusMessage('API keys saved')
+ }
+
+ const API_KEY_FIELDS = [
+ { id: 'openai', label: 'OpenAI', placeholder: 'sk-...' },
+ { id: 'anthropic', label: 'Anthropic (Claude)', placeholder: 'sk-ant-...' },
+ { id: 'openrouter', label: 'OpenRouter', placeholder: 'sk-or-...' },
+ { id: 'gemini', label: 'Google Gemini', placeholder: 'AIza...' },
+ { id: 'semanticScholar', label: 'Semantic Scholar', placeholder: 'API key (optional, avoids rate limits)' }
+ ]
+
const toggleSort = (key: SortKey) => {
if (sortBy === key) {
setSortOrder((o) => (o === 'asc' ? 'desc' : 'asc'))
@@ -206,6 +235,9 @@ export default function ProjectList({ onOpenProject }: Props) {
<div className="projects-header">
<h1>Latte<span className="lattex-x">X</span></h1>
<div className="projects-header-actions">
+ <button className="btn btn-secondary btn-sm" onClick={openApiKeys}>
+ API Keys
+ </button>
<button className="btn btn-secondary btn-sm" onClick={handleLogout}>
Sign out
</button>
@@ -312,6 +344,46 @@ export default function ProjectList({ onOpenProject }: Props) {
</div>
</div>
)}
+ {showApiKeys && (
+ <div className="modal-overlay" onClick={() => setShowApiKeys(false)}>
+ <div className="modal-box" onClick={(e) => e.stopPropagation()} style={{ minWidth: 460 }}>
+ <h3 style={{ margin: '0 0 4px' }}>API Keys</h3>
+ <p style={{ margin: '0 0 16px', fontSize: 12, color: 'var(--text-secondary)' }}>
+ Keys are stored locally on this device.
+ </p>
+ {API_KEY_FIELDS.map((field) => (
+ <div key={field.id} style={{ marginBottom: 12 }}>
+ <label style={{ display: 'block', fontSize: 12, fontWeight: 500, marginBottom: 4, color: 'var(--text-secondary)' }}>
+ {field.label}
+ </label>
+ <div style={{ display: 'flex', gap: 4 }}>
+ <input
+ type={apiKeysVisible[field.id] ? 'text' : 'password'}
+ className="modal-input"
+ value={apiKeys[field.id] || ''}
+ onChange={(e) => setApiKeys({ ...apiKeys, [field.id]: e.target.value })}
+ placeholder={field.placeholder}
+ spellCheck={false}
+ autoComplete="off"
+ />
+ <button
+ className="btn btn-secondary btn-sm"
+ onClick={() => setApiKeysVisible({ ...apiKeysVisible, [field.id]: !apiKeysVisible[field.id] })}
+ style={{ flexShrink: 0, padding: '6px 8px', fontSize: 11 }}
+ title={apiKeysVisible[field.id] ? 'Hide' : 'Show'}
+ >
+ {apiKeysVisible[field.id] ? 'Hide' : 'Show'}
+ </button>
+ </div>
+ </div>
+ ))}
+ <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 16 }}>
+ <button className="btn btn-secondary btn-sm" onClick={() => setShowApiKeys(false)}>Cancel</button>
+ <button className="btn btn-primary btn-sm" onClick={saveApiKeys}>Save</button>
+ </div>
+ </div>
+ </div>
+ )}
</div>
)
}