| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616 |
- import React, { useEffect, useMemo, useState } from 'react';
- import { useNavigate, useParams } from 'react-router-dom';
- import { getPricingMappingLookup, modelApi, ParsedPricingResponse } from '../services/modelApi';
- import { ArrowLeft, Loader2, Image, Video, Mic, FileText, CheckCircle2, XCircle, Copy } from '../icons/commonIcons';
- import { copyToClipboard } from '../utils/clipboard';
- const pricingKeyLabels: Record<string, string> = {
- input: '输入', output: '输出', input_thinking: '输入(思考)', output_thinking: '输出(思考)',
- input_cache_hit: '输入(缓存命中)', input_cache_hit_thinking: '输入(缓存命中·思考)',
- input_batch: '输入(批量)', input_batch_thinking: '输入(批量·思考)',
- output_batch: '输出(批量)', output_thinking_batch: '输出(批量·思考)',
- cache_write: '缓存写入', cache_read: '缓存读取',
- explicit_cache_create: '显式缓存写入', explicit_cache_hit: '显式缓存命中',
- explicit_cache_create_thinking: '显式缓存写入(思考)', explicit_cache_hit_thinking: '显式缓存命中(思考)',
- image_input: '图片输入', text_input: '文本输入'
- };
- const limitKeyLabels: Record<string, string> = {
- max_input_length: '最大输入长度', max_output_length: '最大输出长度',
- rpm: '每分钟请求数 (RPM)', tpm: 'TPM', context_length: '上下文长度', note: '备注'
- };
- const labelTranslations: Record<string, string> = {
- image_generation: '图像生成', video_generation: '视频生成', audio_generation: '音频生成',
- image_input: '图片输入', text_input: '文本输入', model_experience: '模型体验',
- function_calling: 'function calling', structured_output: '结构化输出',
- internet_search: '网页搜索', web_search: '网页搜索', prefix_continuation: '前缀续写',
- cache: 'cache缓存', batch_inference: '批量推理', model_tuning: '模型微调',
- model_optimization: '模型优化', text: '文本', image: '图片', video: '视频', audio: '音频',
- input: '输入', output: '输出', tuning: '微调'
- };
- const formatValue = (value: any, unit?: string): string => {
- if (value === undefined || value === null || value === '') return '—';
- return `${value}${unit ? ` ${unit}` : ''}`;
- };
- // 渲染价格单元格(支持原价划线 + 折扣价 + 折扣标签)
- const renderPriceCell = (value: any, originalValue: any, unit: string, discountRate?: number) => {
- const hasDiscount = originalValue !== undefined && originalValue !== null
- && Number(originalValue) > 0
- && Math.abs(Number(originalValue) - Number(value)) > 0.000001;
- // 优先用传入的 discountRate,否则从原价/折扣价推算
- const rate = discountRate ?? (hasDiscount && Number(originalValue) > 0
- ? Number(value) / Number(originalValue)
- : 1);
- const tenths = Math.round(rate * 10);
- const discountLabel = hasDiscount && tenths < 10 ? `${tenths}折` : undefined;
- return (
- <span className="flex flex-col items-end gap-0.5">
- {hasDiscount && (
- <span className="flex items-center gap-1">
- <span className="line-through text-gray-400 text-xs">{formatValue(originalValue, unit)}</span>
- {discountLabel && (
- <span className="px-1 py-0.5 bg-red-500 text-white text-[10px] font-bold rounded leading-none">
- {discountLabel}
- </span>
- )}
- </span>
- )}
- <span className={hasDiscount ? 'text-blue-600 font-semibold' : 'text-gray-900 font-medium'}>
- {formatValue(value, unit)}
- </span>
- </span>
- );
- };
- const getRangeLabel = (item: any): string => item?.input_range || item?.range || item?.inputRange || item?.tier_range || '—';
- const translateLabel = (keyOrLabel: string): string => {
- if (!keyOrLabel) return '—';
- if (/[\u4e00-\u9fa5]/.test(keyOrLabel)) return keyOrLabel;
- if (labelTranslations[keyOrLabel]) return labelTranslations[keyOrLabel];
- if (keyOrLabel.includes('_')) return keyOrLabel.replace(/_/g, ' ');
- return keyOrLabel;
- };
- const getDisplayLabel = (key: string): string => pricingKeyLabels[key] || translateLabel(key) || key;
- // API示例代码生成(根据模型分类返回对应示例)
- const generateApiExamples = (modelCode: string, categories: number[] = []) => {
- const baseUrl = import.meta.env.VITE_OPENAPI_BASE_URL || window.location.origin;
- const api = `${baseUrl}/api/v1`;
- // 判断主分类(优先级:图像编辑 > 图像生成 > 视频 > TTS > STT > Embedding > Rerank > LLM)
- const has = (c: number) => categories.includes(c);
- const code = modelCode.toLowerCase();
- const isRealtime = code.includes('realtime');
- const isClone = code.includes('clone');
- // realtime 模型:WebSocket 协议,平台不支持代理,不可通过本平台 API 调用
- if (isRealtime) {
- return { _isUnsupported: true, reason: '该模型使用 WebSocket 实时流协议,当前平台仅支持 HTTP 接口代理,暂不支持通过平台 API Key 调用此模型。' } as any;
- }
- // cosyvoice-clone 系列:两步流程(先创建音色,再合成)
- if (isClone && has(2)) {
- return {
- curl: `# ${modelCode} 声音克隆模型,需要两步调用\n\n# 第一步:上传参考音频,创建克隆音色(3~10秒清晰人声)\ncurl ${baseUrl}/api/audio/voice/create \\\n -H "Authorization: Bearer YOUR_API_KEY" \\\n -F "file=@/path/to/reference.mp3" \\\n -F "target_model=${modelCode}" \\\n -F "prefix=my_voice" \\\n -F "voice_name=我的音色"\n# 返回 voice_id,等待 status 变为 OK\n\n# 第二步:用 voice_id 合成语音\ncurl ${baseUrl}/api/v1/audio/speech \\\n -H "Authorization: Bearer YOUR_API_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"model": "${modelCode}", "input": "你好,世界", "voice": "YOUR_VOICE_ID"}' \\\n --output cloned.mp3`,
- python: `import requests\nimport time\n\nheaders = {"Authorization": "Bearer YOUR_API_KEY"}\nbase = "${baseUrl}"\n\n# 第一步:上传参考音频,创建克隆音色\nwith open("/path/to/reference.mp3", "rb") as f:\n resp = requests.post(\n f"{base}/api/audio/voice/create",\n headers=headers,\n files={"file": f},\n data={"target_model": "${modelCode}", "prefix": "my_voice", "voice_name": "我的音色"}\n )\nvoice_id = resp.json()["data"]["voice_id"]\nprint(f"voice_id: {voice_id}") # 等待 status 变为 OK\n\n# 第二步:用 voice_id 合成语音\nresp = requests.post(\n f"{base}/api/v1/audio/speech",\n headers={**headers, "Content-Type": "application/json"},\n json={"model": "${modelCode}", "input": "你好,世界", "voice": voice_id}\n)\nwith open("cloned.mp3", "wb") as f:\n f.write(resp.content)`,
- nodejs: `import fs from 'fs';\n\nconst base = '${baseUrl}';\nconst headers = { 'Authorization': 'Bearer YOUR_API_KEY' };\n\n// 第一步:上传参考音频,创建克隆音色\nconst form = new FormData();\nform.append('file', fs.createReadStream('/path/to/reference.mp3'));\nform.append('target_model', '${modelCode}');\nform.append('prefix', 'my_voice');\nform.append('voice_name', '我的音色');\nconst step1 = await fetch(\`\${base}/api/audio/voice/create\`, { method: 'POST', headers, body: form });\nconst { voice_id } = (await step1.json()).data;\nconsole.log('voice_id:', voice_id); // 等待 status 变为 OK\n\n// 第二步:用 voice_id 合成语音\nconst step2 = await fetch(\`\${base}/api/v1/audio/speech\`, {\n method: 'POST',\n headers: { ...headers, 'Content-Type': 'application/json' },\n body: JSON.stringify({ model: '${modelCode}', input: '你好,世界', voice: voice_id })\n});\nfs.writeFileSync('cloned.mp3', Buffer.from(await step2.arrayBuffer()));`,
- java: `// 第一步:POST ${baseUrl}/api/audio/voice/create\n// multipart: file=<音频>, target_model=${modelCode}, prefix=my_voice, voice_name=我的音色\n// 返回 voice_id,等待 status=OK\n\n// 第二步:POST ${baseUrl}/api/v1/audio/speech\n// JSON: { "model": "${modelCode}", "input": "你好,世界", "voice": "<voice_id>" }`
- };
- }
- // OCR 模型:必须传图片
- const OCR_MODELS = ['qwen-vl-ocr'];
- if (OCR_MODELS.includes(modelCode)) {
- return {
- curl: `curl ${api}/chat/completions \\
- -H "Authorization: Bearer YOUR_API_KEY" \\
- -H "Content-Type: application/json" \\
- -d '{
- "model": "${modelCode}",
- "messages": [
- {
- "role": "user",
- "content": [
- {"type": "image_url", "image_url": {"url": "https://example.com/your-image.jpg"}},
- {"type": "text", "text": "请识别图片中的文字"}
- ]
- }
- ]
- }'`,
- python: `from openai import OpenAI\n\nclient = OpenAI(api_key="YOUR_API_KEY", base_url="${api}")\n\nresponse = client.chat.completions.create(\n model="${modelCode}",\n messages=[\n {\n "role": "user",\n "content": [\n {"type": "image_url", "image_url": {"url": "https://example.com/your-image.jpg"}},\n {"type": "text", "text": "请识别图片中的文字"}\n ]\n }\n ]\n)\nprint(response.choices[0].message.content)`,
- nodejs: `import OpenAI from 'openai';\n\nconst client = new OpenAI({ apiKey: 'YOUR_API_KEY', baseURL: '${api}' });\n\nconst res = await client.chat.completions.create({\n model: '${modelCode}',\n messages: [\n {\n role: 'user',\n content: [\n { type: 'image_url', image_url: { url: 'https://example.com/your-image.jpg' } },\n { type: 'text', text: '请识别图片中的文字' }\n ]\n }\n ]\n});\nconsole.log(res.choices[0].message.content);`,
- java: `// ${modelCode} 是 OCR 模型,消息中必须包含图片\n// 调用 ${api}/chat/completions\n// content 需包含 image_url 和 text 两个 part`
- };
- }
- if (has(6)) {
- // 图像编辑(图生图)
- return {
- curl: `curl ${api}/images/edits \\
- -H "Authorization: Bearer YOUR_API_KEY" \\
- -F "model=${modelCode}" \\
- -F "prompt=把图片变成油画风格" \\
- -F "image=@/path/to/image.png"`,
- python: `import requests\n\nresponse = requests.post(\n "${api}/images/edits",\n headers={"Authorization": "Bearer YOUR_API_KEY"},\n data={"model": "${modelCode}", "prompt": "把图片变成油画风格"},\n files={"image": open("/path/to/image.png", "rb")}\n)\nprint(response.json())`,
- nodejs: `const form = new FormData();\nform.append("model", "${modelCode}");\nform.append("prompt", "把图片变成油画风格");\nform.append("image", fs.createReadStream("/path/to/image.png"));\n\nconst res = await fetch("${api}/images/edits", {\n method: "POST",\n headers: { "Authorization": "Bearer YOUR_API_KEY" },\n body: form\n});\nconsole.log(await res.json());`,
- java: `// 使用 multipart/form-data 调用 ${api}/images/edits\n// model=${modelCode}, prompt=把图片变成油画风格, image=<file>`
- };
- }
- if (has(4)) {
- // 图像生成(文生图)
- return {
- curl: `curl ${api}/images/generations \\
- -H "Authorization: Bearer YOUR_API_KEY" \\
- -H "Content-Type: application/json" \\
- -d '{"model": "${modelCode}", "prompt": "一只可爱的猫咪", "n": 1}'`,
- python: `from openai import OpenAI\n\nclient = OpenAI(api_key="YOUR_API_KEY", base_url="${api}")\nresponse = client.images.generate(model="${modelCode}", prompt="一只可爱的猫咪", n=1)\nprint(response.data[0].url)`,
- nodejs: `import OpenAI from 'openai';\nconst client = new OpenAI({ apiKey: 'YOUR_API_KEY', baseURL: '${api}' });\nconst res = await client.images.generate({ model: '${modelCode}', prompt: '一只可爱的猫咪', n: 1 });\nconsole.log(res.data[0].url);`,
- java: `// 调用 ${api}/images/generations\n// model=${modelCode}, prompt=一只可爱的猫咪`
- };
- }
- if (has(5)) {
- // 视频生成
- return {
- curl: `curl ${api}/videos/generations \\
- -H "Authorization: Bearer YOUR_API_KEY" \\
- -H "Content-Type: application/json" \\
- -d '{"model": "${modelCode}", "prompt": "一段美丽的风景视频"}'`,
- python: `import requests\nresponse = requests.post(\n "${api}/videos/generations",\n headers={"Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json"},\n json={"model": "${modelCode}", "prompt": "一段美丽的风景视频"}\n)\nprint(response.json())`,
- nodejs: `const res = await fetch("${api}/videos/generations", {\n method: "POST",\n headers: { "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" },\n body: JSON.stringify({ model: "${modelCode}", prompt: "一段美丽的风景视频" })\n});\nconsole.log(await res.json());`,
- java: `// 调用 ${api}/videos/generations\n// model=${modelCode}`
- };
- }
- if (has(2)) {
- // TTS — plus 模型只支持 longanyang/longanhuan,其他用 longxiaochun_v3
- const ttsVoice = code.includes('plus') ? 'longanyang' : 'longxiaochun_v3';
- return {
- curl: `curl ${api}/audio/speech \\
- -H "Authorization: Bearer YOUR_API_KEY" \\
- -H "Content-Type: application/json" \\
- -d '{"model": "${modelCode}", "input": "你好,世界", "voice": "${ttsVoice}", "response_format": "mp3"}' \\
- --output speech.mp3`,
- python: `from openai import OpenAI\nclient = OpenAI(api_key="YOUR_API_KEY", base_url="${api}")\nresponse = client.audio.speech.create(model="${modelCode}", input="你好,世界", voice="${ttsVoice}", response_format="mp3")\nresponse.stream_to_file("speech.mp3")`,
- nodejs: `import OpenAI from 'openai';\nconst client = new OpenAI({ apiKey: 'YOUR_API_KEY', baseURL: '${api}' });\nconst mp3 = await client.audio.speech.create({ model: '${modelCode}', input: '你好,世界', voice: '${ttsVoice}', response_format: 'mp3' });\nconst buffer = Buffer.from(await mp3.arrayBuffer());\nawait fs.promises.writeFile('speech.mp3', buffer);`,
- java: `// 调用 ${api}/audio/speech\n// model=${modelCode}, voice=${ttsVoice}`
- };
- }
- if (has(3)) {
- // STT
- return {
- curl: `curl ${api}/audio/transcriptions \\
- -H "Authorization: Bearer YOUR_API_KEY" \\
- -F "model=${modelCode}" \\
- -F "file=@/path/to/audio.mp3"`,
- python: `from openai import OpenAI\nclient = OpenAI(api_key="YOUR_API_KEY", base_url="${api}")\nwith open("/path/to/audio.mp3", "rb") as f:\n transcript = client.audio.transcriptions.create(model="${modelCode}", file=f)\nprint(transcript.text)`,
- nodejs: `import OpenAI from 'openai';\nconst client = new OpenAI({ apiKey: 'YOUR_API_KEY', baseURL: '${api}' });\nconst transcript = await client.audio.transcriptions.create({ model: '${modelCode}', file: fs.createReadStream('/path/to/audio.mp3') });\nconsole.log(transcript.text);`,
- java: `// 调用 ${api}/audio/transcriptions\n// model=${modelCode}`
- };
- }
- if (has(7)) {
- // Embedding
- return {
- curl: `curl ${api}/embeddings \\
- -H "Authorization: Bearer YOUR_API_KEY" \\
- -H "Content-Type: application/json" \\
- -d '{"model": "${modelCode}", "input": "你好,世界"}'`,
- python: `from openai import OpenAI\nclient = OpenAI(api_key="YOUR_API_KEY", base_url="${api}")\nresponse = client.embeddings.create(model="${modelCode}", input="你好,世界")\nprint(response.data[0].embedding)`,
- nodejs: `import OpenAI from 'openai';\nconst client = new OpenAI({ apiKey: 'YOUR_API_KEY', baseURL: '${api}' });\nconst res = await client.embeddings.create({ model: '${modelCode}', input: '你好,世界' });\nconsole.log(res.data[0].embedding);`,
- java: `// 调用 ${api}/embeddings\n// model=${modelCode}`
- };
- }
- if (has(8)) {
- // Rerank
- return {
- curl: `curl ${api}/rerank \\
- -H "Authorization: Bearer YOUR_API_KEY" \\
- -H "Content-Type: application/json" \\
- -d '{"model": "${modelCode}", "query": "什么是人工智能", "documents": ["人工智能是计算机科学的一个分支", "机器学习是AI的子领域"]}'`,
- python: `import requests\nresponse = requests.post(\n "${api}/rerank",\n headers={"Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json"},\n json={"model": "${modelCode}", "query": "什么是人工智能", "documents": ["人工智能是计算机科学的一个分支", "机器学习是AI的子领域"]}\n)\nprint(response.json())`,
- nodejs: `const res = await fetch("${api}/rerank", {\n method: "POST",\n headers: { "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" },\n body: JSON.stringify({ model: "${modelCode}", query: "什么是人工智能", documents: ["人工智能是计算机科学的一个分支", "机器学习是AI的子领域"] })\n});\nconsole.log(await res.json());`,
- java: `// 调用 ${api}/rerank\n// model=${modelCode}`
- };
- }
- if (has(1)) {
- // 多模态(图文/视频理解)—— 返回两套示例
- return {
- _isMultimodal: true,
- text: {
- curl: `curl ${api}/chat/completions \\
- -H "Authorization: Bearer YOUR_API_KEY" \\
- -H "Content-Type: application/json" \\
- -d '{
- "model": "${modelCode}",
- "messages": [
- {"role": "user", "content": "你好,请介绍一下你自己"}
- ],
- "stream": false
- }'`,
- python: `from openai import OpenAI\n\nclient = OpenAI(api_key="YOUR_API_KEY", base_url="${api}")\n\nresponse = client.chat.completions.create(\n model="${modelCode}",\n messages=[{"role": "user", "content": "你好,请介绍一下你自己"}]\n)\nprint(response.choices[0].message.content)`,
- nodejs: `import OpenAI from 'openai';\n\nconst client = new OpenAI({ apiKey: 'YOUR_API_KEY', baseURL: '${api}' });\n\nconst res = await client.chat.completions.create({\n model: '${modelCode}',\n messages: [{ role: 'user', content: '你好,请介绍一下你自己' }]\n});\nconsole.log(res.choices[0].message.content);`,
- java: `OpenAIClient client = OpenAIOkHttpClient.builder()\n .apiKey("YOUR_API_KEY")\n .baseUrl("${api}")\n .build();\n\nChatCompletionCreateParams params = ChatCompletionCreateParams.builder()\n .model("${modelCode}")\n .addMessage(ChatCompletionMessageParam.ofUser(\n ChatCompletionUserMessageParam.builder().content("你好,请介绍一下你自己").build()))\n .build();\n\nChatCompletion completion = client.chat().completions().create(params);\nSystem.out.println(completion.choices().get(0).message().content());`
- },
- multimodal: {
- curl: `curl ${api}/chat/completions \\
- -H "Authorization: Bearer YOUR_API_KEY" \\
- -H "Content-Type: application/json" \\
- -d '{
- "model": "${modelCode}",
- "messages": [
- {
- "role": "user",
- "content": [
- {"type": "image_url", "image_url": {"url": "https://example.com/your-image.jpg"}},
- {"type": "text", "text": "请描述这张图片的内容"}
- ]
- }
- ],
- "stream": false
- }'`,
- python: `from openai import OpenAI\n\nclient = OpenAI(api_key="YOUR_API_KEY", base_url="${api}")\n\nresponse = client.chat.completions.create(\n model="${modelCode}",\n messages=[\n {\n "role": "user",\n "content": [\n {"type": "image_url", "image_url": {"url": "https://example.com/your-image.jpg"}},\n {"type": "text", "text": "请描述这张图片的内容"}\n ]\n }\n ]\n)\nprint(response.choices[0].message.content)`,
- nodejs: `import OpenAI from 'openai';\n\nconst client = new OpenAI({ apiKey: 'YOUR_API_KEY', baseURL: '${api}' });\n\nconst res = await client.chat.completions.create({\n model: '${modelCode}',\n messages: [\n {\n role: 'user',\n content: [\n { type: 'image_url', image_url: { url: 'https://example.com/your-image.jpg' } },\n { type: 'text', text: '请描述这张图片的内容' }\n ]\n }\n ]\n});\nconsole.log(res.choices[0].message.content);`,
- java: `OpenAIClient client = OpenAIOkHttpClient.builder()\n .apiKey("YOUR_API_KEY")\n .baseUrl("${api}")\n .build();\n\n// content 为多模态列表,包含 image_url 和 text\n// 请参考 OpenAI Java SDK 的多模态消息构建方式`
- }
- } as any;
- }
- // 默认:LLM
- return {
- curl: `curl ${api}/chat/completions \\
- -H "Authorization: Bearer YOUR_API_KEY" \\
- -H "Content-Type: application/json" \\
- -d '{
- "model": "${modelCode}",
- "messages": [
- {"role": "user", "content": "你好"}
- ],
- "stream": false
- }'`,
- python: `from openai import OpenAI\n\nclient = OpenAI(\n api_key="YOUR_API_KEY",\n base_url="${api}"\n)\n\nresponse = client.chat.completions.create(\n model="${modelCode}",\n messages=[{"role": "user", "content": "你好"}]\n)\nprint(response.choices[0].message.content)`,
- nodejs: `import OpenAI from 'openai';\n\nconst client = new OpenAI({ apiKey: 'YOUR_API_KEY', baseURL: '${api}' });\n\nconst res = await client.chat.completions.create({\n model: '${modelCode}',\n messages: [{ role: 'user', content: '你好' }]\n});\nconsole.log(res.choices[0].message.content);`,
- java: `OpenAIClient client = OpenAIOkHttpClient.builder()\n .apiKey("YOUR_API_KEY")\n .baseUrl("${api}")\n .build();\n\nChatCompletionCreateParams params = ChatCompletionCreateParams.builder()\n .model("${modelCode}")\n .addMessage(ChatCompletionMessageParam.ofUser(\n ChatCompletionUserMessageParam.builder().content("你好").build()))\n .build();\n\nChatCompletion completion = client.chat().completions().create(params);\nSystem.out.println(completion.choices().get(0).message().content());`
- };
- };
- const ModelPricingDetail: React.FC = () => {
- const navigate = useNavigate();
- const { modelCode } = useParams();
- const [data, setData] = useState<ParsedPricingResponse | null>(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState<string | null>(null);
- const [activeTab, setActiveTab] = useState<'curl' | 'python' | 'nodejs' | 'java'>('curl');
- const [activeInputMode, setActiveInputMode] = useState<'text' | 'multimodal'>('text');
- const [copied, setCopied] = useState(false);
- useEffect(() => {
- const fetchDetail = async () => {
- if (!modelCode) { setError('模型代码缺失'); setLoading(false); return; }
- try {
- setLoading(true);
- let resolvedCode = modelCode;
- try {
- const mapping = await getPricingMappingLookup();
- resolvedCode = mapping.get(modelCode) || modelCode;
- } catch { resolvedCode = modelCode; }
- const response = await modelApi.getParsedPricing(resolvedCode);
- setData(response?.data ?? null);
- } catch { setError('获取模型详情失败'); }
- finally { setLoading(false); }
- };
- fetchDetail();
- }, [modelCode]);
- const pricingView = useMemo(() => {
- if (!data?.model_pricing) return null;
- if (Array.isArray(data.model_pricing)) {
- const arr = data.model_pricing as any[];
- const looksLikeList = arr.length > 0 && arr.every((it) => (it && (it.item || it.name) && (it.price !== undefined || it.amount !== undefined || it.value !== undefined)) && !it.input_range && !it.range && !it.tier_range && !it.inputRange);
- if (looksLikeList) return { type: 'list', items: arr } as const;
- const tiers = arr;
- const columns = new Set<string>();
- tiers.forEach((tier) => { Object.keys(tier || {}).forEach((key) => {
- // 过滤掉元数据字段和 _original 后缀字段(原价字段,不单独渲染)
- if (!['unit', 'input_range', 'range', 'inputRange', 'tier_range', 'discount_rate'].includes(key) && !key.endsWith('_original')) {
- columns.add(key);
- }
- }); });
- return { type: 'tier', tiers, columns: Array.from(columns) } as const;
- }
- if (typeof data.model_pricing === 'object') {
- const pricing = data.model_pricing as Record<string, any>;
- const entries = Object.entries(pricing || {});
- const looksLikeNamedPriceObjects = entries.length > 0 && entries.every(([, v]) => v && typeof v === 'object' && (v.price !== undefined || v.amount !== undefined || v.value !== undefined || v.price_cny_per_image !== undefined));
- if (looksLikeNamedPriceObjects) {
- const items = entries.map(([k, v]) => ({ item: translateLabel(v.item ?? v.name ?? k), price: v.price ?? v.amount ?? v.value ?? v.price_cny_per_image, unit: v.unit ?? v.unit_text ?? '' }));
- return { type: 'list', items } as const;
- }
- return { type: 'simple', pricing } as const;
- }
- return null;
- }, [data]);
- const featureList = useMemo(() => {
- const features = data?.model_capabilities?.features;
- if (!features || typeof features !== 'object') return [];
- return Object.entries(features).map(([key, value]) => ({ key, label: translateLabel(key), enabled: Boolean(value) }));
- }, [data]);
- const [showTier, setShowTier] = useState(false);
- const [selectedTierIndex, setSelectedTierIndex] = useState(0);
- useEffect(() => { setSelectedTierIndex(0); setShowTier(false); }, [pricingView?.type]);
- const apiExamples = useMemo(() => data?.model_code ? generateApiExamples(data.model_code, data.categories || []) : null, [data?.model_code, data?.categories]);
- const renderModelInfo = () => (
- <div className="space-y-6">
- {data?.model_intro && (
- <div className="bg-white rounded-2xl border border-gray-100 p-6">
- <h2 className="text-lg font-semibold text-gray-900 mb-3">模型介绍</h2>
- <p className="text-sm text-gray-700 leading-relaxed">{data.model_intro}</p>
- </div>
- )}
- {data?.model_tags?.length ? (
- <div className="bg-white rounded-2xl border border-gray-100 p-6">
- <h2 className="text-lg font-semibold text-gray-900 mb-3">模型标签</h2>
- <div className="flex flex-wrap gap-2">
- {data.model_tags.map((tag) => (<span key={tag} className="px-3 py-1 rounded-full bg-blue-50 text-blue-700 text-xs font-semibold">{tag}</span>))}
- </div>
- </div>
- ) : null}
- {data?.model_capabilities && (
- <div className="bg-white rounded-2xl border border-gray-100 p-6">
- <h2 className="text-lg font-semibold text-gray-900 mb-4">模型能力</h2>
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
- <div>
- <div className="text-sm font-semibold text-gray-800 mb-2">输入模态</div>
- <div className="flex items-center gap-3 flex-wrap">
- {(data.model_capabilities.input_modalities || []).map((item: string) => {
- const icon = item?.toLowerCase().includes('image') ? <Image className="w-4 h-4" /> : item?.toLowerCase().includes('video') ? <Video className="w-4 h-4" /> : item?.toLowerCase().includes('audio') ? <Mic className="w-4 h-4" /> : <FileText className="w-4 h-4" />;
- return (<div key={item} className="flex items-center gap-2 bg-white border border-gray-100 rounded-lg px-3 py-2 text-sm"><div className="w-6 h-6 flex items-center justify-center text-gray-600">{icon}</div><div className="text-gray-700">{translateLabel(item)}</div></div>);
- })}
- </div>
- </div>
- <div>
- <div className="text-sm font-semibold text-gray-800 mb-2">输出模态</div>
- <div className="flex items-center gap-3 flex-wrap">
- {(data.model_capabilities.output_modalities || []).map((item: string) => {
- const icon = item?.toLowerCase().includes('image') ? <Image className="w-4 h-4" /> : item?.toLowerCase().includes('video') ? <Video className="w-4 h-4" /> : item?.toLowerCase().includes('audio') ? <Mic className="w-4 h-4" /> : <FileText className="w-4 h-4" />;
- return (<div key={item} className="flex items-center gap-2 bg-white border border-gray-100 rounded-lg px-3 py-2 text-sm"><div className="w-6 h-6 flex items-center justify-center text-gray-600">{icon}</div><div className="text-gray-700">{translateLabel(item)}</div></div>);
- })}
- </div>
- </div>
- </div>
- {featureList.length ? (
- <div className="mt-4">
- <div className="text-sm font-semibold text-gray-800 mb-2">功能特性</div>
- <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
- {featureList.map((feature) => (
- <div key={feature.key} className="flex items-center justify-between bg-white rounded-lg px-3 py-3 text-sm border-b border-gray-100">
- <span className="text-gray-600">{feature.label}</span>
- {feature.enabled ? <span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-emerald-50 text-emerald-600 border border-emerald-100"><CheckCircle2 className="w-4 h-4" /></span> : <span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white text-gray-400 border border-gray-200"><XCircle className="w-4 h-4" /></span>}
- </div>
- ))}
- </div>
- </div>
- ) : null}
- </div>
- )}
- {pricingView && (
- <div className="bg-white rounded-2xl border border-gray-100 p-6">
- <div className="flex items-center justify-between mb-4">
- <h2 className="text-lg font-semibold text-gray-900">价格信息</h2>
- {pricingView.type === 'tier' && (
- <div className="flex items-center gap-3">
- <button type="button" onClick={() => setShowTier((s) => !s)} className="text-sm px-3 py-1 rounded-full border border-gray-200 bg-white text-gray-700 hover:bg-gray-50">{showTier ? '隐藏阶梯表' : '阶梯计费'}</button>
- <select value={selectedTierIndex} onChange={(e) => setSelectedTierIndex(Number(e.target.value))} className="text-sm px-3 py-1 rounded-full border border-gray-100 bg-white">
- {pricingView.tiers.map((t: any, idx: number) => (<option key={idx} value={idx}>{getRangeLabel(t)}</option>))}
- </select>
- </div>
- )}
- </div>
- {pricingView.type === 'simple' && (() => {
- const p = pricingView.pricing as any;
- const unit = p.unit;
- const entries = Object.entries(p).filter(([k, v]) => k !== 'unit' && v !== null && v !== undefined && !Array.isArray(v) && typeof v !== 'object');
- const isInputKey = (k: string) => /(^|_)input|image_input|text_input|输入/i.test(k);
- const isOutputKey = (k: string) => /(^|_)output|输出/i.test(k);
- const left: [string, any][] = [], right: [string, any][] = [], other: [string, any][] = [];
- entries.forEach((entry) => { const [key] = entry; if (isInputKey(key)) left.push(entry); else if (isOutputKey(key)) right.push(entry); else other.push(entry); });
- other.forEach((it) => { if (left.length <= right.length) left.push(it); else right.push(it); });
- const renderEntry = ([key, value]: [string, any]) => (<div key={key} className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2 text-sm"><span className="text-gray-600">{getDisplayLabel(key)}</span>{renderPriceCell(value, p[`${key}_original`], unit, p.discount_rate)}</div>);
- return (<div className="grid grid-cols-1 md:grid-cols-2 gap-3"><div className="space-y-3 w-full">{left.map(renderEntry)}</div><div className="space-y-3 w-full">{right.map(renderEntry)}</div></div>);
- })()}
- {pricingView.type === 'list' && (() => {
- const items = pricingView.items || [];
- const isInput = (l: string) => /(^|_)input|image_input|text_input|输入/i.test(String(l));
- const isOutput = (l: string) => /(^|_)output|输出/i.test(String(l));
- const left: any[] = [], right: any[] = [], other: any[] = [];
- items.forEach((it) => { const rawLabel = it.item || it.name || it.title || ''; if (isInput(rawLabel)) left.push(it); else if (isOutput(rawLabel)) right.push(it); else other.push(it); });
- other.forEach((it) => { if (left.length <= right.length) left.push(it); else right.push(it); });
- const renderPill = (it: any, idx: number) => (<div key={idx} className="flex items-center justify-between bg-gray-50 rounded-md px-4 py-3 text-sm w-full"><div className="text-sm text-gray-600">{translateLabel(it.item || it.name || it.title || '—')}</div>{renderPriceCell(it.price ?? it.amount ?? it.value, it.price_original, it.unit || '', it.discount_rate)}</div>);
- return (<div className="grid grid-cols-1 md:grid-cols-2 gap-4"><div className="space-y-3">{left.map(renderPill)}</div><div className="space-y-3">{right.map(renderPill)}</div></div>);
- })()}
- {pricingView.type === 'tier' && !showTier && (() => {
- const tier = pricingView.tiers[selectedTierIndex] || {};
- const cols = pricingView.columns || [];
- const preferredOrder = ['input', 'input_thinking', 'input_cache_hit', 'input_cache_hit_thinking', 'input_batch', 'input_batch_thinking', 'output', 'output_thinking', 'output_batch', 'output_thinking_batch', 'cache_write', 'cache_read', 'explicit_cache_create', 'explicit_cache_hit', 'explicit_cache_create_thinking', 'explicit_cache_hit_thinking', 'image_input', 'text_input'];
- const inputCols = cols.filter((c: string) => /(^|_)input|image_input|text_input/i.test(c));
- const outputCols = cols.filter((c: string) => /(^|_)output/i.test(c));
- const remaining = cols.filter((c: string) => !inputCols.includes(c) && !outputCols.includes(c));
- const sortByPreferred = (arr: string[]) => arr.slice().sort((a, b) => { const ia = preferredOrder.indexOf(a), ib = preferredOrder.indexOf(b); if (ia === -1 && ib === -1) return a.localeCompare(b); if (ia === -1) return 1; if (ib === -1) return -1; return ia - ib; });
- let left = sortByPreferred(inputCols), right = sortByPreferred(outputCols);
- if (left.length === 0 && right.length === 0) { const half = Math.ceil(cols.length / 2); left = cols.slice(0, half); right = cols.slice(half); }
- else { sortByPreferred(remaining).forEach((c: string) => { if (left.length > right.length) right.push(c); else left.push(c); }); }
- const renderRow = (col: string) => (<div key={col} className="flex items-center justify-between bg-gray-50 rounded-md px-4 py-3 text-sm w-full"><div className="text-sm text-gray-600">{getDisplayLabel(col)}</div>{renderPriceCell(tier?.[col], tier?.[`${col}_original`], tier?.unit, tier?.discount_rate)}</div>);
- return (<div className="grid grid-cols-1 md:grid-cols-2 gap-6">{left.length > 0 && <div className="space-y-3 w-full">{left.map(renderRow)}</div>}{right.length > 0 && <div className="space-y-3 w-full">{right.map(renderRow)}</div>}</div>);
- })()}
- {pricingView.type === 'tier' && showTier && (
- <div className="overflow-x-auto">
- <table className="min-w-full text-sm"><thead><tr className="text-left text-gray-500"><th className="py-2 pr-4">输入范围</th>{pricingView.columns.map((col) => (<th key={col} className="py-2 pr-4">{getDisplayLabel(col)}</th>))}</tr></thead>
- <tbody>{pricingView.tiers.map((tier, index) => (<tr key={index} className="border-t border-gray-100 even:bg-gray-50"><td className="py-2 pr-4 text-gray-700">{getRangeLabel(tier)}</td>{pricingView.columns.map((col) => (<td key={col} className="py-2 pr-4">{renderPriceCell((tier as any)?.[col], (tier as any)?.[`${col}_original`], (tier as any)?.unit)}</td>))}</tr>))}</tbody></table>
- </div>
- )}
- </div>
- )}
- {Array.isArray(data?.tool_call_pricing) && data.tool_call_pricing.length > 0 && (
- <div className="bg-white rounded-2xl border border-gray-100 p-6">
- <h2 className="text-lg font-semibold text-gray-900 mb-4">工具调用价格</h2>
- <div className="overflow-x-auto"><table className="min-w-full text-sm"><thead><tr className="text-left text-gray-500"><th className="py-2 pr-4">工具</th><th className="py-2 pr-4">价格</th></tr></thead><tbody>{data.tool_call_pricing.map((item: any, index: number) => (<tr key={index} className="border-t border-gray-100"><td className="py-2 pr-4 text-gray-700">{item.tool || '—'}</td><td className="py-2 pr-4 text-gray-900">{formatValue(item.price ?? item.amount ?? item.value, item.unit)}</td></tr>))}</tbody></table></div>
- </div>
- )}
- {data?.model_limits && (
- <div className="bg-white rounded-2xl border border-gray-100 p-6">
- <h2 className="text-lg font-semibold text-gray-900 mb-4">模型限制</h2>
- <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
- {Object.entries(data.model_limits).map(([key, value]) => key !== 'note' && (<div key={key} className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2 text-sm"><span className="text-gray-600">{limitKeyLabels[key] || key.replace(/_/g, ' ')}</span><span className="text-gray-900 font-medium">{formatValue(value)}</span></div>))}
- </div>
- </div>
- )}
- </div>
- );
- const renderApiExamples = () => {
- if (!apiExamples) return null;
- // realtime 等不支持的模型:显示提示卡片,不展示代码和调试台
- if ((apiExamples as any)._isUnsupported) {
- return (
- <div className="sticky top-6">
- <div className="bg-amber-50 border border-amber-200 rounded-2xl p-6">
- <div className="flex items-start gap-3">
- <span className="text-amber-500 text-xl leading-none mt-0.5">⚠</span>
- <div>
- <div className="text-sm font-semibold text-amber-800 mb-1">暂不支持通过平台 API 调用</div>
- <div className="text-sm text-amber-700">{(apiExamples as any).reason}</div>
- </div>
- </div>
- </div>
- </div>
- );
- }
- const isMultimodal = (apiExamples as any)._isMultimodal === true;
- const examples = isMultimodal
- ? (apiExamples as any)[activeInputMode]
- : apiExamples;
- const tabs = [
- { key: 'curl' as const, label: 'cURL' },
- { key: 'python' as const, label: 'Python' },
- { key: 'nodejs' as const, label: 'Node.js' },
- { key: 'java' as const, label: 'Java' }
- ];
- return (
- <div className="sticky top-6 space-y-6">
- <div className="bg-white rounded-2xl border border-gray-100 p-6">
- <div className="flex items-center justify-between mb-4">
- <h2 className="text-lg font-semibold text-gray-900">API 调用示例</h2>
- <button
- onClick={() => {
- copyToClipboard(examples[activeTab] || '');
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- }}
- className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition-all ${copied ? 'border-green-400 bg-green-50 text-green-600' : 'border-gray-200 bg-white text-gray-500 hover:border-blue-400 hover:text-blue-600 hover:bg-blue-50'}`}
- >
- {copied
- ? <><CheckCircle2 className="w-3.5 h-3.5" />已复制</>
- : <><Copy className="w-3.5 h-3.5" />复制代码</>
- }
- </button>
- </div>
- {isMultimodal && (
- <div className="flex gap-2 mb-4">
- <button
- onClick={() => setActiveInputMode('text')}
- className={`px-3 py-1 rounded-full text-xs font-medium border transition-colors ${activeInputMode === 'text' ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-gray-600 border-gray-200 hover:border-blue-400'}`}
- >纯文本</button>
- <button
- onClick={() => setActiveInputMode('multimodal')}
- className={`px-3 py-1 rounded-full text-xs font-medium border transition-colors ${activeInputMode === 'multimodal' ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-gray-600 border-gray-200 hover:border-blue-400'}`}
- >带图片</button>
- </div>
- )}
- <div className="flex border-b border-gray-200 mb-4">
- {tabs.map((tab) => (
- <button key={tab.key} onClick={() => setActiveTab(tab.key)} className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${activeTab === tab.key ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>{tab.label}</button>
- ))}
- </div>
- <pre className="bg-gray-900 text-gray-100 text-xs rounded-xl p-4 overflow-x-auto whitespace-pre-wrap max-h-[400px] overflow-y-auto">{examples[activeTab]}</pre>
- <p className="mt-4 text-xs text-gray-500">请将 YOUR_API_KEY 替换为您在开放平台获取的 API Key</p>
- {data?.categories?.includes(2) && !isMultimodal && (() => {
- const isPlus = (data.model_code || '').toLowerCase().includes('plus');
- const voices = isPlus
- ? [{ id: 'longanyang', name: '龙安阳' }, { id: 'longanhuan', name: '龙安欢' }]
- : [
- { id: 'longxiaochun_v3', name: '龙小淳' }, { id: 'longhua_v3', name: '龙华' },
- { id: 'longcheng_v3', name: '龙成' }, { id: 'longwan_v3', name: '龙婉' },
- { id: 'longxiaoxia_v3', name: '龙小夏' }, { id: 'longshu_v3', name: '龙书' },
- { id: 'longfei_v3', name: '龙飞' }, { id: 'longxiu_v3', name: '龙秀' },
- { id: 'longmiao_v3', name: '龙苗' }, { id: 'longyingxiao_v3', name: '龙盈晓' },
- { id: 'longhuhu_v3', name: '龙虎虎' }, { id: 'longniuniu_v3', name: '龙妞妞' },
- { id: 'longpaopao_v3', name: '龙泡泡' }, { id: 'longjielidou_v3', name: '龙节力豆' },
- { id: 'longxian_v3', name: '龙鲜' }, { id: 'longjiaxin_v3', name: '龙嘉欣' },
- { id: 'longanyue_v3', name: '龙安悦' }, { id: 'longlaotie_v3', name: '龙老铁' },
- { id: 'longshange_v3', name: '龙山歌' }, { id: 'loongkyong_v3', name: '韩语女声' },
- { id: 'loongriko_v3', name: '日语女声' }, { id: 'longhouge_v3', name: '龙猴哥' },
- { id: 'longjiqi_v3', name: '龙机器' }, { id: 'longdaiyu_v3', name: '龙黛玉' },
- { id: 'longlaobo_v3', name: '龙老伯' }, { id: 'longlaoyi_v3', name: '龙老姨' },
- ];
- return (
- <div className="mt-4 pt-4 border-t border-gray-100">
- <div className="text-xs font-medium text-gray-600 mb-2">
- 支持的音色 {isPlus ? '(plus 专属)' : ''}
- </div>
- <div className="flex flex-wrap gap-1.5">
- {voices.map(v => (
- <span key={v.id} className="px-2 py-1 bg-gray-50 border border-gray-200 rounded text-xs text-gray-600 font-mono">{v.id}<span className="text-gray-400 ml-1 font-sans">({v.name})</span></span>
- ))}
- </div>
- </div>
- );
- })()}
- </div>
- </div>
- );
- };
- const isRealtimeModel = (data?.model_code || modelCode || '').toLowerCase().includes('realtime');
- return (
- <div className="w-full flex-1">
- <div className="mb-4">
- <button onClick={() => navigate('/models')} className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-blue-600"><ArrowLeft className="w-4 h-4" />返回模型广场</button>
- </div>
- <div className="bg-white rounded-2xl border border-gray-100 p-6 shadow-sm mb-6">
- <div className="flex items-center justify-between">
- <div><h1 className="text-2xl font-bold text-gray-900">模型详情</h1><p className="text-sm text-gray-500 mt-1">{data?.model_code || modelCode}</p></div>
- {data?.is_api_enabled && !isRealtimeModel && <span className="px-3 py-1 rounded-full bg-green-50 text-green-700 text-xs font-semibold">支持API调用</span>}
- {isRealtimeModel && <span className="px-3 py-1 rounded-full bg-amber-50 text-amber-700 text-xs font-semibold">实时流协议·暂不支持平台API</span>}
- </div>
- </div>
- {loading && (<div className="bg-white rounded-2xl border border-gray-100 p-8 flex items-center justify-center text-gray-500"><Loader2 className="w-5 h-5 animate-spin mr-2" />加载中...</div>)}
- {!loading && error && (<div className="bg-red-50 border border-red-200 rounded-2xl p-6 text-red-600">{error}</div>)}
- {!loading && !error && data && (
- <div className={data.is_api_enabled && !isRealtimeModel ? "grid grid-cols-1 lg:grid-cols-2 gap-6" : ""}>
- <div className={data.is_api_enabled && !isRealtimeModel ? "" : "w-full"}>{renderModelInfo()}</div>
- {(data.is_api_enabled || isRealtimeModel) && <div>{renderApiExamples()}</div>}
- </div>
- )}
- </div>
- );
- };
- export default ModelPricingDetail;
|