Skip to content
Open
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
136 changes: 135 additions & 1 deletion electron/ipc/ask-code-minimax.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';

// Mock fetch globally
const mockFetch = vi.fn();
Expand All @@ -7,7 +10,9 @@ vi.stubGlobal('fetch', mockFetch);
import {
askAboutCodeMinimax,
cancelAskAboutCodeMinimax,
MINIMAX_IMAGE_INPUT_MODEL,
MINIMAX_MODEL,
minimaxModelAcceptsImages,
setMinimaxApiKey,
} from './ask-code-minimax.js';

Expand Down Expand Up @@ -261,6 +266,135 @@ describe('askAboutCodeMinimax', () => {
});
});

describe('minimax image input', () => {
const pngPath = path.join(os.tmpdir(), 'parallel-code-ask-code-test.png');
const pngBytes = Buffer.from('89504e470d0a1a0a', 'hex');

beforeAll(() => {
fs.writeFileSync(pngPath, pngBytes);
});

afterAll(() => {
fs.rmSync(pngPath, { force: true });
});

beforeEach(() => {
vi.clearAllMocks();
setMinimaxApiKey('test-key');
});

function requestBody() {
return JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) as {
model: string;
messages: Array<{
role: string;
content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>;
}>;
};
}

it('reports which models accept image input', () => {
expect(minimaxModelAcceptsImages(MINIMAX_IMAGE_INPUT_MODEL)).toBe(true);
expect(minimaxModelAcceptsImages(MINIMAX_MODEL)).toBe(false);
expect(minimaxModelAcceptsImages('nope')).toBe(false);
});

it('keeps the user content a plain string when no image is attached', async () => {
const { win, messages } = makeMockWin();

mockFetch.mockResolvedValueOnce(makeStreamResponse('data: [DONE]\n\n'));

askAboutCodeMinimax(win, {
requestId: 'img-none',
channelId: 'ch-img-none',
prompt: 'Explain this',
});

await waitForDone(messages);

const body = requestBody();
expect(body.model).toBe(MINIMAX_MODEL);
expect(body.messages.find((m) => m.role === 'user')?.content).toBe('Explain this');
});

it('sends attached images as content parts to an image-capable model', async () => {
const { win, messages } = makeMockWin();

mockFetch.mockResolvedValueOnce(makeStreamResponse('data: [DONE]\n\n'));

askAboutCodeMinimax(win, {
requestId: 'img-one',
channelId: 'ch-img-one',
prompt: 'What does this screenshot show?',
imagePaths: [pngPath],
});

await waitForDone(messages);

const body = requestBody();
expect(body.model).toBe(MINIMAX_IMAGE_INPUT_MODEL);

const userContent = body.messages.find((m) => m.role === 'user')?.content;
expect(Array.isArray(userContent)).toBe(true);
const parts = userContent as Array<{
type: string;
text?: string;
image_url?: { url: string };
}>;
expect(parts[0]).toEqual({ type: 'text', text: 'What does this screenshot show?' });
expect(parts[1].type).toBe('image_url');
expect(parts[1].image_url?.url).toBe(`data:image/png;base64,${pngBytes.toString('base64')}`);

// The system message stays a plain string
expect(typeof body.messages.find((m) => m.role === 'system')?.content).toBe('string');
});

it('rejects image types the chat API cannot accept', () => {
const { win } = makeMockWin();

expect(() =>
askAboutCodeMinimax(win, {
requestId: 'img-bad-type',
channelId: 'ch-img-bad-type',
prompt: 'Test',
imagePaths: [path.join(os.tmpdir(), 'notes.txt')],
}),
).toThrow(/Unsupported image type/);
expect(mockFetch).not.toHaveBeenCalled();
});

it('rejects more images than a single question allows', () => {
const { win } = makeMockWin();

expect(() =>
askAboutCodeMinimax(win, {
requestId: 'img-too-many',
channelId: 'ch-img-too-many',
prompt: 'Test',
imagePaths: [pngPath, pngPath, pngPath, pngPath, pngPath],
}),
).toThrow(/Too many images/);
expect(mockFetch).not.toHaveBeenCalled();
});

it('reports an unreadable image as an error instead of sending a request', async () => {
const { win, messages } = makeMockWin();

askAboutCodeMinimax(win, {
requestId: 'img-missing',
channelId: 'ch-img-missing',
prompt: 'Test',
imagePaths: [path.join(os.tmpdir(), 'parallel-code-does-not-exist.png')],
});

await waitForDone(messages);

const errors = messages.filter((m) => (m as Record<string, unknown>).type === 'error');
expect(errors).toHaveLength(1);
expect(mockFetch).not.toHaveBeenCalled();
});
});

describe('cancelAskAboutCodeMinimax', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down
146 changes: 125 additions & 21 deletions electron/ipc/ask-code-minimax.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import fs from 'fs';
import path from 'path';
import type { BrowserWindow } from 'electron';
import { debug as logDebug } from '../log.js';
import {
Expand All @@ -13,11 +15,106 @@ interface MinimaxAskCodeRequest {
requestId: string;
channelId: string;
prompt: string;
/**
* Absolute paths of images to send alongside the prompt. The app already
* resolves pasted and dropped images to temp files, so a request carries
* those paths rather than the bytes themselves.
*/
imagePaths?: string[];
}

const MINIMAX_API_URL = 'https://api.minimax.io/v1/chat/completions';
export const MINIMAX_MODEL = 'MiniMax-M2.7';

/** Model used when a request carries image input. */
export const MINIMAX_IMAGE_INPUT_MODEL = 'MiniMax-M3';

/** Input modalities a MiniMax model accepts. */
export type MinimaxInputModality = 'text' | 'image' | 'video';

/** Input modalities per model, as published in the provider model catalog. */
const MINIMAX_INPUT_MODALITIES: Readonly<Record<string, readonly MinimaxInputModality[]>> = {
[MINIMAX_IMAGE_INPUT_MODEL]: ['text', 'image', 'video'],
[MINIMAX_MODEL]: ['text'],
};

/** Whether a model accepts image input according to the catalog. */
export function minimaxModelAcceptsImages(modelId: string): boolean {
return MINIMAX_INPUT_MODALITIES[modelId]?.includes('image') ?? false;
}

/** Image input is capped separately from the prompt: the bytes never count against it. */
const MAX_IMAGES_PER_REQUEST = 4;
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;

const IMAGE_MIME_TYPES: Readonly<Record<string, string>> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
};

/** A chat message content part in the chat completions request schema. */
type MinimaxContentPart =
| { type: 'text'; text: string }
| { type: 'image_url'; image_url: { url: string } };

/**
* Picks the model for a request. Image input requires a model whose catalog
* modalities include images, so image requests fall back to the image-capable
* model whenever the text default cannot accept them.
*/
function resolveModel(hasImages: boolean): string {
if (!hasImages || minimaxModelAcceptsImages(MINIMAX_MODEL)) return MINIMAX_MODEL;
return MINIMAX_IMAGE_INPUT_MODEL;
}

/** Rejects unsupported or oversized image input before a request is started. */
function assertImagesSupported(imagePaths: string[]): void {
if (imagePaths.length > MAX_IMAGES_PER_REQUEST) {
throw new Error(
`Too many images (${imagePaths.length}, max ${MAX_IMAGES_PER_REQUEST} per question)`,
);
}
for (const imagePath of imagePaths) {
if (!IMAGE_MIME_TYPES[path.extname(imagePath).toLowerCase()]) {
throw new Error(`Unsupported image type: ${path.basename(imagePath)}`);
}
}
}

/** Reads an image from disk into the data URL form the chat API expects. */
async function imageDataUrl(imagePath: string): Promise<string> {
const mimeType = IMAGE_MIME_TYPES[path.extname(imagePath).toLowerCase()];
if (!mimeType) throw new Error(`Unsupported image type: ${path.basename(imagePath)}`);

const bytes = await fs.promises.readFile(imagePath);
if (bytes.byteLength > MAX_IMAGE_BYTES) {
throw new Error(
`Image too large: ${path.basename(imagePath)} (${bytes.byteLength} bytes, max ${MAX_IMAGE_BYTES})`,
);
}
return `data:${mimeType};base64,${bytes.toString('base64')}`;
}

/**
* Builds the user message content: a plain string while the question is text
* only, and text plus image parts once images are attached.
*/
async function buildUserContent(
prompt: string,
imagePaths: string[],
): Promise<string | MinimaxContentPart[]> {
if (imagePaths.length === 0) return prompt;

const parts: MinimaxContentPart[] = [{ type: 'text', text: prompt }];
for (const imagePath of imagePaths) {
parts.push({ type: 'image_url', image_url: { url: await imageDataUrl(imagePath) } });
}
return parts;
}

const activeRequests = new RequestRegistry<AbortController>({
maxConcurrent: ASK_CODE_MAX_CONCURRENT,
timeoutMs: ASK_CODE_TIMEOUT_MS,
Expand All @@ -32,17 +129,21 @@ export function setMinimaxApiKey(key: string): void {

export function askAboutCodeMinimax(win: BrowserWindow, args: MinimaxAskCodeRequest): void {
const { requestId, channelId, prompt } = args;
const imagePaths = args.imagePaths ?? [];
const apiKey = storedApiKey;

if (!apiKey) {
throw new Error('MiniMax API key is not set. Please configure it in Settings.');
}

assertPromptWithinLimit(prompt);
assertImagesSupported(imagePaths);
assertCanStart(activeRequests, requestId);

cancelAskAboutCodeMinimax(requestId);

const model = resolveModel(imagePaths.length > 0);

const controller = new AbortController();

const send = (msg: unknown) => {
Expand All @@ -55,28 +156,31 @@ export function askAboutCodeMinimax(win: BrowserWindow, args: MinimaxAskCodeRequ
request.abort(),
);

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.',
buildUserContent(prompt, imagePaths)
.then((content) =>
fetch(MINIMAX_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
{ role: 'user', content: prompt },
],
// MiniMax temperature must be in (0.0, 1.0]
temperature: 0.3,
max_tokens: 2048,
stream: true,
}),
signal: controller.signal,
})
body: JSON.stringify({
model,
messages: [
{
role: 'system',
content: 'Answer concisely about the selected code. Use markdown.',
},
{ role: 'user', content },
],
// MiniMax temperature must be in (0.0, 1.0]
temperature: 0.3,
max_tokens: 2048,
stream: true,
}),
signal: controller.signal,
}),
)
.then(async (res) => {
if (!res.ok || !res.body) {
const text = await res.text().catch(() => `HTTP ${res.status}`);
Expand Down
9 changes: 6 additions & 3 deletions electron/ipc/ask-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ interface AskCodeRequest {
provider?: AskCodeProvider;
/** Env file configured for the Claude Code agent, if any. */
envFile?: string;
/** Absolute paths of images attached to the question. */
imagePaths?: string[];
}

const activeRequests = new RequestRegistry<ChildProcess>({
Expand All @@ -34,12 +36,13 @@ const activeRequests = new RequestRegistry<ChildProcess>({
});

export function askAboutCode(win: BrowserWindow, args: AskCodeRequest): void {
const { requestId, channelId, prompt, cwd, provider, envFile } = args;
const { requestId, channelId, prompt, cwd, provider, envFile, imagePaths } = args;

// Route to MiniMax backend when configured
// Route to MiniMax backend when configured. Image input is wired for this
// provider only; the CLI path below takes a text prompt.
if (provider === 'minimax') {
activeRequests.cancel(requestId);
askAboutCodeMinimax(win, { requestId, channelId, prompt });
askAboutCodeMinimax(win, { requestId, channelId, prompt, imagePaths });
return;
}

Expand Down
8 changes: 8 additions & 0 deletions electron/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -900,13 +900,21 @@ export function registerAllHandlers(win: BrowserWindow): void {
const provider: string | undefined =
typeof args.provider === 'string' ? args.provider : undefined;
assertOptionalString(args.envFile, 'envFile');
const rawImagePaths: unknown = args.imagePaths;
let imagePaths: string[] | undefined;
if (rawImagePaths !== undefined) {
assertStringArray(rawImagePaths, 'imagePaths');
for (const imagePath of rawImagePaths) validatePath(imagePath, 'imagePath');
imagePaths = rawImagePaths;
}
askAboutCode(win, {
requestId: args.requestId,
channelId: args.onOutput.__CHANNEL_ID__,
prompt: args.prompt,
cwd: args.cwd,
provider: provider === 'minimax' ? 'minimax' : 'claude',
envFile: args.envFile,
imagePaths,
});
});

Expand Down
Loading
Loading