Compare commits

...

27 Commits

51 changed files with 3502 additions and 1509 deletions
+75 -7
View File
@@ -13,8 +13,8 @@
"@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2",
"chart.js": "^4.5.1",
"gsap": "^3.14.2",
"i18next": "^25.7.1",
"motion": "^12.34.3",
"react": "^19.2.1",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.1",
@@ -3113,6 +3113,33 @@
"node": ">= 6"
}
},
"node_modules/framer-motion": {
"version": "12.34.3",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz",
"integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.34.3",
"motion-utils": "^12.29.2",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3210,12 +3237,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gsap": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz",
"integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==",
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
},
"node_modules/has-flag": {
"version": "4.0.0",
"dev": true,
@@ -3524,6 +3545,47 @@
"node": "*"
}
},
"node_modules/motion": {
"version": "12.34.3",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.34.3.tgz",
"integrity": "sha512-xZIkBGO7v/Uvm+EyaqYd+9IpXu0sZqLywVlGdCFrrMiaO9JI4Kx51mO9KlHSWwll+gZUVY5OJsWgYI5FywJ/tw==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.34.3",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.34.3",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz",
"integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.29.2"
}
},
"node_modules/motion-utils": {
"version": "12.29.2",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz",
"integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"dev": true,
@@ -4025,6 +4087,12 @@
"node": ">=8.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"dev": true,
+1 -1
View File
@@ -17,8 +17,8 @@
"@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2",
"chart.js": "^4.5.1",
"gsap": "^3.14.2",
"i18next": "^25.7.1",
"motion": "^12.34.3",
"react": "^19.2.1",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.1",
+14 -27
View File
@@ -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>
+139 -121
View File
@@ -1,12 +1,7 @@
import {
ReactNode,
useCallback,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react';
import { useLocation, type Location } from 'react-router-dom';
import gsap from 'gsap';
import { animate } from 'motion/mini';
import type { AnimationPlaybackControlsWithThen } from 'motion-dom';
import { PageTransitionLayerContext, type LayerStatus } from './PageTransitionLayer';
import './PageTransition.scss';
@@ -25,6 +20,20 @@ const IOS_EXIT_TO_X_PERCENT_FORWARD = -30;
const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100;
const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30;
const IOS_EXIT_DIM_OPACITY = 0.72;
const IOS_SHADOW_VALUE = '-14px 0 24px rgba(0, 0, 0, 0.16)';
const easePower2Out = (progress: number) => 1 - (1 - progress) ** 3;
const easeCircOut = (progress: number) => Math.sqrt(1 - (progress - 1) ** 2);
const buildVerticalTransform = (y: number) => `translate3d(0px, ${y}px, 0px)`;
const buildIosTransform = (xPercent: number, y: number) => `translate3d(${xPercent}%, ${y}px, 0px)`;
const clearLayerStyles = (element: HTMLElement | null) => {
if (!element) return;
element.style.removeProperty('transform');
element.style.removeProperty('opacity');
element.style.removeProperty('box-shadow');
};
type Layer = {
key: string;
@@ -150,16 +159,14 @@ export function PageTransition({
const targetIndex = prev.findIndex((layer) => layer.key === location.key);
if (targetIndex !== -1) {
const targetStack: Layer[] = prev
.slice(0, targetIndex + 1)
.map((layer, idx): Layer => {
const isTarget = idx === targetIndex;
return {
...layer,
location: isTarget ? location : layer.location,
status: isTarget ? 'current' : 'stacked',
};
});
const targetStack: Layer[] = prev.slice(0, targetIndex + 1).map((layer, idx): Layer => {
const isTarget = idx === targetIndex;
return {
...layer,
location: isTarget ? location : layer.location,
status: isTarget ? 'current' : 'stacked',
};
});
if (shouldSkipExitLayer) {
nextLayersRef.current = targetStack;
@@ -194,7 +201,7 @@ export function PageTransition({
layers,
]);
// Run GSAP animation when animating starts
// Run Motion animation when animating starts
useLayoutEffect(() => {
if (!isAnimating) return;
@@ -204,10 +211,8 @@ export function PageTransition({
const exitingLayerEl = exitingLayerRef.current;
const transitionVariant = transitionVariantRef.current;
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
if (exitingLayerEl) {
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
}
clearLayerStyles(currentLayerEl);
clearLayerStyles(exitingLayerEl);
const scrollContainer = resolveScrollContainer();
const exitScrollOffset = exitScrollOffsetRef.current;
@@ -221,22 +226,21 @@ export function PageTransition({
const enterFromY = isForward ? VERTICAL_TRAVEL_DISTANCE : -VERTICAL_TRAVEL_DISTANCE;
const exitToY = isForward ? -VERTICAL_TRAVEL_DISTANCE : VERTICAL_TRAVEL_DISTANCE;
const exitBaseY = enterScrollOffset - exitScrollOffset;
const activeAnimations: AnimationPlaybackControlsWithThen[] = [];
let cancelled = false;
let completed = false;
const completeTransition = () => {
if (completed) return;
completed = true;
const tl = gsap.timeline({
onComplete: () => {
const nextLayers = nextLayersRef.current;
nextLayersRef.current = null;
setLayers((prev) => nextLayers ?? prev.filter((layer) => layer.status !== 'exiting'));
setIsAnimating(false);
const nextLayers = nextLayersRef.current;
nextLayersRef.current = null;
setLayers((prev) => nextLayers ?? prev.filter((layer) => layer.status !== 'exiting'));
setIsAnimating(false);
if (currentLayerEl) {
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
}
if (exitingLayerEl) {
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
}
},
});
clearLayerStyles(currentLayerEl);
clearLayerStyles(exitingLayerEl);
};
if (transitionVariant === 'ios') {
const exitToXPercent = isForward
@@ -247,90 +251,104 @@ export function PageTransition({
: IOS_ENTER_FROM_X_PERCENT_BACKWARD;
if (exitingLayerEl) {
gsap.set(exitingLayerEl, {
y: exitBaseY,
xPercent: 0,
opacity: 1,
});
exitingLayerEl.style.transform = buildIosTransform(0, exitBaseY);
exitingLayerEl.style.opacity = '1';
}
gsap.set(currentLayerEl, {
xPercent: enterFromXPercent,
opacity: 1,
});
const shadowValue = '-14px 0 24px rgba(0, 0, 0, 0.16)';
currentLayerEl.style.transform = buildIosTransform(enterFromXPercent, 0);
currentLayerEl.style.opacity = '1';
const topLayerEl = isForward ? currentLayerEl : exitingLayerEl;
if (topLayerEl) {
gsap.set(topLayerEl, { boxShadow: shadowValue });
topLayerEl.style.boxShadow = IOS_SHADOW_VALUE;
}
if (exitingLayerEl) {
tl.to(
exitingLayerEl,
{
xPercent: exitToXPercent,
opacity: isForward ? IOS_EXIT_DIM_OPACITY : 1,
duration: IOS_TRANSITION_DURATION,
ease: 'power2.out',
force3D: true,
},
0
activeAnimations.push(
animate(
exitingLayerEl,
{
transform: [
buildIosTransform(0, exitBaseY),
buildIosTransform(exitToXPercent, exitBaseY),
],
opacity: [1, isForward ? IOS_EXIT_DIM_OPACITY : 1],
},
{
duration: IOS_TRANSITION_DURATION,
ease: easePower2Out,
}
)
);
}
tl.to(
currentLayerEl,
{
xPercent: 0,
opacity: 1,
duration: IOS_TRANSITION_DURATION,
ease: 'power2.out',
force3D: true,
},
0
activeAnimations.push(
animate(
currentLayerEl,
{
transform: [buildIosTransform(enterFromXPercent, 0), buildIosTransform(0, 0)],
opacity: [1, 1],
},
{
duration: IOS_TRANSITION_DURATION,
ease: easePower2Out,
}
)
);
} else {
// Exit animation: fade out with slight movement (runs simultaneously)
if (exitingLayerEl) {
gsap.set(exitingLayerEl, { y: exitBaseY });
tl.to(
exitingLayerEl,
{
y: exitBaseY + exitToY,
opacity: 0,
duration: VERTICAL_TRANSITION_DURATION,
ease: 'circ.out',
force3D: true,
},
0
exitingLayerEl.style.transform = buildVerticalTransform(exitBaseY);
activeAnimations.push(
animate(
exitingLayerEl,
{
transform: [
buildVerticalTransform(exitBaseY),
buildVerticalTransform(exitBaseY + exitToY),
],
opacity: [1, 0],
},
{
duration: VERTICAL_TRANSITION_DURATION,
ease: easeCircOut,
}
)
);
}
// Enter animation: fade in with slight movement (runs simultaneously)
tl.fromTo(
currentLayerEl,
{ y: enterFromY, opacity: 0 },
{
y: 0,
opacity: 1,
duration: VERTICAL_TRANSITION_DURATION,
ease: 'circ.out',
force3D: true,
onComplete: () => {
if (currentLayerEl) {
gsap.set(currentLayerEl, { clearProps: 'transform,opacity' });
}
currentLayerEl.style.transform = buildVerticalTransform(enterFromY);
currentLayerEl.style.opacity = '0';
activeAnimations.push(
animate(
currentLayerEl,
{
transform: [buildVerticalTransform(enterFromY), buildVerticalTransform(0)],
opacity: [0, 1],
},
},
0
{
duration: VERTICAL_TRANSITION_DURATION,
ease: easeCircOut,
}
)
);
}
if (!activeAnimations.length) {
completeTransition();
} else {
void Promise.all(
activeAnimations.map((animation) => animation.finished.catch(() => undefined))
).then(() => {
if (cancelled) return;
completeTransition();
});
}
return () => {
tl.kill();
gsap.killTweensOf([currentLayerEl, exitingLayerEl]);
cancelled = true;
activeAnimations.forEach((animation) => animation.stop());
};
}, [isAnimating, resolveScrollContainer]);
@@ -348,30 +366,30 @@ export function PageTransition({
return layers.map((layer, index) => {
const shouldKeepStacked = layer.status === 'stacked' && index === keepStackedIndex;
return (
<div
key={layer.key}
className={[
'page-transition__layer',
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
shouldKeepStacked ? 'page-transition__layer--stacked-keep' : '',
]
.filter(Boolean)
.join(' ')}
aria-hidden={layer.status !== 'current'}
inert={layer.status !== 'current'}
ref={
layer.status === 'exiting'
? exitingLayerRef
: layer.status === 'current'
? currentLayerRef
: undefined
}
>
<PageTransitionLayerContext.Provider value={{ status: layer.status }}>
{render(layer.location)}
</PageTransitionLayerContext.Provider>
</div>
<div
key={layer.key}
className={[
'page-transition__layer',
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
shouldKeepStacked ? 'page-transition__layer--stacked-keep' : '',
]
.filter(Boolean)
.join(' ')}
aria-hidden={layer.status !== 'current'}
inert={layer.status !== 'current'}
ref={
layer.status === 'exiting'
? exitingLayerRef
: layer.status === 'current'
? currentLayerRef
: undefined
}
>
<PageTransitionLayerContext.Provider value={{ status: layer.status }}>
{render(layer.location)}
</PageTransitionLayerContext.Provider>
</div>
);
});
})()}
+225 -102
View File
@@ -1,9 +1,20 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
$diff-mono: 'Consolas', 'Monaco', 'Menlo', 'SF Mono', monospace;
$diff-font-size: 12px;
$diff-line-height: 20px;
$diff-gutter-width: 50px;
$diff-prefix-width: 20px;
// GitHub-inspired diff colors (theme-adaptive via color-mix)
$diff-add-color: #3fb950;
$diff-del-color: #f85149;
$diff-hunk-color: #388bfd;
.diffModal {
:global(.modal-body) {
padding: $spacing-md $spacing-lg;
padding: 0;
max-height: none;
overflow: hidden;
}
@@ -25,152 +36,264 @@
font-size: 14px;
display: grid;
place-items: center;
margin: $spacing-md;
}
.diffList {
// ── File container ──────────────────────────────────────
.diffContainer {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: $spacing-sm;
overflow: auto;
padding-right: 2px;
}
.diffCard {
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-secondary);
overflow: hidden;
}
.diffCardHeader {
padding: 8px 10px;
border-bottom: 1px dashed var(--border-color);
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
background: color-mix(in srgb, var(--bg-primary) 92%, transparent);
}
// ── File header ─────────────────────────────────────────
.diffColumns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-sm;
padding: $spacing-sm;
}
.diffColumn {
min-width: 0;
border: 1px solid var(--border-color);
border-radius: $radius-sm;
overflow: hidden;
background: var(--bg-primary);
}
.diffColumnHeader {
.fileHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $spacing-sm;
padding: 8px 10px;
border-bottom: 1px solid var(--border-color);
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-secondary);
}
.lineMeta {
display: inline-flex;
align-items: center;
gap: 8px;
gap: $spacing-sm;
padding: 10px $spacing-md;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.lineRange {
font-size: 11px;
color: var(--text-secondary);
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
}
.contextRange {
font-size: 11px;
.fileIcon {
flex-shrink: 0;
color: var(--text-tertiary);
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
}
.codeList {
.fileName {
font-size: 13px;
font-weight: 600;
font-family: $diff-mono;
color: var(--text-primary);
}
.fileStats {
margin-left: auto;
display: flex;
align-items: center;
gap: $spacing-sm;
font-size: 12px;
font-weight: 700;
font-family: $diff-mono;
}
.statAdditions {
color: $diff-add-color;
}
.statDeletions {
color: $diff-del-color;
}
.statBar {
display: inline-flex;
gap: 2px;
margin-left: 2px;
}
.statBlock {
width: 8px;
height: 8px;
border-radius: 2px;
}
.statBlockAdd {
background: $diff-add-color;
}
.statBlockDel {
background: $diff-del-color;
}
// ── Diff body (scrollable) ──────────────────────────────
.diffBody {
flex: 1;
min-height: 0;
overflow: auto;
max-height: 280px;
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
font-family: $diff-mono;
font-size: $diff-font-size;
line-height: $diff-line-height;
}
.codeLine {
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
align-items: start;
border-top: 1px solid color-mix(in srgb, var(--border-color) 55%, transparent);
// ── Hunk ────────────────────────────────────────────────
.hunk + .hunk {
border-top: 1px solid var(--border-color);
}
.codeLine:first-child {
border-top: none;
.hunkHeader {
display: flex;
align-items: center;
background: color-mix(in srgb, $diff-hunk-color 8%, var(--bg-primary));
border-bottom: 1px solid color-mix(in srgb, $diff-hunk-color 12%, var(--border-color));
color: color-mix(in srgb, $diff-hunk-color 75%, var(--text-secondary));
min-height: $diff-line-height;
}
.codeLineChanged {
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
.hunkGutter {
width: $diff-gutter-width;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
border-right: 1px solid color-mix(in srgb, $diff-hunk-color 12%, var(--border-color));
}
.codeLineNumber {
padding: 7px 10px 7px 8px;
.hunkExpandIcon {
color: color-mix(in srgb, $diff-hunk-color 70%, var(--text-tertiary));
opacity: 0.7;
}
.hunkText {
padding: 4px $spacing-sm 4px ($diff-prefix-width + $spacing-sm);
font-size: $diff-font-size;
white-space: nowrap;
}
// ── Diff line ───────────────────────────────────────────
.diffLine {
display: flex;
min-height: $diff-line-height;
}
// ── Line number gutters ─────────────────────────────────
.lineNum {
width: $diff-gutter-width;
flex-shrink: 0;
padding: 0 8px;
text-align: right;
font-size: 11px;
color: var(--text-tertiary);
border-right: 1px solid color-mix(in srgb, var(--border-color) 55%, transparent);
background: color-mix(in srgb, var(--bg-secondary) 90%, transparent);
font-variant-numeric: tabular-nums;
user-select: none;
font-variant-numeric: tabular-nums;
border-right: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
box-sizing: border-box;
}
.codeLineText {
padding: 7px 10px;
font-size: 12px;
line-height: 1.45;
color: var(--text-primary);
.lineNumEmpty {
color: transparent;
}
// ── Prefix column (+/-/space) ───────────────────────────
.linePrefix {
width: $diff-prefix-width;
flex-shrink: 0;
text-align: center;
user-select: none;
font-weight: 700;
}
// ── Code text ───────────────────────────────────────────
.lineText {
flex: 1;
min-width: 0;
padding-right: 12px;
white-space: pre-wrap;
word-break: break-word;
display: block;
box-sizing: border-box;
}
// ── Context lines ───────────────────────────────────────
.context {
background: var(--bg-primary);
.linePrefix {
color: var(--text-tertiary);
}
.lineText {
color: var(--text-primary);
}
}
// ── Deletion lines ──────────────────────────────────────
.deletion {
background: color-mix(in srgb, $diff-del-color 8%, var(--bg-primary));
.lineNum {
background: color-mix(in srgb, $diff-del-color 12%, var(--bg-primary));
border-right-color: color-mix(in srgb, $diff-del-color 18%, var(--border-color));
color: color-mix(in srgb, $diff-del-color 60%, var(--text-tertiary));
}
.linePrefix {
color: $diff-del-color;
}
.lineText {
color: var(--text-primary);
}
}
// ── Addition lines ──────────────────────────────────────
.addition {
background: color-mix(in srgb, $diff-add-color 8%, var(--bg-primary));
.lineNum {
background: color-mix(in srgb, $diff-add-color 12%, var(--bg-primary));
border-right-color: color-mix(in srgb, $diff-add-color 18%, var(--border-color));
color: color-mix(in srgb, $diff-add-color 60%, var(--text-tertiary));
}
.linePrefix {
color: $diff-add-color;
}
.lineText {
color: var(--text-primary);
}
}
// ── Mobile responsive ───────────────────────────────────
@include mobile {
.content {
height: 65vh;
min-height: 360px;
}
.diffColumns {
grid-template-columns: 1fr;
}
.lineMeta {
flex-direction: column;
align-items: flex-end;
gap: 1px;
}
.codeLine {
grid-template-columns: 44px minmax(0, 1fr);
}
.codeLineNumber {
padding: 6px 6px 6px 4px;
.lineNum {
width: 36px;
padding: 0 4px;
font-size: 10px;
}
.codeLineText {
padding: 6px 8px;
.linePrefix {
width: 16px;
font-size: 11px;
}
.hunkGutter {
width: 36px;
}
.hunkText {
padding-left: 20px;
}
.diffBody {
font-size: 11px;
line-height: 18px;
}
.fileName {
font-size: 12px;
}
.fileStats {
font-size: 11px;
line-height: 1.4;
}
}
+236 -123
View File
@@ -15,66 +15,189 @@ type DiffModalProps = {
loading?: boolean;
};
type DiffChunkCard = {
id: string;
current: DiffSide;
modified: DiffSide;
};
type UnifiedLineType = 'context' | 'addition' | 'deletion';
type LineRange = {
start: number;
end: number;
};
type DiffSideLine = {
lineNumber: number;
type UnifiedLine = {
type: UnifiedLineType;
oldNum: number | null;
newNum: number | null;
text: string;
changed: boolean;
};
type DiffSide = {
changedRangeLabel: string;
contextRangeLabel: string;
lines: DiffSideLine[];
type Hunk = {
oldStart: number;
oldCount: number;
newStart: number;
newCount: number;
lines: UnifiedLine[];
};
const DIFF_CONTEXT_LINES = 2;
type DiffResult = {
hunks: Hunk[];
additions: number;
deletions: number;
};
const DIFF_CONTEXT_LINES = 3;
const clampPos = (doc: Text, pos: number) => Math.max(0, Math.min(pos, doc.length));
const getLineRangeLabel = (range: LineRange): string => {
return range.start === range.end ? String(range.start) : `${range.start}-${range.end}`;
};
function computeUnifiedDiff(original: string, modified: string): DiffResult {
const oldDoc = Text.of(original.split('\n'));
const newDoc = Text.of(modified.split('\n'));
const chunks = Chunk.build(oldDoc, newDoc);
const getChangedLineRange = (doc: Text, from: number, to: number): LineRange => {
const start = clampPos(doc, from);
const end = clampPos(doc, to);
if (start === end) {
const linePos = Math.min(start, doc.length);
const anchorLine = doc.lineAt(linePos).number;
return { start: anchorLine, end: anchorLine };
}
const startLine = doc.lineAt(start).number;
const endLine = doc.lineAt(Math.max(start, end - 1)).number;
return { start: startLine, end: endLine };
};
let totalAdditions = 0;
let totalDeletions = 0;
const expandContextRange = (doc: Text, range: LineRange): LineRange => ({
start: Math.max(1, range.start - DIFF_CONTEXT_LINES),
end: Math.min(doc.lines, range.end + DIFF_CONTEXT_LINES)
});
const hunks: Hunk[] = chunks.map((chunk) => {
const lines: UnifiedLine[] = [];
const buildSideLines = (doc: Text, contextRange: LineRange, changedRange: LineRange): DiffSideLine[] => {
const lines: DiffSideLine[] = [];
for (let lineNumber = contextRange.start; lineNumber <= contextRange.end; lineNumber += 1) {
lines.push({
lineNumber,
text: doc.line(lineNumber).text,
changed: lineNumber >= changedRange.start && lineNumber <= changedRange.end
});
}
return lines;
};
const hasDel = chunk.fromA < chunk.toA;
const hasAdd = chunk.fromB < chunk.toB;
// Collect deleted lines from old doc
const delLines: { num: number; text: string }[] = [];
if (hasDel) {
const startLine = oldDoc.lineAt(chunk.fromA).number;
const endLine = oldDoc.lineAt(chunk.toA - 1).number;
for (let i = startLine; i <= endLine; i++) {
delLines.push({ num: i, text: oldDoc.line(i).text });
}
}
// Collect added lines from new doc
const addLines: { num: number; text: string }[] = [];
if (hasAdd) {
const startLine = newDoc.lineAt(chunk.fromB).number;
const endLine = newDoc.lineAt(chunk.toB - 1).number;
for (let i = startLine; i <= endLine; i++) {
addLines.push({ num: i, text: newDoc.line(i).text });
}
}
totalDeletions += delLines.length;
totalAdditions += addLines.length;
// Compute context boundaries
let ctxBeforeEndOld: number;
let ctxAfterStartOld: number;
let ctxBeforeEndNew: number;
let ctxAfterStartNew: number;
if (hasDel) {
ctxBeforeEndOld = delLines[0].num - 1;
ctxAfterStartOld = delLines[delLines.length - 1].num + 1;
} else {
const anchorPos = clampPos(oldDoc, chunk.fromA);
const lineInfo = oldDoc.lineAt(anchorPos);
if (chunk.fromA === lineInfo.from) {
ctxBeforeEndOld = lineInfo.number - 1;
ctxAfterStartOld = lineInfo.number;
} else {
ctxBeforeEndOld = lineInfo.number;
ctxAfterStartOld = lineInfo.number + 1;
}
}
if (hasAdd) {
ctxBeforeEndNew = addLines[0].num - 1;
ctxAfterStartNew = addLines[addLines.length - 1].num + 1;
} else {
const anchorPos = clampPos(newDoc, chunk.fromB);
const lineInfo = newDoc.lineAt(anchorPos);
if (chunk.fromB === lineInfo.from) {
ctxBeforeEndNew = lineInfo.number - 1;
ctxAfterStartNew = lineInfo.number;
} else {
ctxBeforeEndNew = lineInfo.number;
ctxAfterStartNew = lineInfo.number + 1;
}
}
// Context before
const ctxBeforeCount = Math.min(
DIFF_CONTEXT_LINES,
Math.max(0, ctxBeforeEndOld),
Math.max(0, ctxBeforeEndNew)
);
for (let i = ctxBeforeCount; i > 0; i--) {
const oldNum = ctxBeforeEndOld - i + 1;
const newNum = ctxBeforeEndNew - i + 1;
if (oldNum >= 1 && newNum >= 1 && oldNum <= oldDoc.lines) {
lines.push({
type: 'context',
oldNum,
newNum,
text: oldDoc.line(oldNum).text
});
}
}
// Deletions
for (const del of delLines) {
lines.push({ type: 'deletion', oldNum: del.num, newNum: null, text: del.text });
}
// Additions
for (const add of addLines) {
lines.push({ type: 'addition', oldNum: null, newNum: add.num, text: add.text });
}
// Context after
const ctxAfterCountOld = Math.max(
0,
Math.min(DIFF_CONTEXT_LINES, oldDoc.lines - ctxAfterStartOld + 1)
);
const ctxAfterCountNew = Math.max(
0,
Math.min(DIFF_CONTEXT_LINES, newDoc.lines - ctxAfterStartNew + 1)
);
const ctxAfterCount = Math.min(ctxAfterCountOld, ctxAfterCountNew);
for (let i = 0; i < ctxAfterCount; i++) {
const oldNum = ctxAfterStartOld + i;
const newNum = ctxAfterStartNew + i;
if (oldNum >= 1 && oldNum <= oldDoc.lines && newNum >= 1 && newNum <= newDoc.lines) {
lines.push({
type: 'context',
oldNum,
newNum,
text: oldDoc.line(oldNum).text
});
}
}
// Compute hunk header values
const firstOld = lines.find((l) => l.oldNum !== null)?.oldNum ?? 1;
const firstNew = lines.find((l) => l.newNum !== null)?.newNum ?? 1;
const oldCount = lines.filter((l) => l.type !== 'addition').length;
const newCount = lines.filter((l) => l.type !== 'deletion').length;
return { oldStart: firstOld, oldCount, newStart: firstNew, newCount, lines };
});
return { hunks, additions: totalAdditions, deletions: totalDeletions };
}
const STAT_BLOCKS = 5;
function StatBar({ additions, deletions }: { additions: number; deletions: number }) {
const total = additions + deletions;
if (total === 0) return null;
const addBlocks = Math.round((additions / total) * STAT_BLOCKS);
return (
<span className={styles.statBar}>
{Array.from({ length: STAT_BLOCKS }, (_, i) => (
<span
key={i}
className={`${styles.statBlock} ${i < addBlocks ? styles.statBlockAdd : styles.statBlockDel}`}
/>
))}
</span>
);
}
export function DiffModal({
open,
@@ -86,32 +209,10 @@ export function DiffModal({
}: DiffModalProps) {
const { t } = useTranslation();
const diffCards = useMemo<DiffChunkCard[]>(() => {
const currentDoc = Text.of(original.split('\n'));
const modifiedDoc = Text.of(modified.split('\n'));
const chunks = Chunk.build(currentDoc, modifiedDoc);
return chunks.map((chunk, index) => {
const currentChangedRange = getChangedLineRange(currentDoc, chunk.fromA, chunk.toA);
const modifiedChangedRange = getChangedLineRange(modifiedDoc, chunk.fromB, chunk.toB);
const currentContextRange = expandContextRange(currentDoc, currentChangedRange);
const modifiedContextRange = expandContextRange(modifiedDoc, modifiedChangedRange);
return {
id: `${index}-${chunk.fromA}-${chunk.toA}-${chunk.fromB}-${chunk.toB}`,
current: {
changedRangeLabel: getLineRangeLabel(currentChangedRange),
contextRangeLabel: getLineRangeLabel(currentContextRange),
lines: buildSideLines(currentDoc, currentContextRange, currentChangedRange)
},
modified: {
changedRangeLabel: getLineRangeLabel(modifiedChangedRange),
contextRangeLabel: getLineRangeLabel(modifiedContextRange),
lines: buildSideLines(modifiedDoc, modifiedContextRange, modifiedChangedRange)
}
};
});
}, [modified, original]);
const diff = useMemo<DiffResult>(
() => computeUnifiedDiff(original, modified),
[original, modified]
);
return (
<Modal
@@ -133,61 +234,73 @@ export function DiffModal({
}
>
<div className={styles.content}>
{diffCards.length === 0 ? (
{diff.hunks.length === 0 ? (
<div className={styles.emptyState}>{t('config_management.diff.no_changes')}</div>
) : (
<div className={styles.diffList}>
{diffCards.map((card, index) => (
<article key={card.id} className={styles.diffCard}>
<div className={styles.diffCardHeader}>#{index + 1}</div>
<div className={styles.diffColumns}>
<section className={styles.diffColumn}>
<header className={styles.diffColumnHeader}>
<span>{t('config_management.diff.current')}</span>
<span className={styles.lineMeta}>
<span className={styles.lineRange}>L{card.current.changedRangeLabel}</span>
<span className={styles.contextRange}>
±{DIFF_CONTEXT_LINES}: L{card.current.contextRangeLabel}
</span>
<div className={styles.diffContainer}>
<div className={styles.fileHeader}>
<svg className={styles.fileIcon} viewBox="0 0 16 16" width="16" height="16">
<path
fillRule="evenodd"
d="M3.75 1.5a.25.25 0 00-.25.25v11.5c0 .138.112.25.25.25h8.5a.25.25 0 00.25-.25V6H9.75A1.75 1.75 0 018 4.25V1.5H3.75zm5.75.56v2.19c0 .138.112.25.25.25h2.19L9.5 2.06zM2 1.75C2 .784 2.784 0 3.75 0h5.086c.464 0 .909.184 1.237.513l3.414 3.414c.329.328.513.773.513 1.237v8.086A1.75 1.75 0 0112.25 15h-8.5A1.75 1.75 0 012 13.25V1.75z"
fill="currentColor"
/>
</svg>
<span className={styles.fileName}>config.yaml</span>
<span className={styles.fileStats}>
<span className={styles.statAdditions}>+{diff.additions}</span>
<span className={styles.statDeletions}>-{diff.deletions}</span>
<StatBar additions={diff.additions} deletions={diff.deletions} />
</span>
</div>
<div className={styles.diffBody}>
{diff.hunks.map((hunk, hunkIdx) => (
<div key={hunkIdx} className={styles.hunk}>
<div className={styles.hunkHeader}>
<span className={styles.hunkGutter}>
<svg
className={styles.hunkExpandIcon}
viewBox="0 0 16 16"
width="12"
height="12"
>
<path
d="M8.177 1.677l2.896 2.896a.25.25 0 01-.177.427H8.75v1.25a.75.75 0 01-1.5 0V5H5.104a.25.25 0 01-.177-.427l2.896-2.896a.25.25 0 01.354 0zM7.25 11.75a.75.75 0 011.5 0V13h2.146a.25.25 0 01.177.427l-2.896 2.896a.25.25 0 01-.354 0l-2.896-2.896A.25.25 0 015.104 13H7.25v-1.25z"
fill="currentColor"
/>
</svg>
</span>
<span className={styles.hunkGutter} />
<span className={styles.hunkText}>
@@ -{hunk.oldStart},{hunk.oldCount} +{hunk.newStart},{hunk.newCount} @@
</span>
</div>
{hunk.lines.map((line, lineIdx) => (
<div
key={`${hunkIdx}-${lineIdx}`}
className={`${styles.diffLine} ${styles[line.type]}`}
>
<span
className={`${styles.lineNum} ${line.oldNum === null ? styles.lineNumEmpty : ''}`}
>
{line.oldNum ?? ''}
</span>
</header>
<div className={styles.codeList}>
{card.current.lines.map((line) => (
<div
key={`${card.id}-a-${line.lineNumber}`}
className={`${styles.codeLine} ${line.changed ? styles.codeLineChanged : ''}`}
>
<span className={styles.codeLineNumber}>{line.lineNumber}</span>
<code className={styles.codeLineText}>{line.text || ' '}</code>
</div>
))}
</div>
</section>
<section className={styles.diffColumn}>
<header className={styles.diffColumnHeader}>
<span>{t('config_management.diff.modified')}</span>
<span className={styles.lineMeta}>
<span className={styles.lineRange}>L{card.modified.changedRangeLabel}</span>
<span className={styles.contextRange}>
±{DIFF_CONTEXT_LINES}: L{card.modified.contextRangeLabel}
</span>
<span
className={`${styles.lineNum} ${line.newNum === null ? styles.lineNumEmpty : ''}`}
>
{line.newNum ?? ''}
</span>
</header>
<div className={styles.codeList}>
{card.modified.lines.map((line) => (
<div
key={`${card.id}-b-${line.lineNumber}`}
className={`${styles.codeLine} ${line.changed ? styles.codeLineChanged : ''}`}
>
<span className={styles.codeLineNumber}>{line.lineNumber}</span>
<code className={styles.codeLineText}>{line.text || ' '}</code>
</div>
))}
<span className={styles.linePrefix}>
{line.type === 'deletion' ? '-' : line.type === 'addition' ? '+' : ' '}
</span>
<code className={styles.lineText}>{line.text || ' '}</code>
</div>
</section>
))}
</div>
</article>
))}
))}
</div>
</div>
)}
</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%;
}
+71 -663
View File
@@ -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>
);
});
@@ -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}
+8 -1
View File
@@ -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> {
+36 -2
View File
@@ -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),
});
+1 -1
View File
@@ -5,5 +5,5 @@
export { QuotaSection } from './QuotaSection';
export { QuotaCard } from './QuotaCard';
export { useQuotaLoader } from './useQuotaLoader';
export { ANTIGRAVITY_CONFIG, CLAUDE_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
export { ANTIGRAVITY_CONFIG, CLAUDE_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG, KIMI_CONFIG } from './quotaConfigs';
export type { QuotaConfig } from './quotaConfigs';
+195 -10
View File
@@ -11,6 +11,7 @@ import type {
AntigravityQuotaState,
AuthFileItem,
ClaudeExtraUsage,
ClaudeProfileResponse,
ClaudeQuotaState,
ClaudeQuotaWindow,
ClaudeUsagePayload,
@@ -22,11 +23,14 @@ import type {
GeminiCliParsedBucket,
GeminiCliQuotaBucketState,
GeminiCliQuotaState,
KimiQuotaRow,
KimiQuotaState,
} from '@/types';
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,
@@ -34,6 +38,8 @@ import {
CODEX_REQUEST_HEADERS,
GEMINI_CLI_QUOTA_URL,
GEMINI_CLI_REQUEST_HEADERS,
KIMI_USAGE_URL,
KIMI_REQUEST_HEADERS,
normalizeGeminiCliModelId,
normalizeNumberValue,
normalizePlanType,
@@ -43,13 +49,16 @@ import {
parseClaudeUsagePayload,
parseCodexUsagePayload,
parseGeminiCliQuotaPayload,
parseKimiUsagePayload,
resolveCodexChatgptAccountId,
resolveCodexPlanType,
resolveGeminiCliProjectId,
formatCodexResetLabel,
formatQuotaResetTime,
formatKimiResetHint,
buildAntigravityQuotaGroups,
buildGeminiCliQuotaBuckets,
buildKimiQuotaRows,
createStatusError,
getStatusFromError,
isAntigravityFile,
@@ -57,6 +66,7 @@ import {
isCodexFile,
isDisabledAuthFile,
isGeminiCliFile,
isKimiFile,
isRuntimeOnlyAuthFile,
} from '@/utils/quota';
import { normalizeAuthIndex } from '@/utils/usage';
@@ -65,7 +75,7 @@ import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli';
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli' | 'kimi';
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
@@ -74,10 +84,12 @@ export interface QuotaStore {
claudeQuota: Record<string, ClaudeQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
kimiQuota: Record<string, KimiQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setClaudeQuota: (updater: QuotaUpdater<Record<string, ClaudeQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
setKimiQuota: (updater: QuotaUpdater<Record<string, KimiQuotaState>>) => void;
clearQuotaCache: () => void;
}
@@ -663,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);
@@ -690,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 = (
@@ -702,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(
@@ -755,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',
@@ -769,6 +849,7 @@ export const CLAUDE_CONFIG: QuotaConfig<
status: 'success',
windows: data.windows,
extraUsage: data.extraUsage,
planType: data.planType,
}),
buildErrorState: (message, status) => ({
status: 'error',
@@ -859,3 +940,107 @@ export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaB
gridClassName: styles.geminiCliGrid,
renderQuotaItems: renderGeminiCliItems,
};
const fetchKimiQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<KimiQuotaRow[]> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndex(rawAuthIndex);
if (!authIndex) {
throw new Error(t('kimi_quota.missing_auth_index'));
}
const result = await apiCallApi.request({
authIndex,
method: 'GET',
url: KIMI_USAGE_URL,
header: { ...KIMI_REQUEST_HEADERS },
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const payload = parseKimiUsagePayload(result.body ?? result.bodyText);
if (!payload) {
throw new Error(t('kimi_quota.empty_data'));
}
return buildKimiQuotaRows(payload);
};
const renderKimiItems = (
quota: KimiQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h } = React;
const rows = quota.rows ?? [];
if (rows.length === 0) {
return h('div', { className: styleMap.quotaMessage }, t('kimi_quota.empty_data'));
}
return rows.map((row) => {
const limit = row.limit;
const used = row.used;
const remaining =
limit > 0
? Math.max(0, Math.min(100, Math.round(((limit - used) / limit) * 100)))
: used > 0
? 0
: null;
const percentLabel = remaining === null ? '--' : `${remaining}%`;
const rowLabel = row.labelKey
? t(row.labelKey, (row.labelParams ?? {}) as Record<string, string | number>)
: row.label ?? '';
const resetLabel = formatKimiResetHint(t, row.resetHint);
return h(
'div',
{ key: row.id, className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel }, rowLabel),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, percentLabel),
limit > 0
? h('span', { className: styleMap.quotaAmount }, `${used} / ${limit}`)
: null,
resetLabel
? h('span', { className: styleMap.quotaReset }, resetLabel)
: null
)
),
h(QuotaProgressBar, { percent: remaining, highThreshold: 60, mediumThreshold: 20 })
);
});
};
export const KIMI_CONFIG: QuotaConfig<KimiQuotaState, KimiQuotaRow[]> = {
type: 'kimi',
i18nPrefix: 'kimi_quota',
cardIdleMessageKey: 'quota_management.card_idle_hint',
filterFn: (file) => isKimiFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchKimiQuota,
storeSelector: (state) => state.kimiQuota,
storeSetter: 'setKimiQuota',
buildLoadingState: () => ({ status: 'loading', rows: [] }),
buildSuccessState: (rows) => ({ status: 'success', rows }),
buildErrorState: (message, status) => ({
status: 'error',
rows: [],
error: message,
errorStatus: status,
}),
cardClassName: styles.kimiCard,
controlsClassName: styles.kimiControls,
controlClassName: styles.kimiControl,
gridClassName: styles.kimiGrid,
renderQuotaItems: renderKimiItems,
};
+26 -6
View File
@@ -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
View File
@@ -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>}
+4
View File
@@ -108,3 +108,7 @@
background: rgba($primary-color, 0.1);
font-weight: 600;
}
.optionHighlighted {
background: var(--bg-secondary);
}
+112 -11
View File
@@ -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>
@@ -2,14 +2,20 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconBot, IconCheck, IconCode, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons';
import {
IconBot,
IconCheck,
IconCode,
IconDownload,
IconInfo,
IconTrash2,
} from '@/components/ui/icons';
import { ProviderStatusBar } from '@/components/providers/ProviderStatusBar';
import type { AuthFileItem } from '@/types';
import { resolveAuthProvider } from '@/utils/quota';
import { calculateStatusBarData, normalizeAuthIndex, type KeyStats } from '@/utils/usage';
import { formatFileSize } from '@/utils/format';
import {
AUTH_FILE_REFRESH_WARNING_MS,
QUOTA_PROVIDER_TYPES,
formatModified,
getTypeColor,
@@ -17,26 +23,13 @@ import {
isRuntimeOnlyAuthFile,
resolveAuthFileStats,
type QuotaProviderType,
type ResolvedTheme
type ResolvedTheme,
} from '@/features/authFiles/constants';
import type { AuthFileStatusBarData } from '@/features/authFiles/hooks/useAuthFilesStatusBarCache';
import { AuthFileQuotaSection } from '@/features/authFiles/components/AuthFileQuotaSection';
import styles from '@/pages/AuthFilesPage.module.scss';
type AuthFileHealthStatus = 'healthy' | 'warning' | 'disabled' | 'unknown';
const HEALTHY_STATUS_MESSAGES = new Set(['ok', 'healthy', 'ready', 'success', 'available']);
const GOOD_STATUS_VALUES = new Set(['', 'ok', 'ready', 'healthy', 'available']);
const parseDateFromUnknown = (value: unknown): Date | null => {
if (value === null || value === undefined || value === '') return null;
const asNumber = Number(value);
const date =
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
? new Date(Math.abs(asNumber) < 1e12 ? asNumber * 1000 : asNumber)
: new Date(String(value));
return Number.isNaN(date.getTime()) ? null : date;
};
export type AuthFileCardProps = {
file: AuthFileItem;
@@ -48,11 +41,10 @@ export type AuthFileCardProps = {
quotaFilterType: QuotaProviderType | null;
keyStats: KeyStats;
statusBarCache: Map<string, AuthFileStatusBarData>;
nowMs: number;
onShowModels: (file: AuthFileItem) => void;
onShowDetails: (file: AuthFileItem) => void;
onDownload: (name: string) => void;
onOpenPrefixProxyEditor: (name: string) => void;
onOpenPrefixProxyEditor: (file: AuthFileItem) => void;
onDelete: (name: string) => void;
onToggleStatus: (file: AuthFileItem, enabled: boolean) => void;
onToggleSelect: (name: string) => void;
@@ -65,7 +57,7 @@ const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => {
};
export function AuthFileCard(props: AuthFileCardProps) {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const {
file,
selected,
@@ -76,14 +68,13 @@ export function AuthFileCard(props: AuthFileCardProps) {
quotaFilterType,
keyStats,
statusBarCache,
nowMs,
onShowModels,
onShowDetails,
onDownload,
onOpenPrefixProxyEditor,
onDelete,
onToggleStatus,
onToggleSelect
onToggleSelect,
} = props;
const fileStats = resolveAuthFileStats(file, keyStats);
@@ -100,73 +91,23 @@ export function AuthFileCard(props: AuthFileCardProps) {
const providerCardClass =
quotaType === 'antigravity'
? styles.antigravityCard
: quotaType === 'claude'
? styles.claudeCard
: quotaType === 'codex'
? styles.codexCard
: quotaType === 'gemini-cli'
? styles.geminiCliCard
: '';
: quotaType === 'kimi'
? styles.kimiCard
: '';
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndexKey = normalizeAuthIndex(rawAuthIndex);
const statusData =
(authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
const rawStatus = String(file.status ?? file['status'] ?? '')
.trim()
.toLowerCase();
const rawStatusMessage = String(file['status_message'] ?? file.statusMessage ?? '').trim();
const normalizedStatusMessage = rawStatusMessage.toLowerCase();
const isFileDisabled = file.disabled === true || rawStatus === 'disabled';
const isUnavailable = file.unavailable === true || rawStatus === 'unavailable';
const lastRefreshDate = parseDateFromUnknown(file['last_refresh'] ?? file.lastRefresh);
const isRefreshStale = lastRefreshDate
? nowMs - lastRefreshDate.getTime() > AUTH_FILE_REFRESH_WARNING_MS
: false;
const hasStatusWarning =
Boolean(rawStatusMessage) && !HEALTHY_STATUS_MESSAGES.has(normalizedStatusMessage);
const hasStatusFailure = rawStatus === 'error' || rawStatus === 'failed' || rawStatus === 'warning';
const healthStatus: AuthFileHealthStatus = isFileDisabled
? 'disabled'
: hasStatusWarning || hasStatusFailure || isUnavailable || isRefreshStale
? 'warning'
: lastRefreshDate && !isRefreshStale && GOOD_STATUS_VALUES.has(rawStatus)
? 'healthy'
: 'unknown';
const healthStatusClass =
healthStatus === 'healthy'
? styles.healthStatusHealthy
: healthStatus === 'warning'
? styles.healthStatusWarning
: healthStatus === 'disabled'
? styles.healthStatusDisabled
: styles.healthStatusUnknown;
const healthStatusLabel = t(`auth_files.health_status_${healthStatus}`);
const lastRefreshText = (() => {
if (!lastRefreshDate) return t('auth_files.refresh_not_available');
const diffMs = lastRefreshDate.getTime() - nowMs;
const absMs = Math.abs(diffMs);
if (absMs < 30 * 1000) {
return t('auth_files.refresh_just_now');
}
const units: ReadonlyArray<{ unit: Intl.RelativeTimeFormatUnit; ms: number }> = [
{ unit: 'day', ms: 24 * 60 * 60 * 1000 },
{ unit: 'hour', ms: 60 * 60 * 1000 },
{ unit: 'minute', ms: 60 * 1000 },
{ unit: 'second', ms: 1000 }
];
const matched = units.find(({ ms }) => absMs >= ms) || units[units.length - 1];
const value = Math.round(diffMs / matched.ms);
if (typeof Intl === 'undefined' || typeof Intl.RelativeTimeFormat !== 'function') {
return lastRefreshDate.toLocaleString(i18n.language);
}
const formatter = new Intl.RelativeTimeFormat(i18n.language, { numeric: 'auto' });
return formatter.format(value, matched.unit);
})();
const lastRefreshTitle = lastRefreshDate
? lastRefreshDate.toLocaleString(i18n.language)
: t('auth_files.refresh_not_available');
const healthStatusTitle = rawStatusMessage || t('auth_files.health_status_no_message');
Boolean(rawStatusMessage) && !HEALTHY_STATUS_MESSAGES.has(rawStatusMessage.toLowerCase());
return (
<div
@@ -180,7 +121,9 @@ export function AuthFileCard(props: AuthFileCardProps) {
type="button"
className={`${styles.selectionToggle} ${selected ? styles.selectionToggleActive : ''}`}
onClick={() => onToggleSelect(file.name)}
aria-label={selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')}
aria-label={
selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')
}
aria-pressed={selected}
title={selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')}
>
@@ -192,7 +135,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {})
...(typeColor.border ? { border: typeColor.border } : {}),
}}
>
{getTypeLabel(t, file.type || 'unknown')}
@@ -209,17 +152,6 @@ export function AuthFileCard(props: AuthFileCardProps) {
</span>
</div>
<div className={styles.cardHealthRow}>
<span className={`${styles.healthStatusBadge} ${healthStatusClass}`} title={healthStatusTitle}>
{t('auth_files.health_status_label')}: {healthStatusLabel}
</span>
<span
className={`${styles.lastRefreshText} ${isRefreshStale ? styles.lastRefreshStale : ''}`}
title={lastRefreshTitle}
>
{t('auth_files.last_refresh_label')}: {lastRefreshText}
</span>
</div>
{rawStatusMessage && hasStatusWarning && (
<div className={styles.healthStatusMessage} title={rawStatusMessage}>
{rawStatusMessage}
@@ -238,7 +170,11 @@ export function AuthFileCard(props: AuthFileCardProps) {
<ProviderStatusBar statusData={statusData} styles={styles} />
{showQuotaLayout && quotaType && (
<AuthFileQuotaSection file={file} quotaType={quotaType} disableControls={disableControls} />
<AuthFileQuotaSection
file={file}
quotaType={quotaType}
disableControls={disableControls}
/>
)}
<div className={styles.cardActions}>
@@ -279,7 +215,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
<Button
variant="secondary"
size="sm"
onClick={() => onOpenPrefixProxyEditor(file.name)}
onClick={() => onOpenPrefixProxyEditor(file)}
className={styles.iconButton}
title={t('auth_files.prefix_proxy_button')}
disabled={disableControls}
@@ -313,7 +249,9 @@ export function AuthFileCard(props: AuthFileCardProps) {
</div>
)}
{isRuntimeOnly && (
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
<div className={styles.virtualBadge}>
{t('auth_files.type_virtual') || '虚拟认证文件'}
</div>
)}
</div>
</div>
@@ -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 } 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,7 +23,9 @@ 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;
};
@@ -34,13 +42,17 @@ 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;
});
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;
});
@@ -3,9 +3,11 @@ import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Input } from '@/components/ui/Input';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import type {
PrefixProxyEditorField,
PrefixProxyEditorState
PrefixProxyEditorFieldValue,
PrefixProxyEditorState,
} from '@/features/authFiles/hooks/useAuthFilesPrefixProxyEditor';
import styles from '@/pages/AuthFilesPage.module.scss';
@@ -16,7 +18,7 @@ export type AuthFilesPrefixProxyEditorModalProps = {
dirty: boolean;
onClose: () => void;
onSave: () => void;
onChange: (field: PrefixProxyEditorField, value: string) => void;
onChange: (field: PrefixProxyEditorField, value: PrefixProxyEditorFieldValue) => void;
};
export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEditorModalProps) {
@@ -42,9 +44,7 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
<Button
onClick={onSave}
loading={editor?.saving === true}
disabled={
disableControls || editor?.saving === true || !dirty || !editor?.json
}
disabled={disableControls || editor?.saving === true || !dirty || !editor?.json}
>
{t('common.save')}
</Button>
@@ -114,6 +114,18 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('disableCooling', e.target.value)}
/>
{editor.isCodexFile && (
<div className="form-group">
<label>{t('ai_providers.codex_websockets_label')}</label>
<ToggleSwitch
checked={Boolean(editor.websocket)}
disabled={disableControls || editor.saving || !editor.json}
ariaLabel={t('ai_providers.codex_websockets_label')}
onChange={(value) => onChange('websocket', value)}
/>
<div className="hint">{t('ai_providers.codex_websockets_hint')}</div>
</div>
)}
</div>
</>
)}
@@ -122,4 +134,3 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
</Modal>
);
}
+8 -2
View File
@@ -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';
export type QuotaProviderType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli' | 'kimi';
export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>(['antigravity', 'codex', 'gemini-cli']);
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;
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { authFilesApi } from '@/services/api';
import type { AuthFileItem } from '@/types';
import { useNotificationStore } from '@/stores';
import { formatFileSize } from '@/utils/format';
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
@@ -8,7 +9,7 @@ import {
normalizeExcludedModels,
parseDisableCoolingValue,
parseExcludedModelsText,
parsePriorityValue
parsePriorityValue,
} from '@/features/authFiles/constants';
export type PrefixProxyEditorField =
@@ -16,10 +17,14 @@ export type PrefixProxyEditorField =
| 'proxyUrl'
| 'priority'
| 'excludedModelsText'
| 'disableCooling';
| 'disableCooling'
| 'websocket';
export type PrefixProxyEditorFieldValue = string | boolean;
export type PrefixProxyEditorState = {
fileName: string;
isCodexFile: boolean;
loading: boolean;
saving: boolean;
error: string | null;
@@ -31,6 +36,7 @@ export type PrefixProxyEditorState = {
priority: string;
excludedModelsText: string;
disableCooling: string;
websocket: boolean;
};
export type UseAuthFilesPrefixProxyEditorOptions = {
@@ -43,9 +49,12 @@ export type UseAuthFilesPrefixProxyEditorResult = {
prefixProxyEditor: PrefixProxyEditorState | null;
prefixProxyUpdatedText: string;
prefixProxyDirty: boolean;
openPrefixProxyEditor: (name: string) => Promise<void>;
openPrefixProxyEditor: (file: Pick<AuthFileItem, 'name' | 'type' | 'provider'>) => Promise<void>;
closePrefixProxyEditor: () => void;
handlePrefixProxyChange: (field: PrefixProxyEditorField, value: string) => void;
handlePrefixProxyChange: (
field: PrefixProxyEditorField,
value: PrefixProxyEditorFieldValue
) => void;
handlePrefixProxySave: () => Promise<void>;
};
@@ -80,6 +89,10 @@ const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): str
delete next.disable_cooling;
}
if (editor.isCodexFile) {
next.websocket = editor.websocket;
}
return JSON.stringify(next);
};
@@ -102,7 +115,16 @@ export function useAuthFilesPrefixProxyEditor(
setPrefixProxyEditor(null);
};
const openPrefixProxyEditor = async (name: string) => {
const openPrefixProxyEditor = async (file: Pick<AuthFileItem, 'name' | 'type' | 'provider'>) => {
const name = file.name;
const normalizedType = String(file.type ?? '')
.trim()
.toLowerCase();
const normalizedProvider = String(file.provider ?? '')
.trim()
.toLowerCase();
const isCodexFile = normalizedType === 'codex' || normalizedProvider === 'codex';
if (disableControls) return;
if (prefixProxyEditor?.fileName === name) {
setPrefixProxyEditor(null);
@@ -111,6 +133,7 @@ export function useAuthFilesPrefixProxyEditor(
setPrefixProxyEditor({
fileName: name,
isCodexFile,
loading: true,
saving: false,
error: null,
@@ -121,7 +144,8 @@ export function useAuthFilesPrefixProxyEditor(
proxyUrl: '',
priority: '',
excludedModelsText: '',
disableCooling: ''
disableCooling: '',
websocket: false,
});
try {
@@ -139,7 +163,7 @@ export function useAuthFilesPrefixProxyEditor(
loading: false,
error: t('auth_files.prefix_proxy_invalid_json'),
rawText: trimmed,
originalText: trimmed
originalText: trimmed,
};
});
return;
@@ -153,19 +177,24 @@ export function useAuthFilesPrefixProxyEditor(
loading: false,
error: t('auth_files.prefix_proxy_invalid_json'),
rawText: trimmed,
originalText: trimmed
originalText: trimmed,
};
});
return;
}
const json = parsed as Record<string, unknown>;
const json = { ...(parsed as Record<string, unknown>) };
if (isCodexFile) {
const websocketValue = parseDisableCoolingValue(json.websocket);
json.websocket = websocketValue ?? false;
}
const originalText = JSON.stringify(json);
const prefix = typeof json.prefix === 'string' ? json.prefix : '';
const proxyUrl = typeof json.proxy_url === 'string' ? json.proxy_url : '';
const priority = parsePriorityValue(json.priority);
const excludedModels = normalizeExcludedModels(json.excluded_models);
const disableCoolingValue = parseDisableCoolingValue(json.disable_cooling);
const websocketValue = parseDisableCoolingValue(json.websocket);
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
@@ -181,7 +210,8 @@ export function useAuthFilesPrefixProxyEditor(
excludedModelsText: excludedModels.join('\n'),
disableCooling:
disableCoolingValue === undefined ? '' : disableCoolingValue ? 'true' : 'false',
error: null
websocket: websocketValue ?? false,
error: null,
};
});
} catch (err: unknown) {
@@ -194,14 +224,18 @@ export function useAuthFilesPrefixProxyEditor(
}
};
const handlePrefixProxyChange = (field: PrefixProxyEditorField, value: string) => {
const handlePrefixProxyChange = (
field: PrefixProxyEditorField,
value: PrefixProxyEditorFieldValue
) => {
setPrefixProxyEditor((prev) => {
if (!prev) return prev;
if (field === 'prefix') return { ...prev, prefix: value };
if (field === 'proxyUrl') return { ...prev, proxyUrl: value };
if (field === 'priority') return { ...prev, priority: value };
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: value };
return { ...prev, disableCooling: value };
if (field === 'prefix') return { ...prev, prefix: String(value) };
if (field === 'proxyUrl') return { ...prev, proxyUrl: String(value) };
if (field === 'priority') return { ...prev, priority: String(value) };
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: String(value) };
if (field === 'disableCooling') return { ...prev, disableCooling: String(value) };
return { ...prev, websocket: Boolean(value) };
});
};
@@ -249,6 +283,6 @@ export function useAuthFilesPrefixProxyEditor(
openPrefixProxyEditor,
closePrefixProxyEditor,
handlePrefixProxyChange,
handlePrefixProxySave
handlePrefixProxySave,
};
}
+229 -10
View File
@@ -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);
}
@@ -78,7 +172,11 @@ function setStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void
doc.setIn(path, safe);
return;
}
if (docHas(doc, path)) doc.deleteIn(path);
// Preserve existing empty-string keys to avoid dropping template blocks/comments.
// Only keep the key when it already exists in the YAML.
if (docHas(doc, path)) {
doc.setIn(path, '');
}
}
function setIntFromStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void {
@@ -89,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 {
@@ -272,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);
@@ -279,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);
@@ -287,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 : '',
@@ -308,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']),
@@ -343,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 };
}
}, []);
@@ -403,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']);
}
@@ -506,7 +722,7 @@ export function useVisualConfig() {
return currentYaml;
}
},
[baselineValues, visualValues]
[apiKeysStorageMetadata, baselineValues, visualValues]
);
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
@@ -522,6 +738,9 @@ export function useVisualConfig() {
return {
visualValues,
visualDirty,
visualParseError,
visualValidationErrors,
visualHasPayloadValidationErrors,
loadVisualValuesFromYaml,
applyVisualChangesToYaml,
setVisualValues,
+50 -2
View File
@@ -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",
@@ -579,7 +588,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",
@@ -619,6 +634,22 @@
"fetch_all": "Fetch All",
"remaining_amount": "Remaining {{count}}"
},
"kimi_quota": {
"title": "Kimi Quota",
"empty_title": "No Kimi Auth Files",
"empty_desc": "Upload a Kimi credential to view remaining quota.",
"idle": "Click here to refresh quota",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
"empty_data": "No quota data available",
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All",
"weekly_limit": "Weekly limit",
"limit_window": "{{duration}} limit",
"limit_index": "Limit #{{index}}",
"reset_hint": "resets in {{hint}}"
},
"vertex_import": {
"title": "Vertex JSON Login",
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
@@ -1023,6 +1054,7 @@
"trace_confidence_low": "Low",
"trace_score": "Score {{score}}",
"trace_delta_seconds": "Δt {{seconds}}s",
"trace_model_matched": "Model Matched",
"trace_request_id": "Request ID",
"trace_method": "Method",
"trace_path": "Path",
@@ -1050,6 +1082,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",
@@ -1063,6 +1096,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",
@@ -1196,6 +1233,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",
@@ -1214,7 +1252,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",
+50 -2
View File
@@ -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": "Добавить провайдера",
@@ -582,7 +591,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",
@@ -622,6 +637,22 @@
"fetch_all": "Получить все",
"remaining_amount": "Осталось {{count}}"
},
"kimi_quota": {
"title": "Квота Kimi",
"empty_title": "Файлы авторизации Kimi отсутствуют",
"empty_desc": "Загрузите учётные данные Kimi, чтобы увидеть оставшуюся квоту.",
"idle": "Не загружено. Нажмите \"Обновить квоту\".",
"loading": "Загрузка квоты...",
"load_failed": "Не удалось загрузить квоту: {{message}}",
"missing_auth_index": "В файле авторизации отсутствует auth_index",
"empty_data": "Данные по квоте отсутствуют",
"refresh_button": "Обновить квоту",
"fetch_all": "Получить все",
"weekly_limit": "Недельный лимит",
"limit_window": "Лимит {{duration}}",
"limit_index": "Лимит #{{index}}",
"reset_hint": "сброс через {{hint}}"
},
"vertex_import": {
"title": "Вход с Vertex JSON",
"description": "Загрузите JSON ключа сервисного аккаунта Google, чтобы сохранить его как auth-dir/vertex-<project>.json по тем же правилам, что и помощник CLI vertex-import.",
@@ -1026,6 +1057,7 @@
"trace_confidence_low": "Низкая",
"trace_score": "Оценка {{score}}",
"trace_delta_seconds": "Δt {{seconds}}с",
"trace_model_matched": "Модель совпала",
"trace_request_id": "Request ID",
"trace_method": "Метод",
"trace_path": "Путь",
@@ -1053,6 +1085,7 @@
"title": "Панель конфигурации",
"editor_title": "Файл конфигурации",
"reload": "Перезагрузить",
"reload_confirm_message": "Перезагрузка отбросит ваши несохранённые изменения. Продолжить?",
"save": "Сохранить",
"description": "Редактируйте config.yaml через визуальный редактор или исходный файл",
"status_idle": "Ожидание действия",
@@ -1066,6 +1099,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": "Поиск",
@@ -1201,6 +1238,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": "Добавить правило",
@@ -1219,7 +1257,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": "Изменить",
+50 -2
View File
@@ -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 keyAmp官方)吗?",
"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 Keysk-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": "添加提供商",
@@ -579,7 +588,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 额度",
@@ -619,6 +634,22 @@
"fetch_all": "获取全部",
"remaining_amount": "剩余 {{count}}"
},
"kimi_quota": {
"title": "Kimi 额度",
"empty_title": "暂无 Kimi 认证",
"empty_desc": "上传 Kimi 认证文件后即可查看额度。",
"idle": "点击此处刷新额度",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
"empty_data": "暂无额度数据",
"refresh_button": "刷新额度",
"fetch_all": "获取全部",
"weekly_limit": "周限额",
"limit_window": "{{duration}} 限额",
"limit_index": "限额 #{{index}}",
"reset_hint": "{{hint}} 后重置"
},
"vertex_import": {
"title": "Vertex JSON 登录",
"description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
@@ -1023,6 +1054,7 @@
"trace_confidence_low": "低",
"trace_score": "分数 {{score}}",
"trace_delta_seconds": "时间差 {{seconds}} 秒",
"trace_model_matched": "模型匹配",
"trace_request_id": "请求 ID",
"trace_method": "请求方法",
"trace_path": "路径",
@@ -1050,6 +1082,7 @@
"title": "配置面板",
"editor_title": "配置文件",
"reload": "重新加载",
"reload_confirm_message": "重新加载将丢弃你当前未保存的修改,确定继续吗?",
"save": "保存",
"description": "通过可视化或者源文件方式编辑 config.yaml 配置文件",
"status_idle": "等待操作",
@@ -1063,6 +1096,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": "搜索",
@@ -1196,6 +1233,7 @@
"json_path": "JSON 路径 (如 temperature)",
"json_path_filter": "JSON 路径 (gjson/sjson),如 generationConfig.thinkingConfig.thinkingBudget",
"param_type": "参数类型",
"param_value": "参数值",
"add_param": "添加参数",
"no_rules": "暂无规则",
"add_rule": "添加规则",
@@ -1214,7 +1252,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": "编辑",
+136 -16
View File
@@ -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);
}
+18
View File
@@ -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>
+8 -53
View File
@@ -314,6 +314,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));
}
@@ -322,6 +326,10 @@
background-image: linear-gradient(180deg, rgba(231, 239, 255, 0.2), rgba(231, 239, 255, 0));
}
.kimiCard {
background-image: linear-gradient(180deg, rgba(255, 244, 229, 0.2), rgba(255, 244, 229, 0));
}
.quotaSection {
display: flex;
flex-direction: column;
@@ -605,59 +613,6 @@
border-bottom: 1px solid var(--border-color);
}
.cardHealthRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
flex-wrap: wrap;
}
.healthStatusBadge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
font-size: 12px;
font-weight: 600;
line-height: 1.2;
}
.healthStatusHealthy {
color: var(--success-badge-text, #065f46);
background-color: var(--success-badge-bg, #d1fae5);
border-color: var(--success-badge-border, #6ee7b7);
}
.healthStatusWarning {
color: var(--warning-text);
background-color: var(--warning-bg);
border-color: var(--warning-border);
}
.healthStatusDisabled {
color: var(--text-secondary);
background-color: var(--bg-tertiary);
border-color: var(--border-color);
}
.healthStatusUnknown {
color: var(--text-secondary);
background-color: var(--bg-secondary);
border-style: dashed;
}
.lastRefreshText {
font-size: 12px;
color: var(--text-secondary);
}
.lastRefreshStale {
color: var(--warning-text);
}
.healthStatusMessage {
font-size: 12px;
color: var(--warning-text);
+75 -32
View File
@@ -1,8 +1,17 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent } from 'react';
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
} from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import gsap from 'gsap';
import { animate } from 'motion/mini';
import type { AnimationPlaybackControlsWithThen } from 'motion-dom';
import { useInterval } from '@/hooks/useInterval';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
@@ -21,7 +30,7 @@ import {
isRuntimeOnlyAuthFile,
normalizeProviderKey,
type QuotaProviderType,
type ResolvedTheme
type ResolvedTheme,
} from '@/features/authFiles/constants';
import { AuthFileCard } from '@/features/authFiles/components/AuthFileCard';
import { AuthFileDetailModal } from '@/features/authFiles/components/AuthFileDetailModal';
@@ -40,6 +49,11 @@ import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
import type { AuthFileItem } from '@/types';
import styles from './AuthFilesPage.module.scss';
const easePower3Out = (progress: number) => 1 - (1 - progress) ** 4;
const easePower2In = (progress: number) => progress ** 3;
const BATCH_BAR_BASE_TRANSFORM = 'translateX(-50%)';
const BATCH_BAR_HIDDEN_TRANSFORM = 'translateX(-50%) translateY(56px)';
export function AuthFilesPage() {
const { t } = useTranslation();
const showNotification = useNotificationStore((state) => state.showNotification);
@@ -58,8 +72,8 @@ export function AuthFilesPage() {
const [selectedFile, setSelectedFile] = useState<AuthFileItem | null>(null);
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
const [batchActionBarVisible, setBatchActionBarVisible] = useState(false);
const [nowMs, setNowMs] = useState(() => Date.now());
const floatingBatchActionsRef = useRef<HTMLDivElement>(null);
const batchActionAnimationRef = useRef<AnimationPlaybackControlsWithThen | null>(null);
const previousSelectionCountRef = useRef(0);
const selectionCountRef = useRef(0);
@@ -86,7 +100,7 @@ export function AuthFilesPage() {
selectAllVisible,
deselectAll,
batchSetStatus,
batchDelete
batchDelete,
} = useAuthFilesData({ refreshKeyStats });
const statusBarCache = useAuthFilesStatusBarCache(files, usageDetails);
@@ -105,7 +119,7 @@ export function AuthFilesPage() {
handleDeleteLink,
handleToggleFork,
handleRenameAlias,
handleDeleteAlias
handleDeleteAlias,
} = useAuthFilesOauth({ viewMode, files });
const {
@@ -116,7 +130,7 @@ export function AuthFilesPage() {
modelsFileType,
modelsError,
showModels,
closeModelsModal
closeModelsModal,
} = useAuthFilesModels();
const {
@@ -126,11 +140,11 @@ export function AuthFilesPage() {
openPrefixProxyEditor,
closePrefixProxyEditor,
handlePrefixProxyChange,
handlePrefixProxySave
handlePrefixProxySave,
} = useAuthFilesPrefixProxyEditor({
disableControls: connectionStatus !== 'connected',
loadFiles,
loadKeyStats: refreshKeyStats
loadKeyStats: refreshKeyStats,
});
const disableControls = connectionStatus !== 'connected';
@@ -223,7 +237,6 @@ export function AuthFilesPage() {
},
isCurrentLayer ? 240_000 : null
);
useInterval(() => setNowMs(Date.now()), isCurrentLayer ? 60_000 : null);
const existingTypes = useMemo(() => {
const types = new Set<string>(['all']);
@@ -294,7 +307,7 @@ export function AuthFilesPage() {
}
const nextSearch = params.toString();
navigate(`/auth-files/oauth-excluded${nextSearch ? `?${nextSearch}` : ''}`, {
state: { fromAuthFiles: true }
state: { fromAuthFiles: true },
});
},
[filter, navigate]
@@ -309,7 +322,7 @@ export function AuthFilesPage() {
}
const nextSearch = params.toString();
navigate(`/auth-files/oauth-model-alias${nextSearch ? `?${nextSearch}` : ''}`, {
state: { fromAuthFiles: true }
state: { fromAuthFiles: true },
});
},
[filter, navigate]
@@ -356,31 +369,55 @@ export function AuthFilesPage() {
const actionsEl = floatingBatchActionsRef.current;
if (!actionsEl) return;
gsap.killTweensOf(actionsEl);
batchActionAnimationRef.current?.stop();
batchActionAnimationRef.current = null;
if (currentCount > 0 && previousCount === 0) {
gsap.fromTo(
batchActionAnimationRef.current = animate(
actionsEl,
{ y: 56, autoAlpha: 0 },
{ y: 0, autoAlpha: 1, duration: 0.28, ease: 'power3.out' }
{
transform: [BATCH_BAR_HIDDEN_TRANSFORM, BATCH_BAR_BASE_TRANSFORM],
opacity: [0, 1],
},
{
duration: 0.28,
ease: easePower3Out,
onComplete: () => {
actionsEl.style.transform = BATCH_BAR_BASE_TRANSFORM;
actionsEl.style.opacity = '1';
},
}
);
} else if (currentCount === 0 && previousCount > 0) {
gsap.to(actionsEl, {
y: 56,
autoAlpha: 0,
duration: 0.22,
ease: 'power2.in',
onComplete: () => {
if (selectionCountRef.current === 0) {
setBatchActionBarVisible(false);
}
batchActionAnimationRef.current = animate(
actionsEl,
{
transform: [BATCH_BAR_BASE_TRANSFORM, BATCH_BAR_HIDDEN_TRANSFORM],
opacity: [1, 0],
},
{
duration: 0.22,
ease: easePower2In,
onComplete: () => {
if (selectionCountRef.current === 0) {
setBatchActionBarVisible(false);
}
},
}
});
);
}
previousSelectionCountRef.current = currentCount;
}, [batchActionBarVisible, selectionCount]);
useEffect(
() => () => {
batchActionAnimationRef.current?.stop();
batchActionAnimationRef.current = null;
},
[]
);
const renderFilterTags = () => (
<div className={styles.filterTags}>
{existingTypes.map((type) => {
@@ -397,7 +434,7 @@ export function AuthFilesPage() {
style={{
backgroundColor: isActive ? color.text : color.bg,
color: isActive ? activeTextColor : color.text,
borderColor: color.text
borderColor: color.text,
}}
onClick={() => {
setFilter(type);
@@ -444,7 +481,9 @@ export function AuthFilesPage() {
<Button
variant="danger"
size="sm"
onClick={() => handleDeleteAll({ filter, onResetFilterToAll: () => setFilter('all') })}
onClick={() =>
handleDeleteAll({ filter, onResetFilterToAll: () => setFilter('all') })
}
disabled={disableControls || loading || deletingAll}
loading={deletingAll}
>
@@ -504,9 +543,14 @@ export function AuthFilesPage() {
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : pageItems.length === 0 ? (
<EmptyState title={t('auth_files.search_empty_title')} description={t('auth_files.search_empty_desc')} />
<EmptyState
title={t('auth_files.search_empty_title')}
description={t('auth_files.search_empty_desc')}
/>
) : (
<div className={`${styles.fileGrid} ${quotaFilterType ? styles.fileGridQuotaManaged : ''}`}>
<div
className={`${styles.fileGrid} ${quotaFilterType ? styles.fileGridQuotaManaged : ''}`}
>
{pageItems.map((file) => (
<AuthFileCard
key={file.name}
@@ -519,7 +563,6 @@ export function AuthFilesPage() {
quotaFilterType={quotaFilterType}
keyStats={keyStats}
statusBarCache={statusBarCache}
nowMs={nowMs}
onShowModels={showModels}
onShowDetails={showDetails}
onDownload={handleDownload}
@@ -546,7 +589,7 @@ export function AuthFilesPage() {
{t('auth_files.pagination_info', {
current: currentPage,
total: totalPages,
count: filtered.length
count: filtered.length,
})}
</div>
<Button
+118 -16
View File
@@ -5,7 +5,7 @@ import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { yaml } from '@codemirror/lang-yaml';
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { keymap } from '@codemirror/view';
import { parse as parseYaml } from 'yaml';
import { parse as parseYaml, parseDocument } from 'yaml';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
@@ -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,44 @@ export function ConfigPage() {
};
const handleSave = async () => {
if (activeTab === 'visual' && visualParseError) {
showNotification(t('config_management.visual_mode_save_blocked'), 'error');
return;
}
setSaving(true);
try {
const nextMergedYaml = applyVisualChangesToYaml(content);
const latestServerYaml = await configFileApi.fetchConfigYaml();
if (latestServerYaml === nextMergedYaml) {
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.
let diffOriginal = latestServerYaml;
if (activeTab !== 'source') {
try {
const doc = parseDocument(latestServerYaml);
diffOriginal = doc.toString({ indent: 2, lineWidth: 120, minContentWidth: 0 });
} catch { /* keep raw on parse failure */ }
}
if (diffOriginal === nextMergedYaml) {
setDirty(false);
setContent(latestServerYaml);
setServerYaml(latestServerYaml);
@@ -136,7 +187,7 @@ export function ConfigPage() {
return;
}
setServerYaml(latestServerYaml);
setServerYaml(diffOriginal);
setMergedYaml(nextMergedYaml);
setDiffModalOpen(true);
} catch (err: unknown) {
@@ -156,18 +207,28 @@ export function ConfigPage() {
if (tab === activeTab) return;
if (tab === 'source') {
const nextContent = applyVisualChangesToYaml(content);
if (nextContent !== content) {
setContent(nextContent);
setDirty(true);
// Only rewrite YAML when there are pending visual changes; otherwise preserve raw YAML + comments.
if (visualDirty) {
const nextContent = applyVisualChangesToYaml(content);
if (nextContent !== content) {
setContent(nextContent);
setDirty(true);
}
}
} 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]);
}, [activeTab, applyVisualChangesToYaml, content, loadVisualValuesFromYaml, showNotification, t, visualDirty]);
// Search functionality
const performSearch = useCallback((query: string, direction: 'next' | 'prev' = 'next') => {
@@ -331,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}>
@@ -352,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')}
>
@@ -363,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')}
>
@@ -401,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}
/>
+2 -18
View File
@@ -693,34 +693,18 @@
flex-wrap: wrap;
}
.traceConfidenceBadge {
.traceModelBadge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
border: 1px solid var(--success-badge-border, #6ee7b7);
font-size: 11px;
font-weight: 700;
}
.traceConfidenceHigh {
color: var(--success-badge-text, #065f46);
background: var(--success-badge-bg, #d1fae5);
border-color: var(--success-badge-border, #6ee7b7);
}
.traceConfidenceMedium {
color: var(--warning-text);
background: var(--warning-bg);
border-color: var(--warning-border);
}
.traceConfidenceLow {
color: var(--text-secondary);
background: var(--bg-primary);
}
.traceScore,
.traceDelta {
font-size: 11px;
color: var(--text-secondary);
+5 -12
View File
@@ -970,12 +970,6 @@ export function LogsPage() {
) : (
<div className={styles.traceCandidates}>
{trace.traceCandidates.map((candidate) => {
const confidenceClass =
candidate.confidence === 'high'
? styles.traceConfidenceHigh
: candidate.confidence === 'medium'
? styles.traceConfidenceMedium
: styles.traceConfidenceLow;
const sourceInfo = trace.resolveTraceSourceInfo(
String(candidate.detail.source ?? ''),
candidate.detail.auth_index
@@ -986,12 +980,11 @@ export function LogsPage() {
className={styles.traceCandidate}
>
<div className={styles.traceCandidateHeader}>
<span className={`${styles.traceConfidenceBadge} ${confidenceClass}`}>
{t(`logs.trace_confidence_${candidate.confidence}`)}
</span>
<span className={styles.traceScore}>
{t('logs.trace_score', { score: candidate.score })}
</span>
{candidate.modelMatched && (
<span className={styles.traceModelBadge}>
{t('logs.trace_model_matched')}
</span>
)}
{candidate.timeDeltaMs !== null && (
<span className={styles.traceDelta}>
{t('logs.trace_delta_seconds', {
+12 -3
View File
@@ -105,7 +105,8 @@
.antigravityGrid,
.claudeGrid,
.codexGrid,
.geminiCliGrid {
.geminiCliGrid,
.kimiGrid {
display: grid;
gap: $spacing-md;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
@@ -118,7 +119,8 @@
.antigravityControls,
.claudeControls,
.codexControls,
.geminiCliControls {
.geminiCliControls,
.kimiControls {
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
@@ -129,7 +131,8 @@
.antigravityControl,
.claudeControl,
.codexControl,
.geminiCliControl {
.geminiCliControl,
.kimiControl {
display: flex;
flex-direction: column;
gap: 4px;
@@ -172,6 +175,12 @@
rgba(231, 239, 255, 0));
}
.kimiCard {
background-image: linear-gradient(180deg,
rgba(255, 244, 229, 0.2),
rgba(255, 244, 229, 0));
}
.quotaSection {
display: flex;
flex-direction: column;
+8 -1
View File
@@ -12,7 +12,8 @@ import {
ANTIGRAVITY_CONFIG,
CLAUDE_CONFIG,
CODEX_CONFIG,
GEMINI_CLI_CONFIG
GEMINI_CLI_CONFIG,
KIMI_CONFIG
} from '@/components/quota';
import type { AuthFileItem } from '@/types';
import styles from './QuotaPage.module.scss';
@@ -94,6 +95,12 @@ export function QuotaPage() {
loading={loading}
disabled={disableControls}
/>
<QuotaSection
config={KIMI_CONFIG}
files={files}
loading={loading}
disabled={disableControls}
/>
</div>
);
}
+47 -79
View File
@@ -12,19 +12,14 @@ import {
} from '@/utils/usage';
import type { ParsedLogLine } from './logTypes';
type TraceConfidence = 'high' | 'medium' | 'low';
export type TraceCandidate = {
detail: UsageDetailWithEndpoint;
score: number;
confidence: TraceConfidence;
modelMatched: boolean;
timeDeltaMs: number | null;
};
const TRACE_AUTH_CACHE_MS = 60 * 1000;
const TRACE_MATCH_STRONG_WINDOW_MS = 3 * 1000;
const TRACE_MATCH_WINDOW_MS = 10 * 1000;
const TRACE_MATCH_MAX_WINDOW_MS = 30 * 1000;
const TRACE_MAX_CANDIDATES = 5;
const TRACEABLE_EXACT_PATHS = new Set(['/v1/chat/completions', '/v1/messages', '/v1/responses']);
const TRACEABLE_PREFIX_PATHS = ['/v1beta/models'];
@@ -48,70 +43,17 @@ export const isTraceableRequestPath = (value?: string): boolean => {
return TRACEABLE_PREFIX_PATHS.some((prefix) => normalizedPath.startsWith(prefix));
};
const scoreTraceCandidate = (
line: ParsedLogLine,
detail: UsageDetailWithEndpoint
): TraceCandidate | null => {
let score = 0;
let timeDeltaMs: number | null = null;
const MODEL_EXTRACT_REGEX = /\bmodel[=:]\s*"?([a-zA-Z0-9._\-/]+)"?/i;
const logTimestampMs = line.timestamp ? Date.parse(line.timestamp) : Number.NaN;
const detailTimestampMs = detail.__timestampMs;
if (!Number.isNaN(logTimestampMs) && detailTimestampMs > 0) {
timeDeltaMs = Math.abs(logTimestampMs - detailTimestampMs);
if (timeDeltaMs <= TRACE_MATCH_STRONG_WINDOW_MS) {
score += 42;
} else if (timeDeltaMs <= TRACE_MATCH_WINDOW_MS) {
score += 30;
} else if (timeDeltaMs <= TRACE_MATCH_MAX_WINDOW_MS) {
score += 12;
} else {
score -= 12;
}
}
const extractModelFromMessage = (message?: string): string | undefined => {
if (!message) return undefined;
const match = message.match(MODEL_EXTRACT_REGEX);
return match?.[1] || undefined;
};
let methodMatched = false;
if (line.method && detail.__endpointMethod) {
if (line.method.toUpperCase() === detail.__endpointMethod.toUpperCase()) {
score += 18;
methodMatched = true;
} else {
score -= 8;
}
}
const logPath = normalizeTracePath(line.path);
const detailPath = normalizeTracePath(detail.__endpointPath);
let pathMatched = false;
if (logPath && detailPath) {
if (logPath === detailPath) {
score += 24;
pathMatched = true;
} else if (logPath.startsWith(detailPath) || detailPath.startsWith(logPath)) {
score += 12;
pathMatched = true;
} else {
score -= 8;
}
}
if (typeof line.statusCode === 'number') {
const logFailed = line.statusCode >= 400;
score += logFailed === detail.failed ? 10 : -6;
}
if (
timeDeltaMs !== null &&
timeDeltaMs > TRACE_MATCH_MAX_WINDOW_MS &&
!methodMatched &&
!pathMatched
) {
return null;
}
if (score <= 0) return null;
const confidence: TraceConfidence = score >= 70 ? 'high' : score >= 45 ? 'medium' : 'low';
return { detail, score, confidence, timeDeltaMs };
const isPathMatch = (logPath: string, detailPath: string): boolean => {
if (!logPath || !detailPath) return false;
return logPath === detailPath || logPath.startsWith(detailPath) || detailPath.startsWith(logPath);
};
const getErrorMessage = (err: unknown): string => {
@@ -236,16 +178,42 @@ export function useTraceResolver(options: UseTraceResolverOptions): UseTraceReso
const traceCandidates = useMemo(() => {
if (!traceLogLine) return [];
const scored = traceUsageDetails
.map((detail) => scoreTraceCandidate(traceLogLine, detail))
.filter((item): item is TraceCandidate => item !== null)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const aDelta = a.timeDeltaMs ?? Number.MAX_SAFE_INTEGER;
const bDelta = b.timeDeltaMs ?? Number.MAX_SAFE_INTEGER;
return aDelta - bDelta;
});
return scored.slice(0, 8);
const logPath = normalizeTracePath(traceLogLine.path);
if (!logPath) return [];
const logTimestampMs = traceLogLine.timestamp
? Date.parse(traceLogLine.timestamp)
: Number.NaN;
// Step 1: filter by path match
const pathMatched = traceUsageDetails.filter((detail) =>
isPathMatch(logPath, normalizeTracePath(detail.__endpointPath))
);
if (pathMatched.length === 0) return [];
// Step 2: try to extract model from log message, then filter by model
const logModel = extractModelFromMessage(traceLogLine.message);
const modelMatched = logModel
? pathMatched.filter(
(d) => d.__modelName?.toLowerCase() === logModel.toLowerCase()
)
: [];
// Step 3: prefer model-matched set; fall back to path-matched
const useModelSet = modelMatched.length > 0;
const source = useModelSet ? modelMatched : pathMatched;
return source
.map((detail) => {
const timeDeltaMs =
!Number.isNaN(logTimestampMs) && detail.__timestampMs > 0
? Math.abs(logTimestampMs - detail.__timestampMs)
: null;
return { detail, modelMatched: useModelSet, timeDeltaMs } satisfies TraceCandidate;
})
.sort((a, b) => (b.detail.__timestampMs || 0) - (a.detail.__timestampMs || 0))
.slice(0, TRACE_MAX_CANDIDATES);
}, [traceLogLine, traceUsageDetails]);
const resolveTraceSourceInfo = useCallback(
+25 -3
View File
@@ -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 })
};
+3
View File
@@ -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;
};
+38 -2
View File
@@ -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
};
+10 -2
View File
@@ -3,7 +3,7 @@
*/
import { create } from 'zustand';
import type { AntigravityQuotaState, ClaudeQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types';
import type { AntigravityQuotaState, ClaudeQuotaState, CodexQuotaState, GeminiCliQuotaState, KimiQuotaState } from '@/types';
type QuotaUpdater<T> = T | ((prev: T) => T);
@@ -12,10 +12,12 @@ interface QuotaStoreState {
claudeQuota: Record<string, ClaudeQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
kimiQuota: Record<string, KimiQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setClaudeQuota: (updater: QuotaUpdater<Record<string, ClaudeQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
setKimiQuota: (updater: QuotaUpdater<Record<string, KimiQuotaState>>) => void;
clearQuotaCache: () => void;
}
@@ -31,6 +33,7 @@ export const useQuotaStore = create<QuotaStoreState>((set) => ({
claudeQuota: {},
codexQuota: {},
geminiCliQuota: {},
kimiQuota: {},
setAntigravityQuota: (updater) =>
set((state) => ({
antigravityQuota: resolveUpdater(updater, state.antigravityQuota)
@@ -47,11 +50,16 @@ export const useQuotaStore = create<QuotaStoreState>((set) => ({
set((state) => ({
geminiCliQuota: resolveUpdater(updater, state.geminiCliQuota)
})),
setKimiQuota: (updater) =>
set((state) => ({
kimiQuota: resolveUpdater(updater, state.kimiQuota)
})),
clearQuotaCache: () =>
set({
antigravityQuota: {},
claudeQuota: {},
codexQuota: {},
geminiCliQuota: {}
geminiCliQuota: {},
kimiQuota: {}
})
}));
+6 -1
View File
@@ -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;
}
+84
View File
@@ -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;
}
@@ -197,3 +220,64 @@ export interface CodexQuotaState {
error?: string;
errorStatus?: number;
}
// Kimi API payload types
export interface KimiUsageDetail {
used?: number;
limit?: number;
remaining?: number;
name?: string;
title?: string;
resetAt?: string;
reset_at?: string;
resetTime?: string;
reset_time?: string;
resetIn?: number;
reset_in?: number;
ttl?: number;
}
export interface KimiLimitWindow {
duration?: number;
timeUnit?: string;
}
export interface KimiLimitItem {
name?: string;
title?: string;
scope?: string;
detail?: KimiUsageDetail;
window?: KimiLimitWindow;
used?: number;
limit?: number;
remaining?: number;
duration?: number;
timeUnit?: string;
resetAt?: string;
reset_at?: string;
resetIn?: number;
reset_in?: number;
ttl?: number;
}
export interface KimiUsagePayload {
usage?: KimiUsageDetail;
limits?: KimiLimitItem[];
}
export interface KimiQuotaRow {
id: string;
label?: string;
labelKey?: string;
labelParams?: Record<string, string | number>;
used: number;
limit: number;
resetHint?: string;
}
export interface KimiQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
rows: KimiQuotaRow[];
error?: string;
errorStatus?: number;
}
+19
View File
@@ -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;
+182 -37
View File
@@ -9,6 +9,11 @@ import type {
AntigravityModelsPayload,
GeminiCliParsedBucket,
GeminiCliQuotaBucketState,
KimiUsagePayload,
KimiUsageDetail,
KimiLimitItem,
KimiLimitWindow,
KimiQuotaRow,
} from '@/types';
import {
ANTIGRAVITY_QUOTA_GROUPS,
@@ -179,9 +184,9 @@ export function buildAntigravityQuotaGroups(
models: AntigravityModelsPayload
): AntigravityQuotaGroup[] {
const groups: AntigravityQuotaGroup[] = [];
let geminiProResetTime: string | undefined;
const [claudeDef, geminiProDef, flashDef, flashLiteDef, cuDef, geminiFlashDef, imageDef] =
ANTIGRAVITY_QUOTA_GROUPS;
const definitions = new Map(
ANTIGRAVITY_QUOTA_GROUPS.map((definition) => [definition.id, definition] as const)
);
const buildGroup = (
def: AntigravityQuotaGroupDefinition,
@@ -222,41 +227,181 @@ export function buildAntigravityQuotaGroups(
};
};
const claudeGroup = buildGroup(claudeDef);
if (claudeGroup) {
groups.push(claudeGroup);
}
const appendGroup = (
id: string,
overrideResetTime?: string
): AntigravityQuotaGroup | null => {
const definition = definitions.get(id);
if (!definition) return null;
const group = buildGroup(definition, overrideResetTime);
if (group) {
groups.push(group);
}
return group;
};
const geminiProGroup = buildGroup(geminiProDef);
if (geminiProGroup) {
geminiProResetTime = geminiProGroup.resetTime;
groups.push(geminiProGroup);
}
const flashGroup = buildGroup(flashDef);
if (flashGroup) {
groups.push(flashGroup);
}
const flashLiteGroup = buildGroup(flashLiteDef);
if (flashLiteGroup) {
groups.push(flashLiteGroup);
}
const cuGroup = buildGroup(cuDef);
if (cuGroup) {
groups.push(cuGroup);
}
const geminiFlashGroup = buildGroup(geminiFlashDef);
if (geminiFlashGroup) {
groups.push(geminiFlashGroup);
}
const imageGroup = buildGroup(imageDef, geminiProResetTime);
if (imageGroup) {
groups.push(imageGroup);
}
appendGroup('claude-gpt');
const gemini31ProGroup = appendGroup('gemini-3-1-pro-series');
const geminiProGroup = appendGroup('gemini-3-pro');
const geminiProResetTime = gemini31ProGroup?.resetTime ?? geminiProGroup?.resetTime;
appendGroup('gemini-2-5-flash');
appendGroup('gemini-2-5-flash-lite');
appendGroup('gemini-2-5-cu');
appendGroup('gemini-3-flash');
appendGroup('gemini-image', geminiProResetTime);
return groups;
}
function toInt(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return Math.floor(value);
if (typeof value === 'string') {
const parsed = Number(value.trim());
return Number.isFinite(parsed) ? Math.floor(parsed) : null;
}
return null;
}
type KimiRowLabel = Pick<KimiQuotaRow, 'label' | 'labelKey' | 'labelParams'>;
function kimiResetHint(data: Record<string, unknown>): string | undefined {
const absoluteKeys = ['reset_at', 'resetAt', 'reset_time', 'resetTime'];
for (const key of absoluteKeys) {
const raw = data[key];
if (typeof raw === 'string' && raw.trim()) {
try {
const truncated = raw.replace(/(\.\d{6})\d+/, '$1');
const date = new Date(truncated);
if (Number.isNaN(date.getTime())) continue;
const now = Date.now();
const delta = date.getTime() - now;
if (delta <= 0) return undefined;
const totalMinutes = Math.floor(delta / 60000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h`;
if (minutes > 0) return `${minutes}m`;
return '<1m';
} catch {
continue;
}
}
}
const relativeKeys = ['reset_in', 'resetIn', 'ttl'];
for (const key of relativeKeys) {
const raw = toInt(data[key]);
if (raw !== null && raw > 0) {
const hours = Math.floor(raw / 3600);
const minutes = Math.floor((raw % 3600) / 60);
if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h`;
if (minutes > 0) return `${minutes}m`;
return '<1m';
}
}
return undefined;
}
function kimiDurationToken(duration: number, rawTimeUnit: unknown): string {
const unit = typeof rawTimeUnit === 'string' ? rawTimeUnit.trim().toUpperCase() : '';
if (unit === 'MINUTES') {
return duration % 60 === 0 ? `${duration / 60}h` : `${duration}m`;
}
if (unit === 'HOURS') return `${duration}h`;
if (unit === 'DAYS') return `${duration}d`;
return `${duration}s`;
}
function kimiLimitLabel(
item: KimiLimitItem,
detail: KimiUsageDetail | KimiLimitItem,
window: KimiLimitWindow,
index: number
): KimiRowLabel {
for (const key of ['name', 'title', 'scope'] as const) {
const val = (item as Record<string, unknown>)[key] ?? (detail as Record<string, unknown>)[key];
if (typeof val === 'string' && val.trim()) return { label: val.trim() };
}
const duration =
toInt(window.duration) ??
toInt((item as Record<string, unknown>).duration) ??
toInt((detail as Record<string, unknown>).duration);
const timeUnit =
(window as Record<string, unknown>).timeUnit ??
(item as Record<string, unknown>).timeUnit ??
(detail as Record<string, unknown>).timeUnit;
if (duration !== null && duration > 0) {
return {
labelKey: 'kimi_quota.limit_window',
labelParams: {
duration: kimiDurationToken(duration, timeUnit),
},
};
}
return {
labelKey: 'kimi_quota.limit_index',
labelParams: {
index: index + 1,
},
};
}
function toKimiUsageRow(
data: Record<string, unknown>,
fallbackLabel: KimiRowLabel
): (KimiRowLabel & { used: number; limit: number; resetHint?: string }) | null {
const limit = toInt(data.limit);
let used = toInt(data.used);
if (used === null) {
const remaining = toInt(data.remaining);
if (remaining !== null && limit !== null) {
used = limit - remaining;
}
}
if (used === null && limit === null) return null;
const explicitLabel =
(typeof data.name === 'string' && data.name.trim()) ||
(typeof data.title === 'string' && data.title.trim());
const label = explicitLabel ? { label: explicitLabel } : fallbackLabel;
return {
...label,
used: used ?? 0,
limit: limit ?? 0,
resetHint: kimiResetHint(data),
};
}
export function buildKimiQuotaRows(payload: KimiUsagePayload): KimiQuotaRow[] {
const rows: KimiQuotaRow[] = [];
const usage = payload.usage;
if (usage && typeof usage === 'object') {
const summary = toKimiUsageRow(usage as Record<string, unknown>, {
labelKey: 'kimi_quota.weekly_limit',
});
if (summary) {
rows.push({ id: 'summary', ...summary });
}
}
const limits = payload.limits;
if (Array.isArray(limits)) {
limits.forEach((item, idx) => {
const detail = (item.detail && typeof item.detail === 'object' ? item.detail : item) as KimiUsageDetail | KimiLimitItem;
const window = (item.window && typeof item.window === 'object' ? item.window : {}) as KimiLimitWindow;
const fallbackLabel = kimiLimitLabel(item, detail, window, idx);
const row = toKimiUsageRow(detail as Record<string, unknown>, fallbackLabel);
if (row) {
rows.push({ id: `limit-${idx}`, ...row });
}
});
}
return rows;
}
+21 -8
View File
@@ -34,6 +34,10 @@ export const TYPE_COLORS: Record<string, TypeColorSet> = {
light: { bg: '#fff3e0', text: '#ef6c00' },
dark: { bg: '#e65100', text: '#ffb74d' },
},
kimi: {
light: { bg: '#fff4e5', text: '#ad6800' },
dark: { bg: '#7c4a03', text: '#ffd591' },
},
antigravity: {
light: { bg: '#e0f7fa', text: '#006064' },
dark: { bg: '#004d40', text: '#80deea' },
@@ -69,18 +73,18 @@ export const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
{
id: 'claude-gpt',
label: 'Claude/GPT',
identifiers: [
'claude-sonnet-4-5-thinking',
'claude-opus-4-5-thinking',
'claude-sonnet-4-5',
'gpt-oss-120b-medium',
],
identifiers: ['claude-sonnet-4-6', 'claude-opus-4-6-thinking', 'gpt-oss-120b-medium'],
},
{
id: 'gemini-3-pro',
label: 'Gemini 3 Pro',
identifiers: ['gemini-3-pro-high', 'gemini-3-pro-low'],
},
{
id: 'gemini-3-1-pro-series',
label: 'Gemini 3.1 Pro Series',
identifiers: ['gemini-3.1-pro-high', 'gemini-3.1-pro-low'],
},
{
id: 'gemini-2-5-flash',
label: 'Gemini 2.5 Flash',
@@ -103,8 +107,8 @@ export const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
},
{
id: 'gemini-image',
label: 'gemini-3-pro-image',
identifiers: ['gemini-3-pro-image'],
label: 'gemini-3.1-flash-image',
identifiers: ['gemini-3.1-flash-image'],
labelFromModel: true,
},
];
@@ -152,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 = {
@@ -178,3 +184,10 @@ export const CODEX_REQUEST_HEADERS = {
'Content-Type': 'application/json',
'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal',
};
// Kimi API configuration
export const KIMI_USAGE_URL = 'https://api.kimi.com/coding/v1/usages';
export const KIMI_REQUEST_HEADERS = {
Authorization: 'Bearer $TOKEN$',
};
+6
View File
@@ -2,6 +2,7 @@
* Formatting functions for quota display.
*/
import type { TFunction } from 'i18next';
import type { CodexUsageWindow } from '@/types';
import { normalizeNumberValue } from './parsers';
@@ -66,3 +67,8 @@ export function getStatusFromError(err: unknown): number | undefined {
}
return undefined;
}
export function formatKimiResetHint(t: TFunction, hint?: string): string {
if (!hint) return '';
return t('kimi_quota.reset_hint', { hint });
}
+48 -11
View File
@@ -2,7 +2,7 @@
* Normalization and parsing functions for quota data.
*/
import type { ClaudeUsagePayload, CodexUsagePayload, GeminiCliQuotaPayload } from '@/types';
import type { ClaudeUsagePayload, CodexUsagePayload, GeminiCliQuotaPayload, KimiUsagePayload } from '@/types';
import { normalizeAuthIndex } from '@/utils/usage';
const GEMINI_CLI_MODEL_SUFFIX = '_vertex';
@@ -104,20 +104,40 @@ export function parseIdTokenPayload(value: unknown): Record<string, unknown> | n
}
export function parseAntigravityPayload(payload: unknown): Record<string, unknown> | 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 Record<string, unknown>;
} catch {
const toRecord = (value: unknown): Record<string, unknown> | null => {
if (value === undefined || value === null) return null;
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
return null;
}
return null;
}
if (typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return null;
};
const parsed = toRecord(payload);
if (!parsed) return null;
if ('models' in parsed) {
return parsed;
}
if (typeof payload === 'object') {
return payload as Record<string, unknown>;
const nested = toRecord(parsed.body);
if (nested) {
return nested;
}
return null;
return parsed;
}
export function parseClaudeUsagePayload(payload: unknown): ClaudeUsagePayload | null {
@@ -170,3 +190,20 @@ export function parseGeminiCliQuotaPayload(payload: unknown): GeminiCliQuotaPayl
}
return null;
}
export function parseKimiUsagePayload(payload: unknown): KimiUsagePayload | 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 KimiUsagePayload;
} catch {
return null;
}
}
if (typeof payload === 'object') {
return payload as KimiUsagePayload;
}
return null;
}
+4
View File
@@ -39,6 +39,10 @@ export function isGeminiCliFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'gemini-cli';
}
export function isKimiFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'kimi';
}
export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
const raw = file['runtime_only'] ?? file.runtimeOnly;
if (typeof raw === 'boolean') return raw;