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
18 Commits
@@ -6,6 +6,7 @@ import { Card } from '@/components/ui/Card';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import {
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
@@ -53,6 +54,7 @@ interface OpenAISectionProps {
|
||||
onAdd: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onToggle: (index: number, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
interface IndexedOpenAIProvider {
|
||||
@@ -80,11 +82,13 @@ export function OpenAISection({
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
}: OpenAISectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const pageTransitionLayer = usePageTransitionLayer();
|
||||
const isTransitionAnimating = pageTransitionLayer?.isAnimating ?? false;
|
||||
const actionsDisabled = disableControls || loading || isSwitching;
|
||||
const toggleDisabled = disableControls || loading || isSwitching;
|
||||
const [sortOption, setSortOption] = useState<SortOption>('priority');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
|
||||
@@ -529,6 +533,7 @@ export function OpenAISection({
|
||||
const apiKeyEntries = provider.apiKeyEntries || [];
|
||||
const statusData =
|
||||
statusBarCache.get(getOpenAIProviderKey(provider, originalIndex)) || EMPTY_STATUS_BAR;
|
||||
const providerDisabled = provider.disabled === true;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -554,6 +559,11 @@ export function OpenAISection({
|
||||
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
|
||||
<span className={styles.fieldValue}>{provider.baseUrl}</span>
|
||||
</div>
|
||||
{providerDisabled && (
|
||||
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
{t('ai_providers.config_disabled_badge')}
|
||||
</div>
|
||||
)}
|
||||
{headerEntries.length > 0 && (
|
||||
<div className={styles.headerBadgeList}>
|
||||
{headerEntries.map(([key, value]) => (
|
||||
@@ -651,6 +661,12 @@ export function OpenAISection({
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
<ToggleSwitch
|
||||
label={t('ai_providers.config_toggle_label')}
|
||||
checked={!providerDisabled}
|
||||
disabled={toggleDisabled}
|
||||
onChange={(value) => void onToggle(originalIndex, value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,8 +24,7 @@ type SortKey =
|
||||
| 'tokens'
|
||||
| 'cost'
|
||||
| 'successRate'
|
||||
| 'averageLatencyMs'
|
||||
| 'totalLatencyMs';
|
||||
| 'averageLatencyMs';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
interface ModelStatWithRate extends ModelStat {
|
||||
@@ -129,17 +128,6 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
|
||||
{arrow('averageLatencyMs')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('totalLatencyMs')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('totalLatencyMs')}
|
||||
title={latencyHint}
|
||||
>
|
||||
{t('usage_stats.total_time')}
|
||||
{arrow('totalLatencyMs')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('successRate')}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -187,9 +175,6 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
|
||||
<td className={styles.durationCell}>
|
||||
{formatDurationMs(stat.averageLatencyMs)}
|
||||
</td>
|
||||
<td className={styles.durationCell}>
|
||||
{formatDurationMs(stat.totalLatencyMs)}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
formatDurationMs,
|
||||
LATENCY_SOURCE_FIELD,
|
||||
normalizeAuthIndex,
|
||||
type UsageThinking,
|
||||
} from '@/utils/usage';
|
||||
import { downloadBlob } from '@/utils/download';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
@@ -37,6 +38,8 @@ type RequestEventRow = {
|
||||
authIndex: string;
|
||||
failed: boolean;
|
||||
latencyMs: number | null;
|
||||
thinking: UsageThinking | null;
|
||||
thinkingLabel: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
reasoningTokens: number;
|
||||
@@ -60,6 +63,37 @@ const toNumber = (value: unknown): number => {
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const normalizeThinkingText = (value: unknown): string => {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
const formatThinkingLabel = (thinking: UsageThinking | null): string => {
|
||||
if (!thinking) return '-';
|
||||
|
||||
const intensity = normalizeThinkingText(thinking.intensity);
|
||||
const level = normalizeThinkingText(thinking.level);
|
||||
const mode = normalizeThinkingText(thinking.mode);
|
||||
const budget =
|
||||
typeof thinking.budget === 'number' && Number.isFinite(thinking.budget)
|
||||
? thinking.budget
|
||||
: null;
|
||||
const label = intensity || level || (budget !== null ? String(budget) : mode);
|
||||
const budgetLabel = budget !== null ? budget.toLocaleString() : null;
|
||||
|
||||
if (!label) return '-';
|
||||
if (budgetLabel !== null && label === String(budget)) {
|
||||
return budgetLabel;
|
||||
}
|
||||
if (mode === 'budget' && budget !== null && budget > 0) {
|
||||
return `${label} (${budgetLabel})`;
|
||||
}
|
||||
if (budget === -1 && label !== 'auto') {
|
||||
return `${label} (-1)`;
|
||||
}
|
||||
return label;
|
||||
};
|
||||
|
||||
const encodeCsv = (value: string | number): string => {
|
||||
const text = String(value ?? '');
|
||||
const trimmedLeft = text.replace(/^\s+/, '');
|
||||
@@ -127,63 +161,61 @@ export function RequestEventsDetailsCard({
|
||||
const rows = useMemo<RequestEventRow[]>(() => {
|
||||
const details = collectUsageDetails(usage);
|
||||
|
||||
const baseRows = details
|
||||
.map((detail, index) => {
|
||||
const timestamp = detail.timestamp;
|
||||
const timestampMs =
|
||||
typeof detail.__timestampMs === 'number' && detail.__timestampMs > 0
|
||||
? detail.__timestampMs
|
||||
: parseTimestampMs(timestamp);
|
||||
const date = Number.isNaN(timestampMs) ? null : new Date(timestampMs);
|
||||
const sourceRaw = String(detail.source ?? '').trim();
|
||||
const authIndexRaw = detail.auth_index as unknown;
|
||||
const authIndex =
|
||||
authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === ''
|
||||
? '-'
|
||||
: String(authIndexRaw);
|
||||
const sourceInfo = resolveSourceDisplay(
|
||||
sourceRaw,
|
||||
authIndexRaw,
|
||||
sourceInfoMap,
|
||||
authFileMap
|
||||
);
|
||||
const source = sourceInfo.displayName;
|
||||
const sourceKey = sourceInfo.identityKey ?? `source:${sourceRaw || source}`;
|
||||
const sourceType = sourceInfo.type;
|
||||
const model = String(detail.__modelName ?? '').trim() || '-';
|
||||
const inputTokens = Math.max(toNumber(detail.tokens?.input_tokens), 0);
|
||||
const outputTokens = Math.max(toNumber(detail.tokens?.output_tokens), 0);
|
||||
const reasoningTokens = Math.max(toNumber(detail.tokens?.reasoning_tokens), 0);
|
||||
const cachedTokens = Math.max(
|
||||
Math.max(toNumber(detail.tokens?.cached_tokens), 0),
|
||||
Math.max(toNumber(detail.tokens?.cache_tokens), 0)
|
||||
);
|
||||
const totalTokens = Math.max(
|
||||
toNumber(detail.tokens?.total_tokens),
|
||||
extractTotalTokens(detail)
|
||||
);
|
||||
const latencyMs = extractLatencyMs(detail);
|
||||
const baseRows = details.map((detail, index) => {
|
||||
const timestamp = detail.timestamp;
|
||||
const timestampMs =
|
||||
typeof detail.__timestampMs === 'number' && detail.__timestampMs > 0
|
||||
? detail.__timestampMs
|
||||
: parseTimestampMs(timestamp);
|
||||
const date = Number.isNaN(timestampMs) ? null : new Date(timestampMs);
|
||||
const sourceRaw = String(detail.source ?? '').trim();
|
||||
const authIndexRaw = detail.auth_index as unknown;
|
||||
const authIndex =
|
||||
authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === ''
|
||||
? '-'
|
||||
: String(authIndexRaw);
|
||||
const sourceInfo = resolveSourceDisplay(sourceRaw, authIndexRaw, sourceInfoMap, authFileMap);
|
||||
const source = sourceInfo.displayName;
|
||||
const sourceKey = sourceInfo.identityKey ?? `source:${sourceRaw || source}`;
|
||||
const sourceType = sourceInfo.type;
|
||||
const model = String(detail.__modelName ?? '').trim() || '-';
|
||||
const inputTokens = Math.max(toNumber(detail.tokens?.input_tokens), 0);
|
||||
const outputTokens = Math.max(toNumber(detail.tokens?.output_tokens), 0);
|
||||
const reasoningTokens = Math.max(toNumber(detail.tokens?.reasoning_tokens), 0);
|
||||
const cachedTokens = Math.max(
|
||||
Math.max(toNumber(detail.tokens?.cached_tokens), 0),
|
||||
Math.max(toNumber(detail.tokens?.cache_tokens), 0)
|
||||
);
|
||||
const totalTokens = Math.max(
|
||||
toNumber(detail.tokens?.total_tokens),
|
||||
extractTotalTokens(detail)
|
||||
);
|
||||
const latencyMs = extractLatencyMs(detail);
|
||||
const thinking = detail.thinking ?? null;
|
||||
const thinkingLabel = formatThinkingLabel(thinking);
|
||||
|
||||
return {
|
||||
id: `${timestamp}-${model}-${sourceKey}-${authIndex}-${index}`,
|
||||
timestamp,
|
||||
timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
|
||||
timestampLabel: date ? date.toLocaleString(i18n.language) : timestamp || '-',
|
||||
model,
|
||||
sourceKey,
|
||||
sourceRaw: sourceRaw || '-',
|
||||
source,
|
||||
sourceType,
|
||||
authIndex,
|
||||
failed: detail.failed === true,
|
||||
latencyMs,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cachedTokens,
|
||||
totalTokens,
|
||||
};
|
||||
});
|
||||
return {
|
||||
id: `${timestamp}-${model}-${sourceKey}-${authIndex}-${index}`,
|
||||
timestamp,
|
||||
timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
|
||||
timestampLabel: date ? date.toLocaleString(i18n.language) : timestamp || '-',
|
||||
model,
|
||||
sourceKey,
|
||||
sourceRaw: sourceRaw || '-',
|
||||
source,
|
||||
sourceType,
|
||||
authIndex,
|
||||
failed: detail.failed === true,
|
||||
latencyMs,
|
||||
thinking,
|
||||
thinkingLabel,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cachedTokens,
|
||||
totalTokens,
|
||||
};
|
||||
});
|
||||
|
||||
const sourceLabelKeyMap = new Map<string, Set<string>>();
|
||||
baseRows.forEach((row) => {
|
||||
@@ -319,6 +351,10 @@ export function RequestEventsDetailsCard({
|
||||
'auth_index',
|
||||
'result',
|
||||
...(hasLatencyData ? ['latency_ms'] : []),
|
||||
'thinking_intensity',
|
||||
'thinking_mode',
|
||||
'thinking_level',
|
||||
'thinking_budget',
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'reasoning_tokens',
|
||||
@@ -335,6 +371,10 @@ export function RequestEventsDetailsCard({
|
||||
row.authIndex,
|
||||
row.failed ? 'failed' : 'success',
|
||||
...(hasLatencyData ? [row.latencyMs ?? ''] : []),
|
||||
row.thinking?.intensity ?? '',
|
||||
row.thinking?.mode ?? '',
|
||||
row.thinking?.level ?? '',
|
||||
row.thinking?.budget ?? '',
|
||||
row.inputTokens,
|
||||
row.outputTokens,
|
||||
row.reasoningTokens,
|
||||
@@ -364,6 +404,7 @@ export function RequestEventsDetailsCard({
|
||||
auth_index: row.authIndex,
|
||||
failed: row.failed,
|
||||
...(hasLatencyData && row.latencyMs !== null ? { latency_ms: row.latencyMs } : {}),
|
||||
...(row.thinking ? { thinking: row.thinking } : {}),
|
||||
tokens: {
|
||||
input_tokens: row.inputTokens,
|
||||
output_tokens: row.outputTokens,
|
||||
@@ -492,6 +533,7 @@ export function RequestEventsDetailsCard({
|
||||
<th>{t('usage_stats.request_events_auth_index')}</th>
|
||||
<th>{t('usage_stats.request_events_result')}</th>
|
||||
{hasLatencyData && <th title={latencyHint}>{t('usage_stats.time')}</th>}
|
||||
<th>{t('usage_stats.thinking_intensity')}</th>
|
||||
<th>{t('usage_stats.input_tokens')}</th>
|
||||
<th>{t('usage_stats.output_tokens')}</th>
|
||||
<th>{t('usage_stats.reasoning_tokens')}</th>
|
||||
@@ -529,6 +571,34 @@ export function RequestEventsDetailsCard({
|
||||
{hasLatencyData && (
|
||||
<td className={styles.durationCell}>{formatDurationMs(row.latencyMs)}</td>
|
||||
)}
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
row.thinking
|
||||
? styles.requestEventsThinkingBadge
|
||||
: styles.requestEventsThinkingEmpty
|
||||
}
|
||||
title={
|
||||
row.thinking
|
||||
? [
|
||||
row.thinking.mode
|
||||
? `${t('usage_stats.thinking_mode')}: ${row.thinking.mode}`
|
||||
: '',
|
||||
row.thinking.level
|
||||
? `${t('usage_stats.thinking_level')}: ${row.thinking.level}`
|
||||
: '',
|
||||
typeof row.thinking.budget === 'number'
|
||||
? `${t('usage_stats.thinking_budget')}: ${row.thinking.budget.toLocaleString()}`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{row.thinkingLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td>{row.inputTokens.toLocaleString()}</td>
|
||||
<td>{row.outputTokens.toLocaleString()}</td>
|
||||
<td>{row.reasoningTokens.toLocaleString()}</td>
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import type { ChartData, ChartOptions, TooltipItem } from 'chart.js';
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
buildHourlyTokenBreakdown,
|
||||
buildDailyTokenBreakdown,
|
||||
type TokenCategory
|
||||
type TokenCategory,
|
||||
} from '@/utils/usage';
|
||||
import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig';
|
||||
import type { UsagePayload } from './hooks/useUsageData';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
const TOKEN_COLORS: Record<TokenCategory, { border: string; bg: string }> = {
|
||||
input: { border: '#8b8680', bg: 'rgba(139, 134, 128, 0.25)' },
|
||||
output: { border: '#22c55e', bg: 'rgba(34, 197, 94, 0.25)' },
|
||||
cached: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.25)' },
|
||||
reasoning: { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.25)' }
|
||||
const TOKEN_COLORS: Record<TokenCategory, string> = {
|
||||
input: '#8CC21F',
|
||||
output: '#FA6450',
|
||||
cached: '#F5ED58',
|
||||
reasoning: '#00ABA5',
|
||||
};
|
||||
|
||||
const CATEGORIES: TokenCategory[] = ['input', 'output', 'cached', 'reasoning'];
|
||||
|
||||
function formatTokens(num: number): string {
|
||||
if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M';
|
||||
if (num >= 1e3) return (num / 1e3).toFixed(2) + 'K';
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
export interface TokenBreakdownChartProps {
|
||||
usage: UsagePayload | null;
|
||||
loading: boolean;
|
||||
@@ -34,7 +41,7 @@ export function TokenBreakdownChart({
|
||||
loading,
|
||||
isDark,
|
||||
isMobile,
|
||||
hourWindowHours
|
||||
hourWindowHours,
|
||||
}: TokenBreakdownChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const [period, setPeriod] = useState<'hour' | 'day'>('hour');
|
||||
@@ -48,41 +55,72 @@ export function TokenBreakdownChart({
|
||||
input: t('usage_stats.input_tokens'),
|
||||
output: t('usage_stats.output_tokens'),
|
||||
cached: t('usage_stats.cached_tokens'),
|
||||
reasoning: t('usage_stats.reasoning_tokens')
|
||||
reasoning: t('usage_stats.reasoning_tokens'),
|
||||
};
|
||||
|
||||
const data = {
|
||||
const data: ChartData<'bar'> = {
|
||||
labels: series.labels,
|
||||
datasets: CATEGORIES.map((cat) => ({
|
||||
label: categoryLabels[cat],
|
||||
data: series.dataByCategory[cat],
|
||||
borderColor: TOKEN_COLORS[cat].border,
|
||||
backgroundColor: TOKEN_COLORS[cat].bg,
|
||||
pointBackgroundColor: TOKEN_COLORS[cat].border,
|
||||
pointBorderColor: TOKEN_COLORS[cat].border,
|
||||
fill: true,
|
||||
tension: 0.35
|
||||
}))
|
||||
backgroundColor: TOKEN_COLORS[cat],
|
||||
borderColor: isDark ? '#0f172a' : '#ffffff',
|
||||
borderWidth: 1,
|
||||
borderSkipped: false,
|
||||
grouped: true,
|
||||
categoryPercentage: 0.82,
|
||||
barPercentage: 0.88,
|
||||
})),
|
||||
};
|
||||
|
||||
const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile });
|
||||
const options = {
|
||||
const baseOptions = buildChartOptions({
|
||||
period,
|
||||
labels: series.labels,
|
||||
isDark,
|
||||
isMobile,
|
||||
}) as ChartOptions<'bar'>;
|
||||
const options: ChartOptions<'bar'> = {
|
||||
...baseOptions,
|
||||
scales: {
|
||||
...baseOptions.scales,
|
||||
y: {
|
||||
...baseOptions.scales?.y,
|
||||
stacked: true
|
||||
stacked: false,
|
||||
},
|
||||
x: {
|
||||
...baseOptions.scales?.x,
|
||||
stacked: true
|
||||
}
|
||||
}
|
||||
stacked: false,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
...baseOptions.plugins,
|
||||
tooltip: {
|
||||
...baseOptions.plugins?.tooltip,
|
||||
itemSort: (a, b) => a.datasetIndex - b.datasetIndex,
|
||||
callbacks: {
|
||||
...baseOptions.plugins?.tooltip?.callbacks,
|
||||
label: function (context: TooltipItem<'bar'>) {
|
||||
const val = Number(context.raw) || 0;
|
||||
const cat = CATEGORIES[context.datasetIndex];
|
||||
let text = `${context.dataset.label}: ${formatTokens(val)}`;
|
||||
|
||||
if (cat === 'cached') {
|
||||
const inputVal = Number(series.dataByCategory.input[context.dataIndex]) || 0;
|
||||
if (inputVal > 0) {
|
||||
const perc = ((val / inputVal) * 100).toFixed(2);
|
||||
text += ` (${perc}%)`;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { chartData: data, chartOptions: options };
|
||||
}, [usage, period, isDark, isMobile, hourWindowHours, t]);
|
||||
const labels = chartData.labels ?? [];
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -108,7 +146,7 @@ export function TokenBreakdownChart({
|
||||
>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : chartData.labels.length > 0 ? (
|
||||
) : labels.length > 0 ? (
|
||||
<div className={styles.chartWrapper}>
|
||||
<div className={styles.chartLegend} aria-label="Chart legend">
|
||||
{chartData.datasets.map((dataset, index) => (
|
||||
@@ -117,7 +155,10 @@ export function TokenBreakdownChart({
|
||||
className={styles.legendItem}
|
||||
title={dataset.label}
|
||||
>
|
||||
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
|
||||
<span
|
||||
className={styles.legendDot}
|
||||
style={{ backgroundColor: TOKEN_COLORS[CATEGORIES[index]] }}
|
||||
/>
|
||||
<span className={styles.legendLabel}>{dataset.label}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -128,11 +169,11 @@ export function TokenBreakdownChart({
|
||||
className={styles.chartCanvas}
|
||||
style={
|
||||
period === 'hour'
|
||||
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
|
||||
? { minWidth: getHourChartMinWidth(labels.length, isMobile) }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Line data={chartData} options={chartOptions} />
|
||||
<Bar data={chartData} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* LocalStorage Hook
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export function useLocalStorage<T>(
|
||||
key: string,
|
||||
@@ -18,15 +18,22 @@ export function useLocalStorage<T>(
|
||||
}
|
||||
});
|
||||
|
||||
const setValue = (value: T | ((val: T) => T)) => {
|
||||
try {
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||
setStoredValue(valueToStore);
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
} catch (error) {
|
||||
console.error(`Error setting localStorage key "${key}":`, error);
|
||||
}
|
||||
};
|
||||
const setValue = useCallback(
|
||||
(value: T | ((val: T) => T)) => {
|
||||
setStoredValue((currentValue) => {
|
||||
const valueToStore = value instanceof Function ? value(currentValue) : value;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
} catch (error) {
|
||||
console.error(`Error setting localStorage key "${key}":`, error);
|
||||
}
|
||||
|
||||
return valueToStore;
|
||||
});
|
||||
},
|
||||
[key]
|
||||
);
|
||||
|
||||
return [storedValue, setValue];
|
||||
}
|
||||
|
||||
@@ -92,6 +92,18 @@ function setBooleanInDoc(doc: YamlDocument, path: YamlPath, value: boolean): voi
|
||||
if (docHas(doc, path)) doc.setIn(path, false);
|
||||
}
|
||||
|
||||
function shouldWriteManagedField(
|
||||
doc: YamlDocument,
|
||||
path: YamlPath,
|
||||
dirtyFields: Set<string>,
|
||||
dirtyKey: string
|
||||
): boolean {
|
||||
// Optional fields managed by the visual editor must not be created during unrelated saves.
|
||||
// Only materialize them when the YAML already had the key or the user changed that field.
|
||||
// Use this guard for future optional visual-editor fields instead of unconditional `setIn`.
|
||||
return docHas(doc, path) || dirtyFields.has(dirtyKey);
|
||||
}
|
||||
|
||||
function setStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void {
|
||||
const safe = typeof value === 'string' ? value : '';
|
||||
const trimmed = safe.trim();
|
||||
@@ -770,8 +782,8 @@ export function useVisualConfig() {
|
||||
undefined,
|
||||
createInitialVisualConfigState
|
||||
);
|
||||
const { visualValues, visualParseError } = state;
|
||||
const visualDirty = state.dirtyFields.size > 0;
|
||||
const { visualValues, visualParseError, dirtyFields } = state;
|
||||
const visualDirty = dirtyFields.size > 0;
|
||||
const visualValidationErrors = useMemo(
|
||||
() => getVisualConfigValidationErrors(visualValues),
|
||||
[visualValues]
|
||||
@@ -845,7 +857,7 @@ export function useVisualConfig() {
|
||||
|
||||
quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? true),
|
||||
quotaSwitchPreviewModel: Boolean(quotaExceeded?.['switch-preview-model'] ?? true),
|
||||
quotaAntigravityCredits: Boolean(quotaExceeded?.['antigravity-credits'] ?? true),
|
||||
quotaAntigravityCredits: Boolean(quotaExceeded?.['antigravity-credits'] ?? false),
|
||||
|
||||
routingStrategy: routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
|
||||
routingSessionAffinity: Boolean(
|
||||
@@ -962,15 +974,28 @@ export function useVisualConfig() {
|
||||
docHas(doc, ['quota-exceeded']) ||
|
||||
!values.quotaSwitchProject ||
|
||||
!values.quotaSwitchPreviewModel ||
|
||||
!values.quotaAntigravityCredits
|
||||
shouldWriteManagedField(
|
||||
doc,
|
||||
['quota-exceeded', 'antigravity-credits'],
|
||||
dirtyFields,
|
||||
'quotaAntigravityCredits'
|
||||
)
|
||||
) {
|
||||
ensureMapInDoc(doc, ['quota-exceeded']);
|
||||
const writeQuotaAntigravityCredits = shouldWriteManagedField(
|
||||
doc,
|
||||
['quota-exceeded', 'antigravity-credits'],
|
||||
dirtyFields,
|
||||
'quotaAntigravityCredits'
|
||||
);
|
||||
doc.setIn(['quota-exceeded', 'switch-project'], values.quotaSwitchProject);
|
||||
doc.setIn(['quota-exceeded', 'switch-preview-model'], values.quotaSwitchPreviewModel);
|
||||
doc.setIn(
|
||||
['quota-exceeded', 'antigravity-credits'],
|
||||
values.quotaAntigravityCredits
|
||||
);
|
||||
if (writeQuotaAntigravityCredits) {
|
||||
doc.setIn(
|
||||
['quota-exceeded', 'antigravity-credits'],
|
||||
values.quotaAntigravityCredits
|
||||
);
|
||||
}
|
||||
deleteIfMapEmpty(doc, ['quota-exceeded']);
|
||||
}
|
||||
|
||||
@@ -1072,7 +1097,7 @@ export function useVisualConfig() {
|
||||
return currentYaml;
|
||||
}
|
||||
},
|
||||
[visualValues]
|
||||
[dirtyFields, visualValues]
|
||||
);
|
||||
|
||||
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
|
||||
|
||||
@@ -1025,9 +1025,12 @@
|
||||
"request_events_source": "Source",
|
||||
"request_events_auth_index": "Auth Index",
|
||||
"request_events_result": "Result",
|
||||
"thinking_intensity": "Thinking",
|
||||
"thinking_mode": "Thinking Mode",
|
||||
"thinking_level": "Thinking Level",
|
||||
"thinking_budget": "Thinking Budget",
|
||||
"time": "Latency",
|
||||
"avg_time": "Avg Latency",
|
||||
"total_time": "Total Latency",
|
||||
"latency_unit_hint": "Durations use backend field {{field}} and are interpreted as {{unit}} before formatting.",
|
||||
"duration_unit_d": "d",
|
||||
"duration_unit_h": "h",
|
||||
|
||||
@@ -1022,9 +1022,12 @@
|
||||
"request_events_source": "Источник",
|
||||
"request_events_auth_index": "Auth Index",
|
||||
"request_events_result": "Результат",
|
||||
"thinking_intensity": "Рассуждение",
|
||||
"thinking_mode": "Режим рассуждения",
|
||||
"thinking_level": "Уровень рассуждения",
|
||||
"thinking_budget": "Бюджет рассуждения",
|
||||
"time": "Задержка",
|
||||
"avg_time": "Средняя задержка",
|
||||
"total_time": "Суммарная задержка",
|
||||
"latency_unit_hint": "Длительность берётся из поля бэкенда {{field}} и интерпретируется как {{unit}} перед форматированием.",
|
||||
"duration_unit_d": "д",
|
||||
"duration_unit_h": "ч",
|
||||
|
||||
@@ -1025,9 +1025,12 @@
|
||||
"request_events_source": "来源",
|
||||
"request_events_auth_index": "认证索引",
|
||||
"request_events_result": "结果",
|
||||
"thinking_intensity": "思考强度",
|
||||
"thinking_mode": "思考模式",
|
||||
"thinking_level": "思考等级",
|
||||
"thinking_budget": "思考预算",
|
||||
"time": "延迟",
|
||||
"avg_time": "平均延迟",
|
||||
"total_time": "总延迟",
|
||||
"latency_unit_hint": "耗时取自后端字段 {{field}},按 {{unit}} 解释后再格式化显示。",
|
||||
"duration_unit_d": "天",
|
||||
"duration_unit_h": "时",
|
||||
|
||||
@@ -1051,9 +1051,12 @@
|
||||
"request_events_source": "來源",
|
||||
"request_events_auth_index": "驗證索引",
|
||||
"request_events_result": "結果",
|
||||
"thinking_intensity": "思考強度",
|
||||
"thinking_mode": "思考模式",
|
||||
"thinking_level": "思考等級",
|
||||
"thinking_budget": "思考預算",
|
||||
"time": "延遲",
|
||||
"avg_time": "平均延遲",
|
||||
"total_time": "總延遲",
|
||||
"latency_unit_hint": "耗時取自後端欄位 {{field}},按 {{unit}} 解讀後再格式化顯示。",
|
||||
"duration_unit_d": "天",
|
||||
"duration_unit_h": "時",
|
||||
|
||||
@@ -478,6 +478,9 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
if (form.priority !== undefined && Number.isFinite(form.priority)) {
|
||||
payload.priority = Math.trunc(form.priority);
|
||||
}
|
||||
if (initialData?.disabled !== undefined) {
|
||||
payload.disabled = initialData.disabled;
|
||||
}
|
||||
const resolvedTestModel = testModel.trim();
|
||||
if (resolvedTestModel) payload.testModel = resolvedTestModel;
|
||||
const models = entriesToModels(form.modelEntries);
|
||||
@@ -519,6 +522,7 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
editIndex,
|
||||
form,
|
||||
handleBack,
|
||||
initialData?.disabled,
|
||||
providers,
|
||||
setDraftBaseline,
|
||||
showNotification,
|
||||
|
||||
@@ -296,6 +296,38 @@ export function AiProvidersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const setOpenAIProviderEnabled = async (index: number, enabled: boolean) => {
|
||||
const current = openaiProviders[index];
|
||||
if (!current) return;
|
||||
|
||||
const switchingKey = `openai:${current.name}:${index}`;
|
||||
setConfigSwitchingKey(switchingKey);
|
||||
|
||||
const previousList = openaiProviders;
|
||||
const nextItem: OpenAIProviderConfig = { ...current, disabled: !enabled };
|
||||
const nextList = previousList.map((item, idx) => (idx === index ? nextItem : item));
|
||||
|
||||
setOpenaiProviders(nextList);
|
||||
updateConfigValue('openai-compatibility', nextList);
|
||||
clearCache('openai-compatibility');
|
||||
|
||||
try {
|
||||
await providersApi.updateOpenAIProviderDisabled(index, !enabled);
|
||||
showNotification(
|
||||
enabled ? t('notification.config_enabled') : t('notification.config_disabled'),
|
||||
'success'
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
setOpenaiProviders(previousList);
|
||||
updateConfigValue('openai-compatibility', previousList);
|
||||
clearCache('openai-compatibility');
|
||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||
} finally {
|
||||
setConfigSwitchingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteProviderEntry = async (type: 'codex' | 'claude', index: number) => {
|
||||
const source = type === 'codex' ? codexConfigs : claudeConfigs;
|
||||
const entry = source[index];
|
||||
@@ -471,6 +503,7 @@ export function AiProvidersPage() {
|
||||
onAdd={() => openEditor('/ai-providers/openai/new')}
|
||||
onEdit={(index) => openEditor(`/ai-providers/openai/${index}`)}
|
||||
onDelete={deleteOpenai}
|
||||
onToggle={(index, enabled) => void setOpenAIProviderEnabled(index, enabled)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -318,25 +318,28 @@
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
flex: 1 1 auto;
|
||||
min-height: 280px;
|
||||
max-height: calc(100vh - 320px);
|
||||
flex: 0 0 auto;
|
||||
min-height: 420px;
|
||||
max-height: none;
|
||||
height: calc(100vh - 520px);
|
||||
overflow: auto;
|
||||
resize: vertical;
|
||||
position: relative;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
@include tablet {
|
||||
min-height: 240px;
|
||||
max-height: calc(100vh - 300px);
|
||||
min-height: 360px;
|
||||
height: calc(100vh - 500px);
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
min-height: 360px;
|
||||
height: 420px;
|
||||
max-height: 480px;
|
||||
flex: 0 0 auto;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -783,11 +786,6 @@
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.logPanel {
|
||||
min-height: 200px;
|
||||
max-height: calc(100vh - 280px);
|
||||
}
|
||||
|
||||
.logRow {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
@@ -827,8 +825,9 @@
|
||||
}
|
||||
|
||||
.logPanel {
|
||||
min-height: 160px;
|
||||
max-height: calc(100vh - 220px);
|
||||
min-height: 320px;
|
||||
height: 320px;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.logRow {
|
||||
|
||||
@@ -78,11 +78,14 @@ export function LogsPage() {
|
||||
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useLocalStorage('logsPage.autoRefresh', false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
const [hideManagementLogs, setHideManagementLogs] = useState(true);
|
||||
const [showRawLogs, setShowRawLogs] = useState(false);
|
||||
const [hideManagementLogs, setHideManagementLogs] = useLocalStorage(
|
||||
'logsPage.hideManagementLogs',
|
||||
true
|
||||
);
|
||||
const [showRawLogs, setShowRawLogs] = useLocalStorage('logsPage.showRawLogs', false);
|
||||
const [structuredFiltersExpanded, setStructuredFiltersExpanded] = useLocalStorage(
|
||||
'logsPage.structuredFiltersExpanded',
|
||||
true
|
||||
|
||||
@@ -969,6 +969,31 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.requestEventsThinkingBadge,
|
||||
.requestEventsThinkingEmpty {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 48px;
|
||||
padding: 2px 8px;
|
||||
border-radius: $radius-full;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.requestEventsThinkingBadge {
|
||||
color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color) 28%, transparent);
|
||||
}
|
||||
|
||||
.requestEventsThinkingEmpty {
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.chartLineHeader {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
+14
-18
@@ -2,6 +2,7 @@ import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
Filler,
|
||||
} from 'chart.js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
@@ -33,19 +34,20 @@ import {
|
||||
ServiceHealthCard,
|
||||
useUsageData,
|
||||
useSparklines,
|
||||
useChartData
|
||||
useChartData,
|
||||
} from '@/components/usage';
|
||||
import {
|
||||
getModelNamesFromUsage,
|
||||
getApiStats,
|
||||
getModelStats,
|
||||
filterUsageByTimeRange,
|
||||
type UsageTimeRange
|
||||
type UsageTimeRange,
|
||||
} from '@/utils/usage';
|
||||
import styles from './UsagePage.module.scss';
|
||||
|
||||
// Register Chart.js components
|
||||
ChartJS.register(
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
@@ -70,7 +72,7 @@ const TIME_RANGE_OPTIONS: ReadonlyArray<{ value: UsageTimeRange; labelKey: strin
|
||||
const HOUR_WINDOW_BY_TIME_RANGE: Record<Exclude<UsageTimeRange, 'all'>, number> = {
|
||||
'7h': 7,
|
||||
'24h': 24,
|
||||
'7d': 7 * 24
|
||||
'7d': 7 * 24,
|
||||
};
|
||||
|
||||
const isUsageTimeRange = (value: unknown): value is UsageTimeRange =>
|
||||
@@ -143,7 +145,7 @@ export function UsagePage() {
|
||||
handleImportChange,
|
||||
importInputRef,
|
||||
exporting,
|
||||
importing
|
||||
importing,
|
||||
} = useUsageData();
|
||||
|
||||
useHeaderRefresh(loadUsage);
|
||||
@@ -176,13 +178,13 @@ export function UsagePage() {
|
||||
const openaiProvidersForUsage =
|
||||
openaiProviderState && openaiProviderState.source === openaiCompatibilityConfig
|
||||
? openaiProviderState.providers
|
||||
: openaiCompatibilityConfig ?? [];
|
||||
: (openaiCompatibilityConfig ?? []);
|
||||
|
||||
const timeRangeOptions = useMemo(
|
||||
() =>
|
||||
TIME_RANGE_OPTIONS.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: t(opt.labelKey)
|
||||
label: t(opt.labelKey),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
@@ -191,8 +193,7 @@ export function UsagePage() {
|
||||
() => (usage ? filterUsageByTimeRange(usage, timeRange) : null),
|
||||
[usage, timeRange]
|
||||
);
|
||||
const hourWindowHours =
|
||||
timeRange === 'all' ? undefined : HOUR_WINDOW_BY_TIME_RANGE[timeRange];
|
||||
const hourWindowHours = timeRange === 'all' ? undefined : HOUR_WINDOW_BY_TIME_RANGE[timeRange];
|
||||
|
||||
const handleChartLinesChange = useCallback((lines: string[]) => {
|
||||
setChartLines(normalizeChartLines(lines));
|
||||
@@ -223,13 +224,8 @@ export function UsagePage() {
|
||||
const nowMs = lastRefreshedAt?.getTime() ?? 0;
|
||||
|
||||
// Sparklines hook
|
||||
const {
|
||||
requestsSparkline,
|
||||
tokensSparkline,
|
||||
rpmSparkline,
|
||||
tpmSparkline,
|
||||
costSparkline
|
||||
} = useSparklines({ usage: filteredUsage, loading, nowMs });
|
||||
const { requestsSparkline, tokensSparkline, rpmSparkline, tpmSparkline, costSparkline } =
|
||||
useSparklines({ usage: filteredUsage, loading, nowMs });
|
||||
|
||||
// Chart data hook
|
||||
const {
|
||||
@@ -240,7 +236,7 @@ export function UsagePage() {
|
||||
requestsChartData,
|
||||
tokensChartData,
|
||||
requestsChartOptions,
|
||||
tokensChartOptions
|
||||
tokensChartOptions,
|
||||
} = useChartData({ usage: filteredUsage, chartLines, isDark, isMobile, hourWindowHours });
|
||||
|
||||
// Derived data
|
||||
@@ -334,7 +330,7 @@ export function UsagePage() {
|
||||
tokens: tokensSparkline,
|
||||
rpm: rpmSparkline,
|
||||
tpm: tpmSparkline,
|
||||
cost: costSparkline
|
||||
cost: costSparkline,
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import type { HttpMethod, ParsedLogLine, StatusGroup } from './logTypes';
|
||||
import { resolveStatusGroup } from './logTypes';
|
||||
|
||||
@@ -28,9 +29,15 @@ interface UseLogFiltersReturn {
|
||||
export function useLogFilters(options: UseLogFiltersOptions): UseLogFiltersReturn {
|
||||
const { parsedLines } = options;
|
||||
|
||||
const [methodFilters, setMethodFilters] = useState<HttpMethod[]>([]);
|
||||
const [statusFilters, setStatusFilters] = useState<StatusGroup[]>([]);
|
||||
const [pathFilters, setPathFilters] = useState<string[]>([]);
|
||||
const [methodFilters, setMethodFilters] = useLocalStorage<HttpMethod[]>(
|
||||
'logsPage.methodFilters',
|
||||
[]
|
||||
);
|
||||
const [statusFilters, setStatusFilters] = useLocalStorage<StatusGroup[]>(
|
||||
'logsPage.statusFilters',
|
||||
[]
|
||||
);
|
||||
const [pathFilters, setPathFilters] = useLocalStorage<string[]>('logsPage.pathFilters', []);
|
||||
|
||||
const methodFilterSet = useMemo(() => new Set(methodFilters), [methodFilters]);
|
||||
const statusFilterSet = useMemo(() => new Set(statusFilters), [statusFilters]);
|
||||
@@ -70,14 +77,15 @@ export function useLogFilters(options: UseLogFiltersOptions): UseLogFiltersRetur
|
||||
}, [parsedLines]);
|
||||
|
||||
useEffect(() => {
|
||||
if (parsedLines.length === 0) return;
|
||||
|
||||
const validPathSet = new Set(pathOptions.map((item) => item.path));
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPathFilters((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
const next = prev.filter((path) => validPathSet.has(path));
|
||||
return next.length === prev.length ? prev : next;
|
||||
});
|
||||
}, [pathOptions]);
|
||||
}, [parsedLines.length, pathOptions, setPathFilters]);
|
||||
|
||||
const toggleMethodFilter = (method: HttpMethod) => {
|
||||
setMethodFilters((prev) =>
|
||||
|
||||
@@ -145,6 +145,7 @@ const serializeOpenAIProvider = (provider: OpenAIProviderConfig) => {
|
||||
: []
|
||||
};
|
||||
if (provider.prefix?.trim()) payload.prefix = provider.prefix.trim();
|
||||
if (provider.disabled !== undefined) payload.disabled = provider.disabled;
|
||||
const headers = serializeHeaders(provider.headers);
|
||||
if (headers) payload.headers = headers;
|
||||
const models = serializeModelAliases(provider.models);
|
||||
@@ -227,6 +228,9 @@ export const providersApi = {
|
||||
updateOpenAIProvider: (index: number, value: OpenAIProviderConfig) =>
|
||||
apiClient.patch('/openai-compatibility', { index, value: serializeOpenAIProvider(value) }),
|
||||
|
||||
updateOpenAIProviderDisabled: (index: number, disabled: boolean) =>
|
||||
apiClient.patch('/openai-compatibility', { index, value: { disabled } }),
|
||||
|
||||
deleteOpenAIProvider: (name: string) =>
|
||||
apiClient.delete(`/openai-compatibility?name=${encodeURIComponent(name)}`)
|
||||
};
|
||||
|
||||
@@ -254,6 +254,8 @@ const normalizeOpenAIProvider = (provider: unknown): OpenAIProviderConfig | null
|
||||
apiKeyEntries
|
||||
};
|
||||
|
||||
const disabled = normalizeBoolean(provider.disabled ?? provider['disabled']);
|
||||
if (disabled !== undefined) result.disabled = disabled;
|
||||
const prefix = normalizePrefix(provider.prefix ?? provider['prefix']);
|
||||
if (prefix) result.prefix = prefix;
|
||||
if (headers) result.headers = headers;
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: $z-dropdown - 2;
|
||||
height: 188px;
|
||||
height: 141px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
@@ -386,9 +386,7 @@
|
||||
color-mix(in srgb, var(--bg-primary) 72%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||
border-radius: 18px;
|
||||
box-shadow:
|
||||
0 26px 58px rgb(0 0 0 / 0.22),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.04);
|
||||
box-shadow: none;
|
||||
--glass-blur: 18px;
|
||||
backdrop-filter: var(--glass-backdrop-filter);
|
||||
-webkit-backdrop-filter: var(--glass-backdrop-filter);
|
||||
@@ -437,7 +435,7 @@
|
||||
height: 34px;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 18px rgb(0 0 0 / 0.18);
|
||||
box-shadow: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -592,7 +590,7 @@
|
||||
}
|
||||
|
||||
.top-gradient-blur {
|
||||
height: 152px;
|
||||
height: 114px;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
@@ -677,9 +675,7 @@
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.045), rgb(255 255 255 / 0)),
|
||||
color-mix(in srgb, var(--bg-primary) 94%, #0f0e0c);
|
||||
border-color: color-mix(in srgb, var(--border-color) 88%, transparent);
|
||||
box-shadow:
|
||||
0 28px 62px rgb(0 0 0 / 0.34),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.04);
|
||||
box-shadow: none;
|
||||
|
||||
&.open {
|
||||
transform: translateX(0);
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface OpenAIProviderConfig {
|
||||
prefix?: string;
|
||||
baseUrl: string;
|
||||
apiKeyEntries: ApiKeyEntry[];
|
||||
disabled?: boolean;
|
||||
headers?: Record<string, string>;
|
||||
models?: ModelAlias[];
|
||||
priority?: number;
|
||||
|
||||
@@ -117,7 +117,7 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
|
||||
maxRetryInterval: '',
|
||||
quotaSwitchProject: true,
|
||||
quotaSwitchPreviewModel: true,
|
||||
quotaAntigravityCredits: true,
|
||||
quotaAntigravityCredits: false,
|
||||
routingStrategy: 'round-robin',
|
||||
routingSessionAffinity: false,
|
||||
routingSessionAffinityTTL: '',
|
||||
|
||||
+41
-12
@@ -39,6 +39,13 @@ export interface TokenBreakdown {
|
||||
reasoningTokens: number;
|
||||
}
|
||||
|
||||
export interface UsageThinking {
|
||||
intensity?: string;
|
||||
mode?: string;
|
||||
level?: string;
|
||||
budget?: number;
|
||||
}
|
||||
|
||||
export interface RateStats {
|
||||
rpm: number;
|
||||
tpm: number;
|
||||
@@ -66,6 +73,7 @@ export interface UsageDetail {
|
||||
cache_tokens?: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
thinking?: UsageThinking | null;
|
||||
failed: boolean;
|
||||
__modelName?: string;
|
||||
__timestampMs?: number;
|
||||
@@ -99,7 +107,6 @@ export interface ModelStatsSummary {
|
||||
tokens: number;
|
||||
cost: number;
|
||||
averageLatencyMs: number | null;
|
||||
totalLatencyMs: number | null;
|
||||
latencySampleCount: number;
|
||||
}
|
||||
|
||||
@@ -123,6 +130,29 @@ const getApisRecord = (usageData: unknown): Record<string, unknown> | null => {
|
||||
return isRecord(apisRaw) ? apisRaw : null;
|
||||
};
|
||||
|
||||
const normalizeUsageThinking = (value: unknown): UsageThinking | null => {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const intensity = typeof value.intensity === 'string' ? value.intensity.trim() : '';
|
||||
const mode = typeof value.mode === 'string' ? value.mode.trim() : '';
|
||||
const level = typeof value.level === 'string' ? value.level.trim() : '';
|
||||
const budget =
|
||||
typeof value.budget === 'number' && Number.isFinite(value.budget) ? value.budget : undefined;
|
||||
|
||||
if (!intensity && !mode && !level && budget === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...(intensity ? { intensity } : {}),
|
||||
...(mode ? { mode } : {}),
|
||||
...(level ? { level } : {}),
|
||||
...(budget !== undefined ? { budget } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
interface UsageSummary {
|
||||
totalRequests: number;
|
||||
successCount: number;
|
||||
@@ -552,13 +582,13 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] {
|
||||
details.push({
|
||||
timestamp,
|
||||
source: normalizeSource(detailRaw.source),
|
||||
auth_index:
|
||||
(detailRaw?.auth_index ??
|
||||
detailRaw?.authIndex ??
|
||||
detailRaw?.AuthIndex ??
|
||||
null) as UsageDetail['auth_index'],
|
||||
auth_index: (detailRaw?.auth_index ??
|
||||
detailRaw?.authIndex ??
|
||||
detailRaw?.AuthIndex ??
|
||||
null) as UsageDetail['auth_index'],
|
||||
latency_ms: latencyMs ?? undefined,
|
||||
tokens: tokensRaw as unknown as UsageDetail['tokens'],
|
||||
thinking: normalizeUsageThinking(detailRaw.thinking),
|
||||
failed: detailRaw.failed === true,
|
||||
__modelName: modelName,
|
||||
__timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
|
||||
@@ -629,13 +659,13 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail
|
||||
details.push({
|
||||
timestamp,
|
||||
source: normalizeSource(detailRaw.source),
|
||||
auth_index:
|
||||
(detailRaw?.auth_index ??
|
||||
detailRaw?.authIndex ??
|
||||
detailRaw?.AuthIndex ??
|
||||
null) as UsageDetail['auth_index'],
|
||||
auth_index: (detailRaw?.auth_index ??
|
||||
detailRaw?.authIndex ??
|
||||
detailRaw?.AuthIndex ??
|
||||
null) as UsageDetail['auth_index'],
|
||||
latency_ms: latencyMs ?? undefined,
|
||||
tokens: tokensRaw as unknown as UsageDetail['tokens'],
|
||||
thinking: normalizeUsageThinking(detailRaw.thinking),
|
||||
failed: detailRaw.failed === true,
|
||||
__modelName: modelName,
|
||||
__endpoint: endpoint,
|
||||
@@ -1064,7 +1094,6 @@ export function getModelStats(
|
||||
tokens: stats.tokens,
|
||||
cost: stats.cost,
|
||||
averageLatencyMs: latencyStats.averageMs,
|
||||
totalLatencyMs: latencyStats.totalMs,
|
||||
latencySampleCount: latencyStats.sampleCount,
|
||||
};
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user