Files
lobehub/apps/cli/src/commands/generate/text.ts
Arvin Xu e67bcb2571 feat(cli): add generate command for text/image/video/tts/asr (#12799)
*  feat(cli): add generate command for text/image/video/tts/asr

LOBE-5711

- `lh generate text <prompt>` — LLM text completion with SSE streaming
  - Supports --model (provider/model format), --system, --temperature, --pipe
- `lh generate image <prompt>` — Image generation via async task
- `lh generate video <prompt>` — Video generation via async task
- `lh generate tts <text>` — Text-to-speech (openai/microsoft/edge backends)
- `lh generate asr <file>` — Speech-to-text via OpenAI Whisper
- `lh generate status` — Check async generation task status
- `lh generate list` — List generation topics
- Add shared HTTP auth helper (api/http.ts) for webapi endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* update info

* ♻️ refactor(cli): split generate command into submodules, text defaults non-streaming

- Split monolithic generate.ts into generate/{index,text,image,video,tts,asr}.ts
- Text subcommand now defaults to non-streaming (use --stream to opt in)
- Text subcommand supports --json for full JSON response output
- Video subcommand uses requiredOption for --model and --provider

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 🐛 fix(cli): read generation data from result.data and add required X-lobe-chat-auth header

Image/video mutations return { success, data: { ... } }, read IDs from data.
WebAPI endpoints require X-lobe-chat-auth (XOR-encrypted) alongside Oidc-Auth.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 11:19:01 +08:00

147 lines
4.6 KiB
TypeScript

import type { Command } from 'commander';
import { getAuthInfo } from '../../api/http';
import { log } from '../../utils/logger';
export function registerTextCommand(parent: Command) {
parent
.command('text <prompt>')
.description('Generate text with an LLM (single completion, no tools)')
.option('-m, --model <model>', 'Model ID (provider/model format)', 'openai/gpt-4o-mini')
.option('-p, --provider <provider>', 'Provider name (derived from model if omitted)')
.option('-s, --system <prompt>', 'System prompt')
.option('--temperature <n>', 'Temperature (0-2)')
.option('--max-tokens <n>', 'Maximum output tokens')
.option('--stream', 'Enable streaming (SSE, renders incrementally)')
.option('--json', 'Output full JSON response')
.option('--pipe', 'Pipe mode: read additional context from stdin')
.action(
async (
prompt: string,
options: {
json?: boolean;
maxTokens?: string;
model: string;
pipe?: boolean;
provider?: string;
stream?: boolean;
system?: string;
temperature?: string;
},
) => {
// Resolve provider from model if not specified
const parts = options.model.split('/');
const provider = options.provider || (parts.length > 1 ? parts[0] : 'openai');
const model = parts.length > 1 ? parts.slice(1).join('/') : options.model;
// Read additional input from stdin if --pipe
let fullPrompt = prompt;
if (options.pipe) {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk as Buffer);
}
const stdinContent = Buffer.concat(chunks).toString('utf8').trim();
if (stdinContent) {
fullPrompt = `${prompt}\n\n${stdinContent}`;
}
}
const messages: Array<{ content: string; role: string }> = [];
if (options.system) {
messages.push({ content: options.system, role: 'system' });
}
messages.push({ content: fullPrompt, role: 'user' });
const useStream = options.stream === true;
const payload: Record<string, any> = {
messages,
model,
stream: useStream,
};
if (options.temperature) payload.temperature = Number.parseFloat(options.temperature);
if (options.maxTokens) payload.max_tokens = Number.parseInt(options.maxTokens, 10);
const { serverUrl, headers } = await getAuthInfo();
const res = await fetch(`${serverUrl}/webapi/chat/${provider}`, {
body: JSON.stringify(payload),
headers,
method: 'POST',
});
if (!res.ok) {
const text = await res.text();
log.error(`Text generation failed: ${res.status} ${text}`);
process.exit(1);
return;
}
if (!useStream) {
const body = await res.json();
if (options.json) {
console.log(JSON.stringify(body, null, 2));
} else {
const content = (body as any).choices?.[0]?.message?.content || JSON.stringify(body);
process.stdout.write(content);
process.stdout.write('\n');
}
return;
}
// Stream SSE response
if (!res.body) {
log.error('No response body received');
process.exit(1);
return;
}
await streamSSEResponse(res.body, options.json);
},
);
}
async function streamSSEResponse(body: ReadableStream<Uint8Array>, json?: boolean): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (data === '[DONE]') {
if (!json) process.stdout.write('\n');
return;
}
try {
const parsed = JSON.parse(data);
if (json) {
console.log(JSON.stringify(parsed));
} else {
const content = parsed.choices?.[0]?.delta?.content;
if (content) process.stdout.write(content);
}
} catch {
// Not JSON, might be raw text chunk
if (!json) process.stdout.write(data);
}
}
}
// Final newline
if (!json) process.stdout.write('\n');
} finally {
reader.releaseLock();
}
}