|
| 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 | +}); |
0 commit comments