From e0584af3656eb9c1fe068a292bedc5bd5b77402a Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sun, 14 Dec 2025 00:31:05 +0800 Subject: [PATCH] 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) --- src/i18n/locales/en.json | 25 ++ src/i18n/locales/zh-CN.json | 25 ++ src/pages/AiProvidersPage.module.scss | 6 + src/pages/AiProvidersPage.tsx | 327 +++++++++++++++++++++++++- src/pages/SystemPage.module.scss | 1 + src/pages/UsagePage.module.scss | 75 +++++- src/pages/UsagePage.tsx | 177 +++++++++++--- src/services/api/ampcode.ts | 40 ++++ src/services/api/index.ts | 1 + src/services/api/transformers.ts | 80 ++++++- src/stores/useConfigStore.ts | 6 + src/types/ampcode.ts | 17 ++ src/types/config.ts | 3 + src/types/index.ts | 1 + 14 files changed, 744 insertions(+), 40 deletions(-) create mode 100644 src/services/api/ampcode.ts create mode 100644 src/types/ampcode.ts diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f984afb..b532672 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -24,6 +24,7 @@ "disconnected_status": "Disconnected", "yes": "Yes", "no": "No", + "not_set": "Not set", "optional": "Optional", "required": "Required", "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_add_btn": "Add Model", "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_add_button": "Add Provider", "openai_empty_title": "No OpenAI Compatible Providers", @@ -618,6 +641,8 @@ "openai_provider_added": "OpenAI provider added successfully", "openai_provider_updated": "OpenAI provider updated 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_test_url_required": "Please provide a valid Base URL before testing", "openai_test_key_required": "Please add at least one API key before testing", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index c277f76..18af585 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -24,6 +24,7 @@ "disconnected_status": "未连接", "yes": "是", "no": "否", + "not_set": "未设置", "optional": "可选", "required": "必填", "api_key": "密钥", @@ -185,6 +186,28 @@ "claude_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。", "claude_models_add_btn": "添加模型", "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_add_button": "添加提供商", "openai_empty_title": "暂无OpenAI兼容提供商", @@ -618,6 +641,8 @@ "openai_provider_added": "OpenAI提供商添加成功", "openai_provider_updated": "OpenAI提供商更新成功", "openai_provider_deleted": "OpenAI提供商删除成功", + "ampcode_updated": "Ampcode 配置已更新", + "ampcode_upstream_api_key_cleared": "Ampcode upstream API key 覆盖已清除", "openai_model_name_required": "请填写模型名称", "openai_test_url_required": "请先填写有效的 Base URL 以进行测试", "openai_test_key_required": "请至少填写一个 API 密钥以进行测试", diff --git a/src/pages/AiProvidersPage.module.scss b/src/pages/AiProvidersPage.module.scss index b460e27..df650b5 100644 --- a/src/pages/AiProvidersPage.module.scss +++ b/src/pages/AiProvidersPage.module.scss @@ -374,7 +374,13 @@ } // 连通性测试按钮高度对齐 +.openaiTestSelect { + flex: 1 1 0; + min-width: 0; +} + .openaiTestButton { + flex: 1 1 0; padding: 8px 12px; font-size: 14px; line-height: 1.5; diff --git a/src/pages/AiProvidersPage.tsx b/src/pages/AiProvidersPage.tsx index 0992ec8..b946c77 100644 --- a/src/pages/AiProvidersPage.tsx +++ b/src/pages/AiProvidersPage.tsx @@ -7,14 +7,17 @@ import { Modal } from '@/components/ui/Modal'; import { EmptyState } from '@/components/ui/EmptyState'; import { HeaderInputList } from '@/components/ui/HeaderInputList'; import { ModelInputList, modelsToEntries, entriesToModels } from '@/components/ui/ModelInputList'; +import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { IconCheck, IconX } from '@/components/ui/icons'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; -import { modelsApi, providersApi, usageApi } from '@/services/api'; +import { ampcodeApi, modelsApi, providersApi, usageApi } from '@/services/api'; import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig, - ApiKeyEntry + ApiKeyEntry, + AmpcodeConfig, + AmpcodeModelMapping } from '@/types'; import type { KeyStats, KeyStatBucket } from '@/utils/usage'; import type { ModelInfo } from '@/utils/models'; @@ -26,6 +29,7 @@ type ProviderModal = | { type: 'gemini'; index: number | null } | { type: 'codex'; index: number | null } | { type: 'claude'; index: number | null } + | { type: 'ampcode'; index: null } | { type: 'openai'; index: number | null }; interface ModelEntry { @@ -42,6 +46,14 @@ interface OpenAIFormState { apiKeyEntries: ApiKeyEntry[]; } +interface AmpcodeFormState { + upstreamUrl: string; + upstreamApiKey: string; + restrictManagementToLocalhost: boolean; + forceModelMappings: boolean; + mappingEntries: ModelEntry[]; +} + const parseExcludedModels = (text: string): string[] => text .split(/[\n,]+/) @@ -104,6 +116,41 @@ const buildApiKeyEntry = (input?: Partial): ApiKeyEntry => ({ 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(); + 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() { const { t } = useTranslation(); const { showNotification } = useNotificationStore(); @@ -149,6 +196,12 @@ export function AiProvidersPage() { apiKeyEntries: [buildApiKeyEntry()], modelEntries: [{ name: '', alias: '' }] }); + const [ampcodeForm, setAmpcodeForm] = useState(() => 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 [openaiDiscoveryEndpoint, setOpenaiDiscoveryEndpoint] = useState(''); const [openaiDiscoveryModels, setOpenaiDiscoveryModels] = useState([]); @@ -199,6 +252,13 @@ export function AiProvidersPage() { setCodexConfigs(data?.codexApiKeys || []); setClaudeConfigs(data?.claudeApiKeys || []); setOpenaiProviders(data?.openaiCompatibility || []); + try { + const ampcode = await ampcodeApi.getAmpcode(); + updateConfigValue('ampcode', ampcode); + clearCache('ampcode'); + } catch { + // ignore + } } catch (err: any) { setError(err?.message || t('notification.refresh_failed')); } finally { @@ -245,6 +305,12 @@ export function AiProvidersPage() { modelEntries: [{ name: '', alias: '' }], testModel: undefined }); + setAmpcodeForm(buildAmpcodeFormState(null)); + setAmpcodeModalLoading(false); + setAmpcodeLoaded(false); + setAmpcodeMappingsDirty(false); + setAmpcodeModalError(''); + setAmpcodeSaving(false); setOpenaiDiscoveryOpen(false); setOpenaiDiscoveryModels([]); setOpenaiDiscoverySelected(new Set()); @@ -280,6 +346,29 @@ export function AiProvidersPage() { 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) => { if (index !== null) { 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 () => { setSaving(true); try { @@ -1022,6 +1199,63 @@ export function AiProvidersPage() { )} + + {t('common.edit')} + + } + > + {loading ? ( +
{t('common.loading')}
+ ) : ( + <> +
+ {t('ai_providers.ampcode_upstream_url_label')}: + {config?.ampcode?.upstreamUrl || t('common.not_set')} +
+
+ {t('ai_providers.ampcode_upstream_api_key_label')}: + + {config?.ampcode?.upstreamApiKey ? maskApiKey(config.ampcode.upstreamApiKey) : t('common.not_set')} + +
+
+ {t('ai_providers.ampcode_restrict_management_label')}: + + {(config?.ampcode?.restrictManagementToLocalhost ?? true) ? t('common.yes') : t('common.no')} + +
+
+ {t('ai_providers.ampcode_force_model_mappings_label')}: + + {(config?.ampcode?.forceModelMappings ?? false) ? t('common.yes') : t('common.no')} + +
+
+ {t('ai_providers.ampcode_model_mappings_count')}: + {config?.ampcode?.modelMappings?.length || 0} +
+ {config?.ampcode?.modelMappings?.length ? ( +
+ {config.ampcode.modelMappings.slice(0, 5).map((mapping) => ( + + {mapping.from} + {mapping.to} + + ))} + {config.ampcode.modelMappings.length > 5 && ( + + +{config.ampcode.modelMappings.length - 5} + + )} +
+ ) : null} + + )} +
+ + {/* Ampcode Modal */} + + + + + } + > + {ampcodeModalError &&
{ampcodeModalError}
} + setAmpcodeForm((prev) => ({ ...prev, upstreamUrl: e.target.value }))} + disabled={ampcodeModalLoading || ampcodeSaving} + hint={t('ai_providers.ampcode_upstream_url_hint')} + /> + setAmpcodeForm((prev) => ({ ...prev, upstreamApiKey: e.target.value }))} + disabled={ampcodeModalLoading || ampcodeSaving} + hint={t('ai_providers.ampcode_upstream_api_key_hint')} + /> +
+
+ {t('ai_providers.ampcode_upstream_api_key_current', { + key: config?.ampcode?.upstreamApiKey ? maskApiKey(config.ampcode.upstreamApiKey) : t('common.not_set') + })} +
+ +
+ +
+ setAmpcodeForm((prev) => ({ ...prev, restrictManagementToLocalhost: value }))} + disabled={ampcodeModalLoading || ampcodeSaving} + /> +
{t('ai_providers.ampcode_restrict_management_hint')}
+
+ +
+ setAmpcodeForm((prev) => ({ ...prev, forceModelMappings: value }))} + disabled={ampcodeModalLoading || ampcodeSaving} + /> +
{t('ai_providers.ampcode_force_model_mappings_hint')}
+
+ +
+ + { + 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} + /> +
{t('ai_providers.ampcode_model_mappings_hint')}
+
+
+ {/* Gemini Modal */} {t('ai_providers.openai_test_hint')}