mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-31 11:01:36 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77b22e2b68 |
@@ -51,7 +51,6 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@lobehub/icons-static-svg": "^1.73.0",
|
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
@@ -65,14 +64,12 @@
|
|||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
|
||||||
"@tanstack/react-query": "^5.90.3",
|
"@tanstack/react-query": "^5.90.3",
|
||||||
"@tanstack/react-virtual": "^3.13.23",
|
"@tanstack/react-virtual": "^3.13.23",
|
||||||
"@tauri-apps/api": "^2.8.0",
|
"@tauri-apps/api": "^2.8.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||||
"@tauri-apps/plugin-log": "2.8.0",
|
"@tauri-apps/plugin-log": "2.8.0",
|
||||||
"@tauri-apps/plugin-process": "^2.0.0",
|
"@tauri-apps/plugin-process": "^2.0.0",
|
||||||
"@tauri-apps/plugin-store": "^2.0.0",
|
|
||||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -81,7 +78,6 @@
|
|||||||
"flexsearch": "^0.8.212",
|
"flexsearch": "^0.8.212",
|
||||||
"framer-motion": "^12.23.25",
|
"framer-motion": "^12.23.25",
|
||||||
"i18next": "^25.5.2",
|
"i18next": "^25.5.2",
|
||||||
"jsonc-parser": "^3.2.1",
|
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
|||||||
Generated
-51
@@ -41,9 +41,6 @@ importers:
|
|||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.2.2
|
specifier: ^5.2.2
|
||||||
version: 5.2.2(react-hook-form@7.65.0(react@18.3.1))
|
version: 5.2.2(react-hook-form@7.65.0(react@18.3.1))
|
||||||
'@lobehub/icons-static-svg':
|
|
||||||
specifier: ^1.73.0
|
|
||||||
version: 1.73.0
|
|
||||||
'@radix-ui/react-accordion':
|
'@radix-ui/react-accordion':
|
||||||
specifier: ^1.2.12
|
specifier: ^1.2.12
|
||||||
version: 1.2.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 1.2.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
@@ -83,9 +80,6 @@ importers:
|
|||||||
'@radix-ui/react-tooltip':
|
'@radix-ui/react-tooltip':
|
||||||
specifier: ^1.2.8
|
specifier: ^1.2.8
|
||||||
version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@radix-ui/react-visually-hidden':
|
|
||||||
specifier: ^1.2.4
|
|
||||||
version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
|
||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.90.3
|
specifier: ^5.90.3
|
||||||
version: 5.90.3(react@18.3.1)
|
version: 5.90.3(react@18.3.1)
|
||||||
@@ -104,9 +98,6 @@ importers:
|
|||||||
'@tauri-apps/plugin-process':
|
'@tauri-apps/plugin-process':
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.3.0
|
version: 2.3.0
|
||||||
'@tauri-apps/plugin-store':
|
|
||||||
specifier: ^2.0.0
|
|
||||||
version: 2.4.0
|
|
||||||
'@tauri-apps/plugin-updater':
|
'@tauri-apps/plugin-updater':
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.9.0
|
version: 2.9.0
|
||||||
@@ -131,9 +122,6 @@ importers:
|
|||||||
i18next:
|
i18next:
|
||||||
specifier: ^25.5.2
|
specifier: ^25.5.2
|
||||||
version: 25.5.2(typescript@5.9.2)
|
version: 25.5.2(typescript@5.9.2)
|
||||||
jsonc-parser:
|
|
||||||
specifier: ^3.2.1
|
|
||||||
version: 3.3.1
|
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.542.0
|
specifier: ^0.542.0
|
||||||
version: 0.542.0(react@18.3.1)
|
version: 0.542.0(react@18.3.1)
|
||||||
@@ -835,9 +823,6 @@ packages:
|
|||||||
'@lezer/markdown@1.6.0':
|
'@lezer/markdown@1.6.0':
|
||||||
resolution: {integrity: sha512-AXb98u3M6BEzTnreBnGtQaF7xFTiMA92Dsy5tqEjpacbjRxDSFdN4bKJo9uvU4cEEOS7D2B9MT7kvDgOEIzJSw==}
|
resolution: {integrity: sha512-AXb98u3M6BEzTnreBnGtQaF7xFTiMA92Dsy5tqEjpacbjRxDSFdN4bKJo9uvU4cEEOS7D2B9MT7kvDgOEIzJSw==}
|
||||||
|
|
||||||
'@lobehub/icons-static-svg@1.73.0':
|
|
||||||
resolution: {integrity: sha512-ydKUCDoopdmulbjDZo/gppaODd5Ju5nPneVcN9A5dAz9IJZUMkLms8bqostMLrqcdMQ8resKjLuV9RhJaWhaag==}
|
|
||||||
|
|
||||||
'@marijn/find-cluster-break@1.0.2':
|
'@marijn/find-cluster-break@1.0.2':
|
||||||
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
||||||
|
|
||||||
@@ -1319,19 +1304,6 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-visually-hidden@1.2.4':
|
|
||||||
resolution: {integrity: sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==}
|
|
||||||
peerDependencies:
|
|
||||||
'@types/react': '*'
|
|
||||||
'@types/react-dom': '*'
|
|
||||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
|
||||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
|
||||||
peerDependenciesMeta:
|
|
||||||
'@types/react':
|
|
||||||
optional: true
|
|
||||||
'@types/react-dom':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@radix-ui/rect@1.1.1':
|
'@radix-ui/rect@1.1.1':
|
||||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||||
|
|
||||||
@@ -1571,9 +1543,6 @@ packages:
|
|||||||
'@tauri-apps/plugin-process@2.3.0':
|
'@tauri-apps/plugin-process@2.3.0':
|
||||||
resolution: {integrity: sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ==}
|
resolution: {integrity: sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ==}
|
||||||
|
|
||||||
'@tauri-apps/plugin-store@2.4.0':
|
|
||||||
resolution: {integrity: sha512-PjBnlnH6jyI71MGhrPaxUUCsOzc7WO1mbc4gRhME0m2oxLgCqbksw6JyeKQimuzv4ysdpNO3YbmaY2haf82a3A==}
|
|
||||||
|
|
||||||
'@tauri-apps/plugin-updater@2.9.0':
|
'@tauri-apps/plugin-updater@2.9.0':
|
||||||
resolution: {integrity: sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==}
|
resolution: {integrity: sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==}
|
||||||
|
|
||||||
@@ -2267,9 +2236,6 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
jsonc-parser@3.3.1:
|
|
||||||
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
|
||||||
|
|
||||||
launch-ide@1.3.0:
|
launch-ide@1.3.0:
|
||||||
resolution: {integrity: sha512-pxiF+HVNMV0dDc6Z0q89RDmzMF9XmSGaOn4ueTegjMy3cUkezc3zrki5PCiz68zZIqAuhW7iwoWX7JO4Kn6B0A==}
|
resolution: {integrity: sha512-pxiF+HVNMV0dDc6Z0q89RDmzMF9XmSGaOn4ueTegjMy3cUkezc3zrki5PCiz68zZIqAuhW7iwoWX7JO4Kn6B0A==}
|
||||||
|
|
||||||
@@ -3692,8 +3658,6 @@ snapshots:
|
|||||||
'@lezer/common': 1.2.3
|
'@lezer/common': 1.2.3
|
||||||
'@lezer/highlight': 1.2.1
|
'@lezer/highlight': 1.2.1
|
||||||
|
|
||||||
'@lobehub/icons-static-svg@1.73.0': {}
|
|
||||||
|
|
||||||
'@marijn/find-cluster-break@1.0.2': {}
|
'@marijn/find-cluster-break@1.0.2': {}
|
||||||
|
|
||||||
'@mswjs/interceptors@0.40.0':
|
'@mswjs/interceptors@0.40.0':
|
||||||
@@ -4197,15 +4161,6 @@ snapshots:
|
|||||||
'@types/react': 18.3.23
|
'@types/react': 18.3.23
|
||||||
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||||
|
|
||||||
'@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
|
||||||
dependencies:
|
|
||||||
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
|
||||||
react: 18.3.1
|
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
|
||||||
optionalDependencies:
|
|
||||||
'@types/react': 18.3.23
|
|
||||||
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
|
||||||
|
|
||||||
'@radix-ui/rect@1.1.1': {}
|
'@radix-ui/rect@1.1.1': {}
|
||||||
|
|
||||||
'@reduxjs/toolkit@2.11.0(react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1))(react@18.3.1)':
|
'@reduxjs/toolkit@2.11.0(react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1))(react@18.3.1)':
|
||||||
@@ -4362,10 +4317,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.8.0
|
'@tauri-apps/api': 2.8.0
|
||||||
|
|
||||||
'@tauri-apps/plugin-store@2.4.0':
|
|
||||||
dependencies:
|
|
||||||
'@tauri-apps/api': 2.8.0
|
|
||||||
|
|
||||||
'@tauri-apps/plugin-updater@2.9.0':
|
'@tauri-apps/plugin-updater@2.9.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.8.0
|
'@tauri-apps/api': 2.8.0
|
||||||
@@ -5099,8 +5050,6 @@ snapshots:
|
|||||||
|
|
||||||
json5@2.2.3: {}
|
json5@2.2.3: {}
|
||||||
|
|
||||||
jsonc-parser@3.3.1: {}
|
|
||||||
|
|
||||||
launch-ide@1.3.0:
|
launch-ide@1.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
chalk: 4.1.1
|
chalk: 4.1.1
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// 要提取的图标列表(按分类组织)
|
|
||||||
const ICONS_TO_EXTRACT = {
|
|
||||||
// AI 服务商(必需)
|
|
||||||
aiProviders: [
|
|
||||||
'openai', 'anthropic', 'claude', 'google', 'gemini',
|
|
||||||
'deepseek', 'kimi', 'moonshot', 'stepfun', 'zhipu', 'minimax',
|
|
||||||
'baidu', 'alibaba', 'tencent', 'meta', 'microsoft',
|
|
||||||
'cohere', 'perplexity', 'mistral', 'huggingface'
|
|
||||||
],
|
|
||||||
|
|
||||||
// 云平台
|
|
||||||
cloudPlatforms: [
|
|
||||||
'aws', 'azure', 'huawei', 'cloudflare'
|
|
||||||
],
|
|
||||||
|
|
||||||
// 开发工具
|
|
||||||
devTools: [
|
|
||||||
'github', 'gitlab', 'docker', 'kubernetes', 'vscode'
|
|
||||||
],
|
|
||||||
|
|
||||||
// 其他
|
|
||||||
others: [
|
|
||||||
'settings', 'folder', 'file', 'link'
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
// 合并所有图标
|
|
||||||
const ALL_ICONS = [
|
|
||||||
...ICONS_TO_EXTRACT.aiProviders,
|
|
||||||
...ICONS_TO_EXTRACT.cloudPlatforms,
|
|
||||||
...ICONS_TO_EXTRACT.devTools,
|
|
||||||
...ICONS_TO_EXTRACT.others
|
|
||||||
];
|
|
||||||
|
|
||||||
// 提取逻辑
|
|
||||||
const OUTPUT_DIR = path.join(__dirname, '../src/icons/extracted');
|
|
||||||
const SOURCE_DIR = path.join(__dirname, '../node_modules/@lobehub/icons-static-svg/icons');
|
|
||||||
|
|
||||||
// 确保输出目录存在
|
|
||||||
if (!fs.existsSync(OUTPUT_DIR)) {
|
|
||||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🎨 CC-Switch Icon Extractor\n');
|
|
||||||
console.log('========================================');
|
|
||||||
console.log('📦 Extracting icons...\n');
|
|
||||||
|
|
||||||
let extracted = 0;
|
|
||||||
let notFound = [];
|
|
||||||
|
|
||||||
// 提取图标
|
|
||||||
ALL_ICONS.forEach(iconName => {
|
|
||||||
const sourceFile = path.join(SOURCE_DIR, `${iconName}.svg`);
|
|
||||||
const targetFile = path.join(OUTPUT_DIR, `${iconName}.svg`);
|
|
||||||
|
|
||||||
if (fs.existsSync(sourceFile)) {
|
|
||||||
fs.copyFileSync(sourceFile, targetFile);
|
|
||||||
console.log(` ✓ ${iconName}.svg`);
|
|
||||||
extracted++;
|
|
||||||
} else if (fs.existsSync(targetFile)) {
|
|
||||||
console.log(` ✓ ${iconName}.svg (kept local custom icon)`);
|
|
||||||
extracted++;
|
|
||||||
} else {
|
|
||||||
console.log(` ✗ ${iconName}.svg (not found)`);
|
|
||||||
notFound.push(iconName);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 生成索引文件
|
|
||||||
console.log('\n📝 Generating index file...\n');
|
|
||||||
|
|
||||||
const indexContent = `// Auto-generated icon index
|
|
||||||
// Do not edit manually
|
|
||||||
|
|
||||||
export const icons: Record<string, string> = {
|
|
||||||
${ALL_ICONS.filter(name => !notFound.includes(name))
|
|
||||||
.map(name => {
|
|
||||||
const svg = fs.readFileSync(path.join(OUTPUT_DIR, `${name}.svg`), 'utf-8');
|
|
||||||
const escaped = svg.replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
|
||||||
return ` '${name}': \`${escaped}\`,`;
|
|
||||||
})
|
|
||||||
.join('\n')}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const iconList = Object.keys(icons);
|
|
||||||
|
|
||||||
export function getIcon(name: string): string {
|
|
||||||
return icons[name.toLowerCase()] || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasIcon(name: string): boolean {
|
|
||||||
return name.toLowerCase() in icons;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'index.ts'), indexContent);
|
|
||||||
console.log('✓ Generated: src/icons/extracted/index.ts');
|
|
||||||
|
|
||||||
// 生成图标元数据
|
|
||||||
const metadataContent = `// Icon metadata for search and categorization
|
|
||||||
import { IconMetadata } from '@/types/icon';
|
|
||||||
|
|
||||||
export const iconMetadata: Record<string, IconMetadata> = {
|
|
||||||
// AI Providers
|
|
||||||
openai: { name: 'openai', displayName: 'OpenAI', category: 'ai-provider', keywords: ['gpt', 'chatgpt'], defaultColor: '#00A67E' },
|
|
||||||
anthropic: { name: 'anthropic', displayName: 'Anthropic', category: 'ai-provider', keywords: ['claude'], defaultColor: '#D4915D' },
|
|
||||||
claude: { name: 'claude', displayName: 'Claude', category: 'ai-provider', keywords: ['anthropic'], defaultColor: '#D4915D' },
|
|
||||||
google: { name: 'google', displayName: 'Google', category: 'ai-provider', keywords: ['gemini', 'bard'], defaultColor: '#4285F4' },
|
|
||||||
gemini: { name: 'gemini', displayName: 'Gemini', category: 'ai-provider', keywords: ['google'], defaultColor: '#4285F4' },
|
|
||||||
deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },
|
|
||||||
moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },
|
|
||||||
kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },
|
|
||||||
stepfun: { name: 'stepfun', displayName: 'StepFun', category: 'ai-provider', keywords: ['stepfun', 'step', 'jieyue', '阶跃星辰'], defaultColor: '#005AFF' },
|
|
||||||
zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },
|
|
||||||
minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },
|
|
||||||
baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },
|
|
||||||
alibaba: { name: 'alibaba', displayName: 'Alibaba', category: 'ai-provider', keywords: ['qwen', 'tongyi'], defaultColor: '#FF6A00' },
|
|
||||||
tencent: { name: 'tencent', displayName: 'Tencent', category: 'ai-provider', keywords: ['hunyuan'], defaultColor: '#00A4FF' },
|
|
||||||
meta: { name: 'meta', displayName: 'Meta', category: 'ai-provider', keywords: ['facebook', 'llama'], defaultColor: '#0081FB' },
|
|
||||||
microsoft: { name: 'microsoft', displayName: 'Microsoft', category: 'ai-provider', keywords: ['copilot', 'azure'], defaultColor: '#00A4EF' },
|
|
||||||
cohere: { name: 'cohere', displayName: 'Cohere', category: 'ai-provider', keywords: ['cohere'], defaultColor: '#39594D' },
|
|
||||||
perplexity: { name: 'perplexity', displayName: 'Perplexity', category: 'ai-provider', keywords: ['perplexity'], defaultColor: '#20808D' },
|
|
||||||
mistral: { name: 'mistral', displayName: 'Mistral', category: 'ai-provider', keywords: ['mistral'], defaultColor: '#FF7000' },
|
|
||||||
huggingface: { name: 'huggingface', displayName: 'Hugging Face', category: 'ai-provider', keywords: ['huggingface', 'hf'], defaultColor: '#FFD21E' },
|
|
||||||
|
|
||||||
// Cloud Platforms
|
|
||||||
aws: { name: 'aws', displayName: 'AWS', category: 'cloud', keywords: ['amazon', 'cloud'], defaultColor: '#FF9900' },
|
|
||||||
azure: { name: 'azure', displayName: 'Azure', category: 'cloud', keywords: ['microsoft', 'cloud'], defaultColor: '#0078D4' },
|
|
||||||
huawei: { name: 'huawei', displayName: 'Huawei', category: 'cloud', keywords: ['huawei', 'cloud'], defaultColor: '#FF0000' },
|
|
||||||
cloudflare: { name: 'cloudflare', displayName: 'Cloudflare', category: 'cloud', keywords: ['cloudflare', 'cdn'], defaultColor: '#F38020' },
|
|
||||||
|
|
||||||
// Dev Tools
|
|
||||||
github: { name: 'github', displayName: 'GitHub', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#181717' },
|
|
||||||
gitlab: { name: 'gitlab', displayName: 'GitLab', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#FC6D26' },
|
|
||||||
docker: { name: 'docker', displayName: 'Docker', category: 'tool', keywords: ['container'], defaultColor: '#2496ED' },
|
|
||||||
kubernetes: { name: 'kubernetes', displayName: 'Kubernetes', category: 'tool', keywords: ['k8s', 'container'], defaultColor: '#326CE5' },
|
|
||||||
vscode: { name: 'vscode', displayName: 'VS Code', category: 'tool', keywords: ['editor', 'ide'], defaultColor: '#007ACC' },
|
|
||||||
|
|
||||||
// Others
|
|
||||||
settings: { name: 'settings', displayName: 'Settings', category: 'other', keywords: ['config', 'preferences'], defaultColor: '#6B7280' },
|
|
||||||
folder: { name: 'folder', displayName: 'Folder', category: 'other', keywords: ['directory'], defaultColor: '#6B7280' },
|
|
||||||
file: { name: 'file', displayName: 'File', category: 'other', keywords: ['document'], defaultColor: '#6B7280' },
|
|
||||||
link: { name: 'link', displayName: 'Link', category: 'other', keywords: ['url', 'hyperlink'], defaultColor: '#6B7280' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getIconMetadata(name: string): IconMetadata | undefined {
|
|
||||||
return iconMetadata[name.toLowerCase()];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function searchIcons(query: string): string[] {
|
|
||||||
const lowerQuery = query.toLowerCase();
|
|
||||||
return Object.values(iconMetadata)
|
|
||||||
.filter(meta =>
|
|
||||||
meta.name.includes(lowerQuery) ||
|
|
||||||
meta.displayName.toLowerCase().includes(lowerQuery) ||
|
|
||||||
meta.keywords.some(k => k.includes(lowerQuery))
|
|
||||||
)
|
|
||||||
.map(meta => meta.name);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'metadata.ts'), metadataContent);
|
|
||||||
console.log('✓ Generated: src/icons/extracted/metadata.ts');
|
|
||||||
|
|
||||||
// 生成 README
|
|
||||||
const readmeContent = `# Extracted Icons
|
|
||||||
|
|
||||||
This directory contains extracted icons from @lobehub/icons-static-svg.
|
|
||||||
|
|
||||||
## Statistics
|
|
||||||
- Total extracted: ${extracted} icons
|
|
||||||
- Not found: ${notFound.length} icons
|
|
||||||
|
|
||||||
## Extracted Icons
|
|
||||||
${ALL_ICONS.filter(name => !notFound.includes(name)).map(name => `- ${name}`).join('\n')}
|
|
||||||
|
|
||||||
${notFound.length > 0 ? `\n## Not Found\n${notFound.map(name => `- ${name}`).join('\n')}` : ''}
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
\`\`\`typescript
|
|
||||||
import { getIcon, hasIcon, iconList } from './extracted';
|
|
||||||
|
|
||||||
// Get icon SVG
|
|
||||||
const svg = getIcon('openai');
|
|
||||||
|
|
||||||
// Check if icon exists
|
|
||||||
if (hasIcon('openai')) {
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all available icons
|
|
||||||
console.log(iconList);
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
---
|
|
||||||
Last updated: ${new Date().toISOString()}
|
|
||||||
Generated by: scripts/extract-icons.js
|
|
||||||
`;
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'README.md'), readmeContent);
|
|
||||||
console.log('✓ Generated: src/icons/extracted/README.md');
|
|
||||||
|
|
||||||
console.log('\n========================================');
|
|
||||||
console.log('✅ Extraction complete!\n');
|
|
||||||
console.log(` ✓ Extracted: ${extracted} icons`);
|
|
||||||
console.log(` ✗ Not found: ${notFound.length} icons`);
|
|
||||||
console.log(` 📉 Bundle size reduction: ~${Math.round((1 - extracted / 723) * 100)}%`);
|
|
||||||
console.log('========================================\n');
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const ICONS_DIR = path.join(__dirname, '../src/icons/extracted');
|
|
||||||
|
|
||||||
// List of "Famous" icons to keep
|
|
||||||
// Based on common AI providers and tools
|
|
||||||
const KEEP_LIST = [
|
|
||||||
// AI Providers
|
|
||||||
'openai', 'anthropic', 'claude', 'google', 'gemini', 'gemma', 'palm',
|
|
||||||
'microsoft', 'azure', 'copilot', 'meta', 'llama',
|
|
||||||
'alibaba', 'qwen', 'tencent', 'hunyuan', 'baidu', 'wenxin',
|
|
||||||
'bytedance', 'doubao', 'deepseek', 'moonshot', 'kimi', 'stepfun',
|
|
||||||
'zhipu', 'chatglm', 'glm', 'minimax', 'mistral', 'cohere',
|
|
||||||
'perplexity', 'huggingface', 'midjourney', 'stability',
|
|
||||||
'xai', 'grok', 'yi', 'zeroone', 'ollama',
|
|
||||||
'packycode',
|
|
||||||
|
|
||||||
// Cloud/Tools
|
|
||||||
'aws', 'googlecloud', 'huawei', 'cloudflare',
|
|
||||||
'github', 'githubcopilot', 'vercel', 'notion', 'discord',
|
|
||||||
'gitlab', 'docker', 'kubernetes', 'vscode', 'settings', 'folder', 'file', 'link'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Get all SVG files
|
|
||||||
const files = fs.readdirSync(ICONS_DIR).filter(file => file.endsWith('.svg'));
|
|
||||||
|
|
||||||
console.log(`Scanning ${files.length} files...`);
|
|
||||||
|
|
||||||
let keptCount = 0;
|
|
||||||
let deletedCount = 0;
|
|
||||||
let renamedCount = 0;
|
|
||||||
|
|
||||||
// First pass: Identify files to keep and prefer color versions
|
|
||||||
const fileMap = {}; // name -> { hasColor: bool, hasMono: bool }
|
|
||||||
|
|
||||||
files.forEach(file => {
|
|
||||||
const isColor = file.endsWith('-color.svg');
|
|
||||||
const baseName = isColor ? file.replace('-color.svg', '') : file.replace('.svg', '');
|
|
||||||
|
|
||||||
if (!fileMap[baseName]) {
|
|
||||||
fileMap[baseName] = { hasColor: false, hasMono: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isColor) {
|
|
||||||
fileMap[baseName].hasColor = true;
|
|
||||||
} else {
|
|
||||||
fileMap[baseName].hasMono = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Second pass: Process files
|
|
||||||
Object.keys(fileMap).forEach(baseName => {
|
|
||||||
const info = fileMap[baseName];
|
|
||||||
const shouldKeep = KEEP_LIST.includes(baseName);
|
|
||||||
|
|
||||||
if (!shouldKeep) {
|
|
||||||
// Delete both versions if not in keep list
|
|
||||||
if (info.hasColor) {
|
|
||||||
fs.unlinkSync(path.join(ICONS_DIR, `${baseName}-color.svg`));
|
|
||||||
deletedCount++;
|
|
||||||
}
|
|
||||||
if (info.hasMono) {
|
|
||||||
fs.unlinkSync(path.join(ICONS_DIR, `${baseName}.svg`));
|
|
||||||
deletedCount++;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If keeping, prefer color
|
|
||||||
if (info.hasColor) {
|
|
||||||
// Rename color version to base version (overwrite mono if exists)
|
|
||||||
const colorPath = path.join(ICONS_DIR, `${baseName}-color.svg`);
|
|
||||||
const targetPath = path.join(ICONS_DIR, `${baseName}.svg`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// If mono exists, it will be overwritten/replaced
|
|
||||||
fs.renameSync(colorPath, targetPath);
|
|
||||||
renamedCount++;
|
|
||||||
keptCount++;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Error renaming ${baseName}:`, e);
|
|
||||||
}
|
|
||||||
} else if (info.hasMono) {
|
|
||||||
// Keep mono if no color version
|
|
||||||
keptCount++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`\nCleanup complete:`);
|
|
||||||
console.log(`- Kept: ${keptCount}`);
|
|
||||||
console.log(`- Deleted: ${deletedCount}`);
|
|
||||||
console.log(`- Renamed (Color -> Standard): ${renamedCount}`);
|
|
||||||
|
|
||||||
// Regenerate index and metadata
|
|
||||||
require('./generate-icon-index.js');
|
|
||||||
@@ -28,7 +28,6 @@ mod panic_hook;
|
|||||||
mod prompt;
|
mod prompt;
|
||||||
mod prompt_files;
|
mod prompt_files;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod provider_defaults;
|
|
||||||
mod proxy;
|
mod proxy;
|
||||||
mod services;
|
mod services;
|
||||||
mod session_manager;
|
mod session_manager;
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
use once_cell::sync::Lazy;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
/// 供应商图标信息
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct ProviderIcon {
|
|
||||||
pub name: &'static str,
|
|
||||||
pub color: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 供应商名称到图标的默认映射
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub static DEFAULT_PROVIDER_ICONS: Lazy<HashMap<&'static str, ProviderIcon>> = Lazy::new(|| {
|
|
||||||
let mut m = HashMap::new();
|
|
||||||
|
|
||||||
// AI 服务商
|
|
||||||
m.insert(
|
|
||||||
"openai",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "openai",
|
|
||||||
color: "#00A67E",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m.insert(
|
|
||||||
"anthropic",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "anthropic",
|
|
||||||
color: "#D4915D",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m.insert(
|
|
||||||
"claude",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "claude",
|
|
||||||
color: "#D4915D",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m.insert(
|
|
||||||
"google",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "google",
|
|
||||||
color: "#4285F4",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m.insert(
|
|
||||||
"gemini",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "gemini",
|
|
||||||
color: "#4285F4",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m.insert(
|
|
||||||
"deepseek",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "deepseek",
|
|
||||||
color: "#1E88E5",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m.insert(
|
|
||||||
"kimi",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "kimi",
|
|
||||||
color: "#6366F1",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m.insert(
|
|
||||||
"moonshot",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "moonshot",
|
|
||||||
color: "#6366F1",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m.insert(
|
|
||||||
"zhipu",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "zhipu",
|
|
||||||
color: "#0F62FE",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m.insert(
|
|
||||||
"minimax",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "minimax",
|
|
||||||
color: "#FF6B6B",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m.insert(
|
|
||||||
"baidu",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "baidu",
|
|
||||||
color: "#2932E1",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m.insert(
|
|
||||||
"alibaba",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "alibaba",
|
|
||||||
color: "#FF6A00",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m.insert(
|
|
||||||
"tencent",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "tencent",
|
|
||||||
color: "#00A4FF",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m.insert(
|
|
||||||
"meta",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "meta",
|
|
||||||
color: "#0081FB",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m.insert(
|
|
||||||
"microsoft",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "microsoft",
|
|
||||||
color: "#00A4EF",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m.insert(
|
|
||||||
"cohere",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "cohere",
|
|
||||||
color: "#39594D",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m.insert(
|
|
||||||
"perplexity",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "perplexity",
|
|
||||||
color: "#20808D",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m.insert(
|
|
||||||
"mistral",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "mistral",
|
|
||||||
color: "#FF7000",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m.insert(
|
|
||||||
"huggingface",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "huggingface",
|
|
||||||
color: "#FFD21E",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// 云平台
|
|
||||||
m.insert(
|
|
||||||
"aws",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "aws",
|
|
||||||
color: "#FF9900",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m.insert(
|
|
||||||
"azure",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "azure",
|
|
||||||
color: "#0078D4",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m.insert(
|
|
||||||
"huawei",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "huawei",
|
|
||||||
color: "#FF0000",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m.insert(
|
|
||||||
"cloudflare",
|
|
||||||
ProviderIcon {
|
|
||||||
name: "cloudflare",
|
|
||||||
color: "#F38020",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
m
|
|
||||||
});
|
|
||||||
|
|
||||||
/// 根据供应商名称智能推断图标
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn infer_provider_icon(provider_name: &str) -> Option<ProviderIcon> {
|
|
||||||
let name_lower = provider_name.to_lowercase();
|
|
||||||
|
|
||||||
// 精确匹配
|
|
||||||
if let Some(icon) = DEFAULT_PROVIDER_ICONS.get(name_lower.as_str()) {
|
|
||||||
return Some(icon.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模糊匹配(包含关键词)
|
|
||||||
for (key, icon) in DEFAULT_PROVIDER_ICONS.iter() {
|
|
||||||
if name_lower.contains(key) {
|
|
||||||
return Some(icon.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_exact_match() {
|
|
||||||
let icon = infer_provider_icon("openai");
|
|
||||||
assert!(icon.is_some());
|
|
||||||
let icon = icon.unwrap();
|
|
||||||
assert_eq!(icon.name, "openai");
|
|
||||||
assert_eq!(icon.color, "#00A67E");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_fuzzy_match() {
|
|
||||||
let icon = infer_provider_icon("OpenAI Official");
|
|
||||||
assert!(icon.is_some());
|
|
||||||
let icon = icon.unwrap();
|
|
||||||
assert_eq!(icon.name, "openai");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_case_insensitive() {
|
|
||||||
let icon = infer_provider_icon("ANTHROPIC");
|
|
||||||
assert!(icon.is_some());
|
|
||||||
assert_eq!(icon.unwrap().name, "anthropic");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_no_match() {
|
|
||||||
let icon = infer_provider_icon("unknown provider");
|
|
||||||
assert!(icon.is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
//! 健康检查器
|
|
||||||
//!
|
|
||||||
//! 负责定期检查Provider健康状态(占位实现)
|
|
||||||
|
|
||||||
// 占位实现,稍后添加完整逻辑
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct HealthChecker;
|
|
||||||
@@ -15,7 +15,6 @@ pub mod gemini_url;
|
|||||||
pub mod handler_config;
|
pub mod handler_config;
|
||||||
pub mod handler_context;
|
pub mod handler_context;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod health;
|
|
||||||
pub mod http_client;
|
pub mod http_client;
|
||||||
pub mod hyper_client;
|
pub mod hyper_client;
|
||||||
pub(crate) mod json_canonical;
|
pub(crate) mod json_canonical;
|
||||||
@@ -24,7 +23,6 @@ pub mod media_sanitizer;
|
|||||||
pub mod model_mapper;
|
pub mod model_mapper;
|
||||||
pub mod provider_router;
|
pub mod provider_router;
|
||||||
pub mod providers;
|
pub mod providers;
|
||||||
pub mod response_handler;
|
|
||||||
pub mod response_processor;
|
pub mod response_processor;
|
||||||
pub(crate) mod server;
|
pub(crate) mod server;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
@@ -47,11 +45,7 @@ pub use error::ProxyError;
|
|||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use provider_router::ProviderRouter;
|
pub use provider_router::ProviderRouter;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler};
|
pub use session::{extract_session_id, SessionIdResult, SessionIdSource};
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use session::{
|
|
||||||
extract_session_id, ClientFormat, ProxySession, SessionIdResult, SessionIdSource,
|
|
||||||
};
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus};
|
pub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus};
|
||||||
|
|
||||||
|
|||||||
@@ -1,232 +0,0 @@
|
|||||||
//! Response Handler - 统一响应处理
|
|
||||||
//!
|
|
||||||
//! 提供流式和非流式响应的统一处理接口
|
|
||||||
|
|
||||||
use super::session::ProxySession;
|
|
||||||
use super::usage::parser::TokenUsage;
|
|
||||||
use super::ProxyError;
|
|
||||||
use crate::proxy::sse::{strip_sse_field, take_sse_block};
|
|
||||||
use bytes::Bytes;
|
|
||||||
use futures::stream::{Stream, StreamExt};
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tokio::time::timeout;
|
|
||||||
|
|
||||||
/// 响应类型
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub enum ResponseType {
|
|
||||||
/// 流式响应 (SSE)
|
|
||||||
Stream,
|
|
||||||
/// 非流式响应
|
|
||||||
NonStream,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ResponseType {
|
|
||||||
/// 从 Content-Type 检测响应类型
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn from_content_type(content_type: &str) -> Self {
|
|
||||||
if content_type.contains("text/event-stream") {
|
|
||||||
ResponseType::Stream
|
|
||||||
} else {
|
|
||||||
ResponseType::NonStream
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 流式响应处理器
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct StreamHandler {
|
|
||||||
/// 空闲超时时间
|
|
||||||
idle_timeout: Duration,
|
|
||||||
/// 收集的事件
|
|
||||||
events: Arc<Mutex<Vec<Value>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl StreamHandler {
|
|
||||||
/// 创建新的流式处理器
|
|
||||||
pub fn new(idle_timeout_secs: u64) -> Self {
|
|
||||||
Self {
|
|
||||||
idle_timeout: Duration::from_secs(idle_timeout_secs),
|
|
||||||
events: Arc::new(Mutex::new(Vec::new())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 处理流式响应,返回分流后的客户端流
|
|
||||||
///
|
|
||||||
/// 客户端流立即返回,内部流在后台收集事件
|
|
||||||
pub fn handle_stream<S>(
|
|
||||||
&self,
|
|
||||||
stream: S,
|
|
||||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send
|
|
||||||
where
|
|
||||||
S: Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
|
|
||||||
{
|
|
||||||
let events = self.events.clone();
|
|
||||||
let idle_timeout = self.idle_timeout;
|
|
||||||
|
|
||||||
async_stream::stream! {
|
|
||||||
let mut _last_activity = Instant::now();
|
|
||||||
let mut buffer = String::new();
|
|
||||||
let mut utf8_remainder: Vec<u8> = Vec::new();
|
|
||||||
|
|
||||||
tokio::pin!(stream);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let chunk_result = timeout(idle_timeout, stream.next()).await;
|
|
||||||
|
|
||||||
match chunk_result {
|
|
||||||
Ok(Some(Ok(bytes))) => {
|
|
||||||
_last_activity = Instant::now();
|
|
||||||
|
|
||||||
// 解析 SSE 事件
|
|
||||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
|
||||||
|
|
||||||
// 提取完整事件
|
|
||||||
while let Some(event_text) = take_sse_block(&mut buffer) {
|
|
||||||
for line in event_text.lines() {
|
|
||||||
if let Some(data) = strip_sse_field(line, "data") {
|
|
||||||
if data.trim() != "[DONE]" {
|
|
||||||
if let Ok(json) = serde_json::from_str::<Value>(data) {
|
|
||||||
let mut guard = events.lock().await;
|
|
||||||
guard.push(json);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
yield Ok(bytes);
|
|
||||||
}
|
|
||||||
Ok(Some(Err(e))) => {
|
|
||||||
log::error!("流错误: {e}");
|
|
||||||
yield Err(std::io::Error::other(e.to_string()));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
// 流结束
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// 空闲超时
|
|
||||||
log::warn!("流式响应空闲超时: {idle_timeout:?} 无数据");
|
|
||||||
yield Err(std::io::Error::other("Stream idle timeout"));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取收集的事件
|
|
||||||
pub async fn get_events(&self) -> Vec<Value> {
|
|
||||||
let guard = self.events.lock().await;
|
|
||||||
guard.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从收集的事件中提取 Token 使用量
|
|
||||||
pub async fn extract_usage(&self, session: &ProxySession) -> Option<TokenUsage> {
|
|
||||||
let events = self.get_events().await;
|
|
||||||
|
|
||||||
match session.client_format {
|
|
||||||
super::session::ClientFormat::Claude => TokenUsage::from_claude_stream_events(&events),
|
|
||||||
super::session::ClientFormat::Codex => TokenUsage::from_codex_stream_events(&events),
|
|
||||||
super::session::ClientFormat::Gemini | super::session::ClientFormat::GeminiCli => {
|
|
||||||
TokenUsage::from_gemini_stream_chunks(&events)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 非流式响应处理器
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct NonStreamHandler;
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl NonStreamHandler {
|
|
||||||
/// 处理非流式响应
|
|
||||||
///
|
|
||||||
/// 克隆响应体用于后台解析,原始响应立即返回
|
|
||||||
pub async fn handle_response(
|
|
||||||
body: &[u8],
|
|
||||||
session: &ProxySession,
|
|
||||||
) -> Result<Option<TokenUsage>, ProxyError> {
|
|
||||||
let json: Value = serde_json::from_slice(body)
|
|
||||||
.map_err(|e| ProxyError::TransformError(format!("Failed to parse response: {e}")))?;
|
|
||||||
|
|
||||||
let usage = match session.client_format {
|
|
||||||
super::session::ClientFormat::Claude => TokenUsage::from_claude_response(&json),
|
|
||||||
super::session::ClientFormat::Codex => TokenUsage::from_codex_response_adjusted(&json),
|
|
||||||
super::session::ClientFormat::Gemini | super::session::ClientFormat::GeminiCli => {
|
|
||||||
TokenUsage::from_gemini_response(&json)
|
|
||||||
}
|
|
||||||
super::session::ClientFormat::OpenAI => TokenUsage::from_openrouter_response(&json),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(usage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 统一响应分发器
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct ResponseDispatcher;
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl ResponseDispatcher {
|
|
||||||
/// 判断响应类型
|
|
||||||
pub fn detect_type(content_type: &str) -> ResponseType {
|
|
||||||
ResponseType::from_content_type(content_type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_response_type_detection() {
|
|
||||||
assert_eq!(
|
|
||||||
ResponseType::from_content_type("text/event-stream"),
|
|
||||||
ResponseType::Stream
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
ResponseType::from_content_type("text/event-stream; charset=utf-8"),
|
|
||||||
ResponseType::Stream
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
ResponseType::from_content_type("application/json"),
|
|
||||||
ResponseType::NonStream
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_stream_handler_creation() {
|
|
||||||
let handler = StreamHandler::new(30);
|
|
||||||
assert_eq!(handler.idle_timeout, Duration::from_secs(30));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_strip_sse_field_accepts_optional_space() {
|
|
||||||
assert_eq!(
|
|
||||||
super::strip_sse_field("data: {\"ok\":true}", "data"),
|
|
||||||
Some("{\"ok\":true}")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
super::strip_sse_field("data:{\"ok\":true}", "data"),
|
|
||||||
Some("{\"ok\":true}")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
super::strip_sse_field("event: message_start", "event"),
|
|
||||||
Some("message_start")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
super::strip_sse_field("event:message_start", "event"),
|
|
||||||
Some("message_start")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,180 +10,8 @@
|
|||||||
//! - 其他: 生成新的 UUID
|
//! - 其他: 生成新的 UUID
|
||||||
|
|
||||||
use axum::http::HeaderMap;
|
use axum::http::HeaderMap;
|
||||||
use std::time::Instant;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// 客户端请求格式
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub enum ClientFormat {
|
|
||||||
/// Claude Messages API (/v1/messages)
|
|
||||||
Claude,
|
|
||||||
/// Codex Response API (/v1/responses)
|
|
||||||
Codex,
|
|
||||||
/// OpenAI Chat Completions API (/v1/chat/completions)
|
|
||||||
OpenAI,
|
|
||||||
/// Gemini API (/v1beta/models/*/generateContent)
|
|
||||||
Gemini,
|
|
||||||
/// Gemini CLI API (/v1internal/models/*/generateContent)
|
|
||||||
GeminiCli,
|
|
||||||
/// 未知格式
|
|
||||||
Unknown,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl ClientFormat {
|
|
||||||
/// 从请求路径检测格式
|
|
||||||
pub fn from_path(path: &str) -> Self {
|
|
||||||
if path.contains("/v1/messages") {
|
|
||||||
ClientFormat::Claude
|
|
||||||
} else if path.contains("/v1/responses") {
|
|
||||||
ClientFormat::Codex
|
|
||||||
} else if path.contains("/v1/chat/completions") {
|
|
||||||
ClientFormat::OpenAI
|
|
||||||
} else if path.contains("/v1internal/") && path.contains("generateContent") {
|
|
||||||
// Gemini CLI 使用 /v1internal/ 路径
|
|
||||||
ClientFormat::GeminiCli
|
|
||||||
} else if (path.contains("/v1beta/") || path.contains("/v1/"))
|
|
||||||
&& path.contains("generateContent")
|
|
||||||
{
|
|
||||||
// Gemini API 使用 /v1beta/ 或 /v1/ 路径
|
|
||||||
ClientFormat::Gemini
|
|
||||||
} else if path.contains("generateContent") {
|
|
||||||
// 通用 Gemini 端点
|
|
||||||
ClientFormat::Gemini
|
|
||||||
} else {
|
|
||||||
ClientFormat::Unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从请求体内容检测格式(回退方案)
|
|
||||||
pub fn from_body(body: &serde_json::Value) -> Self {
|
|
||||||
// Claude 格式特征: messages 数组 + model 字段 + 无 response_format
|
|
||||||
if body.get("messages").is_some()
|
|
||||||
&& body.get("model").is_some()
|
|
||||||
&& body.get("response_format").is_none()
|
|
||||||
&& body.get("contents").is_none()
|
|
||||||
{
|
|
||||||
// 区分 Claude 和 OpenAI
|
|
||||||
if body.get("max_tokens").is_some() {
|
|
||||||
return ClientFormat::Claude;
|
|
||||||
}
|
|
||||||
return ClientFormat::OpenAI;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Codex 格式特征: input 字段
|
|
||||||
if body.get("input").is_some() {
|
|
||||||
return ClientFormat::Codex;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gemini 格式特征: contents 数组
|
|
||||||
if body.get("contents").is_some() {
|
|
||||||
return ClientFormat::Gemini;
|
|
||||||
}
|
|
||||||
|
|
||||||
ClientFormat::Unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 转换为字符串
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
ClientFormat::Claude => "claude",
|
|
||||||
ClientFormat::Codex => "codex",
|
|
||||||
ClientFormat::OpenAI => "openai",
|
|
||||||
ClientFormat::Gemini => "gemini",
|
|
||||||
ClientFormat::GeminiCli => "gemini_cli",
|
|
||||||
ClientFormat::Unknown => "unknown",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for ClientFormat {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(f, "{}", self.as_str())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 代理会话
|
|
||||||
///
|
|
||||||
/// 包含请求全生命周期的上下文数据
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct ProxySession {
|
|
||||||
/// 唯一会话 ID
|
|
||||||
pub session_id: String,
|
|
||||||
/// 请求开始时间
|
|
||||||
pub start_time: Instant,
|
|
||||||
/// HTTP 方法
|
|
||||||
pub method: String,
|
|
||||||
/// 请求 URL
|
|
||||||
pub request_url: String,
|
|
||||||
/// User-Agent
|
|
||||||
pub user_agent: Option<String>,
|
|
||||||
/// 客户端请求格式
|
|
||||||
pub client_format: ClientFormat,
|
|
||||||
/// 选定的供应商 ID
|
|
||||||
pub provider_id: Option<String>,
|
|
||||||
/// 模型名称
|
|
||||||
pub model: Option<String>,
|
|
||||||
/// 是否为流式请求
|
|
||||||
pub is_streaming: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl ProxySession {
|
|
||||||
/// 从请求创建会话
|
|
||||||
pub fn from_request(
|
|
||||||
method: &str,
|
|
||||||
request_url: &str,
|
|
||||||
user_agent: Option<&str>,
|
|
||||||
body: Option<&serde_json::Value>,
|
|
||||||
) -> Self {
|
|
||||||
// 检测客户端格式
|
|
||||||
let mut client_format = ClientFormat::from_path(request_url);
|
|
||||||
if client_format == ClientFormat::Unknown {
|
|
||||||
if let Some(body) = body {
|
|
||||||
client_format = ClientFormat::from_body(body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检测是否为流式请求
|
|
||||||
let is_streaming = body
|
|
||||||
.and_then(|b| b.get("stream"))
|
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
// 提取模型名称
|
|
||||||
let model = body
|
|
||||||
.and_then(|b| b.get("model"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
|
|
||||||
Self {
|
|
||||||
session_id: Uuid::new_v4().to_string(),
|
|
||||||
start_time: Instant::now(),
|
|
||||||
method: method.to_string(),
|
|
||||||
request_url: request_url.to_string(),
|
|
||||||
user_agent: user_agent.map(|s| s.to_string()),
|
|
||||||
client_format,
|
|
||||||
provider_id: None,
|
|
||||||
model,
|
|
||||||
is_streaming,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置供应商 ID
|
|
||||||
pub fn with_provider(mut self, provider_id: &str) -> Self {
|
|
||||||
self.provider_id = Some(provider_id.to_string());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取请求延迟(毫秒)
|
|
||||||
pub fn latency_ms(&self) -> u64 {
|
|
||||||
self.start_time.elapsed().as_millis() as u64
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Session ID 提取器
|
// Session ID 提取器
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -380,121 +208,6 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_client_format_from_path_claude() {
|
|
||||||
assert_eq!(
|
|
||||||
ClientFormat::from_path("/v1/messages"),
|
|
||||||
ClientFormat::Claude
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
ClientFormat::from_path("/api/v1/messages"),
|
|
||||||
ClientFormat::Claude
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_client_format_from_path_codex() {
|
|
||||||
assert_eq!(
|
|
||||||
ClientFormat::from_path("/v1/responses"),
|
|
||||||
ClientFormat::Codex
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_client_format_from_path_openai() {
|
|
||||||
assert_eq!(
|
|
||||||
ClientFormat::from_path("/v1/chat/completions"),
|
|
||||||
ClientFormat::OpenAI
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_client_format_from_path_gemini() {
|
|
||||||
assert_eq!(
|
|
||||||
ClientFormat::from_path("/v1beta/models/gemini-pro:generateContent"),
|
|
||||||
ClientFormat::Gemini
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_client_format_from_path_gemini_cli() {
|
|
||||||
assert_eq!(
|
|
||||||
ClientFormat::from_path("/v1internal/models/gemini-pro:generateContent"),
|
|
||||||
ClientFormat::GeminiCli
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_client_format_from_body_claude() {
|
|
||||||
let body = json!({
|
|
||||||
"model": "claude-3-5-sonnet",
|
|
||||||
"messages": [{"role": "user", "content": "Hello"}],
|
|
||||||
"max_tokens": 1024
|
|
||||||
});
|
|
||||||
assert_eq!(ClientFormat::from_body(&body), ClientFormat::Claude);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_client_format_from_body_codex() {
|
|
||||||
let body = json!({
|
|
||||||
"input": "Write a function"
|
|
||||||
});
|
|
||||||
assert_eq!(ClientFormat::from_body(&body), ClientFormat::Codex);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_client_format_from_body_gemini() {
|
|
||||||
let body = json!({
|
|
||||||
"contents": [{"parts": [{"text": "Hello"}]}]
|
|
||||||
});
|
|
||||||
assert_eq!(ClientFormat::from_body(&body), ClientFormat::Gemini);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_session_id_uniqueness() {
|
|
||||||
let session1 = ProxySession::from_request("POST", "/v1/messages", None, None);
|
|
||||||
let session2 = ProxySession::from_request("POST", "/v1/messages", None, None);
|
|
||||||
assert_ne!(session1.session_id, session2.session_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_session_from_request() {
|
|
||||||
let body = json!({
|
|
||||||
"model": "claude-3-5-sonnet",
|
|
||||||
"messages": [{"role": "user", "content": "Hello"}],
|
|
||||||
"max_tokens": 1024,
|
|
||||||
"stream": true
|
|
||||||
});
|
|
||||||
|
|
||||||
let session =
|
|
||||||
ProxySession::from_request("POST", "/v1/messages", Some("Mozilla/5.0"), Some(&body));
|
|
||||||
|
|
||||||
assert_eq!(session.method, "POST");
|
|
||||||
assert_eq!(session.request_url, "/v1/messages");
|
|
||||||
assert_eq!(session.user_agent, Some("Mozilla/5.0".to_string()));
|
|
||||||
assert_eq!(session.client_format, ClientFormat::Claude);
|
|
||||||
assert_eq!(session.model, Some("claude-3-5-sonnet".to_string()));
|
|
||||||
assert!(session.is_streaming);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_session_with_provider() {
|
|
||||||
let session = ProxySession::from_request("POST", "/v1/messages", None, None)
|
|
||||||
.with_provider("provider-123");
|
|
||||||
|
|
||||||
assert_eq!(session.provider_id, Some("provider-123".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_client_format_as_str() {
|
|
||||||
assert_eq!(ClientFormat::Claude.as_str(), "claude");
|
|
||||||
assert_eq!(ClientFormat::Codex.as_str(), "codex");
|
|
||||||
assert_eq!(ClientFormat::OpenAI.as_str(), "openai");
|
|
||||||
assert_eq!(ClientFormat::Gemini.as_str(), "gemini");
|
|
||||||
assert_eq!(ClientFormat::GeminiCli.as_str(), "gemini_cli");
|
|
||||||
assert_eq!(ClientFormat::Unknown.as_str(), "unknown");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Session ID 提取测试 ==========
|
// ========== Session ID 提取测试 ==========
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -118,15 +118,6 @@ pub struct ProxyTakeoverStatus {
|
|||||||
pub openclaw: bool,
|
pub openclaw: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// API 格式类型(预留,当前不需要格式转换)
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub enum ApiFormat {
|
|
||||||
Claude,
|
|
||||||
OpenAI,
|
|
||||||
Gemini,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Provider健康状态
|
/// Provider健康状态
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ProviderHealth {
|
pub struct ProviderHealth {
|
||||||
|
|||||||
@@ -112,16 +112,6 @@ impl CostCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 尝试计算成本,如果模型未知则返回 None
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn try_calculate(
|
|
||||||
usage: &TokenUsage,
|
|
||||||
pricing: Option<&ModelPricing>,
|
|
||||||
cost_multiplier: Decimal,
|
|
||||||
) -> Option<CostBreakdown> {
|
|
||||||
pricing.map(|p| Self::calculate(usage, p, cost_multiplier))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn try_calculate_for_app(
|
pub fn try_calculate_for_app(
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
usage: &TokenUsage,
|
usage: &TokenUsage,
|
||||||
@@ -253,23 +243,6 @@ mod tests {
|
|||||||
assert_eq!(cost.total_cost, Decimal::from_str("0.0045").unwrap());
|
assert_eq!(cost.total_cost, Decimal::from_str("0.0045").unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_unknown_model_handling() {
|
|
||||||
let usage = TokenUsage {
|
|
||||||
input_tokens: 1000,
|
|
||||||
output_tokens: 500,
|
|
||||||
cache_read_tokens: 0,
|
|
||||||
cache_creation_tokens: 0,
|
|
||||||
model: None,
|
|
||||||
message_id: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let multiplier = Decimal::from_str("1.0").unwrap();
|
|
||||||
let cost = CostCalculator::try_calculate(&usage, None, multiplier);
|
|
||||||
|
|
||||||
assert!(cost.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_decimal_precision() {
|
fn test_decimal_precision() {
|
||||||
let usage = TokenUsage {
|
let usage = TokenUsage {
|
||||||
|
|||||||
@@ -12,4 +12,4 @@ pub use calculator::{CostBreakdown, CostCalculator, ModelPricing};
|
|||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use logger::{RequestLog, UsageLogger};
|
pub use logger::{RequestLog, UsageLogger};
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use parser::{ApiType, TokenUsage};
|
pub use parser::TokenUsage;
|
||||||
|
|||||||
@@ -82,16 +82,6 @@ impl TokenUsage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// API 类型
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub enum ApiType {
|
|
||||||
Claude,
|
|
||||||
OpenRouter,
|
|
||||||
Codex,
|
|
||||||
Gemini,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TokenUsage {
|
impl TokenUsage {
|
||||||
/// 从 Claude API 非流式响应解析
|
/// 从 Claude API 非流式响应解析
|
||||||
pub fn from_claude_response(body: &Value) -> Option<Self> {
|
pub fn from_claude_response(body: &Value) -> Option<Self> {
|
||||||
@@ -238,20 +228,6 @@ impl TokenUsage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从 OpenRouter 响应解析 (OpenAI 格式)
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn from_openrouter_response(body: &Value) -> Option<Self> {
|
|
||||||
let usage = body.get("usage")?;
|
|
||||||
Some(Self {
|
|
||||||
input_tokens: usage.get("prompt_tokens")?.as_u64()? as u32,
|
|
||||||
output_tokens: usage.get("completion_tokens")?.as_u64()? as u32,
|
|
||||||
cache_read_tokens: 0,
|
|
||||||
cache_creation_tokens: 0,
|
|
||||||
model: None,
|
|
||||||
message_id: response_id(body, "id"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 Codex API 非流式响应解析
|
/// 从 Codex API 非流式响应解析
|
||||||
pub fn from_codex_response(body: &Value) -> Option<Self> {
|
pub fn from_codex_response(body: &Value) -> Option<Self> {
|
||||||
let usage = body.get("usage");
|
let usage = body.get("usage");
|
||||||
@@ -291,60 +267,6 @@ impl TokenUsage {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从 Codex API 响应解析并调整 input_tokens
|
|
||||||
///
|
|
||||||
/// Codex 的 input_tokens 需要减去 cached_tokens 以获得实际计费的 token 数
|
|
||||||
/// 公式: adjusted_input = max(input_tokens - cached_tokens, 0)
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn from_codex_response_adjusted(body: &Value) -> Option<Self> {
|
|
||||||
let usage = body.get("usage")?;
|
|
||||||
let input_tokens = usage.get("input_tokens")?.as_u64()? as u32;
|
|
||||||
let output_tokens = usage.get("output_tokens")?.as_u64()? as u32;
|
|
||||||
|
|
||||||
// 获取 cached_tokens (可能在 cache_read_input_tokens 或 input_tokens_details 中)
|
|
||||||
let cached_tokens = openai_cache_read_tokens(usage);
|
|
||||||
let cache_write_tokens = openai_cache_write_tokens(usage);
|
|
||||||
|
|
||||||
// 调整 input_tokens: OpenAI total input 同时包含 cache read/write 两桶。
|
|
||||||
let adjusted_input = input_tokens
|
|
||||||
.saturating_sub(cached_tokens)
|
|
||||||
.saturating_sub(cache_write_tokens);
|
|
||||||
|
|
||||||
// 提取响应中的模型名称
|
|
||||||
let model = body
|
|
||||||
.get("model")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
|
|
||||||
Some(Self {
|
|
||||||
input_tokens: adjusted_input,
|
|
||||||
output_tokens,
|
|
||||||
cache_read_tokens: cached_tokens,
|
|
||||||
cache_creation_tokens: cache_write_tokens,
|
|
||||||
model,
|
|
||||||
message_id: response_id(body, "id"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 Codex API 流式响应解析
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn from_codex_stream_events(events: &[Value]) -> Option<Self> {
|
|
||||||
log::debug!("[Codex] 解析流式事件,共 {} 个事件", events.len());
|
|
||||||
for event in events {
|
|
||||||
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
|
|
||||||
log::debug!("[Codex] 事件类型: {event_type}");
|
|
||||||
if event_type == "response.completed" {
|
|
||||||
if let Some(response) = event.get("response") {
|
|
||||||
log::debug!("[Codex] 找到 response.completed 事件,解析 usage");
|
|
||||||
return Self::from_codex_response_adjusted(response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log::debug!("[Codex] 未找到 response.completed 事件");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 智能 Codex 响应解析 - 自动检测 OpenAI 或 Codex 格式
|
/// 智能 Codex 响应解析 - 自动检测 OpenAI 或 Codex 格式
|
||||||
///
|
///
|
||||||
/// Codex 支持两种 API 格式:
|
/// Codex 支持两种 API 格式:
|
||||||
@@ -750,22 +672,6 @@ mod tests {
|
|||||||
assert_eq!(usage.model, None);
|
assert_eq!(usage.model, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_openrouter_response_parsing() {
|
|
||||||
let response = json!({
|
|
||||||
"usage": {
|
|
||||||
"prompt_tokens": 100,
|
|
||||||
"completion_tokens": 50
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let usage = TokenUsage::from_openrouter_response(&response).unwrap();
|
|
||||||
assert_eq!(usage.input_tokens, 100);
|
|
||||||
assert_eq!(usage.output_tokens, 50);
|
|
||||||
assert_eq!(usage.cache_read_tokens, 0);
|
|
||||||
assert_eq!(usage.cache_creation_tokens, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_gemini_response_parsing() {
|
fn test_gemini_response_parsing() {
|
||||||
let response = json!({
|
let response = json!({
|
||||||
@@ -883,81 +789,6 @@ mod tests {
|
|||||||
assert_eq!(usage.input_tokens, 1000);
|
assert_eq!(usage.input_tokens, 1000);
|
||||||
assert_eq!(usage.cache_read_tokens, 300);
|
assert_eq!(usage.cache_read_tokens, 300);
|
||||||
assert_eq!(usage.cache_creation_tokens, 200);
|
assert_eq!(usage.cache_creation_tokens, 200);
|
||||||
|
|
||||||
let adjusted = TokenUsage::from_codex_response_adjusted(&response).unwrap();
|
|
||||||
assert_eq!(adjusted.input_tokens, 500);
|
|
||||||
assert_eq!(adjusted.cache_read_tokens, 300);
|
|
||||||
assert_eq!(adjusted.cache_creation_tokens, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_codex_response_adjusted() {
|
|
||||||
let response = json!({
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": 1000,
|
|
||||||
"output_tokens": 500,
|
|
||||||
"input_tokens_details": {
|
|
||||||
"cached_tokens": 300
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
|
|
||||||
// input_tokens 应该被调整: 1000 - 300 = 700
|
|
||||||
assert_eq!(usage.input_tokens, 700);
|
|
||||||
assert_eq!(usage.output_tokens, 500);
|
|
||||||
assert_eq!(usage.cache_read_tokens, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_codex_response_adjusted_no_cache() {
|
|
||||||
let response = json!({
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": 1000,
|
|
||||||
"output_tokens": 500
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
|
|
||||||
// 没有 cached_tokens,input_tokens 保持不变
|
|
||||||
assert_eq!(usage.input_tokens, 1000);
|
|
||||||
assert_eq!(usage.output_tokens, 500);
|
|
||||||
assert_eq!(usage.cache_read_tokens, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_codex_response_adjusted_cache_read_input_tokens() {
|
|
||||||
let response = json!({
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": 1000,
|
|
||||||
"output_tokens": 500,
|
|
||||||
"cache_read_input_tokens": 200
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
|
|
||||||
assert_eq!(usage.input_tokens, 800);
|
|
||||||
assert_eq!(usage.output_tokens, 500);
|
|
||||||
assert_eq!(usage.cache_read_tokens, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_codex_response_adjusted_saturating_sub() {
|
|
||||||
// 测试 cached_tokens > input_tokens 的边界情况
|
|
||||||
let response = json!({
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": 100,
|
|
||||||
"output_tokens": 50,
|
|
||||||
"input_tokens_details": {
|
|
||||||
"cached_tokens": 200
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
|
|
||||||
// saturating_sub 确保不会下溢
|
|
||||||
assert_eq!(usage.input_tokens, 0);
|
|
||||||
assert_eq!(usage.cache_read_tokens, 200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface ColorPickerProps {
|
|
||||||
value?: string;
|
|
||||||
onValueChange: (color: string) => void;
|
|
||||||
label?: string;
|
|
||||||
presets?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_PRESETS = [
|
|
||||||
"#00A67E",
|
|
||||||
"#D4915D",
|
|
||||||
"#4285F4",
|
|
||||||
"#FF6A00",
|
|
||||||
"#00A4FF",
|
|
||||||
"#FF9900",
|
|
||||||
"#0078D4",
|
|
||||||
"#FF0000",
|
|
||||||
"#1E88E5",
|
|
||||||
"#6366F1",
|
|
||||||
"#0F62FE",
|
|
||||||
"#2932E1",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const ColorPicker: React.FC<ColorPickerProps> = ({
|
|
||||||
value = "#4285F4",
|
|
||||||
onValueChange,
|
|
||||||
label,
|
|
||||||
presets = DEFAULT_PRESETS,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const displayLabel = label ?? t("providerIcon.color", "图标颜色");
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label>{displayLabel}</Label>
|
|
||||||
|
|
||||||
{/* 颜色预设 */}
|
|
||||||
<div className="grid grid-cols-6 gap-2">
|
|
||||||
{presets.map((color) => (
|
|
||||||
<button
|
|
||||||
key={color}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onValueChange(color)}
|
|
||||||
className={cn(
|
|
||||||
"w-full aspect-square rounded-lg border-2 transition-all",
|
|
||||||
"hover:scale-110 hover:shadow-lg",
|
|
||||||
value === color
|
|
||||||
? "border-primary ring-2 ring-primary/20"
|
|
||||||
: "border-border",
|
|
||||||
)}
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
title={color}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 自定义颜色输入 */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
type="color"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onValueChange(e.target.value)}
|
|
||||||
className="w-16 h-10 p-1 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onValueChange(e.target.value)}
|
|
||||||
placeholder="#4285F4"
|
|
||||||
className="flex-1 font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
import { SVGProps } from "react";
|
|
||||||
|
|
||||||
export function ITermIcon(props: SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<title>iTerm2</title>
|
|
||||||
<path d="M24 5.359v13.282A5.36 5.36 0 0 1 18.641 24H5.359A5.36 5.36 0 0 1 0 18.641V5.359A5.36 5.36 0 0 1 5.359 0h13.282A5.36 5.36 0 0 1 24 5.359m-.932-.233A4.196 4.196 0 0 0 18.874.932H5.126A4.196 4.196 0 0 0 .932 5.126v13.748a4.196 4.196 0 0 0 4.194 4.194h13.748a4.196 4.196 0 0 0 4.194-4.194zm-.816.233v13.282a3.613 3.613 0 0 1-3.611 3.611H5.359a3.613 3.613 0 0 1-3.611-3.611V5.359a3.613 3.613 0 0 1 3.611-3.611h13.282a3.613 3.613 0 0 1 3.611 3.611M8.854 4.194v6.495h.962V4.194zM5.483 9.493v1.085h.597V9.48q.283-.037.508-.133.373-.165.575-.448.208-.284.208-.649a.9.9 0 0 0-.171-.568 1.4 1.4 0 0 0-.426-.388 3 3 0 0 0-.544-.261 32 32 0 0 0-.545-.209 1.8 1.8 0 0 1-.426-.216q-.164-.12-.164-.284 0-.223.179-.351.18-.126.485-.127.344 0 .575.105.239.105.5.298l.433-.5a2.3 2.3 0 0 0-.605-.433 1.6 1.6 0 0 0-.582-.159v-.968h-.597v.978a2 2 0 0 0-.477.127 1.2 1.2 0 0 0-.545.411q-.194.268-.194.634 0 .335.164.56.164.224.418.38a4 4 0 0 0 .552.262q.291.104.545.209.261.104.425.238a.39.39 0 0 1 .165.321q0 .225-.187.359-.18.134-.537.134-.381 0-.717-.134a4.4 4.4 0 0 1-.649-.351l-.388.589q.209.173.477.306.276.135.575.217.191.046.373.064" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AlacrittyIcon(props: SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<title>Alacritty</title>
|
|
||||||
<path d="m10.065 0-8.57 21.269h3.595l6.91-16.244 6.91 16.244h3.594l-8.57-21.269zm1.935 9.935c-0.76666 1.8547-1.5334 3.7094-2.298 5.565 1.475 4.54 1.475 4.54 2.298 8.5 0.823-3.96 0.823-3.96 2.297-8.5-0.76637-1.8547-1.5315-3.7099-2.297-5.565z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WezTermIcon(props: SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<title>WezTerm</title>
|
|
||||||
<path d="M3.27 8.524c0-.623.62-1.007 2.123-1.007l-.5 2.757c-.931-.623-1.624-1.199-1.624-1.75zm4.008 6.807c0 .647-.644 1.079-2.123 1.15l.524-2.924c.931.624 1.6 1.175 1.6 1.774zm-2.625 5.992.454-2.708c3.603-.336 5.01-1.798 5.01-3.404 0-1.653-2.004-2.948-3.841-4.074l.668-3.548c.764.072 1.67.216 2.744.432l.31-2.469c-.81-.12-1.575-.168-2.29-.216L8.257 2.7l-2.363-.024-.453 2.684C1.838 5.648.43 7.158.43 8.764c0 1.63 2.004 2.876 3.841 3.954l-.668 3.716c-.859-.048-1.908-.192-3.125-.408L0 18.495c1.026.12 1.98.192 2.84.216l-.525 2.588zm15.553-1.894h2.673c.334-2.804.81-8.46 1.121-14.86h-2.553c-.071 1.51-.334 10.498-.43 11.241h-.071c-.644-2.42-1.169-4.386-1.813-6.782h-1.456c-.62 2.396-1.05 4.194-1.694 6.782h-.096c-.071-.743-.477-9.73-.525-11.24h-2.648c.31 6.399.763 12.055 1.097 14.86h2.625l1.838-7.12z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GhosttyIcon(props: SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<title>Ghostty</title>
|
|
||||||
<path d="M12 0C6.7 0 2.4 4.3 2.4 9.6v11.146c0 1.772 1.45 3.267 3.222 3.254a3.18 3.18 0 0 0 1.955-.686 1.96 1.96 0 0 1 2.444 0 3.18 3.18 0 0 0 1.976.686c.75 0 1.436-.257 1.98-.686.715-.563 1.71-.587 2.419-.018.59.476 1.355.743 2.182.699 1.705-.094 3.022-1.537 3.022-3.244V9.601C21.6 4.3 17.302 0 12 0M6.069 6.562a1 1 0 0 1 .46.131l3.578 2.065v.002a.974.974 0 0 1 0 1.687L6.53 12.512a.975.975 0 0 1-.976-1.687L7.67 9.602 5.553 8.38a.975.975 0 0 1 .515-1.818m7.438 2.063h4.7a.975.975 0 1 1 0 1.95h-4.7a.975.975 0 0 1 0-1.95" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KittyIcon(props: SVGProps<SVGSVGElement>) {
|
|
||||||
// Official icon is complex and has fixed width/height/viewBox in original.
|
|
||||||
// Simplifying viewBox to 0 0 256 256 effectively as original was 240x240 but translated.
|
|
||||||
// Original viewBox="0 0 240 240" with g transform="translate(0 -812.362)" and elements around y=850.
|
|
||||||
// 850 - 812 = 38. So it's confusing.
|
|
||||||
// Let's copy the raw SVG content but adapt it to be a component.
|
|
||||||
// To make it behave like an icon, we should probably set viewBox="0 0 240 240" and keep the transform.
|
|
||||||
// It relies on fill colors. If we want it to be monochrome (currentColor), we should remove fills or set them to currentColor.
|
|
||||||
// However, official icons often have brand colors. The user said "official icon", which implies color.
|
|
||||||
// But usually in a dropdown we might want monochrome or original color.
|
|
||||||
// simple-icons are usually monochrome.
|
|
||||||
// Let's keep Kitty as original color since it's complex, OR mono if it works?
|
|
||||||
// The kitty icon has multiple paths with different colors. I'll preserve them for now as it's "official".
|
|
||||||
// If it looks weird in dark mode/light mode, we might need to adjust.
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 240 240"
|
|
||||||
{...props} // Allow overriding width/height
|
|
||||||
>
|
|
||||||
<g transform="translate(0 -812.362)">
|
|
||||||
<rect
|
|
||||||
width="100.446"
|
|
||||||
height="161.551"
|
|
||||||
x="72.824"
|
|
||||||
y="850.13"
|
|
||||||
ry="0"
|
|
||||||
style={{
|
|
||||||
fill: "#ddd",
|
|
||||||
fillOpacity: 1,
|
|
||||||
fillRule: "evenodd",
|
|
||||||
stroke: "none",
|
|
||||||
strokeWidth: 5.86876726,
|
|
||||||
strokeLinecap: "round",
|
|
||||||
strokeLinejoin: "round",
|
|
||||||
strokeMiterlimit: 4,
|
|
||||||
strokeDasharray: "none",
|
|
||||||
strokeOpacity: 1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M67.896 1029.71h104.208a7.065 7.065 0 0 0 7.065-7.066V918.436a7.065 7.065 0 0 0-7.065-7.065H67.896a7.065 7.065 0 0 0-7.065 7.065v104.208a7.065 7.065 0 0 0 7.065 7.065m55.813-38.35h37.444a4.239 4.239 0 0 1 0 8.479H123.71a4.239 4.239 0 0 1 0-8.478m-45.032-45.71a4.239 4.239 0 0 1 5.991-5.99l26.48 26.464a4.24 4.24 0 0 1 0 5.992l-26.48 26.48a4.239 4.239 0 0 1-5.991-5.992l23.484-23.484z"
|
|
||||||
style={{ strokeWidth: 1.41299629 }}
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M96.085 898.143c1.881 0 3.386-3.574 3.386-8.17 0-4.595-1.505-8.169-3.386-8.169-1.88 0-3.385 3.574-3.385 8.17 0 4.595 1.504 8.17 3.385 8.17"
|
|
||||||
style={{
|
|
||||||
clipRule: "evenodd",
|
|
||||||
fill: "#c0c81f",
|
|
||||||
fillOpacity: 1,
|
|
||||||
fillRule: "evenodd",
|
|
||||||
strokeWidth: 3.09913683,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M193.128 836.886c-4.596-4.85-25.53 1.022-38.295 8.936-9.957-5.106-21.956-8.17-34.721-8.17-13.02 0-25.02 3.064-34.977 8.17-12.765-7.914-33.955-14.042-38.295-8.936-4.595 5.106 3.32 26.296 12.765 38.04-.766 3.064-1.276 6.128-1.276 9.446 0 10.212 4.34 19.659 11.744 27.318h42.124c-1.276-2.553.511-4.085 8.17-4.085 7.659.255 9.19 1.532 8.17 4.085h42.124c7.404-7.66 11.744-17.36 11.744-27.318 0-3.318-.51-6.382-1.276-9.446 8.935-11.744 16.594-33.189 11.999-38.04m-97.015 67.4c-8.935 0-16.339-7.404-16.339-16.34s7.404-16.339 16.34-16.339 16.339 7.404 16.339 16.34-7.404 16.339-16.34 16.339m47.997 0c-8.936 0-16.34-7.404-16.34-16.34s7.404-16.339 16.34-16.339 16.34 7.404 16.34 16.34-7.15 16.339-16.34 16.339"
|
|
||||||
style={{
|
|
||||||
clipRule: "evenodd",
|
|
||||||
fill: "#784421",
|
|
||||||
fillOpacity: 1,
|
|
||||||
fillRule: "evenodd",
|
|
||||||
strokeWidth: 2.55301046,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<g style={{ fill: "#2b1100", fillOpacity: 1 }}>
|
|
||||||
<path
|
|
||||||
d="M168.507 903.265c15.318-19.148 46.72-28.339 67.655-15.063-24.509-3.83-46.72 2.553-67.655 15.063"
|
|
||||||
style={{
|
|
||||||
clipRule: "evenodd",
|
|
||||||
fillRule: "evenodd",
|
|
||||||
strokeWidth: 2.55301046,
|
|
||||||
fill: "#2b1100",
|
|
||||||
fillOpacity: 1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M167.486 898.67c8.68-20.425 34.466-33.7 55.145-26.552-21.7 2.808-39.316 11.233-55.145 26.551m-.256 9.957c15.83-15.063 50.806-20.169 61.528-4.34-21.7-6.893-40.593-3.83-61.527 4.34"
|
|
||||||
style={{
|
|
||||||
clipRule: "evenodd",
|
|
||||||
fillRule: "evenodd",
|
|
||||||
strokeWidth: 2.55301046,
|
|
||||||
fill: "#2b1100",
|
|
||||||
fillOpacity: 1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<g style={{ fill: "#2b1100", fillOpacity: 1 }}>
|
|
||||||
<path
|
|
||||||
d="M71.493 903.265c-15.318-19.148-46.72-28.339-67.655-15.063 24.509-3.83 46.72 2.553 67.655 15.063"
|
|
||||||
style={{
|
|
||||||
clipRule: "evenodd",
|
|
||||||
fillRule: "evenodd",
|
|
||||||
strokeWidth: 2.55301046,
|
|
||||||
fill: "#2b1100",
|
|
||||||
fillOpacity: 1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M72.514 898.67c-8.68-20.425-34.466-33.7-55.145-26.552 21.7 2.808 39.316 11.233 55.145 26.551m.256 9.957c-15.83-15.063-50.806-20.169-61.528-4.34 21.7-6.893 40.593-3.83 61.527 4.34"
|
|
||||||
style={{
|
|
||||||
clipRule: "evenodd",
|
|
||||||
fillRule: "evenodd",
|
|
||||||
strokeWidth: 2.55301046,
|
|
||||||
fill: "#2b1100",
|
|
||||||
fillOpacity: 1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<path
|
|
||||||
d="M52.6 893.563c-6.382 0-11.743 3.32-14.296 8.425h-.766c-6.893 0-12.765 5.106-12.765 11.489 0 8.935 9.19 13.786 17.615 10.722 5.106 7.404 16.084 7.915 20.17 0 6.126-.255 16.083-1.276 17.615-10.722 1.021-6.383-5.617-11.489-12.765-11.489h-.766c-2.042-5.106-7.659-8.425-14.041-8.425m134.8 0c6.382 0 11.743 3.32 14.296 8.425h.766c3.574 0 12.765 5.106 12.765 11.489 0 8.935-9.19 13.786-17.615 10.722-5.107 7.404-16.084 7.915-20.17 0-6.126-.255-16.083-1.276-17.615-10.722-1.021-6.383 9.19-11.489 12.765-11.489h.766c2.042-5.106 7.659-8.425 14.041-8.425"
|
|
||||||
style={{
|
|
||||||
clipRule: "evenodd",
|
|
||||||
fill: "#483737",
|
|
||||||
fillOpacity: 1,
|
|
||||||
fillRule: "evenodd",
|
|
||||||
strokeWidth: 2.55301046,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M143.542 898.143c1.881 0 3.386-3.574 3.386-8.17 0-4.595-1.505-8.169-3.386-8.169-1.88 0-3.386 3.574-3.386 8.17 0 4.595 1.505 8.17 3.386 8.17"
|
|
||||||
style={{
|
|
||||||
clipRule: "evenodd",
|
|
||||||
fill: "#c0c81f",
|
|
||||||
fillOpacity: 1,
|
|
||||||
fillRule: "evenodd",
|
|
||||||
strokeWidth: 3.09913683,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { Moon, Sun } from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useTheme } from "@/components/theme-provider";
|
|
||||||
|
|
||||||
export function ModeToggle() {
|
|
||||||
const { theme, setTheme } = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
|
||||||
if (theme === "dark") {
|
|
||||||
setTheme("light");
|
|
||||||
} else {
|
|
||||||
setTheme("dark");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button variant="outline" size="icon" onClick={toggleTheme}>
|
|
||||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
|
||||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
|
||||||
<span className="sr-only">{t("common.toggleTheme")}</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import MarkdownEditor from "@/components/MarkdownEditor";
|
|
||||||
import type { Prompt, AppId } from "@/lib/api";
|
|
||||||
|
|
||||||
interface PromptFormModalProps {
|
|
||||||
appId: AppId;
|
|
||||||
editingId?: string;
|
|
||||||
initialData?: Prompt;
|
|
||||||
onSave: (id: string, prompt: Prompt) => Promise<void>;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PromptFormModal: React.FC<PromptFormModalProps> = ({
|
|
||||||
appId,
|
|
||||||
editingId,
|
|
||||||
initialData,
|
|
||||||
onSave,
|
|
||||||
onClose,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const appName = t(`apps.${appId}`);
|
|
||||||
const filenameMap: Record<Exclude<AppId, "openclaw">, string> = {
|
|
||||||
claude: "CLAUDE.md",
|
|
||||||
"claude-desktop": "CLAUDE.md",
|
|
||||||
codex: "AGENTS.md",
|
|
||||||
gemini: "GEMINI.md",
|
|
||||||
grokbuild: "AGENTS.md",
|
|
||||||
opencode: "AGENTS.md",
|
|
||||||
hermes: "AGENTS.md",
|
|
||||||
};
|
|
||||||
const filename = filenameMap[appId as Exclude<AppId, "openclaw">];
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [content, setContent] = useState("");
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 检测初始暗色模式状态
|
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
|
||||||
|
|
||||||
// 监听 html 元素的 class 变化以实时响应主题切换
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["class"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialData) {
|
|
||||||
setName(initialData.name);
|
|
||||||
setDescription(initialData.description || "");
|
|
||||||
setContent(initialData.content);
|
|
||||||
}
|
|
||||||
}, [initialData]);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!name.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const id = editingId || `prompt-${Date.now()}`;
|
|
||||||
const timestamp = Math.floor(Date.now() / 1000);
|
|
||||||
const prompt: Prompt = {
|
|
||||||
id,
|
|
||||||
name: name.trim(),
|
|
||||||
description: description.trim() || undefined,
|
|
||||||
content: content.trim(),
|
|
||||||
enabled: initialData?.enabled || false,
|
|
||||||
createdAt: initialData?.createdAt || timestamp,
|
|
||||||
updatedAt: timestamp,
|
|
||||||
};
|
|
||||||
await onSave(id, prompt);
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
// Error handled by hook
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open onOpenChange={onClose}>
|
|
||||||
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{editingId
|
|
||||||
? t("prompts.editTitle", { appName })
|
|
||||||
: t("prompts.addTitle", { appName })}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-4 px-6 py-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="name">{t("prompts.name")}</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder={t("prompts.namePlaceholder")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="description">{t("prompts.description")}</Label>
|
|
||||||
<Input
|
|
||||||
id="description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder={t("prompts.descriptionPlaceholder")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="content" className="mb-2 block">
|
|
||||||
{t("prompts.content")}
|
|
||||||
</Label>
|
|
||||||
<MarkdownEditor
|
|
||||||
value={content}
|
|
||||||
onChange={setContent}
|
|
||||||
placeholder={t("prompts.contentPlaceholder", { filename })}
|
|
||||||
darkMode={isDarkMode}
|
|
||||||
minHeight="300px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!name.trim() || saving}
|
|
||||||
>
|
|
||||||
{saving ? t("common.saving") : t("common.save")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PromptFormModal;
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { HealthStatus } from "@/lib/api/connectivity-check";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface HealthStatusIndicatorProps {
|
|
||||||
status: HealthStatus;
|
|
||||||
responseTimeMs?: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusConfig = {
|
|
||||||
operational: {
|
|
||||||
color: "bg-emerald-500",
|
|
||||||
labelKey: "health.operational",
|
|
||||||
labelFallback: "正常",
|
|
||||||
textColor: "text-emerald-600 dark:text-emerald-400",
|
|
||||||
},
|
|
||||||
degraded: {
|
|
||||||
color: "bg-yellow-500",
|
|
||||||
labelKey: "health.degraded",
|
|
||||||
labelFallback: "降级",
|
|
||||||
textColor: "text-yellow-600 dark:text-yellow-400",
|
|
||||||
},
|
|
||||||
failed: {
|
|
||||||
color: "bg-red-500",
|
|
||||||
labelKey: "health.failed",
|
|
||||||
labelFallback: "失败",
|
|
||||||
textColor: "text-red-600 dark:text-red-400",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const HealthStatusIndicator: React.FC<HealthStatusIndicatorProps> = ({
|
|
||||||
status,
|
|
||||||
responseTimeMs,
|
|
||||||
className,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const config = statusConfig[status];
|
|
||||||
const label = t(config.labelKey, { defaultValue: config.labelFallback });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("flex items-center gap-2", className)}>
|
|
||||||
<div className={cn("w-2 h-2 rounded-full", config.color)} />
|
|
||||||
<span className={cn("text-xs font-medium", config.textColor)}>
|
|
||||||
{label}
|
|
||||||
{responseTimeMs !== undefined && ` (${responseTimeMs}ms)`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -4,7 +4,6 @@ export { useBaseUrlState } from "./useBaseUrlState";
|
|||||||
export { useModelState } from "./useModelState";
|
export { useModelState } from "./useModelState";
|
||||||
export { useCodexConfigState } from "./useCodexConfigState";
|
export { useCodexConfigState } from "./useCodexConfigState";
|
||||||
export { useApiKeyLink } from "./useApiKeyLink";
|
export { useApiKeyLink } from "./useApiKeyLink";
|
||||||
export { useCustomEndpoints } from "./useCustomEndpoints";
|
|
||||||
export { useTemplateValues } from "./useTemplateValues";
|
export { useTemplateValues } from "./useTemplateValues";
|
||||||
export { useCommonConfigSnippet } from "./useCommonConfigSnippet";
|
export { useCommonConfigSnippet } from "./useCommonConfigSnippet";
|
||||||
export { useCodexCommonConfig } from "./useCodexCommonConfig";
|
export { useCodexCommonConfig } from "./useCodexCommonConfig";
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import type { AppId } from "@/lib/api";
|
|
||||||
import type { CustomEndpoint } from "@/types";
|
|
||||||
import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
|
||||||
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
|
||||||
|
|
||||||
type PresetEntry = {
|
|
||||||
id: string;
|
|
||||||
preset: ProviderPreset | CodexProviderPreset;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface UseCustomEndpointsProps {
|
|
||||||
appId: AppId;
|
|
||||||
selectedPresetId: string | null;
|
|
||||||
presetEntries: PresetEntry[];
|
|
||||||
draftCustomEndpoints: string[];
|
|
||||||
baseUrl: string;
|
|
||||||
codexBaseUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 收集和管理自定义端点
|
|
||||||
*
|
|
||||||
* 收集来源:
|
|
||||||
* 1. 用户在测速弹窗中新增的自定义端点
|
|
||||||
* 2. 预设中的 endpointCandidates
|
|
||||||
* 3. 当前选中的 Base URL
|
|
||||||
*/
|
|
||||||
export function useCustomEndpoints({
|
|
||||||
appId,
|
|
||||||
selectedPresetId,
|
|
||||||
presetEntries,
|
|
||||||
draftCustomEndpoints,
|
|
||||||
baseUrl,
|
|
||||||
codexBaseUrl,
|
|
||||||
}: UseCustomEndpointsProps) {
|
|
||||||
const customEndpointsMap = useMemo(() => {
|
|
||||||
const urlSet = new Set<string>();
|
|
||||||
|
|
||||||
// 辅助函数:标准化并添加 URL
|
|
||||||
const push = (raw?: string) => {
|
|
||||||
const url = (raw || "").trim().replace(/\/+$/, "");
|
|
||||||
if (url) urlSet.add(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1. 自定义端点(来自用户新增)
|
|
||||||
for (const u of draftCustomEndpoints) push(u);
|
|
||||||
|
|
||||||
// 2. 预设端点候选
|
|
||||||
if (selectedPresetId && selectedPresetId !== "custom") {
|
|
||||||
const entry = presetEntries.find((item) => item.id === selectedPresetId);
|
|
||||||
if (entry) {
|
|
||||||
const preset = entry.preset as any;
|
|
||||||
if (Array.isArray(preset?.endpointCandidates)) {
|
|
||||||
for (const u of preset.endpointCandidates as string[]) push(u);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 当前 Base URL
|
|
||||||
if (appId === "codex") {
|
|
||||||
push(codexBaseUrl);
|
|
||||||
} else {
|
|
||||||
push(baseUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建 CustomEndpoint map
|
|
||||||
const urls = Array.from(urlSet.values());
|
|
||||||
if (urls.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const customMap: Record<string, CustomEndpoint> = {};
|
|
||||||
for (const url of urls) {
|
|
||||||
if (!customMap[url]) {
|
|
||||||
customMap[url] = { url, addedAt: now, lastUsed: undefined };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return customMap;
|
|
||||||
}, [
|
|
||||||
appId,
|
|
||||||
selectedPresetId,
|
|
||||||
presetEntries,
|
|
||||||
draftCustomEndpoints,
|
|
||||||
baseUrl,
|
|
||||||
codexBaseUrl,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return customEndpointsMap;
|
|
||||||
}
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
import {
|
|
||||||
useCircuitBreakerConfig,
|
|
||||||
useUpdateCircuitBreakerConfig,
|
|
||||||
} from "@/lib/query/failover";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 熔断器配置面板
|
|
||||||
* 允许用户调整熔断器参数
|
|
||||||
*/
|
|
||||||
export function CircuitBreakerConfigPanel() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data: config, isLoading } = useCircuitBreakerConfig();
|
|
||||||
const updateConfig = useUpdateCircuitBreakerConfig();
|
|
||||||
|
|
||||||
// 使用字符串状态以支持完全清空输入框
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
failureThreshold: "5",
|
|
||||||
successThreshold: "2",
|
|
||||||
timeoutSeconds: "60",
|
|
||||||
errorRateThreshold: "50", // 存储百分比值
|
|
||||||
minRequests: "10",
|
|
||||||
});
|
|
||||||
|
|
||||||
// 当配置加载完成时更新表单数据
|
|
||||||
useEffect(() => {
|
|
||||||
if (config) {
|
|
||||||
setFormData({
|
|
||||||
failureThreshold: String(config.failureThreshold),
|
|
||||||
successThreshold: String(config.successThreshold),
|
|
||||||
timeoutSeconds: String(config.timeoutSeconds),
|
|
||||||
errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)),
|
|
||||||
minRequests: String(config.minRequests),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
// 解析数字,返回 NaN 表示无效输入
|
|
||||||
const parseNum = (val: string) => {
|
|
||||||
const trimmed = val.trim();
|
|
||||||
// 必须是纯数字
|
|
||||||
if (!/^-?\d+$/.test(trimmed)) return NaN;
|
|
||||||
return parseInt(trimmed);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 定义各字段的有效范围
|
|
||||||
const ranges = {
|
|
||||||
failureThreshold: { min: 1, max: 20 },
|
|
||||||
successThreshold: { min: 1, max: 10 },
|
|
||||||
timeoutSeconds: { min: 0, max: 300 },
|
|
||||||
errorRateThreshold: { min: 0, max: 100 },
|
|
||||||
minRequests: { min: 5, max: 100 },
|
|
||||||
};
|
|
||||||
|
|
||||||
// 解析原始值
|
|
||||||
const raw = {
|
|
||||||
failureThreshold: parseNum(formData.failureThreshold),
|
|
||||||
successThreshold: parseNum(formData.successThreshold),
|
|
||||||
timeoutSeconds: parseNum(formData.timeoutSeconds),
|
|
||||||
errorRateThreshold: parseNum(formData.errorRateThreshold),
|
|
||||||
minRequests: parseNum(formData.minRequests),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 校验是否超出范围(NaN 也视为无效)
|
|
||||||
const errors: string[] = [];
|
|
||||||
const checkRange = (
|
|
||||||
value: number,
|
|
||||||
range: { min: number; max: number },
|
|
||||||
label: string,
|
|
||||||
) => {
|
|
||||||
if (isNaN(value) || value < range.min || value > range.max) {
|
|
||||||
errors.push(`${label}: ${range.min}-${range.max}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkRange(
|
|
||||||
raw.failureThreshold,
|
|
||||||
ranges.failureThreshold,
|
|
||||||
t("circuitBreaker.failureThreshold", "失败阈值"),
|
|
||||||
);
|
|
||||||
checkRange(
|
|
||||||
raw.successThreshold,
|
|
||||||
ranges.successThreshold,
|
|
||||||
t("circuitBreaker.successThreshold", "成功阈值"),
|
|
||||||
);
|
|
||||||
checkRange(
|
|
||||||
raw.timeoutSeconds,
|
|
||||||
ranges.timeoutSeconds,
|
|
||||||
t("circuitBreaker.timeoutSeconds", "超时时间"),
|
|
||||||
);
|
|
||||||
checkRange(
|
|
||||||
raw.errorRateThreshold,
|
|
||||||
ranges.errorRateThreshold,
|
|
||||||
t("circuitBreaker.errorRateThreshold", "错误率阈值"),
|
|
||||||
);
|
|
||||||
checkRange(
|
|
||||||
raw.minRequests,
|
|
||||||
ranges.minRequests,
|
|
||||||
t("circuitBreaker.minRequests", "最小请求数"),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (errors.length > 0) {
|
|
||||||
toast.error(
|
|
||||||
t("circuitBreaker.validationFailed", {
|
|
||||||
fields: errors.join("; "),
|
|
||||||
defaultValue: `以下字段超出有效范围: ${errors.join("; ")}`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateConfig.mutateAsync({
|
|
||||||
failureThreshold: raw.failureThreshold,
|
|
||||||
successThreshold: raw.successThreshold,
|
|
||||||
timeoutSeconds: raw.timeoutSeconds,
|
|
||||||
errorRateThreshold: raw.errorRateThreshold / 100,
|
|
||||||
minRequests: raw.minRequests,
|
|
||||||
});
|
|
||||||
toast.success(t("circuitBreaker.configSaved", "熔断器配置已保存"), {
|
|
||||||
closeButton: true,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
t("circuitBreaker.saveFailed", "保存失败") + ": " + String(error),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
if (config) {
|
|
||||||
setFormData({
|
|
||||||
failureThreshold: String(config.failureThreshold),
|
|
||||||
successThreshold: String(config.successThreshold),
|
|
||||||
timeoutSeconds: String(config.timeoutSeconds),
|
|
||||||
errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)),
|
|
||||||
minRequests: String(config.minRequests),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{t("circuitBreaker.loading", "加载中...")}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold">
|
|
||||||
{t("circuitBreaker.title", "熔断器配置")}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
{t(
|
|
||||||
"circuitBreaker.description",
|
|
||||||
"调整熔断器参数以控制故障检测和恢复行为",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-px bg-border my-4" />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{/* 失败阈值 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="failureThreshold">
|
|
||||||
{t("circuitBreaker.failureThreshold", "失败阈值")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="failureThreshold"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="20"
|
|
||||||
value={formData.failureThreshold}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, failureThreshold: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"circuitBreaker.failureThresholdHint",
|
|
||||||
"连续失败多少次后打开熔断器",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 超时时间 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="timeoutSeconds">
|
|
||||||
{t("circuitBreaker.timeoutSeconds", "超时时间(秒)")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="timeoutSeconds"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="300"
|
|
||||||
value={formData.timeoutSeconds}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, timeoutSeconds: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"circuitBreaker.timeoutSecondsHint",
|
|
||||||
"熔断器打开后多久尝试恢复(半开状态)",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 成功阈值 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="successThreshold">
|
|
||||||
{t("circuitBreaker.successThreshold", "成功阈值")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="successThreshold"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="10"
|
|
||||||
value={formData.successThreshold}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, successThreshold: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"circuitBreaker.successThresholdHint",
|
|
||||||
"半开状态下成功多少次后关闭熔断器",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 错误率阈值 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="errorRateThreshold">
|
|
||||||
{t("circuitBreaker.errorRateThreshold", "错误率阈值 (%)")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="errorRateThreshold"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
step="5"
|
|
||||||
value={formData.errorRateThreshold}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, errorRateThreshold: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"circuitBreaker.errorRateThresholdHint",
|
|
||||||
"错误率超过此值时打开熔断器",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 最小请求数 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="minRequests">
|
|
||||||
{t("circuitBreaker.minRequests", "最小请求数")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="minRequests"
|
|
||||||
type="number"
|
|
||||||
min="5"
|
|
||||||
max="100"
|
|
||||||
value={formData.minRequests}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, minRequests: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("circuitBreaker.minRequestsHint", "计算错误率前的最小请求数")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button onClick={handleSave} disabled={updateConfig.isPending}>
|
|
||||||
{updateConfig.isPending
|
|
||||||
? t("common.saving", "保存中...")
|
|
||||||
: t("circuitBreaker.saveConfig", "保存配置")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleReset}
|
|
||||||
disabled={updateConfig.isPending}
|
|
||||||
>
|
|
||||||
{t("common.reset", "重置")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 说明信息 */}
|
|
||||||
<div className="p-4 bg-muted/50 rounded-lg space-y-2 text-sm">
|
|
||||||
<h4 className="font-medium">
|
|
||||||
{t("circuitBreaker.instructionsTitle", "配置说明")}
|
|
||||||
</h4>
|
|
||||||
<ul className="space-y-1 text-muted-foreground">
|
|
||||||
<li>
|
|
||||||
•{" "}
|
|
||||||
<strong>{t("circuitBreaker.failureThreshold", "失败阈值")}</strong>
|
|
||||||
:
|
|
||||||
{t(
|
|
||||||
"circuitBreaker.instructions.failureThreshold",
|
|
||||||
"连续失败达到此次数时,熔断器打开",
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
• <strong>{t("circuitBreaker.timeoutSeconds", "超时时间")}</strong>
|
|
||||||
:
|
|
||||||
{t(
|
|
||||||
"circuitBreaker.instructions.timeout",
|
|
||||||
"熔断器打开后,等待此时间后尝试半开",
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
•{" "}
|
|
||||||
<strong>{t("circuitBreaker.successThreshold", "成功阈值")}</strong>
|
|
||||||
:
|
|
||||||
{t(
|
|
||||||
"circuitBreaker.instructions.successThreshold",
|
|
||||||
"半开状态下,成功达到此次数时关闭熔断器",
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
•{" "}
|
|
||||||
<strong>
|
|
||||||
{t("circuitBreaker.errorRateThreshold", "错误率阈值")}
|
|
||||||
</strong>
|
|
||||||
:
|
|
||||||
{t(
|
|
||||||
"circuitBreaker.instructions.errorRate",
|
|
||||||
"错误率超过此值时,熔断器打开",
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
• <strong>{t("circuitBreaker.minRequests", "最小请求数")}</strong>:
|
|
||||||
{t(
|
|
||||||
"circuitBreaker.instructions.minRequests",
|
|
||||||
"只有请求数达到此值后才计算错误率",
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Trash2, ExternalLink, Plus } from "lucide-react";
|
|
||||||
import { settingsApi } from "@/lib/api";
|
|
||||||
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
|
|
||||||
|
|
||||||
interface RepoManagerProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
repos: SkillRepo[];
|
|
||||||
skills: DiscoverableSkill[];
|
|
||||||
onAdd: (repo: SkillRepo) => Promise<void>;
|
|
||||||
onRemove: (owner: string, name: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RepoManager({
|
|
||||||
open: isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
repos,
|
|
||||||
skills,
|
|
||||||
onAdd,
|
|
||||||
onRemove,
|
|
||||||
}: RepoManagerProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [repoUrl, setRepoUrl] = useState("");
|
|
||||||
const [branch, setBranch] = useState("");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
const getSkillCount = (repo: SkillRepo) =>
|
|
||||||
skills.filter(
|
|
||||||
(skill) =>
|
|
||||||
skill.repoOwner === repo.owner &&
|
|
||||||
skill.repoName === repo.name &&
|
|
||||||
(skill.repoBranch || "main") === (repo.branch || "main"),
|
|
||||||
).length;
|
|
||||||
|
|
||||||
const parseRepoUrl = (
|
|
||||||
url: string,
|
|
||||||
): { owner: string; name: string } | null => {
|
|
||||||
// 支持格式:
|
|
||||||
// - https://github.com/owner/name
|
|
||||||
// - owner/name
|
|
||||||
// - https://github.com/owner/name.git
|
|
||||||
|
|
||||||
let cleaned = url.trim();
|
|
||||||
cleaned = cleaned.replace(/^https?:\/\/github\.com\//, "");
|
|
||||||
cleaned = cleaned.replace(/\.git$/, "");
|
|
||||||
|
|
||||||
const parts = cleaned.split("/");
|
|
||||||
if (parts.length === 2 && parts[0] && parts[1]) {
|
|
||||||
return { owner: parts[0], name: parts[1] };
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAdd = async () => {
|
|
||||||
setError("");
|
|
||||||
|
|
||||||
const parsed = parseRepoUrl(repoUrl);
|
|
||||||
if (!parsed) {
|
|
||||||
setError(t("skills.repo.invalidUrl"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await onAdd({
|
|
||||||
owner: parsed.owner,
|
|
||||||
name: parsed.name,
|
|
||||||
branch: branch || "main",
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
setRepoUrl("");
|
|
||||||
setBranch("");
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : t("skills.repo.addFailed"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenRepo = async (owner: string, name: string) => {
|
|
||||||
try {
|
|
||||||
await settingsApi.openExternal(`https://github.com/${owner}/${name}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to open URL:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col p-0">
|
|
||||||
{/* 固定头部 */}
|
|
||||||
<DialogHeader className="flex-shrink-0 border-b border-border-default px-6 py-4">
|
|
||||||
<DialogTitle>{t("skills.repo.title")}</DialogTitle>
|
|
||||||
<DialogDescription>{t("skills.repo.description")}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{/* 可滚动内容区域 */}
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4">
|
|
||||||
{/* 添加仓库表单 */}
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="repo-url">{t("skills.repo.url")}</Label>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<Input
|
|
||||||
id="repo-url"
|
|
||||||
placeholder={t("skills.repo.urlPlaceholder")}
|
|
||||||
value={repoUrl}
|
|
||||||
onChange={(e) => setRepoUrl(e.target.value)}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row">
|
|
||||||
<Input
|
|
||||||
id="branch"
|
|
||||||
placeholder={t("skills.repo.branchPlaceholder")}
|
|
||||||
value={branch}
|
|
||||||
onChange={(e) => setBranch(e.target.value)}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleAdd}
|
|
||||||
className="w-full sm:w-auto sm:px-4"
|
|
||||||
variant="mcp"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
{t("skills.repo.add")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 仓库列表 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="text-sm font-medium">{t("skills.repo.list")}</h4>
|
|
||||||
{repos.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("skills.repo.empty")}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{repos.map((repo) => (
|
|
||||||
<div
|
|
||||||
key={`${repo.owner}/${repo.name}`}
|
|
||||||
className="flex items-center justify-between rounded-xl border border-border-default bg-card px-4 py-3"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-foreground">
|
|
||||||
{repo.owner}/{repo.name}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{t("skills.repo.branch")}: {repo.branch || "main"}
|
|
||||||
<span className="ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]">
|
|
||||||
{t("skills.repo.skillCount", {
|
|
||||||
count: getSkillCount(repo),
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleOpenRepo(repo.owner, repo.name)}
|
|
||||||
title={t("common.view", { defaultValue: "查看" })}
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
type="button"
|
|
||||||
onClick={() => onRemove(repo.owner, repo.name)}
|
|
||||||
title={t("common.delete")}
|
|
||||||
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { usageApi } from "@/lib/api/usage";
|
|
||||||
import { usageKeys } from "@/lib/query/usage";
|
|
||||||
import { Database, FileText, RefreshCw, Loader2 } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface DataSourceBarProps {
|
|
||||||
refreshIntervalMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DATA_SOURCE_ICONS: Record<string, React.ReactNode> = {
|
|
||||||
proxy: <Database className="h-3.5 w-3.5" />,
|
|
||||||
session_log: <FileText className="h-3.5 w-3.5" />,
|
|
||||||
codex_db: <Database className="h-3.5 w-3.5" />,
|
|
||||||
codex_session: <FileText className="h-3.5 w-3.5" />,
|
|
||||||
gemini_session: <FileText className="h-3.5 w-3.5" />,
|
|
||||||
opencode_session: <FileText className="h-3.5 w-3.5" />,
|
|
||||||
grok_session: <FileText className="h-3.5 w-3.5" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DataSourceBar({ refreshIntervalMs }: DataSourceBarProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [syncing, setSyncing] = useState(false);
|
|
||||||
|
|
||||||
const { data: sources } = useQuery({
|
|
||||||
queryKey: [...usageKeys.all, "data-sources"],
|
|
||||||
queryFn: usageApi.getDataSourceBreakdown,
|
|
||||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
|
||||||
refetchIntervalInBackground: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSync = async () => {
|
|
||||||
setSyncing(true);
|
|
||||||
try {
|
|
||||||
const result = await usageApi.syncSessionUsage();
|
|
||||||
if (result.imported > 0) {
|
|
||||||
toast.success(
|
|
||||||
t("usage.sessionSync.imported", {
|
|
||||||
count: result.imported,
|
|
||||||
defaultValue: "Imported {{count}} records from session logs",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
// Refresh all usage data
|
|
||||||
queryClient.invalidateQueries({ queryKey: usageKeys.all });
|
|
||||||
} else {
|
|
||||||
toast.info(
|
|
||||||
t("usage.sessionSync.upToDate", {
|
|
||||||
defaultValue: "Session logs are up to date",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error(
|
|
||||||
t("usage.sessionSync.failed", {
|
|
||||||
defaultValue: "Session sync failed",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setSyncing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!sources || sources.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasNonProxy = sources.some((s) => s.dataSource !== "proxy");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground bg-muted/30 rounded-lg px-4 py-2">
|
|
||||||
<span className="font-medium text-foreground/70">
|
|
||||||
{t("usage.dataSources", { defaultValue: "Data Sources" })}:
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
|
||||||
{sources.map((source) => (
|
|
||||||
<div
|
|
||||||
key={source.dataSource}
|
|
||||||
className="flex items-center gap-1.5 bg-background/50 rounded-md px-2 py-1"
|
|
||||||
>
|
|
||||||
{DATA_SOURCE_ICONS[source.dataSource] ?? (
|
|
||||||
<Database className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
<span>
|
|
||||||
{t(`usage.dataSource.${source.dataSource}`, {
|
|
||||||
defaultValue: source.dataSource,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<span className="font-mono font-medium text-foreground/80">
|
|
||||||
{source.requestCount.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ml-auto">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2 text-xs"
|
|
||||||
onClick={handleSync}
|
|
||||||
disabled={syncing}
|
|
||||||
title={t("usage.sessionSync.trigger", {
|
|
||||||
defaultValue: "Sync session logs",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{syncing ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
<span className="ml-1">
|
|
||||||
{hasNonProxy
|
|
||||||
? t("usage.sessionSync.resync", { defaultValue: "Sync" })
|
|
||||||
: t("usage.sessionSync.import", {
|
|
||||||
defaultValue: "Import Sessions",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
/**
|
|
||||||
* 根据供应商名称智能推断图标配置
|
|
||||||
*/
|
|
||||||
|
|
||||||
const iconMappings = {
|
|
||||||
// AI 服务商
|
|
||||||
claude: { icon: "claude", iconColor: "#D4915D" },
|
|
||||||
anthropic: { icon: "anthropic", iconColor: "#D4915D" },
|
|
||||||
deepseek: { icon: "deepseek", iconColor: "#1E88E5" },
|
|
||||||
zhipu: { icon: "zhipu", iconColor: "#0F62FE" },
|
|
||||||
glm: { icon: "zhipu", iconColor: "#0F62FE" },
|
|
||||||
qwen: { icon: "qwen", iconColor: "#FF6A00" },
|
|
||||||
bailian: { icon: "bailian", iconColor: "#624AFF" },
|
|
||||||
alibaba: { icon: "alibaba", iconColor: "#FF6A00" },
|
|
||||||
aliyun: { icon: "alibaba", iconColor: "#FF6A00" },
|
|
||||||
kimi: { icon: "kimi", iconColor: "#6366F1" },
|
|
||||||
moonshot: { icon: "moonshot", iconColor: "#6366F1" },
|
|
||||||
stepfun: { icon: "stepfun", iconColor: "#005AFF" },
|
|
||||||
step: { icon: "stepfun", iconColor: "#005AFF" },
|
|
||||||
baidu: { icon: "baidu", iconColor: "#2932E1" },
|
|
||||||
tencent: { icon: "tencent", iconColor: "#00A4FF" },
|
|
||||||
hunyuan: { icon: "hunyuan", iconColor: "#00A4FF" },
|
|
||||||
minimax: { icon: "minimax", iconColor: "#FF6B6B" },
|
|
||||||
google: { icon: "google", iconColor: "#4285F4" },
|
|
||||||
meta: { icon: "meta", iconColor: "#0081FB" },
|
|
||||||
mistral: { icon: "mistral", iconColor: "#FF7000" },
|
|
||||||
cohere: { icon: "cohere", iconColor: "#39594D" },
|
|
||||||
perplexity: { icon: "perplexity", iconColor: "#20808D" },
|
|
||||||
huggingface: { icon: "huggingface", iconColor: "#FFD21E" },
|
|
||||||
novita: { icon: "novita", iconColor: "#000000" },
|
|
||||||
|
|
||||||
// 云平台
|
|
||||||
aws: { icon: "aws", iconColor: "#FF9900" },
|
|
||||||
azure: { icon: "azure", iconColor: "#0078D4" },
|
|
||||||
huawei: { icon: "huawei", iconColor: "#FF0000" },
|
|
||||||
cloudflare: { icon: "cloudflare", iconColor: "#F38020" },
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据预设名称推断图标
|
|
||||||
*/
|
|
||||||
export function inferIconForPreset(presetName: string): {
|
|
||||||
icon?: string;
|
|
||||||
iconColor?: string;
|
|
||||||
} {
|
|
||||||
const nameLower = presetName.toLowerCase();
|
|
||||||
|
|
||||||
// 精确匹配或模糊匹配
|
|
||||||
for (const [key, config] of Object.entries(iconMappings)) {
|
|
||||||
if (nameLower.includes(key)) {
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 批量为预设添加图标配置
|
|
||||||
*/
|
|
||||||
export function addIconsToPresets<
|
|
||||||
T extends { name: string; icon?: string; iconColor?: string },
|
|
||||||
>(presets: T[]): T[] {
|
|
||||||
return presets.map((preset) => {
|
|
||||||
// 如果已经配置了图标,则保留原配置
|
|
||||||
if (preset.icon) {
|
|
||||||
return preset;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 否则根据名称推断
|
|
||||||
const inferred = inferIconForPreset(preset.name);
|
|
||||||
return {
|
|
||||||
...preset,
|
|
||||||
...inferred,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
/**
|
|
||||||
* 代理配置管理 Hook
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import type { ProxyConfig } from "@/types/proxy";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 代理配置管理
|
|
||||||
*/
|
|
||||||
export function useProxyConfig() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// 查询配置
|
|
||||||
const { data: config, isLoading } = useQuery({
|
|
||||||
queryKey: ["proxyConfig"],
|
|
||||||
queryFn: () => invoke<ProxyConfig>("get_proxy_config"),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 更新配置
|
|
||||||
const updateMutation = useMutation({
|
|
||||||
mutationFn: (newConfig: ProxyConfig) =>
|
|
||||||
invoke("update_proxy_config", { config: newConfig }),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success(t("proxy.settings.toast.saved"), { closeButton: true });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["proxyConfig"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
|
||||||
},
|
|
||||||
onError: (error: Error) => {
|
|
||||||
toast.error(
|
|
||||||
t("proxy.settings.toast.saveFailed", {
|
|
||||||
error: error.message,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
config,
|
|
||||||
isLoading,
|
|
||||||
updateConfig: updateMutation.mutateAsync,
|
|
||||||
isUpdating: updateMutation.isPending,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -23,7 +23,6 @@
|
|||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"enterValidValue": "Please enter a valid value",
|
"enterValidValue": "Please enter a valid value",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"toggleTheme": "Toggle theme",
|
|
||||||
"format": "Format",
|
"format": "Format",
|
||||||
"formatSuccess": "Formatted successfully",
|
"formatSuccess": "Formatted successfully",
|
||||||
"formatError": "Format failed: {{error}}",
|
"formatError": "Format failed: {{error}}",
|
||||||
@@ -1507,24 +1506,6 @@
|
|||||||
"grokbuild": "Grok Build"
|
"grokbuild": "Grok Build"
|
||||||
},
|
},
|
||||||
"rawInputLabel": "Raw",
|
"rawInputLabel": "Raw",
|
||||||
"dataSources": "Data Sources",
|
|
||||||
"dataSource": {
|
|
||||||
"proxy": "Routing",
|
|
||||||
"session_log": "Session Log",
|
|
||||||
"codex_db": "Codex DB",
|
|
||||||
"codex_session": "Codex Session",
|
|
||||||
"gemini_session": "Gemini Session",
|
|
||||||
"opencode_session": "OpenCode Session",
|
|
||||||
"grok_session": "Grok Build Session"
|
|
||||||
},
|
|
||||||
"sessionSync": {
|
|
||||||
"trigger": "Sync session logs",
|
|
||||||
"import": "Import Sessions",
|
|
||||||
"resync": "Sync",
|
|
||||||
"imported": "Imported {{count}} records from session logs",
|
|
||||||
"upToDate": "Session logs are up to date",
|
|
||||||
"failed": "Session sync failed"
|
|
||||||
},
|
|
||||||
"rebuildCodex": {
|
"rebuildCodex": {
|
||||||
"title": "Codex Usage Maintenance",
|
"title": "Codex Usage Maintenance",
|
||||||
"description": "Rebuild Codex session usage from local rollout logs",
|
"description": "Rebuild Codex session usage from local rollout logs",
|
||||||
@@ -2539,8 +2520,7 @@
|
|||||||
"selectIcon": "Select Icon",
|
"selectIcon": "Select Icon",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
"clickToChange": "Click to change icon",
|
"clickToChange": "Click to change icon",
|
||||||
"clickToSelect": "Click to select icon",
|
"clickToSelect": "Click to select icon"
|
||||||
"color": "Icon Color"
|
|
||||||
},
|
},
|
||||||
"migration": {
|
"migration": {
|
||||||
"success": "Configuration migrated successfully",
|
"success": "Configuration migrated successfully",
|
||||||
@@ -2782,33 +2762,6 @@
|
|||||||
"streamingIdle": "Streaming Idle Timeout",
|
"streamingIdle": "Streaming Idle Timeout",
|
||||||
"nonStreaming": "Non-Streaming Timeout"
|
"nonStreaming": "Non-Streaming Timeout"
|
||||||
},
|
},
|
||||||
"circuitBreaker": {
|
|
||||||
"failureThreshold": "Failure Threshold",
|
|
||||||
"successThreshold": "Success Threshold",
|
|
||||||
"timeoutSeconds": "Timeout (seconds)",
|
|
||||||
"errorRateThreshold": "Error Rate Threshold (%)",
|
|
||||||
"minRequests": "Minimum Requests",
|
|
||||||
"validationFailed": "The following fields are out of valid range: {{fields}}",
|
|
||||||
"configSaved": "Circuit breaker configuration saved",
|
|
||||||
"saveFailed": "Save failed",
|
|
||||||
"loading": "Loading...",
|
|
||||||
"title": "Circuit Breaker Configuration",
|
|
||||||
"description": "Adjust circuit breaker parameters to control fault detection and recovery behavior",
|
|
||||||
"failureThresholdHint": "How many consecutive failures trigger the circuit breaker",
|
|
||||||
"timeoutSecondsHint": "How long to wait before attempting recovery (half-open state)",
|
|
||||||
"successThresholdHint": "How many successes in half-open state to close the circuit breaker",
|
|
||||||
"errorRateThresholdHint": "Open circuit breaker when error rate exceeds this value",
|
|
||||||
"minRequestsHint": "Minimum requests before calculating error rate",
|
|
||||||
"saveConfig": "Save Configuration",
|
|
||||||
"instructionsTitle": "Configuration Instructions",
|
|
||||||
"instructions": {
|
|
||||||
"failureThreshold": "Circuit breaker opens when consecutive failures reach this count",
|
|
||||||
"timeout": "After circuit breaker opens, wait this time before attempting half-open",
|
|
||||||
"successThreshold": "In half-open state, close circuit breaker when successes reach this count",
|
|
||||||
"errorRate": "Circuit breaker opens when error rate exceeds this value",
|
|
||||||
"minRequests": "Error rate is only calculated after request count reaches this value"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"universalProvider": {
|
"universalProvider": {
|
||||||
"duplicate": "Duplicate",
|
"duplicate": "Duplicate",
|
||||||
"duplicatedAndSynced": "Universal provider duplicated and synced",
|
"duplicatedAndSynced": "Universal provider duplicated and synced",
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
"unknown": "不明",
|
"unknown": "不明",
|
||||||
"enterValidValue": "有効な値を入力してください",
|
"enterValidValue": "有効な値を入力してください",
|
||||||
"clear": "クリア",
|
"clear": "クリア",
|
||||||
"toggleTheme": "テーマを切り替え",
|
|
||||||
"format": "フォーマット",
|
"format": "フォーマット",
|
||||||
"formatSuccess": "整形しました",
|
"formatSuccess": "整形しました",
|
||||||
"formatError": "整形に失敗しました: {{error}}",
|
"formatError": "整形に失敗しました: {{error}}",
|
||||||
@@ -1507,24 +1506,6 @@
|
|||||||
"grokbuild": "Grok Build"
|
"grokbuild": "Grok Build"
|
||||||
},
|
},
|
||||||
"rawInputLabel": "原始",
|
"rawInputLabel": "原始",
|
||||||
"dataSources": "データソース",
|
|
||||||
"dataSource": {
|
|
||||||
"proxy": "ルーティング",
|
|
||||||
"session_log": "セッションログ",
|
|
||||||
"codex_db": "Codex DB",
|
|
||||||
"codex_session": "Codex セッション",
|
|
||||||
"gemini_session": "Gemini セッション",
|
|
||||||
"opencode_session": "OpenCode セッション",
|
|
||||||
"grok_session": "Grok Build セッション"
|
|
||||||
},
|
|
||||||
"sessionSync": {
|
|
||||||
"trigger": "セッションログを同期",
|
|
||||||
"import": "セッションをインポート",
|
|
||||||
"resync": "同期",
|
|
||||||
"imported": "セッションログから {{count}} 件のレコードをインポートしました",
|
|
||||||
"upToDate": "セッションログは最新です",
|
|
||||||
"failed": "セッション同期に失敗しました"
|
|
||||||
},
|
|
||||||
"rebuildCodex": {
|
"rebuildCodex": {
|
||||||
"title": "Codex 使用量メンテナンス",
|
"title": "Codex 使用量メンテナンス",
|
||||||
"description": "ローカルの rollout ログから Codex セッション使用量を再構築します",
|
"description": "ローカルの rollout ログから Codex セッション使用量を再構築します",
|
||||||
@@ -2539,8 +2520,7 @@
|
|||||||
"selectIcon": "アイコンを選択",
|
"selectIcon": "アイコンを選択",
|
||||||
"preview": "プレビュー",
|
"preview": "プレビュー",
|
||||||
"clickToChange": "クリックでアイコンを変更",
|
"clickToChange": "クリックでアイコンを変更",
|
||||||
"clickToSelect": "クリックでアイコンを選択",
|
"clickToSelect": "クリックでアイコンを選択"
|
||||||
"color": "アイコンカラー"
|
|
||||||
},
|
},
|
||||||
"migration": {
|
"migration": {
|
||||||
"success": "設定の移行が完了しました",
|
"success": "設定の移行が完了しました",
|
||||||
@@ -2782,33 +2762,6 @@
|
|||||||
"streamingIdle": "ストリーミングアイドルタイムアウト",
|
"streamingIdle": "ストリーミングアイドルタイムアウト",
|
||||||
"nonStreaming": "非ストリーミングタイムアウト"
|
"nonStreaming": "非ストリーミングタイムアウト"
|
||||||
},
|
},
|
||||||
"circuitBreaker": {
|
|
||||||
"failureThreshold": "失敗閾値",
|
|
||||||
"successThreshold": "成功閾値",
|
|
||||||
"timeoutSeconds": "タイムアウト(秒)",
|
|
||||||
"errorRateThreshold": "エラー率閾値 (%)",
|
|
||||||
"minRequests": "最小リクエスト数",
|
|
||||||
"validationFailed": "以下のフィールドが有効範囲外です: {{fields}}",
|
|
||||||
"configSaved": "サーキットブレーカー設定が保存されました",
|
|
||||||
"saveFailed": "保存に失敗しました",
|
|
||||||
"loading": "読み込み中...",
|
|
||||||
"title": "サーキットブレーカー設定",
|
|
||||||
"description": "サーキットブレーカーパラメータを調整して、障害検出と復旧動作を制御します",
|
|
||||||
"failureThresholdHint": "連続失敗後にサーキットブレーカーを開く回数",
|
|
||||||
"timeoutSecondsHint": "サーキットブレーカーを開いた後、復旧を試みるまでの時間(半開状態)",
|
|
||||||
"successThresholdHint": "半開状態で成功してサーキットブレーカーを閉じる回数",
|
|
||||||
"errorRateThresholdHint": "エラー率がこの値を超えるとサーキットブレーカーを開く",
|
|
||||||
"minRequestsHint": "エラー率を計算する前の最小リクエスト数",
|
|
||||||
"saveConfig": "設定を保存",
|
|
||||||
"instructionsTitle": "設定説明",
|
|
||||||
"instructions": {
|
|
||||||
"failureThreshold": "連続失敗がこの回数に達するとサーキットブレーカーが開く",
|
|
||||||
"timeout": "サーキットブレーカーを開いた後、この時間待機してから半開を試みる",
|
|
||||||
"successThreshold": "半開状態で成功がこの回数に達するとサーキットブレーカーを閉じる",
|
|
||||||
"errorRate": "エラー率がこの値を超えるとサーキットブレーカーが開く",
|
|
||||||
"minRequests": "リクエスト数がこの値に達した後にのみエラー率が計算される"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"universalProvider": {
|
"universalProvider": {
|
||||||
"duplicate": "複製",
|
"duplicate": "複製",
|
||||||
"duplicatedAndSynced": "統合プロバイダーを複製して同期しました",
|
"duplicatedAndSynced": "統合プロバイダーを複製して同期しました",
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
"unknown": "未知",
|
"unknown": "未知",
|
||||||
"enterValidValue": "請輸入有效的內容",
|
"enterValidValue": "請輸入有效的內容",
|
||||||
"clear": "清除",
|
"clear": "清除",
|
||||||
"toggleTheme": "切換主題",
|
|
||||||
"format": "格式化",
|
"format": "格式化",
|
||||||
"formatSuccess": "格式化成功",
|
"formatSuccess": "格式化成功",
|
||||||
"formatError": "格式化失敗:{{error}}",
|
"formatError": "格式化失敗:{{error}}",
|
||||||
@@ -1478,24 +1477,6 @@
|
|||||||
"grokbuild": "Grok Build"
|
"grokbuild": "Grok Build"
|
||||||
},
|
},
|
||||||
"rawInputLabel": "原始",
|
"rawInputLabel": "原始",
|
||||||
"dataSources": "資料來源",
|
|
||||||
"dataSource": {
|
|
||||||
"proxy": "路由",
|
|
||||||
"session_log": "工作階段日誌",
|
|
||||||
"codex_db": "Codex 資料庫",
|
|
||||||
"codex_session": "Codex 工作階段日誌",
|
|
||||||
"gemini_session": "Gemini 工作階段日誌",
|
|
||||||
"opencode_session": "OpenCode 工作階段日誌",
|
|
||||||
"grok_session": "Grok Build 工作階段日誌"
|
|
||||||
},
|
|
||||||
"sessionSync": {
|
|
||||||
"trigger": "同步工作階段日誌",
|
|
||||||
"import": "匯入工作階段",
|
|
||||||
"resync": "同步",
|
|
||||||
"imported": "從工作階段日誌匯入了 {{count}} 筆紀錄",
|
|
||||||
"upToDate": "工作階段日誌已是最新",
|
|
||||||
"failed": "工作階段同步失敗"
|
|
||||||
},
|
|
||||||
"rebuildCodex": {
|
"rebuildCodex": {
|
||||||
"title": "Codex 用量維護",
|
"title": "Codex 用量維護",
|
||||||
"description": "從本機 rollout 日誌重新建構 Codex 工作階段用量",
|
"description": "從本機 rollout 日誌重新建構 Codex 工作階段用量",
|
||||||
@@ -2510,8 +2491,7 @@
|
|||||||
"selectIcon": "選擇圖示",
|
"selectIcon": "選擇圖示",
|
||||||
"preview": "預覽",
|
"preview": "預覽",
|
||||||
"clickToChange": "點擊更換圖示",
|
"clickToChange": "點擊更換圖示",
|
||||||
"clickToSelect": "點擊選擇圖示",
|
"clickToSelect": "點擊選擇圖示"
|
||||||
"color": "圖示顏色"
|
|
||||||
},
|
},
|
||||||
"migration": {
|
"migration": {
|
||||||
"success": "設定遷移成功",
|
"success": "設定遷移成功",
|
||||||
@@ -2753,33 +2733,6 @@
|
|||||||
"streamingIdle": "串流閒置逾時",
|
"streamingIdle": "串流閒置逾時",
|
||||||
"nonStreaming": "非串流逾時"
|
"nonStreaming": "非串流逾時"
|
||||||
},
|
},
|
||||||
"circuitBreaker": {
|
|
||||||
"failureThreshold": "失敗閾值",
|
|
||||||
"successThreshold": "成功閾值",
|
|
||||||
"timeoutSeconds": "逾時時間(秒)",
|
|
||||||
"errorRateThreshold": "錯誤率閾值 (%)",
|
|
||||||
"minRequests": "最小請求數",
|
|
||||||
"validationFailed": "以下欄位超出有效範圍:{{fields}}",
|
|
||||||
"configSaved": "斷路器設定已儲存",
|
|
||||||
"saveFailed": "儲存失敗",
|
|
||||||
"loading": "載入中...",
|
|
||||||
"title": "斷路器設定",
|
|
||||||
"description": "調整斷路器參數以控制故障檢測和恢復行為",
|
|
||||||
"failureThresholdHint": "連續失敗多少次後打開斷路器",
|
|
||||||
"timeoutSecondsHint": "斷路器打開後多久嘗試恢復(半開狀態)",
|
|
||||||
"successThresholdHint": "半開狀態下成功多少次後關閉斷路器",
|
|
||||||
"errorRateThresholdHint": "錯誤率超過此值時打開斷路器",
|
|
||||||
"minRequestsHint": "計算錯誤率前的最小請求數",
|
|
||||||
"saveConfig": "儲存設定",
|
|
||||||
"instructionsTitle": "設定說明",
|
|
||||||
"instructions": {
|
|
||||||
"failureThreshold": "連續失敗達到此次數時,斷路器打開",
|
|
||||||
"timeout": "斷路器打開後,等待此時間後嘗試半開",
|
|
||||||
"successThreshold": "半開狀態下,成功達到此次數時關閉斷路器",
|
|
||||||
"errorRate": "錯誤率超過此值時,斷路器打開",
|
|
||||||
"minRequests": "只有請求數達到此值後才計算錯誤率"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"universalProvider": {
|
"universalProvider": {
|
||||||
"duplicate": "複製",
|
"duplicate": "複製",
|
||||||
"duplicatedAndSynced": "通用供應商已複製並同步",
|
"duplicatedAndSynced": "通用供應商已複製並同步",
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
"unknown": "未知",
|
"unknown": "未知",
|
||||||
"enterValidValue": "请输入有效的内容",
|
"enterValidValue": "请输入有效的内容",
|
||||||
"clear": "清除",
|
"clear": "清除",
|
||||||
"toggleTheme": "切换主题",
|
|
||||||
"format": "格式化",
|
"format": "格式化",
|
||||||
"formatSuccess": "格式化成功",
|
"formatSuccess": "格式化成功",
|
||||||
"formatError": "格式化失败:{{error}}",
|
"formatError": "格式化失败:{{error}}",
|
||||||
@@ -1507,24 +1506,6 @@
|
|||||||
"grokbuild": "Grok Build"
|
"grokbuild": "Grok Build"
|
||||||
},
|
},
|
||||||
"rawInputLabel": "原始",
|
"rawInputLabel": "原始",
|
||||||
"dataSources": "数据来源",
|
|
||||||
"dataSource": {
|
|
||||||
"proxy": "路由",
|
|
||||||
"session_log": "会话日志",
|
|
||||||
"codex_db": "Codex 数据库",
|
|
||||||
"codex_session": "Codex 会话日志",
|
|
||||||
"gemini_session": "Gemini 会话日志",
|
|
||||||
"opencode_session": "OpenCode 会话日志",
|
|
||||||
"grok_session": "Grok Build 会话日志"
|
|
||||||
},
|
|
||||||
"sessionSync": {
|
|
||||||
"trigger": "同步会话日志",
|
|
||||||
"import": "导入会话",
|
|
||||||
"resync": "同步",
|
|
||||||
"imported": "从会话日志导入了 {{count}} 条记录",
|
|
||||||
"upToDate": "会话日志已是最新",
|
|
||||||
"failed": "会话同步失败"
|
|
||||||
},
|
|
||||||
"rebuildCodex": {
|
"rebuildCodex": {
|
||||||
"title": "Codex 用量维护",
|
"title": "Codex 用量维护",
|
||||||
"description": "从本地 rollout 日志重新构建 Codex 会话用量",
|
"description": "从本地 rollout 日志重新构建 Codex 会话用量",
|
||||||
@@ -2539,8 +2520,7 @@
|
|||||||
"selectIcon": "选择图标",
|
"selectIcon": "选择图标",
|
||||||
"preview": "预览",
|
"preview": "预览",
|
||||||
"clickToChange": "点击更换图标",
|
"clickToChange": "点击更换图标",
|
||||||
"clickToSelect": "点击选择图标",
|
"clickToSelect": "点击选择图标"
|
||||||
"color": "图标颜色"
|
|
||||||
},
|
},
|
||||||
"migration": {
|
"migration": {
|
||||||
"success": "配置迁移成功",
|
"success": "配置迁移成功",
|
||||||
@@ -2782,33 +2762,6 @@
|
|||||||
"streamingIdle": "流式静默超时",
|
"streamingIdle": "流式静默超时",
|
||||||
"nonStreaming": "非流式超时"
|
"nonStreaming": "非流式超时"
|
||||||
},
|
},
|
||||||
"circuitBreaker": {
|
|
||||||
"failureThreshold": "失败阈值",
|
|
||||||
"successThreshold": "成功阈值",
|
|
||||||
"timeoutSeconds": "超时时间(秒)",
|
|
||||||
"errorRateThreshold": "错误率阈值 (%)",
|
|
||||||
"minRequests": "最小请求数",
|
|
||||||
"validationFailed": "以下字段超出有效范围: {{fields}}",
|
|
||||||
"configSaved": "熔断器配置已保存",
|
|
||||||
"saveFailed": "保存失败",
|
|
||||||
"loading": "加载中...",
|
|
||||||
"title": "熔断器配置",
|
|
||||||
"description": "调整熔断器参数以控制故障检测和恢复行为",
|
|
||||||
"failureThresholdHint": "连续失败多少次后打开熔断器",
|
|
||||||
"timeoutSecondsHint": "熔断器打开后多久尝试恢复(半开状态)",
|
|
||||||
"successThresholdHint": "半开状态下成功多少次后关闭熔断器",
|
|
||||||
"errorRateThresholdHint": "错误率超过此值时打开熔断器",
|
|
||||||
"minRequestsHint": "计算错误率前的最小请求数",
|
|
||||||
"saveConfig": "保存配置",
|
|
||||||
"instructionsTitle": "配置说明",
|
|
||||||
"instructions": {
|
|
||||||
"failureThreshold": "连续失败达到此次数时,熔断器打开",
|
|
||||||
"timeout": "熔断器打开后,等待此时间后尝试半开",
|
|
||||||
"successThreshold": "半开状态下,成功达到此次数时关闭熔断器",
|
|
||||||
"errorRate": "错误率超过此值时,熔断器打开",
|
|
||||||
"minRequests": "只有请求数达到此值后才计算错误率"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"universalProvider": {
|
"universalProvider": {
|
||||||
"duplicate": "复制",
|
"duplicate": "复制",
|
||||||
"duplicatedAndSynced": "统一供应商已复制并同步",
|
"duplicatedAndSynced": "统一供应商已复制并同步",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Auto-generated icon index
|
// Hand-curated icon index with optimized SVG content and custom name mappings.
|
||||||
// Do not edit manually
|
// Update entries deliberately; automatic regeneration is intentionally unsupported.
|
||||||
|
|
||||||
import _a6api from "./a6-icon.png";
|
import _a6api from "./a6-icon.png";
|
||||||
import _apikeyfun from "./apikeyfun.png";
|
import _apikeyfun from "./apikeyfun.png";
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
import { validateToml, tomlToMcpServer } from "@/utils/tomlUtils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析 JSON 语法错误,返回更友好的位置信息。
|
|
||||||
*/
|
|
||||||
function parseJsonError(error: unknown): string {
|
|
||||||
if (!(error instanceof SyntaxError)) {
|
|
||||||
return "JSON 格式错误";
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = error.message || "JSON 解析失败";
|
|
||||||
|
|
||||||
// Chrome/V8: "Unexpected token ... in JSON at position 123"
|
|
||||||
const positionMatch = message.match(/at position (\d+)/i);
|
|
||||||
if (positionMatch) {
|
|
||||||
const position = parseInt(positionMatch[1], 10);
|
|
||||||
return `JSON 格式错误(位置:${position})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Firefox: "JSON.parse: unexpected character at line 1 column 23"
|
|
||||||
const lineColumnMatch = message.match(/line (\d+) column (\d+)/i);
|
|
||||||
if (lineColumnMatch) {
|
|
||||||
const line = lineColumnMatch[1];
|
|
||||||
const column = lineColumnMatch[2];
|
|
||||||
return `JSON 格式错误:第 ${line} 行,第 ${column} 列`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `JSON 格式错误:${message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通用的 JSON 配置文本校验:
|
|
||||||
* - 非空
|
|
||||||
* - 可解析且为对象(非数组)
|
|
||||||
*/
|
|
||||||
export const jsonConfigSchema = z
|
|
||||||
.string()
|
|
||||||
.min(1, "配置不能为空")
|
|
||||||
.superRefine((value, ctx) => {
|
|
||||||
try {
|
|
||||||
const obj = JSON.parse(value);
|
|
||||||
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: "需为单个对象配置",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: parseJsonError(e),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通用的 TOML 配置文本校验:
|
|
||||||
* - 允许为空(由上层业务决定是否必填)
|
|
||||||
* - 语法与结构有效
|
|
||||||
* - 针对 stdio/http/sse 的必填字段(command/url)进行提示
|
|
||||||
*/
|
|
||||||
export const tomlConfigSchema = z.string().superRefine((value, ctx) => {
|
|
||||||
const err = validateToml(value);
|
|
||||||
if (err) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: `TOML 无效:${err}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!value.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const server = tomlToMcpServer(value);
|
|
||||||
if (server.type === "stdio" && !server.command?.trim()) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: "stdio 类型需填写 command",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(server.type === "http" || server.type === "sse") &&
|
|
||||||
!server.url?.trim()
|
|
||||||
) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: `${server.type} 类型需填写 url`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: e?.message || "TOML 解析失败",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const mcpServerSpecSchema = z
|
|
||||||
.object({
|
|
||||||
type: z.enum(["stdio", "http", "sse"]).optional(),
|
|
||||||
command: z.string().trim().optional(),
|
|
||||||
args: z.array(z.string()).optional(),
|
|
||||||
env: z.record(z.string(), z.string()).optional(),
|
|
||||||
cwd: z.string().optional(),
|
|
||||||
url: z.string().trim().url("请输入有效的 URL").optional(),
|
|
||||||
headers: z.record(z.string(), z.string()).optional(),
|
|
||||||
})
|
|
||||||
.superRefine((server, ctx) => {
|
|
||||||
const type = server.type ?? "stdio";
|
|
||||||
if (type === "stdio" && !server.command?.trim()) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: "stdio 类型需填写 command",
|
|
||||||
path: ["command"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if ((type === "http" || type === "sse") && !server.url?.trim()) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: `${type} 类型需填写 url`,
|
|
||||||
path: ["url"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const mcpServerSchema = z.object({
|
|
||||||
id: z.string().min(1, "请输入服务器 ID"),
|
|
||||||
name: z.string().optional(),
|
|
||||||
description: z.string().optional(),
|
|
||||||
tags: z.array(z.string()).optional(),
|
|
||||||
homepage: z.string().url().optional(),
|
|
||||||
docs: z.string().url().optional(),
|
|
||||||
enabled: z.boolean().optional(),
|
|
||||||
server: mcpServerSpecSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type McpServerFormData = z.infer<typeof mcpServerSchema>;
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const directorySchema = z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(1, "路径不能为空")
|
|
||||||
.optional()
|
|
||||||
.or(z.literal(""));
|
|
||||||
|
|
||||||
export const settingsSchema = z.object({
|
|
||||||
// 设备级 UI 设置
|
|
||||||
showInTray: z.boolean(),
|
|
||||||
minimizeToTrayOnClose: z.boolean(),
|
|
||||||
enableClaudePluginIntegration: z.boolean().optional(),
|
|
||||||
skipClaudeOnboarding: z.boolean().optional(),
|
|
||||||
launchOnStartup: z.boolean().optional(),
|
|
||||||
enableLocalProxy: z.boolean().optional(),
|
|
||||||
usageDashboardRefreshIntervalMs: z.number().optional(),
|
|
||||||
preserveCodexOfficialAuthOnSwitch: z.boolean().optional(),
|
|
||||||
unifyCodexSessionHistory: z.boolean().optional(),
|
|
||||||
language: z.enum(["en", "zh", "zh-TW", "ja"]).optional(),
|
|
||||||
|
|
||||||
// 设备级目录覆盖
|
|
||||||
claudeConfigDir: directorySchema.nullable().optional(),
|
|
||||||
codexConfigDir: directorySchema.nullable().optional(),
|
|
||||||
geminiConfigDir: directorySchema.nullable().optional(),
|
|
||||||
grokConfigDir: directorySchema.nullable().optional(),
|
|
||||||
opencodeConfigDir: directorySchema.nullable().optional(),
|
|
||||||
openclawConfigDir: directorySchema.nullable().optional(),
|
|
||||||
|
|
||||||
// 当前供应商 ID(设备级)
|
|
||||||
currentProviderClaude: z.string().optional(),
|
|
||||||
currentProviderClaudeDesktop: z.string().optional(),
|
|
||||||
currentProviderCodex: z.string().optional(),
|
|
||||||
currentProviderGemini: z.string().optional(),
|
|
||||||
|
|
||||||
// Skill 同步设置
|
|
||||||
skillSyncMethod: z.enum(["auto", "symlink", "copy"]).optional(),
|
|
||||||
skillStorageLocation: z.enum(["cc_switch", "unified"]).optional(),
|
|
||||||
|
|
||||||
// WebDAV v2 同步设置(通过专用命令保存,schema 仅用于读取)
|
|
||||||
webdavSync: z
|
|
||||||
.object({
|
|
||||||
enabled: z.boolean().optional(),
|
|
||||||
autoSync: z.boolean().optional(),
|
|
||||||
baseUrl: z.string().trim().optional().or(z.literal("")),
|
|
||||||
username: z.string().trim().optional().or(z.literal("")),
|
|
||||||
password: z.string().optional(),
|
|
||||||
remoteRoot: z.string().trim().optional().or(z.literal("")),
|
|
||||||
profile: z.string().trim().optional().or(z.literal("")),
|
|
||||||
status: z
|
|
||||||
.object({
|
|
||||||
lastSyncAt: z.number().nullable().optional(),
|
|
||||||
lastError: z.string().nullable().optional(),
|
|
||||||
lastErrorSource: z.string().nullable().optional(),
|
|
||||||
lastRemoteEtag: z.string().nullable().optional(),
|
|
||||||
lastLocalManifestHash: z.string().nullable().optional(),
|
|
||||||
lastRemoteManifestHash: z.string().nullable().optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
|
|
||||||
// 本机自动迁移状态(后端维护且保存时后端忽略前端值,仅供读取展示)
|
|
||||||
localMigrations: z
|
|
||||||
.object({
|
|
||||||
codexThirdPartyHistoryProviderBucketV1: z
|
|
||||||
.object({
|
|
||||||
completedAt: z.string(),
|
|
||||||
targetProviderId: z.string(),
|
|
||||||
sourceProviderIds: z.array(z.string()).optional(),
|
|
||||||
migratedJsonlFiles: z.number().optional(),
|
|
||||||
migratedStateRows: z.number().optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type SettingsFormData = z.infer<typeof settingsSchema>;
|
|
||||||
Reference in New Issue
Block a user