mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
28 Commits
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { IconX } from '@/components/ui/icons';
|
||||
import type { Notification } from '@/types';
|
||||
@@ -10,52 +11,37 @@ interface AnimatedNotification extends Notification {
|
||||
const ANIMATION_DURATION = 300; // ms
|
||||
|
||||
export function NotificationContainer() {
|
||||
const { t } = useTranslation();
|
||||
const { notifications, removeNotification } = useNotificationStore();
|
||||
const [animatedNotifications, setAnimatedNotifications] = useState<AnimatedNotification[]>([]);
|
||||
const prevNotificationsRef = useRef<Notification[]>([]);
|
||||
|
||||
// Track notifications and manage animation states
|
||||
useEffect(() => {
|
||||
const prevNotifications = prevNotificationsRef.current;
|
||||
const prevIds = new Set(prevNotifications.map((n) => n.id));
|
||||
const currentIds = new Set(notifications.map((n) => n.id));
|
||||
|
||||
// Find new notifications (for enter animation)
|
||||
const newNotifications = notifications.filter((n) => !prevIds.has(n.id));
|
||||
|
||||
// Find removed notifications (for exit animation)
|
||||
const removedIds = new Set(
|
||||
prevNotifications.filter((n) => !currentIds.has(n.id)).map((n) => n.id)
|
||||
);
|
||||
const removedIds = new Set(prevNotifications.filter((n) => !currentIds.has(n.id)).map((n) => n.id));
|
||||
|
||||
setAnimatedNotifications((prev) => {
|
||||
// Mark removed notifications as exiting
|
||||
let updated = prev.map((n) =>
|
||||
removedIds.has(n.id) ? { ...n, isExiting: true } : n
|
||||
);
|
||||
let updated = prev.map((n) => (removedIds.has(n.id) ? { ...n, isExiting: true } : n));
|
||||
|
||||
// Add new notifications
|
||||
newNotifications.forEach((n) => {
|
||||
if (!updated.find((an) => an.id === n.id)) {
|
||||
if (!updated.find((animatedNotification) => animatedNotification.id === n.id)) {
|
||||
updated.push({ ...n, isExiting: false });
|
||||
}
|
||||
});
|
||||
|
||||
// Remove notifications that are not in current and not exiting
|
||||
// (they've already completed their exit animation)
|
||||
updated = updated.filter(
|
||||
(n) => currentIds.has(n.id) || n.isExiting
|
||||
);
|
||||
updated = updated.filter((n) => currentIds.has(n.id) || n.isExiting);
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Clean up exited notifications after animation
|
||||
if (removedIds.size > 0) {
|
||||
setTimeout(() => {
|
||||
setAnimatedNotifications((prev) =>
|
||||
prev.filter((n) => !removedIds.has(n.id))
|
||||
);
|
||||
setAnimatedNotifications((prev) => prev.filter((n) => !removedIds.has(n.id)));
|
||||
}, ANIMATION_DURATION);
|
||||
}
|
||||
|
||||
@@ -63,12 +49,8 @@ export function NotificationContainer() {
|
||||
}, [notifications]);
|
||||
|
||||
const handleClose = (id: string) => {
|
||||
// Start exit animation
|
||||
setAnimatedNotifications((prev) =>
|
||||
prev.map((n) => (n.id === id ? { ...n, isExiting: true } : n))
|
||||
);
|
||||
setAnimatedNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, isExiting: true } : n)));
|
||||
|
||||
// Actually remove after animation
|
||||
setTimeout(() => {
|
||||
removeNotification(id);
|
||||
}, ANIMATION_DURATION);
|
||||
@@ -84,7 +66,12 @@ export function NotificationContainer() {
|
||||
className={`notification ${notification.type} ${notification.isExiting ? 'exiting' : 'entering'}`}
|
||||
>
|
||||
<div className="message">{notification.message}</div>
|
||||
<button className="close-btn" onClick={() => handleClose(notification.id)} aria-label="Close">
|
||||
<button
|
||||
type="button"
|
||||
className="close-btn"
|
||||
onClick={() => handleClose(notification.id)}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,22 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.payloadRuleParamGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.payloadJsonInput {
|
||||
min-height: 96px;
|
||||
resize: vertical;
|
||||
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
.payloadParamError {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.payloadFilterModelRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 160px auto;
|
||||
@@ -23,6 +39,16 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.apiKeyModalInputRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
:global(.input) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.payloadRuleModelRow,
|
||||
.payloadRuleModelRowProtocolFirst,
|
||||
@@ -31,6 +57,11 @@
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.apiKeyModalInputRow {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.payloadRowActionButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,36 +1,38 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
import { useCallback, useId, type ReactNode } 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 { Select } from '@/components/ui/Select';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { ConfigSection } from '@/components/config/ConfigSection';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import styles from './VisualConfigEditor.module.scss';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import type {
|
||||
PayloadFilterRule,
|
||||
PayloadModelEntry,
|
||||
PayloadParamEntry,
|
||||
PayloadParamValueType,
|
||||
PayloadParamValidationErrorCode,
|
||||
PayloadRule,
|
||||
VisualConfigValidationErrorCode,
|
||||
VisualConfigValidationErrors,
|
||||
VisualConfigValues,
|
||||
} from '@/types/visualConfig';
|
||||
import { makeClientId } from '@/types/visualConfig';
|
||||
import {
|
||||
VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS,
|
||||
VISUAL_CONFIG_PROTOCOL_OPTIONS,
|
||||
} from '@/hooks/useVisualConfig';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { isValidApiKeyCharset } from '@/utils/validation';
|
||||
ApiKeysCardEditor,
|
||||
PayloadFilterRulesEditor,
|
||||
PayloadRulesEditor,
|
||||
} from './VisualConfigEditorBlocks';
|
||||
|
||||
interface VisualConfigEditorProps {
|
||||
values: VisualConfigValues;
|
||||
validationErrors?: VisualConfigValidationErrors;
|
||||
disabled?: boolean;
|
||||
onChange: (values: Partial<VisualConfigValues>) => void;
|
||||
}
|
||||
|
||||
function getValidationMessage(
|
||||
t: ReturnType<typeof useTranslation>['t'],
|
||||
errorCode?: VisualConfigValidationErrorCode | PayloadParamValidationErrorCode
|
||||
) {
|
||||
if (!errorCode) return undefined;
|
||||
return t(`config_management.visual.validation.${errorCode}`);
|
||||
}
|
||||
|
||||
type ToggleRowProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
@@ -81,647 +83,43 @@ function Divider() {
|
||||
return <div style={{ height: 1, background: 'var(--border-color)', margin: '16px 0' }} />;
|
||||
}
|
||||
|
||||
function ApiKeysCardEditor({
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
onChange: (nextValue: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const apiKeys = useMemo(
|
||||
() =>
|
||||
value
|
||||
.split('\n')
|
||||
.map((key) => key.trim())
|
||||
.filter(Boolean),
|
||||
[value]
|
||||
);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
|
||||
function generateSecureApiKey(): string {
|
||||
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const array = new Uint8Array(17);
|
||||
crypto.getRandomValues(array);
|
||||
return 'sk-' + Array.from(array, (b) => charset[b % charset.length]).join('');
|
||||
}
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingIndex(null);
|
||||
setInputValue('');
|
||||
setFormError('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (index: number) => {
|
||||
setEditingIndex(index);
|
||||
setInputValue(apiKeys[index] ?? '');
|
||||
setFormError('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
setInputValue('');
|
||||
setEditingIndex(null);
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
const updateApiKeys = (nextKeys: string[]) => {
|
||||
onChange(nextKeys.join('\n'));
|
||||
};
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
updateApiKeys(apiKeys.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmed = inputValue.trim();
|
||||
if (!trimmed) {
|
||||
setFormError(t('config_management.visual.api_keys.error_empty'));
|
||||
return;
|
||||
}
|
||||
if (!isValidApiKeyCharset(trimmed)) {
|
||||
setFormError(t('config_management.visual.api_keys.error_invalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
const nextKeys =
|
||||
editingIndex === null
|
||||
? [...apiKeys, trimmed]
|
||||
: apiKeys.map((key, idx) => (idx === editingIndex ? trimmed : key));
|
||||
updateApiKeys(nextKeys);
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const handleCopy = async (apiKey: string) => {
|
||||
const copied = await copyToClipboard(apiKey);
|
||||
showNotification(
|
||||
t(copied ? 'notification.link_copied' : 'notification.copy_failed'),
|
||||
copied ? 'success' : 'error'
|
||||
);
|
||||
};
|
||||
|
||||
const handleGenerate = () => {
|
||||
setInputValue(generateSecureApiKey());
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<label style={{ margin: 0 }}>{t('config_management.visual.api_keys.label')}</label>
|
||||
<Button size="sm" onClick={openAddModal} disabled={disabled}>
|
||||
{t('config_management.visual.api_keys.add')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{apiKeys.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
border: '1px dashed var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
color: 'var(--text-secondary)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{t('config_management.visual.api_keys.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="item-list" style={{ marginTop: 4 }}>
|
||||
{apiKeys.map((key, index) => (
|
||||
<div key={`${key}-${index}`} className="item-row">
|
||||
<div className="item-meta">
|
||||
<div className="pill">#{index + 1}</div>
|
||||
<div className="item-title">API Key</div>
|
||||
<div className="item-subtitle">{maskApiKey(String(key || ''))}</div>
|
||||
</div>
|
||||
<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}>
|
||||
{t('config_management.visual.common.edit')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => handleDelete(index)} disabled={disabled}>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="hint">{t('config_management.visual.api_keys.hint')}</div>
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
onClose={closeModal}
|
||||
title={editingIndex !== null ? t('config_management.visual.api_keys.edit_title') : t('config_management.visual.api_keys.add_title')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={closeModal} disabled={disabled}>
|
||||
{t('config_management.visual.common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={disabled}>
|
||||
{editingIndex !== null ? t('config_management.visual.common.update') : t('config_management.visual.common.add')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t('config_management.visual.api_keys.input_label')}
|
||||
placeholder={t('config_management.visual.api_keys.input_placeholder')}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
disabled={disabled}
|
||||
error={formError || undefined}
|
||||
hint={t('config_management.visual.api_keys.input_hint')}
|
||||
style={{ paddingRight: 148 }}
|
||||
rightElement={
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleGenerate}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.api_keys.generate')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StringListEditor({
|
||||
value,
|
||||
disabled,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
value: string[];
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
onChange: (next: string[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const items = value.length ? value : [];
|
||||
|
||||
const updateItem = (index: number, nextValue: string) =>
|
||||
onChange(items.map((item, i) => (i === index ? nextValue : item)));
|
||||
const addItem = () => onChange([...items, '']);
|
||||
const removeItem = (index: number) => onChange(items.filter((_, i) => i !== index));
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((item, index) => (
|
||||
<div key={index} style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={placeholder}
|
||||
value={item}
|
||||
onChange={(e) => updateItem(index, e.target.value)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeItem(index)} disabled={disabled}>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={addItem} disabled={disabled}>
|
||||
{t('config_management.visual.common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PayloadRulesEditor({
|
||||
value,
|
||||
disabled,
|
||||
protocolFirst = false,
|
||||
onChange,
|
||||
}: {
|
||||
value: PayloadRule[];
|
||||
disabled?: boolean;
|
||||
protocolFirst?: boolean;
|
||||
onChange: (next: PayloadRule[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rules = value.length ? value : [];
|
||||
const protocolOptions = useMemo(
|
||||
() =>
|
||||
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
const payloadValueTypeOptions = useMemo(
|
||||
() =>
|
||||
VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
|
||||
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
|
||||
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
|
||||
|
||||
const updateRule = (ruleIndex: number, patch: Partial<PayloadRule>) =>
|
||||
onChange(rules.map((rule, i) => (i === ruleIndex ? { ...rule, ...patch } : rule)));
|
||||
|
||||
const addModel = (ruleIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
const nextModel: PayloadModelEntry = { id: makeClientId(), name: '', protocol: undefined };
|
||||
updateRule(ruleIndex, { models: [...rule.models, nextModel] });
|
||||
};
|
||||
|
||||
const removeModel = (ruleIndex: number, modelIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, { models: rule.models.filter((_, i) => i !== modelIndex) });
|
||||
};
|
||||
|
||||
const updateModel = (ruleIndex: number, modelIndex: number, patch: Partial<PayloadModelEntry>) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, {
|
||||
models: rule.models.map((m, i) => (i === modelIndex ? { ...m, ...patch } : m)),
|
||||
});
|
||||
};
|
||||
|
||||
const addParam = (ruleIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
const nextParam: PayloadParamEntry = {
|
||||
id: makeClientId(),
|
||||
path: '',
|
||||
valueType: 'string',
|
||||
value: '',
|
||||
};
|
||||
updateRule(ruleIndex, { params: [...rule.params, nextParam] });
|
||||
};
|
||||
|
||||
const removeParam = (ruleIndex: number, paramIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, { params: rule.params.filter((_, i) => i !== paramIndex) });
|
||||
};
|
||||
|
||||
const updateParam = (ruleIndex: number, paramIndex: number, patch: Partial<PayloadParamEntry>) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, {
|
||||
params: rule.params.map((p, i) => (i === paramIndex ? { ...p, ...patch } : p)),
|
||||
});
|
||||
};
|
||||
|
||||
const getValuePlaceholder = (valueType: PayloadParamValueType) => {
|
||||
switch (valueType) {
|
||||
case 'string':
|
||||
return t('config_management.visual.payload_rules.value_string');
|
||||
case 'number':
|
||||
return t('config_management.visual.payload_rules.value_number');
|
||||
case 'boolean':
|
||||
return t('config_management.visual.payload_rules.value_boolean');
|
||||
case 'json':
|
||||
return t('config_management.visual.payload_rules.value_json');
|
||||
default:
|
||||
return t('config_management.visual.payload_rules.value_default');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{rules.map((rule, ruleIndex) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
style={{
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
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>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{(rule.models.length ? rule.models : []).map((model, modelIndex) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className={[styles.payloadRuleModelRow, protocolFirst ? styles.payloadRuleModelRowProtocolFirst : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{protocolFirst ? (
|
||||
<>
|
||||
<Select
|
||||
value={model.protocol ?? ''}
|
||||
options={protocolOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||
onChange={(nextValue) =>
|
||||
updateModel(ruleIndex, modelIndex, {
|
||||
protocol: (nextValue || undefined) as PayloadModelEntry['protocol'],
|
||||
})
|
||||
}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
value={model.protocol ?? ''}
|
||||
options={protocolOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||
onChange={(nextValue) =>
|
||||
updateModel(ruleIndex, modelIndex, {
|
||||
protocol: (nextValue || undefined) as PayloadModelEntry['protocol'],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.payloadRowActionButton}
|
||||
onClick={() => removeModel(ruleIndex, modelIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={() => addModel(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_model')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{(rule.params.length ? rule.params : []).map((param, paramIndex) => (
|
||||
<div key={param.id} className={styles.payloadRuleParamRow}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('config_management.visual.payload_rules.json_path')}
|
||||
value={param.path}
|
||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { path: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
value={param.valueType}
|
||||
options={payloadValueTypeOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.param_type')}
|
||||
onChange={(nextValue) =>
|
||||
updateParam(ruleIndex, paramIndex, { valueType: nextValue as PayloadParamValueType })
|
||||
}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={getValuePlaceholder(param.valueType)}
|
||||
value={param.value}
|
||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.payloadRowActionButton}
|
||||
onClick={() => removeParam(ruleIndex, paramIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={() => addParam(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_param')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{rules.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
border: '1px dashed var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
color: 'var(--text-secondary)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{t('config_management.visual.payload_rules.no_rules')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={addRule} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_rule')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PayloadFilterRulesEditor({
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
value: PayloadFilterRule[];
|
||||
disabled?: boolean;
|
||||
onChange: (next: PayloadFilterRule[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rules = value.length ? value : [];
|
||||
const protocolOptions = useMemo(
|
||||
() =>
|
||||
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
|
||||
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
|
||||
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
|
||||
|
||||
const updateRule = (ruleIndex: number, patch: Partial<PayloadFilterRule>) =>
|
||||
onChange(rules.map((rule, i) => (i === ruleIndex ? { ...rule, ...patch } : rule)));
|
||||
|
||||
const addModel = (ruleIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
const nextModel: PayloadModelEntry = { id: makeClientId(), name: '', protocol: undefined };
|
||||
updateRule(ruleIndex, { models: [...rule.models, nextModel] });
|
||||
};
|
||||
|
||||
const removeModel = (ruleIndex: number, modelIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, { models: rule.models.filter((_, i) => i !== modelIndex) });
|
||||
};
|
||||
|
||||
const updateModel = (ruleIndex: number, modelIndex: number, patch: Partial<PayloadModelEntry>) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, {
|
||||
models: rule.models.map((m, i) => (i === modelIndex ? { ...m, ...patch } : m)),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{rules.map((rule, ruleIndex) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
style={{
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
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>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{rule.models.map((model, modelIndex) => (
|
||||
<div key={model.id} className={styles.payloadFilterModelRow}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
value={model.protocol ?? ''}
|
||||
options={protocolOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||
onChange={(nextValue) =>
|
||||
updateModel(ruleIndex, modelIndex, {
|
||||
protocol: (nextValue || undefined) as PayloadModelEntry['protocol'],
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.payloadRowActionButton}
|
||||
onClick={() => removeModel(ruleIndex, modelIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={() => addModel(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_model')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.remove_params')}</div>
|
||||
<StringListEditor
|
||||
value={rule.params}
|
||||
disabled={disabled}
|
||||
placeholder={t('config_management.visual.payload_rules.json_path_filter')}
|
||||
onChange={(params) => updateRule(ruleIndex, { params })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{rules.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
border: '1px dashed var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
color: 'var(--text-secondary)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{t('config_management.visual.payload_rules.no_rules')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={addRule} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_rule')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VisualConfigEditor({ values, disabled = false, onChange }: VisualConfigEditorProps) {
|
||||
export function VisualConfigEditor({ values, validationErrors, disabled = false, onChange }: VisualConfigEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const routingStrategyLabelId = useId();
|
||||
const routingStrategyHintId = `${routingStrategyLabelId}-hint`;
|
||||
const keepaliveInputId = useId();
|
||||
const keepaliveHintId = `${keepaliveInputId}-hint`;
|
||||
const keepaliveErrorId = `${keepaliveInputId}-error`;
|
||||
const nonstreamKeepaliveInputId = useId();
|
||||
const nonstreamKeepaliveHintId = `${nonstreamKeepaliveInputId}-hint`;
|
||||
const nonstreamKeepaliveErrorId = `${nonstreamKeepaliveInputId}-error`;
|
||||
const isKeepaliveDisabled = values.streaming.keepaliveSeconds === '' || values.streaming.keepaliveSeconds === '0';
|
||||
const isNonstreamKeepaliveDisabled =
|
||||
values.streaming.nonstreamKeepaliveInterval === '' || values.streaming.nonstreamKeepaliveInterval === '0';
|
||||
const portError = getValidationMessage(t, validationErrors?.port);
|
||||
const logsMaxSizeError = getValidationMessage(t, validationErrors?.logsMaxTotalSizeMb);
|
||||
const requestRetryError = getValidationMessage(t, validationErrors?.requestRetry);
|
||||
const maxRetryIntervalError = getValidationMessage(t, validationErrors?.maxRetryInterval);
|
||||
const keepaliveError = getValidationMessage(t, validationErrors?.['streaming.keepaliveSeconds']);
|
||||
const bootstrapRetriesError = getValidationMessage(t, validationErrors?.['streaming.bootstrapRetries']);
|
||||
const nonstreamKeepaliveError = getValidationMessage(
|
||||
t,
|
||||
validationErrors?.['streaming.nonstreamKeepaliveInterval']
|
||||
);
|
||||
|
||||
const handleApiKeysTextChange = useCallback((apiKeysText: string) => onChange({ apiKeysText }), [onChange]);
|
||||
const handlePayloadDefaultRulesChange = useCallback(
|
||||
(payloadDefaultRules: PayloadRule[]) => onChange({ payloadDefaultRules }),
|
||||
[onChange]
|
||||
);
|
||||
const handlePayloadOverrideRulesChange = useCallback(
|
||||
(payloadOverrideRules: PayloadRule[]) => onChange({ payloadOverrideRules }),
|
||||
[onChange]
|
||||
);
|
||||
const handlePayloadFilterRulesChange = useCallback(
|
||||
(payloadFilterRules: PayloadFilterRule[]) => onChange({ payloadFilterRules }),
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
@@ -741,6 +139,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
value={values.port}
|
||||
onChange={(e) => onChange({ port: e.target.value })}
|
||||
disabled={disabled}
|
||||
error={portError}
|
||||
/>
|
||||
</SectionGrid>
|
||||
</ConfigSection>
|
||||
@@ -827,7 +226,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
<ApiKeysCardEditor
|
||||
value={values.apiKeysText}
|
||||
disabled={disabled}
|
||||
onChange={(apiKeysText) => onChange({ apiKeysText })}
|
||||
onChange={handleApiKeysTextChange}
|
||||
/>
|
||||
</div>
|
||||
</ConfigSection>
|
||||
@@ -873,6 +272,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
value={values.logsMaxTotalSizeMb}
|
||||
onChange={(e) => onChange({ logsMaxTotalSizeMb: e.target.value })}
|
||||
disabled={disabled}
|
||||
error={logsMaxSizeError}
|
||||
/>
|
||||
</SectionGrid>
|
||||
</div>
|
||||
@@ -895,6 +295,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
value={values.requestRetry}
|
||||
onChange={(e) => onChange({ requestRetry: e.target.value })}
|
||||
disabled={disabled}
|
||||
error={requestRetryError}
|
||||
/>
|
||||
<Input
|
||||
label={t('config_management.visual.sections.network.max_retry_interval')}
|
||||
@@ -903,22 +304,25 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
value={values.maxRetryInterval}
|
||||
onChange={(e) => onChange({ maxRetryInterval: e.target.value })}
|
||||
disabled={disabled}
|
||||
error={maxRetryIntervalError}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t('config_management.visual.sections.network.routing_strategy')}</label>
|
||||
<label id={routingStrategyLabelId} htmlFor={`${routingStrategyLabelId}-select`}>{t('config_management.visual.sections.network.routing_strategy')}</label>
|
||||
<Select
|
||||
value={values.routingStrategy}
|
||||
options={[
|
||||
{ value: 'round-robin', label: t('config_management.visual.sections.network.strategy_round_robin') },
|
||||
{ value: 'fill-first', label: t('config_management.visual.sections.network.strategy_fill_first') },
|
||||
]}
|
||||
id={`${routingStrategyLabelId}-select`}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.sections.network.routing_strategy')}
|
||||
ariaLabelledBy={routingStrategyLabelId}
|
||||
ariaDescribedBy={routingStrategyHintId}
|
||||
onChange={(nextValue) =>
|
||||
onChange({ routingStrategy: nextValue as VisualConfigValues['routingStrategy'] })
|
||||
}
|
||||
/>
|
||||
<div className="hint">{t('config_management.visual.sections.network.routing_strategy_hint')}</div>
|
||||
<div id={routingStrategyHintId} className="hint">{t('config_management.visual.sections.network.routing_strategy_hint')}</div>
|
||||
</div>
|
||||
</SectionGrid>
|
||||
|
||||
@@ -962,9 +366,10 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<SectionGrid>
|
||||
<div className="form-group">
|
||||
<label>{t('config_management.visual.sections.streaming.keepalive_seconds')}</label>
|
||||
<label htmlFor={keepaliveInputId}>{t('config_management.visual.sections.streaming.keepalive_seconds')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
id={keepaliveInputId}
|
||||
className="input"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
@@ -993,7 +398,8 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="hint">{t('config_management.visual.sections.streaming.keepalive_hint')}</div>
|
||||
{keepaliveError && <div id={keepaliveErrorId} className="error-box">{keepaliveError}</div>}
|
||||
<div id={keepaliveHintId} className="hint">{t('config_management.visual.sections.streaming.keepalive_hint')}</div>
|
||||
</div>
|
||||
<Input
|
||||
label={t('config_management.visual.sections.streaming.bootstrap_retries')}
|
||||
@@ -1003,12 +409,13 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
onChange={(e) => onChange({ streaming: { ...values.streaming, bootstrapRetries: e.target.value } })}
|
||||
disabled={disabled}
|
||||
hint={t('config_management.visual.sections.streaming.bootstrap_hint')}
|
||||
error={bootstrapRetriesError}
|
||||
/>
|
||||
</SectionGrid>
|
||||
|
||||
<SectionGrid>
|
||||
<div className="form-group">
|
||||
<label>{t('config_management.visual.sections.streaming.nonstream_keepalive')}</label>
|
||||
<label htmlFor={nonstreamKeepaliveInputId}>{t('config_management.visual.sections.streaming.nonstream_keepalive')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
className="input"
|
||||
@@ -1041,7 +448,8 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="hint">
|
||||
{nonstreamKeepaliveError && <div id={nonstreamKeepaliveErrorId} className="error-box">{nonstreamKeepaliveError}</div>}
|
||||
<div id={nonstreamKeepaliveHintId} className="hint">
|
||||
{t('config_management.visual.sections.streaming.nonstream_keepalive_hint')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1059,7 +467,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
<PayloadRulesEditor
|
||||
value={values.payloadDefaultRules}
|
||||
disabled={disabled}
|
||||
onChange={(payloadDefaultRules) => onChange({ payloadDefaultRules })}
|
||||
onChange={handlePayloadDefaultRulesChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1072,7 +480,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
value={values.payloadOverrideRules}
|
||||
disabled={disabled}
|
||||
protocolFirst
|
||||
onChange={(payloadOverrideRules) => onChange({ payloadOverrideRules })}
|
||||
onChange={handlePayloadOverrideRulesChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1084,7 +492,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
<PayloadFilterRulesEditor
|
||||
value={values.payloadFilterRules}
|
||||
disabled={disabled}
|
||||
onChange={(payloadFilterRules) => onChange({ payloadFilterRules })}
|
||||
onChange={handlePayloadFilterRulesChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,784 @@
|
||||
import { memo, useId, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import styles from './VisualConfigEditor.module.scss';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import type {
|
||||
PayloadFilterRule,
|
||||
PayloadModelEntry,
|
||||
PayloadParamEntry,
|
||||
PayloadParamValidationErrorCode,
|
||||
PayloadParamValueType,
|
||||
PayloadRule,
|
||||
} from '@/types/visualConfig';
|
||||
import { makeClientId } from '@/types/visualConfig';
|
||||
import {
|
||||
getPayloadParamValidationError,
|
||||
VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS,
|
||||
VISUAL_CONFIG_PROTOCOL_OPTIONS,
|
||||
} from '@/hooks/useVisualConfig';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { isValidApiKeyCharset } from '@/utils/validation';
|
||||
|
||||
function getValidationMessage(
|
||||
t: ReturnType<typeof useTranslation>['t'],
|
||||
errorCode?: PayloadParamValidationErrorCode
|
||||
) {
|
||||
if (!errorCode) return undefined;
|
||||
return t(`config_management.visual.validation.${errorCode}`);
|
||||
}
|
||||
|
||||
export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
onChange: (nextValue: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||
const apiKeys = useMemo(
|
||||
() =>
|
||||
value
|
||||
.split('\n')
|
||||
.map((key) => key.trim())
|
||||
.filter(Boolean),
|
||||
[value]
|
||||
);
|
||||
const [apiKeyIds, setApiKeyIds] = useState(() => apiKeys.map(() => makeClientId()));
|
||||
const renderApiKeyIds = useMemo(() => {
|
||||
if (apiKeyIds.length === apiKeys.length) return apiKeyIds;
|
||||
if (apiKeyIds.length > apiKeys.length) return apiKeyIds.slice(0, apiKeys.length);
|
||||
return [...apiKeyIds, ...Array.from({ length: apiKeys.length - apiKeyIds.length }, () => makeClientId())];
|
||||
}, [apiKeyIds, apiKeys.length]);
|
||||
|
||||
const apiKeyInputId = useId();
|
||||
const apiKeyHintId = `${apiKeyInputId}-hint`;
|
||||
const apiKeyErrorId = `${apiKeyInputId}-error`;
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingApiKeyId, setEditingApiKeyId] = useState<string | null>(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
|
||||
function generateSecureApiKey(): string {
|
||||
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const array = new Uint8Array(17);
|
||||
crypto.getRandomValues(array);
|
||||
return 'sk-' + Array.from(array, (b) => charset[b % charset.length]).join('');
|
||||
}
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingApiKeyId(null);
|
||||
setInputValue('');
|
||||
setFormError('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (apiKeyId: string) => {
|
||||
const editingIndex = renderApiKeyIds.findIndex((id) => id === apiKeyId);
|
||||
setEditingApiKeyId(apiKeyId);
|
||||
setInputValue(apiKeys[editingIndex] ?? '');
|
||||
setFormError('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
setInputValue('');
|
||||
setEditingApiKeyId(null);
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
const updateApiKeys = (nextKeys: string[]) => {
|
||||
onChange(nextKeys.join('\n'));
|
||||
};
|
||||
|
||||
const handleDelete = (apiKeyId: string) => {
|
||||
const index = renderApiKeyIds.findIndex((id) => id === apiKeyId);
|
||||
if (index < 0) return;
|
||||
setApiKeyIds(renderApiKeyIds.filter((id) => id !== apiKeyId));
|
||||
updateApiKeys(apiKeys.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmed = inputValue.trim();
|
||||
if (!trimmed) {
|
||||
setFormError(t('config_management.visual.api_keys.error_empty'));
|
||||
return;
|
||||
}
|
||||
if (!isValidApiKeyCharset(trimmed)) {
|
||||
setFormError(t('config_management.visual.api_keys.error_invalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
const editingIndex = editingApiKeyId ? renderApiKeyIds.findIndex((id) => id === editingApiKeyId) : -1;
|
||||
const nextKeys =
|
||||
editingApiKeyId === null
|
||||
? [...apiKeys, trimmed]
|
||||
: apiKeys.map((key, idx) => (idx === editingIndex ? trimmed : key));
|
||||
if (editingApiKeyId === null) {
|
||||
setApiKeyIds([...renderApiKeyIds, makeClientId()]);
|
||||
}
|
||||
updateApiKeys(nextKeys);
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const handleCopy = async (apiKey: string) => {
|
||||
const copied = await copyToClipboard(apiKey);
|
||||
showNotification(
|
||||
t(copied ? 'notification.link_copied' : 'notification.copy_failed'),
|
||||
copied ? 'success' : 'error'
|
||||
);
|
||||
};
|
||||
|
||||
const handleGenerate = () => {
|
||||
setInputValue(generateSecureApiKey());
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<label style={{ margin: 0 }}>{t('config_management.visual.api_keys.label')}</label>
|
||||
<Button size="sm" onClick={openAddModal} disabled={disabled}>
|
||||
{t('config_management.visual.api_keys.add')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{apiKeys.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
border: '1px dashed var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
color: 'var(--text-secondary)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{t('config_management.visual.api_keys.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="item-list" style={{ marginTop: 4 }}>
|
||||
{apiKeys.map((key, index) => (
|
||||
<div key={renderApiKeyIds[index] ?? `${key}-${index}`} className="item-row">
|
||||
<div className="item-meta">
|
||||
<div className="pill">#{index + 1}</div>
|
||||
<div className="item-title">{t('config_management.visual.api_keys.input_label')}</div>
|
||||
<div className="item-subtitle">{maskApiKey(String(key || ''))}</div>
|
||||
</div>
|
||||
<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(renderApiKeyIds[index] ?? '')}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(renderApiKeyIds[index] ?? '')}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="hint">{t('config_management.visual.api_keys.hint')}</div>
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
onClose={closeModal}
|
||||
title={editingApiKeyId !== null ? t('config_management.visual.api_keys.edit_title') : t('config_management.visual.api_keys.add_title')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={closeModal} disabled={disabled}>
|
||||
{t('config_management.visual.common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={disabled}>
|
||||
{editingApiKeyId !== null ? t('config_management.visual.common.update') : t('config_management.visual.common.add')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label htmlFor={apiKeyInputId}>{t('config_management.visual.api_keys.input_label')}</label>
|
||||
<div className={styles.apiKeyModalInputRow}>
|
||||
<input
|
||||
id={apiKeyInputId}
|
||||
className="input"
|
||||
placeholder={t('config_management.visual.api_keys.input_placeholder')}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
disabled={disabled}
|
||||
aria-describedby={formError ? `${apiKeyErrorId} ${apiKeyHintId}` : apiKeyHintId}
|
||||
aria-invalid={Boolean(formError)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleGenerate}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.api_keys.generate')}
|
||||
</Button>
|
||||
</div>
|
||||
<div id={apiKeyHintId} className="hint">{t('config_management.visual.api_keys.input_hint')}</div>
|
||||
{formError && <div id={apiKeyErrorId} className="error-box">{formError}</div>}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const StringListEditor = memo(function StringListEditor({
|
||||
value,
|
||||
disabled,
|
||||
placeholder,
|
||||
inputAriaLabel,
|
||||
onChange,
|
||||
}: {
|
||||
value: string[];
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
inputAriaLabel?: string;
|
||||
onChange: (next: string[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const items = value.length ? value : [];
|
||||
const [itemIds, setItemIds] = useState(() => items.map(() => makeClientId()));
|
||||
const renderItemIds = useMemo(() => {
|
||||
if (itemIds.length === items.length) return itemIds;
|
||||
if (itemIds.length > items.length) return itemIds.slice(0, items.length);
|
||||
return [...itemIds, ...Array.from({ length: items.length - itemIds.length }, () => makeClientId())];
|
||||
}, [itemIds, items.length]);
|
||||
|
||||
const updateItem = (index: number, nextValue: string) =>
|
||||
onChange(items.map((item, i) => (i === index ? nextValue : item)));
|
||||
const addItem = () => {
|
||||
setItemIds([...renderItemIds, makeClientId()]);
|
||||
onChange([...items, '']);
|
||||
};
|
||||
const removeItem = (index: number) => {
|
||||
setItemIds(renderItemIds.filter((_, i) => i !== index));
|
||||
onChange(items.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((item, index) => (
|
||||
<div key={renderItemIds[index] ?? `item-${index}`} style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={placeholder}
|
||||
aria-label={inputAriaLabel ?? placeholder}
|
||||
value={item}
|
||||
onChange={(e) => updateItem(index, e.target.value)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeItem(index)} disabled={disabled}>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={addItem} disabled={disabled}>
|
||||
{t('config_management.visual.common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
value,
|
||||
disabled,
|
||||
protocolFirst = false,
|
||||
onChange,
|
||||
}: {
|
||||
value: PayloadRule[];
|
||||
disabled?: boolean;
|
||||
protocolFirst?: boolean;
|
||||
onChange: (next: PayloadRule[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rules = value.length ? value : [];
|
||||
const protocolOptions = useMemo(
|
||||
() =>
|
||||
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
const payloadValueTypeOptions = useMemo(
|
||||
() =>
|
||||
VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
const booleanValueOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'true', label: t('config_management.visual.payload_rules.boolean_true') },
|
||||
{ value: 'false', label: t('config_management.visual.payload_rules.boolean_false') },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
|
||||
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
|
||||
|
||||
const updateRule = (ruleIndex: number, patch: Partial<PayloadRule>) =>
|
||||
onChange(rules.map((rule, i) => (i === ruleIndex ? { ...rule, ...patch } : rule)));
|
||||
|
||||
const addModel = (ruleIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
const nextModel: PayloadModelEntry = { id: makeClientId(), name: '', protocol: undefined };
|
||||
updateRule(ruleIndex, { models: [...rule.models, nextModel] });
|
||||
};
|
||||
|
||||
const removeModel = (ruleIndex: number, modelIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, { models: rule.models.filter((_, i) => i !== modelIndex) });
|
||||
};
|
||||
|
||||
const updateModel = (ruleIndex: number, modelIndex: number, patch: Partial<PayloadModelEntry>) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, {
|
||||
models: rule.models.map((m, i) => (i === modelIndex ? { ...m, ...patch } : m)),
|
||||
});
|
||||
};
|
||||
|
||||
const addParam = (ruleIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
const nextParam: PayloadParamEntry = {
|
||||
id: makeClientId(),
|
||||
path: '',
|
||||
valueType: 'string',
|
||||
value: '',
|
||||
};
|
||||
updateRule(ruleIndex, { params: [...rule.params, nextParam] });
|
||||
};
|
||||
|
||||
const removeParam = (ruleIndex: number, paramIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, { params: rule.params.filter((_, i) => i !== paramIndex) });
|
||||
};
|
||||
|
||||
const updateParam = (ruleIndex: number, paramIndex: number, patch: Partial<PayloadParamEntry>) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, {
|
||||
params: rule.params.map((p, i) => (i === paramIndex ? { ...p, ...patch } : p)),
|
||||
});
|
||||
};
|
||||
|
||||
const getValuePlaceholder = (valueType: PayloadParamValueType) => {
|
||||
switch (valueType) {
|
||||
case 'string':
|
||||
return t('config_management.visual.payload_rules.value_string');
|
||||
case 'number':
|
||||
return t('config_management.visual.payload_rules.value_number');
|
||||
case 'boolean':
|
||||
return t('config_management.visual.payload_rules.value_boolean');
|
||||
case 'json':
|
||||
return t('config_management.visual.payload_rules.value_json');
|
||||
default:
|
||||
return t('config_management.visual.payload_rules.value_default');
|
||||
}
|
||||
};
|
||||
|
||||
const getParamErrorMessage = (param: PayloadParamEntry) => {
|
||||
const errorCode = getPayloadParamValidationError(param);
|
||||
return getValidationMessage(t, errorCode);
|
||||
};
|
||||
|
||||
const renderParamValueEditor = (
|
||||
ruleIndex: number,
|
||||
paramIndex: number,
|
||||
param: PayloadParamEntry
|
||||
) => {
|
||||
if (param.valueType === 'boolean') {
|
||||
return (
|
||||
<Select
|
||||
value={param.value.toLowerCase() === 'true' || param.value.toLowerCase() === 'false' ? param.value.toLowerCase() : ''}
|
||||
options={booleanValueOptions}
|
||||
placeholder={t('config_management.visual.payload_rules.value_boolean')}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.param_value')}
|
||||
onChange={(nextValue) => updateParam(ruleIndex, paramIndex, { value: nextValue })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (param.valueType === 'json') {
|
||||
return (
|
||||
<textarea
|
||||
className={`input ${styles.payloadJsonInput}`}
|
||||
placeholder={getValuePlaceholder(param.valueType)}
|
||||
aria-label={t('config_management.visual.payload_rules.param_value')}
|
||||
value={param.value}
|
||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
className="input"
|
||||
placeholder={getValuePlaceholder(param.valueType)}
|
||||
aria-label={t('config_management.visual.payload_rules.param_value')}
|
||||
value={param.value}
|
||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{rules.map((rule, ruleIndex) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
style={{
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
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>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{(rule.models.length ? rule.models : []).map((model, modelIndex) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className={[styles.payloadRuleModelRow, protocolFirst ? styles.payloadRuleModelRowProtocolFirst : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{protocolFirst ? (
|
||||
<>
|
||||
<Select
|
||||
value={model.protocol ?? ''}
|
||||
options={protocolOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||
onChange={(nextValue) =>
|
||||
updateModel(ruleIndex, modelIndex, {
|
||||
protocol: (nextValue || undefined) as PayloadModelEntry['protocol'],
|
||||
})
|
||||
}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
aria-label={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
aria-label={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
value={model.protocol ?? ''}
|
||||
options={protocolOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||
onChange={(nextValue) =>
|
||||
updateModel(ruleIndex, modelIndex, {
|
||||
protocol: (nextValue || undefined) as PayloadModelEntry['protocol'],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.payloadRowActionButton}
|
||||
onClick={() => removeModel(ruleIndex, modelIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={() => addModel(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_model')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{(rule.params.length ? rule.params : []).map((param, paramIndex) => {
|
||||
const paramError = getParamErrorMessage(param);
|
||||
|
||||
return (
|
||||
<div key={param.id} className={styles.payloadRuleParamGroup}>
|
||||
<div className={styles.payloadRuleParamRow}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('config_management.visual.payload_rules.json_path')}
|
||||
aria-label={t('config_management.visual.payload_rules.json_path')}
|
||||
value={param.path}
|
||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { path: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
value={param.valueType}
|
||||
options={payloadValueTypeOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.param_type')}
|
||||
onChange={(nextValue) =>
|
||||
updateParam(ruleIndex, paramIndex, {
|
||||
valueType: nextValue as PayloadParamValueType,
|
||||
value:
|
||||
nextValue === 'boolean'
|
||||
? 'true'
|
||||
: nextValue === 'json' && param.value.trim() === ''
|
||||
? '{}'
|
||||
: param.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{renderParamValueEditor(ruleIndex, paramIndex, param)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.payloadRowActionButton}
|
||||
onClick={() => removeParam(ruleIndex, paramIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
{paramError && <div className={`error-box ${styles.payloadParamError}`}>{paramError}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={() => addParam(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_param')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{rules.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
border: '1px dashed var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
color: 'var(--text-secondary)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{t('config_management.visual.payload_rules.no_rules')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={addRule} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_rule')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const PayloadFilterRulesEditor = memo(function PayloadFilterRulesEditor({
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
value: PayloadFilterRule[];
|
||||
disabled?: boolean;
|
||||
onChange: (next: PayloadFilterRule[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rules = value.length ? value : [];
|
||||
const protocolOptions = useMemo(
|
||||
() =>
|
||||
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
|
||||
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
|
||||
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
|
||||
|
||||
const updateRule = (ruleIndex: number, patch: Partial<PayloadFilterRule>) =>
|
||||
onChange(rules.map((rule, i) => (i === ruleIndex ? { ...rule, ...patch } : rule)));
|
||||
|
||||
const addModel = (ruleIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
const nextModel: PayloadModelEntry = { id: makeClientId(), name: '', protocol: undefined };
|
||||
updateRule(ruleIndex, { models: [...rule.models, nextModel] });
|
||||
};
|
||||
|
||||
const removeModel = (ruleIndex: number, modelIndex: number) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, { models: rule.models.filter((_, i) => i !== modelIndex) });
|
||||
};
|
||||
|
||||
const updateModel = (ruleIndex: number, modelIndex: number, patch: Partial<PayloadModelEntry>) => {
|
||||
const rule = rules[ruleIndex];
|
||||
updateRule(ruleIndex, {
|
||||
models: rule.models.map((m, i) => (i === modelIndex ? { ...m, ...patch } : m)),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{rules.map((rule, ruleIndex) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
style={{
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
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>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{rule.models.map((model, modelIndex) => (
|
||||
<div key={model.id} className={styles.payloadFilterModelRow}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
aria-label={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
value={model.protocol ?? ''}
|
||||
options={protocolOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||
onChange={(nextValue) =>
|
||||
updateModel(ruleIndex, modelIndex, {
|
||||
protocol: (nextValue || undefined) as PayloadModelEntry['protocol'],
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.payloadRowActionButton}
|
||||
onClick={() => removeModel(ruleIndex, modelIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={() => addModel(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_model')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.remove_params')}</div>
|
||||
<StringListEditor
|
||||
value={rule.params}
|
||||
disabled={disabled}
|
||||
placeholder={t('config_management.visual.payload_rules.json_path_filter')}
|
||||
inputAriaLabel={t('config_management.visual.payload_rules.json_path_filter')}
|
||||
onChange={(params) => updateRule(ruleIndex, { params })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{rules.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
border: '1px dashed var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
color: 'var(--text-secondary)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{t('config_management.visual.payload_rules.no_rules')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={addRule} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_rule')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import { versionApi } from '@/services/api';
|
||||
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
|
||||
import { isSupportedLanguage } from '@/utils/language';
|
||||
import type { Theme } from '@/types';
|
||||
|
||||
const sidebarIcons: Record<string, ReactNode> = {
|
||||
dashboard: <IconLayoutDashboard size={18} />,
|
||||
@@ -117,6 +118,12 @@ const headerIcons = {
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
|
||||
</svg>
|
||||
),
|
||||
whiteTheme: (
|
||||
<svg {...headerIconProps}>
|
||||
<circle cx="12" cy="12" r="7" />
|
||||
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
),
|
||||
autoTheme: (
|
||||
<svg {...headerIconProps}>
|
||||
<defs>
|
||||
@@ -145,6 +152,39 @@ const headerIcons = {
|
||||
),
|
||||
};
|
||||
|
||||
const THEME_CARDS: Array<{
|
||||
key: Theme;
|
||||
labelKey: string;
|
||||
colors: { bg: string; card: string; border: string; text: string; textMuted: string };
|
||||
}> = [
|
||||
{
|
||||
key: 'auto',
|
||||
labelKey: 'theme.auto',
|
||||
colors: {
|
||||
bg: 'linear-gradient(135deg, #faf9f5 0 50%, #151412 50% 100%)',
|
||||
card: 'linear-gradient(135deg, #f0eee8 0 50%, #1d1b18 50% 100%)',
|
||||
border: '#bdb6ae',
|
||||
text: '#2d2a26',
|
||||
textMuted: 'linear-gradient(135deg, #a29c95 0 50%, #9c958d 50% 100%)',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'white',
|
||||
labelKey: 'theme.white',
|
||||
colors: { bg: '#ffffff', card: '#ffffff', border: '#e5e5e5', text: '#2d2a26', textMuted: '#a29c95' },
|
||||
},
|
||||
{
|
||||
key: 'light',
|
||||
labelKey: 'theme.light',
|
||||
colors: { bg: '#faf9f5', card: '#f0eee8', border: '#e3e1db', text: '#2d2a26', textMuted: '#a29c95' },
|
||||
},
|
||||
{
|
||||
key: 'dark',
|
||||
labelKey: 'theme.dark',
|
||||
colors: { bg: '#151412', card: '#1d1b18', border: '#3a3530', text: '#f6f4f1', textMuted: '#9c958d' },
|
||||
},
|
||||
];
|
||||
|
||||
const parseVersionSegments = (version?: string | null) => {
|
||||
if (!version) return null;
|
||||
const cleaned = version.trim().replace(/^v/i, '');
|
||||
@@ -186,7 +226,7 @@ export function MainLayout() {
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const cycleTheme = useThemeStore((state) => state.cycleTheme);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const language = useLanguageStore((state) => state.language);
|
||||
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
||||
|
||||
@@ -194,9 +234,11 @@ export function MainLayout() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [checkingVersion, setCheckingVersion] = useState(false);
|
||||
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
|
||||
const [themeMenuOpen, setThemeMenuOpen] = useState(false);
|
||||
const [brandExpanded, setBrandExpanded] = useState(true);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const languageMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const themeMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const headerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
@@ -304,6 +346,32 @@ export function MainLayout() {
|
||||
};
|
||||
}, [languageMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!themeMenuOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!themeMenuRef.current?.contains(event.target as Node)) {
|
||||
setThemeMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setThemeMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [themeMenuOpen]);
|
||||
|
||||
const handleBrandClick = useCallback(() => {
|
||||
if (!brandExpanded) {
|
||||
setBrandExpanded(true);
|
||||
@@ -319,8 +387,22 @@ export function MainLayout() {
|
||||
|
||||
const toggleLanguageMenu = useCallback(() => {
|
||||
setLanguageMenuOpen((prev) => !prev);
|
||||
setThemeMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const toggleThemeMenu = useCallback(() => {
|
||||
setThemeMenuOpen((prev) => !prev);
|
||||
setLanguageMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleThemeSelect = useCallback(
|
||||
(nextTheme: Theme) => {
|
||||
setTheme(nextTheme);
|
||||
setThemeMenuOpen(false);
|
||||
},
|
||||
[setTheme]
|
||||
);
|
||||
|
||||
const handleLanguageSelect = useCallback(
|
||||
(nextLanguage: string) => {
|
||||
if (!isSupportedLanguage(nextLanguage)) {
|
||||
@@ -566,13 +648,72 @@ export function MainLayout() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
|
||||
{theme === 'auto'
|
||||
? headerIcons.autoTheme
|
||||
: theme === 'dark'
|
||||
? headerIcons.moon
|
||||
: headerIcons.sun}
|
||||
</Button>
|
||||
<div className={`theme-menu ${themeMenuOpen ? 'open' : ''}`} ref={themeMenuRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleThemeMenu}
|
||||
title={t('theme.switch')}
|
||||
aria-label={t('theme.switch')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={themeMenuOpen}
|
||||
>
|
||||
{theme === 'auto'
|
||||
? headerIcons.autoTheme
|
||||
: theme === 'dark'
|
||||
? headerIcons.moon
|
||||
: theme === 'white'
|
||||
? headerIcons.whiteTheme
|
||||
: headerIcons.sun}
|
||||
</Button>
|
||||
{themeMenuOpen && (
|
||||
<div className="notification entering theme-menu-popover" role="menu" aria-label={t('theme.switch')}>
|
||||
{THEME_CARDS.map((tc) => (
|
||||
<button
|
||||
key={tc.key}
|
||||
type="button"
|
||||
className={`theme-card ${theme === tc.key ? 'active' : ''}`}
|
||||
onClick={() => handleThemeSelect(tc.key)}
|
||||
role="menuitemradio"
|
||||
aria-checked={theme === tc.key}
|
||||
>
|
||||
<div
|
||||
className="theme-card-preview"
|
||||
style={{ background: tc.colors.bg, border: `1px solid ${tc.colors.border}` }}
|
||||
>
|
||||
<div
|
||||
className="theme-card-header"
|
||||
style={{
|
||||
background: tc.colors.card,
|
||||
borderBottom: `1px solid ${tc.colors.border}`,
|
||||
}}
|
||||
/>
|
||||
<div className="theme-card-body">
|
||||
<div
|
||||
className="theme-card-sidebar"
|
||||
style={{
|
||||
background: tc.colors.card,
|
||||
borderRight: `1px solid ${tc.colors.border}`,
|
||||
}}
|
||||
/>
|
||||
<div className="theme-card-content" style={{ background: tc.colors.bg }}>
|
||||
<div
|
||||
className="theme-card-line"
|
||||
style={{ background: tc.colors.textMuted }}
|
||||
/>
|
||||
<div
|
||||
className="theme-card-line short"
|
||||
style={{ background: tc.colors.textMuted }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="theme-card-label">{t(tc.labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
|
||||
{headerIcons.logout}
|
||||
</Button>
|
||||
|
||||
@@ -71,6 +71,10 @@ export function AmpcodeSection({
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_model_mappings_count')}:</span>
|
||||
<span className={styles.fieldValue}>{config?.modelMappings?.length || 0}</span>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_upstream_api_keys_count')}:</span>
|
||||
<span className={styles.fieldValue}>{config?.upstreamApiKeys?.length || 0}</span>
|
||||
</div>
|
||||
{config?.modelMappings?.length ? (
|
||||
<div className={styles.modelTagList}>
|
||||
{config.modelMappings.slice(0, 5).map((mapping) => (
|
||||
|
||||
@@ -87,6 +87,7 @@ export function VertexSection({
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
@@ -140,6 +141,20 @@ export function VertexSection({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{excludedModels.length ? (
|
||||
<div className={styles.excludedModelsSection}>
|
||||
<div className={styles.excludedModelsLabel}>
|
||||
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
|
||||
</div>
|
||||
<div className={styles.modelTagList}>
|
||||
{excludedModels.map((model) => (
|
||||
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
|
||||
<span className={styles.modelName}>{model}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.cardStats}>
|
||||
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||
{t('stats.success')}: {stats.success}
|
||||
|
||||
@@ -18,11 +18,17 @@ export interface OpenAIFormState {
|
||||
apiKeyEntries: ApiKeyEntry[];
|
||||
}
|
||||
|
||||
export interface AmpcodeUpstreamApiKeyEntry {
|
||||
upstreamApiKey: string;
|
||||
clientApiKeysText: string;
|
||||
}
|
||||
|
||||
export interface AmpcodeFormState {
|
||||
upstreamUrl: string;
|
||||
upstreamApiKey: string;
|
||||
forceModelMappings: boolean;
|
||||
mappingEntries: ModelEntry[];
|
||||
upstreamApiKeyEntries: AmpcodeUpstreamApiKeyEntry[];
|
||||
}
|
||||
|
||||
export type GeminiFormState = Omit<GeminiKeyConfig, 'headers' | 'models'> & {
|
||||
@@ -37,9 +43,10 @@ export type ProviderFormState = Omit<ProviderKeyConfig, 'headers'> & {
|
||||
excludedText: string;
|
||||
};
|
||||
|
||||
export type VertexFormState = Omit<ProviderKeyConfig, 'headers' | 'excludedModels'> & {
|
||||
export type VertexFormState = Omit<ProviderKeyConfig, 'headers'> & {
|
||||
headers: HeaderEntry[];
|
||||
modelEntries: ModelEntry[];
|
||||
excludedText: string;
|
||||
};
|
||||
|
||||
export interface ProviderSectionProps<TConfig> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types';
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping, AmpcodeUpstreamApiKeyMapping, ApiKeyEntry } from '@/types';
|
||||
import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage';
|
||||
import type { AmpcodeFormState, ModelEntry } from './types';
|
||||
import type { AmpcodeFormState, AmpcodeUpstreamApiKeyEntry, ModelEntry } from './types';
|
||||
|
||||
export const DISABLE_ALL_MODELS_RULE = '*';
|
||||
|
||||
@@ -168,9 +168,43 @@ export const entriesToAmpcodeMappings = (entries: ModelEntry[]): AmpcodeModelMap
|
||||
return mappings;
|
||||
};
|
||||
|
||||
export const ampcodeUpstreamApiKeysToEntries = (
|
||||
mappings?: AmpcodeUpstreamApiKeyMapping[]
|
||||
): AmpcodeUpstreamApiKeyEntry[] => {
|
||||
if (!Array.isArray(mappings) || mappings.length === 0) {
|
||||
return [{ upstreamApiKey: '', clientApiKeysText: '' }];
|
||||
}
|
||||
|
||||
return mappings.map((mapping) => ({
|
||||
upstreamApiKey: mapping.upstreamApiKey ?? '',
|
||||
clientApiKeysText: Array.isArray(mapping.apiKeys) ? mapping.apiKeys.join('\n') : '',
|
||||
}));
|
||||
};
|
||||
|
||||
export const entriesToAmpcodeUpstreamApiKeys = (
|
||||
entries: AmpcodeUpstreamApiKeyEntry[]
|
||||
): AmpcodeUpstreamApiKeyMapping[] => {
|
||||
const seen = new Set<string>();
|
||||
const mappings: AmpcodeUpstreamApiKeyMapping[] = [];
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const upstreamApiKey = String(entry?.upstreamApiKey ?? '').trim();
|
||||
if (!upstreamApiKey || seen.has(upstreamApiKey)) return;
|
||||
|
||||
const apiKeys = Array.from(new Set(parseTextList(String(entry?.clientApiKeysText ?? ''))));
|
||||
if (!apiKeys.length) return;
|
||||
|
||||
seen.add(upstreamApiKey);
|
||||
mappings.push({ upstreamApiKey, apiKeys });
|
||||
});
|
||||
|
||||
return mappings;
|
||||
};
|
||||
|
||||
export const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState => ({
|
||||
upstreamUrl: ampcode?.upstreamUrl ?? '',
|
||||
upstreamApiKey: '',
|
||||
forceModelMappings: ampcode?.forceModelMappings ?? false,
|
||||
mappingEntries: ampcodeMappingsToEntries(ampcode?.modelMappings),
|
||||
upstreamApiKeyEntries: ampcodeUpstreamApiKeysToEntries(ampcode?.upstreamApiKeys),
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
AntigravityQuotaState,
|
||||
AuthFileItem,
|
||||
ClaudeExtraUsage,
|
||||
ClaudeProfileResponse,
|
||||
ClaudeQuotaState,
|
||||
ClaudeQuotaWindow,
|
||||
ClaudeUsagePayload,
|
||||
@@ -29,6 +30,7 @@ import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api
|
||||
import {
|
||||
ANTIGRAVITY_QUOTA_URLS,
|
||||
ANTIGRAVITY_REQUEST_HEADERS,
|
||||
CLAUDE_PROFILE_URL,
|
||||
CLAUDE_USAGE_URL,
|
||||
CLAUDE_REQUEST_HEADERS,
|
||||
CLAUDE_USAGE_WINDOW_KEYS,
|
||||
@@ -673,22 +675,69 @@ const buildClaudeQuotaWindows = (
|
||||
return windows;
|
||||
};
|
||||
|
||||
const CLAUDE_PLAN_TYPE_MAP: Record<string, string> = {
|
||||
default_claude_max_5x: 'plan_max5',
|
||||
default_claude_max_20x: 'plan_max20',
|
||||
default_claude_pro: 'plan_pro',
|
||||
default_claude_ai: 'plan_free',
|
||||
};
|
||||
|
||||
const parseClaudeProfilePayload = (payload: unknown): ClaudeProfileResponse | null => {
|
||||
if (payload === undefined || payload === null) return null;
|
||||
if (typeof payload === 'string') {
|
||||
const trimmed = payload.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return JSON.parse(trimmed) as ClaudeProfileResponse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof payload === 'object') {
|
||||
return payload as ClaudeProfileResponse;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveClaudePlanType = (profile: ClaudeProfileResponse | null): string | null => {
|
||||
if (!profile) return null;
|
||||
|
||||
const tier = normalizeStringValue(profile.organization?.rate_limit_tier);
|
||||
if (!tier) return null;
|
||||
|
||||
return CLAUDE_PLAN_TYPE_MAP[tier] ?? 'plan_unknown';
|
||||
};
|
||||
|
||||
const fetchClaudeQuota = async (
|
||||
file: AuthFileItem,
|
||||
t: TFunction
|
||||
): Promise<{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }> => {
|
||||
): Promise<{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null; planType?: string | null }> => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndex = normalizeAuthIndex(rawAuthIndex);
|
||||
if (!authIndex) {
|
||||
throw new Error(t('claude_quota.missing_auth_index'));
|
||||
}
|
||||
|
||||
const result = await apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'GET',
|
||||
url: CLAUDE_USAGE_URL,
|
||||
header: { ...CLAUDE_REQUEST_HEADERS },
|
||||
});
|
||||
const [usageResult, profileResult] = await Promise.allSettled([
|
||||
apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'GET',
|
||||
url: CLAUDE_USAGE_URL,
|
||||
header: { ...CLAUDE_REQUEST_HEADERS },
|
||||
}),
|
||||
apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'GET',
|
||||
url: CLAUDE_PROFILE_URL,
|
||||
header: { ...CLAUDE_REQUEST_HEADERS },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (usageResult.status === 'rejected') {
|
||||
throw usageResult.reason;
|
||||
}
|
||||
|
||||
const result = usageResult.value;
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
||||
@@ -700,7 +749,16 @@ const fetchClaudeQuota = async (
|
||||
}
|
||||
|
||||
const windows = buildClaudeQuotaWindows(payload, t);
|
||||
return { windows, extraUsage: payload.extra_usage };
|
||||
const planType =
|
||||
profileResult.status === 'fulfilled' &&
|
||||
profileResult.value.statusCode >= 200 &&
|
||||
profileResult.value.statusCode < 300
|
||||
? resolveClaudePlanType(
|
||||
parseClaudeProfilePayload(profileResult.value.body ?? profileResult.value.bodyText)
|
||||
)
|
||||
: null;
|
||||
|
||||
return { windows, extraUsage: payload.extra_usage, planType };
|
||||
};
|
||||
|
||||
const renderClaudeItems = (
|
||||
@@ -712,8 +770,20 @@ const renderClaudeItems = (
|
||||
const { createElement: h, Fragment } = React;
|
||||
const windows = quota.windows ?? [];
|
||||
const extraUsage = quota.extraUsage ?? null;
|
||||
const planType = quota.planType ?? null;
|
||||
const nodes: ReactNode[] = [];
|
||||
|
||||
if (planType) {
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ key: 'plan', className: styleMap.codexPlan },
|
||||
h('span', { className: styleMap.codexPlanLabel }, t('claude_quota.plan_label')),
|
||||
h('span', { className: styleMap.codexPlanValue }, t(`claude_quota.${planType}`))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (extraUsage && extraUsage.is_enabled) {
|
||||
const usedLabel = `$${(extraUsage.used_credits / 100).toFixed(2)} / $${(extraUsage.monthly_limit / 100).toFixed(2)}`;
|
||||
nodes.push(
|
||||
@@ -765,7 +835,7 @@ const renderClaudeItems = (
|
||||
|
||||
export const CLAUDE_CONFIG: QuotaConfig<
|
||||
ClaudeQuotaState,
|
||||
{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }
|
||||
{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null; planType?: string | null }
|
||||
> = {
|
||||
type: 'claude',
|
||||
i18nPrefix: 'claude_quota',
|
||||
@@ -779,6 +849,7 @@ export const CLAUDE_CONFIG: QuotaConfig<
|
||||
status: 'success',
|
||||
windows: data.windows,
|
||||
extraUsage: data.extraUsage,
|
||||
planType: data.planType,
|
||||
}),
|
||||
buildErrorState: (message, status) => ({
|
||||
status: 'error',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { InputHTMLAttributes, ReactNode } from 'react';
|
||||
import { useId, type InputHTMLAttributes, type ReactNode } from 'react';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
@@ -7,20 +7,40 @@ interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
rightElement?: ReactNode;
|
||||
}
|
||||
|
||||
export function Input({ label, hint, error, rightElement, className = '', ...rest }: InputProps) {
|
||||
export function Input({ label, hint, error, rightElement, className = '', id, ...rest }: InputProps) {
|
||||
const generatedId = useId();
|
||||
const inputId = id ?? generatedId;
|
||||
const hintId = hint ? `${inputId}-hint` : undefined;
|
||||
const errorId = error ? `${inputId}-error` : undefined;
|
||||
const describedBy = [rest['aria-describedby'], errorId, hintId].filter(Boolean).join(' ') || undefined;
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
{label && <label>{label}</label>}
|
||||
{label && <label htmlFor={inputId}>{label}</label>}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input className={`input ${className}`.trim()} {...rest} />
|
||||
<input
|
||||
id={inputId}
|
||||
className={`input ${className}`.trim()}
|
||||
aria-invalid={Boolean(error) || rest['aria-invalid']}
|
||||
aria-describedby={describedBy}
|
||||
{...rest}
|
||||
/>
|
||||
{rightElement && (
|
||||
<div style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)' }}>
|
||||
{rightElement}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hint && <div className="hint">{hint}</div>}
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{hint && (
|
||||
<div id={hintId} className="hint">
|
||||
{hint}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div id={errorId} className="error-box">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+109
-5
@@ -1,5 +1,14 @@
|
||||
import { useState, useEffect, useCallback, useRef, type PropsWithChildren, type ReactNode } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconX } from './icons';
|
||||
|
||||
interface ModalProps {
|
||||
@@ -14,6 +23,14 @@ interface ModalProps {
|
||||
|
||||
const CLOSE_ANIMATION_DURATION = 350;
|
||||
const MODAL_LOCK_CLASS = 'modal-open';
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
let activeModalCount = 0;
|
||||
|
||||
const scrollLockSnapshot = {
|
||||
@@ -107,11 +124,23 @@ export function Modal({
|
||||
width = 520,
|
||||
className,
|
||||
closeDisabled = false,
|
||||
children
|
||||
children,
|
||||
}: PropsWithChildren<ModalProps>) {
|
||||
const { t } = useTranslation();
|
||||
const titleId = useId();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const modalRef = useRef<HTMLDivElement | null>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const getFocusableElements = useCallback(() => {
|
||||
if (!modalRef.current) return [] as HTMLElement[];
|
||||
return Array.from(modalRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||
(element) => !element.hasAttribute('disabled') && element.tabIndex !== -1
|
||||
);
|
||||
}, []);
|
||||
|
||||
const startClose = useCallback(
|
||||
(notifyParent: boolean) => {
|
||||
@@ -174,6 +203,70 @@ export function Modal({
|
||||
return () => unlockScroll();
|
||||
}, [shouldLockScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
previouslyFocusedRef.current =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
const firstFocusable = getFocusableElements()[0];
|
||||
(firstFocusable ?? closeButtonRef.current ?? modalRef.current)?.focus();
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(focusTimer);
|
||||
};
|
||||
}, [getFocusableElements, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open || isVisible) return;
|
||||
previouslyFocusedRef.current?.focus();
|
||||
previouslyFocusedRef.current = null;
|
||||
}, [isVisible, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (closeDisabled) return;
|
||||
event.preventDefault();
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
const focusableElements = getFocusableElements();
|
||||
if (focusableElements.length === 0) {
|
||||
event.preventDefault();
|
||||
modalRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
const activeElement = document.activeElement as HTMLElement | null;
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (activeElement === firstElement || activeElement === modalRef.current) {
|
||||
event.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [closeDisabled, getFocusableElements, handleClose, open]);
|
||||
|
||||
if (!open && !isVisible) return null;
|
||||
|
||||
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
|
||||
@@ -181,18 +274,29 @@ export function Modal({
|
||||
|
||||
const modalContent = (
|
||||
<div className={overlayClass}>
|
||||
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={modalClass}
|
||||
style={{ width }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? titleId : undefined}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
type="button"
|
||||
className="modal-close-floating"
|
||||
onClick={closeDisabled ? undefined : handleClose}
|
||||
aria-label="Close"
|
||||
aria-label={t('common.close')}
|
||||
disabled={closeDisabled}
|
||||
>
|
||||
<IconX size={20} />
|
||||
</button>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">{title}</div>
|
||||
<div className="modal-title" id={title ? titleId : undefined}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-body">{children}</div>
|
||||
{footer && <div className="modal-footer">{footer}</div>}
|
||||
|
||||
@@ -108,3 +108,7 @@
|
||||
background: rgba($primary-color, 0.1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.optionHighlighted {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
+112
-11
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { IconChevronDown } from './icons';
|
||||
import styles from './Select.module.scss';
|
||||
|
||||
@@ -15,7 +15,10 @@ interface SelectProps {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
ariaLabelledBy?: string;
|
||||
ariaDescribedBy?: string;
|
||||
fullWidth?: boolean;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function Select({
|
||||
@@ -26,9 +29,16 @@ export function Select({
|
||||
className,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
fullWidth = true
|
||||
ariaLabelledBy,
|
||||
ariaDescribedBy,
|
||||
fullWidth = true,
|
||||
id,
|
||||
}: SelectProps) {
|
||||
const generatedId = useId();
|
||||
const selectId = id ?? generatedId;
|
||||
const listboxId = `${selectId}-listbox`;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -41,23 +51,113 @@ export function Select({
|
||||
}, [disabled, open]);
|
||||
|
||||
const isOpen = open && !disabled;
|
||||
|
||||
const selected = options.find((o) => o.value === value);
|
||||
const selectedIndex = useMemo(() => options.findIndex((option) => option.value === value), [options, value]);
|
||||
const resolvedHighlightedIndex =
|
||||
highlightedIndex >= 0 ? highlightedIndex : selectedIndex >= 0 ? selectedIndex : options.length > 0 ? 0 : -1;
|
||||
const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined;
|
||||
const displayText = selected?.label ?? placeholder ?? '';
|
||||
const isPlaceholder = !selected && placeholder;
|
||||
|
||||
const commitSelection = useCallback(
|
||||
(nextIndex: number) => {
|
||||
const nextOption = options[nextIndex];
|
||||
if (!nextOption) return;
|
||||
onChange(nextOption.value);
|
||||
setOpen(false);
|
||||
setHighlightedIndex(nextIndex);
|
||||
},
|
||||
[onChange, options]
|
||||
);
|
||||
|
||||
const moveHighlight = useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
if (options.length === 0) return;
|
||||
const nextIndex = (resolvedHighlightedIndex + direction + options.length) % options.length;
|
||||
setHighlightedIndex(nextIndex);
|
||||
},
|
||||
[options.length, resolvedHighlightedIndex]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (disabled) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
if (!isOpen) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
moveHighlight(1);
|
||||
return;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
if (!isOpen) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
moveHighlight(-1);
|
||||
return;
|
||||
case 'Home':
|
||||
if (!isOpen || options.length === 0) return;
|
||||
event.preventDefault();
|
||||
setHighlightedIndex(0);
|
||||
return;
|
||||
case 'End':
|
||||
if (!isOpen || options.length === 0) return;
|
||||
event.preventDefault();
|
||||
setHighlightedIndex(options.length - 1);
|
||||
return;
|
||||
case 'Enter':
|
||||
case ' ': {
|
||||
event.preventDefault();
|
||||
if (!isOpen) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
if (resolvedHighlightedIndex >= 0) {
|
||||
commitSelection(resolvedHighlightedIndex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'Escape':
|
||||
if (!isOpen) return;
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
return;
|
||||
case 'Tab':
|
||||
if (isOpen) setOpen(false);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
},
|
||||
[commitSelection, disabled, isOpen, moveHighlight, options.length, resolvedHighlightedIndex]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.wrap} ${fullWidth ? styles.wrapFullWidth : ''} ${className ?? ''}`}
|
||||
ref={wrapRef}
|
||||
>
|
||||
<button
|
||||
id={selectId}
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={isOpen ? listboxId : undefined}
|
||||
aria-activedescendant={
|
||||
isOpen && resolvedHighlightedIndex >= 0
|
||||
? `${selectId}-option-${resolvedHighlightedIndex}`
|
||||
: undefined
|
||||
}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
|
||||
@@ -68,20 +168,21 @@ export function Select({
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className={styles.dropdown} role="listbox" aria-label={ariaLabel}>
|
||||
{options.map((opt) => {
|
||||
<div className={styles.dropdown} id={listboxId} role="listbox" aria-label={ariaLabel}>
|
||||
{options.map((opt, index) => {
|
||||
const active = opt.value === value;
|
||||
const highlighted = index === resolvedHighlightedIndex;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
id={`${selectId}-option-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`${styles.option} ${active ? styles.optionActive : ''}`}
|
||||
onClick={() => {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`${styles.option} ${active ? styles.optionActive : ''} ${highlighted ? styles.optionHighlighted : ''}`.trim()}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={() => commitSelection(index)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { formatFileSize } from '@/utils/format';
|
||||
import {
|
||||
QUOTA_PROVIDER_TYPES,
|
||||
formatModified,
|
||||
getAuthFileStatusMessage,
|
||||
getTypeColor,
|
||||
getTypeLabel,
|
||||
isRuntimeOnlyAuthFile,
|
||||
@@ -91,6 +92,8 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
const providerCardClass =
|
||||
quotaType === 'antigravity'
|
||||
? styles.antigravityCard
|
||||
: quotaType === 'claude'
|
||||
? styles.claudeCard
|
||||
: quotaType === 'codex'
|
||||
? styles.codexCard
|
||||
: quotaType === 'gemini-cli'
|
||||
@@ -103,7 +106,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
const authIndexKey = normalizeAuthIndex(rawAuthIndex);
|
||||
const statusData =
|
||||
(authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
|
||||
const rawStatusMessage = String(file['status_message'] ?? file.statusMessage ?? '').trim();
|
||||
const rawStatusMessage = getAuthFileStatusMessage(file);
|
||||
const hasStatusWarning =
|
||||
Boolean(rawStatusMessage) && !HEALTHY_STATUS_MESSAGES.has(rawStatusMessage.toLowerCase());
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useCallback, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG, KIMI_CONFIG } from '@/components/quota';
|
||||
import {
|
||||
ANTIGRAVITY_CONFIG,
|
||||
CLAUDE_CONFIG,
|
||||
CODEX_CONFIG,
|
||||
GEMINI_CLI_CONFIG,
|
||||
KIMI_CONFIG
|
||||
} from '@/components/quota';
|
||||
import { useNotificationStore, useQuotaStore } from '@/stores';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import { getStatusFromError } from '@/utils/quota';
|
||||
@@ -17,6 +23,7 @@ type QuotaState = { status?: string; error?: string; errorStatus?: number } | un
|
||||
|
||||
const getQuotaConfig = (type: QuotaProviderType) => {
|
||||
if (type === 'antigravity') return ANTIGRAVITY_CONFIG;
|
||||
if (type === 'claude') return CLAUDE_CONFIG;
|
||||
if (type === 'codex') return CODEX_CONFIG;
|
||||
if (type === 'kimi') return KIMI_CONFIG;
|
||||
return GEMINI_CLI_CONFIG;
|
||||
@@ -35,6 +42,7 @@ export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
|
||||
|
||||
const quota = useQuotaStore((state) => {
|
||||
if (quotaType === 'antigravity') return state.antigravityQuota[file.name] as QuotaState;
|
||||
if (quotaType === 'claude') return state.claudeQuota[file.name] as QuotaState;
|
||||
if (quotaType === 'codex') return state.codexQuota[file.name] as QuotaState;
|
||||
if (quotaType === 'kimi') return state.kimiQuota[file.name] as QuotaState;
|
||||
return state.geminiCliQuota[file.name] as QuotaState;
|
||||
@@ -42,6 +50,7 @@ export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
|
||||
|
||||
const updateQuotaState = useQuotaStore((state) => {
|
||||
if (quotaType === 'antigravity') return state.setAntigravityQuota as unknown as (updater: unknown) => void;
|
||||
if (quotaType === 'claude') return state.setClaudeQuota as unknown as (updater: unknown) => void;
|
||||
if (quotaType === 'codex') return state.setCodexQuota as unknown as (updater: unknown) => void;
|
||||
if (quotaType === 'kimi') return state.setKimiQuota as unknown as (updater: unknown) => void;
|
||||
return state.setGeminiCliQuota as unknown as (updater: unknown) => void;
|
||||
|
||||
@@ -12,9 +12,15 @@ export type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
|
||||
export type ResolvedTheme = 'light' | 'dark';
|
||||
export type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string };
|
||||
|
||||
export type QuotaProviderType = 'antigravity' | 'codex' | 'gemini-cli' | 'kimi';
|
||||
export type QuotaProviderType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli' | 'kimi';
|
||||
|
||||
export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>(['antigravity', 'codex', 'gemini-cli', 'kimi']);
|
||||
export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>([
|
||||
'antigravity',
|
||||
'claude',
|
||||
'codex',
|
||||
'gemini-cli',
|
||||
'kimi'
|
||||
]);
|
||||
|
||||
export const MIN_CARD_PAGE_SIZE = 3;
|
||||
export const MAX_CARD_PAGE_SIZE = 30;
|
||||
@@ -87,6 +93,16 @@ export const resolveQuotaErrorMessage = (
|
||||
|
||||
export const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
|
||||
|
||||
export const getAuthFileStatusMessage = (file: AuthFileItem): string => {
|
||||
const raw = file['status_message'] ?? file.statusMessage;
|
||||
if (typeof raw === 'string') return raw.trim();
|
||||
if (raw == null) return '';
|
||||
return String(raw).trim();
|
||||
};
|
||||
|
||||
export const hasAuthFileStatusMessage = (file: AuthFileItem): boolean =>
|
||||
getAuthFileStatusMessage(file).length > 0;
|
||||
|
||||
export const getTypeLabel = (t: TFunction, type: string): string => {
|
||||
const key = `auth_files.filter_${type}`;
|
||||
const translated = t(key);
|
||||
|
||||
@@ -7,11 +7,17 @@ import type { AuthFileItem } from '@/types';
|
||||
import { formatFileSize } from '@/utils/format';
|
||||
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
|
||||
import { downloadBlob } from '@/utils/download';
|
||||
import { getTypeLabel, isRuntimeOnlyAuthFile } from '@/features/authFiles/constants';
|
||||
import {
|
||||
getTypeLabel,
|
||||
hasAuthFileStatusMessage,
|
||||
isRuntimeOnlyAuthFile,
|
||||
} from '@/features/authFiles/constants';
|
||||
|
||||
type DeleteAllOptions = {
|
||||
filter: string;
|
||||
problemOnly: boolean;
|
||||
onResetFilterToAll: () => void;
|
||||
onResetProblemOnly: () => void;
|
||||
};
|
||||
|
||||
export type UseAuthFilesDataResult = {
|
||||
@@ -59,7 +65,6 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const selectionCount = selectedFiles.size;
|
||||
|
||||
const toggleSelect = useCallback((name: string) => {
|
||||
setSelectedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -223,12 +228,17 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
|
||||
const handleDeleteAll = useCallback(
|
||||
(deleteAllOptions: DeleteAllOptions) => {
|
||||
const { filter, onResetFilterToAll } = deleteAllOptions;
|
||||
const { filter, problemOnly, onResetFilterToAll, onResetProblemOnly } = deleteAllOptions;
|
||||
const isFiltered = filter !== 'all';
|
||||
const isProblemOnly = problemOnly === true;
|
||||
const typeLabel = isFiltered ? getTypeLabel(t, filter) : t('auth_files.filter_all');
|
||||
const confirmMessage = isFiltered
|
||||
? t('auth_files.delete_filtered_confirm', { type: typeLabel })
|
||||
: t('auth_files.delete_all_confirm');
|
||||
const confirmMessage = isProblemOnly
|
||||
? isFiltered
|
||||
? t('auth_files.delete_problem_filtered_confirm', { type: typeLabel })
|
||||
: t('auth_files.delete_problem_confirm')
|
||||
: isFiltered
|
||||
? t('auth_files.delete_filtered_confirm', { type: typeLabel })
|
||||
: t('auth_files.delete_all_confirm');
|
||||
|
||||
showConfirmation({
|
||||
title: t('auth_files.delete_all_title', { defaultValue: 'Delete All Files' }),
|
||||
@@ -238,18 +248,26 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
onConfirm: async () => {
|
||||
setDeletingAll(true);
|
||||
try {
|
||||
if (!isFiltered) {
|
||||
if (!isFiltered && !isProblemOnly) {
|
||||
await authFilesApi.deleteAll();
|
||||
showNotification(t('auth_files.delete_all_success'), 'success');
|
||||
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
|
||||
deselectAll();
|
||||
} else {
|
||||
const filesToDelete = files.filter(
|
||||
(f) => f.type === filter && !isRuntimeOnlyAuthFile(f)
|
||||
);
|
||||
const filesToDelete = files.filter((file) => {
|
||||
if (isRuntimeOnlyAuthFile(file)) return false;
|
||||
if (isFiltered && file.type !== filter) return false;
|
||||
if (isProblemOnly && !hasAuthFileStatusMessage(file)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (filesToDelete.length === 0) {
|
||||
showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info');
|
||||
const emptyMessage = isProblemOnly
|
||||
? isFiltered
|
||||
? t('auth_files.delete_problem_filtered_none', { type: typeLabel })
|
||||
: t('auth_files.delete_problem_none')
|
||||
: t('auth_files.delete_filtered_none', { type: typeLabel });
|
||||
showNotification(emptyMessage, 'info');
|
||||
setDeletingAll(false);
|
||||
return;
|
||||
}
|
||||
@@ -284,18 +302,45 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
return changed ? next : prev;
|
||||
});
|
||||
|
||||
if (failed === 0) {
|
||||
if (failed === 0 && isProblemOnly) {
|
||||
showNotification(
|
||||
isFiltered
|
||||
? t('auth_files.delete_problem_filtered_success', {
|
||||
count: success,
|
||||
type: typeLabel,
|
||||
})
|
||||
: t('auth_files.delete_problem_success', { count: success }),
|
||||
'success'
|
||||
);
|
||||
} else if (failed === 0) {
|
||||
showNotification(
|
||||
t('auth_files.delete_filtered_success', { count: success, type: typeLabel }),
|
||||
'success'
|
||||
);
|
||||
} else if (isProblemOnly) {
|
||||
showNotification(
|
||||
isFiltered
|
||||
? t('auth_files.delete_problem_filtered_partial', {
|
||||
success,
|
||||
failed,
|
||||
type: typeLabel,
|
||||
})
|
||||
: t('auth_files.delete_problem_partial', { success, failed }),
|
||||
'warning'
|
||||
);
|
||||
} else {
|
||||
showNotification(
|
||||
t('auth_files.delete_filtered_partial', { success, failed, type: typeLabel }),
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
onResetFilterToAll();
|
||||
|
||||
if (isFiltered) {
|
||||
onResetFilterToAll();
|
||||
}
|
||||
if (isProblemOnly) {
|
||||
onResetProblemOnly();
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
|
||||
@@ -260,8 +260,7 @@ export function useAuthFilesPrefixProxyEditor(
|
||||
});
|
||||
|
||||
try {
|
||||
const file = new File([payload], name, { type: 'application/json' });
|
||||
await authFilesApi.upload(file);
|
||||
await authFilesApi.saveText(name, payload);
|
||||
showNotification(t('auth_files.prefix_proxy_saved_success', { name }), 'success');
|
||||
await loadFiles();
|
||||
await loadKeyStats();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type AuthFilesUiState = {
|
||||
filter?: string;
|
||||
problemOnly?: boolean;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
@@ -27,4 +28,3 @@ export const writeAuthFilesUiState = (state: AuthFilesUiState) => {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,9 +2,12 @@ import { useCallback, useMemo, useState } from 'react';
|
||||
import { isMap, parse as parseYaml, parseDocument } from 'yaml';
|
||||
import type {
|
||||
PayloadFilterRule,
|
||||
PayloadParamEntry,
|
||||
PayloadParamValueType,
|
||||
PayloadRule,
|
||||
VisualConfigValues,
|
||||
VisualConfigValidationErrors,
|
||||
PayloadParamValidationErrorCode,
|
||||
} from '@/types/visualConfig';
|
||||
import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig';
|
||||
|
||||
@@ -44,9 +47,100 @@ function parseApiKeysText(raw: unknown): string {
|
||||
return keys.join('\n');
|
||||
}
|
||||
|
||||
function replaceApiKeyValue(entry: unknown, apiKey: string): unknown {
|
||||
const record = asRecord(entry);
|
||||
if (!record) return apiKey;
|
||||
|
||||
if ('api-key' in record) return { ...record, 'api-key': apiKey };
|
||||
if ('apiKey' in record) return { ...record, apiKey };
|
||||
if ('key' in record) return { ...record, key: apiKey };
|
||||
if ('Key' in record) return { ...record, Key: apiKey };
|
||||
|
||||
return { ...record, 'api-key': apiKey };
|
||||
}
|
||||
|
||||
function buildApiKeyEntries(
|
||||
apiKeys: string[],
|
||||
metadata: ApiKeysStorageMetadata
|
||||
): Array<string | Record<string, unknown>> {
|
||||
return apiKeys.map((apiKey, index) => {
|
||||
const originalEntry = metadata.originalEntries[index];
|
||||
if (metadata.entryMode === 'object') {
|
||||
const replaced = replaceApiKeyValue(originalEntry, apiKey);
|
||||
return asRecord(replaced) ?? { 'api-key': apiKey };
|
||||
}
|
||||
|
||||
const record = asRecord(originalEntry);
|
||||
return record ? ({ ...record, ...(replaceApiKeyValue(record, apiKey) as Record<string, unknown>) }) : apiKey;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveApiKeysStorage(parsed: Record<string, unknown>): {
|
||||
text: string;
|
||||
metadata: ApiKeysStorageMetadata;
|
||||
} {
|
||||
const legacyEntries = Array.isArray(parsed['api-keys']) ? parsed['api-keys'] : [];
|
||||
const auth = asRecord(parsed.auth);
|
||||
const providers = asRecord(auth?.providers);
|
||||
const configApiKeyProvider = asRecord(providers?.['config-api-key']);
|
||||
|
||||
if (configApiKeyProvider) {
|
||||
const providerEntries = Array.isArray(configApiKeyProvider['api-key-entries'])
|
||||
? configApiKeyProvider['api-key-entries']
|
||||
: Array.isArray(configApiKeyProvider['api-keys'])
|
||||
? configApiKeyProvider['api-keys']
|
||||
: [];
|
||||
const providerListKey = Array.isArray(configApiKeyProvider['api-key-entries'])
|
||||
? 'api-key-entries'
|
||||
: 'api-keys';
|
||||
|
||||
return {
|
||||
text: parseApiKeysText(providerEntries),
|
||||
metadata: {
|
||||
source: 'auth-provider',
|
||||
providerListKey,
|
||||
entryMode:
|
||||
providerListKey === 'api-key-entries' || providerEntries.some((entry) => Boolean(asRecord(entry)))
|
||||
? 'object'
|
||||
: 'string',
|
||||
originalEntries: providerEntries,
|
||||
syncLegacy: legacyEntries.length > 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: parseApiKeysText(legacyEntries),
|
||||
metadata: {
|
||||
source: 'legacy',
|
||||
entryMode: legacyEntries.some((entry) => Boolean(asRecord(entry))) ? 'object' : 'string',
|
||||
originalEntries: legacyEntries,
|
||||
syncLegacy: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type YamlDocument = ReturnType<typeof parseDocument>;
|
||||
type YamlPath = string[];
|
||||
|
||||
type ApiKeysStorageMode = 'legacy' | 'auth-provider';
|
||||
type ApiKeysEntryMode = 'string' | 'object';
|
||||
|
||||
type ApiKeysStorageMetadata = {
|
||||
source: ApiKeysStorageMode;
|
||||
providerListKey?: 'api-keys' | 'api-key-entries';
|
||||
entryMode: ApiKeysEntryMode;
|
||||
originalEntries: unknown[];
|
||||
syncLegacy: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_API_KEYS_STORAGE_METADATA: ApiKeysStorageMetadata = {
|
||||
source: 'legacy',
|
||||
entryMode: 'string',
|
||||
originalEntries: [],
|
||||
syncLegacy: false,
|
||||
};
|
||||
|
||||
function docHas(doc: YamlDocument, path: YamlPath): boolean {
|
||||
return doc.hasIn(path);
|
||||
}
|
||||
@@ -93,13 +187,81 @@ function setIntFromStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
if (!/^-?\d+$/.test(trimmed)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
if (Number.isFinite(parsed)) {
|
||||
doc.setIn(path, parsed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (docHas(doc, path)) doc.deleteIn(path);
|
||||
function getNonNegativeIntegerError(value: string): 'non_negative_integer' | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (!/^-?\d+$/.test(trimmed)) return 'non_negative_integer';
|
||||
return Number(trimmed) >= 0 ? undefined : 'non_negative_integer';
|
||||
}
|
||||
|
||||
function getPortError(value: string): 'port_range' | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (!/^\d+$/.test(trimmed)) return 'port_range';
|
||||
const parsed = Number(trimmed);
|
||||
return parsed >= 1 && parsed <= 65535 ? undefined : 'port_range';
|
||||
}
|
||||
|
||||
export function getVisualConfigValidationErrors(
|
||||
values: VisualConfigValues
|
||||
): VisualConfigValidationErrors {
|
||||
return {
|
||||
port: getPortError(values.port),
|
||||
logsMaxTotalSizeMb: getNonNegativeIntegerError(values.logsMaxTotalSizeMb),
|
||||
requestRetry: getNonNegativeIntegerError(values.requestRetry),
|
||||
maxRetryInterval: getNonNegativeIntegerError(values.maxRetryInterval),
|
||||
'streaming.keepaliveSeconds': getNonNegativeIntegerError(values.streaming.keepaliveSeconds),
|
||||
'streaming.bootstrapRetries': getNonNegativeIntegerError(values.streaming.bootstrapRetries),
|
||||
'streaming.nonstreamKeepaliveInterval': getNonNegativeIntegerError(
|
||||
values.streaming.nonstreamKeepaliveInterval
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function getPayloadParamValidationError(
|
||||
param: PayloadParamEntry
|
||||
): PayloadParamValidationErrorCode | undefined {
|
||||
const trimmedValue = param.value.trim();
|
||||
|
||||
switch (param.valueType) {
|
||||
case 'number': {
|
||||
if (!trimmedValue) return 'payload_invalid_number';
|
||||
const parsed = Number(trimmedValue);
|
||||
return Number.isFinite(parsed) ? undefined : 'payload_invalid_number';
|
||||
}
|
||||
case 'boolean': {
|
||||
const normalized = trimmedValue.toLowerCase();
|
||||
return normalized === 'true' || normalized === 'false'
|
||||
? undefined
|
||||
: 'payload_invalid_boolean';
|
||||
}
|
||||
case 'json': {
|
||||
if (!trimmedValue) return 'payload_invalid_json';
|
||||
try {
|
||||
JSON.parse(param.value);
|
||||
return undefined;
|
||||
} catch {
|
||||
return 'payload_invalid_json';
|
||||
}
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function hasPayloadParamValidationErrors(rules: PayloadRule[]): boolean {
|
||||
return rules.some((rule) => rule.params.some((param) => Boolean(getPayloadParamValidationError(param))));
|
||||
}
|
||||
|
||||
function deepClone<T>(value: T): T {
|
||||
@@ -276,6 +438,19 @@ export function useVisualConfig() {
|
||||
const [baselineValues, setBaselineValues] = useState<VisualConfigValues>({
|
||||
...DEFAULT_VISUAL_VALUES,
|
||||
});
|
||||
const [visualParseError, setVisualParseError] = useState<string | null>(null);
|
||||
const [apiKeysStorageMetadata, setApiKeysStorageMetadata] =
|
||||
useState<ApiKeysStorageMetadata>(DEFAULT_API_KEYS_STORAGE_METADATA);
|
||||
const visualValidationErrors = useMemo(
|
||||
() => getVisualConfigValidationErrors(visualValues),
|
||||
[visualValues]
|
||||
);
|
||||
const visualHasPayloadValidationErrors = useMemo(
|
||||
() =>
|
||||
hasPayloadParamValidationErrors(visualValues.payloadDefaultRules) ||
|
||||
hasPayloadParamValidationErrors(visualValues.payloadOverrideRules),
|
||||
[visualValues.payloadDefaultRules, visualValues.payloadOverrideRules]
|
||||
);
|
||||
|
||||
const visualDirty = useMemo(() => {
|
||||
return JSON.stringify(visualValues) !== JSON.stringify(baselineValues);
|
||||
@@ -283,6 +458,11 @@ export function useVisualConfig() {
|
||||
|
||||
const loadVisualValuesFromYaml = useCallback((yamlContent: string) => {
|
||||
try {
|
||||
const document = parseDocument(yamlContent);
|
||||
if (document.errors.length > 0) {
|
||||
throw new Error(document.errors[0]?.message ?? 'Invalid YAML');
|
||||
}
|
||||
|
||||
const parsedRaw: unknown = parseYaml(yamlContent) || {};
|
||||
const parsed = asRecord(parsedRaw) ?? {};
|
||||
const tls = asRecord(parsed.tls);
|
||||
@@ -291,6 +471,7 @@ export function useVisualConfig() {
|
||||
const routing = asRecord(parsed.routing);
|
||||
const payload = asRecord(parsed.payload);
|
||||
const streaming = asRecord(parsed.streaming);
|
||||
const apiKeysStorage = resolveApiKeysStorage(parsed);
|
||||
|
||||
const newValues: VisualConfigValues = {
|
||||
host: typeof parsed.host === 'string' ? parsed.host : '',
|
||||
@@ -312,7 +493,7 @@ export function useVisualConfig() {
|
||||
: '',
|
||||
|
||||
authDir: typeof parsed['auth-dir'] === 'string' ? parsed['auth-dir'] : '',
|
||||
apiKeysText: parseApiKeysText(parsed['api-keys']),
|
||||
apiKeysText: apiKeysStorage.text,
|
||||
|
||||
debug: Boolean(parsed.debug),
|
||||
commercialMode: Boolean(parsed['commercial-mode']),
|
||||
@@ -347,9 +528,13 @@ export function useVisualConfig() {
|
||||
|
||||
setVisualValuesState(newValues);
|
||||
setBaselineValues(deepClone(newValues));
|
||||
} catch {
|
||||
setVisualValuesState({ ...DEFAULT_VISUAL_VALUES });
|
||||
setBaselineValues(deepClone(DEFAULT_VISUAL_VALUES));
|
||||
setApiKeysStorageMetadata(apiKeysStorage.metadata);
|
||||
setVisualParseError(null);
|
||||
return { ok: true as const };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid YAML';
|
||||
setVisualParseError(message);
|
||||
return { ok: false as const, error: message };
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -407,8 +592,35 @@ export function useVisualConfig() {
|
||||
.split('\n')
|
||||
.map((key) => key.trim())
|
||||
.filter(Boolean);
|
||||
if (apiKeys.length > 0) {
|
||||
doc.setIn(['api-keys'], apiKeys);
|
||||
const apiKeyEntries = buildApiKeyEntries(apiKeys, apiKeysStorageMetadata);
|
||||
|
||||
if (apiKeysStorageMetadata.source === 'auth-provider') {
|
||||
ensureMapInDoc(doc, ['auth']);
|
||||
ensureMapInDoc(doc, ['auth', 'providers']);
|
||||
ensureMapInDoc(doc, ['auth', 'providers', 'config-api-key']);
|
||||
|
||||
const providerListKey = apiKeysStorageMetadata.providerListKey ?? 'api-key-entries';
|
||||
const providerPath = ['auth', 'providers', 'config-api-key', providerListKey];
|
||||
|
||||
if (apiKeys.length > 0) {
|
||||
doc.setIn(providerPath, apiKeyEntries);
|
||||
} else if (docHas(doc, providerPath)) {
|
||||
doc.deleteIn(providerPath);
|
||||
}
|
||||
|
||||
deleteIfMapEmpty(doc, ['auth', 'providers', 'config-api-key']);
|
||||
deleteIfMapEmpty(doc, ['auth', 'providers']);
|
||||
deleteIfMapEmpty(doc, ['auth']);
|
||||
|
||||
if (apiKeysStorageMetadata.syncLegacy) {
|
||||
if (apiKeys.length > 0) {
|
||||
doc.setIn(['api-keys'], apiKeys);
|
||||
} else if (docHas(doc, ['api-keys'])) {
|
||||
doc.deleteIn(['api-keys']);
|
||||
}
|
||||
}
|
||||
} else if (apiKeys.length > 0) {
|
||||
doc.setIn(['api-keys'], apiKeyEntries);
|
||||
} else if (docHas(doc, ['api-keys'])) {
|
||||
doc.deleteIn(['api-keys']);
|
||||
}
|
||||
@@ -510,7 +722,7 @@ export function useVisualConfig() {
|
||||
return currentYaml;
|
||||
}
|
||||
},
|
||||
[baselineValues, visualValues]
|
||||
[apiKeysStorageMetadata, baselineValues, visualValues]
|
||||
);
|
||||
|
||||
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
|
||||
@@ -526,6 +738,9 @@ export function useVisualConfig() {
|
||||
return {
|
||||
visualValues,
|
||||
visualDirty,
|
||||
visualParseError,
|
||||
visualValidationErrors,
|
||||
visualHasPayloadValidationErrors,
|
||||
loadVisualValuesFromYaml,
|
||||
applyVisualChangesToYaml,
|
||||
setVisualValues,
|
||||
|
||||
@@ -366,6 +366,13 @@
|
||||
"ampcode_upstream_api_key_current": "Current Amp official key: {{key}}",
|
||||
"ampcode_clear_upstream_api_key": "Clear official key",
|
||||
"ampcode_clear_upstream_api_key_confirm": "Are you sure you want to clear the Ampcode upstream API key (Amp official)?",
|
||||
"ampcode_upstream_api_keys_label": "Multi-upstream API key routing",
|
||||
"ampcode_upstream_api_keys_hint": "Bind different Amp upstream API keys to specific client API keys. Client keys can be separated by commas or new lines.",
|
||||
"ampcode_upstream_api_keys_add_btn": "Add upstream mapping",
|
||||
"ampcode_upstream_api_keys_upstream_placeholder": "Upstream API key (sk-amp-...)",
|
||||
"ampcode_upstream_api_keys_clients_placeholder": "Client API keys, separated by commas or new lines",
|
||||
"ampcode_upstream_api_keys_item_title": "Upstream mapping #{{index}}",
|
||||
"ampcode_upstream_api_keys_count": "Upstream mappings",
|
||||
"ampcode_force_model_mappings_label": "Force model mappings",
|
||||
"ampcode_force_model_mappings_hint": "When enabled, mappings override local API-key availability checks.",
|
||||
"ampcode_model_mappings_label": "Model mappings (from → to)",
|
||||
@@ -374,6 +381,8 @@
|
||||
"ampcode_model_mappings_from_placeholder": "from model (source)",
|
||||
"ampcode_model_mappings_to_placeholder": "to model (target)",
|
||||
"ampcode_model_mappings_count": "Mappings Count",
|
||||
"ampcode_lists_overwrite_title": "Overwrite list settings",
|
||||
"ampcode_lists_overwrite_confirm": "Existing multi-upstream/model mapping lists could not be loaded. Continuing may overwrite or clear them. Continue?",
|
||||
"ampcode_mappings_overwrite_confirm": "Existing mappings could not be loaded. Continuing may overwrite or clear them. Continue?",
|
||||
"openai_title": "OpenAI Compatible Providers",
|
||||
"openai_add_button": "Add Provider",
|
||||
@@ -460,6 +469,10 @@
|
||||
"delete_confirm": "Are you sure you want to delete file",
|
||||
"delete_all_confirm": "Are you sure you want to delete all auth files? This operation cannot be undone!",
|
||||
"delete_filtered_confirm": "Are you sure you want to delete all {{type}} auth files? This operation cannot be undone!",
|
||||
"delete_problem_button": "Delete Problem Files",
|
||||
"delete_problem_button_with_type": "Delete Problematic {{type}} Files",
|
||||
"delete_problem_confirm": "Are you sure you want to delete all problematic auth files? This operation cannot be undone!",
|
||||
"delete_problem_filtered_confirm": "Are you sure you want to delete all problematic {{type}} auth files? This operation cannot be undone!",
|
||||
"upload_error_json": "Only JSON files are allowed",
|
||||
"upload_error_size": "File size cannot exceed {{maxSize}}",
|
||||
"upload_success": "File uploaded successfully",
|
||||
@@ -469,12 +482,20 @@
|
||||
"delete_filtered_success": "Deleted {{count}} {{type}} auth files successfully",
|
||||
"delete_filtered_partial": "{{type}} auth files deletion finished: {{success}} succeeded, {{failed}} failed",
|
||||
"delete_filtered_none": "No deletable auth files under the current filter ({{type}})",
|
||||
"delete_problem_success": "Deleted {{count}} problematic auth files successfully",
|
||||
"delete_problem_filtered_success": "Deleted {{count}} problematic {{type}} auth files successfully",
|
||||
"delete_problem_partial": "Problematic auth files deletion finished: {{success}} succeeded, {{failed}} failed",
|
||||
"delete_problem_filtered_partial": "Problematic {{type}} auth files deletion finished: {{success}} succeeded, {{failed}} failed",
|
||||
"delete_problem_none": "No deletable problematic auth files under the current filter",
|
||||
"delete_problem_filtered_none": "No deletable problematic {{type}} auth files under the current filter",
|
||||
"files_count": "files",
|
||||
"pagination_prev": "Previous",
|
||||
"pagination_next": "Next",
|
||||
"pagination_info": "Page {{current}} / {{total}} · {{count}} files",
|
||||
"search_label": "Search configs",
|
||||
"search_placeholder": "Filter by name, type, or provider",
|
||||
"problem_filter_label": "Problem Filter",
|
||||
"problem_filter_only": "Only show problematic credentials",
|
||||
"page_size_label": "Per page",
|
||||
"page_size_unit": "items",
|
||||
"view_mode_paged": "Paged",
|
||||
@@ -579,7 +600,13 @@
|
||||
"seven_day_sonnet": "7-day Sonnet",
|
||||
"seven_day_cowork": "7-day Cowork",
|
||||
"iguana_necktie": "Iguana Necktie",
|
||||
"extra_usage_label": "Extra Usage"
|
||||
"extra_usage_label": "Extra Usage",
|
||||
"plan_label": "Plan",
|
||||
"plan_unknown": "Unknown",
|
||||
"plan_free": "Free",
|
||||
"plan_pro": "Pro",
|
||||
"plan_max5": "Max 5x",
|
||||
"plan_max20": "Max 20x"
|
||||
},
|
||||
"codex_quota": {
|
||||
"title": "Codex Quota",
|
||||
@@ -1016,6 +1043,10 @@
|
||||
"show_raw_logs": "Show Raw Logs",
|
||||
"show_raw_logs_hint": "Show original log text for easier multi-line copy",
|
||||
"search_placeholder": "Search logs by content or keyword",
|
||||
"filter_panel_title": "Structured Filters",
|
||||
"filter_panel_expand": "Expand structured filters",
|
||||
"filter_panel_collapse": "Collapse structured filters",
|
||||
"filter_panel_active_count": "{{count}} active",
|
||||
"filter_method": "Method",
|
||||
"filter_status": "Status",
|
||||
"filter_path": "Path",
|
||||
@@ -1067,6 +1098,7 @@
|
||||
"title": "Config Panel",
|
||||
"editor_title": "Configuration File",
|
||||
"reload": "Reload",
|
||||
"reload_confirm_message": "Reloading will discard your unsaved changes. Do you want to continue?",
|
||||
"save": "Save",
|
||||
"description": "Edit config.yaml via visual editor or source file",
|
||||
"status_idle": "Waiting for action",
|
||||
@@ -1080,6 +1112,10 @@
|
||||
"status_save_failed": "Save failed",
|
||||
"save_success": "Configuration saved successfully",
|
||||
"error_yaml_not_supported": "Server did not return YAML. Verify the /config.yaml endpoint is available.",
|
||||
"visual_mode_unavailable": "Visual editor unavailable until YAML syntax is fixed",
|
||||
"visual_mode_unavailable_detail": "Visual editor is unavailable because the configuration contains invalid YAML: {{message}}",
|
||||
"visual_mode_save_blocked": "Cannot save from visual mode until the YAML syntax is fixed",
|
||||
"visual_mode_latest_yaml_invalid": "The latest server configuration contains invalid YAML. Review it in source mode before saving visual changes: {{message}}",
|
||||
"editor_placeholder": "key: value",
|
||||
"search_placeholder": "Search config...",
|
||||
"search_button": "Search",
|
||||
@@ -1213,6 +1249,7 @@
|
||||
"json_path": "JSON Path (e.g., temperature)",
|
||||
"json_path_filter": "JSON Path (gjson/sjson), e.g., generationConfig.thinkingConfig.thinkingBudget",
|
||||
"param_type": "Parameter Type",
|
||||
"param_value": "Parameter Value",
|
||||
"add_param": "Add Parameter",
|
||||
"no_rules": "No rules",
|
||||
"add_rule": "Add Rule",
|
||||
@@ -1231,7 +1268,17 @@
|
||||
"value_number": "Number value (e.g., 0.7)",
|
||||
"value_boolean": "true or false",
|
||||
"value_json": "JSON value",
|
||||
"value_default": "Value"
|
||||
"value_default": "Value",
|
||||
"boolean_true": "true",
|
||||
"boolean_false": "false"
|
||||
},
|
||||
"validation": {
|
||||
"validation_blocked": "Fix validation errors before saving",
|
||||
"port_range": "Enter a valid port between 1 and 65535",
|
||||
"non_negative_integer": "Enter a non-negative whole number",
|
||||
"payload_invalid_number": "Enter a valid number",
|
||||
"payload_invalid_boolean": "Choose true or false",
|
||||
"payload_invalid_json": "Enter valid JSON"
|
||||
},
|
||||
"common": {
|
||||
"edit": "Edit",
|
||||
@@ -1369,6 +1416,7 @@
|
||||
"theme": {
|
||||
"switch": "Theme",
|
||||
"light": "Light",
|
||||
"white": "Pure White",
|
||||
"dark": "Dark",
|
||||
"switch_to_light": "Switch to light mode",
|
||||
"switch_to_dark": "Switch to dark mode",
|
||||
|
||||
@@ -366,6 +366,13 @@
|
||||
"ampcode_upstream_api_key_current": "Текущий официальный ключ Amp: {{key}}",
|
||||
"ampcode_clear_upstream_api_key": "Очистить официальный ключ",
|
||||
"ampcode_clear_upstream_api_key_confirm": "Очистить upstream API-ключ Ampcode (официальный Amp)?",
|
||||
"ampcode_upstream_api_keys_label": "Маршрутизация нескольких upstream API-ключей",
|
||||
"ampcode_upstream_api_keys_hint": "Привяжите разные upstream API-ключи Amp к указанным клиентским API-ключам. Клиентские ключи можно разделять запятыми или переводами строки.",
|
||||
"ampcode_upstream_api_keys_add_btn": "Добавить upstream-сопоставление",
|
||||
"ampcode_upstream_api_keys_upstream_placeholder": "Upstream API-ключ (sk-amp-...)",
|
||||
"ampcode_upstream_api_keys_clients_placeholder": "Клиентские API-ключи, через запятую или с новой строки",
|
||||
"ampcode_upstream_api_keys_item_title": "Upstream-сопоставление #{{index}}",
|
||||
"ampcode_upstream_api_keys_count": "Количество upstream-сопоставлений",
|
||||
"ampcode_force_model_mappings_label": "Принудительно применять сопоставления моделей",
|
||||
"ampcode_force_model_mappings_hint": "При включении сопоставления переопределяют локальные проверки доступности API-ключей.",
|
||||
"ampcode_model_mappings_label": "Сопоставления моделей (из → в)",
|
||||
@@ -374,6 +381,8 @@
|
||||
"ampcode_model_mappings_from_placeholder": "исходная модель",
|
||||
"ampcode_model_mappings_to_placeholder": "целевая модель",
|
||||
"ampcode_model_mappings_count": "Количество сопоставлений",
|
||||
"ampcode_lists_overwrite_title": "Перезаписать списки",
|
||||
"ampcode_lists_overwrite_confirm": "Существующие списки multi-upstream/сопоставлений моделей не удалось загрузить. Продолжение может перезаписать или очистить их. Продолжить?",
|
||||
"ampcode_mappings_overwrite_confirm": "Не удалось загрузить существующие сопоставления. Продолжение может перезаписать или очистить их. Продолжить?",
|
||||
"openai_title": "Совместимые с OpenAI провайдеры",
|
||||
"openai_add_button": "Добавить провайдера",
|
||||
@@ -460,6 +469,10 @@
|
||||
"delete_confirm": "Удалить файл",
|
||||
"delete_all_confirm": "Удалить все файлы авторизации? Это действие нельзя отменить!",
|
||||
"delete_filtered_confirm": "Удалить все файлы авторизации {{type}}? Это действие нельзя отменить!",
|
||||
"delete_problem_button": "Удалить проблемные",
|
||||
"delete_problem_button_with_type": "Удалить проблемные файлы {{type}}",
|
||||
"delete_problem_confirm": "Удалить все проблемные файлы авторизации? Это действие нельзя отменить!",
|
||||
"delete_problem_filtered_confirm": "Удалить все проблемные файлы авторизации {{type}}? Это действие нельзя отменить!",
|
||||
"upload_error_json": "Допустимы только файлы JSON",
|
||||
"upload_error_size": "Размер файла не может превышать {{maxSize}}",
|
||||
"upload_success": "Файл успешно загружен",
|
||||
@@ -469,12 +482,20 @@
|
||||
"delete_filtered_success": "Удалено файлов {{type}}: {{count}}",
|
||||
"delete_filtered_partial": "Удаление файлов {{type}} завершено: успешных {{success}}, ошибок {{failed}}",
|
||||
"delete_filtered_none": "Нет файлов {{type}} для удаления при текущем фильтре",
|
||||
"delete_problem_success": "Удалено проблемных файлов авторизации: {{count}}",
|
||||
"delete_problem_filtered_success": "Удалено проблемных файлов авторизации {{type}}: {{count}}",
|
||||
"delete_problem_partial": "Удаление проблемных файлов авторизации завершено: успешных {{success}}, ошибок {{failed}}",
|
||||
"delete_problem_filtered_partial": "Удаление проблемных файлов авторизации {{type}} завершено: успешных {{success}}, ошибок {{failed}}",
|
||||
"delete_problem_none": "Нет проблемных файлов авторизации для удаления при текущем фильтре",
|
||||
"delete_problem_filtered_none": "Нет проблемных файлов авторизации {{type}} для удаления при текущем фильтре",
|
||||
"files_count": "файлов",
|
||||
"pagination_prev": "Предыдущая",
|
||||
"pagination_next": "Следующая",
|
||||
"pagination_info": "Страница {{current}} / {{total}} · {{count}} файлов",
|
||||
"search_label": "Поиск конфигов",
|
||||
"search_placeholder": "Фильтр по имени, типу или провайдеру",
|
||||
"problem_filter_label": "Фильтр проблем",
|
||||
"problem_filter_only": "Показывать только проблемные учётные данные",
|
||||
"page_size_label": "На странице",
|
||||
"page_size_unit": "элементов",
|
||||
"view_mode_paged": "Постранично",
|
||||
@@ -582,7 +603,13 @@
|
||||
"seven_day_sonnet": "7 дней Sonnet",
|
||||
"seven_day_cowork": "7 дней Cowork",
|
||||
"iguana_necktie": "Iguana Necktie",
|
||||
"extra_usage_label": "Дополнительное использование"
|
||||
"extra_usage_label": "Дополнительное использование",
|
||||
"plan_label": "План",
|
||||
"plan_unknown": "Неизвестно",
|
||||
"plan_free": "Free",
|
||||
"plan_pro": "Pro",
|
||||
"plan_max5": "Max 5x",
|
||||
"plan_max20": "Max 20x"
|
||||
},
|
||||
"codex_quota": {
|
||||
"title": "Квота Codex",
|
||||
@@ -1019,6 +1046,10 @@
|
||||
"show_raw_logs": "Показать исходные журналы",
|
||||
"show_raw_logs_hint": "Показать текст журнала без обработки для удобного копирования в несколько строк",
|
||||
"search_placeholder": "Искать по содержимому или ключевым словам",
|
||||
"filter_panel_title": "Структурные фильтры",
|
||||
"filter_panel_expand": "Развернуть структурные фильтры",
|
||||
"filter_panel_collapse": "Свернуть структурные фильтры",
|
||||
"filter_panel_active_count": "Активно: {{count}}",
|
||||
"filter_method": "Метод",
|
||||
"filter_status": "Статус",
|
||||
"filter_path": "Путь",
|
||||
@@ -1070,6 +1101,7 @@
|
||||
"title": "Панель конфигурации",
|
||||
"editor_title": "Файл конфигурации",
|
||||
"reload": "Перезагрузить",
|
||||
"reload_confirm_message": "Перезагрузка отбросит ваши несохранённые изменения. Продолжить?",
|
||||
"save": "Сохранить",
|
||||
"description": "Редактируйте config.yaml через визуальный редактор или исходный файл",
|
||||
"status_idle": "Ожидание действия",
|
||||
@@ -1083,6 +1115,10 @@
|
||||
"status_save_failed": "Не удалось сохранить",
|
||||
"save_success": "Конфигурация успешно сохранена",
|
||||
"error_yaml_not_supported": "Сервер не вернул YAML. Убедитесь, что доступна конечная точка /config.yaml.",
|
||||
"visual_mode_unavailable": "Визуальный редактор недоступен, пока не исправлен синтаксис YAML",
|
||||
"visual_mode_unavailable_detail": "Визуальный редактор недоступен, потому что в конфигурации есть некорректный YAML: {{message}}",
|
||||
"visual_mode_save_blocked": "Нельзя сохранять из визуального режима, пока не исправлен синтаксис YAML",
|
||||
"visual_mode_latest_yaml_invalid": "Последняя конфигурация на сервере содержит некорректный YAML. Проверьте её в режиме исходника перед сохранением визуальных изменений: {{message}}",
|
||||
"editor_placeholder": "key: value",
|
||||
"search_placeholder": "Поиск по конфигурации...",
|
||||
"search_button": "Поиск",
|
||||
@@ -1218,6 +1254,7 @@
|
||||
"json_path": "JSON Path (например, temperature)",
|
||||
"json_path_filter": "JSON Path (gjson/sjson), например generationConfig.thinkingConfig.thinkingBudget",
|
||||
"param_type": "Тип параметра",
|
||||
"param_value": "Значение параметра",
|
||||
"add_param": "Добавить параметр",
|
||||
"no_rules": "Правил нет",
|
||||
"add_rule": "Добавить правило",
|
||||
@@ -1236,7 +1273,17 @@
|
||||
"value_number": "Числовое значение (например, 0.7)",
|
||||
"value_boolean": "true или false",
|
||||
"value_json": "Значение JSON",
|
||||
"value_default": "Значение"
|
||||
"value_default": "Значение",
|
||||
"boolean_true": "true",
|
||||
"boolean_false": "false"
|
||||
},
|
||||
"validation": {
|
||||
"validation_blocked": "Исправьте ошибки валидации перед сохранением",
|
||||
"port_range": "Введите корректный порт от 1 до 65535",
|
||||
"non_negative_integer": "Введите неотрицательное целое число",
|
||||
"payload_invalid_number": "Введите корректное число",
|
||||
"payload_invalid_boolean": "Выберите true или false",
|
||||
"payload_invalid_json": "Введите корректный JSON"
|
||||
},
|
||||
"common": {
|
||||
"edit": "Изменить",
|
||||
@@ -1374,6 +1421,7 @@
|
||||
"theme": {
|
||||
"switch": "Тема",
|
||||
"light": "Светлая",
|
||||
"white": "Чисто-белая",
|
||||
"dark": "Тёмная",
|
||||
"switch_to_light": "Переключиться на светлую тему",
|
||||
"switch_to_dark": "Переключиться на тёмную тему",
|
||||
|
||||
@@ -366,6 +366,13 @@
|
||||
"ampcode_upstream_api_key_current": "当前Amp官方密钥: {{key}}",
|
||||
"ampcode_clear_upstream_api_key": "清除官方密钥",
|
||||
"ampcode_clear_upstream_api_key_confirm": "确定要清除 Ampcode 的 upstream API key(Amp官方)吗?",
|
||||
"ampcode_upstream_api_keys_label": "多上游 API Key 路由",
|
||||
"ampcode_upstream_api_keys_hint": "为指定客户端 API Key 绑定不同的 Amp 上游 API Key;客户端 key 可用逗号或换行分隔。",
|
||||
"ampcode_upstream_api_keys_add_btn": "添加多上游映射",
|
||||
"ampcode_upstream_api_keys_upstream_placeholder": "上游 API Key(sk-amp-...)",
|
||||
"ampcode_upstream_api_keys_clients_placeholder": "客户端 API Keys,用逗号或换行分隔",
|
||||
"ampcode_upstream_api_keys_item_title": "上游映射 #{{index}}",
|
||||
"ampcode_upstream_api_keys_count": "多上游映射",
|
||||
"ampcode_force_model_mappings_label": "强制应用模型映射",
|
||||
"ampcode_force_model_mappings_hint": "开启后,模型映射将覆盖本地 API Key 可用性判断。",
|
||||
"ampcode_model_mappings_label": "模型映射 (from → to)",
|
||||
@@ -374,6 +381,8 @@
|
||||
"ampcode_model_mappings_from_placeholder": "from 模型(原始)",
|
||||
"ampcode_model_mappings_to_placeholder": "to 模型(目标)",
|
||||
"ampcode_model_mappings_count": "映射数量",
|
||||
"ampcode_lists_overwrite_title": "覆盖列表配置",
|
||||
"ampcode_lists_overwrite_confirm": "当前未成功加载服务器已有多上游/模型映射配置,继续保存可能覆盖或清空这些列表,是否继续?",
|
||||
"ampcode_mappings_overwrite_confirm": "当前未成功加载服务器已有映射,继续保存可能覆盖或清空已有映射,是否继续?",
|
||||
"openai_title": "OpenAI 兼容提供商",
|
||||
"openai_add_button": "添加提供商",
|
||||
@@ -460,6 +469,10 @@
|
||||
"delete_confirm": "确定要删除文件",
|
||||
"delete_all_confirm": "确定要删除所有认证文件吗?此操作不可恢复!",
|
||||
"delete_filtered_confirm": "确定要删除筛选出的 {{type}} 认证文件吗?此操作不可恢复!",
|
||||
"delete_problem_button": "删除问题凭证",
|
||||
"delete_problem_button_with_type": "删除 {{type}} 问题凭证",
|
||||
"delete_problem_confirm": "确定要删除所有有问题的认证文件吗?此操作不可恢复!",
|
||||
"delete_problem_filtered_confirm": "确定要删除筛选出的有问题的 {{type}} 认证文件吗?此操作不可恢复!",
|
||||
"upload_error_json": "只能上传JSON文件",
|
||||
"upload_error_size": "文件大小不能超过 {{maxSize}}",
|
||||
"upload_success": "文件上传成功",
|
||||
@@ -469,12 +482,20 @@
|
||||
"delete_filtered_success": "成功删除 {{count}} 个 {{type}} 认证文件",
|
||||
"delete_filtered_partial": "{{type}} 认证文件删除完成,成功 {{success}} 个,失败 {{failed}} 个",
|
||||
"delete_filtered_none": "当前筛选类型 ({{type}}) 下没有可删除的认证文件",
|
||||
"delete_problem_success": "成功删除 {{count}} 个有问题的认证文件",
|
||||
"delete_problem_filtered_success": "成功删除 {{count}} 个有问题的 {{type}} 认证文件",
|
||||
"delete_problem_partial": "有问题认证文件删除完成,成功 {{success}} 个,失败 {{failed}} 个",
|
||||
"delete_problem_filtered_partial": "有问题的 {{type}} 认证文件删除完成,成功 {{success}} 个,失败 {{failed}} 个",
|
||||
"delete_problem_none": "当前没有可删除的有问题认证文件",
|
||||
"delete_problem_filtered_none": "当前筛选类型 ({{type}}) 下没有可删除的有问题认证文件",
|
||||
"files_count": "个文件",
|
||||
"pagination_prev": "上一页",
|
||||
"pagination_next": "下一页",
|
||||
"pagination_info": "第 {{current}} / {{total}} 页 · 共 {{count}} 个文件",
|
||||
"search_label": "搜索配置文件",
|
||||
"search_placeholder": "输入名称、类型或提供方关键字",
|
||||
"problem_filter_label": "问题筛选",
|
||||
"problem_filter_only": "仅显示有问题凭证",
|
||||
"page_size_label": "单页数量",
|
||||
"page_size_unit": "个/页",
|
||||
"view_mode_paged": "按页显示",
|
||||
@@ -579,7 +600,13 @@
|
||||
"seven_day_sonnet": "7 天 Sonnet",
|
||||
"seven_day_cowork": "7 天 Cowork",
|
||||
"iguana_necktie": "Iguana Necktie",
|
||||
"extra_usage_label": "额外用量"
|
||||
"extra_usage_label": "额外用量",
|
||||
"plan_label": "套餐",
|
||||
"plan_unknown": "未知",
|
||||
"plan_free": "免费版",
|
||||
"plan_pro": "专业版",
|
||||
"plan_max5": "Max 5x",
|
||||
"plan_max20": "Max 20x"
|
||||
},
|
||||
"codex_quota": {
|
||||
"title": "Codex 额度",
|
||||
@@ -1016,6 +1043,10 @@
|
||||
"show_raw_logs": "显示原始日志",
|
||||
"show_raw_logs_hint": "直接显示原始日志文本,方便多行复制",
|
||||
"search_placeholder": "搜索日志内容或关键字",
|
||||
"filter_panel_title": "结构化筛选",
|
||||
"filter_panel_expand": "展开结构化筛选",
|
||||
"filter_panel_collapse": "收起结构化筛选",
|
||||
"filter_panel_active_count": "已选 {{count}} 项",
|
||||
"filter_method": "请求方法",
|
||||
"filter_status": "状态码",
|
||||
"filter_path": "路径",
|
||||
@@ -1067,6 +1098,7 @@
|
||||
"title": "配置面板",
|
||||
"editor_title": "配置文件",
|
||||
"reload": "重新加载",
|
||||
"reload_confirm_message": "重新加载将丢弃你当前未保存的修改,确定继续吗?",
|
||||
"save": "保存",
|
||||
"description": "通过可视化或者源文件方式编辑 config.yaml 配置文件",
|
||||
"status_idle": "等待操作",
|
||||
@@ -1080,6 +1112,10 @@
|
||||
"status_save_failed": "保存失败",
|
||||
"save_success": "配置已保存",
|
||||
"error_yaml_not_supported": "服务器未返回 YAML 格式,请确认 /config.yaml 接口可用",
|
||||
"visual_mode_unavailable": "YAML 语法修复前无法使用可视化编辑",
|
||||
"visual_mode_unavailable_detail": "当前配置存在无效 YAML,暂时无法使用可视化编辑:{{message}}",
|
||||
"visual_mode_save_blocked": "请先修复 YAML 语法错误,再从可视化模式保存",
|
||||
"visual_mode_latest_yaml_invalid": "服务端最新配置包含无效 YAML,请先切回源码模式检查后再保存可视化修改:{{message}}",
|
||||
"editor_placeholder": "key: value",
|
||||
"search_placeholder": "搜索配置内容...",
|
||||
"search_button": "搜索",
|
||||
@@ -1213,6 +1249,7 @@
|
||||
"json_path": "JSON 路径 (如 temperature)",
|
||||
"json_path_filter": "JSON 路径 (gjson/sjson),如 generationConfig.thinkingConfig.thinkingBudget",
|
||||
"param_type": "参数类型",
|
||||
"param_value": "参数值",
|
||||
"add_param": "添加参数",
|
||||
"no_rules": "暂无规则",
|
||||
"add_rule": "添加规则",
|
||||
@@ -1231,7 +1268,17 @@
|
||||
"value_number": "数字值 (如 0.7)",
|
||||
"value_boolean": "true 或 false",
|
||||
"value_json": "JSON 值",
|
||||
"value_default": "值"
|
||||
"value_default": "值",
|
||||
"boolean_true": "true",
|
||||
"boolean_false": "false"
|
||||
},
|
||||
"validation": {
|
||||
"validation_blocked": "请先修复表单校验错误再保存",
|
||||
"port_range": "请输入 1 到 65535 之间的有效端口",
|
||||
"non_negative_integer": "请输入非负整数",
|
||||
"payload_invalid_number": "请输入有效数字",
|
||||
"payload_invalid_boolean": "请选择 true 或 false",
|
||||
"payload_invalid_json": "请输入有效的 JSON"
|
||||
},
|
||||
"common": {
|
||||
"edit": "编辑",
|
||||
@@ -1369,6 +1416,7 @@
|
||||
"theme": {
|
||||
"switch": "主题",
|
||||
"light": "亮色",
|
||||
"white": "纯白",
|
||||
"dark": "暗色",
|
||||
"switch_to_light": "切换到亮色模式",
|
||||
"switch_to_dark": "切换到暗色模式",
|
||||
|
||||
@@ -13,7 +13,11 @@ import { ampcodeApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import type { AmpcodeConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { buildAmpcodeFormState, entriesToAmpcodeMappings } from '@/components/providers/utils';
|
||||
import {
|
||||
buildAmpcodeFormState,
|
||||
entriesToAmpcodeMappings,
|
||||
entriesToAmpcodeUpstreamApiKeys,
|
||||
} from '@/components/providers/utils';
|
||||
import type { AmpcodeFormState } from '@/components/providers';
|
||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||
|
||||
@@ -34,11 +38,18 @@ const normalizeMappingEntries = (entries: Array<{ name: string; alias: string }>
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const normalizeUpstreamApiKeyEntries = (form: AmpcodeFormState) =>
|
||||
entriesToAmpcodeUpstreamApiKeys(form.upstreamApiKeyEntries).map((entry) => ({
|
||||
upstreamApiKey: entry.upstreamApiKey,
|
||||
apiKeys: entry.apiKeys,
|
||||
}));
|
||||
|
||||
const buildAmpcodeSignature = (form: AmpcodeFormState) =>
|
||||
JSON.stringify({
|
||||
upstreamUrl: String(form.upstreamUrl ?? '').trim(),
|
||||
upstreamApiKey: String(form.upstreamApiKey ?? '').trim(),
|
||||
forceModelMappings: Boolean(form.forceModelMappings),
|
||||
upstreamApiKeys: normalizeUpstreamApiKeyEntries(form),
|
||||
modelMappings: normalizeMappingEntries(form.mappingEntries),
|
||||
});
|
||||
|
||||
@@ -57,7 +68,8 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
const [form, setForm] = useState<AmpcodeFormState>(() => buildAmpcodeFormState(null));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [mappingsDirty, setMappingsDirty] = useState(false);
|
||||
const [modelMappingsDirty, setModelMappingsDirty] = useState(false);
|
||||
const [upstreamApiKeysDirty, setUpstreamApiKeysDirty] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [baselineSignature, setBaselineSignature] = useState(() =>
|
||||
@@ -102,7 +114,8 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
|
||||
setLoading(true);
|
||||
setLoaded(false);
|
||||
setMappingsDirty(false);
|
||||
setModelMappingsDirty(false);
|
||||
setUpstreamApiKeysDirty(false);
|
||||
setError('');
|
||||
const initialForm = buildAmpcodeFormState(useConfigStore.getState().config?.ampcode ?? null);
|
||||
setForm(initialForm);
|
||||
@@ -183,6 +196,7 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
try {
|
||||
const upstreamUrl = form.upstreamUrl.trim();
|
||||
const overrideKey = form.upstreamApiKey.trim();
|
||||
const upstreamApiKeys = entriesToAmpcodeUpstreamApiKeys(form.upstreamApiKeyEntries);
|
||||
const modelMappings = entriesToAmpcodeMappings(form.mappingEntries);
|
||||
|
||||
if (upstreamUrl) {
|
||||
@@ -193,7 +207,15 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
|
||||
await ampcodeApi.updateForceModelMappings(form.forceModelMappings);
|
||||
|
||||
if (loaded || mappingsDirty) {
|
||||
if (loaded || upstreamApiKeysDirty) {
|
||||
if (upstreamApiKeys.length) {
|
||||
await ampcodeApi.saveUpstreamApiKeys(upstreamApiKeys);
|
||||
} else {
|
||||
await ampcodeApi.deleteUpstreamApiKeys([]);
|
||||
}
|
||||
}
|
||||
|
||||
if (loaded || modelMappingsDirty) {
|
||||
if (modelMappings.length) {
|
||||
await ampcodeApi.saveModelMappings(modelMappings);
|
||||
} else {
|
||||
@@ -207,23 +229,29 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
|
||||
const previous = config?.ampcode ?? {};
|
||||
const next: AmpcodeConfig = {
|
||||
upstreamUrl: upstreamUrl || undefined,
|
||||
...previous,
|
||||
forceModelMappings: form.forceModelMappings,
|
||||
};
|
||||
|
||||
if (previous.upstreamApiKey) {
|
||||
next.upstreamApiKey = previous.upstreamApiKey;
|
||||
}
|
||||
|
||||
if (Array.isArray(previous.modelMappings)) {
|
||||
next.modelMappings = previous.modelMappings;
|
||||
if (upstreamUrl) {
|
||||
next.upstreamUrl = upstreamUrl;
|
||||
} else {
|
||||
delete next.upstreamUrl;
|
||||
}
|
||||
|
||||
if (overrideKey) {
|
||||
next.upstreamApiKey = overrideKey;
|
||||
}
|
||||
|
||||
if (loaded || mappingsDirty) {
|
||||
if (loaded || upstreamApiKeysDirty) {
|
||||
if (upstreamApiKeys.length) {
|
||||
next.upstreamApiKeys = upstreamApiKeys;
|
||||
} else {
|
||||
delete next.upstreamApiKeys;
|
||||
}
|
||||
}
|
||||
|
||||
if (loaded || modelMappingsDirty) {
|
||||
if (modelMappings.length) {
|
||||
next.modelMappings = modelMappings;
|
||||
} else {
|
||||
@@ -247,10 +275,10 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
};
|
||||
|
||||
const saveAmpcode = async () => {
|
||||
if (!loaded && mappingsDirty) {
|
||||
if (!loaded && (modelMappingsDirty || upstreamApiKeysDirty)) {
|
||||
showConfirmation({
|
||||
title: t('ai_providers.ampcode_mappings_overwrite_title', { defaultValue: 'Overwrite Mappings' }),
|
||||
message: t('ai_providers.ampcode_mappings_overwrite_confirm'),
|
||||
title: t('ai_providers.ampcode_lists_overwrite_title'),
|
||||
message: t('ai_providers.ampcode_lists_overwrite_confirm'),
|
||||
variant: 'secondary',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: performSaveAmpcode,
|
||||
@@ -334,6 +362,98 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className={layoutStyles.ampcodeUpstreamMappingsHeader}>
|
||||
<label>{t('ai_providers.ampcode_upstream_api_keys_label')}</label>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUpstreamApiKeysDirty(true);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
upstreamApiKeyEntries: [
|
||||
...prev.upstreamApiKeyEntries,
|
||||
{ upstreamApiKey: '', clientApiKeysText: '' },
|
||||
],
|
||||
}));
|
||||
}}
|
||||
disabled={loading || saving || disableControls}
|
||||
>
|
||||
{t('ai_providers.ampcode_upstream_api_keys_add_btn')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={layoutStyles.ampcodeUpstreamMappingsList}>
|
||||
{(form.upstreamApiKeyEntries.length
|
||||
? form.upstreamApiKeyEntries
|
||||
: [{ upstreamApiKey: '', clientApiKeysText: '' }]
|
||||
).map((entry, index, entries) => (
|
||||
<div key={index} className={layoutStyles.ampcodeUpstreamMappingCard}>
|
||||
<div className={layoutStyles.ampcodeUpstreamMappingCardTop}>
|
||||
<span className={layoutStyles.ampcodeUpstreamMappingTitle}>
|
||||
{t('ai_providers.ampcode_upstream_api_keys_item_title', { index: index + 1 })}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUpstreamApiKeysDirty(true);
|
||||
setForm((prev) => {
|
||||
const nextEntries = prev.upstreamApiKeyEntries.filter((_, entryIndex) => entryIndex !== index);
|
||||
return {
|
||||
...prev,
|
||||
upstreamApiKeyEntries: nextEntries.length
|
||||
? nextEntries
|
||||
: [{ upstreamApiKey: '', clientApiKeysText: '' }],
|
||||
};
|
||||
});
|
||||
}}
|
||||
disabled={loading || saving || disableControls || entries.length <= 1}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('ai_providers.ampcode_upstream_api_keys_upstream_placeholder')}
|
||||
aria-label={t('ai_providers.ampcode_upstream_api_keys_upstream_placeholder')}
|
||||
value={entry.upstreamApiKey}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setUpstreamApiKeysDirty(true);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
upstreamApiKeyEntries: prev.upstreamApiKeyEntries.map((item, itemIndex) =>
|
||||
itemIndex === index ? { ...item, upstreamApiKey: value } : item
|
||||
),
|
||||
}));
|
||||
}}
|
||||
disabled={loading || saving || disableControls}
|
||||
/>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder={t('ai_providers.ampcode_upstream_api_keys_clients_placeholder')}
|
||||
aria-label={t('ai_providers.ampcode_upstream_api_keys_clients_placeholder')}
|
||||
value={entry.clientApiKeysText}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setUpstreamApiKeysDirty(true);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
upstreamApiKeyEntries: prev.upstreamApiKeyEntries.map((item, itemIndex) =>
|
||||
itemIndex === index ? { ...item, clientApiKeysText: value } : item
|
||||
),
|
||||
}));
|
||||
}}
|
||||
rows={3}
|
||||
disabled={loading || saving || disableControls}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hint">{t('ai_providers.ampcode_upstream_api_keys_hint')}</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<ToggleSwitch
|
||||
label={t('ai_providers.ampcode_force_model_mappings_label')}
|
||||
@@ -349,7 +469,7 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
<ModelInputList
|
||||
entries={form.mappingEntries}
|
||||
onChange={(entries) => {
|
||||
setMappingsDirty(true);
|
||||
setModelMappingsDirty(true);
|
||||
setForm((prev) => ({ ...prev, mappingEntries: entries }));
|
||||
}}
|
||||
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
|
||||
|
||||
@@ -31,3 +31,45 @@
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ampcodeUpstreamMappingsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
label {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ampcodeUpstreamMappingsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ampcodeUpstreamMappingCard {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ampcodeUpstreamMappingCardTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ampcodeUpstreamMappingTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
|
||||
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
|
||||
import type { VertexFormState } from '@/components/providers';
|
||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||
@@ -26,7 +27,9 @@ const buildEmptyForm = (): VertexFormState => ({
|
||||
proxyUrl: '',
|
||||
headers: [],
|
||||
models: [],
|
||||
excludedModels: [],
|
||||
modelEntries: [{ name: '', alias: '' }],
|
||||
excludedText: '',
|
||||
});
|
||||
|
||||
const parseIndexParam = (value: string | undefined) => {
|
||||
@@ -54,6 +57,7 @@ const buildVertexSignature = (form: VertexFormState) =>
|
||||
proxyUrl: String(form.proxyUrl ?? '').trim(),
|
||||
headers: normalizeHeaderEntries(form.headers),
|
||||
models: normalizeModelEntries(form.modelEntries),
|
||||
excludedModels: parseExcludedModels(form.excludedText ?? ''),
|
||||
});
|
||||
|
||||
export function AiProvidersVertexEditPage() {
|
||||
@@ -153,6 +157,7 @@ export function AiProvidersVertexEditPage() {
|
||||
...initialData,
|
||||
headers: headersToEntries(initialData.headers),
|
||||
modelEntries: modelsToEntries(initialData.models),
|
||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||
};
|
||||
setForm(nextForm);
|
||||
setBaselineSignature(buildVertexSignature(nextForm));
|
||||
@@ -213,6 +218,7 @@ export function AiProvidersVertexEditPage() {
|
||||
return { name, alias };
|
||||
})
|
||||
.filter(Boolean) as ProviderKeyConfig['models'],
|
||||
excludedModels: parseExcludedModels(form.excludedText),
|
||||
};
|
||||
|
||||
const nextList =
|
||||
@@ -343,6 +349,18 @@ export function AiProvidersVertexEditPage() {
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
|
||||
</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}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -161,6 +161,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
.filterToggleItem {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.filterToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.filterToggleLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pageSizeSelect {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -314,6 +333,10 @@
|
||||
background-image: linear-gradient(180deg, rgba(224, 247, 250, 0.12), rgba(224, 247, 250, 0));
|
||||
}
|
||||
|
||||
.claudeCard {
|
||||
background-image: linear-gradient(180deg, rgba(252, 228, 236, 0.18), rgba(252, 228, 236, 0));
|
||||
}
|
||||
|
||||
.codexCard {
|
||||
background-image: linear-gradient(180deg, rgba(255, 243, 224, 0.18), rgba(255, 243, 224, 0));
|
||||
}
|
||||
|
||||
+51
-11
@@ -19,6 +19,7 @@ import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import {
|
||||
MAX_CARD_PAGE_SIZE,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
clampCardPageSize,
|
||||
getTypeColor,
|
||||
getTypeLabel,
|
||||
hasAuthFileStatusMessage,
|
||||
isRuntimeOnlyAuthFile,
|
||||
normalizeProviderKey,
|
||||
type QuotaProviderType,
|
||||
@@ -64,6 +66,7 @@ export function AuthFilesPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [filter, setFilter] = useState<'all' | string>('all');
|
||||
const [problemOnly, setProblemOnly] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(9);
|
||||
@@ -162,6 +165,9 @@ export function AuthFilesPage() {
|
||||
if (typeof persisted.filter === 'string' && persisted.filter.trim()) {
|
||||
setFilter(persisted.filter);
|
||||
}
|
||||
if (typeof persisted.problemOnly === 'boolean') {
|
||||
setProblemOnly(persisted.problemOnly);
|
||||
}
|
||||
if (typeof persisted.search === 'string') {
|
||||
setSearch(persisted.search);
|
||||
}
|
||||
@@ -174,8 +180,8 @@ export function AuthFilesPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
writeAuthFilesUiState({ filter, search, page, pageSize });
|
||||
}, [filter, search, page, pageSize]);
|
||||
writeAuthFilesUiState({ filter, problemOnly, search, page, pageSize });
|
||||
}, [filter, problemOnly, search, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageSizeInput(String(pageSize));
|
||||
@@ -248,17 +254,22 @@ export function AuthFilesPage() {
|
||||
return Array.from(types);
|
||||
}, [files]);
|
||||
|
||||
const filesMatchingProblemFilter = useMemo(
|
||||
() => (problemOnly ? files.filter(hasAuthFileStatusMessage) : files),
|
||||
[files, problemOnly]
|
||||
);
|
||||
|
||||
const typeCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = { all: files.length };
|
||||
files.forEach((file) => {
|
||||
const counts: Record<string, number> = { all: filesMatchingProblemFilter.length };
|
||||
filesMatchingProblemFilter.forEach((file) => {
|
||||
if (!file.type) return;
|
||||
counts[file.type] = (counts[file.type] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [files]);
|
||||
}, [filesMatchingProblemFilter]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return files.filter((item) => {
|
||||
return filesMatchingProblemFilter.filter((item) => {
|
||||
const matchType = filter === 'all' || item.type === filter;
|
||||
const term = search.trim().toLowerCase();
|
||||
const matchSearch =
|
||||
@@ -268,7 +279,7 @@ export function AuthFilesPage() {
|
||||
(item.provider || '').toString().toLowerCase().includes(term);
|
||||
return matchType && matchSearch;
|
||||
});
|
||||
}, [files, filter, search]);
|
||||
}, [filesMatchingProblemFilter, filter, search]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
@@ -456,6 +467,14 @@ export function AuthFilesPage() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const deleteAllButtonLabel = problemOnly
|
||||
? filter === 'all'
|
||||
? t('auth_files.delete_problem_button')
|
||||
: t('auth_files.delete_problem_button_with_type', { type: getTypeLabel(t, filter) })
|
||||
: filter === 'all'
|
||||
? t('auth_files.delete_all_button')
|
||||
: `${t('common.delete')} ${getTypeLabel(t, filter)}`;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.pageHeader}>
|
||||
@@ -482,14 +501,17 @@ export function AuthFilesPage() {
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleDeleteAll({ filter, onResetFilterToAll: () => setFilter('all') })
|
||||
handleDeleteAll({
|
||||
filter,
|
||||
problemOnly,
|
||||
onResetFilterToAll: () => setFilter('all'),
|
||||
onResetProblemOnly: () => setProblemOnly(false),
|
||||
})
|
||||
}
|
||||
disabled={disableControls || loading || deletingAll}
|
||||
loading={deletingAll}
|
||||
>
|
||||
{filter === 'all'
|
||||
? t('auth_files.delete_all_button')
|
||||
: `${t('common.delete')} ${getTypeLabel(t, filter)}`}
|
||||
{deleteAllButtonLabel}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -537,6 +559,24 @@ export function AuthFilesPage() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.filterItem} ${styles.filterToggleItem}`}>
|
||||
<label>{t('auth_files.problem_filter_label')}</label>
|
||||
<div className={styles.filterToggle}>
|
||||
<ToggleSwitch
|
||||
checked={problemOnly}
|
||||
onChange={(value) => {
|
||||
setProblemOnly(value);
|
||||
setPage(1);
|
||||
}}
|
||||
ariaLabel={t('auth_files.problem_filter_only')}
|
||||
label={
|
||||
<span className={styles.filterToggleLabel}>
|
||||
{t('auth_files.problem_filter_only')}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+97
-10
@@ -31,13 +31,17 @@ function readCommercialModeFromYaml(yamlContent: string): boolean {
|
||||
|
||||
export function ConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||
const showConfirmation = useNotificationStore((state) => state.showConfirmation);
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
|
||||
const {
|
||||
visualValues,
|
||||
visualDirty,
|
||||
visualParseError,
|
||||
visualValidationErrors,
|
||||
visualHasPayloadValidationErrors,
|
||||
loadVisualValuesFromYaml,
|
||||
applyVisualChangesToYaml,
|
||||
setVisualValues
|
||||
@@ -69,6 +73,10 @@ export function ConfigPage() {
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
const isDirty = dirty || visualDirty;
|
||||
const hasVisualModeError = !!visualParseError;
|
||||
const hasVisualValidationErrors =
|
||||
activeTab === 'visual' &&
|
||||
(Object.values(visualValidationErrors).some(Boolean) || visualHasPayloadValidationErrors);
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -93,6 +101,17 @@ export function ConfigPage() {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'visual' || !visualParseError) return;
|
||||
|
||||
setActiveTab('source');
|
||||
localStorage.setItem('config-management:tab', 'source');
|
||||
showNotification(
|
||||
t('config_management.visual_mode_unavailable_detail', { message: visualParseError }),
|
||||
'error'
|
||||
);
|
||||
}, [activeTab, showNotification, t, visualParseError]);
|
||||
|
||||
const handleConfirmSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -121,12 +140,32 @@ export function ConfigPage() {
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (activeTab === 'visual' && visualParseError) {
|
||||
showNotification(t('config_management.visual_mode_save_blocked'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// In source mode, save exactly what the user edited. In visual mode, materialize visual changes into YAML.
|
||||
const nextMergedYaml = activeTab === 'source' ? content : applyVisualChangesToYaml(content);
|
||||
const latestServerYaml = await configFileApi.fetchConfigYaml();
|
||||
|
||||
if (activeTab !== 'source') {
|
||||
const latestDocument = parseDocument(latestServerYaml);
|
||||
if (latestDocument.errors.length > 0) {
|
||||
showNotification(
|
||||
t('config_management.visual_mode_latest_yaml_invalid', {
|
||||
message: latestDocument.errors[0]?.message ?? t('config_management.visual_mode_save_blocked')
|
||||
}),
|
||||
'error'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// In source mode, save exactly what the user edited. In visual mode, materialize visual changes into the latest YAML.
|
||||
const nextMergedYaml =
|
||||
activeTab === 'source' ? content : applyVisualChangesToYaml(latestServerYaml);
|
||||
|
||||
// In visual mode, applyVisualChangesToYaml re-serializes YAML via parseDocument → toString,
|
||||
// which may reformat comments/whitespace. Normalize the server YAML through the same pipeline
|
||||
// so the diff only shows actual value changes, not cosmetic reformatting.
|
||||
@@ -177,12 +216,19 @@ export function ConfigPage() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
loadVisualValuesFromYaml(content);
|
||||
const result = loadVisualValuesFromYaml(content);
|
||||
if (!result.ok) {
|
||||
showNotification(
|
||||
t('config_management.visual_mode_unavailable_detail', { message: result.error }),
|
||||
'error'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setActiveTab(tab);
|
||||
localStorage.setItem('config-management:tab', tab);
|
||||
}, [activeTab, applyVisualChangesToYaml, content, loadVisualValuesFromYaml, visualDirty]);
|
||||
}, [activeTab, applyVisualChangesToYaml, content, loadVisualValuesFromYaml, showNotification, t, visualDirty]);
|
||||
|
||||
// Search functionality
|
||||
const performSearch = useCallback((query: string, direction: 'next' | 'prev' = 'next') => {
|
||||
@@ -346,20 +392,47 @@ export function ConfigPage() {
|
||||
if (disableControls) return t('config_management.status_disconnected');
|
||||
if (loading) return t('config_management.status_loading');
|
||||
if (error) return t('config_management.status_load_failed');
|
||||
if (hasVisualModeError) return t('config_management.visual_mode_unavailable');
|
||||
if (hasVisualValidationErrors) return t('config_management.visual.validation.validation_blocked');
|
||||
if (saving) return t('config_management.status_saving');
|
||||
if (isDirty) return t('config_management.status_dirty');
|
||||
return t('config_management.status_loaded');
|
||||
};
|
||||
|
||||
const isLoadedStatus = !disableControls && !loading && !error && !saving && !isDirty;
|
||||
const isLoadedStatus =
|
||||
!disableControls &&
|
||||
!loading &&
|
||||
!error &&
|
||||
!saving &&
|
||||
!isDirty &&
|
||||
!hasVisualModeError &&
|
||||
!hasVisualValidationErrors;
|
||||
|
||||
const getStatusClass = () => {
|
||||
if (error) return styles.error;
|
||||
if (error || hasVisualModeError || hasVisualValidationErrors) return styles.error;
|
||||
if (isDirty) return styles.modified;
|
||||
if (!loading && !saving) return styles.saved;
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
if (!isDirty) {
|
||||
void loadConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
showConfirmation({
|
||||
title: t('common.unsaved_changes_title'),
|
||||
message: t('config_management.reload_confirm_message'),
|
||||
confirmText: t('config_management.reload'),
|
||||
cancelText: t('common.cancel'),
|
||||
variant: 'danger',
|
||||
onConfirm: async () => {
|
||||
await loadConfig();
|
||||
},
|
||||
});
|
||||
}, [isDirty, loadConfig, showConfirmation, t]);
|
||||
|
||||
const floatingActions = (
|
||||
<div className={styles.floatingActionContainer} ref={floatingActionsRef}>
|
||||
<div className={styles.floatingActionList}>
|
||||
@@ -367,8 +440,8 @@ export function ConfigPage() {
|
||||
<button
|
||||
type="button"
|
||||
className={styles.floatingActionButton}
|
||||
onClick={loadConfig}
|
||||
disabled={loading}
|
||||
onClick={handleReload}
|
||||
disabled={loading || saving}
|
||||
title={t('config_management.reload')}
|
||||
aria-label={t('config_management.reload')}
|
||||
>
|
||||
@@ -378,7 +451,15 @@ export function ConfigPage() {
|
||||
type="button"
|
||||
className={styles.floatingActionButton}
|
||||
onClick={handleSave}
|
||||
disabled={disableControls || loading || saving || !isDirty || diffModalOpen}
|
||||
disabled={
|
||||
disableControls ||
|
||||
loading ||
|
||||
saving ||
|
||||
!isDirty ||
|
||||
diffModalOpen ||
|
||||
hasVisualModeError ||
|
||||
hasVisualValidationErrors
|
||||
}
|
||||
title={t('config_management.save')}
|
||||
aria-label={t('config_management.save')}
|
||||
>
|
||||
@@ -416,10 +497,16 @@ export function ConfigPage() {
|
||||
<Card className={styles.configCard}>
|
||||
<div className={styles.content}>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{!error && visualParseError && (
|
||||
<div className="error-box">
|
||||
{t('config_management.visual_mode_unavailable_detail', { message: visualParseError })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'visual' ? (
|
||||
<VisualConfigEditor
|
||||
values={visualValues}
|
||||
validationErrors={visualValidationErrors}
|
||||
disabled={disableControls || loading}
|
||||
onChange={setVisualValues}
|
||||
/>
|
||||
|
||||
@@ -169,22 +169,8 @@
|
||||
|
||||
// 语言下拉选择
|
||||
.languageSelect {
|
||||
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($primary-color, 0.18);
|
||||
}
|
||||
min-width: 108px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
// 连接信息框
|
||||
@@ -218,19 +204,13 @@
|
||||
.toggleAdvanced {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toggleLabel {
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
|
||||
input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
label {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 错误提示框
|
||||
|
||||
+24
-22
@@ -3,6 +3,8 @@ import { Navigate, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { IconEye, IconEyeOff } from '@/components/ui/icons';
|
||||
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
|
||||
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
||||
@@ -89,9 +91,16 @@ export function LoginPage() {
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
|
||||
const languageOptions = useMemo(
|
||||
() =>
|
||||
LANGUAGE_ORDER.map((lang) => ({
|
||||
value: lang,
|
||||
label: t(LANGUAGE_LABEL_KEYS[lang])
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
const handleLanguageChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const selectedLanguage = event.target.value;
|
||||
(selectedLanguage: string) => {
|
||||
if (!isSupportedLanguage(selectedLanguage)) {
|
||||
return;
|
||||
}
|
||||
@@ -205,19 +214,14 @@ export function LoginPage() {
|
||||
<div className={styles.loginHeader}>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.title}>{t('title.login')}</div>
|
||||
<select
|
||||
<Select
|
||||
className={styles.languageSelect}
|
||||
value={language}
|
||||
options={languageOptions}
|
||||
onChange={handleLanguageChange}
|
||||
title={t('language.switch')}
|
||||
aria-label={t('language.switch')}
|
||||
>
|
||||
{LANGUAGE_ORDER.map((lang) => (
|
||||
<option key={lang} value={lang}>
|
||||
{t(LANGUAGE_LABEL_KEYS[lang])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
fullWidth={false}
|
||||
ariaLabel={t('language.switch')}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.subtitle}>{t('login.subtitle')}</div>
|
||||
</div>
|
||||
@@ -229,13 +233,12 @@ export function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div className={styles.toggleAdvanced}>
|
||||
<input
|
||||
id="custom-connection-toggle"
|
||||
type="checkbox"
|
||||
<ToggleSwitch
|
||||
checked={showCustomBase}
|
||||
onChange={(e) => setShowCustomBase(e.target.checked)}
|
||||
onChange={setShowCustomBase}
|
||||
ariaLabel={t('login.custom_connection_label')}
|
||||
label={<span className={styles.toggleLabel}>{t('login.custom_connection_label')}</span>}
|
||||
/>
|
||||
<label htmlFor="custom-connection-toggle">{t('login.custom_connection_label')}</label>
|
||||
</div>
|
||||
|
||||
{showCustomBase && (
|
||||
@@ -278,13 +281,12 @@ export function LoginPage() {
|
||||
/>
|
||||
|
||||
<div className={styles.toggleAdvanced}>
|
||||
<input
|
||||
id="remember-password-toggle"
|
||||
type="checkbox"
|
||||
<ToggleSwitch
|
||||
checked={rememberPassword}
|
||||
onChange={(e) => setRememberPassword(e.target.checked)}
|
||||
onChange={setRememberPassword}
|
||||
ariaLabel={t('login.remember_password_label')}
|
||||
label={<span className={styles.toggleLabel}>{t('login.remember_password_label')}</span>}
|
||||
/>
|
||||
<label htmlFor="remember-password-toggle">{t('login.remember_password_label')}</label>
|
||||
</div>
|
||||
|
||||
<Button fullWidth onClick={handleSubmit} loading={loading}>
|
||||
|
||||
@@ -121,6 +121,33 @@
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.filterPanelHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.filterPanelToggle {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filterPanelButtonContent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filterPanelCount {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: $radius-full;
|
||||
background: rgba($primary-color, 0.12);
|
||||
color: var(--primary-color);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.structuredFilters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -200,6 +227,20 @@
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.filterPanelHeader {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filterPanelToggle {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filterPanelButtonContent {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filterChipGroup {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
+120
-75
@@ -8,16 +8,20 @@ import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import {
|
||||
IconDownload,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconCode,
|
||||
IconDownload,
|
||||
IconEyeOff,
|
||||
IconRefreshCw,
|
||||
IconSearch,
|
||||
IconSlidersHorizontal,
|
||||
IconTimer,
|
||||
IconTrash2,
|
||||
IconX,
|
||||
} from '@/components/ui/icons';
|
||||
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import { logsApi } from '@/services/api/logs';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
@@ -79,6 +83,10 @@ export function LogsPage() {
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
const [hideManagementLogs, setHideManagementLogs] = useState(true);
|
||||
const [showRawLogs, setShowRawLogs] = useState(false);
|
||||
const [structuredFiltersExpanded, setStructuredFiltersExpanded] = useLocalStorage(
|
||||
'logsPage.structuredFiltersExpanded',
|
||||
true
|
||||
);
|
||||
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
|
||||
const [loadingErrors, setLoadingErrors] = useState(false);
|
||||
const [errorLogsError, setErrorLogsError] = useState('');
|
||||
@@ -305,6 +313,9 @@ export function LogsPage() {
|
||||
}, [baseLines, hideManagementLogs, trimmedSearchQuery]);
|
||||
|
||||
const filters = useLogFilters({ parsedLines: parsedSearchLines });
|
||||
const structuredFiltersPanelId = 'logs-structured-filters';
|
||||
const structuredFilterCount =
|
||||
filters.methodFilters.length + filters.statusFilters.length + filters.pathFilters.length;
|
||||
|
||||
const { filteredParsedLines, filteredLines, removedCount } = useMemo(() => {
|
||||
const filteredParsed = parsedSearchLines.filter((line) => {
|
||||
@@ -498,86 +509,120 @@ export function LogsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.structuredFilters}>
|
||||
<div className={styles.filterChipGroup}>
|
||||
<span className={styles.filterChipLabel}>{t('logs.filter_method')}</span>
|
||||
<div className={styles.filterChipList}>
|
||||
{HTTP_METHODS.map((method) => {
|
||||
const active = filters.methodFilters.includes(method);
|
||||
const count = filters.methodCounts[method] ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={method}
|
||||
type="button"
|
||||
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
|
||||
onClick={() => filters.toggleMethodFilter(method)}
|
||||
disabled={count === 0 && !active}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{method} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterChipGroup}>
|
||||
<span className={styles.filterChipLabel}>{t('logs.filter_status')}</span>
|
||||
<div className={styles.filterChipList}>
|
||||
{STATUS_GROUPS.map((statusGroup) => {
|
||||
const active = filters.statusFilters.includes(statusGroup);
|
||||
const count = filters.statusCounts[statusGroup] ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={statusGroup}
|
||||
type="button"
|
||||
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
|
||||
onClick={() => filters.toggleStatusFilter(statusGroup)}
|
||||
disabled={count === 0 && !active}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{t(`logs.filter_status_${statusGroup}`)} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterChipGroup}>
|
||||
<span className={styles.filterChipLabel}>{t('logs.filter_path')}</span>
|
||||
<div className={styles.filterChipList}>
|
||||
{filters.pathOptions.length === 0 ? (
|
||||
<span className={styles.filterChipHint}>{t('logs.filter_path_empty')}</span>
|
||||
) : (
|
||||
filters.pathOptions.map(({ path, count }) => {
|
||||
const active = filters.pathFilters.includes(path);
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
type="button"
|
||||
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
|
||||
onClick={() => filters.togglePathFilter(path)}
|
||||
aria-pressed={active}
|
||||
title={path}
|
||||
>
|
||||
{path} ({count})
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterPanelHeader}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={filters.clearStructuredFilters}
|
||||
disabled={!filters.hasStructuredFilters}
|
||||
className={styles.filterPanelToggle}
|
||||
onClick={() => setStructuredFiltersExpanded((prev) => !prev)}
|
||||
aria-expanded={structuredFiltersExpanded}
|
||||
aria-controls={structuredFiltersPanelId}
|
||||
title={
|
||||
structuredFiltersExpanded
|
||||
? t('logs.filter_panel_collapse')
|
||||
: t('logs.filter_panel_expand')
|
||||
}
|
||||
>
|
||||
{t('logs.clear_filters')}
|
||||
<span className={styles.filterPanelButtonContent}>
|
||||
<IconSlidersHorizontal size={16} />
|
||||
<span>{t('logs.filter_panel_title')}</span>
|
||||
{structuredFilterCount > 0 && (
|
||||
<span className={styles.filterPanelCount}>
|
||||
{t('logs.filter_panel_active_count', { count: structuredFilterCount })}
|
||||
</span>
|
||||
)}
|
||||
{structuredFiltersExpanded ? (
|
||||
<IconChevronUp size={16} />
|
||||
) : (
|
||||
<IconChevronDown size={16} />
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{structuredFiltersExpanded && (
|
||||
<div id={structuredFiltersPanelId} className={styles.structuredFilters}>
|
||||
<div className={styles.filterChipGroup}>
|
||||
<span className={styles.filterChipLabel}>{t('logs.filter_method')}</span>
|
||||
<div className={styles.filterChipList}>
|
||||
{HTTP_METHODS.map((method) => {
|
||||
const active = filters.methodFilters.includes(method);
|
||||
const count = filters.methodCounts[method] ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={method}
|
||||
type="button"
|
||||
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
|
||||
onClick={() => filters.toggleMethodFilter(method)}
|
||||
disabled={count === 0 && !active}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{method} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterChipGroup}>
|
||||
<span className={styles.filterChipLabel}>{t('logs.filter_status')}</span>
|
||||
<div className={styles.filterChipList}>
|
||||
{STATUS_GROUPS.map((statusGroup) => {
|
||||
const active = filters.statusFilters.includes(statusGroup);
|
||||
const count = filters.statusCounts[statusGroup] ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={statusGroup}
|
||||
type="button"
|
||||
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
|
||||
onClick={() => filters.toggleStatusFilter(statusGroup)}
|
||||
disabled={count === 0 && !active}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{t(`logs.filter_status_${statusGroup}`)} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterChipGroup}>
|
||||
<span className={styles.filterChipLabel}>{t('logs.filter_path')}</span>
|
||||
<div className={styles.filterChipList}>
|
||||
{filters.pathOptions.length === 0 ? (
|
||||
<span className={styles.filterChipHint}>{t('logs.filter_path_empty')}</span>
|
||||
) : (
|
||||
filters.pathOptions.map(({ path, count }) => {
|
||||
const active = filters.pathFilters.includes(path);
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
type="button"
|
||||
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
|
||||
onClick={() => filters.togglePathFilter(path)}
|
||||
aria-pressed={active}
|
||||
title={path}
|
||||
>
|
||||
{path} ({count})
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={filters.clearStructuredFilters}
|
||||
disabled={!filters.hasStructuredFilters}
|
||||
>
|
||||
{t('logs.clear_filters')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ToggleSwitch
|
||||
checked={hideManagementLogs}
|
||||
onChange={setHideManagementLogs}
|
||||
|
||||
@@ -3,8 +3,18 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import { normalizeAmpcodeConfig, normalizeAmpcodeModelMappings } from './transformers';
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping } from '@/types';
|
||||
import {
|
||||
normalizeAmpcodeConfig,
|
||||
normalizeAmpcodeModelMappings,
|
||||
normalizeAmpcodeUpstreamApiKeys,
|
||||
} from './transformers';
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping, AmpcodeUpstreamApiKeyMapping } from '@/types';
|
||||
|
||||
const serializeUpstreamApiKeyMappings = (mappings: AmpcodeUpstreamApiKeyMapping[]) =>
|
||||
mappings.map((mapping) => ({
|
||||
'upstream-api-key': mapping.upstreamApiKey,
|
||||
'api-keys': mapping.apiKeys,
|
||||
}));
|
||||
|
||||
export const ampcodeApi = {
|
||||
async getAmpcode(): Promise<AmpcodeConfig> {
|
||||
@@ -18,6 +28,19 @@ export const ampcodeApi = {
|
||||
updateUpstreamApiKey: (apiKey: string) => apiClient.put('/ampcode/upstream-api-key', { value: apiKey }),
|
||||
clearUpstreamApiKey: () => apiClient.delete('/ampcode/upstream-api-key'),
|
||||
|
||||
async getUpstreamApiKeys(): Promise<AmpcodeUpstreamApiKeyMapping[]> {
|
||||
const data = await apiClient.get<Record<string, unknown>>('/ampcode/upstream-api-keys');
|
||||
const list = data?.['upstream-api-keys'] ?? data?.upstreamApiKeys ?? data?.items ?? data;
|
||||
return normalizeAmpcodeUpstreamApiKeys(list);
|
||||
},
|
||||
|
||||
saveUpstreamApiKeys: (mappings: AmpcodeUpstreamApiKeyMapping[]) =>
|
||||
apiClient.put('/ampcode/upstream-api-keys', { value: serializeUpstreamApiKeyMappings(mappings) }),
|
||||
patchUpstreamApiKeys: (mappings: AmpcodeUpstreamApiKeyMapping[]) =>
|
||||
apiClient.patch('/ampcode/upstream-api-keys', { value: serializeUpstreamApiKeyMappings(mappings) }),
|
||||
deleteUpstreamApiKeys: (upstreamApiKeys: string[]) =>
|
||||
apiClient.delete('/ampcode/upstream-api-keys', { data: { value: upstreamApiKeys } }),
|
||||
|
||||
async getModelMappings(): Promise<AmpcodeModelMapping[]> {
|
||||
const data = await apiClient.get<Record<string, unknown>>('/ampcode/model-mappings');
|
||||
const list = data?.['model-mappings'] ?? data?.modelMappings ?? data?.items ?? data;
|
||||
@@ -34,4 +57,3 @@ export const ampcodeApi = {
|
||||
|
||||
updateForceModelMappings: (enabled: boolean) => apiClient.put('/ampcode/force-model-mappings', { value: enabled })
|
||||
};
|
||||
|
||||
|
||||
@@ -9,12 +9,39 @@ import type { OAuthModelAliasEntry } from '@/types';
|
||||
type StatusError = { status?: number };
|
||||
type AuthFileStatusResponse = { status: string; disabled: boolean };
|
||||
|
||||
export const AUTH_FILE_INVALID_JSON_OBJECT_ERROR = 'AUTH_FILE_INVALID_JSON_OBJECT';
|
||||
|
||||
const getStatusCode = (err: unknown): number | undefined => {
|
||||
if (!err || typeof err !== 'object') return undefined;
|
||||
if ('status' in err) return (err as StatusError).status;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const parseAuthFileJsonObject = (rawText: string): Record<string, unknown> => {
|
||||
const trimmed = rawText.trim();
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
throw new Error(AUTH_FILE_INVALID_JSON_OBJECT_ERROR);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(AUTH_FILE_INVALID_JSON_OBJECT_ERROR);
|
||||
}
|
||||
|
||||
return { ...(parsed as Record<string, unknown>) };
|
||||
};
|
||||
|
||||
const saveAuthFileText = async (name: string, text: string) => {
|
||||
const file = new File([text], name, { type: 'application/json' });
|
||||
await authFilesApi.upload(file);
|
||||
};
|
||||
|
||||
export const isAuthFileInvalidJsonObjectError = (err: unknown): boolean =>
|
||||
err instanceof Error && err.message === AUTH_FILE_INVALID_JSON_OBJECT_ERROR;
|
||||
|
||||
const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]> => {
|
||||
if (!payload || typeof payload !== 'object') return {};
|
||||
|
||||
@@ -126,6 +153,16 @@ export const authFilesApi = {
|
||||
return blob.text();
|
||||
},
|
||||
|
||||
async downloadJsonObject(name: string): Promise<Record<string, unknown>> {
|
||||
const rawText = await authFilesApi.downloadText(name);
|
||||
return parseAuthFileJsonObject(rawText);
|
||||
},
|
||||
|
||||
saveText: (name: string, text: string) => saveAuthFileText(name, text),
|
||||
|
||||
saveJsonObject: (name: string, json: Record<string, unknown>) =>
|
||||
saveAuthFileText(name, JSON.stringify(json)),
|
||||
|
||||
// OAuth 排除模型
|
||||
async getOauthExcludedModels(): Promise<Record<string, string[]>> {
|
||||
const data = await apiClient.get('/oauth-excluded-models');
|
||||
|
||||
@@ -107,6 +107,9 @@ const serializeVertexKey = (config: ProviderKeyConfig) => {
|
||||
if (headers) payload.headers = headers;
|
||||
const models = serializeVertexModelAliases(config.models);
|
||||
if (models && models.length) payload.models = models;
|
||||
if (config.excludedModels && config.excludedModels.length) {
|
||||
payload['excluded-models'] = config.excludedModels;
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import type {
|
||||
OpenAIProviderConfig,
|
||||
ProviderKeyConfig,
|
||||
AmpcodeConfig,
|
||||
AmpcodeModelMapping
|
||||
AmpcodeModelMapping,
|
||||
AmpcodeUpstreamApiKeyMapping
|
||||
} from '@/types';
|
||||
import type { Config } from '@/types/config';
|
||||
import { buildHeaderObject } from '@/utils/headers';
|
||||
@@ -276,6 +277,33 @@ const normalizeAmpcodeModelMappings = (input: unknown): AmpcodeModelMapping[] =>
|
||||
return mappings;
|
||||
};
|
||||
|
||||
const normalizeAmpcodeUpstreamApiKeys = (input: unknown): AmpcodeUpstreamApiKeyMapping[] => {
|
||||
if (!Array.isArray(input)) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const mappings: AmpcodeUpstreamApiKeyMapping[] = [];
|
||||
|
||||
input.forEach((entry) => {
|
||||
if (!isRecord(entry)) return;
|
||||
|
||||
const upstreamApiKey = String(
|
||||
entry['upstream-api-key'] ?? entry.upstreamApiKey ?? entry['upstream_api_key'] ?? ''
|
||||
).trim();
|
||||
if (!upstreamApiKey || seen.has(upstreamApiKey)) return;
|
||||
|
||||
const rawApiKeys = entry['api-keys'] ?? entry.apiKeys ?? entry['api_keys'] ?? [];
|
||||
const apiKeys = Array.isArray(rawApiKeys)
|
||||
? Array.from(new Set(rawApiKeys.map((item) => String(item ?? '').trim()).filter(Boolean)))
|
||||
: [];
|
||||
if (!apiKeys.length) return;
|
||||
|
||||
seen.add(upstreamApiKey);
|
||||
mappings.push({ upstreamApiKey, apiKeys });
|
||||
});
|
||||
|
||||
return mappings;
|
||||
};
|
||||
|
||||
const normalizeAmpcodeConfig = (payload: unknown): AmpcodeConfig | undefined => {
|
||||
const sourceRaw = isRecord(payload) ? (payload.ampcode ?? payload) : payload;
|
||||
if (!isRecord(sourceRaw)) return undefined;
|
||||
@@ -287,6 +315,13 @@ const normalizeAmpcodeConfig = (payload: unknown): AmpcodeConfig | undefined =>
|
||||
const upstreamApiKey = source['upstream-api-key'] ?? source.upstreamApiKey ?? source['upstream_api_key'];
|
||||
if (upstreamApiKey) config.upstreamApiKey = String(upstreamApiKey);
|
||||
|
||||
const upstreamApiKeys = normalizeAmpcodeUpstreamApiKeys(
|
||||
source['upstream-api-keys'] ?? source.upstreamApiKeys ?? source['upstream_api_keys']
|
||||
);
|
||||
if (upstreamApiKeys.length) {
|
||||
config.upstreamApiKeys = upstreamApiKeys;
|
||||
}
|
||||
|
||||
const forceModelMappings = normalizeBoolean(
|
||||
source['force-model-mappings'] ?? source.forceModelMappings ?? source['force_model_mappings']
|
||||
);
|
||||
@@ -420,5 +455,6 @@ export {
|
||||
normalizeHeaders,
|
||||
normalizeExcludedModels,
|
||||
normalizeAmpcodeConfig,
|
||||
normalizeAmpcodeModelMappings
|
||||
normalizeAmpcodeModelMappings,
|
||||
normalizeAmpcodeUpstreamApiKeys
|
||||
};
|
||||
|
||||
@@ -25,12 +25,28 @@ const getSystemTheme = (): ResolvedTheme => {
|
||||
return 'light';
|
||||
};
|
||||
|
||||
const applyTheme = (resolved: ResolvedTheme) => {
|
||||
const resolveTheme = (theme: Theme): ResolvedTheme | 'white' => {
|
||||
if (theme === 'auto') {
|
||||
return getSystemTheme();
|
||||
}
|
||||
if (theme === 'white') {
|
||||
return 'white';
|
||||
}
|
||||
return theme;
|
||||
};
|
||||
|
||||
const applyTheme = (resolved: ResolvedTheme | 'white') => {
|
||||
if (resolved === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolved === 'white') {
|
||||
document.documentElement.setAttribute('data-theme', 'white');
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
};
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
@@ -40,14 +56,17 @@ export const useThemeStore = create<ThemeState>()(
|
||||
resolvedTheme: 'light',
|
||||
|
||||
setTheme: (theme) => {
|
||||
const resolved: ResolvedTheme = theme === 'auto' ? getSystemTheme() : theme;
|
||||
const resolved = resolveTheme(theme);
|
||||
applyTheme(resolved);
|
||||
set({ theme, resolvedTheme: resolved });
|
||||
set({
|
||||
theme,
|
||||
resolvedTheme: resolved === 'white' ? 'light' : resolved,
|
||||
});
|
||||
},
|
||||
|
||||
cycleTheme: () => {
|
||||
const { theme, setTheme } = get();
|
||||
const order: Theme[] = ['light', 'dark', 'auto'];
|
||||
const order: Theme[] = ['light', 'white', 'dark', 'auto'];
|
||||
const currentIndex = order.indexOf(theme);
|
||||
const nextTheme = order[(currentIndex + 1) % order.length];
|
||||
setTheme(nextTheme);
|
||||
|
||||
@@ -251,6 +251,123 @@
|
||||
}
|
||||
}
|
||||
|
||||
.theme-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
.theme-menu-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: $z-dropdown;
|
||||
padding: $spacing-sm $spacing-sm $spacing-xs;
|
||||
display: flex;
|
||||
gap: $spacing-xs;
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 16px);
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
border: 2px solid transparent;
|
||||
border-radius: $radius-md;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 6px 6px 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: border-color $transition-fast, background-color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba($primary-color, 0.22);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-card-preview {
|
||||
width: 72px;
|
||||
height: 52px;
|
||||
border-radius: $radius-sm;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.theme-card-header {
|
||||
height: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.theme-card-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.theme-card-sidebar {
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.theme-card-content {
|
||||
flex: 1;
|
||||
padding: 5px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.theme-card-line {
|
||||
height: 3px;
|
||||
border-radius: 1px;
|
||||
|
||||
&.short {
|
||||
width: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-card-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.theme-menu-popover {
|
||||
right: 0;
|
||||
left: auto;
|
||||
transform: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
justify-content: stretch;
|
||||
width: min(188px, calc(100vw - 16px));
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.theme-card-label {
|
||||
white-space: normal;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,59 @@
|
||||
--accent-tertiary: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
// 纯白主题
|
||||
[data-theme='white'] {
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-tertiary: #f6f6f6;
|
||||
--bg-hover: var(--bg-tertiary);
|
||||
--bg-quinary: #ffffff;
|
||||
--bg-error-light: rgba(198, 87, 70, 0.08);
|
||||
|
||||
--text-primary: #2d2a26;
|
||||
--text-secondary: #6d6760;
|
||||
--text-tertiary: #a29c95;
|
||||
--text-quaternary: #c0bab3;
|
||||
--text-muted: var(--text-tertiary);
|
||||
|
||||
--border-color: #e5e5e5;
|
||||
--border-secondary: var(--border-color);
|
||||
--border-primary: #d9d9d9;
|
||||
--border-hover: #cccccc;
|
||||
|
||||
--primary-color: #8b8680;
|
||||
--primary-hover: #7f7a74;
|
||||
--primary-active: #726d67;
|
||||
--primary-contrast: #ffffff;
|
||||
|
||||
--success-color: #10b981;
|
||||
--warning-color: #c65746;
|
||||
--error-color: #c65746;
|
||||
--danger-color: var(--error-color);
|
||||
--info-color: var(--primary-color);
|
||||
|
||||
--warning-bg: rgba(198, 87, 70, 0.12);
|
||||
--warning-border: rgba(198, 87, 70, 0.35);
|
||||
--warning-text: var(--warning-color);
|
||||
|
||||
--success-badge-bg: #d1fae5;
|
||||
--success-badge-text: #065f46;
|
||||
--success-badge-border: #6ee7b7;
|
||||
|
||||
--failure-badge-bg: rgba(198, 87, 70, 0.14);
|
||||
--failure-badge-text: #8a3a30;
|
||||
--failure-badge-border: rgba(198, 87, 70, 0.35);
|
||||
|
||||
--count-badge-bg: rgba(139, 134, 128, 0.18);
|
||||
--count-badge-text: var(--primary-active);
|
||||
|
||||
--shadow: 0 1px 2px 0 rgb(0 0 0 / 0.08);
|
||||
--shadow-lg: 0 10px 18px -3px rgb(0 0 0 / 0.1);
|
||||
|
||||
--radius-md: 8px;
|
||||
--accent-tertiary: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
// 深色主题(#191919)
|
||||
[data-theme='dark'] {
|
||||
// 极简暖灰:深色模式(提升对比度与层级)
|
||||
|
||||
@@ -7,10 +7,15 @@ export interface AmpcodeModelMapping {
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface AmpcodeUpstreamApiKeyMapping {
|
||||
upstreamApiKey: string;
|
||||
apiKeys: string[];
|
||||
}
|
||||
|
||||
export interface AmpcodeConfig {
|
||||
upstreamUrl?: string;
|
||||
upstreamApiKey?: string;
|
||||
upstreamApiKeys?: AmpcodeUpstreamApiKeyMapping[];
|
||||
modelMappings?: AmpcodeModelMapping[];
|
||||
forceModelMappings?: boolean;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
* 通用类型定义
|
||||
*/
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'auto';
|
||||
export type Theme = 'light' | 'white' | 'dark' | 'auto';
|
||||
|
||||
export type Language = 'zh-CN' | 'en' | 'ru';
|
||||
|
||||
|
||||
@@ -132,6 +132,28 @@ export interface ClaudeUsagePayload {
|
||||
extra_usage?: ClaudeExtraUsage | null;
|
||||
}
|
||||
|
||||
export interface ClaudeProfileResponse {
|
||||
account?: {
|
||||
uuid?: string;
|
||||
full_name?: string;
|
||||
display_name?: string;
|
||||
email?: string;
|
||||
has_claude_max?: boolean;
|
||||
has_claude_pro?: boolean;
|
||||
created_at?: string;
|
||||
};
|
||||
organization?: {
|
||||
uuid?: string;
|
||||
name?: string;
|
||||
organization_type?: string;
|
||||
billing_type?: string;
|
||||
rate_limit_tier?: string;
|
||||
has_extra_usage_enabled?: boolean;
|
||||
subscription_status?: string;
|
||||
subscription_created_at?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ClaudeQuotaWindow {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -144,6 +166,7 @@ export interface ClaudeQuotaState {
|
||||
status: 'idle' | 'loading' | 'success' | 'error';
|
||||
windows: ClaudeQuotaWindow[];
|
||||
extraUsage?: ClaudeExtraUsage | null;
|
||||
planType?: string | null;
|
||||
error?: string;
|
||||
errorStatus?: number;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
export type PayloadParamValueType = 'string' | 'number' | 'boolean' | 'json';
|
||||
export type PayloadParamValidationErrorCode =
|
||||
| 'payload_invalid_number'
|
||||
| 'payload_invalid_boolean'
|
||||
| 'payload_invalid_json';
|
||||
|
||||
export type VisualConfigFieldPath =
|
||||
| 'port'
|
||||
| 'logsMaxTotalSizeMb'
|
||||
| 'requestRetry'
|
||||
| 'maxRetryInterval'
|
||||
| 'streaming.keepaliveSeconds'
|
||||
| 'streaming.bootstrapRetries'
|
||||
| 'streaming.nonstreamKeepaliveInterval';
|
||||
|
||||
export type VisualConfigValidationErrorCode = 'port_range' | 'non_negative_integer';
|
||||
|
||||
export type VisualConfigValidationErrors = Partial<
|
||||
Record<VisualConfigFieldPath, VisualConfigValidationErrorCode>
|
||||
>;
|
||||
|
||||
export type PayloadParamEntry = {
|
||||
id: string;
|
||||
|
||||
@@ -156,6 +156,8 @@ export const GEMINI_CLI_GROUP_LOOKUP = new Map(
|
||||
export const GEMINI_CLI_IGNORED_MODEL_PREFIXES = ['gemini-2.0-flash'];
|
||||
|
||||
// Claude API configuration
|
||||
export const CLAUDE_PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
|
||||
|
||||
export const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
|
||||
|
||||
export const CLAUDE_REQUEST_HEADERS = {
|
||||
|
||||
Reference in New Issue
Block a user