mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
feat: model recommend beta version
This commit is contained in:
@@ -43,7 +43,9 @@
|
||||
"check": "vp check"
|
||||
},
|
||||
"dependencies": {
|
||||
"bailian-cli-core": "workspace:*"
|
||||
"bailian-cli-core": "workspace:*",
|
||||
"boxen": "catalog:",
|
||||
"chalk": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^0.7.0",
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import {
|
||||
analyzeIntent,
|
||||
buildDocLink,
|
||||
type Config,
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
type GetModelsOptions,
|
||||
type GlobalFlags,
|
||||
getModels,
|
||||
type IntentProfile,
|
||||
isInteractive,
|
||||
type PipelineStep,
|
||||
type RecommendedModel,
|
||||
type RecommendResult,
|
||||
rankModels,
|
||||
recallSemantic,
|
||||
} from "bailian-cli-core";
|
||||
import boxen from "boxen";
|
||||
import chalk, { Chalk, type ChalkInstance } from "chalk";
|
||||
import { emitBare, emitResult } from "../../output/output.ts";
|
||||
import { createSpinner } from "../../output/progress.ts";
|
||||
import { failIfMissing, promptText } from "../../output/prompt.ts";
|
||||
|
||||
function formatContextWindow(tokens: number): string {
|
||||
if (tokens >= 1_000_000)
|
||||
return `${(tokens / 1_000_000).toFixed(tokens % 1_000_000 === 0 ? 0 : 1)}M`;
|
||||
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(tokens % 1_000 === 0 ? 0 : 1)}K`;
|
||||
return String(tokens);
|
||||
}
|
||||
|
||||
const MODALITY_LABELS: Record<string, string> = {
|
||||
Text: "文本",
|
||||
Image: "图片",
|
||||
Video: "视频",
|
||||
Audio: "音频",
|
||||
};
|
||||
const CAPABILITY_LABELS: Record<string, string> = {
|
||||
TG: "文本生成",
|
||||
VU: "视觉理解",
|
||||
IG: "图像生成",
|
||||
VG: "视频生成",
|
||||
TTS: "语音合成",
|
||||
ASR: "语音识别",
|
||||
Reasoning: "推理",
|
||||
};
|
||||
const BUDGET_LABELS: Record<string, string> = {
|
||||
low: "低成本优先",
|
||||
medium: "适中",
|
||||
high: "高投入",
|
||||
};
|
||||
const QUALITY_LABELS: Record<string, string> = {
|
||||
flagship: "旗舰优先",
|
||||
balanced: "均衡",
|
||||
"cost-optimized": "性价比优先",
|
||||
};
|
||||
|
||||
function formatIntentSummary(intent: IntentProfile, noColor: boolean): string {
|
||||
const colorize = noColor ? new Chalk({ level: 0 }) : chalk;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(colorize.cyan.bold("需求理解"));
|
||||
|
||||
if (intent.taskSummary) {
|
||||
lines.push("");
|
||||
lines.push(intent.taskSummary);
|
||||
}
|
||||
|
||||
if (intent.scenarioHints.length) {
|
||||
lines.push("");
|
||||
lines.push(`${colorize.dim("场景特征")} ${intent.scenarioHints.join(" · ")}`);
|
||||
}
|
||||
|
||||
const inputLabels = intent.inputModality.map((mod) => MODALITY_LABELS[mod] ?? mod);
|
||||
const outputLabels = intent.outputModality.map((mod) => MODALITY_LABELS[mod] ?? mod);
|
||||
if (inputLabels.length || outputLabels.length) {
|
||||
lines.push("");
|
||||
const parts: string[] = [];
|
||||
if (inputLabels.length) parts.push(`${colorize.dim("输入")} ${inputLabels.join(", ")}`);
|
||||
if (outputLabels.length) parts.push(`${colorize.dim("输出")} ${outputLabels.join(", ")}`);
|
||||
lines.push(parts.join(" "));
|
||||
}
|
||||
|
||||
const capLabels = intent.requiredCapabilities.map((cap) => CAPABILITY_LABELS[cap] ?? cap);
|
||||
if (capLabels.length) {
|
||||
lines.push(`${colorize.dim("所需能力")} ${capLabels.join(", ")}`);
|
||||
}
|
||||
|
||||
const budgetLabel = BUDGET_LABELS[intent.budget] ?? intent.budget;
|
||||
const qualityLabel = QUALITY_LABELS[intent.qualityPreference] ?? intent.qualityPreference;
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`${colorize.dim("预算倾向")} ${budgetLabel} ${colorize.dim("质量偏好")} ${qualityLabel}`,
|
||||
);
|
||||
|
||||
if (intent.segments?.length) {
|
||||
lines.push("");
|
||||
lines.push(colorize.dim("任务拆解"));
|
||||
for (const [idx, segment] of intent.segments.entries()) {
|
||||
const outMods = segment.outputModality.map((mod) => MODALITY_LABELS[mod] ?? mod).join(", ");
|
||||
lines.push(
|
||||
` ${colorize.dim(`${idx + 1}.`)} ${segment.step}${outMods ? colorize.dim(` → ${outMods}`) : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return boxen(lines.join("\n"), {
|
||||
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
||||
margin: { top: 0, bottom: 0, left: 1, right: 0 },
|
||||
borderColor: "cyan",
|
||||
borderStyle: "round",
|
||||
dimBorder: true,
|
||||
});
|
||||
}
|
||||
|
||||
const RECOMMEND_LABELS = ["最佳推荐", "次优选择", "备选参考"];
|
||||
|
||||
function renderCard(rec: RecommendedModel, index: number, colorize: ChalkInstance): string {
|
||||
const labelColors = [colorize.green.bold, colorize.blue.bold, colorize.magenta.bold];
|
||||
const colorFn = labelColors[index] ?? colorize.white.bold;
|
||||
const label = RECOMMEND_LABELS[index] ?? `推荐 #${index + 1}`;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(colorFn(`⬢ 推荐 #${index + 1} — ${label}`));
|
||||
lines.push("");
|
||||
lines.push(`${colorize.bold(rec.name)} ${colorize.dim(`(${rec.model})`)}`);
|
||||
lines.push("");
|
||||
lines.push(`${colorize.cyan("推荐理由")} ${rec.reason}`);
|
||||
|
||||
if (rec.highlights.length) {
|
||||
lines.push("");
|
||||
lines.push(
|
||||
rec.highlights.map((highlight) => colorize.bgGray.white(` ${highlight} `)).join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
const meta: string[] = [];
|
||||
if (rec.contextWindow) meta.push(`上下文 ${formatContextWindow(rec.contextWindow)}`);
|
||||
if (rec.maxOutputTokens) meta.push(`最大输出 ${formatContextWindow(rec.maxOutputTokens)}`);
|
||||
if (meta.length) {
|
||||
lines.push("");
|
||||
lines.push(colorize.dim(meta.join(" · ")));
|
||||
}
|
||||
|
||||
const docLink = buildDocLink(rec.docUrl);
|
||||
if (docLink) {
|
||||
lines.push("");
|
||||
lines.push(colorize.dim(`文档 ${docLink}`));
|
||||
}
|
||||
|
||||
return boxen(lines.join("\n"), {
|
||||
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
||||
margin: { top: 0, bottom: 0, left: 1, right: 0 },
|
||||
borderColor: "gray",
|
||||
borderStyle: "round",
|
||||
dimBorder: true,
|
||||
});
|
||||
}
|
||||
|
||||
function formatSingleResult(results: RecommendedModel[], noColor: boolean): string {
|
||||
const colorize = noColor ? new Chalk({ level: 0 }) : chalk;
|
||||
return results.map((rec, idx) => renderCard(rec, idx, colorize)).join("\n");
|
||||
}
|
||||
|
||||
function formatPipelineResult(summary: string, steps: PipelineStep[], noColor: boolean): string {
|
||||
const colorize = noColor ? new Chalk({ level: 0 }) : chalk;
|
||||
const lines: string[] = [];
|
||||
lines.push(` ${colorize.yellow.bold("⚡ 组合方案")} ${summary}`);
|
||||
|
||||
for (const [stepIdx, { step, recommendations, warnings }] of steps.entries()) {
|
||||
lines.push("");
|
||||
lines.push(colorize.bold(` ━━━ Step ${stepIdx + 1}: ${step} ━━━`));
|
||||
|
||||
if (warnings?.length) {
|
||||
for (const warning of warnings) {
|
||||
lines.push(` ${colorize.yellow("⚠")} ${colorize.yellow(warning)}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(recommendations.map((rec, idx) => renderCard(rec, idx, colorize)).join("\n"));
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatResult(result: RecommendResult, noColor: boolean): string {
|
||||
if (result.type === "pipeline") {
|
||||
return formatPipelineResult(result.summary, result.steps, noColor);
|
||||
}
|
||||
return formatSingleResult(result.recommendations, noColor);
|
||||
}
|
||||
|
||||
function isEmptyResult(result: RecommendResult): boolean {
|
||||
if (result.type === "pipeline") return result.steps.length === 0;
|
||||
return result.recommendations.length === 0;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
name: "advisor recommend",
|
||||
description:
|
||||
"Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking)",
|
||||
usage: "bl advisor recommend <prompt> [flags]",
|
||||
options: [
|
||||
{
|
||||
flag: "--message <text>",
|
||||
description: "Describe your requirements (alternative to positional prompt)",
|
||||
},
|
||||
{
|
||||
flag: "--dry-run",
|
||||
description: "Show intent analysis and candidate list without LLM ranking",
|
||||
},
|
||||
{
|
||||
flag: "--output <format>",
|
||||
description: "Output format: text (default in TTY), json, yaml",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
'bl advisor recommend --message "我要做一个能理解图片的客服机器人"',
|
||||
'bl advisor recommend --message "做一个Agent自动根据用户意图生成动画片"',
|
||||
'bl advisor recommend --message "法律合同审查,要求高精准度"',
|
||||
'bl advisor recommend --message "做一个低成本高并发的在线客服" --output json',
|
||||
'bl advisor recommend --message "长文本摘要" --dry-run',
|
||||
"bl advisor recommend # 交互式输入需求",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const positional = ((flags as Record<string, unknown>)._positional as string[]) ?? [];
|
||||
let userInput = (flags.message as string) || positional.join(" ");
|
||||
|
||||
if (!userInput.trim()) {
|
||||
if (isInteractive({ nonInteractive: config.nonInteractive })) {
|
||||
const hint = await promptText({ message: "描述你的需求:" });
|
||||
if (!hint) {
|
||||
process.stderr.write("已取消。\n");
|
||||
process.exit(1);
|
||||
}
|
||||
userInput = hint;
|
||||
} else {
|
||||
failIfMissing("message", 'bl advisor recommend "你的需求"');
|
||||
}
|
||||
}
|
||||
|
||||
const top = 3;
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
const modelsOptions: GetModelsOptions = {
|
||||
onPrepareStart: () => process.stderr.write("初始化中...\n"),
|
||||
};
|
||||
process.stderr.write("正在分析需求...\n");
|
||||
const [allModels, intent] = await Promise.all([
|
||||
getModels(config, modelsOptions),
|
||||
analyzeIntent(config, userInput),
|
||||
]);
|
||||
|
||||
if (intent.confidence === 0) {
|
||||
process.stderr.write("需求分析超时,使用默认参数继续...\n");
|
||||
} else {
|
||||
process.stderr.write("\n");
|
||||
}
|
||||
|
||||
// Stage 2: Candidate Recall (semantic recall, auto-builds embeddings on first run)
|
||||
const candidates = await recallSemantic(config, allModels, userInput, 50, intent);
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
userInput,
|
||||
intent,
|
||||
candidateCount: candidates.length,
|
||||
candidates: candidates.map(({ model, score }) => ({
|
||||
model: model.model,
|
||||
score,
|
||||
})),
|
||||
top,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stage 3: LLM Ranking
|
||||
const spinner = createSpinner("正在推荐最佳模型...");
|
||||
spinner.start();
|
||||
|
||||
const result = await rankModels(config, candidates, intent, userInput, top);
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (isEmptyResult(result)) {
|
||||
emitBare("暂无满足该需求的模型。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (format !== "text") {
|
||||
emitResult(result, format);
|
||||
return;
|
||||
}
|
||||
|
||||
emitBare(formatIntentSummary(intent, config.noColor));
|
||||
emitBare("");
|
||||
emitBare(formatResult(result, config.noColor));
|
||||
},
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import consoleCall from "./console/call.ts";
|
||||
import usageFree from "./usage/free.ts";
|
||||
import pipelineRun from "./pipeline/run.ts";
|
||||
import pipelineValidate from "./pipeline/validate.ts";
|
||||
import advisorRecommend from "./advisor/recommend.ts";
|
||||
|
||||
/** Command registry map (no dependency on registry.ts — safe for build-time import). */
|
||||
export const commands: Record<string, Command> = {
|
||||
@@ -72,5 +73,6 @@ export const commands: Record<string, Command> = {
|
||||
"config show": configShow,
|
||||
"config set": configSet,
|
||||
"config export-schema": configExportSchema,
|
||||
"advisor recommend": advisorRecommend,
|
||||
update: update,
|
||||
};
|
||||
|
||||
@@ -63,7 +63,8 @@ const NO_AUTH_SETUP = [
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
let argv = process.argv.slice(2);
|
||||
if (argv[0] === "--") argv = argv.slice(1);
|
||||
|
||||
if (argv.includes("--version") || argv.includes("-v")) {
|
||||
process.stdout.write(`bl ${CLI_VERSION}\n`);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
describe("e2e: advisor recommend", () => {
|
||||
test("advisor 分组展示子命令帮助且成功退出", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["advisor"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(`${stdout}\n${stderr}`).toMatch(/advisor|recommend/i);
|
||||
});
|
||||
|
||||
test("advisor recommend --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["advisor", "recommend", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/recommend|--message|dry-run/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend(DashScope)", () => {
|
||||
test("advisor recommend 缺少 --message 时打印帮助并退出 (0)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(`${stdout}\n${stderr}`).toMatch(/--message|Usage:/i);
|
||||
});
|
||||
|
||||
test("advisor recommend --dry-run 输出意图分析和候选列表", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
"我想做一个能理解图片的客服机器人",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
userInput?: string;
|
||||
intent?: { requiredCapabilities?: string[]; inputModality?: string[] };
|
||||
candidateCount?: number;
|
||||
candidates?: Array<{ model?: string; score?: number }>;
|
||||
}>(stdout);
|
||||
expect(data.userInput).toBe("我想做一个能理解图片的客服机器人");
|
||||
expect(data.intent?.requiredCapabilities).toContain("VU");
|
||||
expect(data.intent?.inputModality).toContain("Image");
|
||||
expect(data.candidateCount).toBeGreaterThan(0);
|
||||
expect(data.candidates?.[0]?.model).toBeDefined();
|
||||
expect(data.candidates?.[0]?.score).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
|
||||
test("advisor recommend 完整推荐流程返回结果", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--message",
|
||||
"低成本高并发的在线客服",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
type?: string;
|
||||
recommendations?: Array<{
|
||||
model?: string;
|
||||
name?: string;
|
||||
reason?: string;
|
||||
}>;
|
||||
}>(stdout);
|
||||
expect(data.type).toBe("single");
|
||||
expect(data.recommendations?.length).toBeGreaterThan(0);
|
||||
expect(data.recommendations?.[0]?.model).toBeDefined();
|
||||
expect(data.recommendations?.[0]?.reason).toBeDefined();
|
||||
}, 120_000);
|
||||
});
|
||||
Reference in New Issue
Block a user