mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
9 Commits
Generated
+75
-7
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -22,6 +22,8 @@ import type {
|
||||
GeminiCliParsedBucket,
|
||||
GeminiCliQuotaBucketState,
|
||||
GeminiCliQuotaState,
|
||||
KimiQuotaRow,
|
||||
KimiQuotaState,
|
||||
} from '@/types';
|
||||
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
|
||||
import {
|
||||
@@ -34,6 +36,8 @@ import {
|
||||
CODEX_REQUEST_HEADERS,
|
||||
GEMINI_CLI_QUOTA_URL,
|
||||
GEMINI_CLI_REQUEST_HEADERS,
|
||||
KIMI_USAGE_URL,
|
||||
KIMI_REQUEST_HEADERS,
|
||||
normalizeGeminiCliModelId,
|
||||
normalizeNumberValue,
|
||||
normalizePlanType,
|
||||
@@ -43,13 +47,16 @@ import {
|
||||
parseClaudeUsagePayload,
|
||||
parseCodexUsagePayload,
|
||||
parseGeminiCliQuotaPayload,
|
||||
parseKimiUsagePayload,
|
||||
resolveCodexChatgptAccountId,
|
||||
resolveCodexPlanType,
|
||||
resolveGeminiCliProjectId,
|
||||
formatCodexResetLabel,
|
||||
formatQuotaResetTime,
|
||||
formatKimiResetHint,
|
||||
buildAntigravityQuotaGroups,
|
||||
buildGeminiCliQuotaBuckets,
|
||||
buildKimiQuotaRows,
|
||||
createStatusError,
|
||||
getStatusFromError,
|
||||
isAntigravityFile,
|
||||
@@ -57,6 +64,7 @@ import {
|
||||
isCodexFile,
|
||||
isDisabledAuthFile,
|
||||
isGeminiCliFile,
|
||||
isKimiFile,
|
||||
isRuntimeOnlyAuthFile,
|
||||
} from '@/utils/quota';
|
||||
import { normalizeAuthIndex } from '@/utils/usage';
|
||||
@@ -65,7 +73,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 +82,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;
|
||||
}
|
||||
|
||||
@@ -859,3 +869,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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -104,69 +95,17 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
? 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 +119,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 +133,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 +150,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 +168,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 +213,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 +247,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,7 @@
|
||||
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, 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';
|
||||
@@ -18,6 +18,7 @@ type QuotaState = { status?: string; error?: string; errorStatus?: number } | un
|
||||
const getQuotaConfig = (type: QuotaProviderType) => {
|
||||
if (type === 'antigravity') return ANTIGRAVITY_CONFIG;
|
||||
if (type === 'codex') return CODEX_CONFIG;
|
||||
if (type === 'kimi') return KIMI_CONFIG;
|
||||
return GEMINI_CLI_CONFIG;
|
||||
};
|
||||
|
||||
@@ -35,12 +36,14 @@ export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
|
||||
const quota = useQuotaStore((state) => {
|
||||
if (quotaType === 'antigravity') return state.antigravityQuota[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 === '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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ 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' | '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', '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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,7 +78,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 {
|
||||
|
||||
@@ -619,6 +619,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 +1039,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",
|
||||
|
||||
@@ -622,6 +622,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 +1042,7 @@
|
||||
"trace_confidence_low": "Низкая",
|
||||
"trace_score": "Оценка {{score}}",
|
||||
"trace_delta_seconds": "Δt {{seconds}}с",
|
||||
"trace_model_matched": "Модель совпала",
|
||||
"trace_request_id": "Request ID",
|
||||
"trace_method": "Метод",
|
||||
"trace_path": "Путь",
|
||||
|
||||
@@ -619,6 +619,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 +1039,7 @@
|
||||
"trace_confidence_low": "低",
|
||||
"trace_score": "分数 {{score}}",
|
||||
"trace_delta_seconds": "时间差 {{seconds}} 秒",
|
||||
"trace_model_matched": "模型匹配",
|
||||
"trace_request_id": "请求 ID",
|
||||
"trace_method": "请求方法",
|
||||
"trace_path": "路径",
|
||||
|
||||
@@ -322,6 +322,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 +609,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
@@ -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
|
||||
|
||||
@@ -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';
|
||||
@@ -123,10 +123,22 @@ export function ConfigPage() {
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const nextMergedYaml = applyVisualChangesToYaml(content);
|
||||
// In source mode, save exactly what the user edited. In visual mode, materialize visual changes into YAML.
|
||||
const nextMergedYaml = activeTab === 'source' ? content : applyVisualChangesToYaml(content);
|
||||
const latestServerYaml = await configFileApi.fetchConfigYaml();
|
||||
|
||||
if (latestServerYaml === nextMergedYaml) {
|
||||
// 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 +148,7 @@ export function ConfigPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
setServerYaml(latestServerYaml);
|
||||
setServerYaml(diffOriginal);
|
||||
setMergedYaml(nextMergedYaml);
|
||||
setDiffModalOpen(true);
|
||||
} catch (err: unknown) {
|
||||
@@ -156,10 +168,13 @@ 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);
|
||||
@@ -167,7 +182,7 @@ export function ConfigPage() {
|
||||
|
||||
setActiveTab(tab);
|
||||
localStorage.setItem('config-management:tab', tab);
|
||||
}, [activeTab, applyVisualChangesToYaml, content, loadVisualValuesFromYaml]);
|
||||
}, [activeTab, applyVisualChangesToYaml, content, loadVisualValuesFromYaml, visualDirty]);
|
||||
|
||||
// Search functionality
|
||||
const performSearch = useCallback((query: string, direction: 'next' | 'prev' = 'next') => {
|
||||
|
||||
@@ -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
@@ -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', {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {}
|
||||
})
|
||||
}));
|
||||
|
||||
@@ -197,3 +197,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;
|
||||
}
|
||||
|
||||
+182
-37
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -178,3 +182,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$',
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user