From e75eef1d8e9cda5feb5426d308ed5bfa79ec21e8 Mon Sep 17 00:00:00 2001 From: Octopus Date: Fri, 3 Apr 2026 14:24:58 +0800 Subject: [PATCH 1/3] feat: add MiniMax M2.7 as configurable backend for Ask about Code The "Ask about Code" inline Q&A feature now supports MiniMax as an alternative to the Claude Code CLI. Users can switch providers in Settings and supply a MINIMAX_API_KEY to use MiniMax M2.7 (204K context) without needing claude installed. - electron/ipc/ask-code-minimax.ts: streaming MiniMax backend via OpenAI-compatible API, with abort-signal handling and SSE parsing - electron/ipc/ask-code.ts: route to MiniMax or Claude based on provider arg passed through IPC - electron/ipc/register.ts: forward provider/minimaxApiKey from IPC args - src/store/{types,core,ui,store,persistence}.ts: persist askCodeProvider and minimaxApiKey settings - src/components/SettingsDialog.tsx: provider selector + API key input - src/components/AskCodeCard.tsx: pass provider/apiKey through IPC - README.md: mention MiniMax as supported Ask-about-Code provider - electron/ipc/ask-code-minimax.test.ts: 12 unit tests --- README.md | 1 + electron/ipc/ask-code-minimax.test.ts | 309 ++++++++++++++++++++++++++ electron/ipc/ask-code-minimax.ts | 159 +++++++++++++ electron/ipc/ask-code.ts | 17 +- electron/ipc/register.ts | 8 + src/components/AskCodeCard.tsx | 3 + src/components/SettingsDialog.tsx | 92 ++++++++ src/store/core.ts | 2 + src/store/persistence.ts | 9 + src/store/store.ts | 2 + src/store/types.ts | 4 + src/store/ui.ts | 8 + 12 files changed, 613 insertions(+), 1 deletion(-) create mode 100644 electron/ipc/ask-code-minimax.test.ts create mode 100644 electron/ipc/ask-code-minimax.ts diff --git a/README.md b/README.md index 4bbf5eec..3885506d 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ - **See every session in one place** — switch context without losing momentum. - **Control everything keyboard-first** — every action has a shortcut, mouse optional. - **Monitor progress from your phone** — scan a QR code, watch agents work over Wi-Fi or Tailscale. +- **Ask about code with any LLM** — the inline code Q&A feature supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (default) or [MiniMax](https://www.minimax.io/) M2.7 (204K context) — configurable in Settings.
How does it compare? diff --git a/electron/ipc/ask-code-minimax.test.ts b/electron/ipc/ask-code-minimax.test.ts new file mode 100644 index 00000000..af6988a5 --- /dev/null +++ b/electron/ipc/ask-code-minimax.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock fetch globally +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +import { askAboutCodeMinimax, cancelAskAboutCodeMinimax, MINIMAX_MODEL } from './ask-code-minimax.js'; + +function makeMockWin() { + const messages: unknown[] = []; + const win = { + isDestroyed: vi.fn().mockReturnValue(false), + webContents: { + send: vi.fn().mockImplementation((_ch: string, msg: unknown) => { + messages.push(msg); + }), + }, + } as unknown as import('electron').BrowserWindow; + return { win, messages }; +} + +/** Wait until a 'done' message appears in the messages array. */ +function waitForDone(messages: unknown[], timeoutMs = 3000): Promise { + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeoutMs; + function check() { + if (messages.some((m) => (m as Record).type === 'done')) { + resolve(); + return; + } + if (Date.now() >= deadline) { + reject(new Error('Timed out waiting for done message')); + return; + } + setTimeout(check, 10); + } + check(); + }); +} + +function makeStreamResponse(sseText: string): Response { + const encoder = new TextEncoder(); + const bytes = encoder.encode(sseText); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); + return new Response(stream, { status: 200 }); +} + +function sseChunk(content: string): string { + return `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}\n\n`; +} + +describe('askAboutCodeMinimax', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws if prompt exceeds max length', () => { + const { win } = makeMockWin(); + const longPrompt = 'x'.repeat(50_001); + expect(() => + askAboutCodeMinimax(win, { + requestId: 'r1', + channelId: 'ch1', + prompt: longPrompt, + apiKey: 'test-key', + }), + ).toThrow(/Prompt too long/); + }); + + it('sends chunk messages for each SSE delta', async () => { + const { win, messages } = makeMockWin(); + + const sseText = sseChunk('Hello') + sseChunk(', world') + 'data: [DONE]\n\n'; + mockFetch.mockResolvedValueOnce(makeStreamResponse(sseText)); + + askAboutCodeMinimax(win, { + requestId: 'r2', + channelId: 'ch2', + prompt: 'Explain this code', + apiKey: 'test-key', + }); + + await waitForDone(messages); + + const chunkMsgs = messages.filter((m) => (m as Record).type === 'chunk'); + expect(chunkMsgs).toHaveLength(2); + expect((chunkMsgs[0] as Record).text).toBe('Hello'); + expect((chunkMsgs[1] as Record).text).toBe(', world'); + + const doneMsgs = messages.filter((m) => (m as Record).type === 'done'); + expect(doneMsgs).toHaveLength(1); + expect((doneMsgs[0] as Record).exitCode).toBe(0); + }); + + it('sends error message on non-ok HTTP response', async () => { + const { win, messages } = makeMockWin(); + + mockFetch.mockResolvedValueOnce(new Response('Unauthorized', { status: 401 })); + + askAboutCodeMinimax(win, { + requestId: 'r3', + channelId: 'ch3', + prompt: 'What is this?', + apiKey: 'bad-key', + }); + + await waitForDone(messages); + + const errMsgs = messages.filter((m) => (m as Record).type === 'error'); + expect(errMsgs.length).toBeGreaterThan(0); + expect((errMsgs[0] as Record).text).toMatch(/401/); + + const doneMsgs = messages.filter((m) => (m as Record).type === 'done'); + expect((doneMsgs[0] as Record).exitCode).toBe(1); + }); + + it('sends error message when fetch rejects', async () => { + const { win, messages } = makeMockWin(); + + mockFetch.mockRejectedValueOnce(new Error('Network failure')); + + askAboutCodeMinimax(win, { + requestId: 'r4', + channelId: 'ch4', + prompt: 'Explain', + apiKey: 'test-key', + }); + + await waitForDone(messages); + + const errMsgs = messages.filter((m) => (m as Record).type === 'error'); + expect(errMsgs.length).toBeGreaterThan(0); + expect((errMsgs[0] as Record).text).toMatch(/Network failure/); + }); + + it('sends correct Authorization header with Bearer token', async () => { + const { win, messages } = makeMockWin(); + + mockFetch.mockResolvedValueOnce(makeStreamResponse('data: [DONE]\n\n')); + + askAboutCodeMinimax(win, { + requestId: 'r5', + channelId: 'ch5', + prompt: 'Explain', + apiKey: 'my-secret-key', + }); + + await waitForDone(messages); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('minimax.io'), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer my-secret-key', + }), + }), + ); + }); + + it('uses MiniMax-M2.7 model', async () => { + const { win, messages } = makeMockWin(); + + mockFetch.mockResolvedValueOnce(makeStreamResponse('data: [DONE]\n\n')); + + askAboutCodeMinimax(win, { + requestId: 'r6', + channelId: 'ch6', + prompt: 'Test', + apiKey: 'key', + }); + + await waitForDone(messages); + + const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) as { + model: string; + }; + expect(body.model).toBe(MINIMAX_MODEL); + }); + + it('uses temperature in MiniMax allowed range (0, 1]', async () => { + const { win, messages } = makeMockWin(); + + mockFetch.mockResolvedValueOnce(makeStreamResponse('data: [DONE]\n\n')); + + askAboutCodeMinimax(win, { + requestId: 'r7', + channelId: 'ch7', + prompt: 'Test', + apiKey: 'key', + }); + + await waitForDone(messages); + + const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) as { + temperature: number; + }; + expect(body.temperature).toBeGreaterThan(0); + expect(body.temperature).toBeLessThanOrEqual(1); + }); + + it('uses streaming mode', async () => { + const { win, messages } = makeMockWin(); + + mockFetch.mockResolvedValueOnce(makeStreamResponse('data: [DONE]\n\n')); + + askAboutCodeMinimax(win, { + requestId: 'r8', + channelId: 'ch8', + prompt: 'Test', + apiKey: 'key', + }); + + await waitForDone(messages); + + const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) as { + stream: boolean; + }; + expect(body.stream).toBe(true); + }); + + it('does not send to destroyed window', async () => { + const { win, messages } = makeMockWin(); + (win.isDestroyed as ReturnType).mockReturnValue(true); + + mockFetch.mockResolvedValueOnce(makeStreamResponse(sseChunk('Hello') + 'data: [DONE]\n\n')); + + askAboutCodeMinimax(win, { + requestId: 'r9', + channelId: 'ch9', + prompt: 'Test', + apiKey: 'key', + }); + + // Small delay to let the async chain run + await new Promise((r) => setTimeout(r, 100)); + expect(messages).toHaveLength(0); + }); + + it('includes a system prompt instructing concise markdown answers', async () => { + const { win, messages } = makeMockWin(); + + mockFetch.mockResolvedValueOnce(makeStreamResponse('data: [DONE]\n\n')); + + askAboutCodeMinimax(win, { + requestId: 'r10', + channelId: 'ch10', + prompt: 'Explain this', + apiKey: 'key', + }); + + await waitForDone(messages); + + const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) as { + messages: Array<{ role: string; content: string }>; + }; + const systemMsg = body.messages.find((m) => m.role === 'system'); + expect(systemMsg).toBeDefined(); + expect(systemMsg?.content).toMatch(/markdown/i); + }); +}); + +describe('cancelAskAboutCodeMinimax', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('cancels a pending request without sending an error message', async () => { + const { win, messages } = makeMockWin(); + + // Simulate a slow response that never closes + let rejectReader!: (err: unknown) => void; + const neverEnding = new ReadableStream({ + start(controller) { + // Enqueue one empty byte so the response is ok + controller.enqueue(new Uint8Array(0)); + // never close — reader.read() will block + }, + }); + mockFetch.mockResolvedValueOnce(new Response(neverEnding, { status: 200 })); + + askAboutCodeMinimax(win, { + requestId: 'cancel-1', + channelId: 'ch-cancel', + prompt: 'Test', + apiKey: 'key', + }); + + // Give fetch time to start + await new Promise((r) => setTimeout(r, 20)); + + cancelAskAboutCodeMinimax('cancel-1'); + + // Wait for done message (AbortError -> done sent without error) + await waitForDone(messages); + + const errMsgs = messages.filter((m) => (m as Record).type === 'error'); + // AbortError should NOT produce an error message + expect(errMsgs).toHaveLength(0); + }); + + it('is a no-op for unknown requestId', () => { + expect(() => cancelAskAboutCodeMinimax('unknown-id')).not.toThrow(); + }); +}); diff --git a/electron/ipc/ask-code-minimax.ts b/electron/ipc/ask-code-minimax.ts new file mode 100644 index 00000000..1bca5e22 --- /dev/null +++ b/electron/ipc/ask-code-minimax.ts @@ -0,0 +1,159 @@ +import type { BrowserWindow } from 'electron'; + +interface MinimaxAskCodeRequest { + requestId: string; + channelId: string; + prompt: string; + apiKey: string; +} + +const MAX_PROMPT_LENGTH = 50_000; +const MAX_CONCURRENT = 5; +const TIMEOUT_MS = 120_000; +const MINIMAX_API_URL = 'https://api.minimax.io/v1/chat/completions'; +export const MINIMAX_MODEL = 'MiniMax-M2.7'; + +const activeRequests = new Map(); +const activeTimers = new Map>(); + +export function askAboutCodeMinimax(win: BrowserWindow, args: MinimaxAskCodeRequest): void { + const { requestId, channelId, prompt, apiKey } = args; + + if (prompt.length > MAX_PROMPT_LENGTH) { + throw new Error(`Prompt too long (${prompt.length} chars, max ${MAX_PROMPT_LENGTH})`); + } + if (activeRequests.size >= MAX_CONCURRENT) { + throw new Error('Too many concurrent ask-about-code requests'); + } + + cancelAskAboutCodeMinimax(requestId); + + const controller = new AbortController(); + activeRequests.set(requestId, controller); + + const send = (msg: unknown) => { + if (!win.isDestroyed()) { + win.webContents.send(`channel:${channelId}`, msg); + } + }; + + let finished = false; + + function cleanup() { + activeRequests.delete(requestId); + const timer = activeTimers.get(requestId); + if (timer) { + clearTimeout(timer); + activeTimers.delete(requestId); + } + } + + // Safety timeout: kill after 2 minutes + const timer = setTimeout(() => { + activeTimers.delete(requestId); + if (activeRequests.has(requestId)) { + finished = true; + send({ type: 'error', text: 'Request timed out after 2 minutes.' }); + cancelAskAboutCodeMinimax(requestId); + send({ type: 'done', exitCode: 1 }); + } + }, TIMEOUT_MS); + activeTimers.set(requestId, timer); + + fetch(MINIMAX_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: MINIMAX_MODEL, + messages: [ + { + role: 'system', + content: 'Answer concisely about the selected code. Use markdown.', + }, + { role: 'user', content: prompt }, + ], + // MiniMax temperature must be in (0.0, 1.0] + temperature: 0.3, + stream: true, + }), + signal: controller.signal, + }) + .then(async (res) => { + if (!res.ok || !res.body) { + const text = await res.text().catch(() => `HTTP ${res.status}`); + throw new Error(`MiniMax API error (${res.status}): ${text}`); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + + // When the AbortController fires, cancel the reader so reader.read() resolves + let aborted = false; + const onAbort = () => { + aborted = true; + reader.cancel().catch(() => {}); + }; + controller.signal.addEventListener('abort', onAbort, { once: true }); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done || aborted) break; + buf += decoder.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop() ?? ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed === 'data: [DONE]') continue; + if (!trimmed.startsWith('data:')) continue; + try { + const json = JSON.parse(trimmed.slice(5).trim()) as { + choices?: Array<{ delta?: { content?: string } }>; + }; + const delta = json.choices?.[0]?.delta?.content; + if (delta) send({ type: 'chunk', text: delta }); + } catch { + // ignore parse errors in SSE stream + } + } + } + } finally { + controller.signal.removeEventListener('abort', onAbort); + } + + cleanup(); + if (!finished) { + finished = true; + send({ type: 'done', exitCode: aborted ? 1 : 0 }); + } + }) + .catch((err: unknown) => { + cleanup(); + if (!finished) { + finished = true; + if (err instanceof Error && err.name === 'AbortError') { + // request was cancelled — send done without error + } else { + send({ type: 'error', text: err instanceof Error ? err.message : String(err) }); + } + send({ type: 'done', exitCode: 1 }); + } + }); +} + +export function cancelAskAboutCodeMinimax(requestId: string): void { + const controller = activeRequests.get(requestId); + if (controller) { + controller.abort(); + activeRequests.delete(requestId); + } + const timer = activeTimers.get(requestId); + if (timer) { + clearTimeout(timer); + activeTimers.delete(requestId); + } +} diff --git a/electron/ipc/ask-code.ts b/electron/ipc/ask-code.ts index 447e659b..163389fd 100644 --- a/electron/ipc/ask-code.ts +++ b/electron/ipc/ask-code.ts @@ -1,12 +1,17 @@ import { spawn, type ChildProcess } from 'child_process'; import type { BrowserWindow } from 'electron'; import { validateCommand } from './pty.js'; +import { askAboutCodeMinimax, cancelAskAboutCodeMinimax } from './ask-code-minimax.js'; + +export type AskCodeProvider = 'claude' | 'minimax'; interface AskCodeRequest { requestId: string; channelId: string; prompt: string; cwd: string; + provider?: AskCodeProvider; + minimaxApiKey?: string; } const MAX_PROMPT_LENGTH = 50_000; @@ -17,7 +22,16 @@ const activeRequests = new Map(); const activeTimers = new Map>(); export function askAboutCode(win: BrowserWindow, args: AskCodeRequest): void { - const { requestId, channelId, prompt, cwd } = args; + const { requestId, channelId, prompt, cwd, provider, minimaxApiKey } = args; + + // Route to MiniMax backend when configured + if (provider === 'minimax') { + if (!minimaxApiKey) { + throw new Error('MiniMax API key is required when using the MiniMax provider'); + } + askAboutCodeMinimax(win, { requestId, channelId, prompt, apiKey: minimaxApiKey }); + return; + } if (prompt.length > MAX_PROMPT_LENGTH) { throw new Error(`Prompt too long (${prompt.length} chars, max ${MAX_PROMPT_LENGTH})`); @@ -123,6 +137,7 @@ export function askAboutCode(win: BrowserWindow, args: AskCodeRequest): void { } export function cancelAskAboutCode(requestId: string): void { + cancelAskAboutCodeMinimax(requestId); const proc = activeRequests.get(requestId); if (proc) { proc.kill('SIGTERM'); diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index c4d57f1f..e8612652 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -527,11 +527,19 @@ export function registerAllHandlers(win: BrowserWindow): void { assertString(args.prompt, 'prompt'); assertString(args.onOutput?.__CHANNEL_ID__, 'channelId'); validatePath(args.cwd, 'cwd'); + const provider: string | undefined = + typeof args.provider === 'string' ? args.provider : undefined; + const minimaxApiKey: string | undefined = + typeof args.minimaxApiKey === 'string' && args.minimaxApiKey.trim() + ? args.minimaxApiKey.trim() + : undefined; askAboutCode(win, { requestId: args.requestId, channelId: args.onOutput.__CHANNEL_ID__, prompt: args.prompt, cwd: args.cwd, + provider: provider === 'minimax' ? 'minimax' : 'claude', + minimaxApiKey, }); }); diff --git a/src/components/AskCodeCard.tsx b/src/components/AskCodeCard.tsx index c084bea9..9d54b641 100644 --- a/src/components/AskCodeCard.tsx +++ b/src/components/AskCodeCard.tsx @@ -3,6 +3,7 @@ import { theme } from '../lib/theme'; import { sf } from '../lib/fontScale'; import { Channel, invoke } from '../lib/ipc'; import { IPC } from '../../electron/ipc/channels'; +import { store } from '../store/store'; interface AskCodeCardProps { requestId: string; @@ -61,6 +62,8 @@ export function AskCodeCard(props: AskCodeCardProps) { prompt, cwd: props.worktreePath, onOutput: channel, + provider: store.askCodeProvider, + minimaxApiKey: store.minimaxApiKey, }).catch((err: unknown) => { setError(err instanceof Error ? err.message : String(err)); setLoading(false); diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index bf43a71b..6e463034 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -20,6 +20,8 @@ import { setInactiveColumnOpacity, setEditorCommand, setDockerImage, + setAskCodeProvider, + setMinimaxApiKey, } from '../store/store'; import { CustomAgentEditor } from './CustomAgentEditor'; import { mod } from '../lib/platform'; @@ -327,6 +329,96 @@ export function SettingsDialog(props: SettingsDialogProps) { +
+
+ Ask about Code +
+
+ + + + + + {store.askCodeProvider === 'minimax' + ? 'Uses MiniMax M2.7 (204K context) via the OpenAI-compatible API — no Claude Code CLI required.' + : 'Uses the claude CLI to answer questions about selected code. Requires Claude Code to be installed.'} + +
+
+
({ editorCommand: '', dockerImage: 'parallel-code-agent:latest', dockerAvailable: false, + askCodeProvider: 'claude', + minimaxApiKey: '', newTaskDropUrl: null, newTaskPrefillPrompt: null, missingProjectIds: {}, diff --git a/src/store/persistence.ts b/src/store/persistence.ts index b939d956..53d6441e 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -58,6 +58,8 @@ export async function saveState(): Promise { inactiveColumnOpacity: store.inactiveColumnOpacity, editorCommand: store.editorCommand || undefined, dockerImage: store.dockerImage !== 'parallel-code-agent:latest' ? store.dockerImage : undefined, + askCodeProvider: store.askCodeProvider !== 'claude' ? store.askCodeProvider : undefined, + minimaxApiKey: store.minimaxApiKey || undefined, customAgents: store.customAgents.length > 0 ? [...store.customAgents] : undefined, }; @@ -203,6 +205,8 @@ interface LegacyPersistedState { inactiveColumnOpacity?: unknown; editorCommand?: unknown; dockerImage?: unknown; + askCodeProvider?: unknown; + minimaxApiKey?: unknown; customAgents?: unknown; terminals?: unknown; } @@ -333,6 +337,11 @@ export async function loadState(): Promise { ? rawDockerImage.trim() : 'parallel-code-agent:latest'; + s.askCodeProvider = + raw.askCodeProvider === 'minimax' ? 'minimax' : 'claude'; + s.minimaxApiKey = + typeof raw.minimaxApiKey === 'string' ? raw.minimaxApiKey.trim() : ''; + // Restore custom agents if (Array.isArray(raw.customAgents)) { s.customAgents = raw.customAgents.filter( diff --git a/src/store/store.ts b/src/store/store.ts index 2953c336..20bcc2a1 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -105,6 +105,8 @@ export { setEditorCommand, setDockerImage, setDockerAvailable, + setAskCodeProvider, + setMinimaxApiKey, setWindowState, } from './ui'; export { diff --git a/src/store/types.ts b/src/store/types.ts index 549d8687..db952370 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -137,6 +137,8 @@ export interface PersistedState { inactiveColumnOpacity?: number; editorCommand?: string; dockerImage?: string; + askCodeProvider?: string; + minimaxApiKey?: string; customAgents?: AgentDef[]; } @@ -205,6 +207,8 @@ export interface AppStore { editorCommand: string; dockerImage: string; dockerAvailable: boolean; + askCodeProvider: 'claude' | 'minimax'; + minimaxApiKey: string; newTaskDropUrl: string | null; newTaskPrefillPrompt: { prompt: string; projectId: string | null } | null; missingProjectIds: Record; diff --git a/src/store/ui.ts b/src/store/ui.ts index e63caa7b..17b34023 100644 --- a/src/store/ui.ts +++ b/src/store/ui.ts @@ -89,6 +89,14 @@ export function setDockerImage(image: string): void { setStore('dockerImage', image || 'parallel-code-agent:latest'); } +export function setAskCodeProvider(provider: 'claude' | 'minimax'): void { + setStore('askCodeProvider', provider); +} + +export function setMinimaxApiKey(key: string): void { + setStore('minimaxApiKey', key.trim()); +} + export function setDockerAvailable(available: boolean): void { setStore('dockerAvailable', available); } From b9c345a13ff8d6eec146837ebaefabd68728a87b Mon Sep 17 00:00:00 2001 From: octo-patch Date: Tue, 14 Apr 2026 13:36:28 +0800 Subject: [PATCH 2/3] fix: address PR review feedback for MiniMax provider - Move API key to main process only: add SetMinimaxApiKey IPC channel, store key in main-process memory, remove from renderer store and per-request IPC payload, stop persisting to state.json - Add max_tokens: 2048 cap to MiniMax request body - Fix cancel exit code: emit exitCode 0 with cancelled flag instead of exitCode 1 for user-initiated cancellation - Guard cancelAskAboutCode by provider: track active provider per request, only call cancelAskAboutCodeMinimax when provider is minimax - Narrow PersistedState.askCodeProvider type to 'claude' | 'minimax' - Remove unused rejectReader variable in ask-code-minimax.test.ts Co-Authored-By: Octopus --- electron/ipc/ask-code-minimax.test.ts | 22 +++++++++------------- electron/ipc/ask-code-minimax.ts | 27 ++++++++++++++++++++++----- electron/ipc/ask-code.ts | 21 ++++++++++++++------- electron/ipc/channels.ts | 1 + electron/ipc/register.ts | 11 ++++++----- electron/preload.cjs | 1 + src/components/AskCodeCard.tsx | 1 - src/components/SettingsDialog.tsx | 7 ++----- src/store/core.ts | 1 - src/store/persistence.ts | 6 +----- src/store/types.ts | 4 +--- src/store/ui.ts | 6 +++++- 12 files changed, 62 insertions(+), 46 deletions(-) diff --git a/electron/ipc/ask-code-minimax.test.ts b/electron/ipc/ask-code-minimax.test.ts index af6988a5..8a73d51a 100644 --- a/electron/ipc/ask-code-minimax.test.ts +++ b/electron/ipc/ask-code-minimax.test.ts @@ -4,7 +4,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const mockFetch = vi.fn(); vi.stubGlobal('fetch', mockFetch); -import { askAboutCodeMinimax, cancelAskAboutCodeMinimax, MINIMAX_MODEL } from './ask-code-minimax.js'; +import { + askAboutCodeMinimax, + cancelAskAboutCodeMinimax, + MINIMAX_MODEL, + setMinimaxApiKey, +} from './ask-code-minimax.js'; function makeMockWin() { const messages: unknown[] = []; @@ -57,6 +62,7 @@ function sseChunk(content: string): string { describe('askAboutCodeMinimax', () => { beforeEach(() => { vi.clearAllMocks(); + setMinimaxApiKey('test-key'); }); it('throws if prompt exceeds max length', () => { @@ -67,7 +73,6 @@ describe('askAboutCodeMinimax', () => { requestId: 'r1', channelId: 'ch1', prompt: longPrompt, - apiKey: 'test-key', }), ).toThrow(/Prompt too long/); }); @@ -82,7 +87,6 @@ describe('askAboutCodeMinimax', () => { requestId: 'r2', channelId: 'ch2', prompt: 'Explain this code', - apiKey: 'test-key', }); await waitForDone(messages); @@ -106,7 +110,6 @@ describe('askAboutCodeMinimax', () => { requestId: 'r3', channelId: 'ch3', prompt: 'What is this?', - apiKey: 'bad-key', }); await waitForDone(messages); @@ -128,7 +131,6 @@ describe('askAboutCodeMinimax', () => { requestId: 'r4', channelId: 'ch4', prompt: 'Explain', - apiKey: 'test-key', }); await waitForDone(messages); @@ -143,11 +145,11 @@ describe('askAboutCodeMinimax', () => { mockFetch.mockResolvedValueOnce(makeStreamResponse('data: [DONE]\n\n')); + setMinimaxApiKey('my-secret-key'); askAboutCodeMinimax(win, { requestId: 'r5', channelId: 'ch5', prompt: 'Explain', - apiKey: 'my-secret-key', }); await waitForDone(messages); @@ -171,7 +173,6 @@ describe('askAboutCodeMinimax', () => { requestId: 'r6', channelId: 'ch6', prompt: 'Test', - apiKey: 'key', }); await waitForDone(messages); @@ -191,7 +192,6 @@ describe('askAboutCodeMinimax', () => { requestId: 'r7', channelId: 'ch7', prompt: 'Test', - apiKey: 'key', }); await waitForDone(messages); @@ -212,7 +212,6 @@ describe('askAboutCodeMinimax', () => { requestId: 'r8', channelId: 'ch8', prompt: 'Test', - apiKey: 'key', }); await waitForDone(messages); @@ -233,7 +232,6 @@ describe('askAboutCodeMinimax', () => { requestId: 'r9', channelId: 'ch9', prompt: 'Test', - apiKey: 'key', }); // Small delay to let the async chain run @@ -250,7 +248,6 @@ describe('askAboutCodeMinimax', () => { requestId: 'r10', channelId: 'ch10', prompt: 'Explain this', - apiKey: 'key', }); await waitForDone(messages); @@ -267,13 +264,13 @@ describe('askAboutCodeMinimax', () => { describe('cancelAskAboutCodeMinimax', () => { beforeEach(() => { vi.clearAllMocks(); + setMinimaxApiKey('test-key'); }); it('cancels a pending request without sending an error message', async () => { const { win, messages } = makeMockWin(); // Simulate a slow response that never closes - let rejectReader!: (err: unknown) => void; const neverEnding = new ReadableStream({ start(controller) { // Enqueue one empty byte so the response is ok @@ -287,7 +284,6 @@ describe('cancelAskAboutCodeMinimax', () => { requestId: 'cancel-1', channelId: 'ch-cancel', prompt: 'Test', - apiKey: 'key', }); // Give fetch time to start diff --git a/electron/ipc/ask-code-minimax.ts b/electron/ipc/ask-code-minimax.ts index 1bca5e22..0a72196b 100644 --- a/electron/ipc/ask-code-minimax.ts +++ b/electron/ipc/ask-code-minimax.ts @@ -4,7 +4,6 @@ interface MinimaxAskCodeRequest { requestId: string; channelId: string; prompt: string; - apiKey: string; } const MAX_PROMPT_LENGTH = 50_000; @@ -16,8 +15,24 @@ export const MINIMAX_MODEL = 'MiniMax-M2.7'; const activeRequests = new Map(); const activeTimers = new Map>(); +/** Main-process storage for the MiniMax API key. Never sent back to the renderer. */ +let storedApiKey = ''; + +export function setMinimaxApiKey(key: string): void { + storedApiKey = key.trim(); +} + +export function getMinimaxApiKey(): string { + return storedApiKey; +} + export function askAboutCodeMinimax(win: BrowserWindow, args: MinimaxAskCodeRequest): void { - const { requestId, channelId, prompt, apiKey } = args; + const { requestId, channelId, prompt } = args; + const apiKey = storedApiKey; + + if (!apiKey) { + throw new Error('MiniMax API key is not set. Please configure it in Settings.'); + } if (prompt.length > MAX_PROMPT_LENGTH) { throw new Error(`Prompt too long (${prompt.length} chars, max ${MAX_PROMPT_LENGTH})`); @@ -77,6 +92,7 @@ export function askAboutCodeMinimax(win: BrowserWindow, args: MinimaxAskCodeRequ ], // MiniMax temperature must be in (0.0, 1.0] temperature: 0.3, + max_tokens: 2048, stream: true, }), signal: controller.signal, @@ -128,7 +144,7 @@ export function askAboutCodeMinimax(win: BrowserWindow, args: MinimaxAskCodeRequ cleanup(); if (!finished) { finished = true; - send({ type: 'done', exitCode: aborted ? 1 : 0 }); + send({ type: 'done', exitCode: 0, cancelled: aborted }); } }) .catch((err: unknown) => { @@ -136,11 +152,12 @@ export function askAboutCodeMinimax(win: BrowserWindow, args: MinimaxAskCodeRequ if (!finished) { finished = true; if (err instanceof Error && err.name === 'AbortError') { - // request was cancelled — send done without error + // request was cancelled — send done without error, neutral exit code + send({ type: 'done', exitCode: 0, cancelled: true }); } else { send({ type: 'error', text: err instanceof Error ? err.message : String(err) }); + send({ type: 'done', exitCode: 1 }); } - send({ type: 'done', exitCode: 1 }); } }); } diff --git a/electron/ipc/ask-code.ts b/electron/ipc/ask-code.ts index 163389fd..feb71357 100644 --- a/electron/ipc/ask-code.ts +++ b/electron/ipc/ask-code.ts @@ -11,7 +11,6 @@ interface AskCodeRequest { prompt: string; cwd: string; provider?: AskCodeProvider; - minimaxApiKey?: string; } const MAX_PROMPT_LENGTH = 50_000; @@ -20,16 +19,15 @@ const TIMEOUT_MS = 120_000; const activeRequests = new Map(); const activeTimers = new Map>(); +const activeProviders = new Map(); export function askAboutCode(win: BrowserWindow, args: AskCodeRequest): void { - const { requestId, channelId, prompt, cwd, provider, minimaxApiKey } = args; + const { requestId, channelId, prompt, cwd, provider } = args; // Route to MiniMax backend when configured if (provider === 'minimax') { - if (!minimaxApiKey) { - throw new Error('MiniMax API key is required when using the MiniMax provider'); - } - askAboutCodeMinimax(win, { requestId, channelId, prompt, apiKey: minimaxApiKey }); + activeProviders.set(requestId, 'minimax'); + askAboutCodeMinimax(win, { requestId, channelId, prompt }); return; } @@ -43,6 +41,7 @@ export function askAboutCode(win: BrowserWindow, args: AskCodeRequest): void { // Cancel any existing request with the same ID cancelAskAboutCode(requestId); + activeProviders.set(requestId, 'claude'); validateCommand('claude'); const filteredEnv: Record = {}; @@ -89,6 +88,7 @@ export function askAboutCode(win: BrowserWindow, args: AskCodeRequest): void { function cleanup() { activeRequests.delete(requestId); + activeProviders.delete(requestId); const timer = activeTimers.get(requestId); if (timer) { clearTimeout(timer); @@ -137,7 +137,14 @@ export function askAboutCode(win: BrowserWindow, args: AskCodeRequest): void { } export function cancelAskAboutCode(requestId: string): void { - cancelAskAboutCodeMinimax(requestId); + const provider = activeProviders.get(requestId); + activeProviders.delete(requestId); + + if (provider === 'minimax') { + cancelAskAboutCodeMinimax(requestId); + return; + } + const proc = activeRequests.get(requestId); if (proc) { proc.kill('SIGTERM'); diff --git a/electron/ipc/channels.ts b/electron/ipc/channels.ts index 81988b1a..47214560 100644 --- a/electron/ipc/channels.ts +++ b/electron/ipc/channels.ts @@ -97,6 +97,7 @@ export enum IPC { // Ask about code AskAboutCode = 'ask_about_code', CancelAskAboutCode = 'cancel_ask_about_code', + SetMinimaxApiKey = 'set_minimax_api_key', // Docker CheckDockerAvailable = 'check_docker_available', diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index e8612652..93587ea8 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -59,6 +59,7 @@ import { listAgents } from './agents.js'; import { saveAppState, loadAppState } from './persistence.js'; import { spawn } from 'child_process'; import { askAboutCode, cancelAskAboutCode } from './ask-code.js'; +import { setMinimaxApiKey } from './ask-code-minimax.js'; import { getSystemMonospaceFonts } from './system-fonts.js'; import path from 'path'; import { @@ -522,6 +523,11 @@ export function registerAllHandlers(win: BrowserWindow): void { }); // --- Ask about code --- + ipcMain.handle(IPC.SetMinimaxApiKey, (_e, args) => { + assertString(args.key, 'key'); + setMinimaxApiKey(args.key); + }); + ipcMain.handle(IPC.AskAboutCode, (_e, args) => { assertString(args.requestId, 'requestId'); assertString(args.prompt, 'prompt'); @@ -529,17 +535,12 @@ export function registerAllHandlers(win: BrowserWindow): void { validatePath(args.cwd, 'cwd'); const provider: string | undefined = typeof args.provider === 'string' ? args.provider : undefined; - const minimaxApiKey: string | undefined = - typeof args.minimaxApiKey === 'string' && args.minimaxApiKey.trim() - ? args.minimaxApiKey.trim() - : undefined; askAboutCode(win, { requestId: args.requestId, channelId: args.onOutput.__CHANNEL_ID__, prompt: args.prompt, cwd: args.cwd, provider: provider === 'minimax' ? 'minimax' : 'claude', - minimaxApiKey, }); }); diff --git a/electron/preload.cjs b/electron/preload.cjs index 5d720dfb..2410e406 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -96,6 +96,7 @@ const ALLOWED_CHANNELS = new Set([ // Ask about code 'ask_about_code', 'cancel_ask_about_code', + 'set_minimax_api_key', // System 'get_system_fonts', // File links diff --git a/src/components/AskCodeCard.tsx b/src/components/AskCodeCard.tsx index 9d54b641..cc4f419f 100644 --- a/src/components/AskCodeCard.tsx +++ b/src/components/AskCodeCard.tsx @@ -63,7 +63,6 @@ export function AskCodeCard(props: AskCodeCardProps) { cwd: props.worktreePath, onOutput: channel, provider: store.askCodeProvider, - minimaxApiKey: store.minimaxApiKey, }).catch((err: unknown) => { setError(err instanceof Error ? err.message : String(err)); setLoading(false); diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index 6e463034..8e3d4210 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -361,9 +361,7 @@ export function SettingsDialog(props: SettingsDialogProps) { setMinimaxApiKey(e.currentTarget.value)} - placeholder="Enter your MINIMAX_API_KEY" + placeholder="Enter your MINIMAX_API_KEY (stored in memory only)" style={{ flex: '1', background: theme.taskPanelBg, diff --git a/src/store/core.ts b/src/store/core.ts index bd9ab924..b9c42db3 100644 --- a/src/store/core.ts +++ b/src/store/core.ts @@ -50,7 +50,6 @@ export const [store, setStore] = createStore({ dockerImage: 'parallel-code-agent:latest', dockerAvailable: false, askCodeProvider: 'claude', - minimaxApiKey: '', newTaskDropUrl: null, newTaskPrefillPrompt: null, missingProjectIds: {}, diff --git a/src/store/persistence.ts b/src/store/persistence.ts index 53d6441e..53f0e743 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -59,7 +59,6 @@ export async function saveState(): Promise { editorCommand: store.editorCommand || undefined, dockerImage: store.dockerImage !== 'parallel-code-agent:latest' ? store.dockerImage : undefined, askCodeProvider: store.askCodeProvider !== 'claude' ? store.askCodeProvider : undefined, - minimaxApiKey: store.minimaxApiKey || undefined, customAgents: store.customAgents.length > 0 ? [...store.customAgents] : undefined, }; @@ -337,10 +336,7 @@ export async function loadState(): Promise { ? rawDockerImage.trim() : 'parallel-code-agent:latest'; - s.askCodeProvider = - raw.askCodeProvider === 'minimax' ? 'minimax' : 'claude'; - s.minimaxApiKey = - typeof raw.minimaxApiKey === 'string' ? raw.minimaxApiKey.trim() : ''; + s.askCodeProvider = raw.askCodeProvider === 'minimax' ? 'minimax' : 'claude'; // Restore custom agents if (Array.isArray(raw.customAgents)) { diff --git a/src/store/types.ts b/src/store/types.ts index db952370..8ebe4498 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -137,8 +137,7 @@ export interface PersistedState { inactiveColumnOpacity?: number; editorCommand?: string; dockerImage?: string; - askCodeProvider?: string; - minimaxApiKey?: string; + askCodeProvider?: 'claude' | 'minimax'; customAgents?: AgentDef[]; } @@ -208,7 +207,6 @@ export interface AppStore { dockerImage: string; dockerAvailable: boolean; askCodeProvider: 'claude' | 'minimax'; - minimaxApiKey: string; newTaskDropUrl: string | null; newTaskPrefillPrompt: { prompt: string; projectId: string | null } | null; missingProjectIds: Record; diff --git a/src/store/ui.ts b/src/store/ui.ts index 17b34023..c22cd680 100644 --- a/src/store/ui.ts +++ b/src/store/ui.ts @@ -1,6 +1,8 @@ import { store, setStore } from './core'; import type { LookPreset } from '../lib/look'; import type { PersistedWindowState, TaskViewportVisibility } from './types'; +import { invoke } from '../lib/ipc'; +import { IPC } from '../../electron/ipc/channels'; const MIN_SCALE = 0.5; const MAX_SCALE = 2.0; @@ -94,7 +96,9 @@ export function setAskCodeProvider(provider: 'claude' | 'minimax'): void { } export function setMinimaxApiKey(key: string): void { - setStore('minimaxApiKey', key.trim()); + invoke(IPC.SetMinimaxApiKey, { key: key.trim() }).catch((e) => + console.warn('Failed to set MiniMax API key:', e), + ); } export function setDockerAvailable(available: boolean): void { From aa28151376a12758fe14587846b71f89d065c9d8 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Sun, 19 Apr 2026 20:43:36 +0200 Subject: [PATCH 3/3] fix(ipc): replace activeProviders map with isMinimaxRequestActive probe The activeProviders Map was never cleared on MiniMax's normal completion path, causing a bounded but unbounded-in-time memory leak. Using the MiniMax module's existing AbortController map as the single source of truth for provider dispatch eliminates the duplicated state. --- electron/ipc/ask-code-minimax.ts | 4 ++++ electron/ipc/ask-code.ts | 15 ++++++--------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/electron/ipc/ask-code-minimax.ts b/electron/ipc/ask-code-minimax.ts index 0a72196b..d8fc8311 100644 --- a/electron/ipc/ask-code-minimax.ts +++ b/electron/ipc/ask-code-minimax.ts @@ -174,3 +174,7 @@ export function cancelAskAboutCodeMinimax(requestId: string): void { activeTimers.delete(requestId); } } + +export function isMinimaxRequestActive(requestId: string): boolean { + return activeRequests.has(requestId); +} diff --git a/electron/ipc/ask-code.ts b/electron/ipc/ask-code.ts index feb71357..45fc4cb7 100644 --- a/electron/ipc/ask-code.ts +++ b/electron/ipc/ask-code.ts @@ -1,7 +1,11 @@ import { spawn, type ChildProcess } from 'child_process'; import type { BrowserWindow } from 'electron'; import { validateCommand } from './pty.js'; -import { askAboutCodeMinimax, cancelAskAboutCodeMinimax } from './ask-code-minimax.js'; +import { + askAboutCodeMinimax, + cancelAskAboutCodeMinimax, + isMinimaxRequestActive, +} from './ask-code-minimax.js'; export type AskCodeProvider = 'claude' | 'minimax'; @@ -19,14 +23,12 @@ const TIMEOUT_MS = 120_000; const activeRequests = new Map(); const activeTimers = new Map>(); -const activeProviders = new Map(); export function askAboutCode(win: BrowserWindow, args: AskCodeRequest): void { const { requestId, channelId, prompt, cwd, provider } = args; // Route to MiniMax backend when configured if (provider === 'minimax') { - activeProviders.set(requestId, 'minimax'); askAboutCodeMinimax(win, { requestId, channelId, prompt }); return; } @@ -41,7 +43,6 @@ export function askAboutCode(win: BrowserWindow, args: AskCodeRequest): void { // Cancel any existing request with the same ID cancelAskAboutCode(requestId); - activeProviders.set(requestId, 'claude'); validateCommand('claude'); const filteredEnv: Record = {}; @@ -88,7 +89,6 @@ export function askAboutCode(win: BrowserWindow, args: AskCodeRequest): void { function cleanup() { activeRequests.delete(requestId); - activeProviders.delete(requestId); const timer = activeTimers.get(requestId); if (timer) { clearTimeout(timer); @@ -137,10 +137,7 @@ export function askAboutCode(win: BrowserWindow, args: AskCodeRequest): void { } export function cancelAskAboutCode(requestId: string): void { - const provider = activeProviders.get(requestId); - activeProviders.delete(requestId); - - if (provider === 'minimax') { + if (isMinimaxRequestActive(requestId)) { cancelAskAboutCodeMinimax(requestId); return; }