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
64 changes: 62 additions & 2 deletions src/commands/text/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ import type {
import { readFileSync } from 'fs';
import { isInteractive } from '../../utils/env';
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 @@ -146,6 +153,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 +191,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 +202,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 +227,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
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
97 changes: 90 additions & 7 deletions src/utils/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ 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';
import { dataUriDecodedSize } from './media';

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

export const IMAGE_MIME_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg',
Expand All @@ -12,7 +16,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 +34,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,18 +47,56 @@ 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;
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 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,
);
}
}

const buf = await res.arrayBuffer();
if (buf.byteLength > MAX_IMAGE_SIZE_BYTES) {
if (buf.byteLength > maxBytes) {
throw new CLIError(
`Image too large (${(buf.byteLength / 1024 / 1024).toFixed(1)} MB). Maximum is 50 MB.`,
`Image too large (${(buf.byteLength / 1024 / 1024).toFixed(1)} MB). Maximum is ${(maxBytes / 1024 / 1024).toFixed(0)} MB.`,
ExitCode.USAGE,
);
}
Expand All @@ -57,6 +105,41 @@ export async function toDataUri(image: string): Promise<string> {

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