Commit b40dc588 by renyizhao

使用指南

parent cf1b2f23
<template>
<div class="model-usage-method" v-if="model">
<!-- 没有 apiExamples 时的提示 -->
<div v-if="!hasAnyUsage" class="empty-tip">
该模型暂无使用方式信息
</div>
<!-- 遍历每个 apiExamples(按 endpoint + profile 展示) -->
<div
v-for="(example, exIdx) in usageExamples"
:key="`${example.endpoint}-${example.profile}-${exIdx}`"
class="usage-block"
>
<!-- 调用示例:4 种语言切换 -->
<div class="code-block-wrapper">
<div class="flex" style="margin-bottom: 5px;">
<div>调用示例</div>
<!-- 场景切换(如果该 profile 支持多个场景) -->
<div v-if="scenariosFor(example.profile).length > 1" class="scenario-switcher">
<el-radio-group v-model="scenarioMap[exampleKey(example)]" size="small">
<el-radio-button
v-for="sc in scenariosFor(example.profile)"
:key="sc"
:label="sc"
>{{ scenarioLabel(sc) }}</el-radio-button>
</el-radio-group>
</div>
</div>
<div class="block-title">
<el-radio-group v-model="langMap[exampleKey(example)]" size="small" class="lang-switcher">
<el-radio-button label="curl">cURL</el-radio-button>
<el-radio-button label="python">Python</el-radio-button>
<el-radio-button label="typescript">TypeScript</el-radio-button>
<el-radio-button label="javascript">JavaScript</el-radio-button>
</el-radio-group>
<el-button
size="small"
type="primary"
link
class="copy-btn"
:disabled="copiedKey === exampleKey(example)"
@click="copyCode(example)"
>{{ copiedKey === exampleKey(example) ? '已复制' : '复制' }}</el-button>
</div>
<pre class="code-snippet"><code>{{ buildCodeFor(example) }}</code></pre>
</div>
<!-- 参数列表 -->
<div class="params-block">
<div class="block-title">参数说明</div>
<div v-if="buildParamsFor(example).length === 0" class="empty-tip">
暂未提供参数说明
</div>
<div v-else class="params-table">
<div class="params-header">
<div class="params-cell header-cell">参数名</div>
<div class="params-cell header-cell">类型</div>
<div class="params-cell header-cell">必填</div>
<div class="params-cell header-cell">默认值</div>
<div class="params-cell header-cell">范围</div>
<div class="params-cell header-cell">说明</div>
</div>
<div
v-for="p in buildParamsFor(example)"
:key="p.name"
class="params-row"
>
<div class="params-cell name-cell">{{ p.name }}</div>
<div class="params-cell type-cell">{{ p.type }}</div>
<div class="params-cell required-cell">{{ p.required ? '是' : '否' }}</div>
<div class="params-cell default-cell">{{ formatDefault(p.defaultValue) }}</div>
<div class="params-cell range-cell">
<span v-if="p.enumValues && p.enumValues.length">{{ p.enumValues.join(' / ') }}</span>
<span v-else-if="p.range">{{ p.range }}</span>
<span v-else>-</span>
</div>
<div class="params-cell desc-cell">{{ p.descriptionKey }}</div>
</div>
</div>
</div>
<!-- 身份验证(所有模型共用,按 NewAPI 文档原文) -->
<div class="auth-block">
<div class="block-title">身份验证</div>
<p class="auth-desc">
所有请求必须携带
<code>Authorization: Bearer &lt;TOKEN&gt;</code>
请求头。Anthropic 格式的端点也接受
<code>x-api-key</code>
请求头。
</p>
<p class="auth-tip">
在 Token 页面生成令牌,可以按模型、分组、IP 和速率限制来限定范围。
</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
SCENARIO_LABELS,
PROFILE_LABELS,
exampleScenarios,
profileRequest,
profileParameters,
buildEndpointParameters,
buildSample,
} from '@/utils/newapi-port'
const props = defineProps({
model: {
type: Object,
default: null,
},
/** 网关 base URL,用于代码示例。生产环境填 NewAPI 实际域名 */
baseUrl: {
type: String,
default: 'https://phslgld.hnluchuan.com:3000',
},
})
// ============ 语言/场景切换状态 ============
// 用 Map 而非 ref 避免 reactive 包装带来的跨组件反应性问题
const langMap = reactive({})
const scenarioMap = reactive({})
const exampleKey = (ex) => `${ex.endpoint}__${ex.profile}`
const exampleScenario = (ex) => scenarioMap[exampleKey(ex)] || 'basic'
const exampleLang = (ex) => langMap[exampleKey(ex)] || 'curl'
// ============ 复制状态 ============
// 用 ref 记录刚被复制的 example key,1.5 秒后清空,用于按钮文字短暂切换"复制"→"已复制"
const copiedKey = ref(null)
// 复制当前示例的代码到剪贴板
// 优先 navigator.clipboard(现代浏览器),失败兜底 execCommand(兼容老浏览器)
async function copyCode(example) {
const k = exampleKey(example)
const text = buildCodeFor(example)
if (!text) return
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch (e) {
// 兜底:旧浏览器或非安全上下文(HTTP)下 navigator.clipboard 不可用
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
try {
document.execCommand('copy')
ElMessage.success('已复制')
} finally {
document.body.removeChild(ta)
}
}
copiedKey.value = k
setTimeout(() => {
if (copiedKey.value === k) copiedKey.value = null
}, 1500)
}
// ============ 计算属性 ============
const hasAnyUsage = computed(() => {
return props.model?.apiExamples && props.model.apiExamples.length > 0
})
const usageExamples = computed(() => {
if (!props.model?.apiExamples) return []
// 同一个 endpoint + profile 保留一条
const map = new Map()
for (const ex of props.model.apiExamples) {
const k = `${ex.endpoint}__${ex.profile}`
if (!map.has(k)) {
map.set(k, {
...ex,
endpointType: ex.endpoint,
endpointPath: getEndpointPath(ex.endpoint, ex.profile),
})
}
}
return [...map.values()]
})
// 当 usageExamples 变化时,给 scenarioMap / langMap 设置默认值
// 这样 el-radio-group 的 v-model 有初始值,按钮才能显示选中态
// scenario 默认值用每个 profile 的第一个可用场景(而非硬编码 'basic'),
// 避免 ctyun-happyhorse-reference 等只有 image-reference 的 profile 错误渲染 basic 示例
watch(usageExamples, (examples) => {
for (const ex of examples) {
const k = exampleKey(ex)
if (!scenarioMap[k]) {
scenarioMap[k] = exampleScenarios(ex.profile || 'standard')[0] || 'basic'
}
if (!langMap[k]) langMap[k] = 'curl'
}
}, { immediate: true })
// ============ 辅助方法 ============
function endpointLabel(endpoint) {
const labels = {
'openai': 'OpenAI Chat Completions',
'openai-response': 'OpenAI Responses',
'openai-video': 'OpenAI Video',
'embeddings': 'Embeddings',
'image-generation': 'Image Generation',
'jina-rerank': 'Rerank',
'anthropic': 'Anthropic Messages',
'gemini': 'Gemini',
}
return labels[endpoint] || endpoint
}
function profileLabel(profile) {
return PROFILE_LABELS[profile] || profile
}
function scenarioLabel(sc) {
return SCENARIO_LABELS[sc] || sc
}
function scenariosFor(profile) {
return exampleScenarios(profile || 'standard')
}
function getEndpointPath(endpoint, profile = 'standard') {
// 移植自 NewAPI model-details-api.tsx 的 endpointPath 选择逻辑:
// - profile === 'standard' → 用后端返回的 supported_endpoint.path
// - profile 非标准(如 seedance2 / ctyun-seedream 等) → 用硬编码路径
// 因为这些二次开发 profile 走专有适配器,路径与官方 API 不同
// (如 openai-video 走 /v1/videos 而非 /v1/video/generations)
if (profile !== 'standard') {
const nonStandardDefaults = {
'openai-video': '/v1/videos',
'image-generation': '/v1/images/generations',
'jina-rerank': '/v1/rerank',
embeddings: '/v1/embeddings',
}
if (nonStandardDefaults[endpoint]) return nonStandardDefaults[endpoint]
}
// standard profile(其它端点也兜底)→ 用后端透传的路径
if (props.model?.endpoints?.[endpoint]?.path) {
return props.model.endpoints[endpoint].path
}
// 兜底默认路径
const defaults = {
'openai': '/v1/chat/completions',
'openai-response': '/v1/responses',
'openai-video': '/v1/videos',
'embeddings': '/v1/embeddings',
'image-generation': '/v1/images/generations',
'jina-rerank': '/v1/rerank',
'anthropic': '/v1/messages',
'gemini': '/v1beta/models/{model}:generateContent',
}
return defaults[endpoint] || ''
}
// ============ 代码生成 ============
function buildCodeFor(example) {
const profile = example.profile || 'standard'
const scenario = exampleScenario(example)
const lang = exampleLang(example)
// 先尝试 profile 专属请求体
const adapted = profileRequest(
props.model.modelName,
example.endpoint,
profile,
scenario
)
const ctx = {
baseUrl: props.baseUrl,
apiKeyEnv: 'KEY',
modelName: props.model.modelName,
endpointType: example.endpoint,
endpointPath: getEndpointPath(example.endpoint, profile),
profile,
scenario,
_adapted: adapted, // code-samples 内部判断是否使用专属请求体
}
try {
return buildSample(lang, example.endpoint, ctx) || '// 暂不支持此端点'
} catch (e) {
return `// 生成失败:${e.message}`
}
}
// ============ 参数生成(从 profileRequest 动态提取)============
// 真正"系统自动填充"的字段(请求体里有、auth 里没有、用户不可编辑)
// 注意:stream / tools / tool_choice 是合法 API 参数(在 COMMON_CHAT_PARAMS 里),
// 不应放入此集合,否则会被误过滤掉
const IMPLICIT_FIELDS = new Set(['model', 'messages'])
// 字段描述兜底(仅存放 auth.descriptionKey 没覆盖的字段)
// 主要字段(prompt / input / dimensions 等)的描述已在
// api-parameters.js 的 buildEndpointParameters + example-profiles.js 的
// profileParameters 里直接用中文定义,auth.descriptionKey 优先
const FIELD_DESCRIPTIONS = {
// ===== 兜底(profile 专属参数,未在 auth 中注册的)=====
'image': '输入图片 URL 或图片 URL 数组,用于图片编辑。',
'mask': '编辑遮罩',
// image-generation 专属(auth 未定义)
'quality': '生成质量预设',
'style': '画风',
// jina-rerank 专属(auth 未定义)
'instruct': '可选的重排序指令',
'return_documents': '在重排结果中包含文档原文',
// ===== 系统字段(仅 fallback)=====
'model': '模型名称',
'messages': '对话消息数组',
'tools': '工具调用列表',
'tool_choice': '工具选择策略',
'stream': '是否流式返回',
}
// 推断字段元数据(类型 / enum / 范围)
function inferParamMeta(name, sample) {
const meta = {
name,
type: 'string',
required: false,
descriptionKey: FIELD_DESCRIPTIONS[name] || '请求体字段',
}
if (typeof sample === 'number') {
meta.type = 'number'
if (name === 'seconds') meta.range = '1~60'
} else if (typeof sample === 'boolean') {
meta.type = 'boolean'
} else if (typeof sample === 'string') {
if (name === 'metadata.resolution') {
meta.type = 'enum'
meta.enumValues = ['480P', '720P', '768P', '1080P', '2K', '4K']
} else if (name === 'response_format') {
meta.type = 'enum'
meta.enumValues = ['url', 'b64_json']
} else if (name === 'size') {
meta.type = 'enum'
meta.enumValues = ['256x256', '512x512', '1024x1024', '1792x1024', '1024x1792']
}
}
return meta
}
// 把嵌套对象扁平化为 dot.notation 路径(如 metadata.resolution)
// 顶层 key 如果在 authSet 里(非数组对象),整体保留不展开,
// 否则会被误展开成 input.contents / input.contents.0.text 这种错误的子字段名
function flattenBody(obj, authSet, prefix = '', out = {}) {
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
if (!prefix && authSet.has(k)) {
// 顶层 key 是权威参数 → 整体作为该参数的值,不递归
out[k] = v
continue
}
flattenBody(v, authSet, key, out)
} else {
out[key] = v
}
}
return out
}
// 从权威参数 + 请求体动态构建参数表
// 1) profileParameters() + buildEndpointParameters() 提供 required / type / enum / range / defaultValue
// 2) profileRequest() 提供 sample 值
// 3) 顺序:按权威参数(auth)顺序,与 NewAPI 一致 —— body 顺序与权威顺序不一致时以权威为准
// (如 qwen3-vl-embedding 的 body 是 {input, encoding_format, dimensions},auth 是 [input, dimensions, encoding_format])
// 4) 特殊情况:profileRequest 对 standard profile(chat/reasoning 主流)返回 undefined,
// 此时直接展示权威参数表里的全部字段(COMMON_CHAT_PARAMS 等 16 个),无需 sample
function buildParamsFor(example) {
const profile = example.profile || 'standard'
const scenario = exampleScenario(example)
const endpoint = example.endpoint
// 权威参数列表(按端点硬编码基础表 + profile 场景专属叠加)
const authoritative = profileParameters(
buildEndpointParameters(props.model, endpoint),
endpoint,
profile,
scenario
)
const authMap = new Map(authoritative.map((p) => [p.name, p]))
// 顶层权威 key(不含 '.' 的),用于 flattenBody 判断是否要展开嵌套对象
const topLevelAuthNames = new Set(
authoritative.map((p) => p.name).filter((n) => !n.includes('.'))
)
// 辅助:把权威参数项转成展示用对象
// 描述优先级:auth.descriptionKey(profile 覆盖优先) > FIELD_DESCRIPTIONS 兜底
// 例如 ctyun-vl-embedding 会把 input 的 descriptionKey 改成"使用 input.contents;..."
// 如果用 FIELD_DESCRIPTIONS 兜底会把这个 profile 覆盖遮蔽掉
const toDisplayParam = (p) => ({
name: p.name,
type: p.type || 'string',
required: !!p.required,
defaultValue: p.defaultValue,
range: p.range,
enumValues: p.enumValues,
descriptionKey:
p.descriptionKey || FIELD_DESCRIPTIONS[p.name] || '请求体字段',
})
// 请求体(提供 sample 值;standard profile 时返回 undefined)
const body = profileRequest(
props.model.modelName,
endpoint,
profile,
scenario
)
// 特殊情况:没有请求体(chat/reasoning standard)→ 直接展示全部权威参数
if (!body || typeof body !== 'object') {
return authoritative.map(toDisplayParam)
}
// 扁平化 body(dot.notation 形式),Object.entries 保留请求体的 key 顺序
// 顶层 key 在 authMap 里的(如 input)整体保留,不展开成 input.contents
const flatBody = flattenBody(body, topLevelAuthNames)
const params = []
const matchedKeys = new Set()
// 第一遍:按权威参数顺序遍历(保持 API 文档顺序,与 NewAPI 一致)
for (const p of authoritative) {
// 不管 body 里有没有都展示:profile 专属参数(如 ctyun-vl-encoding 的 input 覆盖、
// metadata.content、image-generation 的 stream 等)也属于该 profile 的 API 参数
params.push(toDisplayParam(p))
matchedKeys.add(p.name)
}
// 第二遍:补全 body 里有但 auth 里没有的字段(兜底,按 body 顺序)
// 只过滤掉真正隐式的字段(model / messages),其它如 stream/tools/tool_choice
// 可能在 auth 之外的边缘场景出现,应让 inferParamMeta 兜底展示
for (const [key, sample] of Object.entries(flatBody)) {
if (IMPLICIT_FIELDS.has(key)) continue
if (matchedKeys.has(key)) continue
params.push(inferParamMeta(key, sample))
}
return params
}
// ============ 显示辅助 ============
function formatDefault(v) {
if (v === undefined || v === null || v === '') return '-'
if (typeof v === 'boolean') return v ? 'true' : 'false'
return String(v)
}
</script>
<style scoped>
.model-usage-method {
margin-top: 8px;
}
.usage-section {
margin-bottom: 12px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #1f2329;
}
.section-desc {
font-size: 12px;
color: #86909c;
margin-top: 2px;
}
.usage-block {
border: 1px solid #e5e6eb;
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
background-color: #fafbfc;
}
.endpoint-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.provider-tag {
font-size: 12px;
color: #86909c;
}
.empty-tip {
padding: 20px;
text-align: center;
color: #86909c;
font-size: 13px;
}
.auth-block,
.code-block-wrapper,
.params-block {
margin-top: 12px;
}
.block-title {
font-size: 13px;
font-weight: 600;
color: #1f2329;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.lang-switcher {
display: inline-flex;
}
.copy-btn {
flex-shrink: 0;
font-size: 12px;
}
.auth-desc {
font-size: 13px;
line-height: 1.7;
color: #1f2329;
margin: 0 0 6px;
}
.auth-desc code {
background-color: #f2f3f5;
color: #1f2329;
padding: 1px 5px;
border-radius: 3px;
font-family: 'Menlo', 'Consolas', monospace;
font-size: 12px;
}
.auth-tip {
font-size: 12px;
color: #86909c;
margin: 0;
}
.auth-text,
.code-snippet {
background-color: #1e1e1e;
color: #d4d4d4;
padding: 12px;
border-radius: 4px;
font-family: 'Menlo', 'Consolas', monospace;
font-size: 12px;
line-height: 1.6;
overflow-x: auto;
white-space: pre;
margin: 0;
word-break: keep-all;
}
.auth-text {
color: #a8c5e0;
}
.params-table {
border: 1px solid #e5e6eb;
border-radius: 4px;
background-color: #fff;
overflow: hidden;
}
.params-header,
.params-row {
display: grid;
grid-template-columns: 1fr 0.8fr 0.6fr 0.8fr 1fr 1.8fr;
font-size: 12px;
}
.params-header {
background-color: #f2f3f5;
font-weight: 600;
color: #1f2329;
}
.params-row {
border-top: 1px solid #f2f3f5;
}
.params-cell {
padding: 8px;
border-right: 1px solid #f2f3f5;
word-break: break-word;
}
.params-cell:last-child {
border-right: none;
}
.name-cell {
font-family: 'Menlo', 'Consolas', monospace;
color: #2e77e3;
}
.scenario-switcher {
}
.scenario-label {
font-size: 12px;
color: #4e5969;
margin-right: 8px;
}
.model-usage-method :deep(*)::after {
content: none !important;
display: inline !important;
clear: none !important;
}
.flex {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
\ No newline at end of file
/**
* 新 NewAPI 模型使用方式 - 参数定义
*
* 移植自 newapi/web/src/features/pricing/lib/mock-stats.ts
* 适配 Vue3 + JavaScript
*
* 提供:
* 1) 各端点的标准参数表(聊天/嵌入/图像/视频/推理)
* 2) 按模型返回对应参数集
*/
/**
* 参数定义结构:
* {
* name: string, // 参数名
* type: 'string'|'number'|'integer'|'boolean'|'object'|'array'|'enum',
* required?: boolean,
* defaultValue?: any,
* range?: string, // 例如 '0 ~ 2'
* enumValues?: string[],
* descriptionKey: string // 描述(中文)
* }
*/
/** 16 个标准聊天参数 */
export const COMMON_CHAT_PARAMS = [
{
name: 'temperature',
type: 'number',
defaultValue: 1,
range: '0 ~ 2',
descriptionKey: '采样温度;越低越稳定',
},
{
name: 'top_p',
type: 'number',
defaultValue: 1,
range: '0 ~ 1',
descriptionKey: '核采样累计概率',
},
{
name: 'max_tokens',
type: 'integer',
range: '>= 1',
descriptionKey: '响应中最大 token 数',
},
{
name: 'frequency_penalty',
type: 'number',
defaultValue: 0,
range: '-2 ~ 2',
descriptionKey: '惩罚高频token的重复出现',
},
{
name: 'presence_penalty',
type: 'number',
defaultValue: 0,
range: '-2 ~ 2',
descriptionKey: '鼓励引入新话题',
},
{
name: 'stop',
type: 'array',
descriptionKey: '最多 4 个停止生成的字符串',
},
{
name: 'seed',
type: 'integer',
descriptionKey: '尽量保证可复现的采样种子',
},
{
name: 'n',
type: 'integer',
defaultValue: 1,
range: '>= 1',
descriptionKey: '生成的候选条数',
},
{
name: 'stream',
type: 'boolean',
defaultValue: false,
descriptionKey: '通过 SSE 流式返回 token',
},
{
name: 'response_format',
type: 'object',
descriptionKey: '强制输出 JSON 对象或符合 Schema 的结果',
},
{
name: 'tools',
type: 'array',
descriptionKey: '模型可调用的工具/函数声明',
},
{
name: 'tool_choice',
type: 'string',
enumValues: ['auto', 'none', 'required'],
descriptionKey: '工具选择策略或具体工具名',
},
{
name: 'logprobs',
type: 'boolean',
defaultValue: false,
descriptionKey: '返回每个 token 的对数概率',
},
{
name: 'top_logprobs',
type: 'integer',
range: '0 ~ 20',
descriptionKey: '每个 token 返回的 top 概率数量',
},
{
name: 'logit_bias',
type: 'object',
descriptionKey: '按 token 的 logit 偏置映射',
},
{
name: 'user',
type: 'string',
descriptionKey: '用于风险审计的终端用户标识',
},
]
/** 推理模型参数(o1/o3/reasoning/thinking/deepseek-r 等) */
export const REASONING_PARAMS = [
{
name: 'reasoning_effort',
type: 'enum',
enumValues: ['low', 'medium', 'high'],
defaultValue: 'medium',
descriptionKey: '控制模型在回答前的思考深度',
},
{
name: 'max_completion_tokens',
type: 'integer',
range: '>= 1',
descriptionKey: '最大 token 数(含隐藏的推理 token)',
},
{
name: 'stop',
type: 'array',
descriptionKey: '最多 4 个停止生成的字符串',
},
{
name: 'seed',
type: 'integer',
descriptionKey: '确定性采样种子(best-effort)',
},
{
name: 'stream',
type: 'boolean',
defaultValue: false,
descriptionKey: '通过 SSE 流式返回 token',
},
{
name: 'response_format',
type: 'object',
descriptionKey: '强制 JSON 对象或符合 schema 的输出',
},
{
name: 'tools',
type: 'array',
descriptionKey: '模型可调用的工具/函数声明',
},
{
name: 'tool_choice',
type: 'string',
enumValues: ['auto', 'none', 'required'],
descriptionKey: '工具选择策略或具体工具名',
},
{
name: 'user',
type: 'string',
descriptionKey: '终端用户标识,用于滥用监控',
},
]
/** 嵌入参数 */
export const EMBEDDING_PARAMS = [
{
name: 'input',
type: 'string',
required: true,
descriptionKey: '待嵌入的文本或文本数组',
},
{
name: 'dimensions',
type: 'integer',
range: '>= 1',
descriptionKey: '截断嵌入向量到此维度',
},
{
name: 'encoding_format',
type: 'enum',
enumValues: ['float', 'base64'],
defaultValue: 'float',
descriptionKey: '嵌入向量的传输编码',
},
{
name: 'user',
type: 'string',
descriptionKey: '终端用户标识,用于滥用监控',
},
]
/** 图像生成参数 */
export const IMAGE_PARAMS = [
{
name: 'prompt',
type: 'string',
required: true,
descriptionKey: '期望图像的文本描述',
},
{
name: 'size',
type: 'enum',
enumValues: ['256x256', '512x512', '1024x1024', '1024x1792', '1792x1024'],
defaultValue: '1024x1024',
descriptionKey: '输出图像尺寸',
},
{
name: 'quality',
type: 'enum',
enumValues: ['standard', 'hd'],
defaultValue: 'standard',
descriptionKey: '生成质量预设',
},
{
name: 'style',
type: 'enum',
enumValues: ['vivid', 'natural'],
defaultValue: 'vivid',
descriptionKey: '美学风格',
},
{
name: 'n',
type: 'integer',
defaultValue: 1,
range: '1 ~ 10',
descriptionKey: '生成图像数量',
},
{
name: 'response_format',
type: 'enum',
enumValues: ['url', 'b64_json'],
defaultValue: 'url',
descriptionKey: '返回图像的方式',
},
]
/** 视频生成参数 */
export const VIDEO_PARAMS = [
{
name: 'prompt',
type: 'string',
required: true,
descriptionKey: '期望视频的文本描述',
},
{
name: 'duration',
type: 'integer',
range: '1 ~ 60',
descriptionKey: '视频时长(秒)',
},
{
name: 'aspect_ratio',
type: 'enum',
enumValues: ['16:9', '9:16', '1:1'],
defaultValue: '16:9',
descriptionKey: '输出宽高比',
},
{
name: 'fps',
type: 'integer',
range: '8 ~ 60',
defaultValue: 24,
descriptionKey: '帧率',
},
]
/**
* 把 NewAPI pricing.txt 的 billing_usage_schema 字段转成统一参数列表
*
* 输入示例:
* {
* "resolution": {"enum": ["480P","720P","1080P"], "description": {"zh": "输出分辨率"}},
* "seconds": {"type": "number", "unit": "second", "description": {"zh": "输出视频秒数"}},
* "video_input": {"enum": ["none","video"], "description": {"zh": "参考视频输入"}}
* }
*
* 输出:
* [
* { name: 'resolution', type: 'enum', enumValues: [...], descriptionKey: '...' },
* ...
* ]
*/
export function buildSchemaParams(schema) {
if (!schema || typeof schema !== 'object') return []
const result = []
for (const [name, def] of Object.entries(schema)) {
const param = { name }
// 推断 type
if (def.enum && Array.isArray(def.enum)) {
param.type = 'enum'
param.enumValues = def.enum
} else if (def.type === 'integer') {
param.type = 'integer'
} else if (def.type === 'number') {
param.type = 'number'
if (def.unit) param.unit = def.unit
} else if (def.type === 'boolean') {
param.type = 'boolean'
} else if (def.type === 'array') {
param.type = 'array'
} else {
param.type = 'string'
}
// 描述:优先中文
if (def.description) {
if (typeof def.description === 'string') {
param.descriptionKey = def.description
} else if (def.description.zh) {
param.descriptionKey = def.description.zh
} else if (def.description.en) {
param.descriptionKey = def.description.en
}
}
// 默认值
if (def.default !== undefined) param.defaultValue = def.default
// 必填
if (def.required) param.required = true
result.push(param)
}
return result
}
/**
* 根据端点类型返回「请求参数」基础表
* 移植自 newapi/web/src/features/pricing/lib/api-samples.ts 的 buildEndpointParameters
*
* 注意:
* - 这里返回的是「请求体字段」(prompt / size / n 等),不是计费字段
* - 不使用 billingUsageSchema(那是计费侧字段,如 resolution/seconds/tokens/video_input,
* 用于计算 token 数量,与请求参数是两个独立的概念)
* - video / embeddings / image-generation / jina-rerank 端点用 NewAPI 硬编码的精简列表
* (与 VIDEO_PARAMS 等基础表略有差异:硬编码版更接近真实 API 形态,含 required 标注)
* - chat / reasoning 端点使用 COMMON_CHAT_PARAMS / REASONING_PARAMS(按模型名推断)
*
* @param {object} model - AppModelPricingVO
* @param {string} endpoint - 当前 example.endpoint(如 openai-video)
* @returns {Array}
*/
export function buildEndpointParameters(model, endpoint) {
if (endpoint === 'openai-video') {
return [
{
name: 'prompt',
type: 'string',
required: true,
descriptionKey: '想要生成视频的文字描述',
},
{
name: 'seconds',
type: 'string',
descriptionKey: '视频时长(秒)',
},
{ name: 'size', type: 'string', descriptionKey: '输出分辨率' },
]
}
if (endpoint === 'jina-rerank') {
return [
{
name: 'query',
type: 'string',
required: true,
descriptionKey: '用于文档排序的查询文本',
},
{
name: 'documents',
type: 'array',
required: true,
descriptionKey: '待排序文档:字符串或包含 text 的对象',
},
{
name: 'top_n',
type: 'integer',
descriptionKey: '最多返回的排序结果数',
},
]
}
if (endpoint === 'embeddings') {
return [
{
name: 'input',
type: 'string',
required: true,
descriptionKey: '需要向量化的文本或文本数组',
},
{
name: 'dimensions',
type: 'integer',
descriptionKey: '将向量截断到指定维度',
},
{
name: 'encoding_format',
type: 'enum',
enumValues: ['float', 'base64'],
descriptionKey: '向量传输的编码格式',
},
]
}
if (endpoint === 'image-generation') {
return [
{
name: 'prompt',
type: 'string',
required: true,
descriptionKey: '想要生成图像的文字描述',
},
{ name: 'size', type: 'string', descriptionKey: '输出图像尺寸' },
{
name: 'n',
type: 'integer',
descriptionKey: '生成的图像数量',
},
{
name: 'response_format',
type: 'enum',
enumValues: ['url', 'b64_json'],
descriptionKey: '图像结果的返回方式',
},
]
}
// chat / reasoning / 其它:使用按模型名推断的通用表
const isReasoning = /^o[1-4]|reasoning|thinking|deepseek-r/i.test(
model?.modelName || ''
)
return isReasoning ? REASONING_PARAMS : COMMON_CHAT_PARAMS
}
\ No newline at end of file
/**
* 新 NewAPI 模型使用方式 - 代码模板生成器
*
* 移植自 newapi/web/src/features/pricing/lib/api-samples.ts
* 适配 Vue3 + JavaScript(无 TypeScript)
*
* 提供 4 种语言的代码模板生成:cURL / Python / TypeScript / JavaScript
* 支持端点:openai, openai-response, openai-video, embeddings,
* image-generation, jina-rerank, anthropic, gemini
*/
/**
* @typedef {Object} SampleContext
* @property {string} baseUrl - 网关 base URL(如 https://phslgld.hnluchuan.com:3000)
* @property {string} apiKeyEnv - API Key 环境变量名)
* @property {string} modelName - 模型标识
* @property {string} endpointType - 端点类型(openai / openai-video / ...)
* @property {string} endpointPath - 端点路径(如 /v1/chat/completions)
* @property {string} [profile] - profile(默认 'standard')
* @property {string} [scenario] - 场景:basic / image-reference / video-reference
*/
/**
* 生成聊天模型代码(OpenAI Chat Completions 协议)
*/
function buildChatSample(lang, ctx) {
const url = `${ctx.baseUrl}${ctx.endpointPath}`
const isResponses = ctx.endpointType === 'openai-response'
const isReasoning = /^o[1-4]|reasoning|thinking|deepseek-r/i.test(ctx.modelName)
const userMessage = 'Explain quantum entanglement in one paragraph.'
const bodyJson = isResponses
? JSON.stringify({ model: ctx.modelName, input: userMessage }, null, 2)
: JSON.stringify(
{
model: ctx.modelName,
messages: [{ role: 'user', content: userMessage }],
...(isReasoning ? {} : { temperature: 0.7 }),
},
null,
2
)
const fnCall = isResponses ? 'responses.create' : 'chat.completions.create'
if (lang === 'curl') {
return [
`curl ${url} \\`,
` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`,
` -H "Content-Type: application/json" \\`,
` -d '${bodyJson.replace(/\n/g, '\n ')}'`,
].join('\n')
}
if (lang === 'python') {
return [
'from openai import OpenAI',
'',
'client = OpenAI(',
` base_url="${ctx.baseUrl}/v1",`,
` api_key="<YOUR_API_KEY>",`,
')',
'',
isResponses
? `response = client.${fnCall}(\n model="${ctx.modelName}",\n input="${userMessage}",\n)\n\nprint(response.output_text)`
: `completion = client.${fnCall}(\n model="${ctx.modelName}",\n messages=[\n {"role": "user", "content": "${userMessage}"}\n ],\n)\n\nprint(completion.choices[0].message.content)`,
].join('\n')
}
if (lang === 'typescript') {
return [
`import OpenAI from 'openai'`,
'',
`const client = new OpenAI({`,
` baseURL: '${ctx.baseUrl}/v1',`,
` apiKey: process.env.${ctx.apiKeyEnv},`,
`})`,
'',
isResponses
? `const response = await client.${fnCall}({\n model: '${ctx.modelName}',\n input: '${userMessage}',\n})\n\nconsole.log(response.output_text)`
: `const completion = await client.${fnCall}({\n model: '${ctx.modelName}',\n messages: [{ role: 'user', content: '${userMessage}' }],\n})\n\nconsole.log(completion.choices[0].message.content)`,
].join('\n')
}
// JavaScript (default)
return [
`const response = await fetch('${url}', {`,
` method: 'POST',`,
` headers: {`,
` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`,
` 'Content-Type': 'application/json',`,
` },`,
` body: JSON.stringify(${bodyJson}),`,
`})`,
'',
`const data = await response.json()`,
`console.log(data)`,
].join('\n')
}
/**
* 生成 Anthropic Claude 协议代码
*/
function buildAnthropicSample(lang, ctx) {
const url = `${ctx.baseUrl}${ctx.endpointPath}`
const userMessage = 'Explain quantum entanglement in one paragraph.'
if (lang === 'curl') {
const body = JSON.stringify(
{
model: ctx.modelName,
max_tokens: 1024,
messages: [{ role: 'user', content: userMessage }],
},
null,
2
)
return [
`curl ${url} \\`,
` -H "x-api-key: $${ctx.apiKeyEnv}" \\`,
` -H "anthropic-version: 2023-06-01" \\`,
` -H "Content-Type: application/json" \\`,
` -d '${body.replace(/\n/g, '\n ')}'`,
].join('\n')
}
if (lang === 'python') {
return [
'import anthropic',
'',
'client = anthropic.Anthropic(',
` base_url="${ctx.baseUrl}",`,
` api_key="<YOUR_API_KEY>",`,
')',
'',
`message = client.messages.create(`,
` model="${ctx.modelName}",`,
` max_tokens=1024,`,
` messages=[{"role": "user", "content": "${userMessage}"}],`,
')',
'',
'print(message.content[0].text)',
].join('\n')
}
if (lang === 'typescript') {
return [
`import Anthropic from '@anthropic-ai/sdk'`,
'',
`const client = new Anthropic({`,
` baseURL: '${ctx.baseUrl}',`,
` apiKey: process.env.${ctx.apiKeyEnv},`,
`})`,
'',
`const message = await client.messages.create({`,
` model: '${ctx.modelName}',`,
` max_tokens: 1024,`,
` messages: [{ role: 'user', content: '${userMessage}' }],`,
`})`,
'',
`console.log(message.content[0].text)`,
].join('\n')
}
// JavaScript
return [
`const response = await fetch('${url}', {`,
` method: 'POST',`,
` headers: {`,
` 'x-api-key': process.env.${ctx.apiKeyEnv},`,
` 'anthropic-version': '2023-06-01',`,
` 'Content-Type': 'application/json',`,
` },`,
` body: JSON.stringify({`,
` model: '${ctx.modelName}',`,
` max_tokens: 1024,`,
` messages: [{ role: 'user', content: '${userMessage}' }],`,
` }),`,
`})`,
'',
`const data = await response.json()`,
`console.log(data.content[0].text)`,
].join('\n')
}
/**
* 生成 Google Gemini 原生协议代码
*/
function buildGeminiSample(lang, ctx) {
const url = `${ctx.baseUrl}${ctx.endpointPath}?key=$${ctx.apiKeyEnv}`
const userMessage = 'Explain quantum entanglement in one paragraph.'
if (lang === 'curl') {
const body = JSON.stringify(
{ contents: [{ parts: [{ text: userMessage }] }] },
null,
2
)
return [
`curl '${url}' \\`,
` -H 'Content-Type: application/json' \\`,
` -d '${body.replace(/\n/g, '\n ')}'`,
].join('\n')
}
if (lang === 'python') {
return [
'import google.generativeai as genai',
'',
`genai.configure(api_key="<YOUR_API_KEY>")`,
'',
`model = genai.GenerativeModel("${ctx.modelName}")`,
`response = model.generate_content("${userMessage}")`,
'',
`print(response.text)`,
].join('\n')
}
if (lang === 'typescript') {
return [
`import { GoogleGenerativeAI } from '@google/generative-ai'`,
'',
`const genAI = new GoogleGenerativeAI(process.env.${ctx.apiKeyEnv}!)`,
`const model = genAI.getGenerativeModel({ model: '${ctx.modelName}' })`,
'',
`const result = await model.generateContent('${userMessage}')`,
`console.log(result.response.text())`,
].join('\n')
}
// JavaScript
return [
`const response = await fetch('${url}', {`,
` method: 'POST',`,
` headers: { 'Content-Type': 'application/json' },`,
` body: JSON.stringify({`,
` contents: [{ parts: [{ text: '${userMessage}' }] }],`,
` }),`,
`})`,
'',
`const data = await response.json()`,
`console.log(data.candidates[0].content.parts[0].text)`,
].join('\n')
}
/**
* 生成 Embeddings 协议代码
*/
function buildEmbeddingSample(lang, ctx) {
const url = `${ctx.baseUrl}${ctx.endpointPath}`
const text = 'The food was delicious and the waiter…'
if (lang === 'curl') {
const body = JSON.stringify({ model: ctx.modelName, input: text }, null, 2)
return [
`curl ${url} \\`,
` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`,
` -H "Content-Type: application/json" \\`,
` -d '${body.replace(/\n/g, '\n ')}'`,
].join('\n')
}
if (lang === 'python') {
return [
'from openai import OpenAI',
'',
`client = OpenAI(base_url="${ctx.baseUrl}/v1", api_key="<YOUR_API_KEY>")`,
'',
'response = client.embeddings.create(',
` model="${ctx.modelName}",`,
` input="${text}",`,
')',
'',
'print(response.data[0].embedding[:8])',
].join('\n')
}
if (lang === 'typescript') {
return [
`import OpenAI from 'openai'`,
'',
`const client = new OpenAI({`,
` baseURL: '${ctx.baseUrl}/v1',`,
` apiKey: process.env.${ctx.apiKeyEnv},`,
`})`,
'',
`const response = await client.embeddings.create({`,
` model: '${ctx.modelName}',`,
` input: '${text}',`,
`})`,
'',
`console.log(response.data[0].embedding.slice(0, 8))`,
].join('\n')
}
// JavaScript
return [
`const response = await fetch('${url}', {`,
` method: 'POST',`,
` headers: {`,
` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`,
` 'Content-Type': 'application/json',`,
` },`,
` body: JSON.stringify({`,
` model: '${ctx.modelName}',`,
` input: '${text}',`,
` }),`,
`})`,
'',
`const data = await response.json()`,
`console.log(data.data[0].embedding.slice(0, 8))`,
].join('\n')
}
/**
* 生成图像生成代码
*/
function buildImageSample(lang, ctx) {
const url = `${ctx.baseUrl}${ctx.endpointPath}`
const prompt = 'A serene koi pond at sunset, ukiyo-e style.'
if (lang === 'curl') {
const body = JSON.stringify(
{ model: ctx.modelName, prompt, size: '1024x1024', n: 1 },
null,
2
)
return [
`curl ${url} \\`,
` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`,
` -H "Content-Type: application/json" \\`,
` -d '${body.replace(/\n/g, '\n ')}'`,
].join('\n')
}
if (lang === 'python') {
return [
'from openai import OpenAI',
'',
`client = OpenAI(base_url="${ctx.baseUrl}/v1", api_key="<YOUR_API_KEY>")`,
'',
'response = client.images.generate(',
` model="${ctx.modelName}",`,
` prompt="${prompt}",`,
` size="1024x1024",`,
` n=1,`,
')',
'',
'print(response.data[0].url)',
].join('\n')
}
if (lang === 'typescript') {
return [
`import OpenAI from 'openai'`,
'',
`const client = new OpenAI({`,
` baseURL: '${ctx.baseUrl}/v1',`,
` apiKey: process.env.${ctx.apiKeyEnv},`,
`})`,
'',
`const response = await client.images.generate({`,
` model: '${ctx.modelName}',`,
` prompt: '${prompt}',`,
` size: '1024x1024',`,
` n: 1,`,
`})`,
'',
`console.log(response.data[0].url)`,
].join('\n')
}
// JavaScript
return [
`const response = await fetch('${url}', {`,
` method: 'POST',`,
` headers: {`,
` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`,
` 'Content-Type': 'application/json',`,
` },`,
` body: JSON.stringify({`,
` model: '${ctx.modelName}',`,
` prompt: '${prompt}',`,
` size: '1024x1024',`,
` n: 1,`,
` }),`,
`})`,
'',
`const data = await response.json()`,
`console.log(data.data[0].url)`,
].join('\n')
}
/**
* 通用 JSON 请求体代码生成(视频、重排都用)
*/
function buildJsonSample(lang, ctx, body) {
const url = `${ctx.baseUrl}${ctx.endpointPath}`
const bodyJson = JSON.stringify(body, null, 2)
const video = ctx.endpointType === 'openai-video'
if (lang === 'curl') {
return [
`curl '${url}' \\`,
` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`,
' -H "Content-Type: application/json" \\',
` -d '${bodyJson.replace(/'/g, "'\\''")}'`,
...(video
? [
'',
'# Set VIDEO_ID to the id returned above; repeat GET until completed or failed.',
"VIDEO_ID='<VIDEO_ID>'",
`curl "${ctx.baseUrl}/v1/videos/$VIDEO_ID" -H "Authorization: Bearer $${ctx.apiKeyEnv}"`,
'',
'# Download only after status is completed.',
`curl --fail "${ctx.baseUrl}/v1/videos/$VIDEO_ID/content" -H "Authorization: Bearer $${ctx.apiKeyEnv}" -o video.mp4`,
]
: []),
].join('\n')
}
if (lang === 'python') {
return [
'import json',
'import os',
...(video ? ['import time'] : []),
'import requests',
'',
`headers = {"Authorization": "Bearer " + os.environ["${ctx.apiKeyEnv}"]}`,
`body = json.loads(${JSON.stringify(bodyJson)})`,
`response = requests.post(${JSON.stringify(url)}, headers=headers, json=body, timeout=300)`,
'response.raise_for_status()',
'data = response.json()',
...(video
? [
`task_url = ${JSON.stringify(`${ctx.baseUrl}/v1/videos/`)} + data["id"]`,
'for _ in range(120):',
' response = requests.get(task_url, headers=headers, timeout=60)',
' response.raise_for_status()',
' data = response.json()',
' if data["status"] == "failed":',
' raise RuntimeError(data.get("error", data))',
' if data["status"] == "completed":',
' break',
' time.sleep(5)',
'else:',
' raise TimeoutError("Video is still running; query the same task later.")',
'response = requests.get(task_url + "/content", headers=headers, timeout=300)',
'response.raise_for_status()',
'with open("video.mp4", "wb") as output:',
' output.write(response.content)',
]
: ['print(data)']),
].join('\n')
}
// TypeScript / JavaScript 共用一段(fetch)
return [
...(video ? ["import { writeFile } from 'node:fs/promises'", ''] : []),
`const headers = { Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`, 'Content-Type': 'application/json' }`,
`const response = await fetch(${JSON.stringify(url)}, {`,
" method: 'POST',",
' headers,',
` body: JSON.stringify(${bodyJson}),`,
'})',
'if (!response.ok) throw new Error(await response.text())',
`${video ? 'let' : 'const'} data = await response.json()`,
...(video
? [
`const taskUrl = ${JSON.stringify(`${ctx.baseUrl}/v1/videos/`)} + encodeURIComponent(data.id)`,
'for (let attempt = 0; attempt < 120; attempt++) {',
' const result = await fetch(taskUrl, { headers })',
' if (!result.ok) throw new Error(await result.text())',
' data = await result.json()',
" if (data.status === 'failed') throw new Error(JSON.stringify(data.error))",
" if (data.status === 'completed') break",
' await new Promise(resolve => setTimeout(resolve, 5000))',
'}',
"if (data.status !== 'completed') throw new Error('Video is still running; query the same task later.')",
"const content = await fetch(taskUrl + '/content', { headers })",
'if (!content.ok) throw new Error(await content.text())',
"await writeFile('video.mp4', Buffer.from(await content.arrayBuffer()))",
]
: ['console.log(data)']),
].join('\n')
}
/**
* 主入口:根据端点类型分发到对应的 builder
* 优先使用 profile 专属请求体(来自 profileRequest),否则用端点默认模板
*
* @param {'curl'|'python'|'typescript'|'javascript'} lang
* @param {string} endpointType
* @param {SampleContext} ctx
* @returns {string}
*/
export function buildSample(lang, endpointType, ctx) {
// 先尝试 profile 专属请求体
// 注:profileRequest 在 example-profiles.js 里定义,通过循环依赖或单独 import 处理
// 为避免循环依赖,这里通过参数传入 adapted
if (ctx._adapted) {
return buildJsonSample(lang, ctx, ctx._adapted)
}
if (endpointType === 'openai-video') {
return buildJsonSample(lang, ctx, {
model: ctx.modelName,
prompt: 'A calm lake at sunset.',
seconds: '4',
})
}
if (endpointType === 'jina-rerank') {
return buildJsonSample(lang, ctx, {
model: ctx.modelName,
query: 'What is the capital of China?',
documents: [
'Beijing is the capital of China.',
'Shanghai is a major city.',
],
top_n: 1,
})
}
if (endpointType === 'anthropic') return buildAnthropicSample(lang, ctx)
if (endpointType === 'gemini') return buildGeminiSample(lang, ctx)
if (endpointType === 'embeddings') return buildEmbeddingSample(lang, ctx)
if (endpointType === 'image-generation') return buildImageSample(lang, ctx)
if (endpointType === 'openai' || endpointType === 'openai-response') {
return buildChatSample(lang, ctx)
}
return ''
}
\ No newline at end of file
/**
* 新 NewAPI 模型使用方式 - Profile 配置
*
* 移植自 newapi/web/src/features/pricing/lib/api-example-profiles.ts
* 适配 Vue3 + JavaScript
*
* 负责:
* 1) profile 名称 → 中文标签映射
* 2) profile → 支持的场景列表(basic / image-reference / video-reference)
* 3) profile + endpoint + scenario → 专属请求体
* 4) profile + endpoint + scenario → 专属参数补充
*/
/** 场景类型 */
export const SCENARIO_LABELS = {
basic: '基础示例',
'image-reference': '图片参考',
'video-reference': '视频参考',
}
/** Profile 标签映射(与新 NewAPI 保持一致) */
export const PROFILE_LABELS = {
standard: '基础示例',
'ctyun-h3': 'MiniMax H3',
seedance: 'Seedance',
seedance2: 'Seedance 2.0',
'ctyun-wan': 'Wan / HappyHorse',
'ctyun-wan-image': 'Wan / HappyHorse I2V',
'ctyun-wan3': 'Wan 3.0',
'ctyun-wan-reference': 'Wan 2.7 R2V',
'ctyun-happyhorse-reference': 'HappyHorse R2V',
'ctyun-seedream': 'Seedream',
'ctyun-seedream-pro': 'Seedream 5.0 pro',
'ctyun-message-image': 'Qwen / Wan Image',
'ctyun-message-image-edit': 'Qwen / Wan Image Edit',
'ctyun-qwen-rerank': 'Qwen Rerank',
'ctyun-gte-rerank': 'GTE Rerank',
'ctyun-vl-embedding': 'Qwen VL Embedding',
}
/**
* 合并同 endpoint 的多个 api_examples 条目(按 profile 去重,providers/groups 取并集)
*/
export function mergeAPIExamples(examples, endpoint) {
const merged = new Map()
for (const example of examples || []) {
if (example.endpoint !== endpoint) continue
const previous = merged.get(example.profile)
merged.set(example.profile, {
...example,
providers: [
...new Set([...(previous?.providers ?? []), ...(example.providers ?? [])]),
].sort(),
groups: [
...new Set([...(previous?.groups ?? []), ...(example.groups ?? [])]),
].sort(),
})
}
return [...merged.values()]
}
/**
* 返回某个 profile 支持的场景列表
*/
export function exampleScenarios(profile) {
if (['ctyun-wan-reference'].includes(profile)) {
return ['image-reference', 'video-reference']
}
if (
[
'ctyun-happyhorse-reference',
'ctyun-wan-image',
'ctyun-message-image-edit',
].includes(profile)
) {
return ['image-reference']
}
if (['ctyun-h3', 'seedance2', 'ctyun-wan3'].includes(profile)) {
return ['basic', 'image-reference', 'video-reference']
}
if (
[
'seedance',
'ctyun-seedream',
'ctyun-seedream-pro',
'ctyun-message-image',
].includes(profile)
) {
return ['basic', 'image-reference']
}
return ['basic']
}
/**
* 根据 (model, endpoint, profile, scenario) 生成专属请求体
* 如果不需要专属请求体(例如 standard),返回 undefined,由默认 builder 处理
*
* @returns {object|undefined}
*/
export function profileRequest(model, endpoint, profile, scenario) {
if (!PROFILE_LABELS[profile] || profile === 'standard') return undefined
if (endpoint === 'openai-video') {
const wan = profile.includes('wan') || profile.includes('happyhorse')
const body = {
model,
prompt: 'A calm lake at sunset.',
seconds: '5',
resolution: profile === 'ctyun-h3' ? '768P' : '1080P',
}
const seedance = profile.startsWith('seedance')
if (seedance) {
delete body.resolution
body.metadata = { resolution: '1080p' }
}
if (profile === 'ctyun-h3') body.ratio = '16:9'
if (scenario !== 'basic') {
const video = scenario === 'video-reference'
const url = video
? 'https://example.com/reference.mp4'
: 'https://example.com/reference.jpg'
if (wan) {
if (profile === 'ctyun-wan-image') {
body.image = url
} else {
body.metadata = {
input: {
media: [
{ type: video ? 'reference_video' : 'reference_image', url },
],
},
}
}
} else {
const type = video ? 'video_url' : 'image_url'
body.metadata = {
...(seedance ? { resolution: '1080p' } : {}),
content: [
{
type,
[type]: { url },
role: video ? 'reference_video' : 'first_frame',
},
],
}
}
}
return body
}
if (endpoint === 'image-generation') {
return {
model,
prompt: 'A calm lake at sunset.',
size: profile.includes('seedream') ? '2K' : '1024x1024',
n: 1,
stream: false,
...(scenario === 'image-reference'
? { image: 'https://example.com/reference.jpg' }
: {}),
}
}
if (endpoint === 'jina-rerank') {
return {
model,
query: 'What is the capital of China?',
documents: [
'Beijing is the capital of China.',
'Shanghai is a major city.',
],
top_n: 1,
...(profile === 'ctyun-qwen-rerank'
? { instruct: 'Rank documents by relevance to the query.' }
: { return_documents: true }),
}
}
if (endpoint === 'embeddings' && profile === 'ctyun-vl-embedding') {
return {
model,
input: {
contents: [
{ text: 'A calm lake.' },
{ image: 'https://example.com/reference.jpg' },
],
},
encoding_format: 'float',
dimensions: 1024,
}
}
return undefined
}
/**
* 在基础参数表上叠加 profile 专属参数
*
* @param {Array} base - 基础参数列表(来自 buildSupportedParameters)
* @param {string} endpoint
* @param {string} profile
* @param {string} scenario
* @returns {Array}
*/
export function profileParameters(base, endpoint, profile, scenario) {
if (!PROFILE_LABELS[profile] || profile === 'standard') return base
let params = base.map((p) => ({ ...p }))
if (endpoint === 'openai-video') {
params = params.filter((p) => p.name !== 'size')
params.push({
name: profile.startsWith('seedance')
? 'metadata.resolution'
: 'resolution',
type: 'enum',
enumValues:
profile === 'ctyun-h3'
? ['480P', '768P', '2K']
: ['480P', '720P', '1080P'],
descriptionKey: '输出分辨率',
})
if (profile === 'ctyun-h3') {
params.push({
name: 'ratio',
type: 'string',
descriptionKey: '输出宽高比。H3 帧输入自适应;纯文本输入需显式指定 ratio。',
})
}
if (scenario !== 'basic') {
const wan = profile.includes('wan') || profile.includes('happyhorse')
if (profile === 'ctyun-wan-image') {
params.push({
name: 'image',
type: 'string',
required: true,
descriptionKey: '输入图片 URL 或图片 URL 数组,用于图片编辑。',
})
} else {
params.push({
name: wan ? 'metadata.input.media' : 'metadata.content',
type: 'array',
required: true,
descriptionKey: wan
? '高级视频输入:input.media 包含类型化媒体 URL;不要混用帧输入与参考输入。'
: '高级视频输入:content 包含 text、image_url、video_url 或 audio_url 项,可指定 role。',
})
}
}
}
if (endpoint === 'image-generation') {
if (scenario === 'image-reference') {
params.push({
name: 'image',
type: 'string',
required: true,
descriptionKey: '输入图片 URL 或图片 URL 数组,用于图片编辑。',
})
}
params.push({
name: 'stream',
type: 'boolean',
defaultValue: false,
descriptionKey: '此适配器中 Qwen/万相图像和 Seedream 5.0 pro 必须使用 stream=false。',
})
const size = params.find((p) => p.name === 'size')
if (size) {
size.descriptionKey = profile.includes('seedream')
? '使用模型支持的图像尺寸,例如 Seedream 推荐 2K。'
: '使用 WIDTHxHEIGHT(每边 512~2048);Wan 同时支持 1K、2K、4K。原生参数 size 使用 WIDTH*HEIGHT。'
}
if (profile.includes('message-image')) {
params.push({
name: 'parameters',
type: 'object',
descriptionKey: '原生图像参数覆盖顶层的 size 和 watermark;需要时可在此填 size、n、seed。',
})
}
}
if (endpoint === 'jina-rerank') {
params.push(
profile === 'ctyun-qwen-rerank'
? {
name: 'instruct',
type: 'string',
descriptionKey: '可选的重排序指令',
}
: {
name: 'return_documents',
type: 'boolean',
descriptionKey: '是否在重排结果中包含文档原文',
}
)
}
if (profile === 'ctyun-vl-embedding') {
params = params.map((p) =>
p.name === 'input'
? {
...p,
type: 'object',
descriptionKey:
'使用 input.contents;每项恰好包含一个非空的 text、image 或 video 字符串。',
}
: p
)
const encoding = params.find((p) => p.name === 'encoding_format')
if (encoding) encoding.enumValues = ['float']
}
return params
}
\ No newline at end of file
/**
* 新 NewAPI 模型使用方式 - 统一导出
*
* 在 puhui 客户端复用 NewAPI 前端代码模板与参数定义
* 来源:newapi/web/src/features/pricing/lib/
*/
// 代码模板
export { buildSample } from './code-samples.js'
// Profile 配置
export {
SCENARIO_LABELS,
PROFILE_LABELS,
mergeAPIExamples,
exampleScenarios,
profileRequest,
profileParameters,
} from './example-profiles.js'
// 参数定义
export {
COMMON_CHAT_PARAMS,
REASONING_PARAMS,
EMBEDDING_PARAMS,
IMAGE_PARAMS,
VIDEO_PARAMS,
buildSchemaParams,
buildEndpointParameters,
} from './api-parameters.js'
\ No newline at end of file
...@@ -391,10 +391,10 @@ ...@@ -391,10 +391,10 @@
</transition> </transition>
<!-- 模型详情侧边栏 --> <!-- 模型详情侧边栏 -->
<el-drawer v-model="showModelDrawer" direction="rtl" :size="500" title="模型详情"> <el-drawer v-model="showModelDrawer" direction="rtl" :size="700" title="模型详情">
<template #default> <template #default>
<div v-if="selectedModel" class="model-detail"> <div v-if="selectedModel" class="model-detail">
<!-- 图标和模型名 --> <!-- 顶部:图标 + 模型名(始终显示,不属于 tab) -->
<div class="model-detail-header"> <div class="model-detail-header">
<div class="model-detail-icon"> <div class="model-detail-icon">
<LobeIcon <LobeIcon
...@@ -406,121 +406,131 @@ ...@@ -406,121 +406,131 @@
<div class="model-detail-name">{{ selectedModel.modelName }}</div> <div class="model-detail-name">{{ selectedModel.modelName }}</div>
</div> </div>
<!-- 基本信息 --> <el-tabs v-model="drawerActiveTab" class="model-detail-tabs">
<div class="model-detail-section"> <!-- Tab 1:概览(基本信息/描述/标签/价格) -->
<div class="section-title">基本信息</div> <el-tab-pane label="概览" name="overview">
<div class="section-desc">模型的详细描述和基本特性</div> <!-- 基本信息 -->
</div> <div class="model-detail-section">
<div class="section-title">基本信息</div>
<div class="section-desc">模型的详细描述和基本特性</div>
</div>
<!-- 描述 --> <!-- 描述 -->
<div class="model-detail-desc"> <div class="model-detail-desc">
{{ selectedModel.description || '暂无描述' }} {{ selectedModel.description || '暂无描述' }}
</div> </div>
<!-- 标签 --> <!-- 标签 -->
<div v-if="selectedModelTags.length > 0" class="model-detail-tags"> <div v-if="selectedModelTags.length > 0" class="model-detail-tags">
<el-tag <el-tag
v-for="(tag, index) in selectedModelTags" v-for="(tag, index) in selectedModelTags"
:key="tag" :key="tag"
size="small" size="small"
:type="tagColors[index % tagColors.length]" :type="tagColors[index % tagColors.length]"
effect="plain" effect="plain"
>{{ tag }}</el-tag> >{{ tag }}</el-tag>
</div> </div>
<!-- 价格信息 --> <!-- 价格信息 -->
<div v-if="selectedModelBillingMode === 'tiered_expr'" class="model-detail-section"> <div v-if="selectedModelBillingMode === 'tiered_expr'" class="model-detail-section">
<div class="section-title">动态计费</div> <div class="section-title">动态计费</div>
<div class="section-desc">价格根据用量档位和请求条件动态调整</div> <div class="section-desc">价格根据用量档位和请求条件动态调整</div>
</div> </div>
<!-- 按次计费 --> <!-- 按次计费 -->
<div v-if="selectedModelIsPerCall" class="price-item"> <div v-if="selectedModelIsPerCall" class="price-item">
<span class="price-label">单次价格</span> <span class="price-label">单次价格</span>
<span class="price-value">¥{{ formatModelPrice(selectedModel.inputPrice) }}</span> <span class="price-value">¥{{ formatModelPrice(selectedModel.inputPrice) }}</span>
</div> </div>
<!-- 分档计费表格 --> <!-- 分档计费表格 -->
<template v-else-if="selectedModelBillingMode === 'tiered_expr' && selectedModel.tiers"> <template v-else-if="selectedModelBillingMode === 'tiered_expr' && selectedModel.tiers">
<div class="tier-table-wrapper"> <div class="tier-table-wrapper">
<div class="tier-table"> <div class="tier-table">
<div class="tier-header"> <div class="tier-header">
<div class="tier-cell header-cell"> <div class="tier-cell header-cell">
<div class="header-label">档位</div> <div class="header-label">档位</div>
</div> </div>
<div class="tier-cell header-cell"> <div class="tier-cell header-cell">
<div class="header-label">输入</div> <div class="header-label">输入</div>
<div class="header-unit">(¥/1M tokens)</div> <div class="header-unit">(¥/1M tokens)</div>
</div> </div>
<div class="tier-cell header-cell"> <div class="tier-cell header-cell">
<div class="header-label">输出</div> <div class="header-label">输出</div>
<div class="header-unit">(¥/1M tokens)</div> <div class="header-unit">(¥/1M tokens)</div>
</div> </div>
<div v-for="col in tierExtraPriceColumns" :key="col.key" class="tier-cell header-cell"> <div v-for="col in tierExtraPriceColumns" :key="col.key" class="tier-cell header-cell">
<div class="header-label">{{ col.label }}</div> <div class="header-label">{{ col.label }}</div>
<div class="header-unit">(¥/1M tokens)</div> <div class="header-unit">(¥/1M tokens)</div>
</div>
</div>
<div v-for="(tier, index) in selectedModel.tiers" :key="index" class="tier-row">
<div class="tier-cell">
<div class="tier-name">{{ tier.name }}</div>
<div v-if="tier.conditionDesc" class="tier-condition">{{ tier.conditionDesc }}</div>
</div>
<div class="tier-cell price-cell">{{ formatModelPrice(tier.inputRatio) }}</div>
<div class="tier-cell price-cell">{{ formatModelPrice(tier.outputRatio) }}</div>
<div v-for="col in tierExtraPriceColumns" :key="col.key" class="tier-cell price-cell">
{{ tier[col.key] != null && tier[col.key] > 0 ? formatModelPrice(tier[col.key]) : '-' }}
</div>
</div>
</div> </div>
</div> </div>
<div v-for="(tier, index) in selectedModel.tiers" :key="index" class="tier-row"> </template>
<div class="tier-cell">
<div class="tier-name">{{ tier.name }}</div> <!-- 条件乘数 -->
<div v-if="tier.conditionDesc" class="tier-condition">{{ tier.conditionDesc }}</div> <template v-if="selectedModelBillingMode === 'tiered_expr' && selectedModel.conditionMultipliers && selectedModel.conditionMultipliers.length > 0">
</div> <div class="model-detail-section">
<div class="tier-cell price-cell">{{ formatModelPrice(tier.inputRatio) }}</div> <div class="section-title">条件乘数</div>
<div class="tier-cell price-cell">{{ formatModelPrice(tier.outputRatio) }}</div> <div class="section-desc">满足条件时,整单价格乘以相应倍率</div>
<div v-for="col in tierExtraPriceColumns" :key="col.key" class="tier-cell price-cell"> </div>
{{ tier[col.key] != null && tier[col.key] > 0 ? formatModelPrice(tier[col.key]) : '-' }} <div class="condition-multipliers">
<div v-for="(cm, index) in selectedModel.conditionMultipliers" :key="index" class="condition-item">
<div class="condition-desc">{{ formatConditionDesc(cm.description) }}</div>
<div class="condition-multiplier">{{ cm.multiplier }}x</div>
</div> </div>
</div> </div>
</div> </template>
</div>
</template>
<!-- 条件乘数 -->
<template v-if="selectedModelBillingMode === 'tiered_expr' && selectedModel.conditionMultipliers && selectedModel.conditionMultipliers.length > 0">
<div class="model-detail-section">
<div class="section-title">条件乘数</div>
<div class="section-desc">满足条件时,整单价格乘以相应倍率</div>
</div>
<div class="condition-multipliers">
<div v-for="(cm, index) in selectedModel.conditionMultipliers" :key="index" class="condition-item">
<div class="condition-desc">{{ formatConditionDesc(cm.description) }}</div>
<div class="condition-multiplier">{{ cm.multiplier }}x</div>
</div>
</div>
</template>
<!-- 按量计费 --> <!-- 按量计费 -->
<template v-else-if="selectedModelBillingMode !== 'tiered_expr'"> <template v-else-if="selectedModelBillingMode !== 'tiered_expr'">
<div class="price-item"> <div class="price-item">
<span class="price-label">输入价格</span> <span class="price-label">输入价格</span>
<span class="price-value">¥{{ formatModelPrice(selectedModel.inputPrice) }} / 1M tokens</span> <span class="price-value">¥{{ formatModelPrice(selectedModel.inputPrice) }} / 1M tokens</span>
</div> </div>
<div class="price-item"> <div class="price-item">
<span class="price-label">输出价格</span> <span class="price-label">输出价格</span>
<span class="price-value">¥{{ formatModelPrice(selectedModel.outputPrice) }} / 1M tokens</span> <span class="price-value">¥{{ formatModelPrice(selectedModel.outputPrice) }} / 1M tokens</span>
</div> </div>
<div v-if="selectedModelCacheReadPrice > 0" class="price-item"> <div v-if="selectedModelCacheReadPrice > 0" class="price-item">
<span class="price-label">缓存读取</span> <span class="price-label">缓存读取</span>
<span class="price-value">¥{{ formatModelPrice(selectedModelCacheReadPrice) }} / 1M tokens</span> <span class="price-value">¥{{ formatModelPrice(selectedModelCacheReadPrice) }} / 1M tokens</span>
</div> </div>
<div v-if="selectedModelCacheCreatePrice > 0" class="price-item"> <div v-if="selectedModelCacheCreatePrice > 0" class="price-item">
<span class="price-label">缓存创建</span> <span class="price-label">缓存创建</span>
<span class="price-value">¥{{ formatModelPrice(selectedModelCacheCreatePrice) }} / 1M tokens</span> <span class="price-value">¥{{ formatModelPrice(selectedModelCacheCreatePrice) }} / 1M tokens</span>
</div> </div>
<div v-if="selectedModel.imageInputPrice > 0" class="price-item"> <div v-if="selectedModel.imageInputPrice > 0" class="price-item">
<span class="price-label">图片输入价格</span> <span class="price-label">图片输入价格</span>
<span class="price-value">¥{{ formatModelPrice(selectedModel.imageInputPrice) }} / 1M tokens</span> <span class="price-value">¥{{ formatModelPrice(selectedModel.imageInputPrice) }} / 1M tokens</span>
</div> </div>
<div v-if="selectedModel.audioInputPrice > 0" class="price-item"> <div v-if="selectedModel.audioInputPrice > 0" class="price-item">
<span class="price-label">音频输入价格</span> <span class="price-label">音频输入价格</span>
<span class="price-value">¥{{ formatModelPrice(selectedModel.audioInputPrice) }} / 1M tokens</span> <span class="price-value">¥{{ formatModelPrice(selectedModel.audioInputPrice) }} / 1M tokens</span>
</div> </div>
<div v-if="selectedModel.audioCompletionPrice > 0" class="price-item"> <div v-if="selectedModel.audioCompletionPrice > 0" class="price-item">
<span class="price-label">音频补全价格</span> <span class="price-label">音频补全价格</span>
<span class="price-value">¥{{ formatModelPrice(selectedModel.audioCompletionPrice) }} / 1M tokens</span> <span class="price-value">¥{{ formatModelPrice(selectedModel.audioCompletionPrice) }} / 1M tokens</span>
</div> </div>
</template> </template>
</el-tab-pane>
<!-- Tab 2:使用方式 -->
<el-tab-pane label="使用方式" name="usage">
<ModelUsageMethod :model="selectedModel" />
</el-tab-pane>
</el-tabs>
</div> </div>
</template> </template>
</el-drawer> </el-drawer>
...@@ -550,6 +560,7 @@ import PDFObject from 'pdfobject'; ...@@ -550,6 +560,7 @@ import PDFObject from 'pdfobject';
import axios from "axios"; import axios from "axios";
import ModelCard from '@/views/console/components/model-card.vue' import ModelCard from '@/views/console/components/model-card.vue'
import LobeIcon from '@/components/LobeIcon/index.vue' import LobeIcon from '@/components/LobeIcon/index.vue'
import ModelUsageMethod from '@/components/ModelUsageMethod.vue'
import { Search } from '@element-plus/icons-vue' import { Search } from '@element-plus/icons-vue'
const route = useRoute() const route = useRoute()
...@@ -579,9 +590,11 @@ const qrCode = ref({ ...@@ -579,9 +590,11 @@ const qrCode = ref({
// 模型详情侧边栏 // 模型详情侧边栏
const showModelDrawer = ref(false) const showModelDrawer = ref(false)
const selectedModel = ref(null) const selectedModel = ref(null)
const drawerActiveTab = ref('overview')
function openModelDetail(model) { function openModelDetail(model) {
selectedModel.value = model selectedModel.value = model
drawerActiveTab.value = 'overview' // 每次打开重置到「概览」Tab
showModelDrawer.value = true showModelDrawer.value = true
} }
...@@ -1717,7 +1730,7 @@ onMounted(() => { ...@@ -1717,7 +1730,7 @@ onMounted(() => {
border: 1px solid #ebeef5; border: 1px solid #ebeef5;
border-radius: 8px; border-radius: 8px;
/* 基础列(档位,输入,输出)+扩展价格列(最多7列),每列最小100px */ /* 基础列(档位,输入,输出)+扩展价格列(最多7列),每列最小100px */
min-width: 1000px; // min-width: 1000px;
} }
.tier-header { .tier-header {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment