Skip to content

Commit d17fdd7

Browse files
Merge pull request #30 from modelstudioai/feat/model-recommend
add model recommend cli commend
2 parents 6a6ba36 + 7eae8b8 commit d17fdd7

28 files changed

Lines changed: 2532 additions & 3 deletions

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
3434
- **App calls** — Invoke agents and workflows already published on Aliyun Model Studio
3535
- **MCP integration** — Orchestrate Bailian MCP servers: list services, inspect tools, and invoke any tool directly from the terminal
3636
- **Web search** — Real-time internet retrieval for up-to-date, accurate answers
37+
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
3738
- **Console capabilities** — Browse Bailian apps (`app list`) and check free-tier quota (`usage free`)
3839
- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity
3940

@@ -98,6 +99,12 @@ bl image generate --prompt "A cat in a spacesuit" --out-dir ./images/
9899
# Generate a video from local image
99100
bl video generate --image ./cat.png --prompt "Make the cat move" --download cat.mp4
100101

102+
# Model recommendation — find the best model for your use case
103+
bl advisor recommend --message "I need a visual-understanding chatbot"
104+
105+
# Compare specific models
106+
bl advisor recommend --message "qwen-max vs deepseek-v3 for code generation"
107+
101108
# Browser login (required for console capability commands)
102109
bl auth login --console
103110

README_CN.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
3434
- **应用调用** — 调用已发布在阿里云百炼平台上的智能体与工作流应用
3535
- **MCP 集成** — 统一调度百炼 MCP 服务:列出服务、查看工具、直接在终端调用任意工具
3636
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
37+
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
3738
- **控制台能力** — 浏览百炼应用(`app list`),查询模型免费额度(`usage free`
3839
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
3940

@@ -93,6 +94,12 @@ bl image generate --prompt "一只穿太空服的猫在火星上" --out-dir ./im
9394
# 图生视频(本地文件自动上传)
9495
bl video generate --image ./cat.png --prompt "让画面中的猫动起来" --download cat.mp4
9596

97+
# 模型推荐 — 根据场景推荐最适合的模型
98+
bl advisor recommend --message "我要做一个能理解图片的客服机器人"
99+
100+
# 对比特定模型
101+
bl advisor recommend --message "qwen-max 和 deepseek-v3 哪个更适合做代码生成"
102+
96103
# 浏览器登录(控制台能力相关命令需要)
97104
bl auth login --console
98105

packages/cli/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@
4343
"check": "vp check"
4444
},
4545
"dependencies": {
46-
"bailian-cli-core": "workspace:*"
46+
"bailian-cli-core": "workspace:*",
47+
"boxen": "catalog:",
48+
"chalk": "catalog:"
4749
},
4850
"devDependencies": {
4951
"@clack/prompts": "^0.7.0",
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
import {
2+
analyzeIntent,
3+
buildDocLink,
4+
type Config,
5+
defineCommand,
6+
detectOutputFormat,
7+
type GetModelsOptions,
8+
type GlobalFlags,
9+
getModels,
10+
type IntentProfile,
11+
isInteractive,
12+
type PipelineStep,
13+
type RecommendedModel,
14+
type RecommendResult,
15+
rankModels,
16+
recallSemantic,
17+
} from "bailian-cli-core";
18+
import boxen from "boxen";
19+
import chalk, { Chalk, type ChalkInstance } from "chalk";
20+
import { emitBare, emitResult } from "../../output/output.ts";
21+
import { createSpinner } from "../../output/progress.ts";
22+
import { failIfMissing, promptText } from "../../output/prompt.ts";
23+
24+
function formatContextWindow(tokens: number): string {
25+
if (tokens >= 1_000_000)
26+
return `${(tokens / 1_000_000).toFixed(tokens % 1_000_000 === 0 ? 0 : 1)}M`;
27+
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(tokens % 1_000 === 0 ? 0 : 1)}K`;
28+
return String(tokens);
29+
}
30+
31+
const MODALITY_LABELS: Record<string, string> = {
32+
Text: "文本",
33+
Image: "图片",
34+
Video: "视频",
35+
Audio: "音频",
36+
};
37+
const CAPABILITY_LABELS: Record<string, string> = {
38+
TG: "文本生成",
39+
VU: "视觉理解",
40+
IG: "图像生成",
41+
VG: "视频生成",
42+
TTS: "语音合成",
43+
ASR: "语音识别",
44+
Reasoning: "推理",
45+
};
46+
const BUDGET_LABELS: Record<string, string> = {
47+
low: "低成本优先",
48+
medium: "适中",
49+
high: "高投入",
50+
};
51+
const QUALITY_LABELS: Record<string, string> = {
52+
flagship: "旗舰优先",
53+
balanced: "均衡",
54+
"cost-optimized": "性价比优先",
55+
};
56+
const PREFERENCE_MODE_LABELS: Record<string, string> = {
57+
scoped: "限定范围",
58+
comparison: "对比评估",
59+
alternative: "替代推荐",
60+
};
61+
62+
function formatIntentSummary(intent: IntentProfile, noColor: boolean): string {
63+
const colorize = noColor ? new Chalk({ level: 0 }) : chalk;
64+
65+
const lines: string[] = [];
66+
lines.push(colorize.cyan.bold("需求理解"));
67+
68+
if (intent.taskSummary) {
69+
lines.push("");
70+
lines.push(intent.taskSummary);
71+
}
72+
73+
if (intent.scenarioHints.length) {
74+
lines.push("");
75+
lines.push(`${colorize.dim("场景特征")} ${intent.scenarioHints.join(" · ")}`);
76+
}
77+
78+
const inputLabels = intent.inputModality.map((mod) => MODALITY_LABELS[mod] ?? mod);
79+
const outputLabels = intent.outputModality.map((mod) => MODALITY_LABELS[mod] ?? mod);
80+
if (inputLabels.length || outputLabels.length) {
81+
lines.push("");
82+
const parts: string[] = [];
83+
if (inputLabels.length) parts.push(`${colorize.dim("输入")} ${inputLabels.join(", ")}`);
84+
if (outputLabels.length) parts.push(`${colorize.dim("输出")} ${outputLabels.join(", ")}`);
85+
lines.push(parts.join(" "));
86+
}
87+
88+
const capLabels = intent.requiredCapabilities.map((cap) => CAPABILITY_LABELS[cap] ?? cap);
89+
if (capLabels.length) {
90+
lines.push(`${colorize.dim("所需能力")} ${capLabels.join(", ")}`);
91+
}
92+
93+
const budgetLabel = BUDGET_LABELS[intent.budget] ?? intent.budget;
94+
const qualityLabel = QUALITY_LABELS[intent.qualityPreference] ?? intent.qualityPreference;
95+
lines.push("");
96+
lines.push(
97+
`${colorize.dim("预算倾向")} ${budgetLabel} ${colorize.dim("质量偏好")} ${qualityLabel}`,
98+
);
99+
100+
const preference = intent.modelPreference;
101+
if (preference && preference.mode !== "unconstrained") {
102+
lines.push("");
103+
const modeLabel = PREFERENCE_MODE_LABELS[preference.mode] ?? preference.mode;
104+
const prefParts = [colorize.dim("推荐模式") + ` ${colorize.yellow(modeLabel)}`];
105+
if (preference.targets?.length) {
106+
prefParts.push(colorize.dim("目标") + ` ${preference.targets.join(", ")}`);
107+
}
108+
if (preference.excludes?.length) {
109+
prefParts.push(colorize.dim("排除") + ` ${preference.excludes.join(", ")}`);
110+
}
111+
lines.push(prefParts.join(" "));
112+
}
113+
114+
if (intent.segments?.length) {
115+
lines.push("");
116+
lines.push(colorize.dim("任务拆解"));
117+
for (const [idx, segment] of intent.segments.entries()) {
118+
const outMods = segment.outputModality.map((mod) => MODALITY_LABELS[mod] ?? mod).join(", ");
119+
lines.push(
120+
` ${colorize.dim(`${idx + 1}.`)} ${segment.step}${outMods ? colorize.dim(` → ${outMods}`) : ""}`,
121+
);
122+
}
123+
}
124+
125+
return boxen(lines.join("\n"), {
126+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
127+
margin: { top: 0, bottom: 0, left: 1, right: 0 },
128+
borderColor: "cyan",
129+
borderStyle: "round",
130+
dimBorder: true,
131+
});
132+
}
133+
134+
const RECOMMEND_LABELS = ["最佳推荐", "次优选择", "备选参考"];
135+
136+
function renderCard(rec: RecommendedModel, index: number, colorize: ChalkInstance): string {
137+
const labelColors = [colorize.green.bold, colorize.blue.bold, colorize.magenta.bold];
138+
const colorFn = labelColors[index] ?? colorize.white.bold;
139+
const label = RECOMMEND_LABELS[index] ?? `推荐 #${index + 1}`;
140+
141+
const lines: string[] = [];
142+
lines.push(colorFn(`⬢ 推荐 #${index + 1}${label}`));
143+
lines.push("");
144+
lines.push(`${colorize.bold(rec.name)} ${colorize.dim(`(${rec.model})`)}`);
145+
lines.push("");
146+
lines.push(`${colorize.cyan("推荐理由")} ${rec.reason}`);
147+
148+
if (rec.highlights.length) {
149+
lines.push("");
150+
lines.push(
151+
rec.highlights.map((highlight) => colorize.bgGray.white(` ${highlight} `)).join(" "),
152+
);
153+
}
154+
155+
const meta: string[] = [];
156+
if (rec.contextWindow) meta.push(`上下文 ${formatContextWindow(rec.contextWindow)}`);
157+
if (rec.maxOutputTokens) meta.push(`最大输出 ${formatContextWindow(rec.maxOutputTokens)}`);
158+
if (meta.length) {
159+
lines.push("");
160+
lines.push(colorize.dim(meta.join(" · ")));
161+
}
162+
163+
const docLink = buildDocLink(rec.docUrl);
164+
if (docLink) {
165+
lines.push("");
166+
lines.push(colorize.dim(`文档 ${docLink}`));
167+
}
168+
169+
return boxen(lines.join("\n"), {
170+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
171+
margin: { top: 0, bottom: 0, left: 1, right: 0 },
172+
borderColor: "gray",
173+
borderStyle: "round",
174+
dimBorder: true,
175+
});
176+
}
177+
178+
function formatSingleResult(results: RecommendedModel[], noColor: boolean): string {
179+
const colorize = noColor ? new Chalk({ level: 0 }) : chalk;
180+
return results.map((rec, idx) => renderCard(rec, idx, colorize)).join("\n");
181+
}
182+
183+
function formatPipelineResult(summary: string, steps: PipelineStep[], noColor: boolean): string {
184+
const colorize = noColor ? new Chalk({ level: 0 }) : chalk;
185+
const lines: string[] = [];
186+
lines.push(` ${colorize.yellow.bold("⚡ 组合方案")} ${summary}`);
187+
188+
for (const [stepIdx, { step, recommendations, warnings }] of steps.entries()) {
189+
lines.push("");
190+
lines.push(colorize.bold(` ━━━ Step ${stepIdx + 1}: ${step} ━━━`));
191+
192+
if (warnings?.length) {
193+
for (const warning of warnings) {
194+
lines.push(` ${colorize.yellow("⚠")} ${colorize.yellow(warning)}`);
195+
}
196+
}
197+
198+
lines.push("");
199+
lines.push(recommendations.map((rec, idx) => renderCard(rec, idx, colorize)).join("\n"));
200+
}
201+
202+
return lines.join("\n");
203+
}
204+
205+
function formatResult(result: RecommendResult, noColor: boolean): string {
206+
if (result.type === "pipeline") {
207+
return formatPipelineResult(result.summary, result.steps, noColor);
208+
}
209+
return formatSingleResult(result.recommendations, noColor);
210+
}
211+
212+
function isEmptyResult(result: RecommendResult): boolean {
213+
if (result.type === "pipeline") return result.steps.length === 0;
214+
return result.recommendations.length === 0;
215+
}
216+
217+
export default defineCommand({
218+
name: "advisor recommend",
219+
description:
220+
"Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking)",
221+
usage: "bl advisor recommend <prompt> [flags]",
222+
options: [
223+
{
224+
flag: "--message <text>",
225+
description: "Describe your requirements (alternative to positional prompt)",
226+
},
227+
{
228+
flag: "--dry-run",
229+
description: "Show intent analysis and candidate list without LLM ranking",
230+
},
231+
{
232+
flag: "--output <format>",
233+
description: "Output format: text (default in TTY), json, yaml",
234+
},
235+
],
236+
examples: [
237+
'bl advisor recommend --message "我要做一个能理解图片的客服机器人"',
238+
'bl advisor recommend --message "做一个Agent自动根据用户意图生成动画片"',
239+
'bl advisor recommend --message "法律合同审查,要求高精准度"',
240+
'bl advisor recommend --message "做一个低成本高并发的在线客服" --output json',
241+
'bl advisor recommend --message "长文本摘要" --dry-run',
242+
"bl advisor recommend # 交互式输入需求",
243+
],
244+
async run(config: Config, flags: GlobalFlags) {
245+
const positional = ((flags as Record<string, unknown>)._positional as string[]) ?? [];
246+
let userInput = (flags.message as string) || positional.join(" ");
247+
248+
if (!userInput.trim()) {
249+
if (isInteractive({ nonInteractive: config.nonInteractive })) {
250+
const hint = await promptText({ message: "描述你的需求:" });
251+
if (!hint) {
252+
process.stderr.write("已取消。\n");
253+
process.exit(1);
254+
}
255+
userInput = hint;
256+
} else {
257+
failIfMissing("message", 'bl advisor recommend "你的需求"');
258+
}
259+
}
260+
261+
const top = 3;
262+
const format = detectOutputFormat(config.output);
263+
264+
const modelsOptions: GetModelsOptions = {
265+
onPrepareStart: () => process.stderr.write("初始化中...\n"),
266+
};
267+
process.stderr.write("正在分析需求...\n");
268+
const [allModels, intent] = await Promise.all([
269+
getModels(config, modelsOptions),
270+
analyzeIntent(config, userInput),
271+
]);
272+
273+
if (intent.confidence === 0) {
274+
process.stderr.write("需求分析超时,使用默认参数继续...\n");
275+
} else {
276+
process.stderr.write("\n");
277+
}
278+
279+
// Stage 2: Candidate Recall (semantic recall, auto-builds embeddings on first run)
280+
const candidates = await recallSemantic(config, allModels, userInput, 50, intent);
281+
282+
if (config.dryRun) {
283+
emitResult(
284+
{
285+
userInput,
286+
intent,
287+
candidateCount: candidates.length,
288+
candidates: candidates.map(({ model, score }) => ({
289+
model: model.model,
290+
score,
291+
})),
292+
top,
293+
},
294+
format,
295+
);
296+
return;
297+
}
298+
299+
// Stage 3: LLM Ranking
300+
const spinner = createSpinner("正在推荐最佳模型...");
301+
spinner.start();
302+
303+
const result = await rankModels(config, candidates, intent, userInput, top);
304+
305+
spinner.stop();
306+
307+
if (isEmptyResult(result)) {
308+
emitBare("暂无满足该需求的模型。");
309+
return;
310+
}
311+
312+
if (format !== "text") {
313+
emitResult(result, format);
314+
return;
315+
}
316+
317+
emitBare(formatIntentSummary(intent, config.noColor));
318+
emitBare("");
319+
emitBare(formatResult(result, config.noColor));
320+
},
321+
});

packages/cli/src/commands/catalog.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import consoleCall from "./console/call.ts";
3838
import usageFree from "./usage/free.ts";
3939
import pipelineRun from "./pipeline/run.ts";
4040
import pipelineValidate from "./pipeline/validate.ts";
41+
import advisorRecommend from "./advisor/recommend.ts";
4142

4243
/** Command registry map (no dependency on registry.ts — safe for build-time import). */
4344
export const commands: Record<string, Command> = {
@@ -78,5 +79,6 @@ export const commands: Record<string, Command> = {
7879
"config show": configShow,
7980
"config set": configSet,
8081
"config export-schema": configExportSchema,
82+
"advisor recommend": advisorRecommend,
8183
update: update,
8284
};

packages/cli/src/main.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ const NO_AUTH_SETUP = [
6666
];
6767

6868
async function main() {
69-
const argv = process.argv.slice(2);
69+
let argv = process.argv.slice(2);
70+
if (argv[0] === "--") argv = argv.slice(1);
7071

7172
if (argv.includes("--version") || argv.includes("-v")) {
7273
process.stdout.write(`bl ${CLI_VERSION}\n`);

0 commit comments

Comments
 (0)