feat: model recommend beta version

This commit is contained in:
故璃
2026-06-05 13:56:24 +08:00
parent 14371a0647
commit a767bee41e
26 changed files with 2159 additions and 3 deletions
+3 -1
View File
@@ -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));
},
});
+2
View File
@@ -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,
};
+2 -1
View File
@@ -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);
});
+35
View File
@@ -0,0 +1,35 @@
import type { Config } from "../config/schema.ts";
import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
import { ApiSource } from "./sources/api.ts";
import { CatalogSource } from "./sources/catalog.ts";
import type { ModelSource } from "./sources/types.ts";
import type { ModelProfile } from "./types.ts";
export interface GetModelsOptions {
onPrepareStart?: () => void;
}
export async function getModels(
config: Config,
options?: GetModelsOptions,
): Promise<ModelProfile[]> {
const sources: ModelSource[] = [
new CatalogSource({ onPrepareStart: options?.onPrepareStart }),
new ApiSource(config),
];
for (const source of sources) {
if (source.available()) {
const models = await source.load();
if (models.length > 0) return models;
}
}
// CatalogSource not available → trigger install + load
const catalog = sources[0] as CatalogSource;
const models = await catalog.load();
if (models.length > 0) return models;
throw new BailianError("No model data available.", ExitCode.GENERAL);
}
@@ -0,0 +1,16 @@
import type { IntentProfile } from "../types.ts";
import { Budgets, Capabilities, Complexities, ContextNeeds, QualityPreferences } from "../types.ts";
export const DEFAULT_INTENT: IntentProfile = {
complexity: Complexities.Single,
taskSummary: "",
scenarioHints: [],
inputModality: [],
outputModality: [],
requiredCapabilities: [Capabilities.TG],
requiredFeatures: [],
budget: Budgets.Medium,
contextNeed: ContextNeeds.Standard,
qualityPreference: QualityPreferences.Balanced,
confidence: 0,
};
@@ -0,0 +1,18 @@
export { DEFAULT_INTENT } from "./defaults.ts";
export {
INTENT_MODEL,
INTENT_SYSTEM_PROMPT,
PIPELINE_SYSTEM_PROMPT,
RANKING_MODEL,
RANKING_MODEL_FAST,
SINGLE_SYSTEM_PROMPT,
} from "./prompts.ts";
export {
CONTEXT_THRESHOLDS,
FALLBACK_THRESHOLD,
GENERATION_CAPS,
MAX_CANDIDATES,
MIN_CANDIDATES,
SNAPSHOT_DATE_RE,
TEXT_CAPS,
} from "./scoring.ts";
@@ -0,0 +1,143 @@
export const INTENT_MODEL = "qwen-turbo";
export const RANKING_MODEL = "qwen3.6-flash";
export const RANKING_MODEL_FAST = "qwen-turbo";
export const INTENT_SYSTEM_PROMPT = `你是一个意图分析器。根据用户的需求描述,先理解用户场景,再提取结构化信息。
## 分析步骤
1. 用一句话总结用户的核心需求(taskSummary),要体现具体场景而非泛泛描述
2. 推断场景特征(scenarioHints),例如:["需要低延迟","面向C端用户","高并发","对话式交互","离线批处理","需要精准度"]
3. 基于场景特征推断 budget 和 qualityPreference
- 只在用户明确表达或场景强烈暗示时偏离默认值
- 用户明确说"低成本"、"便宜"、"省钱" → budget:"low"
- 用户明确说"最好的"、"高精度"、"不计成本" → qualityPreference:"flagship"
- 场景本身有强约束时才推断:如"日均百万请求的客服" → budget:"low"(高并发=成本敏感)
- 其他情况保持 budget:"medium", qualityPreference:"balanced"
4. 提取模态、能力、特性等结构化字段
## 示例
用户: "做一个低成本高并发的在线客服"
→ budget:"low", qualityPreference:"cost-optimized"(用户明确说了低成本)
用户: "法律合同审查,要求高精准度"
→ budget:"medium", qualityPreference:"flagship"(用户明确要求高精准度,但没提预算)
用户: "我要做一个能理解图片的客服机器人"
→ budget:"medium", qualityPreference:"balanced"(用户没提成本和质量要求,不过度推断)
用户: "帮我选一个写代码的模型"
→ budget:"medium", qualityPreference:"balanced"(通用需求,无明确倾向)
用户: "预算有限,做个简单的文本摘要功能"
→ budget:"low", qualityPreference:"cost-optimized"(用户说了预算有限)
用户: "企业级知识库问答,准确率是第一优先级"
→ budget:"high", qualityPreference:"flagship"(企业级+准确率第一=愿投入高成本)
用户: "个人学习项目,试试AI生成图片"
→ budget:"low", qualityPreference:"cost-optimized"(个人学习=成本敏感)
用户: "做一个Agent自动根据用户意图生成动画片"
→ budget:"medium", qualityPreference:"balanced"(复杂pipeline,但没明确成本/质量约束)
## 输出字段
- taskSummary: 一句话场景理解(必须具体,禁止"用户想用AI做某事"这种废话)
- scenarioHints: 推断的场景特征数组
- complexity: "single"(单一模型可完成)或 "pipeline"(需要多个模型协同)
- segments: 仅 pipeline 时填写,每步包含 step/inputModality/outputModality/requiredCapabilities。
- step 必须是一句话描述该步骤在用户任务中解决的具体问题,例如"解析天气预报数据,生成适合视频制作的场景描述文本",禁止用编号或泛化的模态标签
- segments 必须形成模态链路:每步的 inputModality 应包含上一步的 outputModality,确保上下游数据可以衔接
- inputModality: 用户输入涉及的模态 ["Text","Image","Video","Audio"]
- outputModality: 期望输出的模态
- requiredCapabilities: 需要的能力。可选代码(必须严格使用,不要自创):
TG=文本生成, Reasoning=推理, VU=视觉理解, IG=图像生成, VG=视频生成,
TTS=语音合成, ASR=语音识别, Realtime-ASR=实时语音识别,
Realtime-Text-to-Speech=实时语音合成, Realtime-Audio-Translate=实时音频翻译,
Realtime-Omni=实时全模态, Multimodal-Omni=全模态, ME=多模态嵌入,
TR=翻译, 3D-generation=3D生成
- requiredFeatures: 需要的特性 (function-calling, web-search, structured-outputs, prefix-completion)
- budget: "low"/"medium"/"high"(基于场景推断,不要默认 medium)
- contextNeed: "standard"/"large"/"extra-large"
- qualityPreference: "flagship"/"balanced"/"cost-optimized"(基于场景推断,不要默认 balanced)
只输出 JSON,不要有其他文字。`;
export const SINGLE_SYSTEM_PROMPT = `你是阿里云百炼平台的模型推荐顾问。从以下候选模型中选出最佳推荐。
## 背景
系统已根据用户意图预筛选了候选模型,你只需从中精选并排序。
意图分析中包含 budget 和 qualityPreference 字段,这代表了用户的实际需求层次。
## 推荐策略
推荐 3 个不同档次的模型,但排序必须反映用户的真实需求:
- 推荐 #1(最佳推荐):根据 budget 和 qualityPreference 判断哪个档次最适合用户,把那个档次的最佳模型放在第一位
- 推荐 #2(次优选择):另一个档次中值得考虑的模型,说明与 #1 相比的 tradeoff
- 推荐 #3(备选参考):第三个视角的选择,说明适用场景差异
关键原则:
- budget:"low" / qualityPreference:"cost-optimized" → 推荐 #1 应该是性价比最高的模型,而非旗舰模型
- budget:"high" / qualityPreference:"flagship" → 推荐 #1 应该是能力最强的旗舰模型
- budget:"medium" / qualityPreference:"balanced" → 推荐 #1 应该是综合匹配度最高的模型,不预设档次偏好
每个推荐都必须说明该模型为什么适合(或作为备选为什么值得考虑),理由必须关联用户的具体需求。
## 规则
- 只能推荐候选列表中的模型,严禁推荐列表外的模型
- 严禁使用泛泛的推荐理由(如"性能强大"、"综合能力好"、"效果不错"),每条 reason 必须说明该模型解决用户任务中的什么具体问题
- 三个推荐的理由不允许雷同,每个必须从不同维度论证
- 有定价信息时:结合 budget 字段权衡,把最符合用户预算的放在最前面
- 有家族信息时:避免推荐同一家族的多个模型,优先推荐稳定版本
- 有版本标签时:优先推荐 stable/latest 版本,除非用户明确需要特定版本
- 没有增强字段的模型:按能力和描述排序即可,不因缺少信息而降权
- 如果没有合适的模型,返回空数组
- 如果你认为该需求实际需要多模型协同完成(pipeline),可以输出 type:"pipeline" 格式
- 输出严格 JSON,不要输出其他内容
## 输出格式
单一任务:
{"type":"single","recommendations":[{"model":"模型ID","reason":"推荐理由","highlights":["亮点"]}]}
复合任务(仅当你确信需要多模型协同时):
{"type":"pipeline","summary":"一句话方案描述","steps":[{"step":"步骤描述","recommendations":[{"model":"模型ID","reason":"选择理由","highlights":["亮点"]}]}]}`;
export const PIPELINE_SYSTEM_PROMPT = `你是阿里云百炼平台的模型推荐顾问。用户需求已被拆解为多步骤流水线,请为每步选出最佳模型。
## 背景
系统已根据各步骤需求预筛选了候选模型。
意图分析中包含 budget 和 qualityPreference 字段,这代表了用户的实际需求层次。
## 推荐策略
每步推荐 3 个不同档次的模型,但排序必须反映用户的真实需求:
- 推荐 #1(最佳推荐):根据 budget 和 qualityPreference 判断哪个档次最适合用户,把那个档次的最佳模型放在第一位
- 推荐 #2(次优选择):另一个档次中值得考虑的模型,说明 tradeoff
- 推荐 #3(备选参考):第三个视角的选择,说明适用场景差异
关键原则:
- budget:"low" / qualityPreference:"cost-optimized" → 推荐 #1 应该是性价比最高的模型
- budget:"high" / qualityPreference:"flagship" → 推荐 #1 应该是能力最强的旗舰模型
- budget:"medium" / qualityPreference:"balanced" → 推荐 #1 应该是综合匹配度最高的模型
## 规则
- 只能推荐候选列表中的模型
- 每步推荐多个模型,按优先级排序,每个推荐给出简短理由和关键亮点
- step 字段必须用一句话描述该步骤在用户任务中解决的具体问题,禁止用编号或泛化的模态标签(如"输出: Text")
- 严禁使用泛泛的推荐理由,每条 reason 必须说明该模型在这一步解决用户任务中的什么具体问题
- 有定价信息时:结合 budget 字段权衡,把最符合用户预算的放在最前面
- 有家族信息时:避免在相邻步骤使用同一家族的不同规格模型,除非确实需要
- 没有增强字段的模型:按能力和描述排序即可,不因缺少信息而降权
- 相邻步骤的模型必须模态兼容:上一步模型的输出模态必须被下一步模型的输入模态支持
- 如果你认为该需求其实单模型可以完成,可以输出 type:"single" 格式
- 输出严格 JSON
## 输出格式
{"type":"pipeline","summary":"一句话方案描述","steps":[{"step":"该步骤在用户任务中解决的具体问题","recommendations":[{"model":"模型ID","reason":"该模型如何解决这一步的具体问题","highlights":["亮点"]}]}]}
或者(如果你认为单模型即可):
{"type":"single","recommendations":[{"model":"模型ID","reason":"推荐理由","highlights":["亮点"]}]}`;
@@ -0,0 +1,32 @@
import { Capabilities } from "../types.ts";
import type { Capability, ContextNeed } from "../types.ts";
export const MAX_CANDIDATES = 50;
export const MIN_CANDIDATES = 10;
export const FALLBACK_THRESHOLD = 5;
export const FAMILY_CANDIDATE_CAP = 3;
export const SNAPSHOT_DATE_RE = /-\d{4}-\d{2}-\d{2}$/;
export const GENERATION_CAPS: ReadonlySet<Capability> = new Set<Capability>([
Capabilities.IG,
Capabilities.VG,
Capabilities.TTS,
Capabilities.RealtimeTTS,
Capabilities.ThreeDGeneration,
]);
export const TEXT_CAPS: ReadonlySet<Capability> = new Set<Capability>([
Capabilities.TG,
Capabilities.Reasoning,
Capabilities.ASR,
Capabilities.RealtimeASR,
Capabilities.RealtimeAudioTranslate,
Capabilities.TR,
Capabilities.ME,
]);
export const CONTEXT_THRESHOLDS: Record<ContextNeed, number> = {
standard: 0,
large: 32000,
"extra-large": 128000,
};
+195
View File
@@ -0,0 +1,195 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { getConfigDir } from "../config/paths.ts";
import type { Config } from "../config/schema.ts";
import { requestJson } from "../client/http.ts";
import type { ModelProfile } from "./types.ts";
const EMBEDDING_MODEL = "text-embedding-v4";
const DIMENSIONS = 512;
const EMBEDDINGS_FILE = "models-embeddings.json";
const BATCH_SIZE = 10;
export interface ModelEmbedding {
id: string;
vector: number[];
}
export interface EmbeddingsData {
model: string;
dimensions: number;
count: number;
items: ModelEmbedding[];
}
function skillDataDir(): string {
return join(getConfigDir(), "skills/doc-llm-wiki");
}
function embeddingsPath(): string {
return join(skillDataDir(), EMBEDDINGS_FILE);
}
export function loadModelEmbeddings(): ModelEmbedding[] | null {
const path = embeddingsPath();
if (!existsSync(path)) return null;
try {
const raw = JSON.parse(readFileSync(path, "utf-8")) as EmbeddingsData;
return raw.items;
} catch {
return null;
}
}
export async function embedQuery(config: Config, text: string): Promise<number[]> {
const url = `${config.baseUrl}/compatible-mode/v1/embeddings`;
const body = {
model: EMBEDDING_MODEL,
input: [text],
dimensions: DIMENSIONS,
encoding_format: "float",
};
const response = await requestJson<{
data: { index: number; embedding: number[] }[];
}>(config, { url, method: "POST", body, timeout: 10000 });
return response.data[0].embedding;
}
async function embedBatch(config: Config, texts: string[]): Promise<number[][]> {
const url = `${config.baseUrl}/compatible-mode/v1/embeddings`;
const body = {
model: EMBEDDING_MODEL,
input: texts,
dimensions: DIMENSIONS,
encoding_format: "float",
};
const response = await requestJson<{
data: { index: number; embedding: number[] }[];
}>(config, { url, method: "POST", body, timeout: 30000 });
return response.data
.sort((left, right) => left.index - right.index)
.map((item) => item.embedding);
}
const CAPABILITY_LABELS: Record<string, string> = {
TG: "文本生成",
Reasoning: "推理",
VU: "视觉理解",
IG: "图像生成",
VG: "视频生成",
TTS: "语音合成",
ASR: "语音识别",
};
const MODALITY_LABELS: Record<string, string> = {
Text: "文本",
Image: "图片/图像",
Video: "视频",
Audio: "音频/语音",
};
interface GroupData {
description?: string;
items?: { model: string; description?: string }[];
}
function loadGroupDescriptions(): Map<string, string> {
const groupsDir = join(skillDataDir(), "groups");
const map = new Map<string, string>();
if (!existsSync(groupsDir)) return map;
for (const file of readdirSync(groupsDir).filter((name) => name.endsWith(".json"))) {
try {
const data = JSON.parse(readFileSync(join(groupsDir, file), "utf-8")) as GroupData;
const groupDesc = data.description ?? "";
if (data.items) {
for (const item of data.items) {
map.set(item.model, item.description || groupDesc);
}
}
} catch {
// skip
}
}
return map;
}
function buildModelText(model: ModelProfile, descriptions: Map<string, string>): string {
const caps = (model.capabilities ?? []).map((cap) => CAPABILITY_LABELS[cap] ?? cap).join(", ");
const description =
descriptions.get(model.model) || model.shortDescription || model.description || "";
const inputMods = (model.inferenceMetadata?.request_modality ?? [])
.map((mod) => MODALITY_LABELS[mod] ?? mod)
.join(", ");
const outputMods = (model.inferenceMetadata?.response_modality ?? [])
.map((mod) => MODALITY_LABELS[mod] ?? mod)
.join(", ");
const parts = [
model.name,
model.model,
description,
caps ? `能力: ${caps}` : "",
inputMods ? `输入: ${inputMods}` : "",
outputMods ? `输出: ${outputMods}` : "",
model.features?.length ? `特性: ${model.features.join(", ")}` : "",
model.familyName || "",
model.category ? `定位: ${model.category}` : "",
].filter(Boolean);
return parts.join(" | ");
}
export async function buildAndCacheEmbeddings(
config: Config,
models: ModelProfile[],
): Promise<ModelEmbedding[]> {
const descriptions = loadGroupDescriptions();
const texts = models.map((profile) => buildModelText(profile, descriptions));
const allVectors: number[][] = [];
for (let batchStart = 0; batchStart < texts.length; batchStart += BATCH_SIZE) {
const batch = texts.slice(batchStart, batchStart + BATCH_SIZE);
const vectors = await embedBatch(config, batch);
allVectors.push(...vectors);
}
const items: ModelEmbedding[] = models.map((profile, idx) => ({
id: profile.model,
vector: allVectors[idx],
}));
const output: EmbeddingsData = {
model: EMBEDDING_MODEL,
dimensions: DIMENSIONS,
count: items.length,
items,
};
const outPath = embeddingsPath();
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, JSON.stringify(output));
return items;
}
export function cosineSimilarity(vecA: number[], vecB: number[]): number {
let dot = 0;
let normA = 0;
let normB = 0;
for (let idx = 0; idx < vecA.length; idx++) {
dot += vecA[idx] * vecB[idx];
normA += vecA[idx] * vecA[idx];
normB += vecB[idx] * vecB[idx];
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom === 0 ? 0 : dot / denom;
}
export { DIMENSIONS, EMBEDDING_MODEL };
+39
View File
@@ -0,0 +1,39 @@
export type { GetModelsOptions } from "./cache.ts";
export { getModels } from "./cache.ts";
export { analyzeIntent } from "./intent.ts";
export type { ScoredCandidate } from "./recall.ts";
export { recallCandidates } from "./recall.ts";
export { recallSemantic, isSemanticAvailable } from "./recall-semantic.ts";
export type { RecommendOptions } from "./recommend.ts";
export { buildDocLink, rankModels } from "./recommend.ts";
export type { ModelSource } from "./sources/types.ts";
export type {
Budget,
Capability,
Complexity,
ContextNeed,
Feature,
IntentProfile,
IntentSegment,
Modality,
ModelCategory,
ModelPrice,
ModelProfile,
PipelineResult,
PipelineStep,
QpmLimit,
QualityPreference,
RecommendedModel,
RecommendResult,
SingleResult,
} from "./types.ts";
export {
Budgets,
Capabilities,
Complexities,
ContextNeeds,
Features,
Modalities,
ModelCategories,
QualityPreferences,
} from "./types.ts";
+65
View File
@@ -0,0 +1,65 @@
import { requestJson } from "../client/http.ts";
import { chatEndpoint } from "../client/endpoints.ts";
import type { Config } from "../config/schema.ts";
import type { ChatResponse } from "../types/api.ts";
import { Complexities } from "./types.ts";
import type { IntentProfile } from "./types.ts";
import { INTENT_MODEL, INTENT_SYSTEM_PROMPT } from "./constants/prompts.ts";
import { DEFAULT_INTENT } from "./constants/defaults.ts";
export async function analyzeIntent(config: Config, input: string): Promise<IntentProfile> {
const url = chatEndpoint(config.baseUrl);
const body = {
model: INTENT_MODEL,
messages: [
{ role: "system", content: INTENT_SYSTEM_PROMPT },
{ role: "user", content: input },
],
max_tokens: 1024,
temperature: 0,
};
try {
const response = await requestJson<ChatResponse>(config, {
url,
method: "POST",
body,
timeout: 5000,
});
const content = response.choices?.[0]?.message?.content ?? "";
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (!jsonMatch) return DEFAULT_INTENT;
const parsed = JSON.parse(jsonMatch[0]);
return {
complexity:
parsed.complexity === Complexities.Pipeline ? Complexities.Pipeline : Complexities.Single,
taskSummary: typeof parsed.taskSummary === "string" ? parsed.taskSummary : "",
scenarioHints: Array.isArray(parsed.scenarioHints) ? parsed.scenarioHints : [],
segments: Array.isArray(parsed.segments)
? parsed.segments.map((seg: Record<string, unknown>) => ({
step: (seg.step as string) ?? "",
inputModality: Array.isArray(seg.inputModality) ? seg.inputModality : [],
outputModality: Array.isArray(seg.outputModality) ? seg.outputModality : [],
requiredCapabilities: Array.isArray(seg.requiredCapabilities)
? seg.requiredCapabilities
: [],
}))
: undefined,
inputModality: Array.isArray(parsed.inputModality) ? parsed.inputModality : [],
outputModality: Array.isArray(parsed.outputModality) ? parsed.outputModality : [],
requiredCapabilities: Array.isArray(parsed.requiredCapabilities)
? parsed.requiredCapabilities
: [],
requiredFeatures: Array.isArray(parsed.requiredFeatures) ? parsed.requiredFeatures : [],
budget: parsed.budget ?? DEFAULT_INTENT.budget,
contextNeed: parsed.contextNeed ?? DEFAULT_INTENT.contextNeed,
qualityPreference: parsed.qualityPreference ?? DEFAULT_INTENT.qualityPreference,
confidence: 1,
};
} catch {
return DEFAULT_INTENT;
}
}
@@ -0,0 +1,107 @@
import type { Config } from "../config/schema.ts";
import type { IntentProfile, IntentSegment, ModelProfile } from "./types.ts";
import { Complexities } from "./types.ts";
import {
buildAndCacheEmbeddings,
cosineSimilarity,
embedQuery,
loadModelEmbeddings,
type ModelEmbedding,
} from "./embedding.ts";
import type { ScoredCandidate } from "./recall.ts";
let cachedEmbeddings: ModelEmbedding[] | null = null;
function getEmbeddings(): ModelEmbedding[] | null {
if (cachedEmbeddings === null) {
cachedEmbeddings = loadModelEmbeddings();
}
return cachedEmbeddings;
}
export function isSemanticAvailable(): boolean {
return getEmbeddings() !== null;
}
function matchesSegment(model: ModelProfile, segment: IntentSegment): boolean {
const modelIn = model.inferenceMetadata?.request_modality ?? [];
const modelOut = model.inferenceMetadata?.response_modality ?? [];
const inOk =
segment.inputModality.length === 0 ||
segment.inputModality.some((mod) => modelIn.includes(mod));
const outOk =
segment.outputModality.length === 0 ||
segment.outputModality.some((mod) => modelOut.includes(mod));
if (!inOk || !outOk) return false;
if (segment.requiredCapabilities.length === 0) return true;
return segment.requiredCapabilities.some((cap) => model.capabilities.includes(cap));
}
function rankByEmbedding(
embeddings: ModelEmbedding[],
queryVector: number[],
allowedIds: Set<string>,
topK: number,
): { id: string; similarity: number }[] {
return embeddings
.filter((item) => allowedIds.has(item.id))
.map((item) => ({ id: item.id, similarity: cosineSimilarity(queryVector, item.vector) }))
.sort((left, right) => right.similarity - left.similarity)
.slice(0, topK);
}
export async function recallSemantic(
config: Config,
models: ModelProfile[],
query: string,
topK: number,
intent?: IntentProfile,
): Promise<ScoredCandidate[]> {
let embeddings = getEmbeddings();
if (!embeddings) {
embeddings = await buildAndCacheEmbeddings(config, models);
cachedEmbeddings = embeddings;
}
const queryVector = await embedQuery(config, query);
const modelMap = new Map(models.map((profile) => [profile.model, profile]));
if (intent?.complexity === Complexities.Pipeline && intent.segments?.length) {
const seen = new Set<string>();
const results: ScoredCandidate[] = [];
const perSegment = Math.max(5, Math.ceil(topK / intent.segments.length));
for (const segment of intent.segments) {
const matched = models.filter((profile) => matchesSegment(profile, segment));
const allowedIds = new Set(
matched.filter((profile) => !seen.has(profile.model)).map((profile) => profile.model),
);
if (allowedIds.size === 0) continue;
const scored = rankByEmbedding(embeddings, queryVector, allowedIds, perSegment);
for (const { id, similarity } of scored) {
const model = modelMap.get(id);
if (model && !seen.has(id)) {
results.push({ model, score: similarity });
seen.add(id);
}
}
}
return results;
}
const allIds = new Set(models.map((profile) => profile.model));
const scored = rankByEmbedding(embeddings, queryVector, allIds, topK);
const results: ScoredCandidate[] = [];
for (const { id, similarity } of scored) {
const model = modelMap.get(id);
if (model) {
results.push({ model, score: similarity });
}
}
return results;
}
+264
View File
@@ -0,0 +1,264 @@
import { Complexities, ContextNeeds, QualityPreferences, ModelCategories } from "./types.ts";
import type { ModelProfile, IntentProfile, IntentSegment, Capability, Modality } from "./types.ts";
import {
MAX_CANDIDATES,
MIN_CANDIDATES,
FALLBACK_THRESHOLD,
FAMILY_CANDIDATE_CAP,
SNAPSHOT_DATE_RE,
GENERATION_CAPS,
TEXT_CAPS,
CONTEXT_THRESHOLDS,
} from "./constants/scoring.ts";
export interface ScoredCandidate {
model: ModelProfile;
score: number;
}
function hasMultiDomainCapabilities(caps: Capability[]): boolean {
let hasGen = false;
let hasText = false;
for (const cap of caps) {
if (GENERATION_CAPS.has(cap)) hasGen = true;
if (TEXT_CAPS.has(cap)) hasText = true;
}
return hasGen && hasText;
}
function deduplicateSnapshots(models: ModelProfile[]): ModelProfile[] {
const mainModels = new Set(models.map(({ model }) => model));
return models.filter(({ model }) => {
const base = model.replace(SNAPSHOT_DATE_RE, "");
if (base === model) return true;
return !mainModels.has(base);
});
}
function matchesModality(
model: ModelProfile,
inputModality: Modality[],
outputModality: Modality[],
): boolean {
const modelInput = model.inferenceMetadata?.request_modality ?? [];
const modelOutput = model.inferenceMetadata?.response_modality ?? [];
if (inputModality.length > 0) {
if (!inputModality.some((mod) => modelInput.includes(mod))) return false;
}
if (outputModality.length > 0) {
if (!outputModality.some((mod) => modelOutput.includes(mod))) return false;
}
return true;
}
function matchesUpstream(model: ModelProfile, upstreamOutput: Modality[]): boolean {
if (upstreamOutput.length === 0) return true;
const accepts = model.inferenceMetadata?.request_modality ?? [];
return upstreamOutput.some((mod) => accepts.includes(mod));
}
function scoreModel(model: ModelProfile, intent: IntentProfile): number {
const { requiredCapabilities, requiredFeatures, contextNeed, qualityPreference } = intent;
const { capabilities, features, contextWindow, category } = model;
let score = 0;
for (const cap of requiredCapabilities) {
if (capabilities.includes(cap)) score += 10;
}
for (const feat of requiredFeatures) {
if (features.includes(feat)) score += 5;
}
const ctxThreshold = CONTEXT_THRESHOLDS[contextNeed];
if (ctxThreshold > 0 && (contextWindow ?? 0) >= ctxThreshold) {
score += 8;
}
if (qualityPreference === QualityPreferences.Flagship && category === ModelCategories.Flagship) {
score += 15;
} else if (
qualityPreference === QualityPreferences.CostOptimized &&
category === ModelCategories.CostOptimized
) {
score += 15;
} else if (qualityPreference === QualityPreferences.Balanced) {
if (category === ModelCategories.Flagship) score += 5;
}
return score;
}
function scoreAndRank(
models: ModelProfile[],
intent: IntentProfile,
limit: number,
): ScoredCandidate[] {
return models
.map((model) => ({ model, score: scoreModel(model, intent) }))
.sort((left, right) => right.score - left.score)
.slice(0, limit);
}
function candidateIds(candidates: ScoredCandidate[]): Set<string> {
return new Set(candidates.map(({ model }) => model.model));
}
function capByFamily(candidates: ScoredCandidate[], cap: number): ScoredCandidate[] {
const counts = new Map<string, number>();
const kept: ScoredCandidate[] = [];
const overflow: ScoredCandidate[] = [];
for (const candidate of candidates) {
const family = candidate.model.family;
if (!family) {
kept.push(candidate);
continue;
}
const cur = counts.get(family) ?? 0;
if (cur < cap) {
kept.push(candidate);
counts.set(family, cur + 1);
} else {
overflow.push(candidate);
}
}
if (kept.length >= MIN_CANDIDATES) return kept;
return [...kept, ...overflow.slice(0, MIN_CANDIDATES - kept.length)];
}
function deduplicateCandidates(
candidates: ScoredCandidate[],
excludeIds: ReadonlySet<string>,
): ScoredCandidate[] {
const seen = new Set(excludeIds);
return candidates.filter((candidate) => {
if (seen.has(candidate.model.model)) return false;
seen.add(candidate.model.model);
return true;
});
}
function computeRemaining(
models: ModelProfile[],
intent: IntentProfile,
excludeIds: ReadonlySet<string>,
): ScoredCandidate[] {
if (excludeIds.size >= MIN_CANDIDATES) return [];
return scoreAndRank(
models.filter(({ model }) => !excludeIds.has(model)),
intent,
MIN_CANDIDATES - excludeIds.size,
);
}
function recallForSegment(
models: ModelProfile[],
segment: IntentSegment,
upstreamOutput: Modality[],
budget: IntentProfile["budget"],
qualityPreference: IntentProfile["qualityPreference"],
): ScoredCandidate[] {
const { inputModality, outputModality, requiredCapabilities } = segment;
const segmentIntent: IntentProfile = {
complexity: Complexities.Single,
taskSummary: "",
scenarioHints: [],
inputModality,
outputModality,
requiredCapabilities,
requiredFeatures: [],
budget,
contextNeed: ContextNeeds.Standard,
qualityPreference,
confidence: 1,
};
let candidates = models.filter(
(profile) =>
matchesModality(profile, inputModality, outputModality) &&
matchesUpstream(profile, upstreamOutput),
);
if (candidates.length < FALLBACK_THRESHOLD) {
candidates = models.filter((profile) =>
matchesModality(profile, inputModality, outputModality),
);
}
if (candidates.length < FALLBACK_THRESHOLD) {
candidates = models;
}
return scoreAndRank(candidates, segmentIntent, 5);
}
export function recallCandidates(models: ModelProfile[], intent: IntentProfile): ScoredCandidate[] {
models = deduplicateSnapshots(models);
let result: ScoredCandidate[];
if (intent.complexity === Complexities.Pipeline && intent.segments?.length) {
let results: ScoredCandidate[] = [];
for (const [segIdx, segment] of intent.segments.entries()) {
const upstreamOutput = segIdx === 0 ? [] : intent.segments[segIdx - 1].outputModality;
const segCandidates = recallForSegment(
models,
segment,
upstreamOutput,
intent.budget,
intent.qualityPreference,
);
const unique = deduplicateCandidates(segCandidates, candidateIds(results));
results = [...results, ...unique];
}
const remaining = computeRemaining(models, intent, candidateIds(results));
result = [...results, ...remaining];
} else if (hasMultiDomainCapabilities(intent.requiredCapabilities)) {
result = recallCrossDomain(models, intent);
} else {
let hardFiltered = models.filter((profile) =>
matchesModality(profile, intent.inputModality, intent.outputModality),
);
if (hardFiltered.length < FALLBACK_THRESHOLD) {
hardFiltered = models;
}
result = scoreAndRank(hardFiltered, intent, MAX_CANDIDATES);
}
return capByFamily(result, FAMILY_CANDIDATE_CAP);
}
function recallCrossDomain(models: ModelProfile[], intent: IntentProfile): ScoredCandidate[] {
const perDomain = Math.ceil(MAX_CANDIDATES / 2);
const genCaps = intent.requiredCapabilities.filter((cap) => GENERATION_CAPS.has(cap));
const textCaps = intent.requiredCapabilities.filter((cap) => TEXT_CAPS.has(cap));
let results: ScoredCandidate[] = [];
if (genCaps.length > 0) {
const genModels = models.filter((profile) =>
genCaps.some((cap) => profile.capabilities.includes(cap)),
);
results = scoreAndRank(genModels, intent, perDomain);
}
if (textCaps.length > 0) {
const excludeIds = candidateIds(results);
const textIntent: IntentProfile = { ...intent, requiredCapabilities: textCaps };
const textModels = models.filter(
(profile) =>
!excludeIds.has(profile.model) &&
textCaps.some((cap) => profile.capabilities.includes(cap)),
);
results = [...results, ...scoreAndRank(textModels, textIntent, perDomain)];
}
const remaining = computeRemaining(models, intent, candidateIds(results));
return [...results, ...remaining];
}
+288
View File
@@ -0,0 +1,288 @@
import { chatEndpoint } from "../client/endpoints.ts";
import { request, requestJson } from "../client/http.ts";
import { parseSSE } from "../client/stream.ts";
import type { Config } from "../config/schema.ts";
import type { ChatResponse, StreamChunk } from "../types/api.ts";
import {
PIPELINE_SYSTEM_PROMPT,
RANKING_MODEL,
RANKING_MODEL_FAST,
SINGLE_SYSTEM_PROMPT,
} from "./constants/prompts.ts";
import type { ScoredCandidate } from "./recall.ts";
import type {
IntentProfile,
ModelProfile,
PipelineStep,
RecommendedModel,
RecommendResult,
} from "./types.ts";
import { Complexities, ContextNeeds } from "./types.ts";
export interface RecommendOptions {
onThinking?: (text: string) => void;
onContentStart?: () => void;
enableThinking?: boolean;
}
function formatPrices(profile: ModelProfile): string | undefined {
if (!profile.prices?.length) return undefined;
return profile.prices.map((price) => `${price.type}:${price.price}/${price.unit}`).join(", ");
}
function formatQpm(profile: ModelProfile): string | undefined {
if (!profile.qpmInfo) return undefined;
const entries = Object.entries(profile.qpmInfo);
if (entries.length === 0) return undefined;
return entries
.map(([key, limit]) => `${key}:${limit.count_limit}/${limit.count_limit_period}s`)
.join(", ");
}
function buildCandidatesContext(candidates: ScoredCandidate[]): string {
return candidates
.map(({ model: profile }) => {
const parts = [
`ID: ${profile.model}`,
`名称: ${profile.name}`,
`描述: ${profile.shortDescription || profile.description}`,
`能力: ${profile.capabilities.join(", ")}`,
`特性: ${profile.features.join(", ")}`,
];
if (profile.contextWindow) parts.push(`上下文窗口: ${profile.contextWindow}`);
if (profile.maxOutputTokens) parts.push(`最大输出: ${profile.maxOutputTokens}`);
if (profile.category) parts.push(`类别: ${profile.category}`);
const modality = profile.inferenceMetadata;
if (modality?.request_modality?.length)
parts.push(`输入模态: ${modality.request_modality.join(", ")}`);
if (modality?.response_modality?.length)
parts.push(`输出模态: ${modality.response_modality.join(", ")}`);
const prices = formatPrices(profile);
if (prices) parts.push(`定价: ${prices}`);
const qpm = formatQpm(profile);
if (qpm) parts.push(`QPM: ${qpm}`);
if (profile.versionTag) parts.push(`版本: ${profile.versionTag}`);
if (profile.openSource !== undefined) parts.push(`开源: ${profile.openSource ? "是" : "否"}`);
if (profile.family) parts.push(`家族: ${profile.family}`);
return parts.join(" | ");
})
.join("\n");
}
function buildIntentContext(intent: IntentProfile): string {
const {
taskSummary,
scenarioHints,
inputModality,
outputModality,
requiredCapabilities,
requiredFeatures,
budget,
qualityPreference,
contextNeed,
segments,
} = intent;
const parts: string[] = [];
if (taskSummary) parts.push(`场景理解: ${taskSummary}`);
if (scenarioHints.length) parts.push(`场景特征: ${scenarioHints.join(", ")}`);
if (inputModality.length) parts.push(`输入模态: ${inputModality.join(", ")}`);
if (outputModality.length) parts.push(`输出模态: ${outputModality.join(", ")}`);
if (requiredCapabilities.length) parts.push(`所需能力: ${requiredCapabilities.join(", ")}`);
if (requiredFeatures.length) parts.push(`所需特性: ${requiredFeatures.join(", ")}`);
parts.push(`预算倾向: ${budget}`);
parts.push(`质量偏好: ${qualityPreference}`);
if (contextNeed !== ContextNeeds.Standard) parts.push(`上下文需求: ${contextNeed}`);
if (segments?.length) {
parts.push(`拆解步骤:`);
for (const seg of segments) {
const inMod = seg.inputModality.join(",") || "无";
const outMod = seg.outputModality.join(",") || "无";
const caps = seg.requiredCapabilities.join(",") || "无";
parts.push(` - ${seg.step} (输入: ${inMod} → 输出: ${outMod}, 能力: ${caps})`);
}
}
return parts.join("\n");
}
export function buildDocLink(docUrl?: string): string | undefined {
if (!docUrl) return undefined;
const match = docUrl.match(/\/(\d+)\.html/);
if (!match) return undefined;
return `https://bailian.console.aliyun.com/cn-beijing?tab=doc#/doc/?type=model&url=${match[1]}`;
}
function buildRecommendations(
items: any[],
modelMap: Map<string, ModelProfile>,
limit: number,
): RecommendedModel[] {
const list = Array.isArray(items) ? items : [];
const recommendations: RecommendedModel[] = [];
const seenFamilies = new Set<string>();
for (const item of list) {
const profile = modelMap.get(item.model);
if (!profile) continue;
if (profile.family && seenFamilies.has(profile.family)) continue;
if (profile.family) seenFamilies.add(profile.family);
const { model, name, category, contextWindow, maxOutputTokens, docUrl } = profile;
recommendations.push({
model,
name,
reason: item.reason ?? "",
highlights: item.highlights ?? [],
category,
contextWindow,
maxOutputTokens,
docUrl,
});
if (recommendations.length >= limit) break;
}
return recommendations;
}
function validatePipelineCompatibility(
steps: PipelineStep[],
modelMap: Map<string, ModelProfile>,
): void {
for (let stepIdx = 1; stepIdx < steps.length; stepIdx++) {
const prevStep = steps[stepIdx - 1];
const currStep = steps[stepIdx];
const prevOutputs = new Set(
prevStep.recommendations.flatMap((rec) => {
const profile = modelMap.get(rec.model);
return profile?.inferenceMetadata?.response_modality ?? [];
}),
);
if (prevOutputs.size === 0) continue;
const warnings: string[] = [];
for (const rec of currStep.recommendations) {
const profile = modelMap.get(rec.model);
const accepts = profile?.inferenceMetadata?.request_modality ?? [];
const compatible = accepts.some((mod) => prevOutputs.has(mod));
if (!compatible && accepts.length > 0) {
warnings.push(
`${rec.name} 的输入模态 [${accepts.join(", ")}] 可能不兼容上一步的输出模态 [${[...prevOutputs].join(", ")}]`,
);
}
}
if (warnings.length > 0) {
currStep.warnings = warnings;
}
}
}
export async function rankModels(
config: Config,
candidates: ScoredCandidate[],
intent: IntentProfile,
userInput: string,
top: number,
options?: RecommendOptions,
): Promise<RecommendResult> {
const candidatesContext = buildCandidatesContext(candidates);
const intentContext = buildIntentContext(intent);
const systemPrompt =
intent.complexity === Complexities.Pipeline ? PIPELINE_SYSTEM_PROMPT : SINGLE_SYSTEM_PROMPT;
const useThinkingModel = options?.enableThinking ?? false;
const userMessage =
intent.complexity === Complexities.Pipeline
? `意图分析结果:\n${intentContext}\n\n候选模型列表:\n${candidatesContext}\n\n用户原始需求:${userInput}\n\n请为流水线各步骤各推荐最多 ${top} 个模型。`
: `意图分析结果:\n${intentContext}\n\n候选模型列表:\n${candidatesContext}\n\n用户原始需求:${userInput}\n\n请推荐最多 ${top} 个模型。`;
const body: Record<string, unknown> = {
model: useThinkingModel ? RANKING_MODEL : RANKING_MODEL_FAST,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userMessage },
],
max_tokens: 4096,
temperature: 0,
};
if (useThinkingModel) {
body.stream = true;
body.enable_thinking = true;
}
const url = chatEndpoint(config.baseUrl);
let content: string;
if (useThinkingModel) {
const res = await request(config, {
url,
method: "POST",
body,
stream: true,
});
let accumulated = "";
let contentStarted = false;
for await (const event of parseSSE(res)) {
if (event.data === "[DONE]") break;
try {
const parsed = JSON.parse(event.data) as StreamChunk;
for (const choice of parsed.choices) {
const delta = choice.delta;
if (delta.reasoning_content && options?.onThinking) {
options.onThinking(delta.reasoning_content);
}
if (delta.content) {
if (!contentStarted) {
contentStarted = true;
options?.onContentStart?.();
}
accumulated += delta.content;
}
}
} catch {
// skip unparseable chunks
}
}
content = accumulated || "{}";
} else {
const response = await requestJson<ChatResponse>(config, {
url,
method: "POST",
body,
});
content = response.choices?.[0]?.message?.content ?? "{}";
}
let parsed: any;
try {
const jsonMatch = content.match(/\{[\s\S]*\}/);
parsed = JSON.parse(jsonMatch?.[0] ?? "{}");
} catch {
return { type: Complexities.Single, recommendations: [] };
}
const modelMap = new Map(candidates.map(({ model: profile }) => [profile.model, profile]));
if (parsed.type === Complexities.Pipeline && Array.isArray(parsed.steps)) {
const steps: PipelineStep[] = [];
for (const rawStep of parsed.steps) {
const items = rawStep.recommendations ?? (rawStep.model ? [rawStep] : []);
const recs = buildRecommendations(items, modelMap, top);
if (recs.length > 0) {
steps.push({ step: rawStep.step ?? "", recommendations: recs });
}
}
validatePipelineCompatibility(steps, modelMap);
return {
type: Complexities.Pipeline,
summary: parsed.summary ?? "",
steps,
};
}
const items = parsed.recommendations ?? parsed ?? [];
const recommendations = buildRecommendations(items, modelMap, top);
return { type: Complexities.Single, recommendations };
}
+61
View File
@@ -0,0 +1,61 @@
import type { Config } from "../../config/schema.ts";
import { fetchModelList } from "../../console/models.ts";
import type { ModelProfile } from "../types.ts";
import type { ModelSource } from "./types.ts";
const PAGE_SIZE = 50;
function toModelProfile(item: Record<string, unknown>): ModelProfile | null {
if (!item.model) return null;
const meta = item.inferenceMetadata as Record<string, unknown> | undefined;
return {
model: item.model as string,
name: (item.name as string) ?? (item.model as string),
description: (item.description as string) ?? (item.shortDescription as string) ?? "",
shortDescription: item.shortDescription as string | undefined,
provider: (item.provider as string) ?? "",
capabilities: (item.capabilities as string[]) ?? [],
features: (item.features as string[]) ?? [],
category: item.category as ModelProfile["category"],
contextWindow: (item.contextWindow as number) ?? undefined,
maxOutputTokens: (item.maxOutputTokens as number) ?? undefined,
maxInputTokens: (item.maxInputTokens as number) ?? undefined,
docUrl: item.docUrl as string | undefined,
collectionTag: item.collectionTag as string | undefined,
inferenceMetadata: meta as ModelProfile["inferenceMetadata"],
prices: item.prices as ModelProfile["prices"],
qpmInfo: item.qpmInfo as ModelProfile["qpmInfo"],
versionTag: item.versionTag as string | undefined,
openSource: item.openSource as boolean | undefined,
};
}
export class ApiSource implements ModelSource {
readonly name = "api";
constructor(private config: Config) {}
available(): boolean {
return true;
}
async load(): Promise<ModelProfile[]> {
const first = await fetchModelList(this.config, "", {
pageNo: 1,
pageSize: PAGE_SIZE,
});
const allRaw = [...first.models];
const totalPages = Math.ceil(first.total / PAGE_SIZE);
for (let page = 2; page <= totalPages; page++) {
const result = await fetchModelList(this.config, "", {
pageNo: page,
pageSize: PAGE_SIZE,
});
allRaw.push(...result.models);
}
return allRaw
.map(toModelProfile)
.filter((profile): profile is ModelProfile => profile !== null);
}
}
@@ -0,0 +1,102 @@
import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { getConfigDir } from "../../config/paths.ts";
import type { ModelPrice, ModelProfile, QpmLimit } from "../types.ts";
import type { ModelSource } from "./types.ts";
const SKILL_DIR_NAME = "skills/doc-llm-wiki";
const MODELS_FILE = "models.jsonl";
function getCatalogDir(): string {
return join(getConfigDir(), SKILL_DIR_NAME);
}
function getCatalogPath(): string {
return join(getCatalogDir(), MODELS_FILE);
}
function getMonorepoModelsDir(): string {
const coreDir = dirname(fileURLToPath(import.meta.url));
return join(coreDir, "../../../../../skills/doc-llm-wiki/models");
}
function fromJsonlRecord(raw: Record<string, unknown>): ModelProfile | null {
if (!raw.model || typeof raw.model !== "string") return null;
return {
model: raw.model,
name: (raw.name as string) ?? raw.model,
description: (raw.description as string) ?? "",
provider: (raw.provider as string) ?? "",
capabilities: (raw.capabilities as string[]) ?? [],
features: (raw.features as string[]) ?? [],
contextWindow: raw.contextWindow as number | undefined,
maxOutputTokens: raw.maxOutputTokens as number | undefined,
docUrl: raw.docUrl as string | undefined,
inferenceMetadata: raw.inferenceMetadata as ModelProfile["inferenceMetadata"],
shortDescription: raw.shortDescription as string | undefined,
category: raw.category as ModelProfile["category"],
collectionTag: raw.collectionTag as string | undefined,
maxInputTokens: raw.maxInputTokens as number | undefined,
prices: raw.prices as ModelPrice[] | undefined,
qpmInfo: raw.qpmInfo as Record<string, QpmLimit> | undefined,
versionTag: raw.versionTag as string | undefined,
openSource: raw.openSource as boolean | undefined,
family: raw.family as string | undefined,
familyName: raw.familyName as string | undefined,
};
}
function readJsonlModels(filePath: string): ModelProfile[] {
const content = readFileSync(filePath, "utf-8");
const lines = content.split("\n").filter(Boolean);
const models: ModelProfile[] = [];
for (const line of lines) {
try {
const record = fromJsonlRecord(JSON.parse(line));
if (record) models.push(record);
} catch {
// skip malformed lines
}
}
return models;
}
function installFromMonorepo(): boolean {
const src = getMonorepoModelsDir();
if (!existsSync(join(src, MODELS_FILE))) return false;
const dest = getCatalogDir();
try {
mkdirSync(dest, { recursive: true });
cpSync(src, dest, { recursive: true });
return true;
} catch {
return false;
}
}
export interface CatalogSourceOptions {
onPrepareStart?: () => void;
}
export class CatalogSource implements ModelSource {
readonly name = "catalog";
private options: CatalogSourceOptions;
constructor(options?: CatalogSourceOptions) {
this.options = options ?? {};
}
available(): boolean {
return existsSync(getCatalogPath());
}
async load(): Promise<ModelProfile[]> {
if (!this.available()) {
this.options.onPrepareStart?.();
const installed = installFromMonorepo();
if (!installed) return [];
}
return readJsonlModels(getCatalogPath());
}
}
@@ -0,0 +1,7 @@
import type { ModelProfile } from "../types.ts";
export interface ModelSource {
name: string;
available(): boolean;
load(): Promise<ModelProfile[]>;
}
+175
View File
@@ -0,0 +1,175 @@
// ---- Shared Enums ----
export type Modality = "Text" | "Image" | "Video" | "Audio";
export type Complexity = "single" | "pipeline";
export type Budget = "low" | "medium" | "high";
export type ContextNeed = "standard" | "large" | "extra-large";
export type QualityPreference = "flagship" | "balanced" | "cost-optimized";
export type Capability =
| "TG"
| "Reasoning"
| "VU"
| "IG"
| "VG"
| "TTS"
| "ASR"
| "Realtime-ASR"
| "Realtime-Text-to-Speech"
| "Realtime-Audio-Translate"
| "Realtime-Omni"
| "Multimodal-Omni"
| "ME"
| "TR"
| "3D-generation";
export type Feature =
| "function-calling"
| "web-search"
| "structured-outputs"
| "prefix-completion";
export type ModelCategory = "Flagship" | "Cost-optimized";
export const Modalities = {
Text: "Text",
Image: "Image",
Video: "Video",
Audio: "Audio",
} as const;
export const Complexities = { Single: "single", Pipeline: "pipeline" } as const;
export const Budgets = { Low: "low", Medium: "medium", High: "high" } as const;
export const ContextNeeds = {
Standard: "standard",
Large: "large",
ExtraLarge: "extra-large",
} as const;
export const QualityPreferences = {
Flagship: "flagship",
Balanced: "balanced",
CostOptimized: "cost-optimized",
} as const;
export const Capabilities = {
TG: "TG",
Reasoning: "Reasoning",
VU: "VU",
IG: "IG",
VG: "VG",
TTS: "TTS",
ASR: "ASR",
RealtimeASR: "Realtime-ASR",
RealtimeTTS: "Realtime-Text-to-Speech",
RealtimeAudioTranslate: "Realtime-Audio-Translate",
RealtimeOmni: "Realtime-Omni",
MultimodalOmni: "Multimodal-Omni",
ME: "ME",
TR: "TR",
ThreeDGeneration: "3D-generation",
} as const;
export const Features = {
FunctionCalling: "function-calling",
WebSearch: "web-search",
StructuredOutputs: "structured-outputs",
PrefixCompletion: "prefix-completion",
} as const;
export const ModelCategories = {
Flagship: "Flagship",
CostOptimized: "Cost-optimized",
} as const;
// ---- Intent Analysis ----
export interface IntentSegment {
step: string;
inputModality: Modality[];
outputModality: Modality[];
requiredCapabilities: Capability[];
}
export interface IntentProfile {
complexity: Complexity;
segments?: IntentSegment[];
taskSummary: string;
scenarioHints: string[];
inputModality: Modality[];
outputModality: Modality[];
requiredCapabilities: Capability[];
requiredFeatures: Feature[];
budget: Budget;
contextNeed: ContextNeed;
qualityPreference: QualityPreference;
confidence: number;
}
// ---- Model Profile ----
export interface ModelPrice {
type: string;
unit: string;
price: string;
}
export interface QpmLimit {
count_limit: number;
count_limit_period: number;
usage_limit: number;
usage_limit_field: string;
usage_limit_period: number;
}
export interface ModelProfile {
model: string;
name: string;
description: string;
provider: string;
capabilities: string[];
features: string[];
contextWindow?: number;
maxOutputTokens?: number;
docUrl?: string;
inferenceMetadata?: {
request_modality?: Modality[];
response_modality?: Modality[];
};
shortDescription?: string;
category?: ModelCategory;
collectionTag?: string;
maxInputTokens?: number;
prices?: ModelPrice[];
qpmInfo?: Record<string, QpmLimit>;
versionTag?: string;
openSource?: boolean;
family?: string;
familyName?: string;
}
export interface RecommendedModel {
model: string;
name: string;
reason: string;
highlights: string[];
category?: ModelCategory;
contextWindow?: number;
maxOutputTokens?: number;
docUrl?: string;
}
export interface PipelineStep {
step: string;
recommendations: RecommendedModel[];
warnings?: string[];
}
export interface SingleResult {
type: "single";
recommendations: RecommendedModel[];
}
export interface PipelineResult {
type: "pipeline";
summary: string;
steps: PipelineStep[];
}
export type RecommendResult = SingleResult | PipelineResult;
+1 -1
View File
@@ -87,7 +87,7 @@ export function loadConfig(flags: GlobalFlags): Config {
consoleGatewayUrl:
process.env.BAILIAN_CONSOLE_GATEWAY_URL ||
file.console_gateway_url ||
"https://bailian-cs.console.aliyun.com",
"https://pre-bailian-cs.console.aliyun.com",
verbose: flags.verbose || process.env.DASHSCOPE_VERBOSE === "1",
quiet: flags.quiet || false,
noColor: flags.noColor || process.env.NO_COLOR !== undefined || !process.stdout.isTTY,
+2
View File
@@ -1,2 +1,4 @@
export type { ConsoleGatewayRequest } from "./gateway.ts";
export { callConsoleGateway } from "./gateway.ts";
export type { ModelListParams, ModelListResult } from "./models.ts";
export { fetchModelList } from "./models.ts";
+66
View File
@@ -0,0 +1,66 @@
import { callConsoleGateway } from "./gateway.ts";
import type { Config } from "../config/schema.ts";
const MODEL_LIST_API = "zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels";
export interface ModelListParams {
pageNo?: number;
pageSize?: number;
name?: string;
providers?: string[];
capabilities?: string[];
region?: string;
}
export interface ModelListResult {
total: number;
models: Record<string, unknown>[];
}
export async function fetchModelList(
config: Config,
token: string,
params: ModelListParams = {},
): Promise<ModelListResult> {
const {
pageNo = 1,
pageSize = 50,
name = "",
providers = [],
capabilities = [],
region = "cn-beijing",
} = params;
const result = (await callConsoleGateway(config, token, {
api: MODEL_LIST_API,
data: {
input: {
pageNo,
pageSize,
name,
providers,
inferenceProviders: [],
features: [],
group: true,
capabilities,
contextWindows: [],
},
},
region,
})) as any;
const responseData = result?.data?.DataV2?.data ?? result?.data ?? {};
const total: number = responseData?.data?.total ?? responseData?.total ?? 0;
const groups: any[] = responseData?.data?.list ?? responseData?.list ?? [];
const models: Record<string, unknown>[] = [];
for (const group of groups) {
if (group.items?.length) {
for (const item of group.items) models.push(item);
} else {
models.push(group);
}
}
return { total, models };
}
+1
View File
@@ -12,3 +12,4 @@ export * from "./files/index.ts";
export * from "./types/index.ts";
export * from "./utils/index.ts";
export * from "./telemetry/index.ts";
export * from "./advisor/index.ts";
+152
View File
@@ -12,6 +12,12 @@ catalogs:
ajv:
specifier: ^8.20.0
version: 8.20.0
boxen:
specifier: ^8.0.1
version: 8.0.1
chalk:
specifier: ^5.6.2
version: 5.6.2
vite-plus:
specifier: latest
version: 0.1.22
@@ -36,6 +42,12 @@ importers:
bailian-cli-core:
specifier: workspace:*
version: link:../core
boxen:
specifier: 'catalog:'
version: 8.0.1
chalk:
specifier: 'catalog:'
version: 5.6.2
devDependencies:
'@clack/prompts':
specifier: ^0.7.0
@@ -698,14 +710,51 @@ packages:
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
ansi-align@3.0.1:
resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
boxen@8.0.1:
resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==}
engines: {node: '>=18'}
camelcase@8.0.0:
resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
engines: {node: '>=16'}
chalk@5.6.2:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
cli-boxes@3.0.0:
resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==}
engines: {node: '>=10'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
@@ -729,6 +778,14 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
get-east-asian-width@1.6.0:
resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
engines: {node: '>=18'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
@@ -883,6 +940,22 @@ packages:
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string-width@7.2.0:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-ansi@7.2.0:
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
engines: {node: '>=12'}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -905,6 +978,10 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'}
@@ -964,6 +1041,14 @@ packages:
yaml:
optional: true
widest-line@5.0.0:
resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==}
engines: {node: '>=18'}
wrap-ansi@9.0.2:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'}
ws@8.20.0:
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
engines: {node: '>=10.0.0'}
@@ -1401,10 +1486,41 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
ansi-align@3.0.1:
dependencies:
string-width: 4.2.3
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
ansi-styles@6.2.3: {}
assertion-error@2.0.1: {}
boxen@8.0.1:
dependencies:
ansi-align: 3.0.1
camelcase: 8.0.0
chalk: 5.6.2
cli-boxes: 3.0.0
string-width: 7.2.0
type-fest: 4.41.0
widest-line: 5.0.0
wrap-ansi: 9.0.2
camelcase@8.0.0: {}
chalk@5.6.2: {}
cli-boxes@3.0.0: {}
detect-libc@2.1.2: {}
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}
es-module-lexer@1.7.0: {}
fast-deep-equal@3.1.3: {}
@@ -1418,6 +1534,10 @@ snapshots:
fsevents@2.3.3:
optional: true
get-east-asian-width@1.6.0: {}
is-fullwidth-code-point@3.0.0: {}
jiti@2.6.1:
optional: true
@@ -1585,6 +1705,26 @@ snapshots:
std-env@4.1.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string-width@7.2.0:
dependencies:
emoji-regex: 10.6.0
get-east-asian-width: 1.6.0
strip-ansi: 7.2.0
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
strip-ansi@7.2.0:
dependencies:
ansi-regex: 6.2.2
tinybench@2.9.0: {}
tinyexec@1.1.2: {}
@@ -1601,6 +1741,8 @@ snapshots:
tslib@2.8.1:
optional: true
type-fest@4.41.0: {}
typescript@6.0.3: {}
undici-types@7.16.0: {}
@@ -1732,6 +1874,16 @@ snapshots:
jiti: 2.6.1
yaml: 2.8.3
widest-line@5.0.0:
dependencies:
string-width: 7.2.0
wrap-ansi@9.0.2:
dependencies:
ansi-styles: 6.2.3
string-width: 7.2.0
strip-ansi: 7.2.0
ws@8.20.0: {}
yaml@2.8.3: {}
+2
View File
@@ -5,6 +5,8 @@ packages:
catalog:
"@types/node": ^24
ajv: ^8.20.0
boxen: ^8.0.1
chalk: ^5.6.2
typescript: ^5
vite: npm:@voidzero-dev/vite-plus-core@latest
vite-plus: latest