From 66a403488f3a7bc32a02bc9933c396dc4c4e031d Mon Sep 17 00:00:00 2001 From: haoyuren <13851610112@163.com> Date: Sun, 15 Mar 2026 20:52:13 -0500 Subject: Add API key management UI and wire S2 key to MCP - Settings modal on project list page for OpenAI, Anthropic, OpenRouter, Gemini, Semantic Scholar keys - Keys stored in userData/api-keys.json, masked by default with show/hide toggle - S2 API key passed to MCP server via .lattex-mcp.json to avoid rate limits Co-Authored-By: Claude Opus 4.6 --- src/main/index.ts | 25 +++++++++- src/mcp/lattex.mjs | 14 ++++-- src/preload/index.ts | 4 ++ src/renderer/src/components/ProjectList.tsx | 72 +++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 4 deletions(-) (limited to 'src') 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 | null = null async function writeMcpState(): Promise { 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 = { 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) => { + 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>, + setApiKeys: (keys: Record) => 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('desc') const [showNewProject, setShowNewProject] = useState(false) const [newProjectName, setNewProjectName] = useState('Untitled Project') + const [showApiKeys, setShowApiKeys] = useState(false) + const [apiKeys, setApiKeys] = useState>({}) + const [apiKeysVisible, setApiKeysVisible] = useState>({}) 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 = {} + 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) {

LatteX

+ @@ -312,6 +344,46 @@ export default function ProjectList({ onOpenProject }: Props) {
)} + {showApiKeys && ( +
setShowApiKeys(false)}> +
e.stopPropagation()} style={{ minWidth: 460 }}> +

API Keys

+

+ Keys are stored locally on this device. +

+ {API_KEY_FIELDS.map((field) => ( +
+ +
+ setApiKeys({ ...apiKeys, [field.id]: e.target.value })} + placeholder={field.placeholder} + spellCheck={false} + autoComplete="off" + /> + +
+
+ ))} +
+ + +
+
+
+ )} ) } -- cgit v1.2.3