diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/main/index.ts | 25 | ||||
| -rw-r--r-- | src/mcp/lattex.mjs | 14 | ||||
| -rw-r--r-- | src/preload/index.ts | 4 | ||||
| -rw-r--r-- | src/renderer/src/components/ProjectList.tsx | 72 |
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> ) } |
