feat: 增强文档和监控功能

主要更新:
- 完善 README 文档,新增中文详细使用说明与监控中心介绍
- 优化 README.md 文档内容和格式,增加英文和中文文档切换链接
- 新增监控中心模块,支持请求日志、统计分析和模型管理
- 增强 AI 提供商配置页面,添加配置搜索功能
- 更新 .gitignore,移除无效注释和调整条目名称
- 删除 README_CN.md 文件,统一文档结构

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
kongkongyo
2026-01-19 00:58:48 +08:00
parent d077b5dd26
commit e4850656a5
37 changed files with 6435 additions and 333 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { entriesToModels } from '@/components/ui/ModelInputList';
import {
@@ -20,6 +20,7 @@ import {
withDisableAllModelsRule,
withoutDisableAllModelsRule,
} from '@/components/providers/utils';
import { Input } from '@/components/ui/Input';
import { ampcodeApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
@@ -50,10 +51,132 @@ export function AiProvidersPage() {
const [configSwitchingKey, setConfigSwitchingKey] = useState<string | null>(null);
const [modal, setModal] = useState<ProviderModal | null>(null);
const [ampcodeBusy, setAmpcodeBusy] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const disableControls = connectionStatus !== 'connected';
const isSwitching = Boolean(configSwitchingKey);
const normalizedQuery = searchQuery.trim().toLowerCase();
const filteredGeminiKeys = useMemo(() => {
if (!normalizedQuery) return geminiKeys.map((item, index) => ({ item, originalIndex: index }));
return geminiKeys
.map((item, index) => ({ item, originalIndex: index }))
.filter(({ item }) => {
const searchFields = [
item.apiKey,
item.prefix,
item.baseUrl,
...(item.excludedModels || []),
...Object.keys(item.headers || {}),
...Object.values(item.headers || {}),
];
return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery));
});
}, [geminiKeys, normalizedQuery]);
const filteredCodexConfigs = useMemo(() => {
if (!normalizedQuery) return codexConfigs.map((item, index) => ({ item, originalIndex: index }));
return codexConfigs
.map((item, index) => ({ item, originalIndex: index }))
.filter(({ item }) => {
const searchFields = [
item.apiKey,
item.prefix,
item.baseUrl,
item.proxyUrl,
...(item.excludedModels || []),
...(item.models?.map((m) => m.name) || []),
...(item.models?.map((m) => m.alias) || []),
...Object.keys(item.headers || {}),
...Object.values(item.headers || {}),
];
return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery));
});
}, [codexConfigs, normalizedQuery]);
const filteredClaudeConfigs = useMemo(() => {
if (!normalizedQuery) return claudeConfigs.map((item, index) => ({ item, originalIndex: index }));
return claudeConfigs
.map((item, index) => ({ item, originalIndex: index }))
.filter(({ item }) => {
const searchFields = [
item.apiKey,
item.prefix,
item.baseUrl,
item.proxyUrl,
...(item.excludedModels || []),
...(item.models?.map((m) => m.name) || []),
...(item.models?.map((m) => m.alias) || []),
...Object.keys(item.headers || {}),
...Object.values(item.headers || {}),
];
return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery));
});
}, [claudeConfigs, normalizedQuery]);
const filteredVertexConfigs = useMemo(() => {
if (!normalizedQuery) return vertexConfigs.map((item, index) => ({ item, originalIndex: index }));
return vertexConfigs
.map((item, index) => ({ item, originalIndex: index }))
.filter(({ item }) => {
const searchFields = [
item.apiKey,
item.prefix,
item.baseUrl,
item.proxyUrl,
...(item.models?.map((m) => m.name) || []),
...(item.models?.map((m) => m.alias) || []),
...Object.keys(item.headers || {}),
...Object.values(item.headers || {}),
];
return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery));
});
}, [vertexConfigs, normalizedQuery]);
const filteredOpenaiProviders = useMemo(() => {
if (!normalizedQuery)
return openaiProviders.map((item, index) => ({ item, originalIndex: index }));
return openaiProviders
.map((item, index) => ({ item, originalIndex: index }))
.filter(({ item }) => {
const searchFields = [
item.name,
item.prefix,
item.baseUrl,
item.testModel,
...(item.apiKeyEntries?.map((e) => e.apiKey) || []),
...(item.apiKeyEntries?.map((e) => e.proxyUrl) || []),
...(item.models?.map((m) => m.name) || []),
...(item.models?.map((m) => m.alias) || []),
...Object.keys(item.headers || {}),
...Object.values(item.headers || {}),
];
return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery));
});
}, [openaiProviders, normalizedQuery]);
const showAmpcode = useMemo(() => {
if (!normalizedQuery) return true;
const ampcode = config?.ampcode;
if (!ampcode) return false;
const searchFields = [
ampcode.upstreamUrl,
ampcode.upstreamApiKey,
...(ampcode.modelMappings?.map((m) => m.from) || []),
...(ampcode.modelMappings?.map((m) => m.to) || []),
];
return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery));
}, [config?.ampcode, normalizedQuery]);
const hasSearchResults =
filteredGeminiKeys.length > 0 ||
filteredCodexConfigs.length > 0 ||
filteredClaudeConfigs.length > 0 ||
filteredVertexConfigs.length > 0 ||
filteredOpenaiProviders.length > 0 ||
showAmpcode;
const { keyStats, usageDetails, loadKeyStats } = useProviderStats();
const getErrorMessage = (err: unknown) => {
@@ -507,112 +630,171 @@ export function AiProvidersPage() {
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('ai_providers.title')}</h1>
<div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>{t('ai_providers.title')}</h1>
<div className={styles.searchBox}>
<Input
type="text"
placeholder={t('ai_providers.search_placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div className={styles.content}>
{error && <div className="error-box">{error}</div>}
<GeminiSection
configs={geminiKeys}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isModalOpen={modal?.type === 'gemini'}
modalIndex={geminiModalIndex}
onAdd={() => openGeminiModal(null)}
onEdit={(index) => openGeminiModal(index)}
onDelete={deleteGemini}
onToggle={(index, enabled) => void setConfigEnabled('gemini', index, enabled)}
onCloseModal={closeModal}
onSave={saveGemini}
/>
{normalizedQuery && !hasSearchResults && (
<div className={styles.searchEmpty}>
<div className={styles.searchEmptyTitle}>{t('ai_providers.search_empty_title')}</div>
<div className={styles.searchEmptyDesc}>{t('ai_providers.search_empty_desc')}</div>
</div>
)}
<CodexSection
configs={codexConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
resolvedTheme={resolvedTheme}
isModalOpen={modal?.type === 'codex'}
modalIndex={codexModalIndex}
onAdd={() => openProviderModal('codex', null)}
onEdit={(index) => openProviderModal('codex', index)}
onDelete={(index) => void deleteProviderEntry('codex', index)}
onToggle={(index, enabled) => void setConfigEnabled('codex', index, enabled)}
onCloseModal={closeModal}
onSave={(form, editIndex) => saveProvider('codex', form, editIndex)}
/>
{filteredGeminiKeys.length > 0 && (
<GeminiSection
configs={filteredGeminiKeys.map(({ item }) => item)}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isModalOpen={modal?.type === 'gemini'}
modalIndex={
geminiModalIndex !== null
? filteredGeminiKeys.findIndex(({ originalIndex }) => originalIndex === geminiModalIndex)
: null
}
onAdd={() => openGeminiModal(null)}
onEdit={(index) => openGeminiModal(filteredGeminiKeys[index]?.originalIndex ?? index)}
onDelete={(index) => deleteGemini(filteredGeminiKeys[index]?.originalIndex ?? index)}
onToggle={(index, enabled) =>
void setConfigEnabled('gemini', filteredGeminiKeys[index]?.originalIndex ?? index, enabled)
}
onCloseModal={closeModal}
onSave={saveGemini}
/>
)}
<ClaudeSection
configs={claudeConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isModalOpen={modal?.type === 'claude'}
modalIndex={claudeModalIndex}
onAdd={() => openProviderModal('claude', null)}
onEdit={(index) => openProviderModal('claude', index)}
onDelete={(index) => void deleteProviderEntry('claude', index)}
onToggle={(index, enabled) => void setConfigEnabled('claude', index, enabled)}
onCloseModal={closeModal}
onSave={(form, editIndex) => saveProvider('claude', form, editIndex)}
/>
{filteredCodexConfigs.length > 0 && (
<CodexSection
configs={filteredCodexConfigs.map(({ item }) => item)}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
resolvedTheme={resolvedTheme}
isModalOpen={modal?.type === 'codex'}
modalIndex={
codexModalIndex !== null
? filteredCodexConfigs.findIndex(({ originalIndex }) => originalIndex === codexModalIndex)
: null
}
onAdd={() => openProviderModal('codex', null)}
onEdit={(index) => openProviderModal('codex', filteredCodexConfigs[index]?.originalIndex ?? index)}
onDelete={(index) =>
void deleteProviderEntry('codex', filteredCodexConfigs[index]?.originalIndex ?? index)
}
onToggle={(index, enabled) =>
void setConfigEnabled('codex', filteredCodexConfigs[index]?.originalIndex ?? index, enabled)
}
onCloseModal={closeModal}
onSave={(form, editIndex) => saveProvider('codex', form, editIndex)}
/>
)}
<VertexSection
configs={vertexConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isModalOpen={modal?.type === 'vertex'}
modalIndex={vertexModalIndex}
onAdd={() => openVertexModal(null)}
onEdit={(index) => openVertexModal(index)}
onDelete={deleteVertex}
onCloseModal={closeModal}
onSave={saveVertex}
/>
{filteredClaudeConfigs.length > 0 && (
<ClaudeSection
configs={filteredClaudeConfigs.map(({ item }) => item)}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isModalOpen={modal?.type === 'claude'}
modalIndex={
claudeModalIndex !== null
? filteredClaudeConfigs.findIndex(({ originalIndex }) => originalIndex === claudeModalIndex)
: null
}
onAdd={() => openProviderModal('claude', null)}
onEdit={(index) => openProviderModal('claude', filteredClaudeConfigs[index]?.originalIndex ?? index)}
onDelete={(index) =>
void deleteProviderEntry('claude', filteredClaudeConfigs[index]?.originalIndex ?? index)
}
onToggle={(index, enabled) =>
void setConfigEnabled('claude', filteredClaudeConfigs[index]?.originalIndex ?? index, enabled)
}
onCloseModal={closeModal}
onSave={(form, editIndex) => saveProvider('claude', form, editIndex)}
/>
)}
<AmpcodeSection
config={config?.ampcode}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isBusy={ampcodeBusy}
isModalOpen={modal?.type === 'ampcode'}
onOpen={openAmpcodeModal}
onCloseModal={closeModal}
onBusyChange={setAmpcodeBusy}
/>
{filteredVertexConfigs.length > 0 && (
<VertexSection
configs={filteredVertexConfigs.map(({ item }) => item)}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isModalOpen={modal?.type === 'vertex'}
modalIndex={
vertexModalIndex !== null
? filteredVertexConfigs.findIndex(({ originalIndex }) => originalIndex === vertexModalIndex)
: null
}
onAdd={() => openVertexModal(null)}
onEdit={(index) => openVertexModal(filteredVertexConfigs[index]?.originalIndex ?? index)}
onDelete={(index) => deleteVertex(filteredVertexConfigs[index]?.originalIndex ?? index)}
onCloseModal={closeModal}
onSave={saveVertex}
/>
)}
<OpenAISection
configs={openaiProviders}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
resolvedTheme={resolvedTheme}
isModalOpen={modal?.type === 'openai'}
modalIndex={openaiModalIndex}
onAdd={() => openOpenaiModal(null)}
onEdit={(index) => openOpenaiModal(index)}
onDelete={deleteOpenai}
onCloseModal={closeModal}
onSave={saveOpenai}
/>
{showAmpcode && (
<AmpcodeSection
config={config?.ampcode}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isBusy={ampcodeBusy}
isModalOpen={modal?.type === 'ampcode'}
onOpen={openAmpcodeModal}
onCloseModal={closeModal}
onBusyChange={setAmpcodeBusy}
/>
)}
{filteredOpenaiProviders.length > 0 && (
<OpenAISection
configs={filteredOpenaiProviders.map(({ item }) => item)}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
resolvedTheme={resolvedTheme}
isModalOpen={modal?.type === 'openai'}
modalIndex={
openaiModalIndex !== null
? filteredOpenaiProviders.findIndex(({ originalIndex }) => originalIndex === openaiModalIndex)
: null
}
onAdd={() => openOpenaiModal(null)}
onEdit={(index) => openOpenaiModal(filteredOpenaiProviders[index]?.originalIndex ?? index)}
onDelete={(index) => deleteOpenai(filteredOpenaiProviders[index]?.originalIndex ?? index)}
onCloseModal={closeModal}
onSave={saveOpenai}
/>
)}
</div>
</div>
);