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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ mmx text chat --model MiniMax-M3 --message "Hello" --stream
mmx text chat --system "You are a coding assistant" --message "Fizzbuzz in Go"
mmx text chat --message "user:Hi" --message "assistant:Hey!" --message "How are you?"
cat messages.json | mmx text chat --messages-file - --output json
mmx text chat --image photo.jpg --message "What breed is this dog?"
mmx text chat --image before.png --image after.png --message "What changed?"
```

### `mmx image`
Expand Down
2 changes: 2 additions & 0 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ mmx text chat --model MiniMax-M3 --message "你好" --stream
mmx text chat --system "你是编程助手" --message "用 Go 写 Fizzbuzz"
mmx text chat --message "user:你好" --message "assistant:嗨!" --message "你叫什么名字?"
cat messages.json | mmx text chat --messages-file - --output json
mmx text chat --image photo.jpg --message "这是什么品种的狗?"
mmx text chat --image before.png --image after.png --message "这两张图有什么不同?"
```

### `mmx image`
Expand Down
10 changes: 10 additions & 0 deletions skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ mmx text chat --message <text> [flags]
| `--message <text>` | string, **required**, repeatable | Message text. Prefix with `role:` to set role (e.g. `"system:You are helpful"`, `"user:Hello"`) |
| `--messages-file <path>` | string | JSON file with messages array. Use `-` for stdin |
| `--system <text>` | string | System prompt |
| `--image <path-or-url>` | string, repeatable | Image to send with the message (auto base64-encoded). Forces `MiniMax-M3` unless `--model` is set |
| `--model <model>` | string | Model ID (default: `MiniMax-M3`) |
| `--max-tokens <n>` | number | Max tokens (default: 4096) |
| `--temperature <n>` | number | Sampling temperature (0.0, 1.0] |
Expand All @@ -76,8 +77,17 @@ mmx text chat \

# From file
cat conversation.json | mmx text chat --messages-file - --output json

# With images (M3 is multimodal; --image is repeatable)
mmx text chat --image photo.jpg --message "What breed is this dog?" --quiet
mmx text chat --image before.png --image after.png \
--message "List every visual difference between these two." --quiet
```

`text chat` posts to the Anthropic-compatible `/messages` endpoint, so hand-written
`--messages-file` image blocks must use `{"type":"image","source":{"type":"base64",...}}`.
The OpenAI `image_url` shape is rejected. `--image` emits the correct shape for you.

**stdout**: response text (text mode) or full response object (json mode).

---
Expand Down
35 changes: 33 additions & 2 deletions src/commands/text/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
import { readFileSync } from 'fs';
import { isInteractive } from '../../utils/env';
import { promptText, failIfMissing } from '../../utils/prompt';
import { toImageBlock } from '../../utils/image';

// ---------------------------------------------------------------------------
// Thinking indicator — dynamic spinner + color-cycling label
Expand Down Expand Up @@ -146,6 +147,27 @@ function parseMessages(flags: GlobalFlags): ParsedMessages {
return { system, messages };
}

/**
* Attach image blocks to the last user message, promoting its content from a
* bare string to a block array. Images land after the text so the model reads
* the instruction first.
*/
function attachImages(messages: ChatMessage[], images: ContentBlock[]): void {
let idx = messages.length - 1;
while (idx >= 0 && messages[idx]!.role !== 'user') idx--;

if (idx < 0) {
messages.push({ role: 'user', content: images });
return;
}

const target = messages[idx]!;
const content = typeof target.content === 'string'
? (target.content ? [{ type: 'text' as const, text: target.content }] : [])
: target.content;
target.content = [...content, ...images];
}

function extractText(content: ContentBlock[]): string {
return content
.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
Expand All @@ -163,6 +185,7 @@ export default defineCommand({
{ flag: '--message <text>', description: 'Message text (repeatable, prefix role: to set role)', required: true, type: 'array' },
{ flag: '--messages-file <path>', description: 'JSON file with messages array (use - for stdin)' },
{ flag: '--system <text>', description: 'System prompt' },
{ flag: '--image <path-or-url>', description: 'Image to send with the message (repeatable, base64 encoded automatically)', type: 'array' },
{ flag: '--max-tokens <n>', description: 'Maximum tokens to generate (default: 4096)', type: 'number' },
{ flag: '--temperature <n>', description: 'Sampling temperature (0.0, 1.0]', type: 'number' },
{ flag: '--top-p <n>', description: 'Nucleus sampling threshold', type: 'number' },
Expand All @@ -173,14 +196,17 @@ export default defineCommand({
'mmx text chat --message "What is MiniMax?"',
'mmx text chat --model MiniMax-M3 --system "You are a coding assistant." --message "Write fizzbuzz in Python"',
'mmx text chat --message "Hello" --message "assistant:Hi!" --message "How are you?"',
'mmx text chat --image photo.jpg --message "What breed is this dog?"',
'mmx text chat --image before.png --image after.png --message "List every visual difference."',
'cat conversation.json | mmx text chat --messages-file - --stream',
'mmx text chat --message "Hello" --output json',
],
async run(config: Config, flags: GlobalFlags) {
const { system, messages: parsedMessages } = parseMessages(flags);
let messages = parsedMessages;
const imageInputs = (flags.image as string[] | undefined) ?? [];

if (messages.length === 0) {
if (messages.length === 0 && imageInputs.length === 0) {
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const hint = await promptText({
message: 'Enter your message:',
Expand All @@ -195,8 +221,13 @@ export default defineCommand({
}
}

if (imageInputs.length > 0) {
attachImages(messages, await Promise.all(imageInputs.map(toImageBlock)));
}

// Images require a multimodal model, so they override a text-only config default.
const model = (flags.model as string)
|| config.defaultTextModel
|| (imageInputs.length > 0 ? 'MiniMax-M3' : config.defaultTextModel)
|| 'MiniMax-M3';
const format = detectOutputFormat(config.output);
const shouldStream = flags.stream === true || (
Expand Down
3 changes: 2 additions & 1 deletion src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ export type ContentBlock =
| { type: 'text'; text: string }
| { type: 'thinking'; thinking: string }
| { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
| { type: 'tool_result'; tool_use_id: string; content: string };
| { type: 'tool_result'; tool_use_id: string; content: string }
| { type: 'image'; source: { type: 'base64'; media_type: string; data: string } };

export interface ChatMessage {
role: 'user' | 'assistant';
Expand Down
20 changes: 20 additions & 0 deletions src/utils/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { readFileSync, existsSync, statSync } from 'fs';
import { extname } from 'path';
import { CLIError } from '../errors/base';
import { ExitCode } from '../errors/codes';
import type { ContentBlock } from '../types/api';

type ImageBlock = Extract<ContentBlock, { type: 'image' }>;

export const IMAGE_MIME_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg',
Expand Down Expand Up @@ -60,3 +63,20 @@ export async function toDataUri(image: string): Promise<string> {
if (!IMAGE_MIME_TYPES[ext]) throw new CLIError(`Unsupported image format "${ext}". Supported: jpg, jpeg, png, webp`, ExitCode.USAGE);
return localFileToDataUri(image);
}

/**
* Convert a path / URL / data URI into an Anthropic-shaped image content block.
* The Messages API rejects the OpenAI `image_url` shape, so callers targeting
* `/anthropic/v1/messages` must use this instead of a raw data URI.
*/
export async function toImageBlock(image: string): Promise<ImageBlock> {
const uri = await toDataUri(image);
const match = /^data:([^;,]+);base64,(.*)$/s.exec(uri);
if (!match) {
throw new CLIError(
`Unsupported image source "${image}": expected a base64 data URI, file path, or http(s) URL.`,
ExitCode.USAGE,
);
}
return { type: 'image', source: { type: 'base64', media_type: match[1]!, data: match[2]! } };
}
110 changes: 110 additions & 0 deletions test/commands/text/chat.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { describe, it, expect, afterEach } from 'bun:test';
import { mkdtempSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { createMockServer, jsonResponse, sseResponse, type MockServer } from '../../helpers/mock-server';
import textChatResponse from '../../fixtures/text-chat-response.json';
import type { Config } from '../../../src/config/schema';
Expand Down Expand Up @@ -302,4 +305,111 @@ describe('text chat command', () => {
console.log = originalLog;
}
});

describe('--image', () => {
// 1x1 transparent PNG
const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
const dir = mkdtempSync(join(tmpdir(), 'mmx-chat-image-'));
const imgA = join(dir, 'a.png');
const imgB = join(dir, 'b.png');
writeFileSync(imgA, Buffer.from(PNG_BASE64, 'base64'));
writeFileSync(imgB, Buffer.from(PNG_BASE64, 'base64'));

const baseConfig: Config = {
apiKey: 'test-key',
region: 'global' as const,
baseUrl: 'https://api.mmx.io',
output: 'json',
timeout: 10,
verbose: false,
quiet: false,
noColor: true,
yes: false,
dryRun: true,
nonInteractive: true,
async: false,
};

const baseFlags = {
quiet: false,
verbose: false,
noColor: true,
yes: false,
dryRun: true,
help: false,
nonInteractive: true,
async: false,
};

async function dryRunRequest(config: Config, flags: Record<string, unknown>) {
const { default: chatCommand } = await import('../../../src/commands/text/chat');
const originalLog = console.log;
let output = '';
console.log = (msg: string) => { output += msg; };
try {
await chatCommand.execute(config, { ...baseFlags, ...flags } as never);
} finally {
console.log = originalLog;
}
return JSON.parse(output).request;
}

it('appends Anthropic-shaped image blocks to the user message', async () => {
const request = await dryRunRequest(baseConfig, {
message: ['What is this?'],
image: [imgA],
});

expect(request.messages).toHaveLength(1);
expect(request.messages[0].content).toEqual([
{ type: 'text', text: 'What is this?' },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: PNG_BASE64 } },
]);
});

it('supports multiple images in one message', async () => {
const request = await dryRunRequest(baseConfig, {
message: ['Compare these.'],
image: [imgA, imgB],
});

const blocks = request.messages[0].content;
expect(blocks).toHaveLength(3);
expect(blocks.filter((b: { type: string }) => b.type === 'image')).toHaveLength(2);
});

it('sends images with no --message', async () => {
const request = await dryRunRequest(baseConfig, { image: [imgA] });

expect(request.messages).toHaveLength(1);
expect(request.messages[0].role).toBe('user');
expect(request.messages[0].content).toEqual([
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: PNG_BASE64 } },
]);
});

it('overrides a text-only defaultTextModel with MiniMax-M3', async () => {
const request = await dryRunRequest(
{ ...baseConfig, defaultTextModel: 'MiniMax-Text-01' },
{ message: ['What is this?'], image: [imgA] },
);

expect(request.model).toBe('MiniMax-M3');
});

it('still honours an explicit --model', async () => {
const request = await dryRunRequest(
{ ...baseConfig, defaultTextModel: 'MiniMax-Text-01' },
{ message: ['What is this?'], image: [imgA], model: 'MiniMax-VL-01' },
);

expect(request.model).toBe('MiniMax-VL-01');
});

it('errors on a missing image file', async () => {
await expect(
dryRunRequest(baseConfig, { message: ['hi'], image: [join(dir, 'nope.png')] }),
).rejects.toThrow(/File not found/);
});
});
});
Loading