mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-19 11:10:49 +08:00
feat: add Ampcode (Amp CLI Integration) support with configuration UI and i18n
- Add ampcodeApi service for upstream URL, API key, and model mappings management - Implement Ampcode configuration modal in AiProvidersPage - Add complete i18n translations for Ampcode features (en and zh-CN) - Enhance UsagePage with mobile-responsive chart improvements and legend display - Optimize chart rendering for smaller screens - Improve page layout styles (SystemPage, AiProvidersPage alignment)
This commit is contained in:
@@ -24,6 +24,7 @@
|
|||||||
"disconnected_status": "Disconnected",
|
"disconnected_status": "Disconnected",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "No",
|
"no": "No",
|
||||||
|
"not_set": "Not set",
|
||||||
"optional": "Optional",
|
"optional": "Optional",
|
||||||
"required": "Required",
|
"required": "Required",
|
||||||
"api_key": "Key",
|
"api_key": "Key",
|
||||||
@@ -185,6 +186,28 @@
|
|||||||
"claude_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them.",
|
"claude_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them.",
|
||||||
"claude_models_add_btn": "Add Model",
|
"claude_models_add_btn": "Add Model",
|
||||||
"claude_models_count": "Models Count",
|
"claude_models_count": "Models Count",
|
||||||
|
"ampcode_title": "Amp CLI Integration (ampcode)",
|
||||||
|
"ampcode_modal_title": "Configure Ampcode",
|
||||||
|
"ampcode_upstream_url_label": "Upstream URL",
|
||||||
|
"ampcode_upstream_url_placeholder": "e.g. https://ampcode.com",
|
||||||
|
"ampcode_upstream_url_hint": "Optional. Leave empty to use the default/auto-discovered control plane URL.",
|
||||||
|
"ampcode_upstream_api_key_label": "Upstream API Key (Amp Official)",
|
||||||
|
"ampcode_upstream_api_key_placeholder": "Enter sk-amp... (leave empty to keep current)",
|
||||||
|
"ampcode_upstream_api_key_hint": "Optional. Leaving it empty will not change the current Amp official key. Use the button below to clear it.",
|
||||||
|
"ampcode_upstream_api_key_current": "Current Amp official key: {{key}}",
|
||||||
|
"ampcode_clear_upstream_api_key": "Clear official key",
|
||||||
|
"ampcode_clear_upstream_api_key_confirm": "Are you sure you want to clear the Ampcode upstream API key (Amp official)?",
|
||||||
|
"ampcode_restrict_management_label": "Restrict Amp management routes to localhost",
|
||||||
|
"ampcode_restrict_management_hint": "When enabled, Amp management routes (/api/auth, /api/user, /api/threads, etc.) only accept 127.0.0.1/::1 (recommended).",
|
||||||
|
"ampcode_force_model_mappings_label": "Force model mappings",
|
||||||
|
"ampcode_force_model_mappings_hint": "When enabled, mappings override local API-key availability checks.",
|
||||||
|
"ampcode_model_mappings_label": "Model mappings (from → to)",
|
||||||
|
"ampcode_model_mappings_hint": "Rewrites model names in Amp requests. Leave empty to disable mappings.",
|
||||||
|
"ampcode_model_mappings_add_btn": "Add mapping",
|
||||||
|
"ampcode_model_mappings_from_placeholder": "from model (source)",
|
||||||
|
"ampcode_model_mappings_to_placeholder": "to model (target)",
|
||||||
|
"ampcode_model_mappings_count": "Mappings Count",
|
||||||
|
"ampcode_mappings_overwrite_confirm": "Existing mappings could not be loaded. Continuing may overwrite or clear them. Continue?",
|
||||||
"openai_title": "OpenAI Compatible Providers",
|
"openai_title": "OpenAI Compatible Providers",
|
||||||
"openai_add_button": "Add Provider",
|
"openai_add_button": "Add Provider",
|
||||||
"openai_empty_title": "No OpenAI Compatible Providers",
|
"openai_empty_title": "No OpenAI Compatible Providers",
|
||||||
@@ -618,6 +641,8 @@
|
|||||||
"openai_provider_added": "OpenAI provider added successfully",
|
"openai_provider_added": "OpenAI provider added successfully",
|
||||||
"openai_provider_updated": "OpenAI provider updated successfully",
|
"openai_provider_updated": "OpenAI provider updated successfully",
|
||||||
"openai_provider_deleted": "OpenAI provider deleted successfully",
|
"openai_provider_deleted": "OpenAI provider deleted successfully",
|
||||||
|
"ampcode_updated": "Ampcode configuration updated",
|
||||||
|
"ampcode_upstream_api_key_cleared": "Ampcode upstream API key override cleared",
|
||||||
"openai_model_name_required": "Model name is required",
|
"openai_model_name_required": "Model name is required",
|
||||||
"openai_test_url_required": "Please provide a valid Base URL before testing",
|
"openai_test_url_required": "Please provide a valid Base URL before testing",
|
||||||
"openai_test_key_required": "Please add at least one API key before testing",
|
"openai_test_key_required": "Please add at least one API key before testing",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"disconnected_status": "未连接",
|
"disconnected_status": "未连接",
|
||||||
"yes": "是",
|
"yes": "是",
|
||||||
"no": "否",
|
"no": "否",
|
||||||
|
"not_set": "未设置",
|
||||||
"optional": "可选",
|
"optional": "可选",
|
||||||
"required": "必填",
|
"required": "必填",
|
||||||
"api_key": "密钥",
|
"api_key": "密钥",
|
||||||
@@ -185,6 +186,28 @@
|
|||||||
"claude_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。",
|
"claude_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。",
|
||||||
"claude_models_add_btn": "添加模型",
|
"claude_models_add_btn": "添加模型",
|
||||||
"claude_models_count": "模型数量",
|
"claude_models_count": "模型数量",
|
||||||
|
"ampcode_title": "Amp CLI 集成 (ampcode)",
|
||||||
|
"ampcode_modal_title": "配置 Ampcode",
|
||||||
|
"ampcode_upstream_url_label": "Upstream URL",
|
||||||
|
"ampcode_upstream_url_placeholder": "例如: https://ampcode.com",
|
||||||
|
"ampcode_upstream_url_hint": "可选;留空表示使用默认/自动发现的控制平面地址。",
|
||||||
|
"ampcode_upstream_api_key_label": "Upstream API Key (Amp官方)",
|
||||||
|
"ampcode_upstream_api_key_placeholder": "输入 sk-amp...(留空不修改)",
|
||||||
|
"ampcode_upstream_api_key_hint": "可选;留空不会修改当前Amp官方密钥,需清除请点击下方按钮。",
|
||||||
|
"ampcode_upstream_api_key_current": "当前Amp官方密钥: {{key}}",
|
||||||
|
"ampcode_clear_upstream_api_key": "清除官方密钥",
|
||||||
|
"ampcode_clear_upstream_api_key_confirm": "确定要清除 Ampcode 的 upstream API key(Amp官方)吗?",
|
||||||
|
"ampcode_restrict_management_label": "仅允许本机访问 Amp 管理路由",
|
||||||
|
"ampcode_restrict_management_hint": "开启后,/api/auth、/api/user、/api/threads 等 Amp 管理路由仅允许 127.0.0.1/::1 访问(推荐)。",
|
||||||
|
"ampcode_force_model_mappings_label": "强制应用模型映射",
|
||||||
|
"ampcode_force_model_mappings_hint": "开启后,模型映射将覆盖本地 API Key 可用性判断。",
|
||||||
|
"ampcode_model_mappings_label": "模型映射 (from → to)",
|
||||||
|
"ampcode_model_mappings_hint": "用于重写 Amp 请求中的模型名称;留空表示不做映射。",
|
||||||
|
"ampcode_model_mappings_add_btn": "添加映射",
|
||||||
|
"ampcode_model_mappings_from_placeholder": "from 模型(原始)",
|
||||||
|
"ampcode_model_mappings_to_placeholder": "to 模型(目标)",
|
||||||
|
"ampcode_model_mappings_count": "映射数量",
|
||||||
|
"ampcode_mappings_overwrite_confirm": "当前未成功加载服务器已有映射,继续保存可能覆盖或清空已有映射,是否继续?",
|
||||||
"openai_title": "OpenAI 兼容提供商",
|
"openai_title": "OpenAI 兼容提供商",
|
||||||
"openai_add_button": "添加提供商",
|
"openai_add_button": "添加提供商",
|
||||||
"openai_empty_title": "暂无OpenAI兼容提供商",
|
"openai_empty_title": "暂无OpenAI兼容提供商",
|
||||||
@@ -618,6 +641,8 @@
|
|||||||
"openai_provider_added": "OpenAI提供商添加成功",
|
"openai_provider_added": "OpenAI提供商添加成功",
|
||||||
"openai_provider_updated": "OpenAI提供商更新成功",
|
"openai_provider_updated": "OpenAI提供商更新成功",
|
||||||
"openai_provider_deleted": "OpenAI提供商删除成功",
|
"openai_provider_deleted": "OpenAI提供商删除成功",
|
||||||
|
"ampcode_updated": "Ampcode 配置已更新",
|
||||||
|
"ampcode_upstream_api_key_cleared": "Ampcode upstream API key 覆盖已清除",
|
||||||
"openai_model_name_required": "请填写模型名称",
|
"openai_model_name_required": "请填写模型名称",
|
||||||
"openai_test_url_required": "请先填写有效的 Base URL 以进行测试",
|
"openai_test_url_required": "请先填写有效的 Base URL 以进行测试",
|
||||||
"openai_test_key_required": "请至少填写一个 API 密钥以进行测试",
|
"openai_test_key_required": "请至少填写一个 API 密钥以进行测试",
|
||||||
|
|||||||
@@ -374,7 +374,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 连通性测试按钮高度对齐
|
// 连通性测试按钮高度对齐
|
||||||
|
.openaiTestSelect {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.openaiTestButton {
|
.openaiTestButton {
|
||||||
|
flex: 1 1 0;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
|||||||
@@ -7,14 +7,17 @@ import { Modal } from '@/components/ui/Modal';
|
|||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||||
import { ModelInputList, modelsToEntries, entriesToModels } from '@/components/ui/ModelInputList';
|
import { ModelInputList, modelsToEntries, entriesToModels } from '@/components/ui/ModelInputList';
|
||||||
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
import { IconCheck, IconX } from '@/components/ui/icons';
|
import { IconCheck, IconX } from '@/components/ui/icons';
|
||||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||||
import { modelsApi, providersApi, usageApi } from '@/services/api';
|
import { ampcodeApi, modelsApi, providersApi, usageApi } from '@/services/api';
|
||||||
import type {
|
import type {
|
||||||
GeminiKeyConfig,
|
GeminiKeyConfig,
|
||||||
ProviderKeyConfig,
|
ProviderKeyConfig,
|
||||||
OpenAIProviderConfig,
|
OpenAIProviderConfig,
|
||||||
ApiKeyEntry
|
ApiKeyEntry,
|
||||||
|
AmpcodeConfig,
|
||||||
|
AmpcodeModelMapping
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import type { KeyStats, KeyStatBucket } from '@/utils/usage';
|
import type { KeyStats, KeyStatBucket } from '@/utils/usage';
|
||||||
import type { ModelInfo } from '@/utils/models';
|
import type { ModelInfo } from '@/utils/models';
|
||||||
@@ -26,6 +29,7 @@ type ProviderModal =
|
|||||||
| { type: 'gemini'; index: number | null }
|
| { type: 'gemini'; index: number | null }
|
||||||
| { type: 'codex'; index: number | null }
|
| { type: 'codex'; index: number | null }
|
||||||
| { type: 'claude'; index: number | null }
|
| { type: 'claude'; index: number | null }
|
||||||
|
| { type: 'ampcode'; index: null }
|
||||||
| { type: 'openai'; index: number | null };
|
| { type: 'openai'; index: number | null };
|
||||||
|
|
||||||
interface ModelEntry {
|
interface ModelEntry {
|
||||||
@@ -42,6 +46,14 @@ interface OpenAIFormState {
|
|||||||
apiKeyEntries: ApiKeyEntry[];
|
apiKeyEntries: ApiKeyEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AmpcodeFormState {
|
||||||
|
upstreamUrl: string;
|
||||||
|
upstreamApiKey: string;
|
||||||
|
restrictManagementToLocalhost: boolean;
|
||||||
|
forceModelMappings: boolean;
|
||||||
|
mappingEntries: ModelEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
const parseExcludedModels = (text: string): string[] =>
|
const parseExcludedModels = (text: string): string[] =>
|
||||||
text
|
text
|
||||||
.split(/[\n,]+/)
|
.split(/[\n,]+/)
|
||||||
@@ -104,6 +116,41 @@ const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
|
|||||||
headers: input?.headers ?? {}
|
headers: input?.headers ?? {}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ampcodeMappingsToEntries = (mappings?: AmpcodeModelMapping[]): ModelEntry[] => {
|
||||||
|
if (!Array.isArray(mappings) || mappings.length === 0) {
|
||||||
|
return [{ name: '', alias: '' }];
|
||||||
|
}
|
||||||
|
return mappings.map((mapping) => ({
|
||||||
|
name: mapping.from ?? '',
|
||||||
|
alias: mapping.to ?? ''
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const entriesToAmpcodeMappings = (entries: ModelEntry[]): AmpcodeModelMapping[] => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const mappings: AmpcodeModelMapping[] = [];
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const from = entry.name.trim();
|
||||||
|
const to = entry.alias.trim();
|
||||||
|
if (!from || !to) return;
|
||||||
|
const key = from.toLowerCase();
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
mappings.push({ from, to });
|
||||||
|
});
|
||||||
|
|
||||||
|
return mappings;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState => ({
|
||||||
|
upstreamUrl: ampcode?.upstreamUrl ?? '',
|
||||||
|
upstreamApiKey: '',
|
||||||
|
restrictManagementToLocalhost: ampcode?.restrictManagementToLocalhost ?? true,
|
||||||
|
forceModelMappings: ampcode?.forceModelMappings ?? false,
|
||||||
|
mappingEntries: ampcodeMappingsToEntries(ampcode?.modelMappings)
|
||||||
|
});
|
||||||
|
|
||||||
export function AiProvidersPage() {
|
export function AiProvidersPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
@@ -149,6 +196,12 @@ export function AiProvidersPage() {
|
|||||||
apiKeyEntries: [buildApiKeyEntry()],
|
apiKeyEntries: [buildApiKeyEntry()],
|
||||||
modelEntries: [{ name: '', alias: '' }]
|
modelEntries: [{ name: '', alias: '' }]
|
||||||
});
|
});
|
||||||
|
const [ampcodeForm, setAmpcodeForm] = useState<AmpcodeFormState>(() => buildAmpcodeFormState(null));
|
||||||
|
const [ampcodeModalLoading, setAmpcodeModalLoading] = useState(false);
|
||||||
|
const [ampcodeLoaded, setAmpcodeLoaded] = useState(false);
|
||||||
|
const [ampcodeMappingsDirty, setAmpcodeMappingsDirty] = useState(false);
|
||||||
|
const [ampcodeModalError, setAmpcodeModalError] = useState('');
|
||||||
|
const [ampcodeSaving, setAmpcodeSaving] = useState(false);
|
||||||
const [openaiDiscoveryOpen, setOpenaiDiscoveryOpen] = useState(false);
|
const [openaiDiscoveryOpen, setOpenaiDiscoveryOpen] = useState(false);
|
||||||
const [openaiDiscoveryEndpoint, setOpenaiDiscoveryEndpoint] = useState('');
|
const [openaiDiscoveryEndpoint, setOpenaiDiscoveryEndpoint] = useState('');
|
||||||
const [openaiDiscoveryModels, setOpenaiDiscoveryModels] = useState<ModelInfo[]>([]);
|
const [openaiDiscoveryModels, setOpenaiDiscoveryModels] = useState<ModelInfo[]>([]);
|
||||||
@@ -199,6 +252,13 @@ export function AiProvidersPage() {
|
|||||||
setCodexConfigs(data?.codexApiKeys || []);
|
setCodexConfigs(data?.codexApiKeys || []);
|
||||||
setClaudeConfigs(data?.claudeApiKeys || []);
|
setClaudeConfigs(data?.claudeApiKeys || []);
|
||||||
setOpenaiProviders(data?.openaiCompatibility || []);
|
setOpenaiProviders(data?.openaiCompatibility || []);
|
||||||
|
try {
|
||||||
|
const ampcode = await ampcodeApi.getAmpcode();
|
||||||
|
updateConfigValue('ampcode', ampcode);
|
||||||
|
clearCache('ampcode');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.message || t('notification.refresh_failed'));
|
setError(err?.message || t('notification.refresh_failed'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -245,6 +305,12 @@ export function AiProvidersPage() {
|
|||||||
modelEntries: [{ name: '', alias: '' }],
|
modelEntries: [{ name: '', alias: '' }],
|
||||||
testModel: undefined
|
testModel: undefined
|
||||||
});
|
});
|
||||||
|
setAmpcodeForm(buildAmpcodeFormState(null));
|
||||||
|
setAmpcodeModalLoading(false);
|
||||||
|
setAmpcodeLoaded(false);
|
||||||
|
setAmpcodeMappingsDirty(false);
|
||||||
|
setAmpcodeModalError('');
|
||||||
|
setAmpcodeSaving(false);
|
||||||
setOpenaiDiscoveryOpen(false);
|
setOpenaiDiscoveryOpen(false);
|
||||||
setOpenaiDiscoveryModels([]);
|
setOpenaiDiscoveryModels([]);
|
||||||
setOpenaiDiscoverySelected(new Set());
|
setOpenaiDiscoverySelected(new Set());
|
||||||
@@ -280,6 +346,29 @@ export function AiProvidersPage() {
|
|||||||
setModal({ type, index });
|
setModal({ type, index });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openAmpcodeModal = () => {
|
||||||
|
setAmpcodeModalLoading(true);
|
||||||
|
setAmpcodeLoaded(false);
|
||||||
|
setAmpcodeMappingsDirty(false);
|
||||||
|
setAmpcodeModalError('');
|
||||||
|
setAmpcodeForm(buildAmpcodeFormState(config?.ampcode ?? null));
|
||||||
|
setModal({ type: 'ampcode', index: null });
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const ampcode = await ampcodeApi.getAmpcode();
|
||||||
|
setAmpcodeLoaded(true);
|
||||||
|
updateConfigValue('ampcode', ampcode);
|
||||||
|
clearCache('ampcode');
|
||||||
|
setAmpcodeForm(buildAmpcodeFormState(ampcode));
|
||||||
|
} catch (err: any) {
|
||||||
|
setAmpcodeModalError(err?.message || t('notification.refresh_failed'));
|
||||||
|
} finally {
|
||||||
|
setAmpcodeModalLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
const openOpenaiModal = (index: number | null) => {
|
const openOpenaiModal = (index: number | null) => {
|
||||||
if (index !== null) {
|
if (index !== null) {
|
||||||
const entry = openaiProviders[index];
|
const entry = openaiProviders[index];
|
||||||
@@ -506,6 +595,94 @@ export function AiProvidersPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearAmpcodeUpstreamApiKey = async () => {
|
||||||
|
if (!window.confirm(t('ai_providers.ampcode_clear_upstream_api_key_confirm'))) return;
|
||||||
|
setAmpcodeSaving(true);
|
||||||
|
setAmpcodeModalError('');
|
||||||
|
try {
|
||||||
|
await ampcodeApi.clearUpstreamApiKey();
|
||||||
|
const previous = config?.ampcode ?? {};
|
||||||
|
const next: AmpcodeConfig = { ...previous };
|
||||||
|
delete (next as any).upstreamApiKey;
|
||||||
|
updateConfigValue('ampcode', next);
|
||||||
|
clearCache('ampcode');
|
||||||
|
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.message || '';
|
||||||
|
setAmpcodeModalError(message);
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setAmpcodeSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAmpcode = async () => {
|
||||||
|
if (!ampcodeLoaded && ampcodeMappingsDirty) {
|
||||||
|
const confirmed = window.confirm(t('ai_providers.ampcode_mappings_overwrite_confirm'));
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAmpcodeSaving(true);
|
||||||
|
setAmpcodeModalError('');
|
||||||
|
try {
|
||||||
|
const upstreamUrl = ampcodeForm.upstreamUrl.trim();
|
||||||
|
const overrideKey = ampcodeForm.upstreamApiKey.trim();
|
||||||
|
const modelMappings = entriesToAmpcodeMappings(ampcodeForm.mappingEntries);
|
||||||
|
|
||||||
|
if (upstreamUrl) {
|
||||||
|
await ampcodeApi.updateUpstreamUrl(upstreamUrl);
|
||||||
|
} else {
|
||||||
|
await ampcodeApi.clearUpstreamUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
await ampcodeApi.updateRestrictManagementToLocalhost(ampcodeForm.restrictManagementToLocalhost);
|
||||||
|
await ampcodeApi.updateForceModelMappings(ampcodeForm.forceModelMappings);
|
||||||
|
|
||||||
|
if (ampcodeLoaded || ampcodeMappingsDirty) {
|
||||||
|
if (modelMappings.length) {
|
||||||
|
await ampcodeApi.saveModelMappings(modelMappings);
|
||||||
|
} else {
|
||||||
|
await ampcodeApi.clearModelMappings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overrideKey) {
|
||||||
|
await ampcodeApi.updateUpstreamApiKey(overrideKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = config?.ampcode ?? {};
|
||||||
|
const next: AmpcodeConfig = {
|
||||||
|
...previous,
|
||||||
|
upstreamUrl: upstreamUrl || undefined,
|
||||||
|
restrictManagementToLocalhost: ampcodeForm.restrictManagementToLocalhost,
|
||||||
|
forceModelMappings: ampcodeForm.forceModelMappings
|
||||||
|
};
|
||||||
|
|
||||||
|
if (overrideKey) {
|
||||||
|
next.upstreamApiKey = overrideKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ampcodeLoaded || ampcodeMappingsDirty) {
|
||||||
|
if (modelMappings.length) {
|
||||||
|
next.modelMappings = modelMappings;
|
||||||
|
} else {
|
||||||
|
delete (next as any).modelMappings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfigValue('ampcode', next);
|
||||||
|
clearCache('ampcode');
|
||||||
|
showNotification(t('notification.ampcode_updated'), 'success');
|
||||||
|
closeModal();
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.message || '';
|
||||||
|
setAmpcodeModalError(message);
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setAmpcodeSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const saveGemini = async () => {
|
const saveGemini = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -1022,6 +1199,63 @@ export function AiProvidersPage() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title={t('ai_providers.ampcode_title')}
|
||||||
|
extra={
|
||||||
|
<Button size="sm" onClick={openAmpcodeModal} disabled={disableControls}>
|
||||||
|
{t('common.edit')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="hint">{t('common.loading')}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_upstream_url_label')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{config?.ampcode?.upstreamUrl || t('common.not_set')}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_upstream_api_key_label')}:</span>
|
||||||
|
<span className={styles.fieldValue}>
|
||||||
|
{config?.ampcode?.upstreamApiKey ? maskApiKey(config.ampcode.upstreamApiKey) : t('common.not_set')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_restrict_management_label')}:</span>
|
||||||
|
<span className={styles.fieldValue}>
|
||||||
|
{(config?.ampcode?.restrictManagementToLocalhost ?? true) ? t('common.yes') : t('common.no')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_force_model_mappings_label')}:</span>
|
||||||
|
<span className={styles.fieldValue}>
|
||||||
|
{(config?.ampcode?.forceModelMappings ?? false) ? t('common.yes') : t('common.no')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fieldRow} style={{ marginTop: 8 }}>
|
||||||
|
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_model_mappings_count')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{config?.ampcode?.modelMappings?.length || 0}</span>
|
||||||
|
</div>
|
||||||
|
{config?.ampcode?.modelMappings?.length ? (
|
||||||
|
<div className={styles.modelTagList}>
|
||||||
|
{config.ampcode.modelMappings.slice(0, 5).map((mapping) => (
|
||||||
|
<span key={`${mapping.from}→${mapping.to}`} className={styles.modelTag}>
|
||||||
|
<span className={styles.modelName}>{mapping.from}</span>
|
||||||
|
<span className={styles.modelAlias}>{mapping.to}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{config.ampcode.modelMappings.length > 5 && (
|
||||||
|
<span className={styles.modelTag}>
|
||||||
|
<span className={styles.modelName}>+{config.ampcode.modelMappings.length - 5}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
title={t('ai_providers.openai_title')}
|
title={t('ai_providers.openai_title')}
|
||||||
extra={
|
extra={
|
||||||
@@ -1128,6 +1362,93 @@ export function AiProvidersPage() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Ampcode Modal */}
|
||||||
|
<Modal
|
||||||
|
open={modal?.type === 'ampcode'}
|
||||||
|
onClose={closeModal}
|
||||||
|
title={t('ai_providers.ampcode_modal_title')}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={closeModal} disabled={ampcodeSaving}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={saveAmpcode} loading={ampcodeSaving} disabled={disableControls || ampcodeModalLoading}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ampcodeModalError && <div className="error-box">{ampcodeModalError}</div>}
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.ampcode_upstream_url_label')}
|
||||||
|
placeholder={t('ai_providers.ampcode_upstream_url_placeholder')}
|
||||||
|
value={ampcodeForm.upstreamUrl}
|
||||||
|
onChange={(e) => setAmpcodeForm((prev) => ({ ...prev, upstreamUrl: e.target.value }))}
|
||||||
|
disabled={ampcodeModalLoading || ampcodeSaving}
|
||||||
|
hint={t('ai_providers.ampcode_upstream_url_hint')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.ampcode_upstream_api_key_label')}
|
||||||
|
placeholder={t('ai_providers.ampcode_upstream_api_key_placeholder')}
|
||||||
|
type="password"
|
||||||
|
value={ampcodeForm.upstreamApiKey}
|
||||||
|
onChange={(e) => setAmpcodeForm((prev) => ({ ...prev, upstreamApiKey: e.target.value }))}
|
||||||
|
disabled={ampcodeModalLoading || ampcodeSaving}
|
||||||
|
hint={t('ai_providers.ampcode_upstream_api_key_hint')}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: -8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div className="hint" style={{ margin: 0 }}>
|
||||||
|
{t('ai_providers.ampcode_upstream_api_key_current', {
|
||||||
|
key: config?.ampcode?.upstreamApiKey ? maskApiKey(config.ampcode.upstreamApiKey) : t('common.not_set')
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearAmpcodeUpstreamApiKey}
|
||||||
|
disabled={ampcodeModalLoading || ampcodeSaving || !config?.ampcode?.upstreamApiKey}
|
||||||
|
>
|
||||||
|
{t('ai_providers.ampcode_clear_upstream_api_key')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<ToggleSwitch
|
||||||
|
label={t('ai_providers.ampcode_restrict_management_label')}
|
||||||
|
checked={ampcodeForm.restrictManagementToLocalhost}
|
||||||
|
onChange={(value) => setAmpcodeForm((prev) => ({ ...prev, restrictManagementToLocalhost: value }))}
|
||||||
|
disabled={ampcodeModalLoading || ampcodeSaving}
|
||||||
|
/>
|
||||||
|
<div className="hint">{t('ai_providers.ampcode_restrict_management_hint')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<ToggleSwitch
|
||||||
|
label={t('ai_providers.ampcode_force_model_mappings_label')}
|
||||||
|
checked={ampcodeForm.forceModelMappings}
|
||||||
|
onChange={(value) => setAmpcodeForm((prev) => ({ ...prev, forceModelMappings: value }))}
|
||||||
|
disabled={ampcodeModalLoading || ampcodeSaving}
|
||||||
|
/>
|
||||||
|
<div className="hint">{t('ai_providers.ampcode_force_model_mappings_hint')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('ai_providers.ampcode_model_mappings_label')}</label>
|
||||||
|
<ModelInputList
|
||||||
|
entries={ampcodeForm.mappingEntries}
|
||||||
|
onChange={(entries) => {
|
||||||
|
setAmpcodeMappingsDirty(true);
|
||||||
|
setAmpcodeForm((prev) => ({ ...prev, mappingEntries: entries }));
|
||||||
|
}}
|
||||||
|
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
|
||||||
|
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
|
||||||
|
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
|
||||||
|
disabled={ampcodeModalLoading || ampcodeSaving}
|
||||||
|
/>
|
||||||
|
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Gemini Modal */}
|
{/* Gemini Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
open={modal?.type === 'gemini'}
|
open={modal?.type === 'gemini'}
|
||||||
@@ -1322,7 +1643,7 @@ export function AiProvidersPage() {
|
|||||||
<div className="hint">{t('ai_providers.openai_test_hint')}</div>
|
<div className="hint">{t('ai_providers.openai_test_hint')}</div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
<select
|
<select
|
||||||
className="input"
|
className={`input ${styles.openaiTestSelect}`}
|
||||||
value={openaiTestModel}
|
value={openaiTestModel}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setOpenaiTestModel(e.target.value);
|
setOpenaiTestModel(e.target.value);
|
||||||
|
|||||||
@@ -80,6 +80,7 @@
|
|||||||
.modelTags {
|
.modelTags {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
flex: 0 0 100%;
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -535,12 +535,83 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chartWrapper {
|
.chartWrapper {
|
||||||
padding: 16px;
|
padding: 12px;
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
min-height: 240px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLegend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 12px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendItem {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 240px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendDot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendLabel {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartArea {
|
||||||
height: 240px;
|
height: 240px;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
height: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartScroller {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
|
||||||
|
// Chart.js 默认会设置 canvas 的 touch-action: none,导致移动端无法横向滚动
|
||||||
|
:global(canvas) {
|
||||||
|
touch-action: pan-x pan-y !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartCanvas {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.periodButtons {
|
.periodButtons {
|
||||||
|
|||||||
@@ -9,13 +9,15 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
Filler
|
Filler,
|
||||||
|
type ChartOptions
|
||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
||||||
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { usageApi } from '@/services/api/usage';
|
import { usageApi } from '@/services/api/usage';
|
||||||
import {
|
import {
|
||||||
formatTokensInMillions,
|
formatTokensInMillions,
|
||||||
@@ -59,6 +61,7 @@ interface UsagePayload {
|
|||||||
|
|
||||||
export function UsagePage() {
|
export function UsagePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||||
|
|
||||||
const [usage, setUsage] = useState<UsagePayload | null>(null);
|
const [usage, setUsage] = useState<UsagePayload | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -216,39 +219,97 @@ export function UsagePage() {
|
|||||||
[buildLastHourSeries, buildSparkline]
|
[buildLastHourSeries, buildSparkline]
|
||||||
);
|
);
|
||||||
|
|
||||||
const chartOptions = {
|
const buildChartOptions = useCallback(
|
||||||
responsive: true,
|
(period: 'hour' | 'day', labels: string[]): ChartOptions<'line'> => {
|
||||||
maintainAspectRatio: false,
|
const pointRadius = isMobile && period === 'hour' ? 0 : isMobile ? 2 : 4;
|
||||||
interaction: {
|
const tickFontSize = isMobile ? 10 : 12;
|
||||||
mode: 'index' as const,
|
const maxTickLabelCount = isMobile ? (period === 'hour' ? 8 : 6) : period === 'hour' ? 12 : 10;
|
||||||
intersect: false
|
|
||||||
},
|
return {
|
||||||
plugins: {
|
responsive: true,
|
||||||
legend: {
|
maintainAspectRatio: false,
|
||||||
display: true,
|
interaction: {
|
||||||
position: 'top' as const,
|
mode: 'index',
|
||||||
align: 'start' as const,
|
intersect: false
|
||||||
labels: {
|
},
|
||||||
usePointStyle: true
|
plugins: {
|
||||||
|
legend: { display: false }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
font: { size: tickFontSize },
|
||||||
|
maxRotation: isMobile ? 0 : 45,
|
||||||
|
minRotation: isMobile ? 0 : 0,
|
||||||
|
autoSkip: true,
|
||||||
|
maxTicksLimit: maxTickLabelCount,
|
||||||
|
callback: (value) => {
|
||||||
|
const index = typeof value === 'number' ? value : Number(value);
|
||||||
|
const raw =
|
||||||
|
Number.isFinite(index) && labels[index] ? labels[index] : typeof value === 'string' ? value : '';
|
||||||
|
|
||||||
|
if (period === 'hour') {
|
||||||
|
const [md, time] = raw.split(' ');
|
||||||
|
if (!time) return raw;
|
||||||
|
if (time.startsWith('00:')) {
|
||||||
|
return md ? [md, time] : time;
|
||||||
|
}
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
const parts = raw.split('-');
|
||||||
|
if (parts.length === 3) {
|
||||||
|
return `${parts[1]}-${parts[2]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
font: { size: tickFontSize }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
elements: {
|
||||||
|
line: {
|
||||||
|
tension: 0.35,
|
||||||
|
borderWidth: isMobile ? 1.5 : 2
|
||||||
|
},
|
||||||
|
point: {
|
||||||
|
borderWidth: 2,
|
||||||
|
radius: pointRadius,
|
||||||
|
hoverRadius: 4
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
scales: {
|
[isMobile]
|
||||||
y: {
|
);
|
||||||
beginAtZero: true
|
|
||||||
}
|
const requestsChartOptions = useMemo(
|
||||||
|
() => buildChartOptions(requestsPeriod, requestsChartData.labels),
|
||||||
|
[buildChartOptions, requestsPeriod, requestsChartData.labels]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokensChartOptions = useMemo(
|
||||||
|
() => buildChartOptions(tokensPeriod, tokensChartData.labels),
|
||||||
|
[buildChartOptions, tokensPeriod, tokensChartData.labels]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getHourChartMinWidth = useCallback(
|
||||||
|
(labelCount: number) => {
|
||||||
|
if (!isMobile || labelCount <= 0) return undefined;
|
||||||
|
// 24 小时标签在移动端需要更宽的画布,避免 X 轴与点位过度挤压
|
||||||
|
const perPoint = 56;
|
||||||
|
const minWidth = Math.min(labelCount * perPoint, 3000);
|
||||||
|
return `${minWidth}px`;
|
||||||
},
|
},
|
||||||
elements: {
|
[isMobile]
|
||||||
line: {
|
);
|
||||||
tension: 0.35,
|
|
||||||
borderWidth: 2
|
|
||||||
},
|
|
||||||
point: {
|
|
||||||
borderWidth: 2,
|
|
||||||
radius: 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Chart line management
|
// Chart line management
|
||||||
const handleAddChartLine = () => {
|
const handleAddChartLine = () => {
|
||||||
@@ -521,7 +582,32 @@ export function UsagePage() {
|
|||||||
<div className={styles.hint}>{t('common.loading')}</div>
|
<div className={styles.hint}>{t('common.loading')}</div>
|
||||||
) : requestsChartData.labels.length > 0 ? (
|
) : requestsChartData.labels.length > 0 ? (
|
||||||
<div className={styles.chartWrapper}>
|
<div className={styles.chartWrapper}>
|
||||||
<Line data={requestsChartData} options={chartOptions} />
|
<div className={styles.chartLegend} aria-label="Chart legend">
|
||||||
|
{requestsChartData.datasets.map((dataset, index) => (
|
||||||
|
<div
|
||||||
|
key={`${dataset.label}-${index}`}
|
||||||
|
className={styles.legendItem}
|
||||||
|
title={dataset.label}
|
||||||
|
>
|
||||||
|
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
|
||||||
|
<span className={styles.legendLabel}>{dataset.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.chartArea}>
|
||||||
|
<div className={styles.chartScroller}>
|
||||||
|
<div
|
||||||
|
className={styles.chartCanvas}
|
||||||
|
style={
|
||||||
|
requestsPeriod === 'hour'
|
||||||
|
? { minWidth: getHourChartMinWidth(requestsChartData.labels.length) }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Line data={requestsChartData} options={requestsChartOptions} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||||
@@ -554,7 +640,32 @@ export function UsagePage() {
|
|||||||
<div className={styles.hint}>{t('common.loading')}</div>
|
<div className={styles.hint}>{t('common.loading')}</div>
|
||||||
) : tokensChartData.labels.length > 0 ? (
|
) : tokensChartData.labels.length > 0 ? (
|
||||||
<div className={styles.chartWrapper}>
|
<div className={styles.chartWrapper}>
|
||||||
<Line data={tokensChartData} options={chartOptions} />
|
<div className={styles.chartLegend} aria-label="Chart legend">
|
||||||
|
{tokensChartData.datasets.map((dataset, index) => (
|
||||||
|
<div
|
||||||
|
key={`${dataset.label}-${index}`}
|
||||||
|
className={styles.legendItem}
|
||||||
|
title={dataset.label}
|
||||||
|
>
|
||||||
|
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
|
||||||
|
<span className={styles.legendLabel}>{dataset.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.chartArea}>
|
||||||
|
<div className={styles.chartScroller}>
|
||||||
|
<div
|
||||||
|
className={styles.chartCanvas}
|
||||||
|
style={
|
||||||
|
tokensPeriod === 'hour'
|
||||||
|
? { minWidth: getHourChartMinWidth(tokensChartData.labels.length) }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Line data={tokensChartData} options={tokensChartOptions} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||||
|
|||||||
40
src/services/api/ampcode.ts
Normal file
40
src/services/api/ampcode.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Amp CLI Integration (ampcode) 相关 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from './client';
|
||||||
|
import { normalizeAmpcodeConfig, normalizeAmpcodeModelMappings } from './transformers';
|
||||||
|
import type { AmpcodeConfig, AmpcodeModelMapping } from '@/types';
|
||||||
|
|
||||||
|
export const ampcodeApi = {
|
||||||
|
async getAmpcode(): Promise<AmpcodeConfig> {
|
||||||
|
const data = await apiClient.get('/ampcode');
|
||||||
|
return normalizeAmpcodeConfig(data) ?? {};
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUpstreamUrl: (url: string) => apiClient.put('/ampcode/upstream-url', { value: url }),
|
||||||
|
clearUpstreamUrl: () => apiClient.delete('/ampcode/upstream-url'),
|
||||||
|
|
||||||
|
updateUpstreamApiKey: (apiKey: string) => apiClient.put('/ampcode/upstream-api-key', { value: apiKey }),
|
||||||
|
clearUpstreamApiKey: () => apiClient.delete('/ampcode/upstream-api-key'),
|
||||||
|
|
||||||
|
updateRestrictManagementToLocalhost: (enabled: boolean) =>
|
||||||
|
apiClient.put('/ampcode/restrict-management-to-localhost', { value: enabled }),
|
||||||
|
|
||||||
|
async getModelMappings(): Promise<AmpcodeModelMapping[]> {
|
||||||
|
const data = await apiClient.get('/ampcode/model-mappings');
|
||||||
|
const list = data?.['model-mappings'] ?? data?.modelMappings ?? data?.items ?? data;
|
||||||
|
return normalizeAmpcodeModelMappings(list);
|
||||||
|
},
|
||||||
|
|
||||||
|
saveModelMappings: (mappings: AmpcodeModelMapping[]) =>
|
||||||
|
apiClient.put('/ampcode/model-mappings', { value: mappings }),
|
||||||
|
patchModelMappings: (mappings: AmpcodeModelMapping[]) =>
|
||||||
|
apiClient.patch('/ampcode/model-mappings', { value: mappings }),
|
||||||
|
clearModelMappings: () => apiClient.delete('/ampcode/model-mappings'),
|
||||||
|
deleteModelMappings: (fromList: string[]) =>
|
||||||
|
apiClient.delete('/ampcode/model-mappings', { data: { value: fromList } }),
|
||||||
|
|
||||||
|
updateForceModelMappings: (enabled: boolean) => apiClient.put('/ampcode/force-model-mappings', { value: enabled })
|
||||||
|
};
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@ export * from './client';
|
|||||||
export * from './config';
|
export * from './config';
|
||||||
export * from './configFile';
|
export * from './configFile';
|
||||||
export * from './apiKeys';
|
export * from './apiKeys';
|
||||||
|
export * from './ampcode';
|
||||||
export * from './providers';
|
export * from './providers';
|
||||||
export * from './authFiles';
|
export * from './authFiles';
|
||||||
export * from './oauth';
|
export * from './oauth';
|
||||||
|
|||||||
@@ -3,11 +3,25 @@ import type {
|
|||||||
GeminiKeyConfig,
|
GeminiKeyConfig,
|
||||||
ModelAlias,
|
ModelAlias,
|
||||||
OpenAIProviderConfig,
|
OpenAIProviderConfig,
|
||||||
ProviderKeyConfig
|
ProviderKeyConfig,
|
||||||
|
AmpcodeConfig,
|
||||||
|
AmpcodeModelMapping
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import type { Config } from '@/types/config';
|
import type { Config } from '@/types/config';
|
||||||
import { buildHeaderObject } from '@/utils/headers';
|
import { buildHeaderObject } from '@/utils/headers';
|
||||||
|
|
||||||
|
const normalizeBoolean = (value: any): boolean | undefined => {
|
||||||
|
if (value === undefined || value === null) return undefined;
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
if (typeof value === 'number') return value !== 0;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim().toLowerCase();
|
||||||
|
if (['true', '1', 'yes', 'y', 'on'].includes(trimmed)) return true;
|
||||||
|
if (['false', '0', 'no', 'n', 'off'].includes(trimmed)) return false;
|
||||||
|
}
|
||||||
|
return Boolean(value);
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeModelAliases = (models: any): ModelAlias[] => {
|
const normalizeModelAliases = (models: any): ModelAlias[] => {
|
||||||
if (!Array.isArray(models)) return [];
|
if (!Array.isArray(models)) return [];
|
||||||
return models
|
return models
|
||||||
@@ -162,6 +176,61 @@ const normalizeOauthExcluded = (payload: any): Record<string, string[]> | undefi
|
|||||||
return map;
|
return map;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeAmpcodeModelMappings = (input: any): AmpcodeModelMapping[] => {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const mappings: AmpcodeModelMapping[] = [];
|
||||||
|
|
||||||
|
input.forEach((entry) => {
|
||||||
|
if (!entry || typeof entry !== 'object') return;
|
||||||
|
const from = String(entry.from ?? entry['from'] ?? '').trim();
|
||||||
|
const to = String(entry.to ?? entry['to'] ?? '').trim();
|
||||||
|
if (!from || !to) return;
|
||||||
|
const key = from.toLowerCase();
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
mappings.push({ from, to });
|
||||||
|
});
|
||||||
|
|
||||||
|
return mappings;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeAmpcodeConfig = (payload: any): AmpcodeConfig | undefined => {
|
||||||
|
const source = payload?.ampcode ?? payload;
|
||||||
|
if (!source || typeof source !== 'object') return undefined;
|
||||||
|
|
||||||
|
const config: AmpcodeConfig = {};
|
||||||
|
const upstreamUrl = source['upstream-url'] ?? source.upstreamUrl ?? source['upstream_url'];
|
||||||
|
if (upstreamUrl) config.upstreamUrl = String(upstreamUrl);
|
||||||
|
const upstreamApiKey = source['upstream-api-key'] ?? source.upstreamApiKey ?? source['upstream_api_key'];
|
||||||
|
if (upstreamApiKey) config.upstreamApiKey = String(upstreamApiKey);
|
||||||
|
|
||||||
|
const restrictManagementToLocalhost = normalizeBoolean(
|
||||||
|
source['restrict-management-to-localhost'] ??
|
||||||
|
source.restrictManagementToLocalhost ??
|
||||||
|
source['restrict_management_to_localhost']
|
||||||
|
);
|
||||||
|
if (restrictManagementToLocalhost !== undefined) {
|
||||||
|
config.restrictManagementToLocalhost = restrictManagementToLocalhost;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forceModelMappings = normalizeBoolean(
|
||||||
|
source['force-model-mappings'] ?? source.forceModelMappings ?? source['force_model_mappings']
|
||||||
|
);
|
||||||
|
if (forceModelMappings !== undefined) {
|
||||||
|
config.forceModelMappings = forceModelMappings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelMappings = normalizeAmpcodeModelMappings(
|
||||||
|
source['model-mappings'] ?? source.modelMappings ?? source['model_mappings']
|
||||||
|
);
|
||||||
|
if (modelMappings.length) {
|
||||||
|
config.modelMappings = modelMappings;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 规范化 /config 返回值
|
* 规范化 /config 返回值
|
||||||
*/
|
*/
|
||||||
@@ -217,6 +286,11 @@ export const normalizeConfigResponse = (raw: any): Config => {
|
|||||||
.filter(Boolean) as OpenAIProviderConfig[];
|
.filter(Boolean) as OpenAIProviderConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ampcode = normalizeAmpcodeConfig(raw.ampcode);
|
||||||
|
if (ampcode) {
|
||||||
|
config.ampcode = ampcode;
|
||||||
|
}
|
||||||
|
|
||||||
const oauthExcluded = normalizeOauthExcluded(raw['oauth-excluded-models'] ?? raw.oauthExcludedModels);
|
const oauthExcluded = normalizeOauthExcluded(raw['oauth-excluded-models'] ?? raw.oauthExcludedModels);
|
||||||
if (oauthExcluded) {
|
if (oauthExcluded) {
|
||||||
config.oauthExcludedModels = oauthExcluded;
|
config.oauthExcludedModels = oauthExcluded;
|
||||||
@@ -232,5 +306,7 @@ export {
|
|||||||
normalizeOpenAIProvider,
|
normalizeOpenAIProvider,
|
||||||
normalizeProviderKeyConfig,
|
normalizeProviderKeyConfig,
|
||||||
normalizeHeaders,
|
normalizeHeaders,
|
||||||
normalizeExcludedModels
|
normalizeExcludedModels,
|
||||||
|
normalizeAmpcodeConfig,
|
||||||
|
normalizeAmpcodeModelMappings
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const SECTION_KEYS: RawConfigSection[] = [
|
|||||||
'logging-to-file',
|
'logging-to-file',
|
||||||
'ws-auth',
|
'ws-auth',
|
||||||
'api-keys',
|
'api-keys',
|
||||||
|
'ampcode',
|
||||||
'gemini-api-key',
|
'gemini-api-key',
|
||||||
'codex-api-key',
|
'codex-api-key',
|
||||||
'claude-api-key',
|
'claude-api-key',
|
||||||
@@ -65,6 +66,8 @@ const extractSectionValue = (config: Config | null, section?: RawConfigSection)
|
|||||||
return config.wsAuth;
|
return config.wsAuth;
|
||||||
case 'api-keys':
|
case 'api-keys':
|
||||||
return config.apiKeys;
|
return config.apiKeys;
|
||||||
|
case 'ampcode':
|
||||||
|
return config.ampcode;
|
||||||
case 'gemini-api-key':
|
case 'gemini-api-key':
|
||||||
return config.geminiApiKeys;
|
return config.geminiApiKeys;
|
||||||
case 'codex-api-key':
|
case 'codex-api-key':
|
||||||
@@ -166,6 +169,9 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
|
|||||||
case 'api-keys':
|
case 'api-keys':
|
||||||
nextConfig.apiKeys = value;
|
nextConfig.apiKeys = value;
|
||||||
break;
|
break;
|
||||||
|
case 'ampcode':
|
||||||
|
nextConfig.ampcode = value;
|
||||||
|
break;
|
||||||
case 'gemini-api-key':
|
case 'gemini-api-key':
|
||||||
nextConfig.geminiApiKeys = value;
|
nextConfig.geminiApiKeys = value;
|
||||||
break;
|
break;
|
||||||
|
|||||||
17
src/types/ampcode.ts
Normal file
17
src/types/ampcode.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Amp CLI Integration (ampcode) 配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AmpcodeModelMapping {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AmpcodeConfig {
|
||||||
|
upstreamUrl?: string;
|
||||||
|
upstreamApiKey?: string;
|
||||||
|
restrictManagementToLocalhost?: boolean;
|
||||||
|
modelMappings?: AmpcodeModelMapping[];
|
||||||
|
forceModelMappings?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from './provider';
|
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from './provider';
|
||||||
|
import type { AmpcodeConfig } from './ampcode';
|
||||||
|
|
||||||
export interface QuotaExceededConfig {
|
export interface QuotaExceededConfig {
|
||||||
switchProject?: boolean;
|
switchProject?: boolean;
|
||||||
@@ -20,6 +21,7 @@ export interface Config {
|
|||||||
loggingToFile?: boolean;
|
loggingToFile?: boolean;
|
||||||
wsAuth?: boolean;
|
wsAuth?: boolean;
|
||||||
apiKeys?: string[];
|
apiKeys?: string[];
|
||||||
|
ampcode?: AmpcodeConfig;
|
||||||
geminiApiKeys?: GeminiKeyConfig[];
|
geminiApiKeys?: GeminiKeyConfig[];
|
||||||
codexApiKeys?: ProviderKeyConfig[];
|
codexApiKeys?: ProviderKeyConfig[];
|
||||||
claudeApiKeys?: ProviderKeyConfig[];
|
claudeApiKeys?: ProviderKeyConfig[];
|
||||||
@@ -38,6 +40,7 @@ export type RawConfigSection =
|
|||||||
| 'logging-to-file'
|
| 'logging-to-file'
|
||||||
| 'ws-auth'
|
| 'ws-auth'
|
||||||
| 'api-keys'
|
| 'api-keys'
|
||||||
|
| 'ampcode'
|
||||||
| 'gemini-api-key'
|
| 'gemini-api-key'
|
||||||
| 'codex-api-key'
|
| 'codex-api-key'
|
||||||
| 'claude-api-key'
|
| 'claude-api-key'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export * from './api';
|
|||||||
export * from './config';
|
export * from './config';
|
||||||
export * from './auth';
|
export * from './auth';
|
||||||
export * from './provider';
|
export * from './provider';
|
||||||
|
export * from './ampcode';
|
||||||
export * from './authFile';
|
export * from './authFile';
|
||||||
export * from './oauth';
|
export * from './oauth';
|
||||||
export * from './usage';
|
export * from './usage';
|
||||||
|
|||||||
Reference in New Issue
Block a user