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 @@ -75,6 +75,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 @@ -70,6 +70,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
13 changes: 12 additions & 1 deletion skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,10 @@ mmx text chat --message <text> [flags]

| Flag | Type | Description |
|---|---|---|
| `--message <text>` | string, **required**, repeatable | Message text. Prefix with `role:` to set role (e.g. `"system:You are helpful"`, `"user:Hello"`) |
| `--message <text>` | string, repeatable (required unless `--image` or `--messages-file` is given) | 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,18 @@ 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 base64 shape for you; a hand-written
`--messages-file` may also use `{"type":"image","source":{"type":"url","url":"https://..."}}`.

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

---
Expand Down
77 changes: 74 additions & 3 deletions src/commands/text/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ import { readFileSync } from 'fs';
import { isInteractive } from '../../utils/env';
import { readTextFromPathOrStdin } from '../../utils/fs';
import { promptText, failIfMissing } from '../../utils/prompt';
import { toImageBlock } from '../../utils/image';

// MiniMax Anthropic API contract (platform.minimax.io/docs/api-reference/text-anthropic-api):
// per-image cap, supported formats, and the whole-request-body cap.
const CHAT_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
const CHAT_IMAGE_ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const CHAT_MAX_REQUEST_BYTES = 64 * 1024 * 1024;

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

/**
* Attach image blocks to the final 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. If the conversation does not end with a user message
* (empty, or the assistant spoke last), the images become a new user turn rather
* than being spliced into an earlier one.
*/
function attachImages(messages: ChatMessage[], images: ContentBlock[]): void {
const last = messages[messages.length - 1];
if (!last || last.role !== 'user') {
messages.push({ role: 'user', content: images });
return;
}

const target = last;
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 @@ -159,9 +187,10 @@ export default defineCommand({
usage: 'mmx text chat --message <text> [flags]',
options: [
{ flag: '--model <model>', description: 'Model ID (default: MiniMax-M3)' },
{ flag: '--message <text>', description: 'Message text (repeatable, prefix role: to set role)', required: true, type: 'array' },
{ flag: '--message <text>', description: 'Message text (repeatable, prefix role: to set role; optional when --image or --messages-file is given)', 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 @@ -172,14 +201,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 @@ -194,8 +226,36 @@ export default defineCommand({
}
}

if (imageInputs.length > 0) {
const images: ContentBlock[] = [];
// Track the running request-body size so a run bails as soon as it's
// clear the request would exceed the cap, not after every image is
// fetched and encoded.
let requestBytes = Buffer.byteLength(JSON.stringify(messages), 'utf-8')
+ Buffer.byteLength(system ?? '', 'utf-8');

for (const input of imageInputs) {
const block = await toImageBlock(input, {
maxBytes: CHAT_IMAGE_MAX_BYTES,
allowedMediaTypes: CHAT_IMAGE_ALLOWED_TYPES,
});
requestBytes += block.source.data.length;
if (requestBytes > CHAT_MAX_REQUEST_BYTES) {
throw new CLIError(
`Request body exceeds the ${CHAT_MAX_REQUEST_BYTES / 1024 / 1024} MB limit once this image is attached.`,
ExitCode.USAGE,
'Send fewer or smaller --image inputs.',
);
}
images.push(block);
}

attachImages(messages, images);
}

// 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 Expand Up @@ -235,6 +295,17 @@ export default defineCommand({
body.tools = tools;
}

// The per-image accumulation above is a fast fail on a lower bound; this is the
// authoritative check on the bytes that actually go on the wire.
const wireBytes = Buffer.byteLength(JSON.stringify(body), 'utf-8');
if (wireBytes > CHAT_MAX_REQUEST_BYTES) {
throw new CLIError(
`Request body is ${(wireBytes / 1024 / 1024).toFixed(1)} MB; the API limit is ${CHAT_MAX_REQUEST_BYTES / 1024 / 1024} MB.`,
ExitCode.USAGE,
'Send fewer or smaller --image inputs, or shorten the message and system text.',
);
}

if (config.dryRun) {
console.log(formatOutput({ request: body }, format));
return;
Expand Down
8 changes: 7 additions & 1 deletion src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ 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: ImageSource };

/** Image source shapes accepted by the Messages API. `--image` always emits base64. */
export type ImageSource =
| { type: 'base64'; media_type: string; data: string }
| { type: 'url'; url: string };

export interface ChatMessage {
role: 'user' | 'assistant';
Expand Down
131 changes: 119 additions & 12 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 { dataUriDecodedSize } from './media';

type ImageBlock = { type: 'image'; source: { type: 'base64'; media_type: string; data: string } };

export const IMAGE_MIME_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg',
Expand All @@ -12,7 +15,13 @@ export const IMAGE_MIME_TYPES: Record<string, string> = {
'.heif': 'image/heif',
};

export function localFileToDataUri(filePath: string, maxBytes?: number): string {
/** Per-image and format constraints for a specific caller (e.g. chat's Anthropic-API contract). */
export interface ImageValidationOptions {
maxBytes: number;
allowedMediaTypes: readonly string[];
}

export function localFileToDataUri(filePath: string, maxBytes?: number, mimeOverride?: string): string {
if (maxBytes !== undefined) {
const size = statSync(filePath).size;
if (size > maxBytes) {
Expand All @@ -24,7 +33,7 @@ export function localFileToDataUri(filePath: string, maxBytes?: number): string
}
}
const ext = extname(filePath).toLowerCase();
const mime = IMAGE_MIME_TYPES[ext] || 'image/jpeg';
const mime = mimeOverride || IMAGE_MIME_TYPES[ext] || 'image/jpeg';
const data = readFileSync(filePath);
return `data:${mime};base64,${data.toString('base64')}`;
}
Expand All @@ -37,26 +46,124 @@ export function resolveImageInput(input: string, maxBytes?: number): string {

const MAX_IMAGE_SIZE_BYTES = 50 * 1024 * 1024;

export async function toDataUri(image: string): Promise<string> {
if (image.startsWith('data:')) return image;
async function readBodyWithLimit(res: Response, maxBytes: number): Promise<Buffer> {
if (!res.body) {
const buf = Buffer.from(await res.arrayBuffer());
if (buf.byteLength > maxBytes) throw tooLarge(buf.byteLength, maxBytes);
return buf;
}
const reader = res.body.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
received += value.byteLength;
if (received > maxBytes) {
await reader.cancel();
throw tooLarge(received, maxBytes);
}
chunks.push(value);
}
return Buffer.concat(chunks);
}

function tooLarge(bytes: number, maxBytes: number): CLIError {
return new CLIError(
`Image too large (${(bytes / 1024 / 1024).toFixed(1)} MB received). Maximum is ${(maxBytes / 1024 / 1024).toFixed(0)} MB.`,
ExitCode.USAGE,
);
}

export async function toDataUri(image: string, opts?: ImageValidationOptions): Promise<string> {
if (image.startsWith('data:')) {
if (opts) {
const mime = /^data:([^;,]+)[;,]/.exec(image)?.[1];
if (!mime || !opts.allowedMediaTypes.includes(mime)) {
throw new CLIError(
`Unsupported image type "${mime ?? 'unknown'}". Supported: ${opts.allowedMediaTypes.join(', ')}`,
ExitCode.USAGE,
);
}
const size = dataUriDecodedSize(image);
if (size !== undefined && size > opts.maxBytes) {
throw new CLIError(
`Image too large (${(size / 1024 / 1024).toFixed(1)} MB). Maximum is ${(opts.maxBytes / 1024 / 1024).toFixed(0)} MB.`,
ExitCode.USAGE,
);
}
}
return image;
}

if (image.startsWith('http://') || image.startsWith('https://')) {
const res = await fetch(image);
if (!res.ok) throw new CLIError(`Failed to download image: HTTP ${res.status}`, ExitCode.GENERAL);
const contentType = res.headers.get('content-type') || 'image/jpeg';
const mime = contentType.split(';')[0]!.trim();
const buf = await res.arrayBuffer();
if (buf.byteLength > MAX_IMAGE_SIZE_BYTES) {
throw new CLIError(
`Image too large (${(buf.byteLength / 1024 / 1024).toFixed(1)} MB). Maximum is 50 MB.`,
ExitCode.USAGE,
);
const maxBytes = opts?.maxBytes ?? MAX_IMAGE_SIZE_BYTES;

if (opts) {
if (!opts.allowedMediaTypes.includes(mime)) {
throw new CLIError(
`Unsupported image type "${mime}". Supported: ${opts.allowedMediaTypes.join(', ')}`,
ExitCode.USAGE,
);
}
// content-length can lie or be absent, but checking it first avoids
// buffering an oversized body just to reject it a moment later.
const contentLength = Number(res.headers.get('content-length'));
if (contentLength > maxBytes) {
throw new CLIError(
`Image too large (${(contentLength / 1024 / 1024).toFixed(1)} MB). Maximum is ${(maxBytes / 1024 / 1024).toFixed(0)} MB.`,
ExitCode.USAGE,
);
}
}
return `data:${mime};base64,${Buffer.from(buf).toString('base64')}`;

// content-length is advisory; enforce the cap on the bytes actually received and
// cancel the download the moment it is exceeded instead of buffering the whole body.
const buf = await readBodyWithLimit(res, maxBytes);
return `data:${mime};base64,${buf.toString('base64')}`;
}

if (!existsSync(image)) throw new CLIError(`File not found: ${image}`, ExitCode.USAGE);
const ext = extname(image).toLowerCase();
if (!IMAGE_MIME_TYPES[ext]) throw new CLIError(`Unsupported image format "${ext}". Supported: jpg, jpeg, png, webp`, ExitCode.USAGE);
const mime = opts
? (IMAGE_MIME_TYPES[ext] ?? (ext === '.gif' ? 'image/gif' : undefined))
: IMAGE_MIME_TYPES[ext];
if (!mime || (opts && !opts.allowedMediaTypes.includes(mime))) {
throw new CLIError(
`Unsupported image format "${ext}". Supported: ${opts ? opts.allowedMediaTypes.join(', ') : 'jpg, jpeg, png, webp'}`,
ExitCode.USAGE,
);
}
if (opts) {
const size = statSync(image).size;
if (size > opts.maxBytes) {
throw new CLIError(
`Image too large (${(size / 1024 / 1024).toFixed(1)} MB). Maximum is ${(opts.maxBytes / 1024 / 1024).toFixed(0)} MB.`,
ExitCode.USAGE,
);
}
return localFileToDataUri(image, undefined, mime);
}
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, opts?: ImageValidationOptions): Promise<ImageBlock> {
const uri = await toDataUri(image, opts);
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]! } };
}
Loading
Loading