Merge upstream/main: 同步上游更新,解决 OAuthPage 冲突
合并上游 main 分支,包含俄语国际化、Kimi OAuth、UI 重构、 类型安全加固等更新。保留本地 Kiro OAuth 功能,同时引入 上游新增的类型安全工具函数。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,6 +18,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
skills
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
settings.local.json
|
settings.local.json
|
||||||
|
|||||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1244,6 +1244,18 @@
|
|||||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@openai/codex": {
|
||||||
|
"version": "0.98.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.98.0.tgz",
|
||||||
|
"integrity": "sha512-CKjrhAmzTvWn7Vbsi27iZRKBAJw9a7ZTTkWQDbLgQZP1weGbDIBk1r6wiLEp1ZmDO7w0fHPLYgnVspiOrYgcxg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"codex": "bin/codex.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@parcel/watcher": {
|
"node_modules/@parcel/watcher": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
ReactNode,
|
ReactNode,
|
||||||
createContext,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useLocation, type Location } from 'react-router-dom';
|
import { useLocation, type Location } from 'react-router-dom';
|
||||||
import gsap from 'gsap';
|
import gsap from 'gsap';
|
||||||
|
import { PageTransitionLayerContext, type LayerStatus } from './PageTransitionLayer';
|
||||||
import './PageTransition.scss';
|
import './PageTransition.scss';
|
||||||
|
|
||||||
interface PageTransitionProps {
|
interface PageTransitionProps {
|
||||||
@@ -27,8 +26,6 @@ const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100;
|
|||||||
const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30;
|
const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30;
|
||||||
const IOS_EXIT_DIM_OPACITY = 0.72;
|
const IOS_EXIT_DIM_OPACITY = 0.72;
|
||||||
|
|
||||||
type LayerStatus = 'current' | 'exiting' | 'stacked';
|
|
||||||
|
|
||||||
type Layer = {
|
type Layer = {
|
||||||
key: string;
|
key: string;
|
||||||
location: Location;
|
location: Location;
|
||||||
@@ -39,16 +36,6 @@ type TransitionDirection = 'forward' | 'backward';
|
|||||||
|
|
||||||
type TransitionVariant = 'vertical' | 'ios';
|
type TransitionVariant = 'vertical' | 'ios';
|
||||||
|
|
||||||
type PageTransitionLayerContextValue = {
|
|
||||||
status: LayerStatus;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PageTransitionLayerContext = createContext<PageTransitionLayerContextValue | null>(null);
|
|
||||||
|
|
||||||
export function usePageTransitionLayer() {
|
|
||||||
return useContext(PageTransitionLayerContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PageTransition({
|
export function PageTransition({
|
||||||
render,
|
render,
|
||||||
getRouteOrder,
|
getRouteOrder,
|
||||||
|
|||||||
15
src/components/common/PageTransitionLayer.ts
Normal file
15
src/components/common/PageTransitionLayer.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
export type LayerStatus = 'current' | 'exiting' | 'stacked';
|
||||||
|
|
||||||
|
type PageTransitionLayerContextValue = {
|
||||||
|
status: LayerStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PageTransitionLayerContext =
|
||||||
|
createContext<PageTransitionLayerContextValue | null>(null);
|
||||||
|
|
||||||
|
export function usePageTransitionLayer() {
|
||||||
|
return useContext(PageTransitionLayerContext);
|
||||||
|
}
|
||||||
|
|
||||||
37
src/components/config/VisualConfigEditor.module.scss
Normal file
37
src/components/config/VisualConfigEditor.module.scss
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
.payloadRuleModelRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 160px auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payloadRuleModelRowProtocolFirst {
|
||||||
|
grid-template-columns: 160px 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payloadRuleParamRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 140px 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payloadFilterModelRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 160px auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.payloadRuleModelRow,
|
||||||
|
.payloadRuleModelRowProtocolFirst,
|
||||||
|
.payloadRuleParamRow,
|
||||||
|
.payloadFilterModelRow {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payloadRowActionButton {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { Modal } from '@/components/ui/Modal';
|
|||||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
import { IconChevronDown } from '@/components/ui/icons';
|
import { IconChevronDown } from '@/components/ui/icons';
|
||||||
import { ConfigSection } from '@/components/config/ConfigSection';
|
import { ConfigSection } from '@/components/config/ConfigSection';
|
||||||
|
import { useNotificationStore } from '@/stores';
|
||||||
|
import styles from './VisualConfigEditor.module.scss';
|
||||||
import type {
|
import type {
|
||||||
PayloadFilterRule,
|
PayloadFilterRule,
|
||||||
PayloadModelEntry,
|
PayloadModelEntry,
|
||||||
@@ -200,6 +202,7 @@ function ApiKeysCardEditor({
|
|||||||
onChange: (nextValue: string) => void;
|
onChange: (nextValue: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { showNotification } = useNotificationStore();
|
||||||
const apiKeys = useMemo(
|
const apiKeys = useMemo(
|
||||||
() =>
|
() =>
|
||||||
value
|
value
|
||||||
@@ -262,6 +265,34 @@ function ApiKeysCardEditor({
|
|||||||
closeModal();
|
closeModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopy = async (apiKey: string) => {
|
||||||
|
const copyByExecCommand = () => {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = apiKey;
|
||||||
|
textarea.setAttribute('readonly', '');
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
textarea.style.pointerEvents = 'none';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
textarea.setSelectionRange(0, textarea.value.length);
|
||||||
|
const copied = document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
if (!copied) throw new Error('copy_failed');
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(apiKey);
|
||||||
|
} else {
|
||||||
|
copyByExecCommand();
|
||||||
|
}
|
||||||
|
showNotification(t('notification.link_copied'), 'success');
|
||||||
|
} catch {
|
||||||
|
showNotification(t('notification.copy_failed'), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
@@ -293,6 +324,9 @@ function ApiKeysCardEditor({
|
|||||||
<div className="item-subtitle">{maskApiKey(String(key || ''))}</div>
|
<div className="item-subtitle">{maskApiKey(String(key || ''))}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="item-actions">
|
<div className="item-actions">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => handleCopy(key)} disabled={disabled}>
|
||||||
|
{t('common.copy')}
|
||||||
|
</Button>
|
||||||
<Button variant="secondary" size="sm" onClick={() => openEditModal(index)} disabled={disabled}>
|
<Button variant="secondary" size="sm" onClick={() => openEditModal(index)} disabled={disabled}>
|
||||||
{t('config_management.visual.common.edit')}
|
{t('config_management.visual.common.edit')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -358,7 +392,7 @@ function StringListEditor({
|
|||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<div key={index} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
<div key={index} style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
@@ -471,7 +505,15 @@ function PayloadRulesEditor({
|
|||||||
gap: 12,
|
gap: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}</div>
|
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}</div>
|
||||||
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
|
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
|
||||||
{t('config_management.visual.common.delete')}
|
{t('config_management.visual.common.delete')}
|
||||||
@@ -483,11 +525,9 @@ function PayloadRulesEditor({
|
|||||||
{(rule.models.length ? rule.models : []).map((model, modelIndex) => (
|
{(rule.models.length ? rule.models : []).map((model, modelIndex) => (
|
||||||
<div
|
<div
|
||||||
key={model.id}
|
key={model.id}
|
||||||
style={{
|
className={[styles.payloadRuleModelRow, protocolFirst ? styles.payloadRuleModelRowProtocolFirst : '']
|
||||||
display: 'grid',
|
.filter(Boolean)
|
||||||
gridTemplateColumns: protocolFirst ? '160px 1fr auto' : '1fr 160px auto',
|
.join(' ')}
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{protocolFirst ? (
|
{protocolFirst ? (
|
||||||
<>
|
<>
|
||||||
@@ -532,7 +572,13 @@ function PayloadRulesEditor({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" size="sm" onClick={() => removeModel(ruleIndex, modelIndex)} disabled={disabled}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={styles.payloadRowActionButton}
|
||||||
|
onClick={() => removeModel(ruleIndex, modelIndex)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
{t('config_management.visual.common.delete')}
|
{t('config_management.visual.common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -547,7 +593,7 @@ function PayloadRulesEditor({
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.params')}</div>
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.params')}</div>
|
||||||
{(rule.params.length ? rule.params : []).map((param, paramIndex) => (
|
{(rule.params.length ? rule.params : []).map((param, paramIndex) => (
|
||||||
<div key={param.id} style={{ display: 'grid', gridTemplateColumns: '1fr 140px 1fr auto', gap: 8 }}>
|
<div key={param.id} className={styles.payloadRuleParamRow}>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
placeholder={t('config_management.visual.payload_rules.json_path')}
|
placeholder={t('config_management.visual.payload_rules.json_path')}
|
||||||
@@ -571,7 +617,13 @@ function PayloadRulesEditor({
|
|||||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })}
|
onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<Button variant="ghost" size="sm" onClick={() => removeParam(ruleIndex, paramIndex)} disabled={disabled}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={styles.payloadRowActionButton}
|
||||||
|
onClick={() => removeParam(ruleIndex, paramIndex)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
{t('config_management.visual.common.delete')}
|
{t('config_management.visual.common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -658,7 +710,15 @@ function PayloadFilterRulesEditor({
|
|||||||
gap: 12,
|
gap: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}</div>
|
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}</div>
|
||||||
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
|
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
|
||||||
{t('config_management.visual.common.delete')}
|
{t('config_management.visual.common.delete')}
|
||||||
@@ -668,7 +728,7 @@ function PayloadFilterRulesEditor({
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.models')}</div>
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.models')}</div>
|
||||||
{rule.models.map((model, modelIndex) => (
|
{rule.models.map((model, modelIndex) => (
|
||||||
<div key={model.id} style={{ display: 'grid', gridTemplateColumns: '1fr 160px auto', gap: 8 }}>
|
<div key={model.id} className={styles.payloadFilterModelRow}>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||||
@@ -687,7 +747,13 @@ function PayloadFilterRulesEditor({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Button variant="ghost" size="sm" onClick={() => removeModel(ruleIndex, modelIndex)} disabled={disabled}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={styles.payloadRowActionButton}
|
||||||
|
onClick={() => removeModel(ruleIndex, modelIndex)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
{t('config_management.visual.common.delete')}
|
{t('config_management.visual.common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -891,15 +957,6 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
|||||||
onChange={(e) => onChange({ logsMaxTotalSizeMb: e.target.value })}
|
onChange={(e) => onChange({ logsMaxTotalSizeMb: e.target.value })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<Input
|
|
||||||
label={t('config_management.visual.sections.system.usage_retention_days')}
|
|
||||||
type="number"
|
|
||||||
placeholder="30"
|
|
||||||
value={values.usageRecordsRetentionDays}
|
|
||||||
onChange={(e) => onChange({ usageRecordsRetentionDays: e.target.value })}
|
|
||||||
disabled={disabled}
|
|
||||||
hint={t('config_management.visual.sections.system.usage_retention_hint')}
|
|
||||||
/>
|
|
||||||
</SectionGrid>
|
</SectionGrid>
|
||||||
</div>
|
</div>
|
||||||
</ConfigSection>
|
</ConfigSection>
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import {
|
|||||||
import { NavLink, useLocation } from 'react-router-dom';
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
|
||||||
import { PageTransition } from '@/components/common/PageTransition';
|
import { PageTransition } from '@/components/common/PageTransition';
|
||||||
import { MainRoutes } from '@/router/MainRoutes';
|
import { MainRoutes } from '@/router/MainRoutes';
|
||||||
import {
|
import {
|
||||||
@@ -34,8 +32,10 @@ import {
|
|||||||
useNotificationStore,
|
useNotificationStore,
|
||||||
useThemeStore,
|
useThemeStore,
|
||||||
} from '@/stores';
|
} from '@/stores';
|
||||||
import { configApi, versionApi } from '@/services/api';
|
import { versionApi } from '@/services/api';
|
||||||
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
|
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
|
||||||
|
import { isSupportedLanguage } from '@/utils/language';
|
||||||
|
|
||||||
const sidebarIcons: Record<string, ReactNode> = {
|
const sidebarIcons: Record<string, ReactNode> = {
|
||||||
dashboard: <IconLayoutDashboard size={18} />,
|
dashboard: <IconLayoutDashboard size={18} />,
|
||||||
@@ -174,44 +174,36 @@ const compareVersions = (latest?: string | null, current?: string | null) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function MainLayout() {
|
export function MainLayout() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const apiBase = useAuthStore((state) => state.apiBase);
|
const apiBase = useAuthStore((state) => state.apiBase);
|
||||||
const serverVersion = useAuthStore((state) => state.serverVersion);
|
const serverVersion = useAuthStore((state) => state.serverVersion);
|
||||||
const serverBuildDate = useAuthStore((state) => state.serverBuildDate);
|
|
||||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
const logout = useAuthStore((state) => state.logout);
|
const logout = useAuthStore((state) => state.logout);
|
||||||
|
|
||||||
const config = useConfigStore((state) => state.config);
|
const config = useConfigStore((state) => state.config);
|
||||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||||
const clearCache = useConfigStore((state) => state.clearCache);
|
const clearCache = useConfigStore((state) => state.clearCache);
|
||||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
|
||||||
|
|
||||||
const theme = useThemeStore((state) => state.theme);
|
const theme = useThemeStore((state) => state.theme);
|
||||||
const cycleTheme = useThemeStore((state) => state.cycleTheme);
|
const cycleTheme = useThemeStore((state) => state.cycleTheme);
|
||||||
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
|
const language = useLanguageStore((state) => state.language);
|
||||||
|
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
||||||
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [checkingVersion, setCheckingVersion] = useState(false);
|
const [checkingVersion, setCheckingVersion] = useState(false);
|
||||||
|
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
|
||||||
const [brandExpanded, setBrandExpanded] = useState(true);
|
const [brandExpanded, setBrandExpanded] = useState(true);
|
||||||
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
|
|
||||||
const [requestLogDraft, setRequestLogDraft] = useState(false);
|
|
||||||
const [requestLogTouched, setRequestLogTouched] = useState(false);
|
|
||||||
const [requestLogSaving, setRequestLogSaving] = useState(false);
|
|
||||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const languageMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const headerRef = useRef<HTMLElement | null>(null);
|
const headerRef = useRef<HTMLElement | null>(null);
|
||||||
const versionTapCount = useRef(0);
|
|
||||||
const versionTapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const fullBrandName = 'CLI Proxy API Management Center';
|
const fullBrandName = 'CLI Proxy API Management Center';
|
||||||
const abbrBrandName = t('title.abbr');
|
const abbrBrandName = t('title.abbr');
|
||||||
const requestLogEnabled = config?.requestLog ?? false;
|
|
||||||
const requestLogDirty = requestLogDraft !== requestLogEnabled;
|
|
||||||
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
|
|
||||||
const isLogsPage = location.pathname.startsWith('/logs');
|
const isLogsPage = location.pathname.startsWith('/logs');
|
||||||
|
|
||||||
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
||||||
@@ -243,7 +235,7 @@ export function MainLayout() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 将主内容区的中心点写入 CSS 变量,供底部浮层(如配置面板操作栏)对齐到内容区而非整窗
|
// 将主内容区的中心点写入 CSS 变量,供底部浮层(配置面板操作栏、提供商导航)对齐到内容区
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const updateContentCenter = () => {
|
const updateContentCenter = () => {
|
||||||
const el = contentRef.current;
|
const el = contentRef.current;
|
||||||
@@ -271,6 +263,7 @@ export function MainLayout() {
|
|||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
}
|
}
|
||||||
window.removeEventListener('resize', updateContentCenter);
|
window.removeEventListener('resize', updateContentCenter);
|
||||||
|
document.documentElement.style.removeProperty('--content-center-x');
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -288,18 +281,30 @@ export function MainLayout() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (requestLogModalOpen && !requestLogTouched) {
|
if (!languageMenuOpen) {
|
||||||
setRequestLogDraft(requestLogEnabled);
|
return;
|
||||||
}
|
}
|
||||||
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handlePointerDown = (event: MouseEvent) => {
|
||||||
return () => {
|
if (!languageMenuRef.current?.contains(event.target as Node)) {
|
||||||
if (versionTapTimer.current) {
|
setLanguageMenuOpen(false);
|
||||||
clearTimeout(versionTapTimer.current);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setLanguageMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handlePointerDown);
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handlePointerDown);
|
||||||
|
document.removeEventListener('keydown', handleEscape);
|
||||||
|
};
|
||||||
|
}, [languageMenuOpen]);
|
||||||
|
|
||||||
const handleBrandClick = useCallback(() => {
|
const handleBrandClick = useCallback(() => {
|
||||||
if (!brandExpanded) {
|
if (!brandExpanded) {
|
||||||
@@ -314,59 +319,20 @@ export function MainLayout() {
|
|||||||
}
|
}
|
||||||
}, [brandExpanded]);
|
}, [brandExpanded]);
|
||||||
|
|
||||||
const openRequestLogModal = useCallback(() => {
|
const toggleLanguageMenu = useCallback(() => {
|
||||||
setRequestLogTouched(false);
|
setLanguageMenuOpen((prev) => !prev);
|
||||||
setRequestLogDraft(requestLogEnabled);
|
|
||||||
setRequestLogModalOpen(true);
|
|
||||||
}, [requestLogEnabled]);
|
|
||||||
|
|
||||||
const handleRequestLogClose = useCallback(() => {
|
|
||||||
setRequestLogModalOpen(false);
|
|
||||||
setRequestLogTouched(false);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleVersionTap = useCallback(() => {
|
const handleLanguageSelect = useCallback(
|
||||||
versionTapCount.current += 1;
|
(nextLanguage: string) => {
|
||||||
if (versionTapTimer.current) {
|
if (!isSupportedLanguage(nextLanguage)) {
|
||||||
clearTimeout(versionTapTimer.current);
|
return;
|
||||||
}
|
|
||||||
versionTapTimer.current = setTimeout(() => {
|
|
||||||
versionTapCount.current = 0;
|
|
||||||
}, 1500);
|
|
||||||
|
|
||||||
if (versionTapCount.current >= 7) {
|
|
||||||
versionTapCount.current = 0;
|
|
||||||
if (versionTapTimer.current) {
|
|
||||||
clearTimeout(versionTapTimer.current);
|
|
||||||
versionTapTimer.current = null;
|
|
||||||
}
|
}
|
||||||
openRequestLogModal();
|
setLanguage(nextLanguage);
|
||||||
}
|
setLanguageMenuOpen(false);
|
||||||
}, [openRequestLogModal]);
|
},
|
||||||
|
[setLanguage]
|
||||||
const handleRequestLogSave = async () => {
|
);
|
||||||
if (!canEditRequestLog) return;
|
|
||||||
if (!requestLogDirty) {
|
|
||||||
setRequestLogModalOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previous = requestLogEnabled;
|
|
||||||
setRequestLogSaving(true);
|
|
||||||
updateConfigValue('request-log', requestLogDraft);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await configApi.updateRequestLog(requestLogDraft);
|
|
||||||
clearCache('request-log');
|
|
||||||
showNotification(t('notification.request_log_updated'), 'success');
|
|
||||||
setRequestLogModalOpen(false);
|
|
||||||
} catch (error: any) {
|
|
||||||
updateConfigValue('request-log', previous);
|
|
||||||
showNotification(`${t('notification.update_failed')}: ${error?.message || ''}`, 'error');
|
|
||||||
} finally {
|
|
||||||
setRequestLogSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfig().catch(() => {
|
fetchConfig().catch(() => {
|
||||||
@@ -478,7 +444,8 @@ export function MainLayout() {
|
|||||||
setCheckingVersion(true);
|
setCheckingVersion(true);
|
||||||
try {
|
try {
|
||||||
const data = await versionApi.checkLatest();
|
const data = await versionApi.checkLatest();
|
||||||
const latest = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
|
const latestRaw = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
|
||||||
|
const latest = typeof latestRaw === 'string' ? latestRaw : String(latestRaw ?? '');
|
||||||
const comparison = compareVersions(latest, serverVersion);
|
const comparison = compareVersions(latest, serverVersion);
|
||||||
|
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
@@ -496,8 +463,11 @@ export function MainLayout() {
|
|||||||
} else {
|
} else {
|
||||||
showNotification(t('system_info.version_is_latest'), 'success');
|
showNotification(t('system_info.version_is_latest'), 'success');
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
showNotification(`${t('system_info.version_check_error')}: ${error?.message || ''}`, 'error');
|
const message =
|
||||||
|
error instanceof Error ? error.message : typeof error === 'string' ? error : '';
|
||||||
|
const suffix = message ? `: ${message}` : '';
|
||||||
|
showNotification(`${t('system_info.version_check_error')}${suffix}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setCheckingVersion(false);
|
setCheckingVersion(false);
|
||||||
}
|
}
|
||||||
@@ -569,9 +539,36 @@ export function MainLayout() {
|
|||||||
>
|
>
|
||||||
{headerIcons.update}
|
{headerIcons.update}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}>
|
<div className={`language-menu ${languageMenuOpen ? 'open' : ''}`} ref={languageMenuRef}>
|
||||||
{headerIcons.language}
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={toggleLanguageMenu}
|
||||||
|
title={t('language.switch')}
|
||||||
|
aria-label={t('language.switch')}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={languageMenuOpen}
|
||||||
|
>
|
||||||
|
{headerIcons.language}
|
||||||
|
</Button>
|
||||||
|
{languageMenuOpen && (
|
||||||
|
<div className="notification entering language-menu-popover" role="menu" aria-label={t('language.switch')}>
|
||||||
|
{LANGUAGE_ORDER.map((lang) => (
|
||||||
|
<button
|
||||||
|
key={lang}
|
||||||
|
type="button"
|
||||||
|
className={`language-menu-option ${language === lang ? 'active' : ''}`}
|
||||||
|
onClick={() => handleLanguageSelect(lang)}
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={language === lang}
|
||||||
|
>
|
||||||
|
<span>{t(LANGUAGE_LABEL_KEYS[lang])}</span>
|
||||||
|
{language === lang ? <span className="language-menu-check">✓</span> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
|
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
|
||||||
{theme === 'auto'
|
{theme === 'auto'
|
||||||
? headerIcons.autoTheme
|
? headerIcons.autoTheme
|
||||||
@@ -615,57 +612,8 @@ export function MainLayout() {
|
|||||||
scrollContainerRef={contentRef}
|
scrollContainerRef={contentRef}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="footer">
|
|
||||||
<span>
|
|
||||||
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
|
|
||||||
</span>
|
|
||||||
<span className="footer-version" onClick={handleVersionTap}>
|
|
||||||
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{t('footer.build_date')}:{' '}
|
|
||||||
{serverBuildDate
|
|
||||||
? new Date(serverBuildDate).toLocaleString(i18n.language)
|
|
||||||
: t('system_info.version_unknown')}
|
|
||||||
</span>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={requestLogModalOpen}
|
|
||||||
onClose={handleRequestLogClose}
|
|
||||||
title={t('basic_settings.request_log_title')}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={handleRequestLogClose} disabled={requestLogSaving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleRequestLogSave}
|
|
||||||
loading={requestLogSaving}
|
|
||||||
disabled={!canEditRequestLog || !requestLogDirty}
|
|
||||||
>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="request-log-modal">
|
|
||||||
<div className="status-badge warning">{t('basic_settings.request_log_warning')}</div>
|
|
||||||
<ToggleSwitch
|
|
||||||
label={t('basic_settings.request_log_enable')}
|
|
||||||
labelPosition="left"
|
|
||||||
checked={requestLogDraft}
|
|
||||||
disabled={!canEditRequestLog || requestLogSaving}
|
|
||||||
onChange={(value) => {
|
|
||||||
setRequestLogDraft(value);
|
|
||||||
setRequestLogTouched(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,7 +285,6 @@ export const ModelMappingDiagram = forwardRef<ModelMappingDiagramRef, ModelMappi
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
// updateLines is called after layout is calculated, ensuring elements are in place.
|
// updateLines is called after layout is calculated, ensuring elements are in place.
|
||||||
updateLines();
|
|
||||||
const raf = requestAnimationFrame(updateLines);
|
const raf = requestAnimationFrame(updateLines);
|
||||||
window.addEventListener('resize', updateLines);
|
window.addEventListener('resize', updateLines);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -295,7 +294,6 @@ export const ModelMappingDiagram = forwardRef<ModelMappingDiagramRef, ModelMappi
|
|||||||
}, [updateLines, aliasNodes]);
|
}, [updateLines, aliasNodes]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
updateLines();
|
|
||||||
const raf = requestAnimationFrame(updateLines);
|
const raf = requestAnimationFrame(updateLines);
|
||||||
return () => cancelAnimationFrame(raf);
|
return () => cancelAnimationFrame(raf);
|
||||||
}, [providerGroupHeights, updateLines]);
|
}, [providerGroupHeights, updateLines]);
|
||||||
|
|||||||
@@ -1,281 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
|
||||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
|
||||||
import { useConfigStore, useNotificationStore } from '@/stores';
|
|
||||||
import { ampcodeApi } from '@/services/api';
|
|
||||||
import type { AmpcodeConfig } from '@/types';
|
|
||||||
import { maskApiKey } from '@/utils/format';
|
|
||||||
import { buildAmpcodeFormState, entriesToAmpcodeMappings } from '../utils';
|
|
||||||
import type { AmpcodeFormState } from '../types';
|
|
||||||
|
|
||||||
interface AmpcodeModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
disableControls: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onBusyChange?: (busy: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { showNotification, showConfirmation } = useNotificationStore();
|
|
||||||
const config = useConfigStore((state) => state.config);
|
|
||||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
|
||||||
const clearCache = useConfigStore((state) => state.clearCache);
|
|
||||||
|
|
||||||
const [form, setForm] = useState<AmpcodeFormState>(() => buildAmpcodeFormState(null));
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
const [mappingsDirty, setMappingsDirty] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const initializedRef = useRef(false);
|
|
||||||
|
|
||||||
const getErrorMessage = (err: unknown) => {
|
|
||||||
if (err instanceof Error) return err.message;
|
|
||||||
if (typeof err === 'string') return err;
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onBusyChange?.(loading || saving);
|
|
||||||
}, [loading, saving, onBusyChange]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
initializedRef.current = false;
|
|
||||||
setLoading(false);
|
|
||||||
setSaving(false);
|
|
||||||
setError('');
|
|
||||||
setLoaded(false);
|
|
||||||
setMappingsDirty(false);
|
|
||||||
setForm(buildAmpcodeFormState(null));
|
|
||||||
onBusyChange?.(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (initializedRef.current) return;
|
|
||||||
initializedRef.current = true;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setLoaded(false);
|
|
||||||
setMappingsDirty(false);
|
|
||||||
setError('');
|
|
||||||
setForm(buildAmpcodeFormState(config?.ampcode ?? null));
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const ampcode = await ampcodeApi.getAmpcode();
|
|
||||||
setLoaded(true);
|
|
||||||
updateConfigValue('ampcode', ampcode);
|
|
||||||
clearCache('ampcode');
|
|
||||||
setForm(buildAmpcodeFormState(ampcode));
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setError(getErrorMessage(err) || t('notification.refresh_failed'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]);
|
|
||||||
|
|
||||||
const clearAmpcodeUpstreamApiKey = async () => {
|
|
||||||
showConfirmation({
|
|
||||||
title: t('ai_providers.ampcode_clear_upstream_api_key_title', { defaultValue: 'Clear Upstream API Key' }),
|
|
||||||
message: t('ai_providers.ampcode_clear_upstream_api_key_confirm'),
|
|
||||||
variant: 'danger',
|
|
||||||
confirmText: t('common.confirm'),
|
|
||||||
onConfirm: async () => {
|
|
||||||
setSaving(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await ampcodeApi.clearUpstreamApiKey();
|
|
||||||
const previous = config?.ampcode ?? {};
|
|
||||||
const next: AmpcodeConfig = { ...previous };
|
|
||||||
delete next.upstreamApiKey;
|
|
||||||
updateConfigValue('ampcode', next);
|
|
||||||
clearCache('ampcode');
|
|
||||||
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = getErrorMessage(err);
|
|
||||||
setError(message);
|
|
||||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const performSaveAmpcode = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const upstreamUrl = form.upstreamUrl.trim();
|
|
||||||
const overrideKey = form.upstreamApiKey.trim();
|
|
||||||
const modelMappings = entriesToAmpcodeMappings(form.mappingEntries);
|
|
||||||
|
|
||||||
if (upstreamUrl) {
|
|
||||||
await ampcodeApi.updateUpstreamUrl(upstreamUrl);
|
|
||||||
} else {
|
|
||||||
await ampcodeApi.clearUpstreamUrl();
|
|
||||||
}
|
|
||||||
|
|
||||||
await ampcodeApi.updateForceModelMappings(form.forceModelMappings);
|
|
||||||
|
|
||||||
if (loaded || mappingsDirty) {
|
|
||||||
if (modelMappings.length) {
|
|
||||||
await ampcodeApi.saveModelMappings(modelMappings);
|
|
||||||
} else {
|
|
||||||
await ampcodeApi.clearModelMappings();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overrideKey) {
|
|
||||||
await ampcodeApi.updateUpstreamApiKey(overrideKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
const previous = config?.ampcode ?? {};
|
|
||||||
const next: AmpcodeConfig = {
|
|
||||||
upstreamUrl: upstreamUrl || undefined,
|
|
||||||
forceModelMappings: form.forceModelMappings,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (previous.upstreamApiKey) {
|
|
||||||
next.upstreamApiKey = previous.upstreamApiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(previous.modelMappings)) {
|
|
||||||
next.modelMappings = previous.modelMappings;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overrideKey) {
|
|
||||||
next.upstreamApiKey = overrideKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loaded || mappingsDirty) {
|
|
||||||
if (modelMappings.length) {
|
|
||||||
next.modelMappings = modelMappings;
|
|
||||||
} else {
|
|
||||||
delete next.modelMappings;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateConfigValue('ampcode', next);
|
|
||||||
clearCache('ampcode');
|
|
||||||
showNotification(t('notification.ampcode_updated'), 'success');
|
|
||||||
onClose();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = getErrorMessage(err);
|
|
||||||
setError(message);
|
|
||||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveAmpcode = async () => {
|
|
||||||
if (!loaded && mappingsDirty) {
|
|
||||||
showConfirmation({
|
|
||||||
title: t('ai_providers.ampcode_mappings_overwrite_title', { defaultValue: 'Overwrite Mappings' }),
|
|
||||||
message: t('ai_providers.ampcode_mappings_overwrite_confirm'),
|
|
||||||
variant: 'secondary', // Not dangerous, just a warning
|
|
||||||
confirmText: t('common.confirm'),
|
|
||||||
onConfirm: performSaveAmpcode,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await performSaveAmpcode();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={t('ai_providers.ampcode_modal_title')}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={saving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={saveAmpcode} loading={saving} disabled={disableControls || loading}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{error && <div className="error-box">{error}</div>}
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.ampcode_upstream_url_label')}
|
|
||||||
placeholder={t('ai_providers.ampcode_upstream_url_placeholder')}
|
|
||||||
value={form.upstreamUrl}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, upstreamUrl: e.target.value }))}
|
|
||||||
disabled={loading || saving}
|
|
||||||
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={form.upstreamApiKey}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, upstreamApiKey: e.target.value }))}
|
|
||||||
disabled={loading || saving}
|
|
||||||
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={loading || saving || !config?.ampcode?.upstreamApiKey}
|
|
||||||
>
|
|
||||||
{t('ai_providers.ampcode_clear_upstream_api_key')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<ToggleSwitch
|
|
||||||
label={t('ai_providers.ampcode_force_model_mappings_label')}
|
|
||||||
checked={form.forceModelMappings}
|
|
||||||
onChange={(value) => setForm((prev) => ({ ...prev, forceModelMappings: value }))}
|
|
||||||
disabled={loading || saving}
|
|
||||||
/>
|
|
||||||
<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={form.mappingEntries}
|
|
||||||
onChange={(entries) => {
|
|
||||||
setMappingsDirty(true);
|
|
||||||
setForm((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={loading || saving}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
|
||||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
|
||||||
import type { ProviderKeyConfig } from '@/types';
|
|
||||||
import { headersToEntries } from '@/utils/headers';
|
|
||||||
import { excludedModelsToText } from '../utils';
|
|
||||||
import type { ProviderFormState, ProviderModalProps } from '../types';
|
|
||||||
|
|
||||||
interface ClaudeModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
|
|
||||||
isSaving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildEmptyForm = (): ProviderFormState => ({
|
|
||||||
apiKey: '',
|
|
||||||
prefix: '',
|
|
||||||
baseUrl: '',
|
|
||||||
proxyUrl: '',
|
|
||||||
headers: [],
|
|
||||||
models: [],
|
|
||||||
excludedModels: [],
|
|
||||||
modelEntries: [{ name: '', alias: '' }],
|
|
||||||
excludedText: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
export function ClaudeModal({
|
|
||||||
isOpen,
|
|
||||||
editIndex,
|
|
||||||
initialData,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: ClaudeModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [form, setForm] = useState<ProviderFormState>(buildEmptyForm);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
if (initialData) {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setForm({
|
|
||||||
...initialData,
|
|
||||||
headers: headersToEntries(initialData.headers),
|
|
||||||
modelEntries: modelsToEntries(initialData.models),
|
|
||||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setForm(buildEmptyForm());
|
|
||||||
}, [initialData, isOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={
|
|
||||||
editIndex !== null
|
|
||||||
? t('ai_providers.claude_edit_modal_title')
|
|
||||||
: t('ai_providers.claude_add_modal_title')
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.claude_add_modal_key_label')}
|
|
||||||
value={form.apiKey}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.prefix_label')}
|
|
||||||
placeholder={t('ai_providers.prefix_placeholder')}
|
|
||||||
value={form.prefix ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
|
||||||
hint={t('ai_providers.prefix_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.claude_add_modal_url_label')}
|
|
||||||
value={form.baseUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.claude_add_modal_proxy_label')}
|
|
||||||
value={form.proxyUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<HeaderInputList
|
|
||||||
entries={form.headers}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
|
||||||
addLabel={t('common.custom_headers_add')}
|
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
|
||||||
/>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.claude_models_label')}</label>
|
|
||||||
<ModelInputList
|
|
||||||
entries={form.modelEntries}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
|
||||||
addLabel={t('ai_providers.claude_models_add_btn')}
|
|
||||||
namePlaceholder={t('common.model_name_placeholder')}
|
|
||||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
|
||||||
disabled={isSaving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
|
||||||
<textarea
|
|
||||||
className="input"
|
|
||||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
|
||||||
value={form.excludedText}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import type { ProviderKeyConfig } from '@/types';
|
|
||||||
import { headersToEntries } from '@/utils/headers';
|
|
||||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
|
||||||
import { excludedModelsToText } from '../utils';
|
|
||||||
import type { ProviderFormState, ProviderModalProps } from '../types';
|
|
||||||
|
|
||||||
interface CodexModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
|
|
||||||
isSaving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildEmptyForm = (): ProviderFormState => ({
|
|
||||||
apiKey: '',
|
|
||||||
prefix: '',
|
|
||||||
baseUrl: '',
|
|
||||||
proxyUrl: '',
|
|
||||||
headers: [],
|
|
||||||
models: [],
|
|
||||||
excludedModels: [],
|
|
||||||
modelEntries: [{ name: '', alias: '' }],
|
|
||||||
excludedText: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
export function CodexModal({
|
|
||||||
isOpen,
|
|
||||||
editIndex,
|
|
||||||
initialData,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: CodexModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [form, setForm] = useState<ProviderFormState>(buildEmptyForm);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
if (initialData) {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setForm({
|
|
||||||
...initialData,
|
|
||||||
headers: headersToEntries(initialData.headers),
|
|
||||||
modelEntries: modelsToEntries(initialData.models),
|
|
||||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setForm(buildEmptyForm());
|
|
||||||
}, [initialData, isOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={
|
|
||||||
editIndex !== null
|
|
||||||
? t('ai_providers.codex_edit_modal_title')
|
|
||||||
: t('ai_providers.codex_add_modal_title')
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.codex_add_modal_key_label')}
|
|
||||||
value={form.apiKey}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.prefix_label')}
|
|
||||||
placeholder={t('ai_providers.prefix_placeholder')}
|
|
||||||
value={form.prefix ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
|
||||||
hint={t('ai_providers.prefix_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.codex_add_modal_url_label')}
|
|
||||||
value={form.baseUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.codex_add_modal_proxy_label')}
|
|
||||||
value={form.proxyUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<HeaderInputList
|
|
||||||
entries={form.headers}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
|
||||||
addLabel={t('common.custom_headers_add')}
|
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
|
||||||
/>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
|
||||||
<textarea
|
|
||||||
className="input"
|
|
||||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
|
||||||
value={form.excludedText}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import type { GeminiKeyConfig } from '@/types';
|
|
||||||
import { headersToEntries } from '@/utils/headers';
|
|
||||||
import { excludedModelsToText } from '../utils';
|
|
||||||
import type { GeminiFormState, ProviderModalProps } from '../types';
|
|
||||||
|
|
||||||
interface GeminiModalProps extends ProviderModalProps<GeminiKeyConfig, GeminiFormState> {
|
|
||||||
isSaving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildEmptyForm = (): GeminiFormState => ({
|
|
||||||
apiKey: '',
|
|
||||||
prefix: '',
|
|
||||||
baseUrl: '',
|
|
||||||
headers: [],
|
|
||||||
excludedModels: [],
|
|
||||||
excludedText: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
export function GeminiModal({
|
|
||||||
isOpen,
|
|
||||||
editIndex,
|
|
||||||
initialData,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: GeminiModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [form, setForm] = useState<GeminiFormState>(buildEmptyForm);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
if (initialData) {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setForm({
|
|
||||||
...initialData,
|
|
||||||
headers: headersToEntries(initialData.headers),
|
|
||||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setForm(buildEmptyForm());
|
|
||||||
}, [initialData, isOpen]);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
void onSave(form, editIndex);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={
|
|
||||||
editIndex !== null
|
|
||||||
? t('ai_providers.gemini_edit_modal_title')
|
|
||||||
: t('ai_providers.gemini_add_modal_title')
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} loading={isSaving}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.gemini_add_modal_key_label')}
|
|
||||||
placeholder={t('ai_providers.gemini_add_modal_key_placeholder')}
|
|
||||||
value={form.apiKey}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.prefix_label')}
|
|
||||||
placeholder={t('ai_providers.prefix_placeholder')}
|
|
||||||
value={form.prefix ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
|
||||||
hint={t('ai_providers.prefix_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.gemini_base_url_label')}
|
|
||||||
placeholder={t('ai_providers.gemini_base_url_placeholder')}
|
|
||||||
value={form.baseUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<HeaderInputList
|
|
||||||
entries={form.headers}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
|
||||||
addLabel={t('common.custom_headers_add')}
|
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
|
||||||
/>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
|
||||||
<textarea
|
|
||||||
className="input"
|
|
||||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
|
||||||
value={form.excludedText}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { modelsApi } from '@/services/api';
|
|
||||||
import type { ApiKeyEntry } from '@/types';
|
|
||||||
import type { ModelInfo } from '@/utils/models';
|
|
||||||
import { buildHeaderObject, type HeaderEntry } from '@/utils/headers';
|
|
||||||
import { buildOpenAIModelsEndpoint } from '../utils';
|
|
||||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
|
||||||
|
|
||||||
interface OpenAIDiscoveryModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
baseUrl: string;
|
|
||||||
headers: HeaderEntry[];
|
|
||||||
apiKeyEntries: ApiKeyEntry[];
|
|
||||||
onClose: () => void;
|
|
||||||
onApply: (selected: ModelInfo[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OpenAIDiscoveryModal({
|
|
||||||
isOpen,
|
|
||||||
baseUrl,
|
|
||||||
headers,
|
|
||||||
apiKeyEntries,
|
|
||||||
onClose,
|
|
||||||
onApply,
|
|
||||||
}: OpenAIDiscoveryModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [endpoint, setEndpoint] = useState('');
|
|
||||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const getErrorMessage = (err: unknown) => {
|
|
||||||
if (err instanceof Error) return err.message;
|
|
||||||
if (typeof err === 'string') return err;
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredModels = useMemo(() => {
|
|
||||||
const filter = search.trim().toLowerCase();
|
|
||||||
if (!filter) return models;
|
|
||||||
return models.filter((model) => {
|
|
||||||
const name = (model.name || '').toLowerCase();
|
|
||||||
const alias = (model.alias || '').toLowerCase();
|
|
||||||
const desc = (model.description || '').toLowerCase();
|
|
||||||
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
|
|
||||||
});
|
|
||||||
}, [models, search]);
|
|
||||||
|
|
||||||
const fetchOpenaiModelDiscovery = useCallback(
|
|
||||||
async ({ allowFallback = true }: { allowFallback?: boolean } = {}) => {
|
|
||||||
const trimmedBaseUrl = baseUrl.trim();
|
|
||||||
if (!trimmedBaseUrl) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const headerObject = buildHeaderObject(headers);
|
|
||||||
const firstKey = apiKeyEntries.find((entry) => entry.apiKey?.trim())?.apiKey?.trim();
|
|
||||||
const hasAuthHeader = Boolean(headerObject.Authorization || headerObject['authorization']);
|
|
||||||
const list = await modelsApi.fetchModelsViaApiCall(
|
|
||||||
trimmedBaseUrl,
|
|
||||||
hasAuthHeader ? undefined : firstKey,
|
|
||||||
headerObject
|
|
||||||
);
|
|
||||||
setModels(list);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (allowFallback) {
|
|
||||||
try {
|
|
||||||
const list = await modelsApi.fetchModelsViaApiCall(trimmedBaseUrl);
|
|
||||||
setModels(list);
|
|
||||||
return;
|
|
||||||
} catch (fallbackErr: unknown) {
|
|
||||||
const message = getErrorMessage(fallbackErr) || getErrorMessage(err);
|
|
||||||
setModels([]);
|
|
||||||
setError(`${t('ai_providers.openai_models_fetch_error')}: ${message}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setModels([]);
|
|
||||||
setError(`${t('ai_providers.openai_models_fetch_error')}: ${getErrorMessage(err)}`);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[apiKeyEntries, baseUrl, headers, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
setEndpoint(buildOpenAIModelsEndpoint(baseUrl));
|
|
||||||
setModels([]);
|
|
||||||
setSearch('');
|
|
||||||
setSelected(new Set());
|
|
||||||
setError('');
|
|
||||||
void fetchOpenaiModelDiscovery();
|
|
||||||
}, [baseUrl, fetchOpenaiModelDiscovery, isOpen]);
|
|
||||||
|
|
||||||
const toggleSelection = (name: string) => {
|
|
||||||
setSelected((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(name)) {
|
|
||||||
next.delete(name);
|
|
||||||
} else {
|
|
||||||
next.add(name);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApply = () => {
|
|
||||||
const selectedModels = models.filter((model) => selected.has(model.name));
|
|
||||||
onApply(selectedModels);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={t('ai_providers.openai_models_fetch_title')}
|
|
||||||
width={720}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
|
||||||
{t('ai_providers.openai_models_fetch_back')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleApply} disabled={loading}>
|
|
||||||
{t('ai_providers.openai_models_fetch_apply')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="hint" style={{ marginBottom: 8 }}>
|
|
||||||
{t('ai_providers.openai_models_fetch_hint')}
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.openai_models_fetch_url_label')}</label>
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
||||||
<input className="input" readOnly value={endpoint} />
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void fetchOpenaiModelDiscovery({ allowFallback: true })}
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
{t('ai_providers.openai_models_fetch_refresh')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.openai_models_search_label')}
|
|
||||||
placeholder={t('ai_providers.openai_models_search_placeholder')}
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
{error && <div className="error-box">{error}</div>}
|
|
||||||
{loading ? (
|
|
||||||
<div className="hint">{t('ai_providers.openai_models_fetch_loading')}</div>
|
|
||||||
) : models.length === 0 ? (
|
|
||||||
<div className="hint">{t('ai_providers.openai_models_fetch_empty')}</div>
|
|
||||||
) : filteredModels.length === 0 ? (
|
|
||||||
<div className="hint">{t('ai_providers.openai_models_search_empty')}</div>
|
|
||||||
) : (
|
|
||||||
<div className={styles.modelDiscoveryList}>
|
|
||||||
{filteredModels.map((model) => {
|
|
||||||
const checked = selected.has(model.name);
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={model.name}
|
|
||||||
className={`${styles.modelDiscoveryRow} ${checked ? styles.modelDiscoveryRowSelected : ''}`}
|
|
||||||
>
|
|
||||||
<input type="checkbox" checked={checked} onChange={() => toggleSelection(model.name)} />
|
|
||||||
<div className={styles.modelDiscoveryMeta}>
|
|
||||||
<div className={styles.modelDiscoveryName}>
|
|
||||||
{model.name}
|
|
||||||
{model.alias && <span className={styles.modelDiscoveryAlias}>{model.alias}</span>}
|
|
||||||
</div>
|
|
||||||
{model.description && (
|
|
||||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,433 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
|
||||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
|
||||||
import { useNotificationStore } from '@/stores';
|
|
||||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
|
||||||
import type { OpenAIProviderConfig, ApiKeyEntry } from '@/types';
|
|
||||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
|
||||||
import type { ModelInfo } from '@/utils/models';
|
|
||||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
|
||||||
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '../utils';
|
|
||||||
import type { ModelEntry, OpenAIFormState, ProviderModalProps } from '../types';
|
|
||||||
import { OpenAIDiscoveryModal } from './OpenAIDiscoveryModal';
|
|
||||||
|
|
||||||
const OPENAI_TEST_TIMEOUT_MS = 30_000;
|
|
||||||
|
|
||||||
interface OpenAIModalProps extends ProviderModalProps<OpenAIProviderConfig, OpenAIFormState> {
|
|
||||||
isSaving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildEmptyForm = (): OpenAIFormState => ({
|
|
||||||
name: '',
|
|
||||||
prefix: '',
|
|
||||||
baseUrl: '',
|
|
||||||
headers: [],
|
|
||||||
apiKeyEntries: [buildApiKeyEntry()],
|
|
||||||
modelEntries: [{ name: '', alias: '' }],
|
|
||||||
testModel: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function OpenAIModal({
|
|
||||||
isOpen,
|
|
||||||
editIndex,
|
|
||||||
initialData,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: OpenAIModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { showNotification } = useNotificationStore();
|
|
||||||
const [form, setForm] = useState<OpenAIFormState>(buildEmptyForm);
|
|
||||||
const [discoveryOpen, setDiscoveryOpen] = useState(false);
|
|
||||||
const [testModel, setTestModel] = useState('');
|
|
||||||
const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
|
||||||
const [testMessage, setTestMessage] = useState('');
|
|
||||||
|
|
||||||
const getErrorMessage = (err: unknown) => {
|
|
||||||
if (err instanceof Error) return err.message;
|
|
||||||
if (typeof err === 'string') return err;
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const availableModels = useMemo(
|
|
||||||
() => form.modelEntries.map((entry) => entry.name.trim()).filter(Boolean),
|
|
||||||
[form.modelEntries]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
setDiscoveryOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (initialData) {
|
|
||||||
const modelEntries = modelsToEntries(initialData.models);
|
|
||||||
setForm({
|
|
||||||
name: initialData.name,
|
|
||||||
prefix: initialData.prefix ?? '',
|
|
||||||
baseUrl: initialData.baseUrl,
|
|
||||||
headers: headersToEntries(initialData.headers),
|
|
||||||
testModel: initialData.testModel,
|
|
||||||
modelEntries,
|
|
||||||
apiKeyEntries: initialData.apiKeyEntries?.length
|
|
||||||
? initialData.apiKeyEntries
|
|
||||||
: [buildApiKeyEntry()],
|
|
||||||
});
|
|
||||||
const available = modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
|
|
||||||
const initialModel =
|
|
||||||
initialData.testModel && available.includes(initialData.testModel)
|
|
||||||
? initialData.testModel
|
|
||||||
: available[0] || '';
|
|
||||||
setTestModel(initialModel);
|
|
||||||
} else {
|
|
||||||
setForm(buildEmptyForm());
|
|
||||||
setTestModel('');
|
|
||||||
}
|
|
||||||
|
|
||||||
setTestStatus('idle');
|
|
||||||
setTestMessage('');
|
|
||||||
setDiscoveryOpen(false);
|
|
||||||
}, [initialData, isOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
if (availableModels.length === 0) {
|
|
||||||
if (testModel) {
|
|
||||||
setTestModel('');
|
|
||||||
setTestStatus('idle');
|
|
||||||
setTestMessage('');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!testModel || !availableModels.includes(testModel)) {
|
|
||||||
setTestModel(availableModels[0]);
|
|
||||||
setTestStatus('idle');
|
|
||||||
setTestMessage('');
|
|
||||||
}
|
|
||||||
}, [availableModels, isOpen, testModel]);
|
|
||||||
|
|
||||||
const renderKeyEntries = (entries: ApiKeyEntry[]) => {
|
|
||||||
const list = entries.length ? entries : [buildApiKeyEntry()];
|
|
||||||
const updateEntry = (idx: number, field: keyof ApiKeyEntry, value: string) => {
|
|
||||||
const next = list.map((entry, i) => (i === idx ? { ...entry, [field]: value } : entry));
|
|
||||||
setForm((prev) => ({ ...prev, apiKeyEntries: next }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeEntry = (idx: number) => {
|
|
||||||
const next = list.filter((_, i) => i !== idx);
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
apiKeyEntries: next.length ? next : [buildApiKeyEntry()],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addEntry = () => {
|
|
||||||
setForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="stack">
|
|
||||||
{list.map((entry, index) => (
|
|
||||||
<div key={index} className="item-row">
|
|
||||||
<div className="item-meta">
|
|
||||||
<Input
|
|
||||||
label={`${t('common.api_key')} #${index + 1}`}
|
|
||||||
value={entry.apiKey}
|
|
||||||
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('common.proxy_url')}
|
|
||||||
value={entry.proxyUrl ?? ''}
|
|
||||||
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="item-actions">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => removeEntry(index)}
|
|
||||||
disabled={list.length <= 1 || isSaving}
|
|
||||||
>
|
|
||||||
{t('common.delete')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button variant="secondary" size="sm" onClick={addEntry} disabled={isSaving}>
|
|
||||||
{t('ai_providers.openai_keys_add_btn')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openOpenaiModelDiscovery = () => {
|
|
||||||
const baseUrl = form.baseUrl.trim();
|
|
||||||
if (!baseUrl) {
|
|
||||||
showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDiscoveryOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyOpenaiModelDiscoverySelection = (selectedModels: ModelInfo[]) => {
|
|
||||||
if (!selectedModels.length) {
|
|
||||||
setDiscoveryOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedMap = new Map<string, ModelEntry>();
|
|
||||||
form.modelEntries.forEach((entry) => {
|
|
||||||
const name = entry.name.trim();
|
|
||||||
if (!name) return;
|
|
||||||
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
|
|
||||||
});
|
|
||||||
|
|
||||||
let addedCount = 0;
|
|
||||||
selectedModels.forEach((model) => {
|
|
||||||
const name = model.name.trim();
|
|
||||||
if (!name || mergedMap.has(name)) return;
|
|
||||||
mergedMap.set(name, { name, alias: model.alias ?? '' });
|
|
||||||
addedCount += 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
const mergedEntries = Array.from(mergedMap.values());
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
setDiscoveryOpen(false);
|
|
||||||
if (addedCount > 0) {
|
|
||||||
showNotification(t('ai_providers.openai_models_fetch_added', { count: addedCount }), 'success');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const testOpenaiProviderConnection = async () => {
|
|
||||||
const baseUrl = form.baseUrl.trim();
|
|
||||||
if (!baseUrl) {
|
|
||||||
const message = t('notification.openai_test_url_required');
|
|
||||||
setTestStatus('error');
|
|
||||||
setTestMessage(message);
|
|
||||||
showNotification(message, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const endpoint = buildOpenAIChatCompletionsEndpoint(baseUrl);
|
|
||||||
if (!endpoint) {
|
|
||||||
const message = t('notification.openai_test_url_required');
|
|
||||||
setTestStatus('error');
|
|
||||||
setTestMessage(message);
|
|
||||||
showNotification(message, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstKeyEntry = form.apiKeyEntries.find((entry) => entry.apiKey?.trim());
|
|
||||||
if (!firstKeyEntry) {
|
|
||||||
const message = t('notification.openai_test_key_required');
|
|
||||||
setTestStatus('error');
|
|
||||||
setTestMessage(message);
|
|
||||||
showNotification(message, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelName = testModel.trim() || availableModels[0] || '';
|
|
||||||
if (!modelName) {
|
|
||||||
const message = t('notification.openai_test_model_required');
|
|
||||||
setTestStatus('error');
|
|
||||||
setTestMessage(message);
|
|
||||||
showNotification(message, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const customHeaders = buildHeaderObject(form.headers);
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...customHeaders,
|
|
||||||
};
|
|
||||||
if (!headers.Authorization && !headers['authorization']) {
|
|
||||||
headers.Authorization = `Bearer ${firstKeyEntry.apiKey.trim()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTestStatus('loading');
|
|
||||||
setTestMessage(t('ai_providers.openai_test_running'));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await apiCallApi.request(
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
url: endpoint,
|
|
||||||
header: Object.keys(headers).length ? headers : undefined,
|
|
||||||
data: JSON.stringify({
|
|
||||||
model: modelName,
|
|
||||||
messages: [{ role: 'user', content: 'Hi' }],
|
|
||||||
stream: false,
|
|
||||||
max_tokens: 5,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{ timeout: OPENAI_TEST_TIMEOUT_MS }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
|
||||||
throw new Error(getApiCallErrorMessage(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
setTestStatus('success');
|
|
||||||
setTestMessage(t('ai_providers.openai_test_success'));
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setTestStatus('error');
|
|
||||||
const message = getErrorMessage(err);
|
|
||||||
const errorCode =
|
|
||||||
typeof err === 'object' && err !== null && 'code' in err ? String((err as { code?: string }).code) : '';
|
|
||||||
const isTimeout =
|
|
||||||
errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout');
|
|
||||||
if (isTimeout) {
|
|
||||||
setTestMessage(t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 }));
|
|
||||||
} else {
|
|
||||||
setTestMessage(`${t('ai_providers.openai_test_failed')}: ${message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={
|
|
||||||
editIndex !== null
|
|
||||||
? t('ai_providers.openai_edit_modal_title')
|
|
||||||
: t('ai_providers.openai_add_modal_title')
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.openai_add_modal_name_label')}
|
|
||||||
value={form.name}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.prefix_label')}
|
|
||||||
placeholder={t('ai_providers.prefix_placeholder')}
|
|
||||||
value={form.prefix ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
|
||||||
hint={t('ai_providers.prefix_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.openai_add_modal_url_label')}
|
|
||||||
value={form.baseUrl}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<HeaderInputList
|
|
||||||
entries={form.headers}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
|
||||||
addLabel={t('common.custom_headers_add')}
|
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>
|
|
||||||
{editIndex !== null
|
|
||||||
? t('ai_providers.openai_edit_modal_models_label')
|
|
||||||
: t('ai_providers.openai_add_modal_models_label')}
|
|
||||||
</label>
|
|
||||||
<div className="hint">{t('ai_providers.openai_models_hint')}</div>
|
|
||||||
<ModelInputList
|
|
||||||
entries={form.modelEntries}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
|
||||||
addLabel={t('ai_providers.openai_models_add_btn')}
|
|
||||||
namePlaceholder={t('common.model_name_placeholder')}
|
|
||||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
|
||||||
disabled={isSaving}
|
|
||||||
/>
|
|
||||||
<Button variant="secondary" size="sm" onClick={openOpenaiModelDiscovery} disabled={isSaving}>
|
|
||||||
{t('ai_providers.openai_models_fetch_button')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.openai_test_title')}</label>
|
|
||||||
<div className="hint">{t('ai_providers.openai_test_hint')}</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
||||||
<select
|
|
||||||
className={`input ${styles.openaiTestSelect}`}
|
|
||||||
value={testModel}
|
|
||||||
onChange={(e) => {
|
|
||||||
setTestModel(e.target.value);
|
|
||||||
setTestStatus('idle');
|
|
||||||
setTestMessage('');
|
|
||||||
}}
|
|
||||||
disabled={isSaving || availableModels.length === 0}
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
{availableModels.length
|
|
||||||
? t('ai_providers.openai_test_select_placeholder')
|
|
||||||
: t('ai_providers.openai_test_select_empty')}
|
|
||||||
</option>
|
|
||||||
{form.modelEntries
|
|
||||||
.filter((entry) => entry.name.trim())
|
|
||||||
.map((entry, idx) => {
|
|
||||||
const name = entry.name.trim();
|
|
||||||
const alias = entry.alias.trim();
|
|
||||||
const label = alias && alias !== name ? `${name} (${alias})` : name;
|
|
||||||
return (
|
|
||||||
<option key={`${name}-${idx}`} value={name}>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
<Button
|
|
||||||
variant={testStatus === 'error' ? 'danger' : 'secondary'}
|
|
||||||
className={`${styles.openaiTestButton} ${testStatus === 'success' ? styles.openaiTestButtonSuccess : ''}`}
|
|
||||||
onClick={testOpenaiProviderConnection}
|
|
||||||
loading={testStatus === 'loading'}
|
|
||||||
disabled={isSaving || availableModels.length === 0}
|
|
||||||
>
|
|
||||||
{t('ai_providers.openai_test_action')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{testMessage && (
|
|
||||||
<div
|
|
||||||
className={`status-badge ${
|
|
||||||
testStatus === 'error' ? 'error' : testStatus === 'success' ? 'success' : 'muted'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{testMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
|
|
||||||
{renderKeyEntries(form.apiKeyEntries)}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<OpenAIDiscoveryModal
|
|
||||||
isOpen={discoveryOpen}
|
|
||||||
baseUrl={form.baseUrl}
|
|
||||||
headers={form.headers}
|
|
||||||
apiKeyEntries={form.apiKeyEntries}
|
|
||||||
onClose={() => setDiscoveryOpen(false)}
|
|
||||||
onApply={applyOpenaiModelDiscoverySelection}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { usePageTransitionLayer } from '@/components/common/PageTransition';
|
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
|
||||||
import { useThemeStore } from '@/stores';
|
import { useThemeStore } from '@/stores';
|
||||||
import iconGemini from '@/assets/icons/gemini.svg';
|
import iconGemini from '@/assets/icons/gemini.svg';
|
||||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||||
@@ -135,8 +135,9 @@ export function ProviderNav() {
|
|||||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
contentScroller?.addEventListener('scroll', handleScroll, { passive: true });
|
contentScroller?.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
window.addEventListener('resize', handleScroll);
|
window.addEventListener('resize', handleScroll);
|
||||||
handleScroll();
|
const raf = requestAnimationFrame(handleScroll);
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
window.removeEventListener('scroll', handleScroll);
|
window.removeEventListener('scroll', handleScroll);
|
||||||
window.removeEventListener('resize', handleScroll);
|
window.removeEventListener('resize', handleScroll);
|
||||||
contentScroller?.removeEventListener('scroll', handleScroll);
|
contentScroller?.removeEventListener('scroll', handleScroll);
|
||||||
@@ -168,7 +169,8 @@ export function ProviderNav() {
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!shouldShow) return;
|
if (!shouldShow) return;
|
||||||
updateIndicator(activeProvider);
|
const raf = requestAnimationFrame(() => updateIndicator(activeProvider));
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
}, [activeProvider, shouldShow, updateIndicator]);
|
}, [activeProvider, shouldShow, updateIndicator]);
|
||||||
|
|
||||||
// Expose overlay height to the page, so it can reserve bottom padding and avoid being covered.
|
// Expose overlay height to the page, so it can reserve bottom padding and avoid being covered.
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
|
||||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
|
||||||
import type { ProviderKeyConfig } from '@/types';
|
|
||||||
import { headersToEntries } from '@/utils/headers';
|
|
||||||
import type { ProviderModalProps, VertexFormState } from '../types';
|
|
||||||
|
|
||||||
interface VertexModalProps extends ProviderModalProps<ProviderKeyConfig, VertexFormState> {
|
|
||||||
isSaving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildEmptyForm = (): VertexFormState => ({
|
|
||||||
apiKey: '',
|
|
||||||
prefix: '',
|
|
||||||
baseUrl: '',
|
|
||||||
proxyUrl: '',
|
|
||||||
headers: [],
|
|
||||||
models: [],
|
|
||||||
modelEntries: [{ name: '', alias: '' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
export function VertexModal({
|
|
||||||
isOpen,
|
|
||||||
editIndex,
|
|
||||||
initialData,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: VertexModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [form, setForm] = useState<VertexFormState>(buildEmptyForm);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
if (initialData) {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setForm({
|
|
||||||
...initialData,
|
|
||||||
headers: headersToEntries(initialData.headers),
|
|
||||||
modelEntries: modelsToEntries(initialData.models),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setForm(buildEmptyForm());
|
|
||||||
}, [initialData, isOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={
|
|
||||||
editIndex !== null
|
|
||||||
? t('ai_providers.vertex_edit_modal_title')
|
|
||||||
: t('ai_providers.vertex_add_modal_title')
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.vertex_add_modal_key_label')}
|
|
||||||
placeholder={t('ai_providers.vertex_add_modal_key_placeholder')}
|
|
||||||
value={form.apiKey}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.prefix_label')}
|
|
||||||
placeholder={t('ai_providers.prefix_placeholder')}
|
|
||||||
value={form.prefix ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
|
||||||
hint={t('ai_providers.prefix_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.vertex_add_modal_url_label')}
|
|
||||||
placeholder={t('ai_providers.vertex_add_modal_url_placeholder')}
|
|
||||||
value={form.baseUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.vertex_add_modal_proxy_label')}
|
|
||||||
placeholder={t('ai_providers.vertex_add_modal_proxy_placeholder')}
|
|
||||||
value={form.proxyUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<HeaderInputList
|
|
||||||
entries={form.headers}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
|
||||||
addLabel={t('common.custom_headers_add')}
|
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
|
||||||
/>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.vertex_models_label')}</label>
|
|
||||||
<ModelInputList
|
|
||||||
entries={form.modelEntries}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
|
||||||
addLabel={t('ai_providers.vertex_models_add_btn')}
|
|
||||||
namePlaceholder={t('common.model_name_placeholder')}
|
|
||||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
|
||||||
disabled={isSaving}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,6 @@ import type { ApiKeyEntry, GeminiKeyConfig, ProviderKeyConfig } from '@/types';
|
|||||||
import type { HeaderEntry } from '@/utils/headers';
|
import type { HeaderEntry } from '@/utils/headers';
|
||||||
import type { KeyStats, UsageDetail } from '@/utils/usage';
|
import type { KeyStats, UsageDetail } from '@/utils/usage';
|
||||||
|
|
||||||
export type ProviderModal =
|
|
||||||
| { type: 'gemini'; index: number | null }
|
|
||||||
| { type: 'codex'; index: number | null }
|
|
||||||
| { type: 'claude'; index: number | null }
|
|
||||||
| { type: 'vertex'; index: number | null }
|
|
||||||
| { type: 'ampcode'; index: null }
|
|
||||||
| { type: 'openai'; index: number | null };
|
|
||||||
|
|
||||||
export interface ModelEntry {
|
export interface ModelEntry {
|
||||||
name: string;
|
name: string;
|
||||||
alias: string;
|
alias: string;
|
||||||
@@ -58,12 +50,3 @@ export interface ProviderSectionProps<TConfig> {
|
|||||||
onDelete: (index: number) => void;
|
onDelete: (index: number) => void;
|
||||||
onToggle?: (index: number, enabled: boolean) => void;
|
onToggle?: (index: number, enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderModalProps<TConfig, TPayload = TConfig> {
|
|
||||||
isOpen: boolean;
|
|
||||||
editIndex: number | null;
|
|
||||||
initialData?: TConfig;
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (data: TPayload, index: number | null) => Promise<void>;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
KIRO_QUOTA_URL,
|
KIRO_QUOTA_URL,
|
||||||
KIRO_REQUEST_HEADERS,
|
KIRO_REQUEST_HEADERS,
|
||||||
normalizeAuthIndexValue,
|
normalizeAuthIndexValue,
|
||||||
|
normalizeGeminiCliModelId,
|
||||||
normalizeNumberValue,
|
normalizeNumberValue,
|
||||||
normalizePlanType,
|
normalizePlanType,
|
||||||
normalizeQuotaFraction,
|
normalizeQuotaFraction,
|
||||||
@@ -375,7 +376,7 @@ const fetchGeminiCliQuota = async (
|
|||||||
|
|
||||||
const parsedBuckets = buckets
|
const parsedBuckets = buckets
|
||||||
.map((bucket) => {
|
.map((bucket) => {
|
||||||
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
|
const modelId = normalizeGeminiCliModelId(bucket.modelId ?? bucket.model_id);
|
||||||
if (!modelId) return null;
|
if (!modelId) return null;
|
||||||
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
|
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
|
||||||
const remainingFractionRaw = normalizeQuotaFraction(
|
const remainingFractionRaw = normalizeQuotaFraction(
|
||||||
|
|||||||
@@ -16,11 +16,53 @@ const CLOSE_ANIMATION_DURATION = 350;
|
|||||||
const MODAL_LOCK_CLASS = 'modal-open';
|
const MODAL_LOCK_CLASS = 'modal-open';
|
||||||
let activeModalCount = 0;
|
let activeModalCount = 0;
|
||||||
|
|
||||||
|
const scrollLockSnapshot = {
|
||||||
|
scrollY: 0,
|
||||||
|
contentScrollTop: 0,
|
||||||
|
contentEl: null as HTMLElement | null,
|
||||||
|
bodyPosition: '',
|
||||||
|
bodyTop: '',
|
||||||
|
bodyLeft: '',
|
||||||
|
bodyRight: '',
|
||||||
|
bodyWidth: '',
|
||||||
|
bodyOverflow: '',
|
||||||
|
htmlOverflow: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveContentScrollContainer = () => {
|
||||||
|
if (typeof document === 'undefined') return null;
|
||||||
|
const contentEl = document.querySelector('.content');
|
||||||
|
return contentEl instanceof HTMLElement ? contentEl : null;
|
||||||
|
};
|
||||||
|
|
||||||
const lockScroll = () => {
|
const lockScroll = () => {
|
||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
if (activeModalCount === 0) {
|
if (activeModalCount === 0) {
|
||||||
document.body?.classList.add(MODAL_LOCK_CLASS);
|
const body = document.body;
|
||||||
document.documentElement?.classList.add(MODAL_LOCK_CLASS);
|
const html = document.documentElement;
|
||||||
|
const contentEl = resolveContentScrollContainer();
|
||||||
|
|
||||||
|
scrollLockSnapshot.scrollY = window.scrollY || window.pageYOffset || html.scrollTop || 0;
|
||||||
|
scrollLockSnapshot.contentEl = contentEl;
|
||||||
|
scrollLockSnapshot.contentScrollTop = contentEl?.scrollTop ?? 0;
|
||||||
|
scrollLockSnapshot.bodyPosition = body.style.position;
|
||||||
|
scrollLockSnapshot.bodyTop = body.style.top;
|
||||||
|
scrollLockSnapshot.bodyLeft = body.style.left;
|
||||||
|
scrollLockSnapshot.bodyRight = body.style.right;
|
||||||
|
scrollLockSnapshot.bodyWidth = body.style.width;
|
||||||
|
scrollLockSnapshot.bodyOverflow = body.style.overflow;
|
||||||
|
scrollLockSnapshot.htmlOverflow = html.style.overflow;
|
||||||
|
|
||||||
|
body.classList.add(MODAL_LOCK_CLASS);
|
||||||
|
html.classList.add(MODAL_LOCK_CLASS);
|
||||||
|
|
||||||
|
body.style.position = 'fixed';
|
||||||
|
body.style.top = `-${scrollLockSnapshot.scrollY}px`;
|
||||||
|
body.style.left = '0';
|
||||||
|
body.style.right = '0';
|
||||||
|
body.style.width = '100%';
|
||||||
|
body.style.overflow = 'hidden';
|
||||||
|
html.style.overflow = 'hidden';
|
||||||
}
|
}
|
||||||
activeModalCount += 1;
|
activeModalCount += 1;
|
||||||
};
|
};
|
||||||
@@ -29,8 +71,31 @@ const unlockScroll = () => {
|
|||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
activeModalCount = Math.max(0, activeModalCount - 1);
|
activeModalCount = Math.max(0, activeModalCount - 1);
|
||||||
if (activeModalCount === 0) {
|
if (activeModalCount === 0) {
|
||||||
document.body?.classList.remove(MODAL_LOCK_CLASS);
|
const body = document.body;
|
||||||
document.documentElement?.classList.remove(MODAL_LOCK_CLASS);
|
const html = document.documentElement;
|
||||||
|
const scrollY = scrollLockSnapshot.scrollY;
|
||||||
|
const contentScrollTop = scrollLockSnapshot.contentScrollTop;
|
||||||
|
const contentEl = scrollLockSnapshot.contentEl;
|
||||||
|
|
||||||
|
body.classList.remove(MODAL_LOCK_CLASS);
|
||||||
|
html.classList.remove(MODAL_LOCK_CLASS);
|
||||||
|
|
||||||
|
body.style.position = scrollLockSnapshot.bodyPosition;
|
||||||
|
body.style.top = scrollLockSnapshot.bodyTop;
|
||||||
|
body.style.left = scrollLockSnapshot.bodyLeft;
|
||||||
|
body.style.right = scrollLockSnapshot.bodyRight;
|
||||||
|
body.style.width = scrollLockSnapshot.bodyWidth;
|
||||||
|
body.style.overflow = scrollLockSnapshot.bodyOverflow;
|
||||||
|
html.style.overflow = scrollLockSnapshot.htmlOverflow;
|
||||||
|
|
||||||
|
if (contentEl) {
|
||||||
|
contentEl.scrollTo({ top: contentScrollTop, left: 0, behavior: 'auto' });
|
||||||
|
}
|
||||||
|
window.scrollTo({ top: scrollY, left: 0, behavior: 'auto' });
|
||||||
|
|
||||||
|
scrollLockSnapshot.scrollY = 0;
|
||||||
|
scrollLockSnapshot.contentScrollTop = 0;
|
||||||
|
scrollLockSnapshot.contentEl = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ export function useUsageData(): UseUsageDataReturn {
|
|||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const data = await usageApi.getUsage();
|
const data = await usageApi.getUsage();
|
||||||
const payload = data?.usage ?? data;
|
const payload = (data?.usage ?? data) as unknown;
|
||||||
setUsage(payload);
|
setUsage(payload && typeof payload === 'object' ? (payload as UsagePayload) : null);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
|
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
|
||||||
setError(message);
|
setError(message);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface UseApiOptions<T> {
|
|||||||
successMessage?: string;
|
successMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useApi<T = any, Args extends any[] = any[]>(
|
export function useApi<T = unknown, Args extends unknown[] = unknown[]>(
|
||||||
apiFunction: (...args: Args) => Promise<T>,
|
apiFunction: (...args: Args) => Promise<T>,
|
||||||
options: UseApiOptions<T> = {}
|
options: UseApiOptions<T> = {}
|
||||||
) {
|
) {
|
||||||
@@ -38,8 +38,9 @@ export function useApi<T = any, Args extends any[] = any[]>(
|
|||||||
|
|
||||||
options.onSuccess?.(result);
|
options.onSuccess?.(result);
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
const errorObj = err as Error;
|
const errorObj =
|
||||||
|
err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error');
|
||||||
setError(errorObj);
|
setError(errorObj);
|
||||||
|
|
||||||
if (options.showErrorNotification !== false) {
|
if (options.showErrorNotification !== false) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||||
import type {
|
import type {
|
||||||
PayloadFilterRule,
|
PayloadFilterRule,
|
||||||
@@ -102,67 +102,125 @@ function deepClone<T>(value: T): T {
|
|||||||
return JSON.parse(JSON.stringify(value)) as T;
|
return JSON.parse(JSON.stringify(value)) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePayloadParamValue(raw: unknown): { valueType: PayloadParamValueType; value: string } {
|
||||||
|
if (typeof raw === 'number') {
|
||||||
|
return { valueType: 'number', value: String(raw) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof raw === 'boolean') {
|
||||||
|
return { valueType: 'boolean', value: String(raw) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw === null || typeof raw === 'object') {
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(raw, null, 2);
|
||||||
|
return { valueType: 'json', value: json ?? 'null' };
|
||||||
|
} catch {
|
||||||
|
return { valueType: 'json', value: String(raw) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valueType: 'string', value: String(raw ?? '') };
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAYLOAD_PROTOCOL_VALUES = [
|
||||||
|
'openai',
|
||||||
|
'openai-response',
|
||||||
|
'gemini',
|
||||||
|
'claude',
|
||||||
|
'codex',
|
||||||
|
'antigravity',
|
||||||
|
] as const;
|
||||||
|
type PayloadProtocol = (typeof PAYLOAD_PROTOCOL_VALUES)[number];
|
||||||
|
|
||||||
|
function parsePayloadProtocol(raw: unknown): PayloadProtocol | undefined {
|
||||||
|
if (typeof raw !== 'string') return undefined;
|
||||||
|
return PAYLOAD_PROTOCOL_VALUES.includes(raw as PayloadProtocol)
|
||||||
|
? (raw as PayloadProtocol)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function parsePayloadRules(rules: unknown): PayloadRule[] {
|
function parsePayloadRules(rules: unknown): PayloadRule[] {
|
||||||
if (!Array.isArray(rules)) return [];
|
if (!Array.isArray(rules)) return [];
|
||||||
|
|
||||||
return rules.map((rule, index) => ({
|
return rules.map((rule, index) => {
|
||||||
id: `payload-rule-${index}`,
|
const record = asRecord(rule) ?? {};
|
||||||
models: Array.isArray((rule as any)?.models)
|
|
||||||
? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({
|
const modelsRaw = record.models;
|
||||||
id: `model-${index}-${modelIndex}`,
|
const models = Array.isArray(modelsRaw)
|
||||||
name: typeof model === 'string' ? model : model?.name || '',
|
? modelsRaw.map((model, modelIndex) => {
|
||||||
protocol: typeof model === 'object' ? (model?.protocol as any) : undefined,
|
const modelRecord = asRecord(model);
|
||||||
}))
|
const nameRaw =
|
||||||
: [],
|
typeof model === 'string' ? model : (modelRecord?.name ?? modelRecord?.id ?? '');
|
||||||
params: (rule as any)?.params
|
const name = typeof nameRaw === 'string' ? nameRaw : String(nameRaw ?? '');
|
||||||
? Object.entries((rule as any).params as Record<string, unknown>).map(([path, value], pIndex) => ({
|
return {
|
||||||
id: `param-${index}-${pIndex}`,
|
id: `model-${index}-${modelIndex}`,
|
||||||
path,
|
name,
|
||||||
valueType:
|
protocol: parsePayloadProtocol(modelRecord?.protocol),
|
||||||
typeof value === 'number'
|
};
|
||||||
? 'number'
|
})
|
||||||
: typeof value === 'boolean'
|
: [];
|
||||||
? 'boolean'
|
|
||||||
: typeof value === 'object'
|
const paramsRecord = asRecord(record.params);
|
||||||
? 'json'
|
const params = paramsRecord
|
||||||
: 'string',
|
? Object.entries(paramsRecord).map(([path, value], pIndex) => {
|
||||||
value: String(value),
|
const parsedValue = parsePayloadParamValue(value);
|
||||||
}))
|
return {
|
||||||
: [],
|
id: `param-${index}-${pIndex}`,
|
||||||
}));
|
path,
|
||||||
|
valueType: parsedValue.valueType,
|
||||||
|
value: parsedValue.value,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return { id: `payload-rule-${index}`, models, params };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePayloadFilterRules(rules: unknown): PayloadFilterRule[] {
|
function parsePayloadFilterRules(rules: unknown): PayloadFilterRule[] {
|
||||||
if (!Array.isArray(rules)) return [];
|
if (!Array.isArray(rules)) return [];
|
||||||
|
|
||||||
return rules.map((rule, index) => ({
|
return rules.map((rule, index) => {
|
||||||
id: `payload-filter-rule-${index}`,
|
const record = asRecord(rule) ?? {};
|
||||||
models: Array.isArray((rule as any)?.models)
|
|
||||||
? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({
|
const modelsRaw = record.models;
|
||||||
id: `filter-model-${index}-${modelIndex}`,
|
const models = Array.isArray(modelsRaw)
|
||||||
name: typeof model === 'string' ? model : model?.name || '',
|
? modelsRaw.map((model, modelIndex) => {
|
||||||
protocol: typeof model === 'object' ? (model?.protocol as any) : undefined,
|
const modelRecord = asRecord(model);
|
||||||
}))
|
const nameRaw =
|
||||||
: [],
|
typeof model === 'string' ? model : (modelRecord?.name ?? modelRecord?.id ?? '');
|
||||||
params: Array.isArray((rule as any)?.params) ? ((rule as any).params as unknown[]).map(String) : [],
|
const name = typeof nameRaw === 'string' ? nameRaw : String(nameRaw ?? '');
|
||||||
}));
|
return {
|
||||||
|
id: `filter-model-${index}-${modelIndex}`,
|
||||||
|
name,
|
||||||
|
protocol: parsePayloadProtocol(modelRecord?.protocol),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const paramsRaw = record.params;
|
||||||
|
const params = Array.isArray(paramsRaw) ? paramsRaw.map(String) : [];
|
||||||
|
|
||||||
|
return { id: `payload-filter-rule-${index}`, models, params };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializePayloadRulesForYaml(rules: PayloadRule[]): any[] {
|
function serializePayloadRulesForYaml(rules: PayloadRule[]): Array<Record<string, unknown>> {
|
||||||
return rules
|
return rules
|
||||||
.map((rule) => {
|
.map((rule) => {
|
||||||
const models = (rule.models || [])
|
const models = (rule.models || [])
|
||||||
.filter((m) => m.name?.trim())
|
.filter((m) => m.name?.trim())
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
const obj: Record<string, any> = { name: m.name.trim() };
|
const obj: Record<string, unknown> = { name: m.name.trim() };
|
||||||
if (m.protocol) obj.protocol = m.protocol;
|
if (m.protocol) obj.protocol = m.protocol;
|
||||||
return obj;
|
return obj;
|
||||||
});
|
});
|
||||||
|
|
||||||
const params: Record<string, any> = {};
|
const params: Record<string, unknown> = {};
|
||||||
for (const param of rule.params || []) {
|
for (const param of rule.params || []) {
|
||||||
if (!param.path?.trim()) continue;
|
if (!param.path?.trim()) continue;
|
||||||
let value: any = param.value;
|
let value: unknown = param.value;
|
||||||
if (param.valueType === 'number') {
|
if (param.valueType === 'number') {
|
||||||
const num = Number(param.value);
|
const num = Number(param.value);
|
||||||
value = Number.isFinite(num) ? num : param.value;
|
value = Number.isFinite(num) ? num : param.value;
|
||||||
@@ -183,13 +241,15 @@ function serializePayloadRulesForYaml(rules: PayloadRule[]): any[] {
|
|||||||
.filter((rule) => rule.models.length > 0);
|
.filter((rule) => rule.models.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializePayloadFilterRulesForYaml(rules: PayloadFilterRule[]): any[] {
|
function serializePayloadFilterRulesForYaml(
|
||||||
|
rules: PayloadFilterRule[]
|
||||||
|
): Array<Record<string, unknown>> {
|
||||||
return rules
|
return rules
|
||||||
.map((rule) => {
|
.map((rule) => {
|
||||||
const models = (rule.models || [])
|
const models = (rule.models || [])
|
||||||
.filter((m) => m.name?.trim())
|
.filter((m) => m.name?.trim())
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
const obj: Record<string, any> = { name: m.name.trim() };
|
const obj: Record<string, unknown> = { name: m.name.trim() };
|
||||||
if (m.protocol) obj.protocol = m.protocol;
|
if (m.protocol) obj.protocol = m.protocol;
|
||||||
return obj;
|
return obj;
|
||||||
});
|
});
|
||||||
@@ -208,71 +268,83 @@ export function useVisualConfig() {
|
|||||||
...DEFAULT_VISUAL_VALUES,
|
...DEFAULT_VISUAL_VALUES,
|
||||||
});
|
});
|
||||||
|
|
||||||
const baselineValues = useRef<VisualConfigValues>({ ...DEFAULT_VISUAL_VALUES });
|
const [baselineValues, setBaselineValues] = useState<VisualConfigValues>({
|
||||||
|
...DEFAULT_VISUAL_VALUES,
|
||||||
|
});
|
||||||
|
|
||||||
const visualDirty = useMemo(() => {
|
const visualDirty = useMemo(() => {
|
||||||
return JSON.stringify(visualValues) !== JSON.stringify(baselineValues.current);
|
return JSON.stringify(visualValues) !== JSON.stringify(baselineValues);
|
||||||
}, [visualValues]);
|
}, [baselineValues, visualValues]);
|
||||||
|
|
||||||
const loadVisualValuesFromYaml = useCallback((yamlContent: string) => {
|
const loadVisualValuesFromYaml = useCallback((yamlContent: string) => {
|
||||||
try {
|
try {
|
||||||
const parsed: any = parseYaml(yamlContent) || {};
|
const parsedRaw: unknown = parseYaml(yamlContent) || {};
|
||||||
|
const parsed = asRecord(parsedRaw) ?? {};
|
||||||
|
const tls = asRecord(parsed.tls);
|
||||||
|
const remoteManagement = asRecord(parsed['remote-management']);
|
||||||
|
const quotaExceeded = asRecord(parsed['quota-exceeded']);
|
||||||
|
const routing = asRecord(parsed.routing);
|
||||||
|
const payload = asRecord(parsed.payload);
|
||||||
|
const streaming = asRecord(parsed.streaming);
|
||||||
|
|
||||||
const newValues: VisualConfigValues = {
|
const newValues: VisualConfigValues = {
|
||||||
host: parsed.host || '',
|
host: typeof parsed.host === 'string' ? parsed.host : '',
|
||||||
port: String(parsed.port || ''),
|
port: String(parsed.port ?? ''),
|
||||||
|
|
||||||
tlsEnable: Boolean(parsed.tls?.enable),
|
tlsEnable: Boolean(tls?.enable),
|
||||||
tlsCert: parsed.tls?.cert || '',
|
tlsCert: typeof tls?.cert === 'string' ? tls.cert : '',
|
||||||
tlsKey: parsed.tls?.key || '',
|
tlsKey: typeof tls?.key === 'string' ? tls.key : '',
|
||||||
|
|
||||||
rmAllowRemote: Boolean(parsed['remote-management']?.['allow-remote']),
|
rmAllowRemote: Boolean(remoteManagement?.['allow-remote']),
|
||||||
rmSecretKey: parsed['remote-management']?.['secret-key'] || '',
|
rmSecretKey:
|
||||||
rmDisableControlPanel: Boolean(parsed['remote-management']?.['disable-control-panel']),
|
typeof remoteManagement?.['secret-key'] === 'string' ? remoteManagement['secret-key'] : '',
|
||||||
|
rmDisableControlPanel: Boolean(remoteManagement?.['disable-control-panel']),
|
||||||
rmPanelRepo:
|
rmPanelRepo:
|
||||||
parsed['remote-management']?.['panel-github-repository'] ??
|
typeof remoteManagement?.['panel-github-repository'] === 'string'
|
||||||
parsed['remote-management']?.['panel-repo'] ??
|
? remoteManagement['panel-github-repository']
|
||||||
'',
|
: typeof remoteManagement?.['panel-repo'] === 'string'
|
||||||
|
? remoteManagement['panel-repo']
|
||||||
|
: '',
|
||||||
|
|
||||||
authDir: parsed['auth-dir'] || '',
|
authDir: typeof parsed['auth-dir'] === 'string' ? parsed['auth-dir'] : '',
|
||||||
apiKeysText: parseApiKeysText(parsed['api-keys']),
|
apiKeysText: parseApiKeysText(parsed['api-keys']),
|
||||||
|
|
||||||
debug: Boolean(parsed.debug),
|
debug: Boolean(parsed.debug),
|
||||||
commercialMode: Boolean(parsed['commercial-mode']),
|
commercialMode: Boolean(parsed['commercial-mode']),
|
||||||
loggingToFile: Boolean(parsed['logging-to-file']),
|
loggingToFile: Boolean(parsed['logging-to-file']),
|
||||||
logsMaxTotalSizeMb: String(parsed['logs-max-total-size-mb'] || ''),
|
logsMaxTotalSizeMb: String(parsed['logs-max-total-size-mb'] ?? ''),
|
||||||
usageStatisticsEnabled: Boolean(parsed['usage-statistics-enabled']),
|
usageStatisticsEnabled: Boolean(parsed['usage-statistics-enabled']),
|
||||||
usageRecordsRetentionDays: String(parsed['usage-records-retention-days'] ?? ''),
|
|
||||||
|
|
||||||
proxyUrl: parsed['proxy-url'] || '',
|
proxyUrl: typeof parsed['proxy-url'] === 'string' ? parsed['proxy-url'] : '',
|
||||||
forceModelPrefix: Boolean(parsed['force-model-prefix']),
|
forceModelPrefix: Boolean(parsed['force-model-prefix']),
|
||||||
requestRetry: String(parsed['request-retry'] || ''),
|
requestRetry: String(parsed['request-retry'] ?? ''),
|
||||||
maxRetryInterval: String(parsed['max-retry-interval'] || ''),
|
maxRetryInterval: String(parsed['max-retry-interval'] ?? ''),
|
||||||
wsAuth: Boolean(parsed['ws-auth']),
|
wsAuth: Boolean(parsed['ws-auth']),
|
||||||
|
|
||||||
quotaSwitchProject: Boolean(parsed['quota-exceeded']?.['switch-project'] ?? true),
|
quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? true),
|
||||||
quotaSwitchPreviewModel: Boolean(
|
quotaSwitchPreviewModel: Boolean(
|
||||||
parsed['quota-exceeded']?.['switch-preview-model'] ?? true
|
quotaExceeded?.['switch-preview-model'] ?? true
|
||||||
),
|
),
|
||||||
|
|
||||||
routingStrategy: (parsed.routing?.strategy || 'round-robin') as 'round-robin' | 'fill-first',
|
routingStrategy:
|
||||||
|
routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
|
||||||
|
|
||||||
payloadDefaultRules: parsePayloadRules(parsed.payload?.default),
|
payloadDefaultRules: parsePayloadRules(payload?.default),
|
||||||
payloadOverrideRules: parsePayloadRules(parsed.payload?.override),
|
payloadOverrideRules: parsePayloadRules(payload?.override),
|
||||||
payloadFilterRules: parsePayloadFilterRules(parsed.payload?.filter),
|
payloadFilterRules: parsePayloadFilterRules(payload?.filter),
|
||||||
|
|
||||||
streaming: {
|
streaming: {
|
||||||
keepaliveSeconds: String(parsed.streaming?.['keepalive-seconds'] ?? ''),
|
keepaliveSeconds: String(streaming?.['keepalive-seconds'] ?? ''),
|
||||||
bootstrapRetries: String(parsed.streaming?.['bootstrap-retries'] ?? ''),
|
bootstrapRetries: String(streaming?.['bootstrap-retries'] ?? ''),
|
||||||
nonstreamKeepaliveInterval: String(parsed['nonstream-keepalive-interval'] ?? ''),
|
nonstreamKeepaliveInterval: String(parsed['nonstream-keepalive-interval'] ?? ''),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
setVisualValuesState(newValues);
|
setVisualValuesState(newValues);
|
||||||
baselineValues.current = deepClone(newValues);
|
setBaselineValues(deepClone(newValues));
|
||||||
} catch {
|
} catch {
|
||||||
setVisualValuesState({ ...DEFAULT_VISUAL_VALUES });
|
setVisualValuesState({ ...DEFAULT_VISUAL_VALUES });
|
||||||
baselineValues.current = deepClone(DEFAULT_VISUAL_VALUES);
|
setBaselineValues(deepClone(DEFAULT_VISUAL_VALUES));
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -315,7 +387,7 @@ export function useVisualConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setString(parsed, 'auth-dir', values.authDir);
|
setString(parsed, 'auth-dir', values.authDir);
|
||||||
if (values.apiKeysText !== baselineValues.current.apiKeysText) {
|
if (values.apiKeysText !== baselineValues.apiKeysText) {
|
||||||
const apiKeys = values.apiKeysText
|
const apiKeys = values.apiKeysText
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((key) => key.trim())
|
.map((key) => key.trim())
|
||||||
@@ -333,11 +405,6 @@ export function useVisualConfig() {
|
|||||||
setBoolean(parsed, 'logging-to-file', values.loggingToFile);
|
setBoolean(parsed, 'logging-to-file', values.loggingToFile);
|
||||||
setIntFromString(parsed, 'logs-max-total-size-mb', values.logsMaxTotalSizeMb);
|
setIntFromString(parsed, 'logs-max-total-size-mb', values.logsMaxTotalSizeMb);
|
||||||
setBoolean(parsed, 'usage-statistics-enabled', values.usageStatisticsEnabled);
|
setBoolean(parsed, 'usage-statistics-enabled', values.usageStatisticsEnabled);
|
||||||
setIntFromString(
|
|
||||||
parsed,
|
|
||||||
'usage-records-retention-days',
|
|
||||||
values.usageRecordsRetentionDays
|
|
||||||
);
|
|
||||||
|
|
||||||
setString(parsed, 'proxy-url', values.proxyUrl);
|
setString(parsed, 'proxy-url', values.proxyUrl);
|
||||||
setBoolean(parsed, 'force-model-prefix', values.forceModelPrefix);
|
setBoolean(parsed, 'force-model-prefix', values.forceModelPrefix);
|
||||||
@@ -408,7 +475,7 @@ export function useVisualConfig() {
|
|||||||
return currentYaml;
|
return currentYaml;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[visualValues]
|
[baselineValues, visualValues]
|
||||||
);
|
);
|
||||||
|
|
||||||
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
|
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
|
||||||
@@ -433,6 +500,7 @@ export function useVisualConfig() {
|
|||||||
export const VISUAL_CONFIG_PROTOCOL_OPTIONS = [
|
export const VISUAL_CONFIG_PROTOCOL_OPTIONS = [
|
||||||
{ value: '', label: '默认' },
|
{ value: '', label: '默认' },
|
||||||
{ value: 'openai', label: 'OpenAI' },
|
{ value: 'openai', label: 'OpenAI' },
|
||||||
|
{ value: 'openai-response', label: 'OpenAI Response' },
|
||||||
{ value: 'gemini', label: 'Gemini' },
|
{ value: 'gemini', label: 'Gemini' },
|
||||||
{ value: 'claude', label: 'Claude' },
|
{ value: 'claude', label: 'Claude' },
|
||||||
{ value: 'codex', label: 'Codex' },
|
{ value: 'codex', label: 'Codex' },
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import i18n from 'i18next';
|
|||||||
import { initReactI18next } from 'react-i18next';
|
import { initReactI18next } from 'react-i18next';
|
||||||
import zhCN from './locales/zh-CN.json';
|
import zhCN from './locales/zh-CN.json';
|
||||||
import en from './locales/en.json';
|
import en from './locales/en.json';
|
||||||
|
import ru from './locales/ru.json';
|
||||||
import { getInitialLanguage } from '@/utils/language';
|
import { getInitialLanguage } from '@/utils/language';
|
||||||
|
|
||||||
i18n.use(initReactI18next).init({
|
i18n.use(initReactI18next).init({
|
||||||
resources: {
|
resources: {
|
||||||
'zh-CN': { translation: zhCN },
|
'zh-CN': { translation: zhCN },
|
||||||
en: { translation: en }
|
en: { translation: en },
|
||||||
|
ru: { translation: ru }
|
||||||
},
|
},
|
||||||
lng: getInitialLanguage(),
|
lng: getInitialLanguage(),
|
||||||
fallbackLng: 'zh-CN',
|
fallbackLng: 'zh-CN',
|
||||||
|
|||||||
@@ -380,6 +380,7 @@
|
|||||||
"filter_qwen": "Qwen",
|
"filter_qwen": "Qwen",
|
||||||
"filter_gemini": "Gemini",
|
"filter_gemini": "Gemini",
|
||||||
"filter_gemini-cli": "GeminiCLI",
|
"filter_gemini-cli": "GeminiCLI",
|
||||||
|
"filter_kimi": "Kimi",
|
||||||
"filter_aistudio": "AIStudio",
|
"filter_aistudio": "AIStudio",
|
||||||
"filter_claude": "Claude",
|
"filter_claude": "Claude",
|
||||||
"filter_codex": "Codex",
|
"filter_codex": "Codex",
|
||||||
@@ -392,6 +393,7 @@
|
|||||||
"type_qwen": "Qwen",
|
"type_qwen": "Qwen",
|
||||||
"type_gemini": "Gemini",
|
"type_gemini": "Gemini",
|
||||||
"type_gemini-cli": "GeminiCLI",
|
"type_gemini-cli": "GeminiCLI",
|
||||||
|
"type_kimi": "Kimi",
|
||||||
"type_aistudio": "AIStudio",
|
"type_aistudio": "AIStudio",
|
||||||
"type_claude": "Claude",
|
"type_claude": "Claude",
|
||||||
"type_codex": "Codex",
|
"type_codex": "Codex",
|
||||||
@@ -409,8 +411,8 @@
|
|||||||
"models_empty_desc": "This credential may not be loaded by the server yet, or no models are bound to it.",
|
"models_empty_desc": "This credential may not be loaded by the server yet, or no models are bound to it.",
|
||||||
"models_unsupported": "This feature is not supported in the current version",
|
"models_unsupported": "This feature is not supported in the current version",
|
||||||
"models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again",
|
"models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again",
|
||||||
"models_excluded_badge": "Excluded",
|
"models_excluded_badge": "Disabled",
|
||||||
"models_excluded_hint": "This model is excluded by OAuth",
|
"models_excluded_hint": "This OAuth model is disabled",
|
||||||
"status_toggle_label": "Enabled",
|
"status_toggle_label": "Enabled",
|
||||||
"status_enabled_success": "\"{{name}}\" enabled",
|
"status_enabled_success": "\"{{name}}\" enabled",
|
||||||
"status_disabled_success": "\"{{name}}\" disabled",
|
"status_disabled_success": "\"{{name}}\" disabled",
|
||||||
@@ -423,9 +425,6 @@
|
|||||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||||
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.",
|
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.",
|
||||||
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully",
|
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully",
|
||||||
"card_tools_title": "Tools",
|
|
||||||
"quota_refresh_single": "Refresh quota",
|
|
||||||
"quota_refresh_hint": "Refresh quota for this credential only",
|
|
||||||
"quota_refresh_success": "Quota refreshed for \"{{name}}\"",
|
"quota_refresh_success": "Quota refreshed for \"{{name}}\"",
|
||||||
"quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}"
|
"quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}"
|
||||||
},
|
},
|
||||||
@@ -433,7 +432,7 @@
|
|||||||
"title": "Antigravity Quota",
|
"title": "Antigravity Quota",
|
||||||
"empty_title": "No Antigravity Auth Files",
|
"empty_title": "No Antigravity Auth Files",
|
||||||
"empty_desc": "Upload an Antigravity credential to view remaining quota.",
|
"empty_desc": "Upload an Antigravity credential to view remaining quota.",
|
||||||
"idle": "Not loaded. Click Refresh Button.",
|
"idle": "Click here to refresh quota",
|
||||||
"loading": "Loading quota...",
|
"loading": "Loading quota...",
|
||||||
"load_failed": "Failed to load quota: {{message}}",
|
"load_failed": "Failed to load quota: {{message}}",
|
||||||
"missing_auth_index": "Auth file missing auth_index",
|
"missing_auth_index": "Auth file missing auth_index",
|
||||||
@@ -445,7 +444,7 @@
|
|||||||
"title": "Codex Quota",
|
"title": "Codex Quota",
|
||||||
"empty_title": "No Codex Auth Files",
|
"empty_title": "No Codex Auth Files",
|
||||||
"empty_desc": "Upload a Codex credential to view quota.",
|
"empty_desc": "Upload a Codex credential to view quota.",
|
||||||
"idle": "Not loaded. Click Refresh Button.",
|
"idle": "Click here to refresh quota",
|
||||||
"loading": "Loading quota...",
|
"loading": "Loading quota...",
|
||||||
"load_failed": "Failed to load quota: {{message}}",
|
"load_failed": "Failed to load quota: {{message}}",
|
||||||
"missing_auth_index": "Auth file missing auth_index",
|
"missing_auth_index": "Auth file missing auth_index",
|
||||||
@@ -467,7 +466,7 @@
|
|||||||
"title": "Gemini CLI Quota",
|
"title": "Gemini CLI Quota",
|
||||||
"empty_title": "No Gemini CLI Auth Files",
|
"empty_title": "No Gemini CLI Auth Files",
|
||||||
"empty_desc": "Upload a Gemini CLI credential to view remaining quota.",
|
"empty_desc": "Upload a Gemini CLI credential to view remaining quota.",
|
||||||
"idle": "Not loaded. Click Refresh Button.",
|
"idle": "Click here to refresh quota",
|
||||||
"loading": "Loading quota...",
|
"loading": "Loading quota...",
|
||||||
"load_failed": "Failed to load quota: {{message}}",
|
"load_failed": "Failed to load quota: {{message}}",
|
||||||
"missing_auth_index": "Auth file missing auth_index",
|
"missing_auth_index": "Auth file missing auth_index",
|
||||||
@@ -514,43 +513,43 @@
|
|||||||
"result_file": "Persisted file"
|
"result_file": "Persisted file"
|
||||||
},
|
},
|
||||||
"oauth_excluded": {
|
"oauth_excluded": {
|
||||||
"title": "OAuth Excluded Models",
|
"title": "OAuth Model Disablement",
|
||||||
"description": "Per-provider exclusions are shown as cards; click edit to adjust. Wildcards * are supported and the scope follows the auth file filter.",
|
"description": "Per-provider model disablement is shown as cards; click a card to edit or delete. Wildcards * are supported and the scope follows the auth file filter.",
|
||||||
"add": "Add Exclusion",
|
"add": "Add Disablement",
|
||||||
"add_title": "Add provider exclusion",
|
"add_title": "Add provider model disablement",
|
||||||
"edit_title": "Edit exclusions for {{provider}}",
|
"edit_title": "Edit model disablement for {{provider}}",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"refreshing": "Refreshing...",
|
"refreshing": "Refreshing...",
|
||||||
"provider_label": "Provider",
|
"provider_label": "Provider",
|
||||||
"provider_auto": "Follow current filter",
|
"provider_auto": "Follow current filter",
|
||||||
"provider_placeholder": "e.g. gemini-cli",
|
"provider_placeholder": "e.g. gemini-cli",
|
||||||
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
|
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
|
||||||
"models_label": "Models to exclude",
|
"models_label": "Models to disable",
|
||||||
"models_loading": "Loading models...",
|
"models_loading": "Loading models...",
|
||||||
"models_unsupported": "Current CPA version does not support fetching model lists.",
|
"models_unsupported": "Current CPA version does not support fetching model lists.",
|
||||||
"models_loaded": "{{count}} models loaded. Check the models to exclude.",
|
"models_loaded": "{{count}} models loaded. Check the models to disable.",
|
||||||
"no_models_available": "No models available for this provider.",
|
"no_models_available": "No models available for this provider.",
|
||||||
"save": "Save/Update",
|
"save": "Save/Update",
|
||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
"save_success": "Excluded models updated",
|
"save_success": "Model disablement updated",
|
||||||
"save_failed": "Failed to update excluded models",
|
"save_failed": "Failed to update model disablement",
|
||||||
"delete": "Delete Provider",
|
"delete": "Delete Provider",
|
||||||
"delete_confirm": "Delete the exclusion list for {{provider}}?",
|
"delete_confirm": "Delete model disablement for {{provider}}?",
|
||||||
"delete_success": "Exclusion list removed",
|
"delete_success": "Provider model disablement removed",
|
||||||
"delete_failed": "Failed to delete exclusion list",
|
"delete_failed": "Failed to delete model disablement",
|
||||||
"deleting": "Deleting...",
|
"deleting": "Deleting...",
|
||||||
"no_models": "No excluded models",
|
"no_models": "No disabled models configured",
|
||||||
"model_count": "{{count}} models excluded",
|
"model_count": "{{count}} models disabled",
|
||||||
"list_empty_all": "No exclusions yet—use “Add Exclusion” to create one.",
|
"list_empty_all": "No provider model disablement yet; click “Add Disablement” to create one.",
|
||||||
"list_empty_filtered": "No exclusions in this scope; click “Add Exclusion” to add.",
|
"list_empty_filtered": "No disabled items in this scope; click “Add Disablement” to add.",
|
||||||
"disconnected": "Connect to the server to view exclusions",
|
"disconnected": "Connect to the server to view model disablement",
|
||||||
"load_failed": "Failed to load exclusion list",
|
"load_failed": "Failed to load model disablement",
|
||||||
"provider_required": "Please enter a provider first",
|
"provider_required": "Please enter a provider first",
|
||||||
"scope_all": "Scope: All providers",
|
"scope_all": "Scope: All providers",
|
||||||
"scope_provider": "Scope: {{provider}}",
|
"scope_provider": "Scope: {{provider}}",
|
||||||
"upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.",
|
"upgrade_required": "Current CPA version does not support OAuth model disablement. Please upgrade.",
|
||||||
"upgrade_required_title": "Please upgrade CLI Proxy API",
|
"upgrade_required_title": "Please upgrade CLI Proxy API",
|
||||||
"upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version."
|
"upgrade_required_desc": "The current server version does not support fetching OAuth model disablement. Please upgrade to the latest CPA (CLI Proxy API) version and try again."
|
||||||
},
|
},
|
||||||
"oauth_model_alias": {
|
"oauth_model_alias": {
|
||||||
"title": "OAuth Model Aliases",
|
"title": "OAuth Model Aliases",
|
||||||
@@ -663,6 +662,17 @@
|
|||||||
"gemini_cli_oauth_status_error": "Authentication failed:",
|
"gemini_cli_oauth_status_error": "Authentication failed:",
|
||||||
"gemini_cli_oauth_start_error": "Failed to start Gemini CLI OAuth:",
|
"gemini_cli_oauth_start_error": "Failed to start Gemini CLI OAuth:",
|
||||||
"gemini_cli_oauth_polling_error": "Failed to check authentication status:",
|
"gemini_cli_oauth_polling_error": "Failed to check authentication status:",
|
||||||
|
"kimi_oauth_title": "Kimi OAuth",
|
||||||
|
"kimi_oauth_button": "Start Kimi Login",
|
||||||
|
"kimi_oauth_hint": "Login to Kimi service through OAuth device flow, automatically obtain and save authentication files.",
|
||||||
|
"kimi_oauth_url_label": "Authorization URL:",
|
||||||
|
"kimi_open_link": "Open Link",
|
||||||
|
"kimi_copy_link": "Copy Link",
|
||||||
|
"kimi_oauth_status_waiting": "Waiting for authentication...",
|
||||||
|
"kimi_oauth_status_success": "Authentication successful!",
|
||||||
|
"kimi_oauth_status_error": "Authentication failed:",
|
||||||
|
"kimi_oauth_start_error": "Failed to start Kimi OAuth:",
|
||||||
|
"kimi_oauth_polling_error": "Failed to check authentication status:",
|
||||||
"qwen_oauth_title": "Qwen OAuth",
|
"qwen_oauth_title": "Qwen OAuth",
|
||||||
"qwen_oauth_button": "Start Qwen Login",
|
"qwen_oauth_button": "Start Qwen Login",
|
||||||
"qwen_oauth_hint": "Login to Qwen service through device authorization flow, automatically obtain and save authentication files.",
|
"qwen_oauth_hint": "Login to Qwen service through device authorization flow, automatically obtain and save authentication files.",
|
||||||
@@ -919,14 +929,12 @@
|
|||||||
"debug": "Debug Mode",
|
"debug": "Debug Mode",
|
||||||
"debug_desc": "Enable verbose debug logging",
|
"debug_desc": "Enable verbose debug logging",
|
||||||
"commercial_mode": "Commercial Mode",
|
"commercial_mode": "Commercial Mode",
|
||||||
"commercial_mode_desc": "Disable high-overhead middleware to reduce memory under high concurrency",
|
"commercial_mode_desc": "Disable high-overhead middleware to support high concurrency",
|
||||||
"logging_to_file": "Log to File",
|
"logging_to_file": "Log to File",
|
||||||
"logging_to_file_desc": "Save logs to rotating files",
|
"logging_to_file_desc": "Save logs to files",
|
||||||
"usage_statistics": "Usage Statistics",
|
"usage_statistics": "Usage Statistics",
|
||||||
"usage_statistics_desc": "Collect usage statistics",
|
"usage_statistics_desc": "Collect usage statistics",
|
||||||
"logs_max_size": "Log File Size Limit (MB)",
|
"logs_max_size": "Log File Size Limit (MB)"
|
||||||
"usage_retention_days": "Usage Records Retention Days",
|
|
||||||
"usage_retention_hint": "0 means no limit (no cleanup)"
|
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"title": "Network Configuration",
|
"title": "Network Configuration",
|
||||||
@@ -1023,6 +1031,7 @@
|
|||||||
},
|
},
|
||||||
"system_info": {
|
"system_info": {
|
||||||
"title": "Management Center Info",
|
"title": "Management Center Info",
|
||||||
|
"about_title": "CLI Proxy API Management Center",
|
||||||
"connection_status_title": "Connection Status",
|
"connection_status_title": "Connection Status",
|
||||||
"api_status_label": "API Status:",
|
"api_status_label": "API Status:",
|
||||||
"config_status_label": "Config Status:",
|
"config_status_label": "Config Status:",
|
||||||
@@ -1127,12 +1136,15 @@
|
|||||||
"gemini_api_key": "Gemini API key",
|
"gemini_api_key": "Gemini API key",
|
||||||
"codex_api_key": "Codex API key",
|
"codex_api_key": "Codex API key",
|
||||||
"claude_api_key": "Claude API key",
|
"claude_api_key": "Claude API key",
|
||||||
|
"commercial_mode_restart_required": "Commercial mode setting changed. Please restart the service for it to take effect",
|
||||||
|
"copy_failed": "Copy failed",
|
||||||
"link_copied": "Link copied to clipboard"
|
"link_copied": "Link copied to clipboard"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"switch": "Language",
|
"switch": "Language",
|
||||||
"chinese": "中文",
|
"chinese": "中文",
|
||||||
"english": "English"
|
"english": "English",
|
||||||
|
"russian": "Русский"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"switch": "Theme",
|
"switch": "Theme",
|
||||||
|
|||||||
1130
src/i18n/locales/ru.json
Normal file
1130
src/i18n/locales/ru.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -380,6 +380,7 @@
|
|||||||
"filter_qwen": "Qwen",
|
"filter_qwen": "Qwen",
|
||||||
"filter_gemini": "Gemini",
|
"filter_gemini": "Gemini",
|
||||||
"filter_gemini-cli": "GeminiCLI",
|
"filter_gemini-cli": "GeminiCLI",
|
||||||
|
"filter_kimi": "Kimi",
|
||||||
"filter_aistudio": "AIStudio",
|
"filter_aistudio": "AIStudio",
|
||||||
"filter_claude": "Claude",
|
"filter_claude": "Claude",
|
||||||
"filter_codex": "Codex",
|
"filter_codex": "Codex",
|
||||||
@@ -392,6 +393,7 @@
|
|||||||
"type_qwen": "Qwen",
|
"type_qwen": "Qwen",
|
||||||
"type_gemini": "Gemini",
|
"type_gemini": "Gemini",
|
||||||
"type_gemini-cli": "GeminiCLI",
|
"type_gemini-cli": "GeminiCLI",
|
||||||
|
"type_kimi": "Kimi",
|
||||||
"type_aistudio": "AIStudio",
|
"type_aistudio": "AIStudio",
|
||||||
"type_claude": "Claude",
|
"type_claude": "Claude",
|
||||||
"type_codex": "Codex",
|
"type_codex": "Codex",
|
||||||
@@ -409,8 +411,8 @@
|
|||||||
"models_empty_desc": "该认证凭证可能尚未被服务器加载或没有绑定任何模型",
|
"models_empty_desc": "该认证凭证可能尚未被服务器加载或没有绑定任何模型",
|
||||||
"models_unsupported": "当前版本不支持此功能",
|
"models_unsupported": "当前版本不支持此功能",
|
||||||
"models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试",
|
"models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试",
|
||||||
"models_excluded_badge": "已排除",
|
"models_excluded_badge": "已禁用",
|
||||||
"models_excluded_hint": "此模型已被 OAuth 排除",
|
"models_excluded_hint": "此 OAuth 模型已被禁用",
|
||||||
"status_toggle_label": "启用",
|
"status_toggle_label": "启用",
|
||||||
"status_enabled_success": "已启用 \"{{name}}\"",
|
"status_enabled_success": "已启用 \"{{name}}\"",
|
||||||
"status_disabled_success": "已停用 \"{{name}}\"",
|
"status_disabled_success": "已停用 \"{{name}}\"",
|
||||||
@@ -423,9 +425,6 @@
|
|||||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||||
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
|
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
|
||||||
"prefix_proxy_saved_success": "已更新 \"{{name}}\"",
|
"prefix_proxy_saved_success": "已更新 \"{{name}}\"",
|
||||||
"card_tools_title": "配置面板",
|
|
||||||
"quota_refresh_single": "刷新额度",
|
|
||||||
"quota_refresh_hint": "仅刷新当前凭证的额度数据",
|
|
||||||
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
|
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
|
||||||
"quota_refresh_failed": "刷新 \"{{name}}\" 的额度失败:{{message}}"
|
"quota_refresh_failed": "刷新 \"{{name}}\" 的额度失败:{{message}}"
|
||||||
},
|
},
|
||||||
@@ -433,7 +432,7 @@
|
|||||||
"title": "Antigravity 额度",
|
"title": "Antigravity 额度",
|
||||||
"empty_title": "暂无 Antigravity 认证",
|
"empty_title": "暂无 Antigravity 认证",
|
||||||
"empty_desc": "上传 Antigravity 认证文件后即可查看额度。",
|
"empty_desc": "上传 Antigravity 认证文件后即可查看额度。",
|
||||||
"idle": "尚未加载额度,请点击刷新按钮。",
|
"idle": "点击此处刷新额度",
|
||||||
"loading": "正在加载额度...",
|
"loading": "正在加载额度...",
|
||||||
"load_failed": "额度获取失败:{{message}}",
|
"load_failed": "额度获取失败:{{message}}",
|
||||||
"missing_auth_index": "认证文件缺少 auth_index",
|
"missing_auth_index": "认证文件缺少 auth_index",
|
||||||
@@ -445,7 +444,7 @@
|
|||||||
"title": "Codex 额度",
|
"title": "Codex 额度",
|
||||||
"empty_title": "暂无 Codex 认证",
|
"empty_title": "暂无 Codex 认证",
|
||||||
"empty_desc": "上传 Codex 认证文件后即可查看额度。",
|
"empty_desc": "上传 Codex 认证文件后即可查看额度。",
|
||||||
"idle": "尚未加载额度,请点击刷新按钮。",
|
"idle": "点击此处刷新额度",
|
||||||
"loading": "正在加载额度...",
|
"loading": "正在加载额度...",
|
||||||
"load_failed": "额度获取失败:{{message}}",
|
"load_failed": "额度获取失败:{{message}}",
|
||||||
"missing_auth_index": "认证文件缺少 auth_index",
|
"missing_auth_index": "认证文件缺少 auth_index",
|
||||||
@@ -467,7 +466,7 @@
|
|||||||
"title": "Gemini CLI 额度",
|
"title": "Gemini CLI 额度",
|
||||||
"empty_title": "暂无 Gemini CLI 认证",
|
"empty_title": "暂无 Gemini CLI 认证",
|
||||||
"empty_desc": "上传 Gemini CLI 认证文件后即可查看额度。",
|
"empty_desc": "上传 Gemini CLI 认证文件后即可查看额度。",
|
||||||
"idle": "尚未加载额度,请点击刷新按钮。",
|
"idle": "点击此处刷新额度",
|
||||||
"loading": "正在加载额度...",
|
"loading": "正在加载额度...",
|
||||||
"load_failed": "额度获取失败:{{message}}",
|
"load_failed": "额度获取失败:{{message}}",
|
||||||
"missing_auth_index": "认证文件缺少 auth_index",
|
"missing_auth_index": "认证文件缺少 auth_index",
|
||||||
@@ -514,43 +513,43 @@
|
|||||||
"result_file": "存储文件"
|
"result_file": "存储文件"
|
||||||
},
|
},
|
||||||
"oauth_excluded": {
|
"oauth_excluded": {
|
||||||
"title": "OAuth 排除列表",
|
"title": "OAuth 模型禁用",
|
||||||
"description": "按提供商分列展示,点击卡片编辑或删除;支持 * 通配符,范围跟随上方的配置文件过滤标签。",
|
"description": "按提供商分列展示,点击卡片编辑或删除;支持 * 通配符,范围跟随上方的配置文件过滤标签。",
|
||||||
"add": "新增排除",
|
"add": "新增禁用",
|
||||||
"add_title": "新增提供商排除列表",
|
"add_title": "新增提供商模型禁用",
|
||||||
"edit_title": "编辑 {{provider}} 的排除列表",
|
"edit_title": "编辑 {{provider}} 的模型禁用",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"refreshing": "刷新中...",
|
"refreshing": "刷新中...",
|
||||||
"provider_label": "提供商",
|
"provider_label": "提供商",
|
||||||
"provider_auto": "跟随当前过滤",
|
"provider_auto": "跟随当前过滤",
|
||||||
"provider_placeholder": "例如 gemini-cli / openai",
|
"provider_placeholder": "例如 gemini-cli / openai",
|
||||||
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
|
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
|
||||||
"models_label": "排除的模型",
|
"models_label": "禁用的模型",
|
||||||
"models_loading": "正在加载模型列表...",
|
"models_loading": "正在加载模型列表...",
|
||||||
"models_unsupported": "当前 CPA 版本不支持获取模型列表。",
|
"models_unsupported": "当前 CPA 版本不支持获取模型列表。",
|
||||||
"models_loaded": "已加载 {{count}} 个模型,勾选要排除的模型。",
|
"models_loaded": "已加载 {{count}} 个模型,勾选要禁用的模型。",
|
||||||
"no_models_available": "该提供商暂无可用模型列表。",
|
"no_models_available": "该提供商暂无可用模型列表。",
|
||||||
"save": "保存/更新",
|
"save": "保存/更新",
|
||||||
"saving": "正在保存...",
|
"saving": "正在保存...",
|
||||||
"save_success": "排除列表已更新",
|
"save_success": "模型禁用已更新",
|
||||||
"save_failed": "更新排除列表失败",
|
"save_failed": "更新模型禁用失败",
|
||||||
"delete": "删除提供商",
|
"delete": "删除提供商",
|
||||||
"delete_confirm": "确定要删除 {{provider}} 的排除列表吗?",
|
"delete_confirm": "确定要删除 {{provider}} 的模型禁用吗?",
|
||||||
"delete_success": "已删除该提供商的排除列表",
|
"delete_success": "已删除该提供商的模型禁用",
|
||||||
"delete_failed": "删除排除列表失败",
|
"delete_failed": "删除模型禁用失败",
|
||||||
"deleting": "正在删除...",
|
"deleting": "正在删除...",
|
||||||
"no_models": "未配置排除模型",
|
"no_models": "未配置禁用模型",
|
||||||
"model_count": "排除 {{count}} 个模型",
|
"model_count": "禁用 {{count}} 个模型",
|
||||||
"list_empty_all": "暂无任何提供商的排除列表,点击“新增排除”创建。",
|
"list_empty_all": "暂无任何提供商的模型禁用,点击“新增禁用”创建。",
|
||||||
"list_empty_filtered": "当前筛选下没有排除项,点击“新增排除”添加。",
|
"list_empty_filtered": "当前筛选下没有禁用项,点击“新增禁用”添加。",
|
||||||
"disconnected": "请先连接服务器以查看排除列表",
|
"disconnected": "请先连接服务器以查看模型禁用",
|
||||||
"load_failed": "加载排除列表失败",
|
"load_failed": "加载模型禁用失败",
|
||||||
"provider_required": "请先填写提供商名称",
|
"provider_required": "请先填写提供商名称",
|
||||||
"scope_all": "当前范围:全局(显示所有提供商)",
|
"scope_all": "当前范围:全局(显示所有提供商)",
|
||||||
"scope_provider": "当前范围:{{provider}}",
|
"scope_provider": "当前范围:{{provider}}",
|
||||||
"upgrade_required": "当前 CPA 版本不支持模型排除列表,请升级 CPA 版本",
|
"upgrade_required": "当前 CPA 版本不支持 OAuth 模型禁用,请升级 CPA 版本",
|
||||||
"upgrade_required_title": "需要升级 CPA 版本",
|
"upgrade_required_title": "需要升级 CPA 版本",
|
||||||
"upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPA(CLI Proxy API)后重试。"
|
"upgrade_required_desc": "当前服务器版本不支持获取 OAuth 模型禁用功能,请升级到最新版本的 CPA(CLI Proxy API)后重试。"
|
||||||
},
|
},
|
||||||
"oauth_model_alias": {
|
"oauth_model_alias": {
|
||||||
"title": "OAuth 模型别名",
|
"title": "OAuth 模型别名",
|
||||||
@@ -663,6 +662,17 @@
|
|||||||
"gemini_cli_oauth_status_error": "认证失败:",
|
"gemini_cli_oauth_status_error": "认证失败:",
|
||||||
"gemini_cli_oauth_start_error": "启动 Gemini CLI OAuth 失败:",
|
"gemini_cli_oauth_start_error": "启动 Gemini CLI OAuth 失败:",
|
||||||
"gemini_cli_oauth_polling_error": "检查认证状态失败:",
|
"gemini_cli_oauth_polling_error": "检查认证状态失败:",
|
||||||
|
"kimi_oauth_title": "Kimi OAuth",
|
||||||
|
"kimi_oauth_button": "开始 Kimi 登录",
|
||||||
|
"kimi_oauth_hint": "通过设备授权流程登录 Kimi 服务,自动获取并保存认证文件。",
|
||||||
|
"kimi_oauth_url_label": "授权链接:",
|
||||||
|
"kimi_open_link": "打开链接",
|
||||||
|
"kimi_copy_link": "复制链接",
|
||||||
|
"kimi_oauth_status_waiting": "等待认证中...",
|
||||||
|
"kimi_oauth_status_success": "认证成功!",
|
||||||
|
"kimi_oauth_status_error": "认证失败:",
|
||||||
|
"kimi_oauth_start_error": "启动 Kimi OAuth 失败:",
|
||||||
|
"kimi_oauth_polling_error": "检查认证状态失败:",
|
||||||
"qwen_oauth_title": "Qwen OAuth",
|
"qwen_oauth_title": "Qwen OAuth",
|
||||||
"qwen_oauth_button": "开始 Qwen 登录",
|
"qwen_oauth_button": "开始 Qwen 登录",
|
||||||
"qwen_oauth_hint": "通过设备授权流程登录 Qwen 服务,自动获取并保存认证文件。",
|
"qwen_oauth_hint": "通过设备授权流程登录 Qwen 服务,自动获取并保存认证文件。",
|
||||||
@@ -919,14 +929,12 @@
|
|||||||
"debug": "调试模式",
|
"debug": "调试模式",
|
||||||
"debug_desc": "启用详细的调试日志",
|
"debug_desc": "启用详细的调试日志",
|
||||||
"commercial_mode": "商业模式",
|
"commercial_mode": "商业模式",
|
||||||
"commercial_mode_desc": "禁用高开销中间件以减少高并发内存",
|
"commercial_mode_desc": "禁用高开销中间件以支持高并发",
|
||||||
"logging_to_file": "写入日志文件",
|
"logging_to_file": "写入日志文件",
|
||||||
"logging_to_file_desc": "将日志保存到滚动文件",
|
"logging_to_file_desc": "将日志保存到文件",
|
||||||
"usage_statistics": "使用统计",
|
"usage_statistics": "使用统计",
|
||||||
"usage_statistics_desc": "收集使用统计信息",
|
"usage_statistics_desc": "收集使用统计信息",
|
||||||
"logs_max_size": "日志文件大小限制 (MB)",
|
"logs_max_size": "日志文件大小限制 (MB)"
|
||||||
"usage_retention_days": "使用记录保留天数",
|
|
||||||
"usage_retention_hint": "0 为无限制(不清理)"
|
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"title": "网络配置",
|
"title": "网络配置",
|
||||||
@@ -1023,6 +1031,7 @@
|
|||||||
},
|
},
|
||||||
"system_info": {
|
"system_info": {
|
||||||
"title": "管理中心信息",
|
"title": "管理中心信息",
|
||||||
|
"about_title": "CLI Proxy API Management Center",
|
||||||
"connection_status_title": "连接状态",
|
"connection_status_title": "连接状态",
|
||||||
"api_status_label": "API 状态:",
|
"api_status_label": "API 状态:",
|
||||||
"config_status_label": "配置状态:",
|
"config_status_label": "配置状态:",
|
||||||
@@ -1127,12 +1136,15 @@
|
|||||||
"gemini_api_key": "Gemini API密钥",
|
"gemini_api_key": "Gemini API密钥",
|
||||||
"codex_api_key": "Codex API密钥",
|
"codex_api_key": "Codex API密钥",
|
||||||
"claude_api_key": "Claude API密钥",
|
"claude_api_key": "Claude API密钥",
|
||||||
|
"commercial_mode_restart_required": "商业模式开关已变更,请重启服务后生效",
|
||||||
|
"copy_failed": "复制失败",
|
||||||
"link_copied": "已复制"
|
"link_copied": "已复制"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"switch": "语言",
|
"switch": "语言",
|
||||||
"chinese": "中文",
|
"chinese": "中文",
|
||||||
"english": "English"
|
"english": "English",
|
||||||
|
"russian": "Русский"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"switch": "主题",
|
"switch": "主题",
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
setTestStatus('idle');
|
setTestStatus('idle');
|
||||||
setTestMessage('');
|
setTestMessage('');
|
||||||
}
|
}
|
||||||
}, [availableModels, loading, testModel]);
|
}, [availableModels, loading, setTestMessage, setTestModel, setTestStatus, testModel]);
|
||||||
|
|
||||||
const mergeDiscoveredModels = useCallback(
|
const mergeDiscoveredModels = useCallback(
|
||||||
(selectedModels: ModelInfo[]) => {
|
(selectedModels: ModelInfo[]) => {
|
||||||
@@ -280,12 +280,20 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
|
const name = form.name.trim();
|
||||||
|
const baseUrl = form.baseUrl.trim();
|
||||||
|
|
||||||
|
if (!name || !baseUrl) {
|
||||||
|
showNotification(t('notification.openai_provider_required'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload: OpenAIProviderConfig = {
|
const payload: OpenAIProviderConfig = {
|
||||||
name: form.name.trim(),
|
name,
|
||||||
prefix: form.prefix?.trim() || undefined,
|
prefix: form.prefix?.trim() || undefined,
|
||||||
baseUrl: form.baseUrl.trim(),
|
baseUrl,
|
||||||
headers: buildHeaderObject(form.headers),
|
headers: buildHeaderObject(form.headers),
|
||||||
apiKeyEntries: form.apiKeyEntries.map((entry: ApiKeyEntry) => ({
|
apiKeyEntries: form.apiKeyEntries.map((entry: ApiKeyEntry) => ({
|
||||||
apiKey: entry.apiKey.trim(),
|
apiKey: entry.apiKey.trim(),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const OAUTH_PROVIDER_PRESETS = [
|
|||||||
'claude',
|
'claude',
|
||||||
'codex',
|
'codex',
|
||||||
'qwen',
|
'qwen',
|
||||||
|
'kimi',
|
||||||
'iflow',
|
'iflow',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const OAUTH_PROVIDER_PRESETS = [
|
|||||||
'claude',
|
'claude',
|
||||||
'codex',
|
'codex',
|
||||||
'qwen',
|
'qwen',
|
||||||
|
'kimi',
|
||||||
'iflow',
|
'iflow',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -185,10 +185,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fileGridQuotaManaged {
|
.fileGridQuotaManaged {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(520px, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
|
||||||
@include tablet {
|
@include tablet {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
@include mobile {
|
@include mobile {
|
||||||
@@ -414,6 +414,24 @@
|
|||||||
padding: $spacing-sm 0;
|
padding: $spacing-sm 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quotaMessageAction {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.quotaError {
|
.quotaError {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--danger-color);
|
color: var(--danger-color);
|
||||||
@@ -487,17 +505,6 @@
|
|||||||
gap: $spacing-md;
|
gap: $spacing-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileCardLayoutQuota {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 156px;
|
|
||||||
gap: $spacing-md;
|
|
||||||
align-items: stretch;
|
|
||||||
|
|
||||||
@include mobile {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileCardMain {
|
.fileCardMain {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -506,41 +513,6 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileCardSidebar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $spacing-sm;
|
|
||||||
padding-left: $spacing-md;
|
|
||||||
border-left: 1px dashed var(--border-color);
|
|
||||||
|
|
||||||
@include mobile {
|
|
||||||
border-left: none;
|
|
||||||
border-top: 1px dashed var(--border-color);
|
|
||||||
padding-left: 0;
|
|
||||||
padding-top: $spacing-md;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileCardSidebarHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: $spacing-xs;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileCardSidebarTitle {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileCardSidebarHint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardHeader {
|
.cardHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -813,7 +785,7 @@
|
|||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth 排除列表
|
// OAuth 模型禁用
|
||||||
.excludedList {
|
.excludedList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -861,7 +833,7 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth 排除列表表单:提供商快捷标签
|
// OAuth 模型禁用表单:提供商快捷标签
|
||||||
.providerField {
|
.providerField {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Trans, useTranslation } from 'react-i18next';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useInterval } from '@/hooks/useInterval';
|
import { useInterval } from '@/hooks/useInterval';
|
||||||
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
|
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
|
||||||
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 { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||||
@@ -17,7 +18,6 @@ import {
|
|||||||
IconChevronUp,
|
IconChevronUp,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconInfo,
|
IconInfo,
|
||||||
IconRefreshCw,
|
|
||||||
IconTrash2,
|
IconTrash2,
|
||||||
} from '@/components/ui/icons';
|
} from '@/components/ui/icons';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
@@ -49,6 +49,10 @@ const TYPE_COLORS: Record<string, TypeColorSet> = {
|
|||||||
light: { bg: '#e8f5e9', text: '#2e7d32' },
|
light: { bg: '#e8f5e9', text: '#2e7d32' },
|
||||||
dark: { bg: '#1b5e20', text: '#81c784' },
|
dark: { bg: '#1b5e20', text: '#81c784' },
|
||||||
},
|
},
|
||||||
|
kimi: {
|
||||||
|
light: { bg: '#fff4e5', text: '#ad6800' },
|
||||||
|
dark: { bg: '#7c4a03', text: '#ffd591' },
|
||||||
|
},
|
||||||
gemini: {
|
gemini: {
|
||||||
light: { bg: '#e3f2fd', text: '#1565c0' },
|
light: { bg: '#e3f2fd', text: '#1565c0' },
|
||||||
dark: { bg: '#0d47a1', text: '#64b5f6' },
|
dark: { bg: '#0d47a1', text: '#64b5f6' },
|
||||||
@@ -248,6 +252,8 @@ export function AuthFilesPage() {
|
|||||||
const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota);
|
const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota);
|
||||||
const setCodexQuota = useQuotaStore((state) => state.setCodexQuota);
|
const setCodexQuota = useQuotaStore((state) => state.setCodexQuota);
|
||||||
const setGeminiCliQuota = useQuotaStore((state) => state.setGeminiCliQuota);
|
const setGeminiCliQuota = useQuotaStore((state) => state.setGeminiCliQuota);
|
||||||
|
const pageTransitionLayer = usePageTransitionLayer();
|
||||||
|
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
||||||
@@ -504,7 +510,7 @@ export function AuthFilesPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 加载 OAuth 排除列表
|
// 加载 OAuth 模型禁用
|
||||||
const loadExcluded = useCallback(async () => {
|
const loadExcluded = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await authFilesApi.getOauthExcludedModels();
|
const res = await authFilesApi.getOauthExcludedModels();
|
||||||
@@ -563,14 +569,15 @@ export function AuthFilesPage() {
|
|||||||
useHeaderRefresh(handleHeaderRefresh);
|
useHeaderRefresh(handleHeaderRefresh);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isCurrentLayer) return;
|
||||||
loadFiles();
|
loadFiles();
|
||||||
loadKeyStats();
|
loadKeyStats();
|
||||||
loadExcluded();
|
loadExcluded();
|
||||||
loadModelAlias();
|
loadModelAlias();
|
||||||
}, [loadFiles, loadKeyStats, loadExcluded, loadModelAlias]);
|
}, [isCurrentLayer, loadFiles, loadKeyStats, loadExcluded, loadModelAlias]);
|
||||||
|
|
||||||
// 定时刷新状态数据(每240秒)
|
// 定时刷新状态数据(每240秒)
|
||||||
useInterval(loadKeyStats, 240_000);
|
useInterval(loadKeyStats, isCurrentLayer ? 240_000 : null);
|
||||||
|
|
||||||
// 提取所有存在的类型
|
// 提取所有存在的类型
|
||||||
const existingTypes = useMemo(() => {
|
const existingTypes = useMemo(() => {
|
||||||
@@ -1468,11 +1475,14 @@ export function AuthFilesPage() {
|
|||||||
return GEMINI_CLI_CONFIG;
|
return GEMINI_CLI_CONFIG;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getQuotaState = (type: QuotaProviderType, fileName: string) => {
|
const getQuotaState = useCallback(
|
||||||
if (type === 'antigravity') return antigravityQuota[fileName];
|
(type: QuotaProviderType, fileName: string) => {
|
||||||
if (type === 'codex') return codexQuota[fileName];
|
if (type === 'antigravity') return antigravityQuota[fileName];
|
||||||
return geminiCliQuota[fileName];
|
if (type === 'codex') return codexQuota[fileName];
|
||||||
};
|
return geminiCliQuota[fileName];
|
||||||
|
},
|
||||||
|
[antigravityQuota, codexQuota, geminiCliQuota]
|
||||||
|
);
|
||||||
|
|
||||||
const updateQuotaState = useCallback(
|
const updateQuotaState = useCallback(
|
||||||
(
|
(
|
||||||
@@ -1547,6 +1557,7 @@ export function AuthFilesPage() {
|
|||||||
| { status?: string; error?: string; errorStatus?: number }
|
| { status?: string; error?: string; errorStatus?: number }
|
||||||
| undefined;
|
| undefined;
|
||||||
const quotaStatus = quota?.status ?? 'idle';
|
const quotaStatus = quota?.status ?? 'idle';
|
||||||
|
const canRefreshQuota = !disableControls && !item.disabled;
|
||||||
const quotaErrorMessage = resolveQuotaErrorMessage(
|
const quotaErrorMessage = resolveQuotaErrorMessage(
|
||||||
t,
|
t,
|
||||||
quota?.errorStatus,
|
quota?.errorStatus,
|
||||||
@@ -1558,7 +1569,14 @@ export function AuthFilesPage() {
|
|||||||
{quotaStatus === 'loading' ? (
|
{quotaStatus === 'loading' ? (
|
||||||
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.loading`)}</div>
|
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.loading`)}</div>
|
||||||
) : quotaStatus === 'idle' ? (
|
) : quotaStatus === 'idle' ? (
|
||||||
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.idle`)}</div>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.quotaMessage} ${styles.quotaMessageAction}`}
|
||||||
|
onClick={() => void refreshQuotaForFile(item, quotaType)}
|
||||||
|
disabled={!canRefreshQuota}
|
||||||
|
>
|
||||||
|
{t(`${config.i18nPrefix}.idle`)}
|
||||||
|
</button>
|
||||||
) : quotaStatus === 'error' ? (
|
) : quotaStatus === 'error' ? (
|
||||||
<div className={styles.quotaError}>
|
<div className={styles.quotaError}>
|
||||||
{t(`${config.i18nPrefix}.load_failed`, {
|
{t(`${config.i18nPrefix}.load_failed`, {
|
||||||
@@ -1586,8 +1604,6 @@ export function AuthFilesPage() {
|
|||||||
quotaFilterType && resolveQuotaType(item) === quotaFilterType ? quotaFilterType : null;
|
quotaFilterType && resolveQuotaType(item) === quotaFilterType ? quotaFilterType : null;
|
||||||
|
|
||||||
const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly;
|
const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly;
|
||||||
const quotaState = quotaType ? getQuotaState(quotaType, item.name) : undefined;
|
|
||||||
const quotaRefreshing = quotaState?.status === 'loading';
|
|
||||||
|
|
||||||
const providerCardClass =
|
const providerCardClass =
|
||||||
quotaType === 'antigravity'
|
quotaType === 'antigravity'
|
||||||
@@ -1604,7 +1620,7 @@ export function AuthFilesPage() {
|
|||||||
className={`${styles.fileCard} ${providerCardClass} ${item.disabled ? styles.fileCardDisabled : ''}`}
|
className={`${styles.fileCard} ${providerCardClass} ${item.disabled ? styles.fileCardDisabled : ''}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`${styles.fileCardLayout} ${showQuotaLayout ? styles.fileCardLayoutQuota : ''}`}
|
className={styles.fileCardLayout}
|
||||||
>
|
>
|
||||||
<div className={styles.fileCardMain}>
|
<div className={styles.fileCardMain}>
|
||||||
<div className={styles.cardHeader}>
|
<div className={styles.cardHeader}>
|
||||||
@@ -1722,29 +1738,6 @@ export function AuthFilesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showQuotaLayout && quotaType && (
|
|
||||||
<div className={styles.fileCardSidebar}>
|
|
||||||
<div className={styles.fileCardSidebarHeader}>
|
|
||||||
<span className={styles.fileCardSidebarTitle}>
|
|
||||||
{t('auth_files.card_tools_title')}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
className={styles.iconButton}
|
|
||||||
onClick={() => void refreshQuotaForFile(item, quotaType)}
|
|
||||||
disabled={disableControls || item.disabled}
|
|
||||||
loading={quotaRefreshing}
|
|
||||||
title={t('auth_files.quota_refresh_single')}
|
|
||||||
aria-label={t('auth_files.quota_refresh_single')}
|
|
||||||
>
|
|
||||||
{!quotaRefreshing && <IconRefreshCw className={styles.actionIcon} size={16} />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className={styles.fileCardSidebarHint}>{t('auth_files.quota_refresh_hint')}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1886,7 +1879,7 @@ export function AuthFilesPage() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* OAuth 排除列表卡片 */}
|
{/* OAuth 模型禁用卡片 */}
|
||||||
<Card
|
<Card
|
||||||
title={t('oauth_excluded.title')}
|
title={t('oauth_excluded.title')}
|
||||||
extra={
|
extra={
|
||||||
@@ -2120,7 +2113,7 @@ export function AuthFilesPage() {
|
|||||||
title={
|
title={
|
||||||
isExcluded
|
isExcluded
|
||||||
? t('auth_files.models_excluded_hint', {
|
? t('auth_files.models_excluded_hint', {
|
||||||
defaultValue: '此模型已被 OAuth 排除',
|
defaultValue: '此 OAuth 模型已被禁用',
|
||||||
})
|
})
|
||||||
: t('common.copy', { defaultValue: '点击复制' })
|
: t('common.copy', { defaultValue: '点击复制' })
|
||||||
}
|
}
|
||||||
@@ -2132,7 +2125,7 @@ export function AuthFilesPage() {
|
|||||||
{model.type && <span className={styles.modelType}>{model.type}</span>}
|
{model.type && <span className={styles.modelType}>{model.type}</span>}
|
||||||
{isExcluded && (
|
{isExcluded && (
|
||||||
<span className={styles.modelExcludedBadge}>
|
<span className={styles.modelExcludedBadge}>
|
||||||
{t('auth_files.models_excluded_badge', { defaultValue: '已排除' })}
|
{t('auth_files.models_excluded_badge', { defaultValue: '已禁用' })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
|||||||
import { yaml } from '@codemirror/lang-yaml';
|
import { yaml } from '@codemirror/lang-yaml';
|
||||||
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||||
import { keymap } from '@codemirror/view';
|
import { keymap } from '@codemirror/view';
|
||||||
|
import { parse as parseYaml } from 'yaml';
|
||||||
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';
|
||||||
@@ -17,6 +18,16 @@ import styles from './ConfigPage.module.scss';
|
|||||||
|
|
||||||
type ConfigEditorTab = 'visual' | 'source';
|
type ConfigEditorTab = 'visual' | 'source';
|
||||||
|
|
||||||
|
function readCommercialModeFromYaml(yamlContent: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = parseYaml(yamlContent);
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
|
||||||
|
return Boolean((parsed as Record<string, unknown>)['commercial-mode']);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ConfigPage() {
|
export function ConfigPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
@@ -78,12 +89,19 @@ export function ConfigPage() {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
|
const previousCommercialMode = readCommercialModeFromYaml(content);
|
||||||
const nextContent = activeTab === 'visual' ? applyVisualChangesToYaml(content) : content;
|
const nextContent = activeTab === 'visual' ? applyVisualChangesToYaml(content) : content;
|
||||||
|
const nextCommercialMode = readCommercialModeFromYaml(nextContent);
|
||||||
|
const commercialModeChanged = previousCommercialMode !== nextCommercialMode;
|
||||||
await configFileApi.saveConfigYaml(nextContent);
|
await configFileApi.saveConfigYaml(nextContent);
|
||||||
|
const latestContent = await configFileApi.fetchConfigYaml();
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
setContent(nextContent);
|
setContent(latestContent);
|
||||||
loadVisualValuesFromYaml(nextContent);
|
loadVisualValuesFromYaml(latestContent);
|
||||||
showNotification(t('config_management.save_success'), 'success');
|
showNotification(t('config_management.save_success'), 'success');
|
||||||
|
if (commercialModeChanged) {
|
||||||
|
showNotification(t('notification.commercial_mode_restart_required'), 'warning');
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : '';
|
const message = err instanceof Error ? err.message : '';
|
||||||
showNotification(`${t('notification.save_failed')}: ${message}`, 'error');
|
showNotification(`${t('notification.save_failed')}: ${message}`, 'error');
|
||||||
|
|||||||
@@ -62,14 +62,23 @@ export function DashboardPage() {
|
|||||||
apiKeysCache.current = [];
|
apiKeysCache.current = [];
|
||||||
}, [apiBase, config?.apiKeys]);
|
}, [apiBase, config?.apiKeys]);
|
||||||
|
|
||||||
const normalizeApiKeyList = (input: any): string[] => {
|
const normalizeApiKeyList = (input: unknown): string[] => {
|
||||||
if (!Array.isArray(input)) return [];
|
if (!Array.isArray(input)) return [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
|
|
||||||
input.forEach((item) => {
|
input.forEach((item) => {
|
||||||
const value = typeof item === 'string' ? item : item?.['api-key'] ?? item?.apiKey ?? '';
|
const record =
|
||||||
const trimmed = String(value || '').trim();
|
item !== null && typeof item === 'object' && !Array.isArray(item)
|
||||||
|
? (item as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const value =
|
||||||
|
typeof item === 'string'
|
||||||
|
? item
|
||||||
|
: record
|
||||||
|
? (record['api-key'] ?? record['apiKey'] ?? record.key ?? record.Key)
|
||||||
|
: '';
|
||||||
|
const trimmed = String(value ?? '').trim();
|
||||||
if (!trimmed || seen.has(trimmed)) return;
|
if (!trimmed || seen.has(trimmed)) return;
|
||||||
seen.add(trimmed);
|
seen.add(trimmed);
|
||||||
keys.push(trimmed);
|
keys.push(trimmed);
|
||||||
|
|||||||
@@ -167,9 +167,24 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 语言切换按钮
|
// 语言下拉选择
|
||||||
.languageBtn {
|
.languageSelect {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
height: 40px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 连接信息框
|
// 连接信息框
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { Input } from '@/components/ui/Input';
|
|||||||
import { IconEye, IconEyeOff } from '@/components/ui/icons';
|
import { IconEye, IconEyeOff } from '@/components/ui/icons';
|
||||||
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
|
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
|
||||||
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
||||||
|
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
|
||||||
|
import { isSupportedLanguage } from '@/utils/language';
|
||||||
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||||
import type { ApiError } from '@/types';
|
import type { ApiError } from '@/types';
|
||||||
import styles from './LoginPage.module.scss';
|
import styles from './LoginPage.module.scss';
|
||||||
@@ -13,11 +15,20 @@ import styles from './LoginPage.module.scss';
|
|||||||
/**
|
/**
|
||||||
* 将 API 错误转换为本地化的用户友好消息
|
* 将 API 错误转换为本地化的用户友好消息
|
||||||
*/
|
*/
|
||||||
function getLocalizedErrorMessage(error: any, t: (key: string) => string): string {
|
type RedirectState = { from?: { pathname?: string } };
|
||||||
const apiError = error as ApiError;
|
|
||||||
const status = apiError?.status;
|
function getLocalizedErrorMessage(error: unknown, t: (key: string) => string): string {
|
||||||
const code = apiError?.code;
|
const apiError = error as Partial<ApiError>;
|
||||||
const message = apiError?.message || '';
|
const status = typeof apiError.status === 'number' ? apiError.status : undefined;
|
||||||
|
const code = typeof apiError.code === 'string' ? apiError.code : undefined;
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: typeof apiError.message === 'string'
|
||||||
|
? apiError.message
|
||||||
|
: typeof error === 'string'
|
||||||
|
? error
|
||||||
|
: '';
|
||||||
|
|
||||||
// 根据 HTTP 状态码判断
|
// 根据 HTTP 状态码判断
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
@@ -59,7 +70,7 @@ export function LoginPage() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
const language = useLanguageStore((state) => state.language);
|
const language = useLanguageStore((state) => state.language);
|
||||||
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
|
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
||||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
const login = useAuthStore((state) => state.login);
|
const login = useAuthStore((state) => state.login);
|
||||||
const restoreSession = useAuthStore((state) => state.restoreSession);
|
const restoreSession = useAuthStore((state) => state.restoreSession);
|
||||||
@@ -78,7 +89,16 @@ export function LoginPage() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
|
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
|
||||||
const nextLanguageLabel = language === 'zh-CN' ? t('language.english') : t('language.chinese');
|
const handleLanguageChange = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const selectedLanguage = event.target.value;
|
||||||
|
if (!isSupportedLanguage(selectedLanguage)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLanguage(selectedLanguage);
|
||||||
|
},
|
||||||
|
[setLanguage]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
@@ -88,7 +108,7 @@ export function LoginPage() {
|
|||||||
setAutoLoginSuccess(true);
|
setAutoLoginSuccess(true);
|
||||||
// 延迟跳转,让用户看到成功动画
|
// 延迟跳转,让用户看到成功动画
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const redirect = (location.state as any)?.from?.pathname || '/';
|
const redirect = (location.state as RedirectState | null)?.from?.pathname || '/';
|
||||||
navigate(redirect, { replace: true });
|
navigate(redirect, { replace: true });
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} else {
|
} else {
|
||||||
@@ -124,7 +144,7 @@ export function LoginPage() {
|
|||||||
});
|
});
|
||||||
showNotification(t('common.connected_status'), 'success');
|
showNotification(t('common.connected_status'), 'success');
|
||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
const message = getLocalizedErrorMessage(err, t);
|
const message = getLocalizedErrorMessage(err, t);
|
||||||
setError(message);
|
setError(message);
|
||||||
showNotification(`${t('notification.login_failed')}: ${message}`, 'error');
|
showNotification(`${t('notification.login_failed')}: ${message}`, 'error');
|
||||||
@@ -144,7 +164,7 @@ export function LoginPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (isAuthenticated && !autoLoading && !autoLoginSuccess) {
|
if (isAuthenticated && !autoLoading && !autoLoginSuccess) {
|
||||||
const redirect = (location.state as any)?.from?.pathname || '/';
|
const redirect = (location.state as RedirectState | null)?.from?.pathname || '/';
|
||||||
return <Navigate to={redirect} replace />;
|
return <Navigate to={redirect} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,17 +205,19 @@ export function LoginPage() {
|
|||||||
<div className={styles.loginHeader}>
|
<div className={styles.loginHeader}>
|
||||||
<div className={styles.titleRow}>
|
<div className={styles.titleRow}>
|
||||||
<div className={styles.title}>{t('title.login')}</div>
|
<div className={styles.title}>{t('title.login')}</div>
|
||||||
<Button
|
<select
|
||||||
type="button"
|
className={styles.languageSelect}
|
||||||
variant="ghost"
|
value={language}
|
||||||
size="sm"
|
onChange={handleLanguageChange}
|
||||||
className={styles.languageBtn}
|
|
||||||
onClick={toggleLanguage}
|
|
||||||
title={t('language.switch')}
|
title={t('language.switch')}
|
||||||
aria-label={t('language.switch')}
|
aria-label={t('language.switch')}
|
||||||
>
|
>
|
||||||
{nextLanguageLabel}
|
{LANGUAGE_ORDER.map((lang) => (
|
||||||
</Button>
|
<option key={lang} value={lang}>
|
||||||
|
{t(LANGUAGE_LABEL_KEYS[lang])}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.subtitle}>{t('login.subtitle')}</div>
|
<div className={styles.subtitle}>{t('login.subtitle')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import iconCodexDark from '@/assets/icons/codex_drak.svg';
|
|||||||
import iconClaude from '@/assets/icons/claude.svg';
|
import iconClaude from '@/assets/icons/claude.svg';
|
||||||
import iconAntigravity from '@/assets/icons/antigravity.svg';
|
import iconAntigravity from '@/assets/icons/antigravity.svg';
|
||||||
import iconGemini from '@/assets/icons/gemini.svg';
|
import iconGemini from '@/assets/icons/gemini.svg';
|
||||||
|
import iconKimiLight from '@/assets/icons/kimi-light.svg';
|
||||||
|
import iconKimiDark from '@/assets/icons/kimi-dark.svg';
|
||||||
import iconQwen from '@/assets/icons/qwen.svg';
|
import iconQwen from '@/assets/icons/qwen.svg';
|
||||||
import iconIflow from '@/assets/icons/iflow.svg';
|
import iconIflow from '@/assets/icons/iflow.svg';
|
||||||
import iconVertex from '@/assets/icons/vertex.svg';
|
import iconVertex from '@/assets/icons/vertex.svg';
|
||||||
@@ -55,6 +57,21 @@ interface VertexImportState {
|
|||||||
result?: VertexImportResult;
|
result?: VertexImportResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === 'object';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
if (isRecord(error) && typeof error.message === 'string') return error.message;
|
||||||
|
return typeof error === 'string' ? error : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorStatus(error: unknown): number | undefined {
|
||||||
|
if (!isRecord(error)) return undefined;
|
||||||
|
return typeof error.status === 'number' ? error.status : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
interface KiroOAuthState {
|
interface KiroOAuthState {
|
||||||
method: 'builder-id' | 'idc' | null;
|
method: 'builder-id' | 'idc' | null;
|
||||||
startUrl: string;
|
startUrl: string;
|
||||||
@@ -73,6 +90,7 @@ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabe
|
|||||||
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
|
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
|
||||||
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity },
|
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity },
|
||||||
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini },
|
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini },
|
||||||
|
{ id: 'kimi', titleKey: 'auth_login.kimi_oauth_title', hintKey: 'auth_login.kimi_oauth_hint', urlLabelKey: 'auth_login.kimi_oauth_url_label', icon: { light: iconKimiLight, dark: iconKimiDark } },
|
||||||
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label', icon: iconQwen }
|
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label', icon: iconQwen }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -147,8 +165,8 @@ export function OAuthPage() {
|
|||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
delete timers.current[provider];
|
delete timers.current[provider];
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
|
updateProviderState(provider, { status: 'error', error: getErrorMessage(err), polling: false });
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
delete timers.current[provider];
|
delete timers.current[provider];
|
||||||
}
|
}
|
||||||
@@ -179,9 +197,13 @@ export function OAuthPage() {
|
|||||||
if (res.state) {
|
if (res.state) {
|
||||||
startPolling(provider, res.state);
|
startPolling(provider, res.state);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
|
const message = getErrorMessage(err);
|
||||||
showNotification(`${t(getAuthKey(provider, 'oauth_start_error'))} ${err?.message || ''}`, 'error');
|
updateProviderState(provider, { status: 'error', error: message, polling: false });
|
||||||
|
showNotification(
|
||||||
|
`${t(getAuthKey(provider, 'oauth_start_error'))}${message ? ` ${message}` : ''}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -210,13 +232,15 @@ export function OAuthPage() {
|
|||||||
await oauthApi.submitCallback(provider, redirectUrl);
|
await oauthApi.submitCallback(provider, redirectUrl);
|
||||||
updateProviderState(provider, { callbackSubmitting: false, callbackStatus: 'success' });
|
updateProviderState(provider, { callbackSubmitting: false, callbackStatus: 'success' });
|
||||||
showNotification(t('auth_login.oauth_callback_success'), 'success');
|
showNotification(t('auth_login.oauth_callback_success'), 'success');
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
|
const status = getErrorStatus(err);
|
||||||
|
const message = getErrorMessage(err);
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
err?.status === 404
|
status === 404
|
||||||
? t('auth_login.oauth_callback_upgrade_hint', {
|
? t('auth_login.oauth_callback_upgrade_hint', {
|
||||||
defaultValue: 'Please update CLI Proxy API or check the connection.'
|
defaultValue: 'Please update CLI Proxy API or check the connection.'
|
||||||
})
|
})
|
||||||
: err?.message;
|
: message || undefined;
|
||||||
updateProviderState(provider, {
|
updateProviderState(provider, {
|
||||||
callbackSubmitting: false,
|
callbackSubmitting: false,
|
||||||
callbackStatus: 'error',
|
callbackStatus: 'error',
|
||||||
@@ -256,15 +280,19 @@ export function OAuthPage() {
|
|||||||
}));
|
}));
|
||||||
showNotification(`${t('auth_login.iflow_cookie_status_error')} ${res.error || ''}`, 'error');
|
showNotification(`${t('auth_login.iflow_cookie_status_error')} ${res.error || ''}`, 'error');
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
if (err?.status === 409) {
|
if (getErrorStatus(err) === 409) {
|
||||||
const message = t('auth_login.iflow_cookie_config_duplicate');
|
const message = t('auth_login.iflow_cookie_config_duplicate');
|
||||||
setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'warning' }));
|
setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'warning' }));
|
||||||
showNotification(message, 'warning');
|
showNotification(message, 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIflowCookie((prev) => ({ ...prev, loading: false, error: err?.message, errorType: 'error' }));
|
const message = getErrorMessage(err);
|
||||||
showNotification(`${t('auth_login.iflow_cookie_start_error')} ${err?.message || ''}`, 'error');
|
setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'error' }));
|
||||||
|
showNotification(
|
||||||
|
`${t('auth_login.iflow_cookie_start_error')}${message ? ` ${message}` : ''}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -312,8 +340,8 @@ export function OAuthPage() {
|
|||||||
};
|
};
|
||||||
setVertexState((prev) => ({ ...prev, loading: false, result }));
|
setVertexState((prev) => ({ ...prev, loading: false, result }));
|
||||||
showNotification(t('vertex_import.success'), 'success');
|
showNotification(t('vertex_import.success'), 'success');
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
const message = err?.message || '';
|
const message = getErrorMessage(err);
|
||||||
setVertexState((prev) => ({
|
setVertexState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|||||||
@@ -15,6 +15,108 @@
|
|||||||
gap: $spacing-xl;
|
gap: $spacing-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aboutCard {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aboutHeader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
gap: $spacing-md;
|
||||||
|
padding: $spacing-lg 0 $spacing-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aboutLogo {
|
||||||
|
width: 108px;
|
||||||
|
height: 108px;
|
||||||
|
border-radius: 26px;
|
||||||
|
object-fit: cover;
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aboutTitle {
|
||||||
|
width: min(100%, 920px);
|
||||||
|
font-size: clamp(28px, 4.2vw, 44px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.12;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
text-align: center;
|
||||||
|
text-wrap: balance;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aboutInfoGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: $spacing-md;
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoTile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 120px;
|
||||||
|
padding: $spacing-md $spacing-lg;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tapTile {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
|
||||||
|
color: inherit;
|
||||||
|
padding: $spacing-md $spacing-lg;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.18s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 8px 18px rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileValue {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.25;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileSub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aboutActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -231,3 +333,29 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.aboutLogo {
|
||||||
|
width: 92px;
|
||||||
|
height: 92px;
|
||||||
|
border-radius: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aboutTitle {
|
||||||
|
width: min(100%, 24ch);
|
||||||
|
font-size: clamp(22px, 6.6vw, 34px);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.18;
|
||||||
|
letter-spacing: -0.012em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.aboutTitle {
|
||||||
|
width: min(100%, 19ch);
|
||||||
|
font-size: clamp(20px, 7.2vw, 28px);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.22;
|
||||||
|
letter-spacing: -0.006em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
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 { Modal } from '@/components/ui/Modal';
|
||||||
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/components/ui/icons';
|
import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/components/ui/icons';
|
||||||
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore, useThemeStore } from '@/stores';
|
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore, useThemeStore } from '@/stores';
|
||||||
|
import { configApi } from '@/services/api';
|
||||||
import { apiKeysApi } from '@/services/api/apiKeys';
|
import { apiKeysApi } from '@/services/api/apiKeys';
|
||||||
import { classifyModels } from '@/utils/models';
|
import { classifyModels } from '@/utils/models';
|
||||||
import { STORAGE_KEY_AUTH } from '@/utils/constants';
|
import { STORAGE_KEY_AUTH } from '@/utils/constants';
|
||||||
|
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||||
import iconGemini from '@/assets/icons/gemini.svg';
|
import iconGemini from '@/assets/icons/gemini.svg';
|
||||||
import iconClaude from '@/assets/icons/claude.svg';
|
import iconClaude from '@/assets/icons/claude.svg';
|
||||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||||
@@ -39,6 +43,8 @@ export function SystemPage() {
|
|||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const config = useConfigStore((state) => state.config);
|
const config = useConfigStore((state) => state.config);
|
||||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||||
|
const clearCache = useConfigStore((state) => state.clearCache);
|
||||||
|
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||||
|
|
||||||
const models = useModelsStore((state) => state.models);
|
const models = useModelsStore((state) => state.models);
|
||||||
const modelsLoading = useModelsStore((state) => state.loading);
|
const modelsLoading = useModelsStore((state) => state.loading);
|
||||||
@@ -46,14 +52,29 @@ export function SystemPage() {
|
|||||||
const fetchModelsFromStore = useModelsStore((state) => state.fetchModels);
|
const fetchModelsFromStore = useModelsStore((state) => state.fetchModels);
|
||||||
|
|
||||||
const [modelStatus, setModelStatus] = useState<{ type: 'success' | 'warning' | 'error' | 'muted'; message: string }>();
|
const [modelStatus, setModelStatus] = useState<{ type: 'success' | 'warning' | 'error' | 'muted'; message: string }>();
|
||||||
|
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
|
||||||
|
const [requestLogDraft, setRequestLogDraft] = useState(false);
|
||||||
|
const [requestLogTouched, setRequestLogTouched] = useState(false);
|
||||||
|
const [requestLogSaving, setRequestLogSaving] = useState(false);
|
||||||
|
|
||||||
const apiKeysCache = useRef<string[]>([]);
|
const apiKeysCache = useRef<string[]>([]);
|
||||||
|
const versionTapCount = useRef(0);
|
||||||
|
const versionTapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const otherLabel = useMemo(
|
const otherLabel = useMemo(
|
||||||
() => (i18n.language?.toLowerCase().startsWith('zh') ? '其他' : 'Other'),
|
() => (i18n.language?.toLowerCase().startsWith('zh') ? '其他' : 'Other'),
|
||||||
[i18n.language]
|
[i18n.language]
|
||||||
);
|
);
|
||||||
const groupedModels = useMemo(() => classifyModels(models, { otherLabel }), [models, otherLabel]);
|
const groupedModels = useMemo(() => classifyModels(models, { otherLabel }), [models, otherLabel]);
|
||||||
|
const requestLogEnabled = config?.requestLog ?? false;
|
||||||
|
const requestLogDirty = requestLogDraft !== requestLogEnabled;
|
||||||
|
const canEditRequestLog = auth.connectionStatus === 'connected' && Boolean(config);
|
||||||
|
|
||||||
|
const appVersion = __APP_VERSION__ || t('system_info.version_unknown');
|
||||||
|
const apiVersion = auth.serverVersion || t('system_info.version_unknown');
|
||||||
|
const buildTime = auth.serverBuildDate
|
||||||
|
? new Date(auth.serverBuildDate).toLocaleString(i18n.language)
|
||||||
|
: t('system_info.version_unknown');
|
||||||
|
|
||||||
const getIconForCategory = (categoryId: string): string | null => {
|
const getIconForCategory = (categoryId: string): string | null => {
|
||||||
const iconEntry = MODEL_CATEGORY_ICONS[categoryId];
|
const iconEntry = MODEL_CATEGORY_ICONS[categoryId];
|
||||||
@@ -62,14 +83,23 @@ export function SystemPage() {
|
|||||||
return resolvedTheme === 'dark' ? iconEntry.dark : iconEntry.light;
|
return resolvedTheme === 'dark' ? iconEntry.dark : iconEntry.light;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeApiKeyList = (input: any): string[] => {
|
const normalizeApiKeyList = (input: unknown): string[] => {
|
||||||
if (!Array.isArray(input)) return [];
|
if (!Array.isArray(input)) return [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
|
|
||||||
input.forEach((item) => {
|
input.forEach((item) => {
|
||||||
const value = typeof item === 'string' ? item : item?.['api-key'] ?? item?.apiKey ?? '';
|
const record =
|
||||||
const trimmed = String(value || '').trim();
|
item !== null && typeof item === 'object' && !Array.isArray(item)
|
||||||
|
? (item as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const value =
|
||||||
|
typeof item === 'string'
|
||||||
|
? item
|
||||||
|
: record
|
||||||
|
? (record['api-key'] ?? record['apiKey'] ?? record.key ?? record.Key)
|
||||||
|
: '';
|
||||||
|
const trimmed = String(value ?? '').trim();
|
||||||
if (!trimmed || seen.has(trimmed)) return;
|
if (!trimmed || seen.has(trimmed)) return;
|
||||||
seen.add(trimmed);
|
seen.add(trimmed);
|
||||||
keys.push(trimmed);
|
keys.push(trimmed);
|
||||||
@@ -130,9 +160,12 @@ export function SystemPage() {
|
|||||||
type: hasModels ? 'success' : 'warning',
|
type: hasModels ? 'success' : 'warning',
|
||||||
message: hasModels ? t('system_info.models_count', { count: list.length }) : t('system_info.models_empty')
|
message: hasModels ? t('system_info.models_count', { count: list.length }) : t('system_info.models_empty')
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
const message = `${t('system_info.models_error')}: ${err?.message || ''}`;
|
const message =
|
||||||
setModelStatus({ type: 'error', message });
|
err instanceof Error ? err.message : typeof err === 'string' ? err : '';
|
||||||
|
const suffix = message ? `: ${message}` : '';
|
||||||
|
const text = `${t('system_info.models_error')}${suffix}`;
|
||||||
|
setModelStatus({ type: 'error', message: text });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,12 +185,85 @@ export function SystemPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openRequestLogModal = useCallback(() => {
|
||||||
|
setRequestLogTouched(false);
|
||||||
|
setRequestLogDraft(requestLogEnabled);
|
||||||
|
setRequestLogModalOpen(true);
|
||||||
|
}, [requestLogEnabled]);
|
||||||
|
|
||||||
|
const handleInfoVersionTap = useCallback(() => {
|
||||||
|
versionTapCount.current += 1;
|
||||||
|
if (versionTapTimer.current) {
|
||||||
|
clearTimeout(versionTapTimer.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versionTapCount.current >= 7) {
|
||||||
|
versionTapCount.current = 0;
|
||||||
|
versionTapTimer.current = null;
|
||||||
|
openRequestLogModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
versionTapTimer.current = setTimeout(() => {
|
||||||
|
versionTapCount.current = 0;
|
||||||
|
versionTapTimer.current = null;
|
||||||
|
}, 1500);
|
||||||
|
}, [openRequestLogModal]);
|
||||||
|
|
||||||
|
const handleRequestLogClose = useCallback(() => {
|
||||||
|
setRequestLogModalOpen(false);
|
||||||
|
setRequestLogTouched(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRequestLogSave = async () => {
|
||||||
|
if (!canEditRequestLog) return;
|
||||||
|
if (!requestLogDirty) {
|
||||||
|
setRequestLogModalOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = requestLogEnabled;
|
||||||
|
setRequestLogSaving(true);
|
||||||
|
updateConfigValue('request-log', requestLogDraft);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await configApi.updateRequestLog(requestLogDraft);
|
||||||
|
clearCache('request-log');
|
||||||
|
showNotification(t('notification.request_log_updated'), 'success');
|
||||||
|
setRequestLogModalOpen(false);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : typeof error === 'string' ? error : '';
|
||||||
|
updateConfigValue('request-log', previous);
|
||||||
|
showNotification(
|
||||||
|
`${t('notification.update_failed')}${message ? `: ${message}` : ''}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setRequestLogSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfig().catch(() => {
|
fetchConfig().catch(() => {
|
||||||
// ignore
|
// ignore
|
||||||
});
|
});
|
||||||
}, [fetchConfig]);
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (requestLogModalOpen && !requestLogTouched) {
|
||||||
|
setRequestLogDraft(requestLogEnabled);
|
||||||
|
}
|
||||||
|
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (versionTapTimer.current) {
|
||||||
|
clearTimeout(versionTapTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchModels();
|
fetchModels();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -167,33 +273,43 @@ export function SystemPage() {
|
|||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<h1 className={styles.pageTitle}>{t('system_info.title')}</h1>
|
<h1 className={styles.pageTitle}>{t('system_info.title')}</h1>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<Card
|
<Card className={styles.aboutCard}>
|
||||||
title={t('system_info.connection_status_title')}
|
<div className={styles.aboutHeader}>
|
||||||
extra={
|
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className={styles.aboutLogo} />
|
||||||
|
<div className={styles.aboutTitle}>{t('system_info.about_title')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.aboutInfoGrid}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.infoTile} ${styles.tapTile}`}
|
||||||
|
onClick={handleInfoVersionTap}
|
||||||
|
>
|
||||||
|
<div className={styles.tileLabel}>{t('footer.version')}</div>
|
||||||
|
<div className={styles.tileValue}>{appVersion}</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={styles.infoTile}>
|
||||||
|
<div className={styles.tileLabel}>{t('footer.api_version')}</div>
|
||||||
|
<div className={styles.tileValue}>{apiVersion}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.infoTile}>
|
||||||
|
<div className={styles.tileLabel}>{t('footer.build_date')}</div>
|
||||||
|
<div className={styles.tileValue}>{buildTime}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.infoTile}>
|
||||||
|
<div className={styles.tileLabel}>{t('connection.status')}</div>
|
||||||
|
<div className={styles.tileValue}>{t(`common.${auth.connectionStatus}_status`)}</div>
|
||||||
|
<div className={styles.tileSub}>{auth.apiBase || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.aboutActions}>
|
||||||
<Button variant="secondary" size="sm" onClick={() => fetchConfig(undefined, true)}>
|
<Button variant="secondary" size="sm" onClick={() => fetchConfig(undefined, true)}>
|
||||||
{t('common.refresh')}
|
{t('common.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="grid cols-2">
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">{t('connection.server_address')}</div>
|
|
||||||
<div className="stat-value">{auth.apiBase || '-'}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">{t('footer.api_version')}</div>
|
|
||||||
<div className="stat-value">{auth.serverVersion || t('system_info.version_unknown')}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">{t('footer.build_date')}</div>
|
|
||||||
<div className="stat-value">
|
|
||||||
{auth.serverBuildDate ? new Date(auth.serverBuildDate).toLocaleString() : t('system_info.version_unknown')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">{t('connection.status')}</div>
|
|
||||||
<div className="stat-value">{t(`common.${auth.connectionStatus}_status` as any)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -312,6 +428,40 @@ export function SystemPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={requestLogModalOpen}
|
||||||
|
onClose={handleRequestLogClose}
|
||||||
|
title={t('basic_settings.request_log_title')}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={handleRequestLogClose} disabled={requestLogSaving}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleRequestLogSave}
|
||||||
|
loading={requestLogSaving}
|
||||||
|
disabled={!canEditRequestLog || !requestLogDirty}
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="request-log-modal">
|
||||||
|
<div className="status-badge warning">{t('basic_settings.request_log_warning')}</div>
|
||||||
|
<ToggleSwitch
|
||||||
|
label={t('basic_settings.request_log_enable')}
|
||||||
|
labelPosition="left"
|
||||||
|
checked={requestLogDraft}
|
||||||
|
disabled={!canEditRequestLog || requestLogSaving}
|
||||||
|
onChange={(value) => {
|
||||||
|
setRequestLogDraft(value);
|
||||||
|
setRequestLogTouched(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const ampcodeApi = {
|
|||||||
clearUpstreamApiKey: () => apiClient.delete('/ampcode/upstream-api-key'),
|
clearUpstreamApiKey: () => apiClient.delete('/ampcode/upstream-api-key'),
|
||||||
|
|
||||||
async getModelMappings(): Promise<AmpcodeModelMapping[]> {
|
async getModelMappings(): Promise<AmpcodeModelMapping[]> {
|
||||||
const data = await apiClient.get('/ampcode/model-mappings');
|
const data = await apiClient.get<Record<string, unknown>>('/ampcode/model-mappings');
|
||||||
const list = data?.['model-mappings'] ?? data?.modelMappings ?? data?.items ?? data;
|
const list = data?.['model-mappings'] ?? data?.modelMappings ?? data?.items ?? data;
|
||||||
return normalizeAmpcodeModelMappings(list);
|
return normalizeAmpcodeModelMappings(list);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ export interface ApiCallRequest {
|
|||||||
data?: string;
|
data?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiCallResult<T = any> {
|
export interface ApiCallResult<T = unknown> {
|
||||||
statusCode: number;
|
statusCode: number;
|
||||||
header: Record<string, string[]>;
|
header: Record<string, string[]>;
|
||||||
bodyText: string;
|
bodyText: string;
|
||||||
body: T | null;
|
body: T | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeBody = (input: unknown): { bodyText: string; body: any | null } => {
|
const normalizeBody = (input: unknown): { bodyText: string; body: unknown | null } => {
|
||||||
if (input === undefined || input === null) {
|
if (input === undefined || input === null) {
|
||||||
return { bodyText: '', body: null };
|
return { bodyText: '', body: null };
|
||||||
}
|
}
|
||||||
@@ -46,13 +46,24 @@ const normalizeBody = (input: unknown): { bodyText: string; body: any | null } =
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getApiCallErrorMessage = (result: ApiCallResult): string => {
|
export const getApiCallErrorMessage = (result: ApiCallResult): string => {
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
value !== null && typeof value === 'object';
|
||||||
|
|
||||||
const status = result.statusCode;
|
const status = result.statusCode;
|
||||||
const body = result.body;
|
const body = result.body;
|
||||||
const bodyText = result.bodyText;
|
const bodyText = result.bodyText;
|
||||||
let message = '';
|
let message = '';
|
||||||
|
|
||||||
if (body && typeof body === 'object') {
|
if (isRecord(body)) {
|
||||||
message = body?.error?.message || body?.error || body?.message || '';
|
const errorValue = body.error;
|
||||||
|
if (isRecord(errorValue) && typeof errorValue.message === 'string') {
|
||||||
|
message = errorValue.message;
|
||||||
|
} else if (typeof errorValue === 'string') {
|
||||||
|
message = errorValue;
|
||||||
|
}
|
||||||
|
if (!message && typeof body.message === 'string') {
|
||||||
|
message = body.message;
|
||||||
|
}
|
||||||
} else if (typeof body === 'string') {
|
} else if (typeof body === 'string') {
|
||||||
message = body;
|
message = body;
|
||||||
}
|
}
|
||||||
@@ -71,7 +82,7 @@ export const apiCallApi = {
|
|||||||
payload: ApiCallRequest,
|
payload: ApiCallRequest,
|
||||||
config?: AxiosRequestConfig
|
config?: AxiosRequestConfig
|
||||||
): Promise<ApiCallResult> => {
|
): Promise<ApiCallResult> => {
|
||||||
const response = await apiClient.post('/api-call', payload, config);
|
const response = await apiClient.post<Record<string, unknown>>('/api-call', payload, config);
|
||||||
const statusCode = Number(response?.status_code ?? response?.statusCode ?? 0);
|
const statusCode = Number(response?.status_code ?? response?.statusCode ?? 0);
|
||||||
const header = (response?.header ?? response?.headers ?? {}) as Record<string, string[]>;
|
const header = (response?.header ?? response?.headers ?? {}) as Record<string, string[]>;
|
||||||
const { bodyText, body } = normalizeBody(response?.body);
|
const { bodyText, body } = normalizeBody(response?.body);
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { apiClient } from './client';
|
|||||||
|
|
||||||
export const apiKeysApi = {
|
export const apiKeysApi = {
|
||||||
async list(): Promise<string[]> {
|
async list(): Promise<string[]> {
|
||||||
const data = await apiClient.get('/api-keys');
|
const data = await apiClient.get<Record<string, unknown>>('/api-keys');
|
||||||
const keys = (data && (data['api-keys'] ?? data.apiKeys)) as unknown;
|
const keys = data['api-keys'] ?? data.apiKeys;
|
||||||
return Array.isArray(keys) ? (keys as string[]) : [];
|
return Array.isArray(keys) ? keys.map((key) => String(key)) : [];
|
||||||
},
|
},
|
||||||
|
|
||||||
replace: (keys: string[]) => apiClient.put('/api-keys', keys),
|
replace: (keys: string[]) => apiClient.put('/api-keys', keys),
|
||||||
|
|||||||
@@ -171,15 +171,25 @@ export const authFilesApi = {
|
|||||||
|
|
||||||
// 获取认证凭证支持的模型
|
// 获取认证凭证支持的模型
|
||||||
async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
|
async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
|
||||||
const data = await apiClient.get(`/auth-files/models?name=${encodeURIComponent(name)}`);
|
const data = await apiClient.get<Record<string, unknown>>(
|
||||||
return (data && Array.isArray(data['models'])) ? data['models'] : [];
|
`/auth-files/models?name=${encodeURIComponent(name)}`
|
||||||
|
);
|
||||||
|
const models = data.models ?? data['models'];
|
||||||
|
return Array.isArray(models)
|
||||||
|
? (models as { id: string; display_name?: string; type?: string; owned_by?: string }[])
|
||||||
|
: [];
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取指定 channel 的模型定义
|
// 获取指定 channel 的模型定义
|
||||||
async getModelDefinitions(channel: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
|
async getModelDefinitions(channel: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
|
||||||
const normalizedChannel = String(channel ?? '').trim().toLowerCase();
|
const normalizedChannel = String(channel ?? '').trim().toLowerCase();
|
||||||
if (!normalizedChannel) return [];
|
if (!normalizedChannel) return [];
|
||||||
const data = await apiClient.get(`/model-definitions/${encodeURIComponent(normalizedChannel)}`);
|
const data = await apiClient.get<Record<string, unknown>>(
|
||||||
return (data && Array.isArray(data['models'])) ? data['models'] : [];
|
`/model-definitions/${encodeURIComponent(normalizedChannel)}`
|
||||||
|
);
|
||||||
|
const models = data.models ?? data['models'];
|
||||||
|
return Array.isArray(models)
|
||||||
|
? (models as { id: string; display_name?: string; type?: string; owned_by?: string }[])
|
||||||
|
: [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -62,7 +62,10 @@ class ApiClient {
|
|||||||
return `${normalized}${MANAGEMENT_API_PREFIX}`;
|
return `${normalized}${MANAGEMENT_API_PREFIX}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readHeader(headers: Record<string, any> | undefined, keys: string[]): string | null {
|
private readHeader(
|
||||||
|
headers: Record<string, unknown> | undefined,
|
||||||
|
keys: string[]
|
||||||
|
): string | null {
|
||||||
if (!headers) return null;
|
if (!headers) return null;
|
||||||
|
|
||||||
const normalizeValue = (value: unknown): string | null => {
|
const normalizeValue = (value: unknown): string | null => {
|
||||||
@@ -75,7 +78,7 @@ class ApiClient {
|
|||||||
return text ? text : null;
|
return text ? text : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const headerGetter = (headers as { get?: (name: string) => any }).get;
|
const headerGetter = (headers as { get?: (name: string) => unknown }).get;
|
||||||
if (typeof headerGetter === 'function') {
|
if (typeof headerGetter === 'function') {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const match = normalizeValue(headerGetter.call(headers, key));
|
const match = normalizeValue(headerGetter.call(headers, key));
|
||||||
@@ -84,8 +87,8 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entries =
|
const entries =
|
||||||
typeof (headers as { entries?: () => Iterable<[string, any]> }).entries === 'function'
|
typeof (headers as { entries?: () => Iterable<[string, unknown]> }).entries === 'function'
|
||||||
? Array.from((headers as { entries: () => Iterable<[string, any]> }).entries())
|
? Array.from((headers as { entries: () => Iterable<[string, unknown]> }).entries())
|
||||||
: Object.entries(headers);
|
: Object.entries(headers);
|
||||||
|
|
||||||
const normalized = Object.fromEntries(
|
const normalized = Object.fromEntries(
|
||||||
@@ -147,10 +150,22 @@ class ApiClient {
|
|||||||
/**
|
/**
|
||||||
* 错误处理
|
* 错误处理
|
||||||
*/
|
*/
|
||||||
private handleError(error: any): ApiError {
|
private handleError(error: unknown): ApiError {
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
value !== null && typeof value === 'object';
|
||||||
|
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
const responseData = error.response?.data as any;
|
const responseData: unknown = error.response?.data;
|
||||||
const message = responseData?.error || responseData?.message || error.message || 'Request failed';
|
const responseRecord = isRecord(responseData) ? responseData : null;
|
||||||
|
const errorValue = responseRecord?.error;
|
||||||
|
const message =
|
||||||
|
typeof errorValue === 'string'
|
||||||
|
? errorValue
|
||||||
|
: isRecord(errorValue) && typeof errorValue.message === 'string'
|
||||||
|
? errorValue.message
|
||||||
|
: typeof responseRecord?.message === 'string'
|
||||||
|
? responseRecord.message
|
||||||
|
: error.message || 'Request failed';
|
||||||
const apiError = new Error(message) as ApiError;
|
const apiError = new Error(message) as ApiError;
|
||||||
apiError.name = 'ApiError';
|
apiError.name = 'ApiError';
|
||||||
apiError.status = error.response?.status;
|
apiError.status = error.response?.status;
|
||||||
@@ -166,7 +181,9 @@ class ApiClient {
|
|||||||
return apiError;
|
return apiError;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallback = new Error(error?.message || 'Unknown error occurred') as ApiError;
|
const fallbackMessage =
|
||||||
|
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error occurred';
|
||||||
|
const fallback = new Error(fallbackMessage) as ApiError;
|
||||||
fallback.name = 'ApiError';
|
fallback.name = 'ApiError';
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
@@ -174,7 +191,7 @@ class ApiClient {
|
|||||||
/**
|
/**
|
||||||
* GET 请求
|
* GET 请求
|
||||||
*/
|
*/
|
||||||
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
async get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||||
const response = await this.instance.get<T>(url, config);
|
const response = await this.instance.get<T>(url, config);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@@ -182,7 +199,7 @@ class ApiClient {
|
|||||||
/**
|
/**
|
||||||
* POST 请求
|
* POST 请求
|
||||||
*/
|
*/
|
||||||
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
async post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||||||
const response = await this.instance.post<T>(url, data, config);
|
const response = await this.instance.post<T>(url, data, config);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@@ -190,7 +207,7 @@ class ApiClient {
|
|||||||
/**
|
/**
|
||||||
* PUT 请求
|
* PUT 请求
|
||||||
*/
|
*/
|
||||||
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
async put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||||||
const response = await this.instance.put<T>(url, data, config);
|
const response = await this.instance.put<T>(url, data, config);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@@ -198,7 +215,7 @@ class ApiClient {
|
|||||||
/**
|
/**
|
||||||
* PATCH 请求
|
* PATCH 请求
|
||||||
*/
|
*/
|
||||||
async patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
async patch<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||||||
const response = await this.instance.patch<T>(url, data, config);
|
const response = await this.instance.patch<T>(url, data, config);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@@ -206,7 +223,7 @@ class ApiClient {
|
|||||||
/**
|
/**
|
||||||
* DELETE 请求
|
* DELETE 请求
|
||||||
*/
|
*/
|
||||||
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
async delete<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||||
const response = await this.instance.delete<T>(url, config);
|
const response = await this.instance.delete<T>(url, config);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@@ -221,7 +238,11 @@ class ApiClient {
|
|||||||
/**
|
/**
|
||||||
* 发送 FormData
|
* 发送 FormData
|
||||||
*/
|
*/
|
||||||
async postForm<T = any>(url: string, formData: FormData, config?: AxiosRequestConfig): Promise<T> {
|
async postForm<T = unknown>(
|
||||||
|
url: string,
|
||||||
|
formData: FormData,
|
||||||
|
config?: AxiosRequestConfig
|
||||||
|
): Promise<T> {
|
||||||
const response = await this.instance.post<T>(url, formData, {
|
const response = await this.instance.post<T>(url, formData, {
|
||||||
...config,
|
...config,
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -72,8 +72,10 @@ export const configApi = {
|
|||||||
* 获取日志总大小上限(MB)
|
* 获取日志总大小上限(MB)
|
||||||
*/
|
*/
|
||||||
async getLogsMaxTotalSizeMb(): Promise<number> {
|
async getLogsMaxTotalSizeMb(): Promise<number> {
|
||||||
const data = await apiClient.get('/logs-max-total-size-mb');
|
const data = await apiClient.get<Record<string, unknown>>('/logs-max-total-size-mb');
|
||||||
return data?.['logs-max-total-size-mb'] ?? data?.logsMaxTotalSizeMb ?? 0;
|
const value = data?.['logs-max-total-size-mb'] ?? data?.logsMaxTotalSizeMb ?? 0;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,8 +93,8 @@ export const configApi = {
|
|||||||
* 获取强制模型前缀开关
|
* 获取强制模型前缀开关
|
||||||
*/
|
*/
|
||||||
async getForceModelPrefix(): Promise<boolean> {
|
async getForceModelPrefix(): Promise<boolean> {
|
||||||
const data = await apiClient.get('/force-model-prefix');
|
const data = await apiClient.get<Record<string, unknown>>('/force-model-prefix');
|
||||||
return data?.['force-model-prefix'] ?? data?.forceModelPrefix ?? false;
|
return Boolean(data?.['force-model-prefix'] ?? data?.forceModelPrefix ?? false);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,8 +106,9 @@ export const configApi = {
|
|||||||
* 获取路由策略
|
* 获取路由策略
|
||||||
*/
|
*/
|
||||||
async getRoutingStrategy(): Promise<string> {
|
async getRoutingStrategy(): Promise<string> {
|
||||||
const data = await apiClient.get('/routing/strategy');
|
const data = await apiClient.get<Record<string, unknown>>('/routing/strategy');
|
||||||
return data?.strategy ?? data?.['routing-strategy'] ?? data?.routingStrategy ?? 'round-robin';
|
const strategy = data?.strategy ?? data?.['routing-strategy'] ?? data?.routingStrategy;
|
||||||
|
return typeof strategy === 'string' ? strategy : 'round-robin';
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const configFileApi = {
|
|||||||
responseType: 'text',
|
responseType: 'text',
|
||||||
headers: { Accept: 'application/yaml, text/yaml, text/plain' }
|
headers: { Accept: 'application/yaml, text/yaml, text/plain' }
|
||||||
});
|
});
|
||||||
const data = response.data as any;
|
const data: unknown = response.data;
|
||||||
if (typeof data === 'string') return data;
|
if (typeof data === 'string') return data;
|
||||||
if (data === undefined || data === null) return '';
|
if (data === undefined || data === null) return '';
|
||||||
return String(data);
|
return String(data);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type OAuthProvider =
|
|||||||
| 'anthropic'
|
| 'anthropic'
|
||||||
| 'antigravity'
|
| 'antigravity'
|
||||||
| 'gemini-cli'
|
| 'gemini-cli'
|
||||||
|
| 'kimi'
|
||||||
| 'qwen';
|
| 'qwen';
|
||||||
|
|
||||||
export interface OAuthStartResponse {
|
export interface OAuthStartResponse {
|
||||||
|
|||||||
@@ -18,12 +18,22 @@ import type {
|
|||||||
|
|
||||||
const serializeHeaders = (headers?: Record<string, string>) => (headers && Object.keys(headers).length ? headers : undefined);
|
const serializeHeaders = (headers?: Record<string, string>) => (headers && Object.keys(headers).length ? headers : undefined);
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
|
||||||
|
const extractArrayPayload = (data: unknown, key: string): unknown[] => {
|
||||||
|
if (Array.isArray(data)) return data;
|
||||||
|
if (!isRecord(data)) return [];
|
||||||
|
const candidate = data[key] ?? data.items ?? data.data ?? data;
|
||||||
|
return Array.isArray(candidate) ? candidate : [];
|
||||||
|
};
|
||||||
|
|
||||||
const serializeModelAliases = (models?: ModelAlias[]) =>
|
const serializeModelAliases = (models?: ModelAlias[]) =>
|
||||||
Array.isArray(models)
|
Array.isArray(models)
|
||||||
? models
|
? models
|
||||||
.map((model) => {
|
.map((model) => {
|
||||||
if (!model?.name) return null;
|
if (!model?.name) return null;
|
||||||
const payload: Record<string, any> = { name: model.name };
|
const payload: Record<string, unknown> = { name: model.name };
|
||||||
if (model.alias && model.alias !== model.name) {
|
if (model.alias && model.alias !== model.name) {
|
||||||
payload.alias = model.alias;
|
payload.alias = model.alias;
|
||||||
}
|
}
|
||||||
@@ -39,7 +49,7 @@ const serializeModelAliases = (models?: ModelAlias[]) =>
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const serializeApiKeyEntry = (entry: ApiKeyEntry) => {
|
const serializeApiKeyEntry = (entry: ApiKeyEntry) => {
|
||||||
const payload: Record<string, any> = { 'api-key': entry.apiKey };
|
const payload: Record<string, unknown> = { 'api-key': entry.apiKey };
|
||||||
if (entry.proxyUrl) payload['proxy-url'] = entry.proxyUrl;
|
if (entry.proxyUrl) payload['proxy-url'] = entry.proxyUrl;
|
||||||
const headers = serializeHeaders(entry.headers);
|
const headers = serializeHeaders(entry.headers);
|
||||||
if (headers) payload.headers = headers;
|
if (headers) payload.headers = headers;
|
||||||
@@ -47,7 +57,7 @@ const serializeApiKeyEntry = (entry: ApiKeyEntry) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const serializeProviderKey = (config: ProviderKeyConfig) => {
|
const serializeProviderKey = (config: ProviderKeyConfig) => {
|
||||||
const payload: Record<string, any> = { 'api-key': config.apiKey };
|
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
|
||||||
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
|
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
|
||||||
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
||||||
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
|
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
|
||||||
@@ -74,7 +84,7 @@ const serializeVertexModelAliases = (models?: ModelAlias[]) =>
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const serializeVertexKey = (config: ProviderKeyConfig) => {
|
const serializeVertexKey = (config: ProviderKeyConfig) => {
|
||||||
const payload: Record<string, any> = { 'api-key': config.apiKey };
|
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
|
||||||
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
|
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
|
||||||
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
||||||
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
|
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
|
||||||
@@ -86,7 +96,7 @@ const serializeVertexKey = (config: ProviderKeyConfig) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const serializeGeminiKey = (config: GeminiKeyConfig) => {
|
const serializeGeminiKey = (config: GeminiKeyConfig) => {
|
||||||
const payload: Record<string, any> = { 'api-key': config.apiKey };
|
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
|
||||||
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
|
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
|
||||||
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
||||||
const headers = serializeHeaders(config.headers);
|
const headers = serializeHeaders(config.headers);
|
||||||
@@ -98,7 +108,7 @@ const serializeGeminiKey = (config: GeminiKeyConfig) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const serializeOpenAIProvider = (provider: OpenAIProviderConfig) => {
|
const serializeOpenAIProvider = (provider: OpenAIProviderConfig) => {
|
||||||
const payload: Record<string, any> = {
|
const payload: Record<string, unknown> = {
|
||||||
name: provider.name,
|
name: provider.name,
|
||||||
'base-url': provider.baseUrl,
|
'base-url': provider.baseUrl,
|
||||||
'api-key-entries': Array.isArray(provider.apiKeyEntries)
|
'api-key-entries': Array.isArray(provider.apiKeyEntries)
|
||||||
@@ -118,8 +128,7 @@ const serializeOpenAIProvider = (provider: OpenAIProviderConfig) => {
|
|||||||
export const providersApi = {
|
export const providersApi = {
|
||||||
async getGeminiKeys(): Promise<GeminiKeyConfig[]> {
|
async getGeminiKeys(): Promise<GeminiKeyConfig[]> {
|
||||||
const data = await apiClient.get('/gemini-api-key');
|
const data = await apiClient.get('/gemini-api-key');
|
||||||
const list = (data && (data['gemini-api-key'] ?? data.items ?? data)) as any;
|
const list = extractArrayPayload(data, 'gemini-api-key');
|
||||||
if (!Array.isArray(list)) return [];
|
|
||||||
return list.map((item) => normalizeGeminiKeyConfig(item)).filter(Boolean) as GeminiKeyConfig[];
|
return list.map((item) => normalizeGeminiKeyConfig(item)).filter(Boolean) as GeminiKeyConfig[];
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -134,8 +143,7 @@ export const providersApi = {
|
|||||||
|
|
||||||
async getCodexConfigs(): Promise<ProviderKeyConfig[]> {
|
async getCodexConfigs(): Promise<ProviderKeyConfig[]> {
|
||||||
const data = await apiClient.get('/codex-api-key');
|
const data = await apiClient.get('/codex-api-key');
|
||||||
const list = (data && (data['codex-api-key'] ?? data.items ?? data)) as any;
|
const list = extractArrayPayload(data, 'codex-api-key');
|
||||||
if (!Array.isArray(list)) return [];
|
|
||||||
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
|
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -150,8 +158,7 @@ export const providersApi = {
|
|||||||
|
|
||||||
async getClaudeConfigs(): Promise<ProviderKeyConfig[]> {
|
async getClaudeConfigs(): Promise<ProviderKeyConfig[]> {
|
||||||
const data = await apiClient.get('/claude-api-key');
|
const data = await apiClient.get('/claude-api-key');
|
||||||
const list = (data && (data['claude-api-key'] ?? data.items ?? data)) as any;
|
const list = extractArrayPayload(data, 'claude-api-key');
|
||||||
if (!Array.isArray(list)) return [];
|
|
||||||
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
|
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -166,8 +173,7 @@ export const providersApi = {
|
|||||||
|
|
||||||
async getVertexConfigs(): Promise<ProviderKeyConfig[]> {
|
async getVertexConfigs(): Promise<ProviderKeyConfig[]> {
|
||||||
const data = await apiClient.get('/vertex-api-key');
|
const data = await apiClient.get('/vertex-api-key');
|
||||||
const list = (data && (data['vertex-api-key'] ?? data.items ?? data)) as any;
|
const list = extractArrayPayload(data, 'vertex-api-key');
|
||||||
if (!Array.isArray(list)) return [];
|
|
||||||
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
|
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -182,8 +188,7 @@ export const providersApi = {
|
|||||||
|
|
||||||
async getOpenAIProviders(): Promise<OpenAIProviderConfig[]> {
|
async getOpenAIProviders(): Promise<OpenAIProviderConfig[]> {
|
||||||
const data = await apiClient.get('/openai-compatibility');
|
const data = await apiClient.get('/openai-compatibility');
|
||||||
const list = (data && (data['openai-compatibility'] ?? data.items ?? data)) as any;
|
const list = extractArrayPayload(data, 'openai-compatibility');
|
||||||
if (!Array.isArray(list)) return [];
|
|
||||||
return list.map((item) => normalizeOpenAIProvider(item)).filter(Boolean) as OpenAIProviderConfig[];
|
return list.map((item) => normalizeOpenAIProvider(item)).filter(Boolean) as OpenAIProviderConfig[];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import type {
|
|||||||
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 => {
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
|
||||||
|
const normalizeBoolean = (value: unknown): boolean | undefined => {
|
||||||
if (value === undefined || value === null) return undefined;
|
if (value === undefined || value === null) return undefined;
|
||||||
if (typeof value === 'boolean') return value;
|
if (typeof value === 'boolean') return value;
|
||||||
if (typeof value === 'number') return value !== 0;
|
if (typeof value === 'number') return value !== 0;
|
||||||
@@ -22,11 +25,17 @@ const normalizeBoolean = (value: any): boolean | undefined => {
|
|||||||
return Boolean(value);
|
return Boolean(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeModelAliases = (models: any): ModelAlias[] => {
|
const normalizeModelAliases = (models: unknown): ModelAlias[] => {
|
||||||
if (!Array.isArray(models)) return [];
|
if (!Array.isArray(models)) return [];
|
||||||
return models
|
return models
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
if (!item) return null;
|
if (item === undefined || item === null) return null;
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
const trimmed = item.trim();
|
||||||
|
return trimmed ? ({ name: trimmed } satisfies ModelAlias) : null;
|
||||||
|
}
|
||||||
|
if (!isRecord(item)) return null;
|
||||||
|
|
||||||
const name = item.name || item.id || item.model;
|
const name = item.name || item.id || item.model;
|
||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
const alias = item.alias || item.display_name || item.displayName;
|
const alias = item.alias || item.display_name || item.displayName;
|
||||||
@@ -37,7 +46,10 @@ const normalizeModelAliases = (models: any): ModelAlias[] => {
|
|||||||
entry.alias = String(alias);
|
entry.alias = String(alias);
|
||||||
}
|
}
|
||||||
if (priority !== undefined) {
|
if (priority !== undefined) {
|
||||||
entry.priority = Number(priority);
|
const parsed = Number(priority);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
entry.priority = parsed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (testModel) {
|
if (testModel) {
|
||||||
entry.testModel = String(testModel);
|
entry.testModel = String(testModel);
|
||||||
@@ -47,13 +59,17 @@ const normalizeModelAliases = (models: any): ModelAlias[] => {
|
|||||||
.filter(Boolean) as ModelAlias[];
|
.filter(Boolean) as ModelAlias[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeHeaders = (headers: any) => {
|
const normalizeHeaders = (headers: unknown) => {
|
||||||
if (!headers || typeof headers !== 'object') return undefined;
|
if (!headers || typeof headers !== 'object') return undefined;
|
||||||
const normalized = buildHeaderObject(headers as Record<string, string>);
|
const normalized = buildHeaderObject(
|
||||||
|
Array.isArray(headers)
|
||||||
|
? (headers as Array<{ key: string; value: string }>)
|
||||||
|
: (headers as Record<string, string | undefined | null>)
|
||||||
|
);
|
||||||
return Object.keys(normalized).length ? normalized : undefined;
|
return Object.keys(normalized).length ? normalized : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeExcludedModels = (input: any): string[] => {
|
const normalizeExcludedModels = (input: unknown): string[] => {
|
||||||
const rawList = Array.isArray(input) ? input : typeof input === 'string' ? input.split(/[\n,]/) : [];
|
const rawList = Array.isArray(input) ? input : typeof input === 'string' ? input.split(/[\n,]/) : [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const normalized: string[] = [];
|
const normalized: string[] = [];
|
||||||
@@ -70,20 +86,22 @@ const normalizeExcludedModels = (input: any): string[] => {
|
|||||||
return normalized;
|
return normalized;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizePrefix = (value: any): string | undefined => {
|
const normalizePrefix = (value: unknown): string | undefined => {
|
||||||
if (value === undefined || value === null) return undefined;
|
if (value === undefined || value === null) return undefined;
|
||||||
const trimmed = String(value).trim();
|
const trimmed = String(value).trim();
|
||||||
return trimmed ? trimmed : undefined;
|
return trimmed ? trimmed : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeApiKeyEntry = (entry: any): ApiKeyEntry | null => {
|
const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => {
|
||||||
if (!entry) return null;
|
if (entry === undefined || entry === null) return null;
|
||||||
const apiKey = entry['api-key'] ?? entry.apiKey ?? entry.key ?? (typeof entry === 'string' ? entry : '');
|
const record = isRecord(entry) ? entry : null;
|
||||||
|
const apiKey =
|
||||||
|
record?.['api-key'] ?? record?.apiKey ?? record?.key ?? (typeof entry === 'string' ? entry : '');
|
||||||
const trimmed = String(apiKey || '').trim();
|
const trimmed = String(apiKey || '').trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
|
|
||||||
const proxyUrl = entry['proxy-url'] ?? entry.proxyUrl;
|
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl : undefined;
|
||||||
const headers = normalizeHeaders(entry.headers);
|
const headers = record ? normalizeHeaders(record.headers) : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
apiKey: trimmed,
|
apiKey: trimmed,
|
||||||
@@ -92,33 +110,38 @@ const normalizeApiKeyEntry = (entry: any): ApiKeyEntry | null => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeProviderKeyConfig = (item: any): ProviderKeyConfig | null => {
|
const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null => {
|
||||||
if (!item) return null;
|
if (item === undefined || item === null) return null;
|
||||||
const apiKey = item['api-key'] ?? item.apiKey ?? (typeof item === 'string' ? item : '');
|
const record = isRecord(item) ? item : null;
|
||||||
|
const apiKey = record?.['api-key'] ?? record?.apiKey ?? (typeof item === 'string' ? item : '');
|
||||||
const trimmed = String(apiKey || '').trim();
|
const trimmed = String(apiKey || '').trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
|
|
||||||
const config: ProviderKeyConfig = { apiKey: trimmed };
|
const config: ProviderKeyConfig = { apiKey: trimmed };
|
||||||
const prefix = normalizePrefix(item.prefix ?? item['prefix']);
|
const prefix = normalizePrefix(record?.prefix ?? record?.['prefix']);
|
||||||
if (prefix) config.prefix = prefix;
|
if (prefix) config.prefix = prefix;
|
||||||
const baseUrl = item['base-url'] ?? item.baseUrl;
|
const baseUrl = record ? record['base-url'] ?? record.baseUrl : undefined;
|
||||||
const proxyUrl = item['proxy-url'] ?? item.proxyUrl;
|
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl : undefined;
|
||||||
if (baseUrl) config.baseUrl = String(baseUrl);
|
if (baseUrl) config.baseUrl = String(baseUrl);
|
||||||
if (proxyUrl) config.proxyUrl = String(proxyUrl);
|
if (proxyUrl) config.proxyUrl = String(proxyUrl);
|
||||||
const headers = normalizeHeaders(item.headers);
|
const headers = normalizeHeaders(record?.headers);
|
||||||
if (headers) config.headers = headers;
|
if (headers) config.headers = headers;
|
||||||
const models = normalizeModelAliases(item.models);
|
const models = normalizeModelAliases(record?.models);
|
||||||
if (models.length) config.models = models;
|
if (models.length) config.models = models;
|
||||||
const excludedModels = normalizeExcludedModels(
|
const excludedModels = normalizeExcludedModels(
|
||||||
item['excluded-models'] ?? item.excludedModels ?? item['excluded_models'] ?? item.excluded_models
|
record?.['excluded-models'] ??
|
||||||
|
record?.excludedModels ??
|
||||||
|
record?.['excluded_models'] ??
|
||||||
|
record?.excluded_models
|
||||||
);
|
);
|
||||||
if (excludedModels.length) config.excludedModels = excludedModels;
|
if (excludedModels.length) config.excludedModels = excludedModels;
|
||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeGeminiKeyConfig = (item: any): GeminiKeyConfig | null => {
|
const normalizeGeminiKeyConfig = (item: unknown): GeminiKeyConfig | null => {
|
||||||
if (!item) return null;
|
if (item === undefined || item === null) return null;
|
||||||
let apiKey = item['api-key'] ?? item.apiKey;
|
const record = isRecord(item) ? item : null;
|
||||||
|
let apiKey = record?.['api-key'] ?? record?.apiKey;
|
||||||
if (!apiKey && typeof item === 'string') {
|
if (!apiKey && typeof item === 'string') {
|
||||||
apiKey = item;
|
apiKey = item;
|
||||||
}
|
}
|
||||||
@@ -126,19 +149,19 @@ const normalizeGeminiKeyConfig = (item: any): GeminiKeyConfig | null => {
|
|||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
|
|
||||||
const config: GeminiKeyConfig = { apiKey: trimmed };
|
const config: GeminiKeyConfig = { apiKey: trimmed };
|
||||||
const prefix = normalizePrefix(item.prefix ?? item['prefix']);
|
const prefix = normalizePrefix(record?.prefix ?? record?.['prefix']);
|
||||||
if (prefix) config.prefix = prefix;
|
if (prefix) config.prefix = prefix;
|
||||||
const baseUrl = item['base-url'] ?? item.baseUrl ?? item['base_url'];
|
const baseUrl = record ? record['base-url'] ?? record.baseUrl ?? record['base_url'] : undefined;
|
||||||
if (baseUrl) config.baseUrl = String(baseUrl);
|
if (baseUrl) config.baseUrl = String(baseUrl);
|
||||||
const headers = normalizeHeaders(item.headers);
|
const headers = normalizeHeaders(record?.headers);
|
||||||
if (headers) config.headers = headers;
|
if (headers) config.headers = headers;
|
||||||
const excludedModels = normalizeExcludedModels(item['excluded-models'] ?? item.excludedModels);
|
const excludedModels = normalizeExcludedModels(record?.['excluded-models'] ?? record?.excludedModels);
|
||||||
if (excludedModels.length) config.excludedModels = excludedModels;
|
if (excludedModels.length) config.excludedModels = excludedModels;
|
||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeOpenAIProvider = (provider: any): OpenAIProviderConfig | null => {
|
const normalizeOpenAIProvider = (provider: unknown): OpenAIProviderConfig | null => {
|
||||||
if (!provider || typeof provider !== 'object') return null;
|
if (!isRecord(provider)) return null;
|
||||||
const name = provider.name || provider.id;
|
const name = provider.name || provider.id;
|
||||||
const baseUrl = provider['base-url'] ?? provider.baseUrl;
|
const baseUrl = provider['base-url'] ?? provider.baseUrl;
|
||||||
if (!name || !baseUrl) return null;
|
if (!name || !baseUrl) return null;
|
||||||
@@ -146,11 +169,11 @@ const normalizeOpenAIProvider = (provider: any): OpenAIProviderConfig | null =>
|
|||||||
let apiKeyEntries: ApiKeyEntry[] = [];
|
let apiKeyEntries: ApiKeyEntry[] = [];
|
||||||
if (Array.isArray(provider['api-key-entries'])) {
|
if (Array.isArray(provider['api-key-entries'])) {
|
||||||
apiKeyEntries = provider['api-key-entries']
|
apiKeyEntries = provider['api-key-entries']
|
||||||
.map((entry: any) => normalizeApiKeyEntry(entry))
|
.map((entry) => normalizeApiKeyEntry(entry))
|
||||||
.filter(Boolean) as ApiKeyEntry[];
|
.filter(Boolean) as ApiKeyEntry[];
|
||||||
} else if (Array.isArray(provider['api-keys'])) {
|
} else if (Array.isArray(provider['api-keys'])) {
|
||||||
apiKeyEntries = provider['api-keys']
|
apiKeyEntries = provider['api-keys']
|
||||||
.map((key: any) => normalizeApiKeyEntry({ 'api-key': key }))
|
.map((key) => normalizeApiKeyEntry({ 'api-key': key }))
|
||||||
.filter(Boolean) as ApiKeyEntry[];
|
.filter(Boolean) as ApiKeyEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,10 +197,10 @@ const normalizeOpenAIProvider = (provider: any): OpenAIProviderConfig | null =>
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeOauthExcluded = (payload: any): Record<string, string[]> | undefined => {
|
const normalizeOauthExcluded = (payload: unknown): Record<string, string[]> | undefined => {
|
||||||
if (!payload || typeof payload !== 'object') return undefined;
|
if (!isRecord(payload)) return undefined;
|
||||||
const source = payload['oauth-excluded-models'] ?? payload.items ?? payload;
|
const source = payload['oauth-excluded-models'] ?? payload.items ?? payload;
|
||||||
if (!source || typeof source !== 'object') return undefined;
|
if (!isRecord(source)) return undefined;
|
||||||
const map: Record<string, string[]> = {};
|
const map: Record<string, string[]> = {};
|
||||||
Object.entries(source).forEach(([provider, models]) => {
|
Object.entries(source).forEach(([provider, models]) => {
|
||||||
const key = String(provider || '').trim();
|
const key = String(provider || '').trim();
|
||||||
@@ -188,13 +211,13 @@ const normalizeOauthExcluded = (payload: any): Record<string, string[]> | undefi
|
|||||||
return map;
|
return map;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeAmpcodeModelMappings = (input: any): AmpcodeModelMapping[] => {
|
const normalizeAmpcodeModelMappings = (input: unknown): AmpcodeModelMapping[] => {
|
||||||
if (!Array.isArray(input)) return [];
|
if (!Array.isArray(input)) return [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const mappings: AmpcodeModelMapping[] = [];
|
const mappings: AmpcodeModelMapping[] = [];
|
||||||
|
|
||||||
input.forEach((entry) => {
|
input.forEach((entry) => {
|
||||||
if (!entry || typeof entry !== 'object') return;
|
if (!isRecord(entry)) return;
|
||||||
const from = String(entry.from ?? entry['from'] ?? '').trim();
|
const from = String(entry.from ?? entry['from'] ?? '').trim();
|
||||||
const to = String(entry.to ?? entry['to'] ?? '').trim();
|
const to = String(entry.to ?? entry['to'] ?? '').trim();
|
||||||
if (!from || !to) return;
|
if (!from || !to) return;
|
||||||
@@ -207,9 +230,10 @@ const normalizeAmpcodeModelMappings = (input: any): AmpcodeModelMapping[] => {
|
|||||||
return mappings;
|
return mappings;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeAmpcodeConfig = (payload: any): AmpcodeConfig | undefined => {
|
const normalizeAmpcodeConfig = (payload: unknown): AmpcodeConfig | undefined => {
|
||||||
const source = payload?.ampcode ?? payload;
|
const sourceRaw = isRecord(payload) ? (payload.ampcode ?? payload) : payload;
|
||||||
if (!source || typeof source !== 'object') return undefined;
|
if (!isRecord(sourceRaw)) return undefined;
|
||||||
|
const source = sourceRaw;
|
||||||
|
|
||||||
const config: AmpcodeConfig = {};
|
const config: AmpcodeConfig = {};
|
||||||
const upstreamUrl = source['upstream-url'] ?? source.upstreamUrl ?? source['upstream_url'];
|
const upstreamUrl = source['upstream-url'] ?? source.upstreamUrl ?? source['upstream_url'];
|
||||||
@@ -237,70 +261,94 @@ const normalizeAmpcodeConfig = (payload: any): AmpcodeConfig | undefined => {
|
|||||||
/**
|
/**
|
||||||
* 规范化 /config 返回值
|
* 规范化 /config 返回值
|
||||||
*/
|
*/
|
||||||
export const normalizeConfigResponse = (raw: any): Config => {
|
export const normalizeConfigResponse = (raw: unknown): Config => {
|
||||||
const config: Config = { raw: raw || {} };
|
const config: Config = { raw: isRecord(raw) ? raw : {} };
|
||||||
if (!raw || typeof raw !== 'object') {
|
if (!isRecord(raw)) {
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
config.debug = raw.debug;
|
config.debug = normalizeBoolean(raw.debug);
|
||||||
config.proxyUrl = raw['proxy-url'] ?? raw.proxyUrl;
|
const proxyUrl = raw['proxy-url'] ?? raw.proxyUrl;
|
||||||
config.requestRetry = raw['request-retry'] ?? raw.requestRetry;
|
config.proxyUrl =
|
||||||
|
typeof proxyUrl === 'string' ? proxyUrl : proxyUrl === undefined || proxyUrl === null ? undefined : String(proxyUrl);
|
||||||
|
const requestRetry = raw['request-retry'] ?? raw.requestRetry;
|
||||||
|
if (typeof requestRetry === 'number' && Number.isFinite(requestRetry)) {
|
||||||
|
config.requestRetry = requestRetry;
|
||||||
|
} else if (typeof requestRetry === 'string' && requestRetry.trim() !== '') {
|
||||||
|
const parsed = Number(requestRetry);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
config.requestRetry = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const quota = raw['quota-exceeded'] ?? raw.quotaExceeded;
|
const quota = raw['quota-exceeded'] ?? raw.quotaExceeded;
|
||||||
if (quota && typeof quota === 'object') {
|
if (isRecord(quota)) {
|
||||||
config.quotaExceeded = {
|
config.quotaExceeded = {
|
||||||
switchProject: quota['switch-project'] ?? quota.switchProject,
|
switchProject: normalizeBoolean(quota['switch-project'] ?? quota.switchProject),
|
||||||
switchPreviewModel: quota['switch-preview-model'] ?? quota.switchPreviewModel
|
switchPreviewModel: normalizeBoolean(quota['switch-preview-model'] ?? quota.switchPreviewModel)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
config.usageStatisticsEnabled = raw['usage-statistics-enabled'] ?? raw.usageStatisticsEnabled;
|
config.usageStatisticsEnabled = normalizeBoolean(
|
||||||
config.requestLog = raw['request-log'] ?? raw.requestLog;
|
raw['usage-statistics-enabled'] ?? raw.usageStatisticsEnabled
|
||||||
config.loggingToFile = raw['logging-to-file'] ?? raw.loggingToFile;
|
);
|
||||||
config.logsMaxTotalSizeMb = raw['logs-max-total-size-mb'] ?? raw.logsMaxTotalSizeMb;
|
config.requestLog = normalizeBoolean(raw['request-log'] ?? raw.requestLog);
|
||||||
config.wsAuth = raw['ws-auth'] ?? raw.wsAuth;
|
config.loggingToFile = normalizeBoolean(raw['logging-to-file'] ?? raw.loggingToFile);
|
||||||
config.forceModelPrefix = raw['force-model-prefix'] ?? raw.forceModelPrefix;
|
const logsMaxTotalSizeMb = raw['logs-max-total-size-mb'] ?? raw.logsMaxTotalSizeMb;
|
||||||
const routing = raw.routing;
|
if (typeof logsMaxTotalSizeMb === 'number' && Number.isFinite(logsMaxTotalSizeMb)) {
|
||||||
if (routing && typeof routing === 'object') {
|
config.logsMaxTotalSizeMb = logsMaxTotalSizeMb;
|
||||||
config.routingStrategy = routing.strategy ?? routing['strategy'];
|
} else if (typeof logsMaxTotalSizeMb === 'string' && logsMaxTotalSizeMb.trim() !== '') {
|
||||||
} else {
|
const parsed = Number(logsMaxTotalSizeMb);
|
||||||
config.routingStrategy = raw['routing-strategy'] ?? raw.routingStrategy;
|
if (Number.isFinite(parsed)) {
|
||||||
|
config.logsMaxTotalSizeMb = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.wsAuth = normalizeBoolean(raw['ws-auth'] ?? raw.wsAuth);
|
||||||
|
config.forceModelPrefix = normalizeBoolean(raw['force-model-prefix'] ?? raw.forceModelPrefix);
|
||||||
|
const routing = raw.routing;
|
||||||
|
const strategyRaw = isRecord(routing)
|
||||||
|
? (routing.strategy ?? routing['strategy'])
|
||||||
|
: (raw['routing-strategy'] ?? raw.routingStrategy);
|
||||||
|
if (strategyRaw !== undefined && strategyRaw !== null) {
|
||||||
|
config.routingStrategy = String(strategyRaw);
|
||||||
|
}
|
||||||
|
const apiKeysRaw = raw['api-keys'] ?? raw.apiKeys;
|
||||||
|
if (Array.isArray(apiKeysRaw)) {
|
||||||
|
config.apiKeys = apiKeysRaw.map((key) => String(key)).filter((key) => key.trim() !== '');
|
||||||
}
|
}
|
||||||
config.apiKeys = Array.isArray(raw['api-keys']) ? raw['api-keys'].slice() : raw.apiKeys;
|
|
||||||
|
|
||||||
const geminiList = raw['gemini-api-key'] ?? raw.geminiApiKey ?? raw.geminiApiKeys;
|
const geminiList = raw['gemini-api-key'] ?? raw.geminiApiKey ?? raw.geminiApiKeys;
|
||||||
if (Array.isArray(geminiList)) {
|
if (Array.isArray(geminiList)) {
|
||||||
config.geminiApiKeys = geminiList
|
config.geminiApiKeys = geminiList
|
||||||
.map((item: any) => normalizeGeminiKeyConfig(item))
|
.map((item) => normalizeGeminiKeyConfig(item))
|
||||||
.filter(Boolean) as GeminiKeyConfig[];
|
.filter(Boolean) as GeminiKeyConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const codexList = raw['codex-api-key'] ?? raw.codexApiKey ?? raw.codexApiKeys;
|
const codexList = raw['codex-api-key'] ?? raw.codexApiKey ?? raw.codexApiKeys;
|
||||||
if (Array.isArray(codexList)) {
|
if (Array.isArray(codexList)) {
|
||||||
config.codexApiKeys = codexList
|
config.codexApiKeys = codexList
|
||||||
.map((item: any) => normalizeProviderKeyConfig(item))
|
.map((item) => normalizeProviderKeyConfig(item))
|
||||||
.filter(Boolean) as ProviderKeyConfig[];
|
.filter(Boolean) as ProviderKeyConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const claudeList = raw['claude-api-key'] ?? raw.claudeApiKey ?? raw.claudeApiKeys;
|
const claudeList = raw['claude-api-key'] ?? raw.claudeApiKey ?? raw.claudeApiKeys;
|
||||||
if (Array.isArray(claudeList)) {
|
if (Array.isArray(claudeList)) {
|
||||||
config.claudeApiKeys = claudeList
|
config.claudeApiKeys = claudeList
|
||||||
.map((item: any) => normalizeProviderKeyConfig(item))
|
.map((item) => normalizeProviderKeyConfig(item))
|
||||||
.filter(Boolean) as ProviderKeyConfig[];
|
.filter(Boolean) as ProviderKeyConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const vertexList = raw['vertex-api-key'] ?? raw.vertexApiKey ?? raw.vertexApiKeys;
|
const vertexList = raw['vertex-api-key'] ?? raw.vertexApiKey ?? raw.vertexApiKeys;
|
||||||
if (Array.isArray(vertexList)) {
|
if (Array.isArray(vertexList)) {
|
||||||
config.vertexApiKeys = vertexList
|
config.vertexApiKeys = vertexList
|
||||||
.map((item: any) => normalizeProviderKeyConfig(item))
|
.map((item) => normalizeProviderKeyConfig(item))
|
||||||
.filter(Boolean) as ProviderKeyConfig[];
|
.filter(Boolean) as ProviderKeyConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const openaiList = raw['openai-compatibility'] ?? raw.openaiCompatibility ?? raw.openAICompatibility;
|
const openaiList = raw['openai-compatibility'] ?? raw.openaiCompatibility ?? raw.openAICompatibility;
|
||||||
if (Array.isArray(openaiList)) {
|
if (Array.isArray(openaiList)) {
|
||||||
config.openaiCompatibility = openaiList
|
config.openaiCompatibility = openaiList
|
||||||
.map((item: any) => normalizeOpenAIProvider(item))
|
.map((item) => normalizeOpenAIProvider(item))
|
||||||
.filter(Boolean) as OpenAIProviderConfig[];
|
.filter(Boolean) as OpenAIProviderConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const usageApi = {
|
|||||||
/**
|
/**
|
||||||
* 获取使用统计原始数据
|
* 获取使用统计原始数据
|
||||||
*/
|
*/
|
||||||
getUsage: () => apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS }),
|
getUsage: () => apiClient.get<Record<string, unknown>>('/usage', { timeout: USAGE_TIMEOUT_MS }),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导出使用统计快照
|
* 导出使用统计快照
|
||||||
@@ -42,10 +42,10 @@ export const usageApi = {
|
|||||||
/**
|
/**
|
||||||
* 计算密钥成功/失败统计,必要时会先获取 usage 数据
|
* 计算密钥成功/失败统计,必要时会先获取 usage 数据
|
||||||
*/
|
*/
|
||||||
async getKeyStats(usageData?: any): Promise<KeyStats> {
|
async getKeyStats(usageData?: unknown): Promise<KeyStats> {
|
||||||
let payload = usageData;
|
let payload = usageData;
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
const response = await apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS });
|
const response = await apiClient.get<Record<string, unknown>>('/usage', { timeout: USAGE_TIMEOUT_MS });
|
||||||
payload = response?.usage ?? response;
|
payload = response?.usage ?? response;
|
||||||
}
|
}
|
||||||
return computeKeyStats(payload);
|
return computeKeyStats(payload);
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
import { apiClient } from './client';
|
import { apiClient } from './client';
|
||||||
|
|
||||||
export const versionApi = {
|
export const versionApi = {
|
||||||
checkLatest: () => apiClient.get('/latest-version')
|
checkLatest: () => apiClient.get<Record<string, unknown>>('/latest-version')
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class SecureStorageService {
|
|||||||
/**
|
/**
|
||||||
* 存储数据
|
* 存储数据
|
||||||
*/
|
*/
|
||||||
setItem(key: string, value: any, options: StorageOptions = {}): void {
|
setItem(key: string, value: unknown, options: StorageOptions = {}): void {
|
||||||
const { encrypt = true } = options;
|
const { encrypt = true } = options;
|
||||||
|
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
@@ -30,7 +30,7 @@ class SecureStorageService {
|
|||||||
/**
|
/**
|
||||||
* 获取数据
|
* 获取数据
|
||||||
*/
|
*/
|
||||||
getItem<T = any>(key: string, options: StorageOptions = {}): T | null {
|
getItem<T = unknown>(key: string, options: StorageOptions = {}): T | null {
|
||||||
const { encrypt = true } = options;
|
const { encrypt = true } = options;
|
||||||
|
|
||||||
const raw = localStorage.getItem(key);
|
const raw = localStorage.getItem(key);
|
||||||
@@ -84,7 +84,7 @@ class SecureStorageService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed: any = raw;
|
let parsed: unknown = raw;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(raw);
|
parsed = JSON.parse(raw);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -117,10 +117,16 @@ export const useAuthStore = create<AuthStoreState>()(
|
|||||||
} else {
|
} else {
|
||||||
localStorage.removeItem('isLoggedIn');
|
localStorage.removeItem('isLoggedIn');
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: typeof error === 'string'
|
||||||
|
? error
|
||||||
|
: 'Connection failed';
|
||||||
set({
|
set({
|
||||||
connectionStatus: 'error',
|
connectionStatus: 'error',
|
||||||
connectionError: error.message || 'Connection failed'
|
connectionError: message || 'Connection failed'
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { configApi } from '@/services/api/config';
|
|||||||
import { CACHE_EXPIRY_MS } from '@/utils/constants';
|
import { CACHE_EXPIRY_MS } from '@/utils/constants';
|
||||||
|
|
||||||
interface ConfigCache {
|
interface ConfigCache {
|
||||||
data: any;
|
data: unknown;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,8 +21,11 @@ interface ConfigState {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
// 操作
|
// 操作
|
||||||
fetchConfig: (section?: RawConfigSection, forceRefresh?: boolean) => Promise<Config | any>;
|
fetchConfig: {
|
||||||
updateConfigValue: (section: RawConfigSection, value: any) => void;
|
(section?: undefined, forceRefresh?: boolean): Promise<Config>;
|
||||||
|
(section: RawConfigSection, forceRefresh?: boolean): Promise<unknown>;
|
||||||
|
};
|
||||||
|
updateConfigValue: (section: RawConfigSection, value: unknown) => void;
|
||||||
clearCache: (section?: RawConfigSection) => void;
|
clearCache: (section?: RawConfigSection) => void;
|
||||||
isCacheValid: (section?: RawConfigSection) => boolean;
|
isCacheValid: (section?: RawConfigSection) => boolean;
|
||||||
}
|
}
|
||||||
@@ -105,7 +108,7 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
fetchConfig: async (section, forceRefresh = false) => {
|
fetchConfig: (async (section?: RawConfigSection, forceRefresh: boolean = false) => {
|
||||||
const { cache, isCacheValid } = get();
|
const { cache, isCacheValid } = get();
|
||||||
|
|
||||||
// 检查缓存
|
// 检查缓存
|
||||||
@@ -163,10 +166,12 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return section ? extractSectionValue(data, section) : data;
|
return section ? extractSectionValue(data, section) : data;
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Failed to fetch config';
|
||||||
if (requestId === configRequestToken) {
|
if (requestId === configRequestToken) {
|
||||||
set({
|
set({
|
||||||
error: error.message || 'Failed to fetch config',
|
error: message || 'Failed to fetch config',
|
||||||
loading: false
|
loading: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -176,7 +181,7 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
|
|||||||
inFlightConfigRequest = null;
|
inFlightConfigRequest = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}) as ConfigState['fetchConfig'],
|
||||||
|
|
||||||
updateConfigValue: (section, value) => {
|
updateConfigValue: (section, value) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -186,61 +191,61 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
|
|||||||
|
|
||||||
switch (section) {
|
switch (section) {
|
||||||
case 'debug':
|
case 'debug':
|
||||||
nextConfig.debug = value;
|
nextConfig.debug = value as Config['debug'];
|
||||||
break;
|
break;
|
||||||
case 'proxy-url':
|
case 'proxy-url':
|
||||||
nextConfig.proxyUrl = value;
|
nextConfig.proxyUrl = value as Config['proxyUrl'];
|
||||||
break;
|
break;
|
||||||
case 'request-retry':
|
case 'request-retry':
|
||||||
nextConfig.requestRetry = value;
|
nextConfig.requestRetry = value as Config['requestRetry'];
|
||||||
break;
|
break;
|
||||||
case 'quota-exceeded':
|
case 'quota-exceeded':
|
||||||
nextConfig.quotaExceeded = value;
|
nextConfig.quotaExceeded = value as Config['quotaExceeded'];
|
||||||
break;
|
break;
|
||||||
case 'usage-statistics-enabled':
|
case 'usage-statistics-enabled':
|
||||||
nextConfig.usageStatisticsEnabled = value;
|
nextConfig.usageStatisticsEnabled = value as Config['usageStatisticsEnabled'];
|
||||||
break;
|
break;
|
||||||
case 'request-log':
|
case 'request-log':
|
||||||
nextConfig.requestLog = value;
|
nextConfig.requestLog = value as Config['requestLog'];
|
||||||
break;
|
break;
|
||||||
case 'logging-to-file':
|
case 'logging-to-file':
|
||||||
nextConfig.loggingToFile = value;
|
nextConfig.loggingToFile = value as Config['loggingToFile'];
|
||||||
break;
|
break;
|
||||||
case 'logs-max-total-size-mb':
|
case 'logs-max-total-size-mb':
|
||||||
nextConfig.logsMaxTotalSizeMb = value;
|
nextConfig.logsMaxTotalSizeMb = value as Config['logsMaxTotalSizeMb'];
|
||||||
break;
|
break;
|
||||||
case 'ws-auth':
|
case 'ws-auth':
|
||||||
nextConfig.wsAuth = value;
|
nextConfig.wsAuth = value as Config['wsAuth'];
|
||||||
break;
|
break;
|
||||||
case 'force-model-prefix':
|
case 'force-model-prefix':
|
||||||
nextConfig.forceModelPrefix = value;
|
nextConfig.forceModelPrefix = value as Config['forceModelPrefix'];
|
||||||
break;
|
break;
|
||||||
case 'routing/strategy':
|
case 'routing/strategy':
|
||||||
nextConfig.routingStrategy = value;
|
nextConfig.routingStrategy = value as Config['routingStrategy'];
|
||||||
break;
|
break;
|
||||||
case 'api-keys':
|
case 'api-keys':
|
||||||
nextConfig.apiKeys = value;
|
nextConfig.apiKeys = value as Config['apiKeys'];
|
||||||
break;
|
break;
|
||||||
case 'ampcode':
|
case 'ampcode':
|
||||||
nextConfig.ampcode = value;
|
nextConfig.ampcode = value as Config['ampcode'];
|
||||||
break;
|
break;
|
||||||
case 'gemini-api-key':
|
case 'gemini-api-key':
|
||||||
nextConfig.geminiApiKeys = value;
|
nextConfig.geminiApiKeys = value as Config['geminiApiKeys'];
|
||||||
break;
|
break;
|
||||||
case 'codex-api-key':
|
case 'codex-api-key':
|
||||||
nextConfig.codexApiKeys = value;
|
nextConfig.codexApiKeys = value as Config['codexApiKeys'];
|
||||||
break;
|
break;
|
||||||
case 'claude-api-key':
|
case 'claude-api-key':
|
||||||
nextConfig.claudeApiKeys = value;
|
nextConfig.claudeApiKeys = value as Config['claudeApiKeys'];
|
||||||
break;
|
break;
|
||||||
case 'vertex-api-key':
|
case 'vertex-api-key':
|
||||||
nextConfig.vertexApiKeys = value;
|
nextConfig.vertexApiKeys = value as Config['vertexApiKeys'];
|
||||||
break;
|
break;
|
||||||
case 'openai-compatibility':
|
case 'openai-compatibility':
|
||||||
nextConfig.openaiCompatibility = value;
|
nextConfig.openaiCompatibility = value as Config['openaiCompatibility'];
|
||||||
break;
|
break;
|
||||||
case 'oauth-excluded-models':
|
case 'oauth-excluded-models':
|
||||||
nextConfig.oauthExcludedModels = value;
|
nextConfig.oauthExcludedModels = value as Config['oauthExcludedModels'];
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import type { Language } from '@/types';
|
import type { Language } from '@/types';
|
||||||
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
|
import { LANGUAGE_ORDER, STORAGE_KEY_LANGUAGE } from '@/utils/constants';
|
||||||
import i18n from '@/i18n';
|
import i18n from '@/i18n';
|
||||||
import { getInitialLanguage } from '@/utils/language';
|
import { getInitialLanguage, isSupportedLanguage } from '@/utils/language';
|
||||||
|
|
||||||
interface LanguageState {
|
interface LanguageState {
|
||||||
language: Language;
|
language: Language;
|
||||||
setLanguage: (language: Language) => void;
|
setLanguage: (language: string) => void;
|
||||||
toggleLanguage: () => void;
|
toggleLanguage: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +22,9 @@ export const useLanguageStore = create<LanguageState>()(
|
|||||||
language: getInitialLanguage(),
|
language: getInitialLanguage(),
|
||||||
|
|
||||||
setLanguage: (language) => {
|
setLanguage: (language) => {
|
||||||
|
if (!isSupportedLanguage(language)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 切换 i18next 语言
|
// 切换 i18next 语言
|
||||||
i18n.changeLanguage(language);
|
i18n.changeLanguage(language);
|
||||||
set({ language });
|
set({ language });
|
||||||
@@ -29,12 +32,24 @@ export const useLanguageStore = create<LanguageState>()(
|
|||||||
|
|
||||||
toggleLanguage: () => {
|
toggleLanguage: () => {
|
||||||
const { language, setLanguage } = get();
|
const { language, setLanguage } = get();
|
||||||
const newLanguage: Language = language === 'zh-CN' ? 'en' : 'zh-CN';
|
const currentIndex = LANGUAGE_ORDER.indexOf(language);
|
||||||
setLanguage(newLanguage);
|
const nextLanguage = LANGUAGE_ORDER[(currentIndex + 1) % LANGUAGE_ORDER.length];
|
||||||
|
setLanguage(nextLanguage);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: STORAGE_KEY_LANGUAGE
|
name: STORAGE_KEY_LANGUAGE,
|
||||||
|
merge: (persistedState, currentState) => {
|
||||||
|
const nextLanguage = (persistedState as Partial<LanguageState>)?.language;
|
||||||
|
if (typeof nextLanguage === 'string' && isSupportedLanguage(nextLanguage)) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
...(persistedState as Partial<LanguageState>),
|
||||||
|
language: nextLanguage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return currentState;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,8 +52,9 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
const message = error?.message || 'Failed to fetch models';
|
const message =
|
||||||
|
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Failed to fetch models';
|
||||||
set({
|
set({
|
||||||
error: message,
|
error: message,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|||||||
@@ -190,6 +190,67 @@
|
|||||||
gap: $spacing-xs;
|
gap: $spacing-xs;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.language-menu {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.language-menu-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
z-index: $z-dropdown;
|
||||||
|
min-width: 164px;
|
||||||
|
padding: $spacing-xs;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-menu-option {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
transition: background-color $transition-fast, color $transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-menu-check {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-mobile) {
|
||||||
|
.language-menu-popover {
|
||||||
|
right: auto;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -387,27 +448,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
|
||||||
padding: $spacing-md $spacing-lg;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
background: var(--bg-primary);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 14px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: $spacing-sm;
|
|
||||||
|
|
||||||
.footer-version {
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-ms-user-select: none;
|
|
||||||
-webkit-touch-callout: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: $spacing-lg;
|
gap: $spacing-lg;
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export interface ApiClientConfig {
|
|||||||
export interface RequestOptions {
|
export interface RequestOptions {
|
||||||
method?: HttpMethod;
|
method?: HttpMethod;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
params?: Record<string, any>;
|
params?: Record<string, unknown>;
|
||||||
data?: any;
|
data?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 服务器版本信息
|
// 服务器版本信息
|
||||||
@@ -31,6 +31,6 @@ export interface ServerVersion {
|
|||||||
export type ApiError = Error & {
|
export type ApiError = Error & {
|
||||||
status?: number;
|
status?: number;
|
||||||
code?: string;
|
code?: string;
|
||||||
details?: any;
|
details?: unknown;
|
||||||
data?: any;
|
data?: unknown;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
export type AuthFileType =
|
export type AuthFileType =
|
||||||
| 'qwen'
|
| 'qwen'
|
||||||
|
| 'kimi'
|
||||||
| 'gemini'
|
| 'gemini'
|
||||||
| 'gemini-cli'
|
| 'gemini-cli'
|
||||||
| 'aistudio'
|
| 'aistudio'
|
||||||
@@ -25,7 +26,7 @@ export interface AuthFileItem {
|
|||||||
runtimeOnly?: boolean | string;
|
runtimeOnly?: boolean | string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
modified?: number;
|
modified?: number;
|
||||||
[key: string]: any;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthFilesResponse {
|
export interface AuthFilesResponse {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
export type Theme = 'light' | 'dark' | 'auto';
|
export type Theme = 'light' | 'dark' | 'auto';
|
||||||
|
|
||||||
export type Language = 'zh-CN' | 'en';
|
export type Language = 'zh-CN' | 'en' | 'ru';
|
||||||
|
|
||||||
export type NotificationType = 'info' | 'success' | 'warning' | 'error';
|
export type NotificationType = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ export interface Notification {
|
|||||||
duration?: number;
|
duration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiResponse<T = any> {
|
export interface ApiResponse<T = unknown> {
|
||||||
data?: T;
|
data?: T;
|
||||||
error?: string;
|
error?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export interface Config {
|
|||||||
vertexApiKeys?: ProviderKeyConfig[];
|
vertexApiKeys?: ProviderKeyConfig[];
|
||||||
openaiCompatibility?: OpenAIProviderConfig[];
|
openaiCompatibility?: OpenAIProviderConfig[];
|
||||||
oauthExcludedModels?: Record<string, string[]>;
|
oauthExcludedModels?: Record<string, string[]>;
|
||||||
raw?: Record<string, any>;
|
raw?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RawConfigSection =
|
export type RawConfigSection =
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface LogEntry {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
level: LogLevel;
|
level: LogLevel;
|
||||||
message: string;
|
message: string;
|
||||||
details?: any;
|
details?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 日志筛选
|
// 日志筛选
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type OAuthProvider =
|
|||||||
| 'anthropic'
|
| 'anthropic'
|
||||||
| 'antigravity'
|
| 'antigravity'
|
||||||
| 'gemini-cli'
|
| 'gemini-cli'
|
||||||
|
| 'kimi'
|
||||||
| 'qwen';
|
| 'qwen';
|
||||||
|
|
||||||
// OAuth 流程状态
|
// OAuth 流程状态
|
||||||
|
|||||||
@@ -43,5 +43,5 @@ export interface OpenAIProviderConfig {
|
|||||||
models?: ModelAlias[];
|
models?: ModelAlias[];
|
||||||
priority?: number;
|
priority?: number;
|
||||||
testModel?: string;
|
testModel?: string;
|
||||||
[key: string]: any;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export type PayloadParamEntry = {
|
|||||||
export type PayloadModelEntry = {
|
export type PayloadModelEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
protocol?: 'openai' | 'gemini' | 'claude' | 'codex' | 'antigravity';
|
protocol?: 'openai' | 'openai-response' | 'gemini' | 'claude' | 'codex' | 'antigravity';
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PayloadRule = {
|
export type PayloadRule = {
|
||||||
@@ -48,7 +48,6 @@ export type VisualConfigValues = {
|
|||||||
loggingToFile: boolean;
|
loggingToFile: boolean;
|
||||||
logsMaxTotalSizeMb: string;
|
logsMaxTotalSizeMb: string;
|
||||||
usageStatisticsEnabled: boolean;
|
usageStatisticsEnabled: boolean;
|
||||||
usageRecordsRetentionDays: string;
|
|
||||||
proxyUrl: string;
|
proxyUrl: string;
|
||||||
forceModelPrefix: boolean;
|
forceModelPrefix: boolean;
|
||||||
requestRetry: string;
|
requestRetry: string;
|
||||||
@@ -85,7 +84,6 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
|
|||||||
loggingToFile: false,
|
loggingToFile: false,
|
||||||
logsMaxTotalSizeMb: '',
|
logsMaxTotalSizeMb: '',
|
||||||
usageStatisticsEnabled: false,
|
usageStatisticsEnabled: false,
|
||||||
usageRecordsRetentionDays: '',
|
|
||||||
proxyUrl: '',
|
proxyUrl: '',
|
||||||
forceModelPrefix: false,
|
forceModelPrefix: false,
|
||||||
requestRetry: '',
|
requestRetry: '',
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
* 从原项目 src/utils/constants.js 迁移
|
* 从原项目 src/utils/constants.js 迁移
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { Language } from '@/types';
|
||||||
|
|
||||||
|
const defineLanguageOrder = <T extends readonly Language[]>(
|
||||||
|
languages: T & ([Language] extends [T[number]] ? unknown : never)
|
||||||
|
) => languages;
|
||||||
|
|
||||||
// 缓存过期时间(毫秒)
|
// 缓存过期时间(毫秒)
|
||||||
export const CACHE_EXPIRY_MS = 30 * 1000; // 与基线保持一致,减少管理端压力
|
export const CACHE_EXPIRY_MS = 30 * 1000; // 与基线保持一致,减少管理端压力
|
||||||
|
|
||||||
@@ -33,6 +39,15 @@ export const STORAGE_KEY_LANGUAGE = 'cli-proxy-language';
|
|||||||
export const STORAGE_KEY_SIDEBAR = 'cli-proxy-sidebar-collapsed';
|
export const STORAGE_KEY_SIDEBAR = 'cli-proxy-sidebar-collapsed';
|
||||||
export const STORAGE_KEY_AUTH_FILES_PAGE_SIZE = 'cli-proxy-auth-files-page-size';
|
export const STORAGE_KEY_AUTH_FILES_PAGE_SIZE = 'cli-proxy-auth-files-page-size';
|
||||||
|
|
||||||
|
// 语言配置
|
||||||
|
export const LANGUAGE_ORDER = defineLanguageOrder(['zh-CN', 'en', 'ru'] as const);
|
||||||
|
export const LANGUAGE_LABEL_KEYS: Record<Language, string> = {
|
||||||
|
'zh-CN': 'language.chinese',
|
||||||
|
en: 'language.english',
|
||||||
|
ru: 'language.russian'
|
||||||
|
};
|
||||||
|
export const SUPPORTED_LANGUAGES = LANGUAGE_ORDER;
|
||||||
|
|
||||||
// 通知持续时间
|
// 通知持续时间
|
||||||
export const NOTIFICATION_DURATION_MS = 3000;
|
export const NOTIFICATION_DURATION_MS = 3000;
|
||||||
|
|
||||||
@@ -42,6 +57,7 @@ export const OAUTH_CARD_IDS = [
|
|||||||
'anthropic-oauth-card',
|
'anthropic-oauth-card',
|
||||||
'antigravity-oauth-card',
|
'antigravity-oauth-card',
|
||||||
'gemini-cli-oauth-card',
|
'gemini-cli-oauth-card',
|
||||||
|
'kimi-oauth-card',
|
||||||
'qwen-oauth-card'
|
'qwen-oauth-card'
|
||||||
];
|
];
|
||||||
export const OAUTH_PROVIDERS = {
|
export const OAUTH_PROVIDERS = {
|
||||||
@@ -49,6 +65,7 @@ export const OAUTH_PROVIDERS = {
|
|||||||
ANTHROPIC: 'anthropic',
|
ANTHROPIC: 'anthropic',
|
||||||
ANTIGRAVITY: 'antigravity',
|
ANTIGRAVITY: 'antigravity',
|
||||||
GEMINI_CLI: 'gemini-cli',
|
GEMINI_CLI: 'gemini-cli',
|
||||||
|
KIMI: 'kimi',
|
||||||
QWEN: 'qwen'
|
QWEN: 'qwen'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ export function normalizeArrayResponse<T>(data: T | T[] | null | undefined): T[]
|
|||||||
/**
|
/**
|
||||||
* 防抖函数
|
* 防抖函数
|
||||||
*/
|
*/
|
||||||
export function debounce<T extends (...args: any[]) => any>(
|
export function debounce<This, Args extends unknown[], Return>(
|
||||||
func: T,
|
func: (this: This, ...args: Args) => Return,
|
||||||
delay: number
|
delay: number
|
||||||
): (...args: Parameters<T>) => void {
|
): (this: This, ...args: Args) => void {
|
||||||
let timeoutId: ReturnType<typeof setTimeout>;
|
let timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
return function (this: any, ...args: Parameters<T>) {
|
return function (this: This, ...args: Args) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
timeoutId = setTimeout(() => func.apply(this, args), delay);
|
timeoutId = setTimeout(() => func.apply(this, args), delay);
|
||||||
};
|
};
|
||||||
@@ -30,13 +30,13 @@ export function debounce<T extends (...args: any[]) => any>(
|
|||||||
/**
|
/**
|
||||||
* 节流函数
|
* 节流函数
|
||||||
*/
|
*/
|
||||||
export function throttle<T extends (...args: any[]) => any>(
|
export function throttle<This, Args extends unknown[], Return>(
|
||||||
func: T,
|
func: (this: This, ...args: Args) => Return,
|
||||||
limit: number
|
limit: number
|
||||||
): (...args: Parameters<T>) => void {
|
): (this: This, ...args: Args) => void {
|
||||||
let inThrottle: boolean;
|
let inThrottle: boolean;
|
||||||
|
|
||||||
return function (this: any, ...args: Parameters<T>) {
|
return function (this: This, ...args: Args) {
|
||||||
if (!inThrottle) {
|
if (!inThrottle) {
|
||||||
func.apply(this, args);
|
func.apply(this, args);
|
||||||
inThrottle = true;
|
inThrottle = true;
|
||||||
@@ -67,16 +67,17 @@ export function generateId(): string {
|
|||||||
export function deepClone<T>(obj: T): T {
|
export function deepClone<T>(obj: T): T {
|
||||||
if (obj === null || typeof obj !== 'object') return obj;
|
if (obj === null || typeof obj !== 'object') return obj;
|
||||||
|
|
||||||
if (obj instanceof Date) return new Date(obj.getTime()) as any;
|
if (obj instanceof Date) return new Date(obj.getTime()) as unknown as T;
|
||||||
if (obj instanceof Array) return obj.map((item) => deepClone(item)) as any;
|
if (Array.isArray(obj)) return obj.map((item) => deepClone(item)) as unknown as T;
|
||||||
|
|
||||||
const clonedObj = {} as T;
|
const source = obj as Record<string, unknown>;
|
||||||
for (const key in obj) {
|
const cloned: Record<string, unknown> = {};
|
||||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
for (const key in source) {
|
||||||
clonedObj[key] = deepClone((obj as any)[key]);
|
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||||
|
cloned[key] = deepClone(source[key]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return clonedObj;
|
return cloned as unknown as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import type { Language } from '@/types';
|
import type { Language } from '@/types';
|
||||||
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
|
import { STORAGE_KEY_LANGUAGE, SUPPORTED_LANGUAGES } from '@/utils/constants';
|
||||||
|
|
||||||
|
export const isSupportedLanguage = (value: string): value is Language =>
|
||||||
|
SUPPORTED_LANGUAGES.includes(value as Language);
|
||||||
|
|
||||||
const parseStoredLanguage = (value: string): Language | null => {
|
const parseStoredLanguage = (value: string): Language | null => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(value);
|
const parsed = JSON.parse(value);
|
||||||
const candidate = parsed?.state?.language ?? parsed?.language ?? parsed;
|
const candidate = parsed?.state?.language ?? parsed?.language ?? parsed;
|
||||||
if (candidate === 'zh-CN' || candidate === 'en') {
|
if (typeof candidate === 'string' && isSupportedLanguage(candidate)) {
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (value === 'zh-CN' || value === 'en') {
|
if (isSupportedLanguage(value)) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,7 +39,10 @@ const getBrowserLanguage = (): Language => {
|
|||||||
return 'zh-CN';
|
return 'zh-CN';
|
||||||
}
|
}
|
||||||
const raw = navigator.languages?.[0] || navigator.language || 'zh-CN';
|
const raw = navigator.languages?.[0] || navigator.language || 'zh-CN';
|
||||||
return raw.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en';
|
const lower = raw.toLowerCase();
|
||||||
|
if (lower.startsWith('zh')) return 'zh-CN';
|
||||||
|
if (lower.startsWith('ru')) return 'ru';
|
||||||
|
return 'en';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getInitialLanguage = (): Language => getStoredLanguage() ?? getBrowserLanguage();
|
export const getInitialLanguage = (): Language => getStoredLanguage() ?? getBrowserLanguage();
|
||||||
|
|||||||
@@ -30,12 +30,15 @@ const matchCategory = (text: string) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function normalizeModelList(payload: any, { dedupe = false } = {}): ModelInfo[] {
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
const toModel = (entry: any): ModelInfo | null => {
|
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
|
||||||
|
export function normalizeModelList(payload: unknown, { dedupe = false } = {}): ModelInfo[] {
|
||||||
|
const toModel = (entry: unknown): ModelInfo | null => {
|
||||||
if (typeof entry === 'string') {
|
if (typeof entry === 'string') {
|
||||||
return { name: entry };
|
return { name: entry };
|
||||||
}
|
}
|
||||||
if (!entry || typeof entry !== 'object') {
|
if (!isRecord(entry)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const name = entry.id || entry.name || entry.model || entry.value;
|
const name = entry.id || entry.name || entry.model || entry.value;
|
||||||
@@ -57,7 +60,7 @@ export function normalizeModelList(payload: any, { dedupe = false } = {}): Model
|
|||||||
|
|
||||||
if (Array.isArray(payload)) {
|
if (Array.isArray(payload)) {
|
||||||
models = payload.map(toModel);
|
models = payload.map(toModel);
|
||||||
} else if (payload && typeof payload === 'object') {
|
} else if (isRecord(payload)) {
|
||||||
if (Array.isArray(payload.data)) {
|
if (Array.isArray(payload.data)) {
|
||||||
models = payload.data.map(toModel);
|
models = payload.data.map(toModel);
|
||||||
} else if (Array.isArray(payload.models)) {
|
} else if (Array.isArray(payload.models)) {
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import type {
|
|||||||
GeminiCliParsedBucket,
|
GeminiCliParsedBucket,
|
||||||
GeminiCliQuotaBucketState,
|
GeminiCliQuotaBucketState,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import { ANTIGRAVITY_QUOTA_GROUPS, GEMINI_CLI_GROUP_LOOKUP } from './constants';
|
import {
|
||||||
|
ANTIGRAVITY_QUOTA_GROUPS,
|
||||||
|
GEMINI_CLI_GROUP_LOOKUP,
|
||||||
|
GEMINI_CLI_GROUP_ORDER,
|
||||||
|
} from './constants';
|
||||||
import { normalizeQuotaFraction } from './parsers';
|
import { normalizeQuotaFraction } from './parsers';
|
||||||
import { isIgnoredGeminiCliModel } from './validators';
|
import { isIgnoredGeminiCliModel } from './validators';
|
||||||
|
|
||||||
@@ -92,24 +96,40 @@ export function buildGeminiCliQuotaBuckets(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(grouped.values()).map((bucket) => {
|
const toGroupOrder = (bucket: GeminiCliQuotaBucketGroup): number => {
|
||||||
const uniqueModelIds = Array.from(new Set(bucket.modelIds));
|
const tokenSuffix = bucket.tokenType ? `-${bucket.tokenType}` : '';
|
||||||
const preferred = bucket.preferredBucket;
|
const groupId = bucket.id.endsWith(tokenSuffix)
|
||||||
const remainingFraction = preferred
|
? bucket.id.slice(0, bucket.id.length - tokenSuffix.length)
|
||||||
? preferred.remainingFraction
|
: bucket.id;
|
||||||
: bucket.fallbackRemainingFraction;
|
return GEMINI_CLI_GROUP_ORDER.get(groupId) ?? Number.MAX_SAFE_INTEGER;
|
||||||
const remainingAmount = preferred ? preferred.remainingAmount : bucket.fallbackRemainingAmount;
|
};
|
||||||
const resetTime = preferred ? preferred.resetTime : bucket.fallbackResetTime;
|
|
||||||
return {
|
return Array.from(grouped.values())
|
||||||
id: bucket.id,
|
.sort((a, b) => {
|
||||||
label: bucket.label,
|
const orderDiff = toGroupOrder(a) - toGroupOrder(b);
|
||||||
remainingFraction,
|
if (orderDiff !== 0) return orderDiff;
|
||||||
remainingAmount,
|
const tokenTypeA = a.tokenType ?? '';
|
||||||
resetTime,
|
const tokenTypeB = b.tokenType ?? '';
|
||||||
tokenType: bucket.tokenType,
|
return tokenTypeA.localeCompare(tokenTypeB);
|
||||||
modelIds: uniqueModelIds,
|
})
|
||||||
};
|
.map((bucket) => {
|
||||||
});
|
const uniqueModelIds = Array.from(new Set(bucket.modelIds));
|
||||||
|
const preferred = bucket.preferredBucket;
|
||||||
|
const remainingFraction = preferred
|
||||||
|
? preferred.remainingFraction
|
||||||
|
: bucket.fallbackRemainingFraction;
|
||||||
|
const remainingAmount = preferred ? preferred.remainingAmount : bucket.fallbackRemainingAmount;
|
||||||
|
const resetTime = preferred ? preferred.resetTime : bucket.fallbackResetTime;
|
||||||
|
return {
|
||||||
|
id: bucket.id,
|
||||||
|
label: bucket.label,
|
||||||
|
remainingFraction,
|
||||||
|
remainingAmount,
|
||||||
|
resetTime,
|
||||||
|
tokenType: bucket.tokenType,
|
||||||
|
modelIds: uniqueModelIds,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): {
|
export function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): {
|
||||||
|
|||||||
@@ -123,11 +123,17 @@ export const GEMINI_CLI_REQUEST_HEADERS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [
|
export const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'gemini-flash-lite-series',
|
||||||
|
label: 'Gemini Flash Lite Series',
|
||||||
|
preferredModelId: 'gemini-2.5-flash-lite',
|
||||||
|
modelIds: ['gemini-2.5-flash-lite'],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'gemini-flash-series',
|
id: 'gemini-flash-series',
|
||||||
label: 'Gemini Flash Series',
|
label: 'Gemini Flash Series',
|
||||||
preferredModelId: 'gemini-3-flash-preview',
|
preferredModelId: 'gemini-3-flash-preview',
|
||||||
modelIds: ['gemini-3-flash-preview', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'],
|
modelIds: ['gemini-3-flash-preview', 'gemini-2.5-flash'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gemini-pro-series',
|
id: 'gemini-pro-series',
|
||||||
@@ -137,6 +143,10 @@ export const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const GEMINI_CLI_GROUP_ORDER = new Map(
|
||||||
|
GEMINI_CLI_QUOTA_GROUPS.map((group, index) => [group.id, index] as const)
|
||||||
|
);
|
||||||
|
|
||||||
export const GEMINI_CLI_GROUP_LOOKUP = new Map(
|
export const GEMINI_CLI_GROUP_LOOKUP = new Map(
|
||||||
GEMINI_CLI_QUOTA_GROUPS.flatMap((group) =>
|
GEMINI_CLI_QUOTA_GROUPS.flatMap((group) =>
|
||||||
group.modelIds.map((modelId) => [modelId, group] as const)
|
group.modelIds.map((modelId) => [modelId, group] as const)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
import type { CodexUsagePayload, GeminiCliQuotaPayload, KiroQuotaPayload } from '@/types';
|
import type { CodexUsagePayload, GeminiCliQuotaPayload, KiroQuotaPayload } from '@/types';
|
||||||
|
|
||||||
|
const GEMINI_CLI_MODEL_SUFFIX = '_vertex';
|
||||||
|
|
||||||
export function normalizeAuthIndexValue(value: unknown): string | null {
|
export function normalizeAuthIndexValue(value: unknown): string | null {
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
return value.toString();
|
return value.toString();
|
||||||
@@ -26,6 +28,15 @@ export function normalizeStringValue(value: unknown): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeGeminiCliModelId(value: unknown): string | null {
|
||||||
|
const modelId = normalizeStringValue(value);
|
||||||
|
if (!modelId) return null;
|
||||||
|
if (modelId.endsWith(GEMINI_CLI_MODEL_SUFFIX)) {
|
||||||
|
return modelId.slice(0, -GEMINI_CLI_MODEL_SUFFIX.length);
|
||||||
|
}
|
||||||
|
return modelId;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeNumberValue(value: unknown): number | null {
|
export function normalizeNumberValue(value: unknown): number | null {
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
|
|||||||
@@ -64,7 +64,16 @@ export interface ApiStats {
|
|||||||
const TOKENS_PER_PRICE_UNIT = 1_000_000;
|
const TOKENS_PER_PRICE_UNIT = 1_000_000;
|
||||||
const MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices-v2';
|
const MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices-v2';
|
||||||
|
|
||||||
const normalizeAuthIndex = (value: any) => {
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
|
||||||
|
const getApisRecord = (usageData: unknown): Record<string, unknown> | null => {
|
||||||
|
const usageRecord = isRecord(usageData) ? usageData : null;
|
||||||
|
const apisRaw = usageRecord ? usageRecord.apis : null;
|
||||||
|
return isRecord(apisRaw) ? apisRaw : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeAuthIndex = (value: unknown) => {
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
return value.toString();
|
return value.toString();
|
||||||
}
|
}
|
||||||
@@ -306,24 +315,29 @@ export function formatUsd(value: number): string {
|
|||||||
/**
|
/**
|
||||||
* 从使用数据中收集所有请求明细
|
* 从使用数据中收集所有请求明细
|
||||||
*/
|
*/
|
||||||
export function collectUsageDetails(usageData: any): UsageDetail[] {
|
export function collectUsageDetails(usageData: unknown): UsageDetail[] {
|
||||||
if (!usageData) {
|
const apis = getApisRecord(usageData);
|
||||||
return [];
|
if (!apis) return [];
|
||||||
}
|
|
||||||
const apis = usageData.apis || {};
|
|
||||||
const details: UsageDetail[] = [];
|
const details: UsageDetail[] = [];
|
||||||
Object.values(apis as Record<string, any>).forEach((apiEntry) => {
|
Object.values(apis).forEach((apiEntry) => {
|
||||||
const models = apiEntry?.models || {};
|
if (!isRecord(apiEntry)) return;
|
||||||
Object.entries(models as Record<string, any>).forEach(([modelName, modelEntry]) => {
|
const modelsRaw = apiEntry.models;
|
||||||
const modelDetails = Array.isArray(modelEntry.details) ? modelEntry.details : [];
|
const models = isRecord(modelsRaw) ? modelsRaw : null;
|
||||||
modelDetails.forEach((detail: any) => {
|
if (!models) return;
|
||||||
if (detail && detail.timestamp) {
|
|
||||||
details.push({
|
Object.entries(models).forEach(([modelName, modelEntry]) => {
|
||||||
...detail,
|
if (!isRecord(modelEntry)) return;
|
||||||
source: normalizeUsageSourceId(detail.source),
|
const modelDetailsRaw = modelEntry.details;
|
||||||
__modelName: modelName
|
const modelDetails = Array.isArray(modelDetailsRaw) ? modelDetailsRaw : [];
|
||||||
});
|
|
||||||
}
|
modelDetails.forEach((detailRaw) => {
|
||||||
|
if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return;
|
||||||
|
const detail = detailRaw as unknown as UsageDetail;
|
||||||
|
details.push({
|
||||||
|
...detail,
|
||||||
|
source: normalizeUsageSourceId(detail.source),
|
||||||
|
__modelName: modelName,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -333,8 +347,10 @@ export function collectUsageDetails(usageData: any): UsageDetail[] {
|
|||||||
/**
|
/**
|
||||||
* 从单条明细提取总 tokens
|
* 从单条明细提取总 tokens
|
||||||
*/
|
*/
|
||||||
export function extractTotalTokens(detail: any): number {
|
export function extractTotalTokens(detail: unknown): number {
|
||||||
const tokens = detail?.tokens || {};
|
const record = isRecord(detail) ? detail : null;
|
||||||
|
const tokensRaw = record?.tokens;
|
||||||
|
const tokens = isRecord(tokensRaw) ? tokensRaw : {};
|
||||||
if (typeof tokens.total_tokens === 'number') {
|
if (typeof tokens.total_tokens === 'number') {
|
||||||
return tokens.total_tokens;
|
return tokens.total_tokens;
|
||||||
}
|
}
|
||||||
@@ -352,7 +368,7 @@ export function extractTotalTokens(detail: any): number {
|
|||||||
/**
|
/**
|
||||||
* 计算 token 分类统计
|
* 计算 token 分类统计
|
||||||
*/
|
*/
|
||||||
export function calculateTokenBreakdown(usageData: any): TokenBreakdown {
|
export function calculateTokenBreakdown(usageData: unknown): TokenBreakdown {
|
||||||
const details = collectUsageDetails(usageData);
|
const details = collectUsageDetails(usageData);
|
||||||
if (!details.length) {
|
if (!details.length) {
|
||||||
return { cachedTokens: 0, reasoningTokens: 0 };
|
return { cachedTokens: 0, reasoningTokens: 0 };
|
||||||
@@ -361,8 +377,8 @@ export function calculateTokenBreakdown(usageData: any): TokenBreakdown {
|
|||||||
let cachedTokens = 0;
|
let cachedTokens = 0;
|
||||||
let reasoningTokens = 0;
|
let reasoningTokens = 0;
|
||||||
|
|
||||||
details.forEach(detail => {
|
details.forEach((detail) => {
|
||||||
const tokens = detail?.tokens || {};
|
const tokens = detail.tokens;
|
||||||
cachedTokens += Math.max(
|
cachedTokens += Math.max(
|
||||||
typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0,
|
typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0,
|
||||||
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0
|
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0
|
||||||
@@ -378,7 +394,10 @@ export function calculateTokenBreakdown(usageData: any): TokenBreakdown {
|
|||||||
/**
|
/**
|
||||||
* 计算最近 N 分钟的 RPM/TPM
|
* 计算最近 N 分钟的 RPM/TPM
|
||||||
*/
|
*/
|
||||||
export function calculateRecentPerMinuteRates(windowMinutes: number = 30, usageData: any): RateStats {
|
export function calculateRecentPerMinuteRates(
|
||||||
|
windowMinutes: number = 30,
|
||||||
|
usageData: unknown
|
||||||
|
): RateStats {
|
||||||
const details = collectUsageDetails(usageData);
|
const details = collectUsageDetails(usageData);
|
||||||
const effectiveWindow = Number.isFinite(windowMinutes) && windowMinutes > 0 ? windowMinutes : 30;
|
const effectiveWindow = Number.isFinite(windowMinutes) && windowMinutes > 0 ? windowMinutes : 30;
|
||||||
|
|
||||||
@@ -391,7 +410,7 @@ export function calculateRecentPerMinuteRates(windowMinutes: number = 30, usageD
|
|||||||
let requestCount = 0;
|
let requestCount = 0;
|
||||||
let tokenCount = 0;
|
let tokenCount = 0;
|
||||||
|
|
||||||
details.forEach(detail => {
|
details.forEach((detail) => {
|
||||||
const timestamp = Date.parse(detail.timestamp);
|
const timestamp = Date.parse(detail.timestamp);
|
||||||
if (Number.isNaN(timestamp) || timestamp < windowStart) {
|
if (Number.isNaN(timestamp) || timestamp < windowStart) {
|
||||||
return;
|
return;
|
||||||
@@ -413,15 +432,16 @@ export function calculateRecentPerMinuteRates(windowMinutes: number = 30, usageD
|
|||||||
/**
|
/**
|
||||||
* 从使用数据获取模型名称列表
|
* 从使用数据获取模型名称列表
|
||||||
*/
|
*/
|
||||||
export function getModelNamesFromUsage(usageData: any): string[] {
|
export function getModelNamesFromUsage(usageData: unknown): string[] {
|
||||||
if (!usageData) {
|
const apis = getApisRecord(usageData);
|
||||||
return [];
|
if (!apis) return [];
|
||||||
}
|
|
||||||
const apis = usageData.apis || {};
|
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
Object.values(apis as Record<string, any>).forEach(apiEntry => {
|
Object.values(apis).forEach((apiEntry) => {
|
||||||
const models = apiEntry?.models || {};
|
if (!isRecord(apiEntry)) return;
|
||||||
Object.keys(models).forEach(modelName => {
|
const modelsRaw = apiEntry.models;
|
||||||
|
const models = isRecord(modelsRaw) ? modelsRaw : null;
|
||||||
|
if (!models) return;
|
||||||
|
Object.keys(models).forEach((modelName) => {
|
||||||
if (modelName) {
|
if (modelName) {
|
||||||
names.add(modelName);
|
names.add(modelName);
|
||||||
}
|
}
|
||||||
@@ -433,13 +453,13 @@ export function getModelNamesFromUsage(usageData: any): string[] {
|
|||||||
/**
|
/**
|
||||||
* 计算成本数据
|
* 计算成本数据
|
||||||
*/
|
*/
|
||||||
export function calculateCost(detail: any, modelPrices: Record<string, ModelPrice>): number {
|
export function calculateCost(detail: UsageDetail, modelPrices: Record<string, ModelPrice>): number {
|
||||||
const modelName = detail.__modelName || '';
|
const modelName = detail.__modelName || '';
|
||||||
const price = modelPrices[modelName];
|
const price = modelPrices[modelName];
|
||||||
if (!price) {
|
if (!price) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const tokens = detail?.tokens || {};
|
const tokens = detail.tokens;
|
||||||
const rawInputTokens = Number(tokens.input_tokens);
|
const rawInputTokens = Number(tokens.input_tokens);
|
||||||
const rawCompletionTokens = Number(tokens.output_tokens);
|
const rawCompletionTokens = Number(tokens.output_tokens);
|
||||||
const rawCachedTokensPrimary = Number(tokens.cached_tokens);
|
const rawCachedTokensPrimary = Number(tokens.cached_tokens);
|
||||||
@@ -463,7 +483,7 @@ export function calculateCost(detail: any, modelPrices: Record<string, ModelPric
|
|||||||
/**
|
/**
|
||||||
* 计算总成本
|
* 计算总成本
|
||||||
*/
|
*/
|
||||||
export function calculateTotalCost(usageData: any, modelPrices: Record<string, ModelPrice>): number {
|
export function calculateTotalCost(usageData: unknown, modelPrices: Record<string, ModelPrice>): number {
|
||||||
const details = collectUsageDetails(usageData);
|
const details = collectUsageDetails(usageData);
|
||||||
if (!details.length || !Object.keys(modelPrices).length) {
|
if (!details.length || !Object.keys(modelPrices).length) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -483,16 +503,17 @@ export function loadModelPrices(): Record<string, ModelPrice> {
|
|||||||
if (!raw) {
|
if (!raw) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
const parsed = JSON.parse(raw);
|
const parsed: unknown = JSON.parse(raw);
|
||||||
if (!parsed || typeof parsed !== 'object') {
|
if (!isRecord(parsed)) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
const normalized: Record<string, ModelPrice> = {};
|
const normalized: Record<string, ModelPrice> = {};
|
||||||
Object.entries(parsed).forEach(([model, price]: [string, any]) => {
|
Object.entries(parsed).forEach(([model, price]: [string, unknown]) => {
|
||||||
if (!model) return;
|
if (!model) return;
|
||||||
const promptRaw = Number(price?.prompt);
|
const priceRecord = isRecord(price) ? price : null;
|
||||||
const completionRaw = Number(price?.completion);
|
const promptRaw = Number(priceRecord?.prompt);
|
||||||
const cacheRaw = Number(price?.cache);
|
const completionRaw = Number(priceRecord?.completion);
|
||||||
|
const cacheRaw = Number(priceRecord?.cache);
|
||||||
|
|
||||||
if (!Number.isFinite(promptRaw) && !Number.isFinite(completionRaw) && !Number.isFinite(cacheRaw)) {
|
if (!Number.isFinite(promptRaw) && !Number.isFinite(completionRaw) && !Number.isFinite(cacheRaw)) {
|
||||||
return;
|
return;
|
||||||
@@ -536,21 +557,21 @@ export function saveModelPrices(prices: Record<string, ModelPrice>): void {
|
|||||||
/**
|
/**
|
||||||
* 获取 API 统计数据
|
* 获取 API 统计数据
|
||||||
*/
|
*/
|
||||||
export function getApiStats(usageData: any, modelPrices: Record<string, ModelPrice>): ApiStats[] {
|
export function getApiStats(usageData: unknown, modelPrices: Record<string, ModelPrice>): ApiStats[] {
|
||||||
if (!usageData?.apis) {
|
const apis = getApisRecord(usageData);
|
||||||
return [];
|
if (!apis) return [];
|
||||||
}
|
|
||||||
const apis = usageData.apis;
|
|
||||||
const result: ApiStats[] = [];
|
const result: ApiStats[] = [];
|
||||||
|
|
||||||
Object.entries(apis as Record<string, any>).forEach(([endpoint, apiData]) => {
|
Object.entries(apis).forEach(([endpoint, apiData]) => {
|
||||||
|
if (!isRecord(apiData)) return;
|
||||||
const models: Record<string, { requests: number; successCount: number; failureCount: number; tokens: number }> = {};
|
const models: Record<string, { requests: number; successCount: number; failureCount: number; tokens: number }> = {};
|
||||||
let derivedSuccessCount = 0;
|
let derivedSuccessCount = 0;
|
||||||
let derivedFailureCount = 0;
|
let derivedFailureCount = 0;
|
||||||
let totalCost = 0;
|
let totalCost = 0;
|
||||||
|
|
||||||
const modelsData = apiData?.models || {};
|
const modelsData = isRecord(apiData.models) ? apiData.models : {};
|
||||||
Object.entries(modelsData as Record<string, any>).forEach(([modelName, modelData]) => {
|
Object.entries(modelsData).forEach(([modelName, modelData]) => {
|
||||||
|
if (!isRecord(modelData)) return;
|
||||||
const details = Array.isArray(modelData.details) ? modelData.details : [];
|
const details = Array.isArray(modelData.details) ? modelData.details : [];
|
||||||
const hasExplicitCounts =
|
const hasExplicitCounts =
|
||||||
typeof modelData.success_count === 'number' || typeof modelData.failure_count === 'number';
|
typeof modelData.success_count === 'number' || typeof modelData.failure_count === 'number';
|
||||||
@@ -564,46 +585,50 @@ export function getApiStats(usageData: any, modelPrices: Record<string, ModelPri
|
|||||||
|
|
||||||
const price = modelPrices[modelName];
|
const price = modelPrices[modelName];
|
||||||
if (details.length > 0 && (!hasExplicitCounts || price)) {
|
if (details.length > 0 && (!hasExplicitCounts || price)) {
|
||||||
details.forEach((detail: any) => {
|
details.forEach((detail) => {
|
||||||
|
const detailRecord = isRecord(detail) ? detail : null;
|
||||||
if (!hasExplicitCounts) {
|
if (!hasExplicitCounts) {
|
||||||
if (detail?.failed === true) {
|
if (detailRecord?.failed === true) {
|
||||||
failureCount += 1;
|
failureCount += 1;
|
||||||
} else {
|
} else {
|
||||||
successCount += 1;
|
successCount += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (price) {
|
if (price && detailRecord) {
|
||||||
totalCost += calculateCost({ ...detail, __modelName: modelName }, modelPrices);
|
totalCost += calculateCost(
|
||||||
|
{ ...(detailRecord as unknown as UsageDetail), __modelName: modelName },
|
||||||
|
modelPrices
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
models[modelName] = {
|
models[modelName] = {
|
||||||
requests: modelData.total_requests || 0,
|
requests: Number(modelData.total_requests) || 0,
|
||||||
successCount,
|
successCount,
|
||||||
failureCount,
|
failureCount,
|
||||||
tokens: modelData.total_tokens || 0
|
tokens: Number(modelData.total_tokens) || 0
|
||||||
};
|
};
|
||||||
derivedSuccessCount += successCount;
|
derivedSuccessCount += successCount;
|
||||||
derivedFailureCount += failureCount;
|
derivedFailureCount += failureCount;
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasApiExplicitCounts =
|
const hasApiExplicitCounts =
|
||||||
typeof apiData?.success_count === 'number' || typeof apiData?.failure_count === 'number';
|
typeof apiData.success_count === 'number' || typeof apiData.failure_count === 'number';
|
||||||
const successCount = hasApiExplicitCounts
|
const successCount = hasApiExplicitCounts
|
||||||
? (Number(apiData?.success_count) || 0)
|
? (Number(apiData.success_count) || 0)
|
||||||
: derivedSuccessCount;
|
: derivedSuccessCount;
|
||||||
const failureCount = hasApiExplicitCounts
|
const failureCount = hasApiExplicitCounts
|
||||||
? (Number(apiData?.failure_count) || 0)
|
? (Number(apiData.failure_count) || 0)
|
||||||
: derivedFailureCount;
|
: derivedFailureCount;
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
endpoint: maskUsageSensitiveValue(endpoint) || endpoint,
|
endpoint: maskUsageSensitiveValue(endpoint) || endpoint,
|
||||||
totalRequests: apiData.total_requests || 0,
|
totalRequests: Number(apiData.total_requests) || 0,
|
||||||
successCount,
|
successCount,
|
||||||
failureCount,
|
failureCount,
|
||||||
totalTokens: apiData.total_tokens || 0,
|
totalTokens: Number(apiData.total_tokens) || 0,
|
||||||
totalCost,
|
totalCost,
|
||||||
models
|
models
|
||||||
});
|
});
|
||||||
@@ -615,7 +640,7 @@ export function getApiStats(usageData: any, modelPrices: Record<string, ModelPri
|
|||||||
/**
|
/**
|
||||||
* 获取模型统计数据
|
* 获取模型统计数据
|
||||||
*/
|
*/
|
||||||
export function getModelStats(usageData: any, modelPrices: Record<string, ModelPrice>): Array<{
|
export function getModelStats(usageData: unknown, modelPrices: Record<string, ModelPrice>): Array<{
|
||||||
model: string;
|
model: string;
|
||||||
requests: number;
|
requests: number;
|
||||||
successCount: number;
|
successCount: number;
|
||||||
@@ -623,18 +648,22 @@ export function getModelStats(usageData: any, modelPrices: Record<string, ModelP
|
|||||||
tokens: number;
|
tokens: number;
|
||||||
cost: number;
|
cost: number;
|
||||||
}> {
|
}> {
|
||||||
if (!usageData?.apis) {
|
const apis = getApisRecord(usageData);
|
||||||
return [];
|
if (!apis) return [];
|
||||||
}
|
|
||||||
|
|
||||||
const modelMap = new Map<string, { requests: number; successCount: number; failureCount: number; tokens: number; cost: number }>();
|
const modelMap = new Map<string, { requests: number; successCount: number; failureCount: number; tokens: number; cost: number }>();
|
||||||
|
|
||||||
Object.values(usageData.apis as Record<string, any>).forEach(apiData => {
|
Object.values(apis).forEach((apiData) => {
|
||||||
const models = apiData?.models || {};
|
if (!isRecord(apiData)) return;
|
||||||
Object.entries(models as Record<string, any>).forEach(([modelName, modelData]) => {
|
const modelsRaw = apiData.models;
|
||||||
|
const models = isRecord(modelsRaw) ? modelsRaw : null;
|
||||||
|
if (!models) return;
|
||||||
|
|
||||||
|
Object.entries(models).forEach(([modelName, modelData]) => {
|
||||||
|
if (!isRecord(modelData)) return;
|
||||||
const existing = modelMap.get(modelName) || { requests: 0, successCount: 0, failureCount: 0, tokens: 0, cost: 0 };
|
const existing = modelMap.get(modelName) || { requests: 0, successCount: 0, failureCount: 0, tokens: 0, cost: 0 };
|
||||||
existing.requests += modelData.total_requests || 0;
|
existing.requests += Number(modelData.total_requests) || 0;
|
||||||
existing.tokens += modelData.total_tokens || 0;
|
existing.tokens += Number(modelData.total_tokens) || 0;
|
||||||
|
|
||||||
const details = Array.isArray(modelData.details) ? modelData.details : [];
|
const details = Array.isArray(modelData.details) ? modelData.details : [];
|
||||||
|
|
||||||
@@ -648,17 +677,21 @@ export function getModelStats(usageData: any, modelPrices: Record<string, ModelP
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (details.length > 0 && (!hasExplicitCounts || price)) {
|
if (details.length > 0 && (!hasExplicitCounts || price)) {
|
||||||
details.forEach((detail: any) => {
|
details.forEach((detail) => {
|
||||||
|
const detailRecord = isRecord(detail) ? detail : null;
|
||||||
if (!hasExplicitCounts) {
|
if (!hasExplicitCounts) {
|
||||||
if (detail?.failed === true) {
|
if (detailRecord?.failed === true) {
|
||||||
existing.failureCount += 1;
|
existing.failureCount += 1;
|
||||||
} else {
|
} else {
|
||||||
existing.successCount += 1;
|
existing.successCount += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (price) {
|
if (price && detailRecord) {
|
||||||
existing.cost += calculateCost({ ...detail, __modelName: modelName }, modelPrices);
|
existing.cost += calculateCost(
|
||||||
|
{ ...(detailRecord as unknown as UsageDetail), __modelName: modelName },
|
||||||
|
modelPrices
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -700,7 +733,10 @@ export function formatDayLabel(date: Date): string {
|
|||||||
/**
|
/**
|
||||||
* 构建小时级别的数据序列
|
* 构建小时级别的数据序列
|
||||||
*/
|
*/
|
||||||
export function buildHourlySeriesByModel(usageData: any, metric: 'requests' | 'tokens' = 'requests'): {
|
export function buildHourlySeriesByModel(
|
||||||
|
usageData: unknown,
|
||||||
|
metric: 'requests' | 'tokens' = 'requests'
|
||||||
|
): {
|
||||||
labels: string[];
|
labels: string[];
|
||||||
dataByModel: Map<string, number[]>;
|
dataByModel: Map<string, number[]>;
|
||||||
hasData: boolean;
|
hasData: boolean;
|
||||||
@@ -728,7 +764,7 @@ export function buildHourlySeriesByModel(usageData: any, metric: 'requests' | 't
|
|||||||
return { labels, dataByModel, hasData };
|
return { labels, dataByModel, hasData };
|
||||||
}
|
}
|
||||||
|
|
||||||
details.forEach(detail => {
|
details.forEach((detail) => {
|
||||||
const timestamp = Date.parse(detail.timestamp);
|
const timestamp = Date.parse(detail.timestamp);
|
||||||
if (Number.isNaN(timestamp)) {
|
if (Number.isNaN(timestamp)) {
|
||||||
return;
|
return;
|
||||||
@@ -767,7 +803,10 @@ export function buildHourlySeriesByModel(usageData: any, metric: 'requests' | 't
|
|||||||
/**
|
/**
|
||||||
* 构建日级别的数据序列
|
* 构建日级别的数据序列
|
||||||
*/
|
*/
|
||||||
export function buildDailySeriesByModel(usageData: any, metric: 'requests' | 'tokens' = 'requests'): {
|
export function buildDailySeriesByModel(
|
||||||
|
usageData: unknown,
|
||||||
|
metric: 'requests' | 'tokens' = 'requests'
|
||||||
|
): {
|
||||||
labels: string[];
|
labels: string[];
|
||||||
dataByModel: Map<string, number[]>;
|
dataByModel: Map<string, number[]>;
|
||||||
hasData: boolean;
|
hasData: boolean;
|
||||||
@@ -781,7 +820,7 @@ export function buildDailySeriesByModel(usageData: any, metric: 'requests' | 'to
|
|||||||
return { labels: [], dataByModel: new Map(), hasData };
|
return { labels: [], dataByModel: new Map(), hasData };
|
||||||
}
|
}
|
||||||
|
|
||||||
details.forEach(detail => {
|
details.forEach((detail) => {
|
||||||
const timestamp = Date.parse(detail.timestamp);
|
const timestamp = Date.parse(detail.timestamp);
|
||||||
if (Number.isNaN(timestamp)) {
|
if (Number.isNaN(timestamp)) {
|
||||||
return;
|
return;
|
||||||
@@ -885,7 +924,7 @@ const buildAreaGradient = (context: ScriptableContext<'line'>, baseHex: string,
|
|||||||
* 构建图表数据
|
* 构建图表数据
|
||||||
*/
|
*/
|
||||||
export function buildChartData(
|
export function buildChartData(
|
||||||
usageData: any,
|
usageData: unknown,
|
||||||
period: 'hour' | 'day' = 'day',
|
period: 'hour' | 'day' = 'day',
|
||||||
metric: 'requests' | 'tokens' = 'requests',
|
metric: 'requests' | 'tokens' = 'requests',
|
||||||
selectedModels: string[] = []
|
selectedModels: string[] = []
|
||||||
@@ -1034,8 +1073,9 @@ export function calculateStatusBarData(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computeKeyStats(usageData: any, masker: (val: string) => string = maskApiKey): KeyStats {
|
export function computeKeyStats(usageData: unknown, masker: (val: string) => string = maskApiKey): KeyStats {
|
||||||
if (!usageData) {
|
const apis = getApisRecord(usageData);
|
||||||
|
if (!apis) {
|
||||||
return { bySource: {}, byAuthIndex: {} };
|
return { bySource: {}, byAuthIndex: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1049,17 +1089,21 @@ export function computeKeyStats(usageData: any, masker: (val: string) => string
|
|||||||
return bucket[key];
|
return bucket[key];
|
||||||
};
|
};
|
||||||
|
|
||||||
const apis = usageData.apis || {};
|
Object.values(apis).forEach((apiEntry) => {
|
||||||
Object.values(apis as any).forEach((apiEntry: any) => {
|
if (!isRecord(apiEntry)) return;
|
||||||
const models = apiEntry?.models || {};
|
const modelsRaw = apiEntry.models;
|
||||||
|
const models = isRecord(modelsRaw) ? modelsRaw : null;
|
||||||
|
if (!models) return;
|
||||||
|
|
||||||
Object.values(models as any).forEach((modelEntry: any) => {
|
Object.values(models).forEach((modelEntry) => {
|
||||||
const details = modelEntry?.details || [];
|
if (!isRecord(modelEntry)) return;
|
||||||
|
const details = Array.isArray(modelEntry.details) ? modelEntry.details : [];
|
||||||
|
|
||||||
details.forEach((detail: any) => {
|
details.forEach((detail) => {
|
||||||
const source = normalizeUsageSourceId(detail?.source, masker);
|
const detailRecord = isRecord(detail) ? detail : null;
|
||||||
const authIndexKey = normalizeAuthIndex(detail?.auth_index);
|
const source = normalizeUsageSourceId(detailRecord?.source, masker);
|
||||||
const isFailed = detail?.failed === true;
|
const authIndexKey = normalizeAuthIndex(detailRecord?.auth_index);
|
||||||
|
const isFailed = detailRecord?.failed === true;
|
||||||
|
|
||||||
if (source) {
|
if (source) {
|
||||||
const bucket = ensureBucket(sourceStats, source);
|
const bucket = ensureBucket(sourceStats, source);
|
||||||
|
|||||||
Reference in New Issue
Block a user