style(usage): show reasoning effort under the model name

Drop the separate reasoning-effort column from request details and render the raw effort, or auto, directly beneath the model name. Include it in the model tooltip, reset saved layouts that still use the old default column, and keep the field in CSV export.
This commit is contained in:
lzt404 committed 2026-10-06 01:22:41 +08:00
1 parent 56a88ccb6f
commit 6e34850780
22 files changed
+1129 -383

No files matched your search

+2 -1
View File
@@ -10,6 +10,7 @@
"core:window:allow-theme",
"core:window:allow-set-background-color",
"core:webview:allow-set-webview-background-color",
"dialog:allow-open"
"dialog:allow-open",
"dialog:allow-save"
]
}
+1
View File
@@ -2725,6 +2725,7 @@ fn main() {
usage::get_usage_overview,
usage::get_usage_analysis,
usage::get_usage_events,
usage::save_usage_events_export,
usage::get_usage_pricing,
usage::get_usage_storage_settings,
usage::repair_usage_cache_records,
+108 -1
View File
@@ -21,7 +21,7 @@ use sha2::{Digest, Sha256};
use std::{
collections::{BTreeMap, HashMap, HashSet},
fs,
path::{Path, PathBuf},
path::{Component, Path, PathBuf},
sync::{LazyLock, Mutex},
time::{Duration, SystemTime, UNIX_EPOCH},
};
@@ -4232,6 +4232,113 @@ fn load_api_key_categories(
Ok(categories)
}
#[tauri::command]
pub(crate) async fn save_usage_events_export(path: String, contents: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || write_usage_events_export(&path, &contents))
.await
.map_err(|error| format!("Failed to save usage export: {error}"))?
}
fn write_usage_events_export(path: &str, contents: &str) -> Result<(), String> {
let path = validated_usage_export_path(path)?;
fs::write(&path, contents.as_bytes())
.map_err(|error| format!("Failed to write usage export {}: {error}", path.display()))
}
fn validated_usage_export_path(path: &str) -> Result<PathBuf, String> {
let trimmed = path.trim();
if trimmed.is_empty() || trimmed.contains('\0') {
return Err("Usage export path is invalid".to_string());
}
let path = PathBuf::from(trimmed);
if !path.is_absolute()
|| path
.components()
.any(|component| matches!(component, Component::ParentDir))
{
return Err("Usage export path must be an absolute file path".to_string());
}
if path.file_name().is_none_or(|name| name.is_empty()) {
return Err("Usage export path must include a file name".to_string());
}
if path.is_dir() {
return Err("Usage export path is a directory".to_string());
}
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.ok_or_else(|| "Usage export path has no directory".to_string())?;
if !parent.is_dir() {
return Err(format!(
"Usage export directory does not exist: {}",
parent.display()
));
}
Ok(path)
}
#[cfg(test)]
mod save_usage_events_export_tests {
use super::{validated_usage_export_path, write_usage_events_export};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
struct TempDir(PathBuf);
impl TempDir {
fn new(label: &str) -> Self {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!(
"cpa-usage-export-{}-{}-{}",
std::process::id(),
label,
stamp
));
fs::create_dir_all(&path).unwrap();
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn writes_the_selected_csv_and_replaces_an_existing_file() {
let directory = TempDir::new("write");
let path = directory.path().join("usage.csv");
fs::write(&path, "old").unwrap();
let selected = path.to_string_lossy().into_owned();
write_usage_events_export(&selected, "\u{feff}id\r\n1").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "\u{feff}id\r\n1");
}
#[test]
fn rejects_relative_paths_missing_directories_and_directories() {
let directory = TempDir::new("reject");
assert!(validated_usage_export_path("usage.csv").is_err());
assert!(validated_usage_export_path(&directory.path().to_string_lossy()).is_err());
let missing = directory
.path()
.join("missing")
.join("usage.csv")
.to_string_lossy()
.into_owned();
assert!(write_usage_events_export(&missing, "id").is_err());
}
}
#[tauri::command]
pub(crate) async fn get_usage_events(
query: UsageQuery,
+341
View File
@@ -0,0 +1,341 @@
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
} from 'react';
import { Check, ChevronDown, LoaderCircle, RefreshCw, Search, X } from 'lucide-react';
import {
agentModelAlias,
filterAgentModels,
findAgentModel,
} from '../services/agentModelPicker';
import type { ModelOption } from '../services/modelService';
import { useI18n } from '../i18n';
export type AgentModelPickerProps = {
models: ModelOption[];
value: string;
loading?: boolean;
error?: string;
disabled?: boolean;
onChange: (value: string) => void;
onRefresh?: () => void;
allowCustomValue?: boolean;
editable?: { label: string; placeholder: string; maxLength: number };
menuClassName?: string;
};
type AgentModelDropdownLayout = {
top: number;
left: number;
width: number;
height: number;
};
export function AgentModelPicker({
models,
value,
loading = false,
error = '',
disabled = false,
onChange,
onRefresh,
allowCustomValue = false,
editable,
menuClassName = '',
}: AgentModelPickerProps) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
const [dropdownLayout, setDropdownLayout] = useState<AgentModelDropdownLayout | null>(null);
const [dialogOverlay, setDialogOverlay] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const valueRef = useRef<HTMLInputElement>(null);
const listboxId = useId();
const visibleModels = useMemo(
() => editable && !search.trim() ? models : filterAgentModels(models, search),
[models, search, editable],
);
const choices = useMemo(() => {
const options = visibleModels.map((model) => ({ name: model.name, alias: model.alias ?? '' }));
if (allowCustomValue && search.trim() && !findAgentModel(models, search)) {
options.push({ name: search.trim(), alias: t('agents.model.useCustom') });
}
return options;
}, [visibleModels, allowCustomValue, search, models, t]);
const selectedModel = findAgentModel(models, value);
const selectedName = selectedModel?.name ?? (allowCustomValue || editable ? value.trim() : '');
const selectedAlias = selectedName ? agentModelAlias(models, selectedName) : '';
const updateDropdownLayout = useCallback(() => {
const root = rootRef.current;
if (!root) return;
const rect = root.getBoundingClientRect();
const dialog = root.closest('dialog');
const footer = dialog?.querySelector('.credential-settings-footer');
const heading = dialog?.querySelector('.credential-settings-heading');
const frame = dialog?.getBoundingClientRect();
const footerTop = footer?.getBoundingClientRect().top;
const headingBottom = heading?.getBoundingClientRect().bottom;
const edgeGap = 12;
const triggerGap = 6;
const preferredHeight = 282;
const minimumHeight = 150;
const bounds = {
top: (headingBottom ?? frame?.top ?? 0) + edgeGap,
bottom: (footerTop ?? frame?.bottom ?? window.innerHeight) - edgeGap,
left: (frame?.left ?? 0) + edgeGap,
right: (frame?.right ?? window.innerWidth) - edgeGap,
};
const scroller = dialog?.querySelector('.credential-settings-body');
if (scroller) {
const view = scroller.getBoundingClientRect();
if (rect.bottom < view.top + 4 || rect.top > view.bottom - 4) {
setOpen(false);
return;
}
}
const spaceBelow = Math.max(0, bounds.bottom - rect.bottom - triggerGap);
const spaceAbove = Math.max(0, rect.top - triggerGap - bounds.top);
const placeAbove = spaceBelow < minimumHeight && spaceAbove > spaceBelow;
const availableHeight = placeAbove ? spaceAbove : spaceBelow;
const limit = Math.max(0, bounds.bottom - bounds.top);
const height = Math.min(preferredHeight, limit, Math.max(Math.min(minimumHeight, limit), availableHeight));
const width = Math.min(rect.width, Math.max(0, bounds.right - bounds.left));
const left = Math.min(Math.max(bounds.left, rect.left), Math.max(bounds.left, bounds.right - width));
const desiredTop = placeAbove ? rect.top - triggerGap - height : rect.bottom + triggerGap;
const top = Math.min(Math.max(bounds.top, desiredTop), Math.max(bounds.top, bounds.bottom - height));
setDropdownLayout({ top, left, width, height });
}, []);
useLayoutEffect(() => {
if (!open) {
setDropdownLayout(null);
setDialogOverlay(false);
return undefined;
}
const inDialog = Boolean(rootRef.current?.closest('dialog'));
if (inDialog !== dialogOverlay) {
setDialogOverlay(inDialog);
return undefined;
}
updateDropdownLayout();
const menu = menuRef.current;
if (dialogOverlay && menu && !menu.matches(':popover-open')) menu.showPopover();
const onScroll = (event: Event) => {
if (menuRef.current?.contains(event.target as Node)) return;
updateDropdownLayout();
};
window.addEventListener('resize', updateDropdownLayout);
window.addEventListener('scroll', onScroll, true);
return () => {
window.removeEventListener('resize', updateDropdownLayout);
window.removeEventListener('scroll', onScroll, true);
};
}, [open, dialogOverlay, updateDropdownLayout]);
useEffect(() => {
if (!open) return undefined;
const close = (event: MouseEvent) => {
const target = event.target as Node;
if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) setOpen(false);
};
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [open]);
useEffect(() => {
if (!open) return;
if (!editable) setSearch('');
const selectedIndex = (editable ? visibleModels : filterAgentModels(models, '')).findIndex(
(model) => model.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase(),
);
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
if (!editable) requestAnimationFrame(() => searchRef.current?.focus());
}, [open]);
useEffect(() => {
setActiveIndex((current) => Math.min(current, Math.max(choices.length - 1, 0)));
}, [choices.length]);
const choose = (name: string) => {
onChange(name);
setOpen(false);
if (editable) valueRef.current?.focus();
};
const moveActive = (offset: number) => {
if (choices.length === 0) return;
setActiveIndex((current) => (current + offset + choices.length) % choices.length);
};
const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
if (!open) setOpen(true);
else moveActive(1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
if (!open) setOpen(true);
else moveActive(-1);
} else if (event.key === 'Enter' && open && choices[activeIndex]) {
event.preventDefault();
choose(choices[activeIndex].name);
} else if (event.key === 'Escape') {
event.preventDefault();
setOpen(false);
}
};
return (
<div className={`agent-model-picker ${open ? 'open' : ''}`} ref={rootRef}
onMouseDown={(event) => {
if (event.button === 0 && event.currentTarget.contains(document.activeElement)
&& event.target instanceof Element && event.target.closest('button')) {
event.preventDefault();
}
}}
onBlur={(event) => {
const next = event.relatedTarget as Node | null;
if (!event.currentTarget.contains(next) && !menuRef.current?.contains(next)) setOpen(false);
}}>
{editable ? (
<div className="agent-model-trigger agent-model-editable-trigger">
<input ref={valueRef} type="text" value={value} aria-label={editable.label}
placeholder={editable.placeholder} maxLength={editable.maxLength} disabled={disabled}
spellCheck={false} autoComplete="off" role="combobox" aria-autocomplete="list"
aria-controls={listboxId} aria-expanded={open}
onClick={() => { setSearch(''); setOpen(true); }} onKeyDown={handleSearchKeyDown}
onChange={(event) => {
onChange(event.currentTarget.value);
setSearch(event.currentTarget.value);
setActiveIndex(0);
setOpen(true);
}} />
<button type="button" className="icon-button quiet" aria-label={editable.label}
aria-haspopup="listbox" aria-expanded={open} aria-controls={listboxId}
disabled={disabled} onClick={() => { setSearch(''); setOpen((current) => !current); }}>
<ChevronDown size={17} aria-hidden />
</button>
</div>
) : <button
type="button"
className="agent-model-trigger"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listboxId}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
onKeyDown={(event) => {
if (!open && ['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key)) {
event.preventDefault();
setOpen(true);
}
}}
>
<span>
<strong title={selectedName || undefined}>
{selectedName || (loading ? t('agents.model.loading') : error ? t('agents.model.loadFailed') : models.length ? t('agents.model.select') : t('agents.model.none'))}
</strong>
{selectedAlias ? <small title={selectedAlias}>{selectedAlias}</small> : null}
</span>
<ChevronDown size={17} aria-hidden />
</button>}
{open ? (
<div
ref={menuRef}
popover={dialogOverlay ? 'manual' : undefined}
className={`agent-model-dropdown${editable ? ' agent-model-dropdown-editable' : ''}${menuClassName ? ` ${menuClassName}` : ''}`}
style={dropdownLayout
? { top: dropdownLayout.top, left: dropdownLayout.left, width: dropdownLayout.width, height: dropdownLayout.height }
: { top: 0, left: 0, width: 0, height: 0, visibility: 'hidden' }}
>
{!editable ? <div className="agent-model-search">
<Search size={15} aria-hidden />
<input
ref={searchRef}
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setActiveIndex(0);
}}
onKeyDown={handleSearchKeyDown}
placeholder={t('agents.model.search')}
role="combobox"
aria-controls={listboxId}
aria-expanded="true"
/>
{search ? (
<button
type="button"
className="icon-button quiet"
onClick={() => {
setSearch('');
setActiveIndex(0);
searchRef.current?.focus();
}}
title={t('agents.model.clearSearch')}
aria-label={t('agents.model.clearSearch')}
>
<X size={14} aria-hidden="true" />
</button>
) : null}
{onRefresh ? <button type="button" className="icon-button quiet" onClick={onRefresh} disabled={loading} title={t('agents.model.refresh')} aria-label={t('agents.model.refresh')}>
<RefreshCw size={14} className={loading ? 'spin' : ''} aria-hidden="true" />
</button> : null}
</div> : null}
<div className="agent-model-list" id={listboxId} role="listbox">
{loading && models.length === 0 && choices.length === 0 ? (
<div className="agent-model-empty"><LoaderCircle size={18} className="spin" />{t('agents.model.fetching')}</div>
) : error && models.length === 0 && choices.length === 0 ? (
<div className="agent-model-empty error"><strong>{t('agents.model.loadFailed')}</strong><span>{error}</span></div>
) : choices.length === 0 ? (
<div className="agent-model-empty">
{editable ? <span>{t('agents.claudeDesktopMapping.customAliasHint')}</span> : <>
<strong>{search.trim() ? t('agents.model.noMatch') : t('agents.model.unavailable')}</strong>
<span>{search.trim() ? t('agents.model.tryKeywords') : t('agents.model.connectFirst')}</span>
</>}
</div>
) : choices.map((choice, index) => {
const selected = choice.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase();
return (
<button
type="button"
role="option"
aria-selected={selected}
className={`agent-model-option ${selected ? 'selected' : ''} ${index === activeIndex ? 'active' : ''}`}
key={choice.name}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => choose(choice.name)}
>
<span>
<strong title={choice.name}>{choice.name}</strong>
<small>{choice.alias || t('agents.model.available')}</small>
</span>
{selected ? <Check size={16} aria-hidden /> : null}
</button>
);
})}
</div>
<div className="agent-model-dropdown-footer">
<span>{t(editable ? 'agents.claudeDesktopMapping.suggestionCount' : 'agents.model.count', { count: models.length })}</span>
{error && models.length > 0 ? <span className="error">{t('agents.model.stale')}</span> : null}
</div>
</div>
) : null}
</div>
);
}
+61 -7
View File
@@ -1,6 +1,8 @@
.credential-settings-dialog {
width: min(760px, calc(100vw - 32px));
width: min(820px, calc(100vw - 32px));
max-height: min(900px, calc(100dvh - 40px));
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
border: 1px solid var(--border-color);
@@ -10,17 +12,17 @@
box-shadow: 0 24px 80px #0004;
}
.credential-settings-dialog::backdrop { background: #0b122080; backdrop-filter: blur(3px); }
.credential-settings-dialog form { display: flex; flex-direction: column; max-height: min(898px, calc(100dvh - 42px)); min-height: 0; }
.credential-settings-dialog form { display: flex; flex: 1 1 auto; flex-direction: column; width: 100%; min-height: 0; }
.credential-settings-heading, .credential-settings-footer { flex-shrink: 0; }
.credential-settings-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 20px 24px; border-bottom: 1px solid var(--border-color); }
.credential-settings-heading > div { min-width: 0; }
.credential-settings-heading h2 { display: flex; align-items: center; gap: 9px; margin: 0; font-size: var(--font-size-dialog-title); line-height: var(--line-height-tight); }
.credential-settings-heading p { margin: 6px 0 0; color: var(--text-tertiary); font-size: var(--font-size-meta); overflow-wrap: anywhere; }
.credential-settings-body { overflow-y: auto; min-height: 0; padding: 22px 24px; overscroll-behavior: contain; }
.credential-settings-body { overflow: auto; flex: 1 1 auto; min-height: 0; padding: 22px 24px 28px; overscroll-behavior: contain; }
.credential-settings-body fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
.credential-settings-section + .credential-settings-section { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border-color); }
.credential-settings-section h3 { margin: 0 0 15px; font-size: var(--font-size-section); line-height: var(--line-height-tight); }
.credential-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 20px; }
.credential-settings-section + .credential-settings-section { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border-color); }
.credential-settings-section h3 { margin: 0 0 8px; font-size: var(--font-size-section); line-height: var(--line-height-tight); }
.credential-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 16px; }
.credential-settings-field { display: grid; align-content: start; gap: 7px; min-width: 0; font-size: var(--font-size-label); }
.credential-settings-field > span { font-weight: var(--font-weight-medium); }
.credential-settings-field + .credential-settings-field { margin-top: 16px; }
@@ -40,7 +42,7 @@
.credential-settings-models label { display: flex; align-items: flex-start; gap: 8px; overflow-wrap: anywhere; }
.credential-settings-models input { accent-color: var(--primary-color); margin-top: 3px; }
.credential-settings-models span { min-width: 0; }
.credential-settings-footer { padding: 16px 24px; border-top: 1px solid var(--border-color); }
.credential-settings-footer { position: relative; z-index: 1; flex: 0 0 auto; padding: 16px 24px; border-top: 1px solid var(--border-color); background: var(--bg-card); }
.credential-settings-actions { display: flex; gap: 10px; justify-content: flex-end; }
.credential-settings-error { color: var(--failure-badge-text); margin: 0 0 12px; font-size: var(--font-size-caption); overflow-wrap: anywhere; max-height: 90px; overflow-y: auto; }
.credential-settings-warning { color: var(--amber-text); }
@@ -51,3 +53,55 @@
.credential-settings-grid { grid-template-columns: minmax(0, 1fr); }
.credential-settings-heading, .credential-settings-body, .credential-settings-footer { padding: 16px; }
}
.credential-settings-lead { margin: 0 0 10px; color: var(--text-tertiary); font-size: var(--font-size-meta); line-height: var(--line-height-body); }
.credential-settings-span { grid-column: 1 / -1; }
.credential-settings-inline-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.credential-alias-list, .credential-header-list { display: grid; gap: 12px; }
.credential-alias-card { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-primary); }
.credential-alias-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 12px; }
.credential-alias-fields .credential-settings-field { margin: 0; }
.credential-alias-fields .credential-settings-field:last-child { grid-column: 1 / -1; }
.credential-settings-dialog .agent-model-trigger { box-sizing: border-box; min-height: 41px; padding: 0 4px 0 11px; border-color: var(--border-color); border-radius: 7px; background: var(--bg-primary); color: var(--text-primary); }
.credential-settings-dialog .agent-model-trigger strong { color: var(--text-primary); }
.credential-settings-dialog .agent-model-editable-trigger input { box-sizing: border-box; width: 100%; height: 39px; margin: 0; padding: 0; border: 0; border-radius: 0; outline: 0; background: transparent; box-shadow: none; color: inherit; font: inherit; font-size: inherit; line-height: 1.5; }
.credential-settings-dialog .credential-model-menu {
position: fixed;
z-index: 2;
margin: 0;
max-height: none;
overflow: hidden;
border: 1px solid var(--border-color);
background: var(--bg-card);
color: var(--text-primary);
box-shadow: 0 16px 40px #0003;
}
.credential-settings-dialog .credential-model-menu .agent-model-option {
background: var(--bg-card);
color: var(--text-primary);
}
.credential-settings-dialog .credential-model-menu .agent-model-option:hover,
.credential-settings-dialog .credential-model-menu .agent-model-option.active {
background: var(--bg-secondary);
}
.credential-settings-dialog .credential-model-menu .agent-model-option.selected {
background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-card));
}
.credential-settings-dialog .credential-model-menu .agent-model-option small,
.credential-settings-dialog .credential-model-menu .agent-model-dropdown-footer {
color: var(--text-tertiary);
}
.credential-alias-card-heading, .credential-header-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.credential-alias-card-heading strong { font-size: var(--font-size-meta); }
.credential-alias-checks { display: flex; flex-wrap: wrap; gap: 8px 16px; }
.credential-alias-checks label { display: flex; align-items: center; gap: 8px; font-size: var(--font-size-meta); }
.credential-header-row { display: grid; grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr) auto; }
.credential-header-row input { min-width: 0; }
@media (max-width: 560px) {
.credential-header-row { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 560px) {
.credential-alias-fields { grid-template-columns: minmax(0, 1fr); }
}
+16 -20
View File
@@ -6,22 +6,23 @@ import { managementApi } from '../services/managementApi';
import { loadAuthFileSettings, saveAuthFileSettings, type AuthFileSettingsDraft, type BooleanOverride } from '../services/authFileSettings';
import { modelMatchesRule, normalizeOAuthExcludedRules, oauthModelCandidates, oauthModelsFromPayload, setOAuthModelsExcluded, type OAuthModelDefinition } from '../services/oauthModels';
import './AuthFileSettingsDialog.css';
import { StructuredConfigEditor, useConfigText } from './StructuredConfigEditor';
import { credentialAdvancedShape } from '../services/credentialAdvancedSettings';
import { CredentialAdvancedFields, CredentialHeadersEditor, credentialProviderKey } from './CredentialAdvancedFields';
import { modelSearchText, type ModelOption } from '../services/modelService';
export function AuthFileSettingsDialog({ name, onClose, onSaved }: {
export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }: {
name: string;
provider?: string;
onClose: () => void;
onSaved: () => void;
}) {
const { t } = useI18n();
const tx = useConfigText();
const id = useId();
const dialogRef = useRef<HTMLDialogElement>(null);
const savingRef = useRef(false);
const [original, setOriginal] = useState<AuthFileSettingsDraft | null>(null);
const [draft, setDraft] = useState<AuthFileSettingsDraft | null>(null);
const [models, setModels] = useState<OAuthModelDefinition[]>([]);
const [pickerModels, setPickerModels] = useState<ModelOption[]>([]);
const [catalogError, setCatalogError] = useState('');
const [catalogLoading, setCatalogLoading] = useState(true);
const [search, setSearch] = useState('');
@@ -57,7 +58,10 @@ export function AuthFileSettingsDialog({ name, onClose, onSaved }: {
if (active) setError(reason instanceof Error ? reason.message : String(reason));
}).finally(() => { if (active) setLoading(false); });
void managementApi.get('/credentials/models', { name }).then((payload) => {
if (active) setModels(oauthModelsFromPayload(payload));
const catalog = oauthModelsFromPayload(payload);
if (!active) return;
setModels(catalog);
setPickerModels(catalog.map((model) => ({ name: model.id, displayName: model.displayName })));
}).catch((reason: unknown) => {
if (active) setCatalogError(reason instanceof Error ? reason.message : String(reason));
}).finally(() => { if (active) setCatalogLoading(false); });
@@ -92,8 +96,10 @@ export function AuthFileSettingsDialog({ name, onClose, onSaved }: {
}
};
const rules = normalizeOAuthExcludedRules((draft?.excluded_models ?? '').split(/\r?\n/));
const candidates = oauthModelCandidates(models, rules).filter((model) =>
`${model.id} ${model.displayName ?? ''}`.toLowerCase().includes(search.trim().toLowerCase()));
const query = search.trim().toLowerCase();
const candidates = oauthModelCandidates(models, rules).filter((model) => modelSearchText({ name: model.id, displayName: model.displayName }).includes(query));
const resolvedProvider = credentialProviderKey(name, provider);
const showWebsockets = resolvedProvider === 'codex' || resolvedProvider === 'xai' || draft?.websockets === 'true' || draft?.websockets === 'false';
const textField = (key: 'prefix' | 'proxy_url' | 'priority' | 'weight') => (
<label className="credential-settings-field">
<span>{t(`authFiles.settings.${key}`)} <code>{key}</code></span>
@@ -142,7 +148,7 @@ export function AuthFileSettingsDialog({ name, onClose, onSaved }: {
<fieldset disabled={saving}>
<section className="credential-settings-section">
<h3>{t('authFiles.settings.routing')}</h3>
<div className="credential-settings-grid">{textField('prefix')}{textField('proxy_url')}{textField('priority')}{textField('weight')}{booleanField('disable_cooling')}{booleanField('websockets')}</div>
<div className="credential-settings-grid">{textField('prefix')}{textField('proxy_url')}{textField('priority')}{textField('weight')}{booleanField('disable_cooling')}{showWebsockets ? booleanField('websockets') : null}</div>
</section>
<section className="credential-settings-section">
<h3>{t('authFiles.settings.excluded_models')} <code>excluded_models</code></h3>
@@ -169,20 +175,10 @@ export function AuthFileSettingsDialog({ name, onClose, onSaved }: {
</div>
</details>
</section>
<section className="credential-settings-section">
<details>
<summary>{tx({ zh: '模型别名与 Claude 兼容设置', en: 'Model aliases and Claude compatibility', ja: 'モデル別名と Claude 互換性' })}</summary>
<p><small>{tx({ zh: '这里的别名仅作用于此凭据,优先于全局别名。伪装与请求指纹适用于 Claude 及兼容委派凭据;未设置的字段继续使用默认行为。', en: 'Aliases apply only to this credential and take precedence over global aliases. Cloaking and fingerprints apply to Claude and compatible delegated credentials. Unset fields retain default behavior.', ja: '別名はこの認証情報にのみ適用され、全体の別名より優先されます。偽装とフィンガープリントは Claude と互換の委譲認証情報に適用します。' })}</small></p>
<StructuredConfigEditor shape={credentialAdvancedShape} value={draft.advanced} onChange={value => update('advanced', value as Record<string, unknown>)} disabled={saving} id={`${id}-advanced`} />
</details>
</section>
<CredentialAdvancedFields name={name} provider={provider} advanced={draft.advanced} models={pickerModels} modelsLoading={catalogLoading} modelsError={catalogError} disabled={saving} onChange={(advanced) => update('advanced', advanced)} />
<section className="credential-settings-section">
<h3>{t('authFiles.settings.additional')}</h3>
<label className="credential-settings-field">
<span>{t('authFiles.settings.headers')} <code>headers</code></span>
<textarea className="credential-settings-json" rows={5} value={draft.headers} onChange={(event) => update('headers', event.currentTarget.value)} spellCheck={false} autoComplete="off" />
<small>{t('authFiles.settings.headersHint')}</small>
</label>
<CredentialHeadersEditor value={draft.headers} disabled={saving} onChange={(headers) => update('headers', headers)} />
<label className="credential-settings-field">
<span>{t('authFiles.settings.note')} <code>note</code></span>
<textarea rows={2} value={draft.note} onChange={(event) => update('note', event.currentTarget.value)} />
+235
View File
@@ -0,0 +1,235 @@
import { useState } from 'react';
import { Plus, Undo2, X } from 'lucide-react';
import { AgentModelPicker } from './AgentModelPicker';
import type { ModelOption } from '../services/modelService';
import { useI18n } from '../i18n';
import type { MessageKey } from '../i18n/resources';
import { isRecord } from '../services/managementApi';
import { normalizeOAuthProvider } from '../services/authFiles';
type Advanced = Record<string, unknown>;
type AliasRow = Record<string, unknown>;
const claudeKeys = ['cloak_mode', 'cloak_strict_mode', 'cloak_cache_user_id', 'cloak_sensitive_words', 'fingerprint_profile'] as const;
export function credentialProviderKey(name: string, provider = ''): string {
const explicit = normalizeOAuthProvider(provider);
if (explicit) return explicit;
const prefix = name.trim().toLowerCase().replace(/\.json$/i, '').split('-')[0] ?? '';
return normalizeOAuthProvider(prefix);
}
export function showsClaudeCredentialSettings(provider: string, advanced: Advanced): boolean {
return provider === 'claude' || claudeKeys.some((key) => advanced[key] !== undefined);
}
function withoutKey(advanced: Advanced, key: string, value: unknown): Advanced {
const next = { ...advanced };
if (value === undefined) delete next[key];
else next[key] = value;
return next;
}
function textValue(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function wordsValue(value: unknown): string {
if (Array.isArray(value)) return value.filter((word): word is string => typeof word === 'string').join(', ');
return typeof value === 'string' ? value : '';
}
function aliasRows(value: unknown): AliasRow[] {
return Array.isArray(value) ? value.filter(isRecord) : [];
}
export function CredentialAdvancedFields({
name, provider = '', advanced, models = [], modelsLoading = false, modelsError = '', onChange, disabled = false,
}: {
name: string;
provider?: string;
advanced: Advanced;
models?: ModelOption[];
modelsLoading?: boolean;
modelsError?: string;
onChange: (advanced: Advanced) => void;
disabled?: boolean;
}) {
const { t } = useI18n();
const resolvedProvider = credentialProviderKey(name, provider);
const showClaude = showsClaudeCredentialSettings(resolvedProvider, advanced);
const aliases = aliasRows(advanced.model_aliases);
const aliasesSet = Array.isArray(advanced.model_aliases);
const [wordsDraft, setWordsDraft] = useState<string | null>(null);
const setField = (key: string, value: unknown) => onChange(withoutKey(advanced, key, value));
const tri = (value: unknown) => value === true ? 'true' : value === false ? 'false' : '';
const choice = (label: MessageKey, key: string, options: ReadonlyArray<{ value: string; label: MessageKey }>) => {
const current = textValue(advanced[key]);
return (
<label className="credential-settings-field">
<span>{t(label)}</span>
<select value={options.some((option) => option.value === current) ? current : ''} disabled={disabled} onChange={(event) => setField(key, event.currentTarget.value || undefined)}>
<option value="">{t('authFiles.settings.inherit')}</option>
{options.map((option) => <option key={option.value} value={option.value}>{t(option.label)}</option>)}
</select>
</label>
);
};
const boolChoice = (label: MessageKey, key: 'cloak_strict_mode' | 'cloak_cache_user_id') => (
<label className="credential-settings-field">
<span>{t(label)}</span>
<select value={tri(advanced[key])} disabled={disabled} onChange={(event) => setField(key, event.currentTarget.value === '' ? undefined : event.currentTarget.value === 'true')}>
<option value="">{t('authFiles.settings.inherit')}</option>
<option value="true">{t('common.enable')}</option>
<option value="false">{t('common.disable')}</option>
</select>
</label>
);
const updateAlias = (index: number, patch: AliasRow) => {
const next = aliases.map((row, rowIndex) => {
if (rowIndex !== index) return row;
const merged = { ...row, ...patch };
Object.entries(patch).forEach(([key, value]) => { if (value === undefined) delete merged[key]; });
return merged;
});
setField('model_aliases', next);
};
return (
<>
<section className="credential-settings-section">
<h3>{t('authFiles.settings.aliases')}</h3>
<p className="credential-settings-lead">{t('authFiles.settings.aliasesHint')}</p>
{aliases.length ? <div className="credential-alias-list">
{aliases.map((row, index) => <div className="credential-alias-card" key={index}>
<div className="credential-alias-card-heading">
<strong>{t('authFiles.settings.aliasItem', { index: index + 1 })}</strong>
<button type="button" className="icon-button quiet" disabled={disabled} aria-label={t('authFiles.settings.aliasRemove', { index: index + 1 })} onClick={() => setField('model_aliases', aliases.filter((_, rowIndex) => rowIndex !== index))}><X size={15} /></button>
</div>
<div className="credential-alias-fields">
<label className="credential-settings-field">
<span>{t('authFiles.settings.aliasUpstream')}</span>
<AgentModelPicker models={models} value={textValue(row.name)} loading={modelsLoading} error={modelsError} disabled={disabled}
editable={{ label: t('authFiles.settings.aliasUpstream'), placeholder: t('authFiles.settings.aliasModelPlaceholder'), maxLength: 240 }}
menuClassName="credential-model-menu" onChange={(value) => updateAlias(index, { name: value })} />
</label>
<label className="credential-settings-field">
<span>{t('authFiles.settings.aliasClient')}</span>
<AgentModelPicker models={models} value={textValue(row.alias)} loading={modelsLoading} error={modelsError} disabled={disabled}
editable={{ label: t('authFiles.settings.aliasClient'), placeholder: t('authFiles.settings.aliasModelPlaceholder'), maxLength: 240 }}
menuClassName="credential-model-menu" onChange={(value) => updateAlias(index, { alias: value })} />
</label>
<label className="credential-settings-field">
<span>{t('authFiles.settings.aliasDisplay')}</span>
<input value={textValue(row['display-name'])} disabled={disabled} autoComplete="off" spellCheck={false} onChange={(event) => updateAlias(index, { 'display-name': event.currentTarget.value.trim() ? event.currentTarget.value : undefined })} />
</label>
</div>
<div className="credential-alias-checks">
<label><input type="checkbox" checked={row.fork === true} disabled={disabled} onChange={(event) => updateAlias(index, { fork: event.currentTarget.checked })} />{t('authFiles.settings.aliasFork')}</label>
<label><input type="checkbox" checked={row['force-mapping'] === true} disabled={disabled} onChange={(event) => updateAlias(index, { 'force-mapping': event.currentTarget.checked })} />{t('authFiles.settings.aliasForce')}</label>
</div>
</div>)}
</div> : <p className="credential-settings-lead">{t(aliasesSet ? 'authFiles.settings.aliasCleared' : 'authFiles.settings.aliasInherited')}</p>}
<div className="credential-settings-inline-actions">
<button type="button" className="secondary-button compact-button" disabled={disabled} onClick={(event) => { const scroller = event.currentTarget.closest('.credential-settings-body'); const top = scroller?.scrollTop ?? 0; setField('model_aliases', [...aliases, { name: '', alias: '' }]); requestAnimationFrame(() => { if (scroller) scroller.scrollTop = top; }); }}><Plus size={14} />{t('authFiles.settings.aliasAdd')}</button>
{aliasesSet ? <button type="button" className="secondary-button compact-button" disabled={disabled} onClick={() => setField('model_aliases', undefined)}><Undo2 size={14} />{t('authFiles.settings.aliasInherit')}</button> : null}
</div>
</section>
{showClaude ? <section className="credential-settings-section">
<h3>{t('authFiles.settings.claude')}</h3>
<p className="credential-settings-lead">{t('authFiles.settings.claudeHint')}</p>
<div className="credential-settings-grid">
{choice('authFiles.settings.cloak_mode', 'cloak_mode', [
{ value: 'auto', label: 'authFiles.settings.cloak_mode.auto' },
{ value: 'always', label: 'authFiles.settings.cloak_mode.always' },
{ value: 'never', label: 'authFiles.settings.cloak_mode.never' },
])}
{boolChoice('authFiles.settings.cloak_strict_mode', 'cloak_strict_mode')}
{boolChoice('authFiles.settings.cloak_cache_user_id', 'cloak_cache_user_id')}
{choice('authFiles.settings.fingerprint_profile', 'fingerprint_profile', [
{ value: 'claude-code-cli', label: 'authFiles.settings.fingerprint_profile.claude-code-cli' },
{ value: 'oauth-cli', label: 'authFiles.settings.fingerprint_profile.oauth-cli' },
])}
<label className="credential-settings-field credential-settings-span">
<span>{t('authFiles.settings.cloak_sensitive_words')}</span>
<input value={wordsDraft ?? wordsValue(advanced.cloak_sensitive_words)} disabled={disabled} autoComplete="off" spellCheck={false}
placeholder={t('authFiles.settings.cloakWordsPlaceholder')}
onChange={(event) => {
const text = event.currentTarget.value;
setWordsDraft(text);
const words = text.split(',').map((word) => word.trim()).filter(Boolean);
setField('cloak_sensitive_words', words.length ? words : undefined);
}}
onBlur={() => setWordsDraft(null)} />
<small>{t('authFiles.settings.cloakWordsHint')}</small>
</label>
</div>
</section> : null}
<section className="credential-settings-section">
<div className="credential-settings-grid">
<label className="credential-settings-field">
<span>{t('authFiles.settings.timezone')}</span>
<input value={textValue(advanced.timezone)} disabled={disabled} autoComplete="off" spellCheck={false} placeholder="Asia/Shanghai"
onChange={(event) => setField('timezone', event.currentTarget.value)}
onBlur={(event) => setField('timezone', event.currentTarget.value.trim() || undefined)} />
<small>{t('authFiles.settings.timezoneHint')}</small>
</label>
</div>
</section>
</>
);
}
export function headerEntries(value: string): Array<{ name: string; value: string }> | null {
let parsed: unknown;
try { parsed = JSON.parse(value.trim() || '{}'); } catch { return null; }
if (!isRecord(parsed) || Object.values(parsed).some((item) => typeof item !== 'string')) return null;
return Object.entries(parsed).map(([name, item]) => ({ name, value: String(item) }));
}
export function CredentialHeadersEditor({ value, onChange, disabled = false }: {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}) {
const { t } = useI18n();
const parsed = headerEntries(value);
const [rows, setRows] = useState<Array<{ name: string; value: string }> | null>(null);
const visible = rows ?? parsed ?? [];
const write = (next: Array<{ name: string; value: string }>) => {
setRows(next);
const headers: Record<string, string> = {};
next.forEach((row) => {
if (row.name.trim() || row.value.trim()) headers[row.name] = row.value;
});
onChange(JSON.stringify(headers, null, 2));
};
if (parsed === null) {
return (
<label className="credential-settings-field">
<span>{t('authFiles.settings.headers')} <code>headers</code></span>
<textarea className="credential-settings-json" rows={5} value={value} disabled={disabled} spellCheck={false} autoComplete="off" onChange={(event) => onChange(event.currentTarget.value)} />
<small>{t('authFiles.settings.headersHint')}</small>
</label>
);
}
return (
<div className="credential-settings-field">
<span>{t('authFiles.settings.headers')}</span>
{visible.length ? <div className="credential-header-list">
{visible.map((row, index) => <div className="credential-header-row" key={index}>
<input aria-label={t('authFiles.settings.headerName')} value={row.name} disabled={disabled} autoComplete="off" spellCheck={false} placeholder={t('authFiles.settings.headerName')}
onChange={(event) => write(visible.map((item, itemIndex) => itemIndex === index ? { ...item, name: event.currentTarget.value } : item))} />
<input aria-label={t('authFiles.settings.headerValue')} value={row.value} disabled={disabled} autoComplete="off" spellCheck={false} placeholder={t('authFiles.settings.headerValue')}
onChange={(event) => write(visible.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.currentTarget.value } : item))} />
<button type="button" className="icon-button quiet" disabled={disabled} aria-label={t('authFiles.settings.headerRemove', { name: row.name || String(index + 1) })}
onClick={() => write(visible.filter((_, itemIndex) => itemIndex !== index))}><X size={15} /></button>
</div>)}
</div> : <p className="credential-settings-lead">{t('authFiles.settings.headersEmpty')}</p>}
<div className="credential-settings-inline-actions">
<button type="button" className="secondary-button compact-button" disabled={disabled} onClick={() => write([...visible, { name: '', value: '' }])}><Plus size={14} />{t('authFiles.settings.headerAdd')}</button>
</div>
<small>{t('authFiles.settings.headersRowHint')}</small>
</div>
);
}
+42 -1
View File
@@ -78,6 +78,43 @@ export const jaOverrides = {
'authFiles.settings.websockets': "WebSockets",
'authFiles.settings.websocketsHint': "この認証情報の WebSocket 対応(Codex / xAI)。接続にはバックエンドと上流の対応も必要です。",
'authFiles.settings.inherit': "既定(上書きなし)",
'authFiles.settings.aliases': "モデル別名",
'authFiles.settings.aliasesHint': "この認証情報だけに適用され、全体の別名より優先されます。追加しなければ全体の別名を使います。",
'authFiles.settings.aliasInherited': "全体の別名を使用しています。",
'authFiles.settings.aliasCleared': "この認証情報ではモデル別名を使わない設定です。追加するか、全体の別名に戻せます。",
'authFiles.settings.aliasAdd': "別名を追加",
'authFiles.settings.aliasInherit': "全体の別名に戻す",
'authFiles.settings.aliasItem': "別名 {index}",
'authFiles.settings.aliasRemove': "別名 {index} を削除",
'authFiles.settings.aliasUpstream': "上流モデル",
'authFiles.settings.aliasClient': "クライアント別名",
'authFiles.settings.aliasModelPlaceholder': "モデルを検索または入力",
'authFiles.settings.aliasDisplay': "表示名",
'authFiles.settings.aliasFork': "元のモデルを残す",
'authFiles.settings.aliasForce': "応答で別名を使う",
'authFiles.settings.claude': "Claude 互換",
'authFiles.settings.claudeHint': "偽装とフィンガープリントは Claude、または既に個別設定がある認証情報に表示します。既定を選ぶと上書きを解除します。",
'authFiles.settings.cloak_mode': "リクエスト偽装",
'authFiles.settings.cloak_mode.auto': "自動",
'authFiles.settings.cloak_mode.always': "常に",
'authFiles.settings.cloak_mode.never': "しない",
'authFiles.settings.cloak_strict_mode': "厳格な偽装",
'authFiles.settings.cloak_cache_user_id': "ユーザー ID を再利用",
'authFiles.settings.cloak_sensitive_words': "偽装時に難読化する単語",
'authFiles.settings.cloakWordsPlaceholder': "カンマ区切り。空欄なら上書きしません",
'authFiles.settings.cloakWordsHint': "空欄の場合は既定の動作を使います。",
'authFiles.settings.fingerprint_profile': "リクエストのフィンガープリント",
'authFiles.settings.fingerprint_profile.claude-code-cli': "Claude Code CLI",
'authFiles.settings.fingerprint_profile.oauth-cli': "OAuth CLI",
'authFiles.settings.timezone': "認証情報のタイムゾーン",
'authFiles.settings.timezoneHint': "Asia/Shanghai などの IANA タイムゾーン。空欄なら上書きしません。",
'authFiles.settings.headersEmpty': "カスタムヘッダーはありません。",
'authFiles.settings.headerAdd': "ヘッダーを追加",
'authFiles.settings.headerName': "名前",
'authFiles.settings.headerValue': "値",
'authFiles.settings.headerRemove': "ヘッダー {name} を削除",
'authFiles.settings.headersRowHint': "名前は重複できず、値に改行は使えません。すべて削除するとヘッダーを解除します。",
'authFiles.settings.excluded_models': "除外モデル",
'authFiles.settings.rules': "除外ルール",
'authFiles.settings.excludedHint': "1 行に 1 つのモデル ID。* ワイルドカード対応。空欄で個別の除外を解除。全体の除外設定は引き続き適用。",
@@ -449,6 +486,8 @@ export const jaOverrides = {
'quota.queryableCredentials.other': '照会可能な認証情報 {count} 件',
'quota.credentials.one': '認証情報 {count} 件',
'quota.credentials.other': '認証情報 {count} 件',
'quota.filter.label': 'カテゴリで絞り込む',
'quota.filter.all': 'すべて',
'quota.readList': '一覧を更新',
'quota.refreshAll': 'クォータを更新',
'quota.loadingFiles': '認証ファイルを読み込んでいます',
@@ -739,7 +778,9 @@ export const jaOverrides = {
'usage.events.description': '選択した期間のリクエストイベントを絞り込んで確認します。',
'usage.events.columns': '列',
'usage.events.exportPage': '絞り込み結果を CSV 出力',
'usage.events.exportDescription': '現在の絞り込み結果の全リクエスト項目を CSV ファイルに出力します',
'usage.events.exportDescription': '現在の絞り込み結果の全リクエスト項目を CSV ファイルに出力し、保存先を選択します',
'usage.events.exportDialogTitle': 'CSV の保存先を選択',
'usage.events.exportFileType': 'CSV ファイル',
'usage.events.exporting': '出力中…',
'usage.events.loaded': '読み込み済み {count} / {total}',
'usage.events.total': '合計 {count} 件',
+42 -1
View File
@@ -78,6 +78,43 @@ export const en: Record<MessageKey, string> = {
'authFiles.settings.websockets': "WebSockets",
'authFiles.settings.websocketsHint': "WebSocket support for this credential (Codex / xAI). Connections also depend on backend and upstream support.",
'authFiles.settings.inherit': "Default (no override)",
'authFiles.settings.aliases': "Model aliases",
'authFiles.settings.aliasesHint': "Applies only to this credential and takes precedence over global aliases. Leave this empty to keep using global aliases.",
'authFiles.settings.aliasInherited': "Using global aliases.",
'authFiles.settings.aliasCleared': "This credential explicitly has no model aliases. Add one, or return to global aliases.",
'authFiles.settings.aliasAdd': "Add alias",
'authFiles.settings.aliasInherit': "Use global aliases",
'authFiles.settings.aliasItem': "Alias {index}",
'authFiles.settings.aliasRemove': "Remove alias {index}",
'authFiles.settings.aliasUpstream': "Upstream model",
'authFiles.settings.aliasClient': "Client alias",
'authFiles.settings.aliasModelPlaceholder': "Search or enter a model",
'authFiles.settings.aliasDisplay': "Display name",
'authFiles.settings.aliasFork': "Keep original model",
'authFiles.settings.aliasForce': "Use alias in responses",
'authFiles.settings.claude': "Claude compatibility",
'authFiles.settings.claudeHint': "Cloaking and fingerprints apply to Claude, and stay visible when this credential already overrides them. Choose Default to remove an override.",
'authFiles.settings.cloak_mode': "Request cloaking",
'authFiles.settings.cloak_mode.auto': "Auto",
'authFiles.settings.cloak_mode.always': "Always",
'authFiles.settings.cloak_mode.never': "Never",
'authFiles.settings.cloak_strict_mode': "Strict cloaking",
'authFiles.settings.cloak_cache_user_id': "Reuse user ID",
'authFiles.settings.cloak_sensitive_words': "Words to obfuscate when cloaking",
'authFiles.settings.cloakWordsPlaceholder': "Comma-separated; empty means no override",
'authFiles.settings.cloakWordsHint': "Leave empty to keep the default behavior.",
'authFiles.settings.fingerprint_profile': "Request fingerprint",
'authFiles.settings.fingerprint_profile.claude-code-cli': "Claude Code CLI",
'authFiles.settings.fingerprint_profile.oauth-cli': "OAuth CLI",
'authFiles.settings.timezone': "Credential timezone",
'authFiles.settings.timezoneHint': "IANA timezone, such as Asia/Shanghai. Leave empty for no override.",
'authFiles.settings.headersEmpty': "No custom headers.",
'authFiles.settings.headerAdd': "Add header",
'authFiles.settings.headerName': "Name",
'authFiles.settings.headerValue': "Value",
'authFiles.settings.headerRemove': "Remove header {name}",
'authFiles.settings.headersRowHint': "Names must be unique and values cannot contain line breaks. Remove every header to clear them.",
'authFiles.settings.excluded_models': "Excluded models",
'authFiles.settings.rules': "Exclusion rules",
'authFiles.settings.excludedHint': "One model ID per line; * wildcards are supported. Clearing removes credential exclusions; global exclusions still apply.",
@@ -436,6 +473,8 @@ export const en: Record<MessageKey, string> = {
'quota.queryableCredentials.other': '{count} queryable credentials',
'quota.credentials.one': '{count} credential',
'quota.credentials.other': '{count} credentials',
'quota.filter.label': 'Filter by category',
'quota.filter.all': 'All',
'quota.readList': 'Refresh List',
'quota.refreshAll': 'Refresh Quota',
'quota.loadingFiles': 'Loading authentication files',
@@ -727,7 +766,9 @@ export const en: Record<MessageKey, string> = {
'usage.events.description': 'Filter and inspect request events in the selected time range.',
'usage.events.columns': 'Columns',
'usage.events.exportPage': 'Export Filtered CSV',
'usage.events.exportDescription': 'Export all request fields matching the current filters as a CSV file',
'usage.events.exportDescription': 'Export all request fields matching the current filters and choose where to save the CSV',
'usage.events.exportDialogTitle': 'Choose where to save the CSV',
'usage.events.exportFileType': 'CSV file',
'usage.events.exporting': 'Exporting…',
'usage.events.loaded': 'Loaded {count} / {total}',
'usage.events.total': '{count} records',
+42 -1
View File
@@ -77,6 +77,43 @@ export const zhCN = {
'authFiles.settings.websockets': "WebSockets",
'authFiles.settings.websocketsHint': "控制此凭证的 WebSocket 支持,适用于 Codex / xAI,实际连接仍取决于内核及上游支持。",
'authFiles.settings.inherit': "默认(不覆盖)",
'authFiles.settings.aliases': "模型别名",
'authFiles.settings.aliasesHint': "只作用于这一个凭证,并优先于全局别名。不添加时继续使用全局别名。",
'authFiles.settings.aliasInherited': "当前使用全局别名。",
'authFiles.settings.aliasCleared': "已明确不使用模型别名。可添加别名,或改回全局别名。",
'authFiles.settings.aliasAdd': "添加别名",
'authFiles.settings.aliasInherit': "改回全局别名",
'authFiles.settings.aliasItem': "别名 {index}",
'authFiles.settings.aliasRemove': "删除别名 {index}",
'authFiles.settings.aliasUpstream': "上游模型",
'authFiles.settings.aliasClient': "客户端别名",
'authFiles.settings.aliasModelPlaceholder': "搜索或输入模型",
'authFiles.settings.aliasDisplay': "展示名称",
'authFiles.settings.aliasFork': "保留原始模型",
'authFiles.settings.aliasForce': "响应使用别名",
'authFiles.settings.claude': "Claude 兼容",
'authFiles.settings.claudeHint': "请求伪装和指纹只用于 Claude 及已经单独配置过的凭证。选择“默认”即取消这项覆盖。",
'authFiles.settings.cloak_mode': "请求伪装",
'authFiles.settings.cloak_mode.auto': "自动",
'authFiles.settings.cloak_mode.always': "总是",
'authFiles.settings.cloak_mode.never': "从不",
'authFiles.settings.cloak_strict_mode': "严格伪装",
'authFiles.settings.cloak_cache_user_id': "复用用户标识",
'authFiles.settings.cloak_sensitive_words': "伪装时处理的敏感词",
'authFiles.settings.cloakWordsPlaceholder': "用逗号分隔,留空则不覆盖",
'authFiles.settings.cloakWordsHint': "留空表示继续使用默认行为。",
'authFiles.settings.fingerprint_profile': "请求指纹",
'authFiles.settings.fingerprint_profile.claude-code-cli': "Claude Code CLI",
'authFiles.settings.fingerprint_profile.oauth-cli': "OAuth CLI",
'authFiles.settings.timezone': "凭据时区",
'authFiles.settings.timezoneHint': "IANA 时区,例如 Asia/Shanghai。留空则不覆盖。",
'authFiles.settings.headersEmpty': "没有自定义请求头。",
'authFiles.settings.headerAdd': "添加请求头",
'authFiles.settings.headerName': "名称",
'authFiles.settings.headerValue': "值",
'authFiles.settings.headerRemove': "删除请求头 {name}",
'authFiles.settings.headersRowHint': "名称不能重复,值不能包含换行。删除全部请求头即可清空。",
'authFiles.settings.excluded_models': "排除模型",
'authFiles.settings.rules': "排除规则",
'authFiles.settings.excludedHint': "每行一个模型 ID,支持 * 通配符。清空即移除此凭证的排除规则,全局排除规则仍然生效。",
@@ -435,6 +472,8 @@ export const zhCN = {
'quota.queryableCredentials.other': '{count} 个可查询凭据',
'quota.credentials.one': '{count} 个凭据',
'quota.credentials.other': '{count} 个凭据',
'quota.filter.label': '按类别筛选',
'quota.filter.all': '全部',
'quota.readList': '刷新列表',
'quota.refreshAll': '刷新额度',
'quota.loadingFiles': '读取凭证文件中',
@@ -726,7 +765,9 @@ export const zhCN = {
'usage.events.description': '筛选并查看所选时间范围内的请求事件。',
'usage.events.columns': '列',
'usage.events.exportPage': '导出筛选结果 CSV',
'usage.events.exportDescription': '导出当前筛选结果中的全部请求字段为 CSV 文件',
'usage.events.exportDescription': '导出当前筛选结果中的全部请求字段,并选择保存位置',
'usage.events.exportDialogTitle': '选择 CSV 保存位置',
'usage.events.exportFileType': 'CSV 文件',
'usage.events.exporting': '正在导出…',
'usage.events.loaded': '已加载 {count} / {total}',
'usage.events.total': '共 {count} 条记录',
+1
View File
@@ -838,6 +838,7 @@ export function createBrowserMockRuntime(
: 'C:\\Users\\Mock\\Certificates\\mock.pem';
}
case 'plugin:dialog|save': return 'C:\\Users\\Mock\\Downloads\\mock-output.json';
case 'save_usage_events_export': return null;
case 'detect_core_platform': return { os: 'windows', arch: 'x86_64', assetOs: 'windows', assetArch: 'amd64', archiveKind: 'zip' };
case 'get_linux_system_theme': return 'light';
case 'set_app_locale':
+1 -296
View File
@@ -3,12 +3,10 @@ import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
type ComponentType,
type KeyboardEvent,
} from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
@@ -20,8 +18,6 @@ import {
Check,
ChevronDown,
LoaderCircle,
RefreshCw,
Search,
SlidersHorizontal,
Trash2,
X,
@@ -39,8 +35,6 @@ import zcodeIcon from '../assets/icons/zcode.png';
import workbuddyIcon from '../assets/icons/workbuddy.png';
import antigravityIcon from '../assets/icons/antigravity.svg';
import {
agentModelAlias,
filterAgentModels,
filterAgentModelsByAlias,
findAgentModel,
resolveAgentModelForAliasMode,
@@ -64,6 +58,7 @@ import {
type DeepSeekHarnessLaunchOptions,
} from '../services/deepSeekHarnessLaunch';
import type { ModelOption } from '../services/modelService';
import { AgentModelPicker } from '../components/AgentModelPicker';
import { claudeDesktopAliasSuggestions, claudeDesktopDefaultAliases, createDefaultDesktopModels, desktopAliasNotice, desktopEntryValidation, desktopModelEntries, desktopModelId, desktopModelValidation, isClaudeDesktopModel, selectedDesktopModelEntries, type ClaudeDesktopModelMapping } from '../services/claudeDesktopModels';
import { getCurrentLocale, translate, useI18n } from '../i18n';
import { CodexSessionsPanel } from './CodexSessionsPanel';
@@ -478,296 +473,6 @@ const listStatusText = (status: AgentConfigStatus | undefined) => {
: translate(locale, 'agents.list.installed');
};
type AgentModelPickerProps = {
models: ModelOption[];
value: string;
loading: boolean;
error: string;
disabled: boolean;
onChange: (value: string) => void;
onRefresh?: () => void;
allowCustomValue?: boolean;
editable?: { label: string; placeholder: string; maxLength: number };
};
type AgentModelDropdownLayout = {
top: number;
left: number;
width: number;
height: number;
};
function AgentModelPicker({
models,
value,
loading,
error,
disabled,
onChange,
onRefresh,
allowCustomValue = false,
editable,
}: AgentModelPickerProps) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
const [dropdownLayout, setDropdownLayout] = useState<AgentModelDropdownLayout | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const valueRef = useRef<HTMLInputElement>(null);
const listboxId = useId();
const visibleModels = useMemo(
() => editable && !search.trim() ? models : filterAgentModels(models, search),
[models, search, editable],
);
const choices = useMemo(() => {
const options = visibleModels.map((model) => ({ name: model.name, alias: model.alias ?? '' }));
if (allowCustomValue && search.trim() && !findAgentModel(models, search)) {
options.push({ name: search.trim(), alias: t('agents.model.useCustom') });
}
return options;
}, [visibleModels, allowCustomValue, search, models, t]);
const selectedModel = findAgentModel(models, value);
const selectedName = selectedModel?.name ?? (allowCustomValue || editable ? value.trim() : '');
const selectedAlias = selectedName ? agentModelAlias(models, selectedName) : '';
const updateDropdownLayout = useCallback(() => {
const root = rootRef.current;
if (!root) return;
const rect = root.getBoundingClientRect();
const edgeGap = 12;
const triggerGap = 6;
const preferredHeight = 282;
const minimumHeight = 150;
const spaceBelow = Math.max(0, window.innerHeight - rect.bottom - triggerGap - edgeGap);
const spaceAbove = Math.max(0, rect.top - triggerGap - edgeGap);
const placeAbove = spaceBelow < preferredHeight && spaceAbove > spaceBelow;
const availableHeight = placeAbove ? spaceAbove : spaceBelow;
const height = Math.min(preferredHeight, Math.max(minimumHeight, availableHeight));
const width = Math.min(rect.width, window.innerWidth - edgeGap * 2);
const left = Math.min(
Math.max(edgeGap, rect.left),
Math.max(edgeGap, window.innerWidth - edgeGap - width),
);
const desiredTop = placeAbove
? rect.top - triggerGap - height
: rect.bottom + triggerGap;
const top = Math.min(
Math.max(edgeGap, desiredTop),
Math.max(edgeGap, window.innerHeight - edgeGap - height),
);
setDropdownLayout({ top, left, width, height });
}, []);
useLayoutEffect(() => {
if (!open) {
setDropdownLayout(null);
return undefined;
}
updateDropdownLayout();
window.addEventListener('resize', updateDropdownLayout);
window.addEventListener('scroll', updateDropdownLayout);
return () => {
window.removeEventListener('resize', updateDropdownLayout);
window.removeEventListener('scroll', updateDropdownLayout);
};
}, [open, updateDropdownLayout]);
useEffect(() => {
if (!open) return undefined;
const close = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [open]);
useEffect(() => {
if (!open) return;
if (!editable) setSearch('');
const selectedIndex = (editable ? visibleModels : filterAgentModels(models, '')).findIndex(
(model) => model.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase(),
);
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
if (!editable) requestAnimationFrame(() => searchRef.current?.focus());
}, [open]);
useEffect(() => {
setActiveIndex((current) => Math.min(current, Math.max(choices.length - 1, 0)));
}, [choices.length]);
const choose = (name: string) => {
onChange(name);
setOpen(false);
if (editable) valueRef.current?.focus();
};
const moveActive = (offset: number) => {
if (choices.length === 0) return;
setActiveIndex((current) => (current + offset + choices.length) % choices.length);
};
const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
if (!open) setOpen(true);
else moveActive(1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
if (!open) setOpen(true);
else moveActive(-1);
} else if (event.key === 'Enter' && open && choices[activeIndex]) {
event.preventDefault();
choose(choices[activeIndex].name);
} else if (event.key === 'Escape') {
event.preventDefault();
setOpen(false);
}
};
return (
<div className={`agent-model-picker ${open ? 'open' : ''}`} ref={rootRef}
onMouseDown={(event) => {
if (event.button === 0 && event.currentTarget.contains(document.activeElement)
&& event.target instanceof Element && event.target.closest('button')) {
event.preventDefault();
}
}}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setOpen(false);
}}>
{editable ? (
<div className="agent-model-trigger agent-model-editable-trigger">
<input ref={valueRef} type="text" value={value} aria-label={editable.label}
placeholder={editable.placeholder} maxLength={editable.maxLength} disabled={disabled}
spellCheck={false} autoComplete="off" role="combobox" aria-autocomplete="list"
aria-controls={listboxId} aria-expanded={open}
onClick={() => { setSearch(''); setOpen(true); }} onKeyDown={handleSearchKeyDown}
onChange={(event) => {
onChange(event.currentTarget.value);
setSearch(event.currentTarget.value);
setActiveIndex(0);
setOpen(true);
}} />
<button type="button" className="icon-button quiet" aria-label={editable.label}
aria-haspopup="listbox" aria-expanded={open} aria-controls={listboxId}
disabled={disabled} onClick={() => { setSearch(''); setOpen((current) => !current); }}>
<ChevronDown size={17} aria-hidden />
</button>
</div>
) : <button
type="button"
className="agent-model-trigger"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listboxId}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
onKeyDown={(event) => {
if (!open && ['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key)) {
event.preventDefault();
setOpen(true);
}
}}
>
<span>
<strong title={selectedName || undefined}>
{selectedName || (loading ? t('agents.model.loading') : error ? t('agents.model.loadFailed') : models.length ? t('agents.model.select') : t('agents.model.none'))}
</strong>
{selectedAlias ? <small title={selectedAlias}>{selectedAlias}</small> : null}
</span>
<ChevronDown size={17} aria-hidden />
</button>}
{open ? (
<div
className={`agent-model-dropdown${editable ? ' agent-model-dropdown-editable' : ''}`}
style={dropdownLayout
? dropdownLayout
: { top: 0, left: 0, width: 0, height: 0, visibility: 'hidden' }}
>
{!editable ? <div className="agent-model-search">
<Search size={15} aria-hidden />
<input
ref={searchRef}
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setActiveIndex(0);
}}
onKeyDown={handleSearchKeyDown}
placeholder={t('agents.model.search')}
role="combobox"
aria-controls={listboxId}
aria-expanded="true"
/>
{search ? (
<button
type="button"
className="icon-button quiet"
onClick={() => {
setSearch('');
setActiveIndex(0);
searchRef.current?.focus();
}}
title={t('agents.model.clearSearch')}
aria-label={t('agents.model.clearSearch')}
>
<X size={14} aria-hidden="true" />
</button>
) : null}
{onRefresh ? <button type="button" className="icon-button quiet" onClick={onRefresh} disabled={loading} title={t('agents.model.refresh')} aria-label={t('agents.model.refresh')}>
<RefreshCw size={14} className={loading ? 'spin' : ''} aria-hidden="true" />
</button> : null}
</div> : null}
<div className="agent-model-list" id={listboxId} role="listbox">
{loading && models.length === 0 && choices.length === 0 ? (
<div className="agent-model-empty"><LoaderCircle size={18} className="spin" />{t('agents.model.fetching')}</div>
) : error && models.length === 0 && choices.length === 0 ? (
<div className="agent-model-empty error"><strong>{t('agents.model.loadFailed')}</strong><span>{error}</span></div>
) : choices.length === 0 ? (
<div className="agent-model-empty">
{editable ? <span>{t('agents.claudeDesktopMapping.customAliasHint')}</span> : <>
<strong>{search.trim() ? t('agents.model.noMatch') : t('agents.model.unavailable')}</strong>
<span>{search.trim() ? t('agents.model.tryKeywords') : t('agents.model.connectFirst')}</span>
</>}
</div>
) : choices.map((choice, index) => {
const selected = choice.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase();
return (
<button
type="button"
role="option"
aria-selected={selected}
className={`agent-model-option ${selected ? 'selected' : ''} ${index === activeIndex ? 'active' : ''}`}
key={choice.name}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => choose(choice.name)}
>
<span>
<strong title={choice.name}>{choice.name}</strong>
<small>{choice.alias || t('agents.model.available')}</small>
</span>
{selected ? <Check size={16} aria-hidden /> : null}
</button>
);
})}
</div>
<div className="agent-model-dropdown-footer">
<span>{t(editable ? 'agents.claudeDesktopMapping.suggestionCount' : 'agents.model.count', { count: models.length })}</span>
{error && models.length > 0 ? <span className="error">{t('agents.model.stale')}</span> : null}
</div>
</div>
) : null}
</div>
);
}
function ClaudeDesktopHelpDialog({ onClose }: { onClose: () => void }) {
const { t } = useI18n();
const dialogRef = useRef<HTMLDialogElement>(null);
+1 -1
View File
@@ -620,7 +620,7 @@ export function AuthFileManagementPage() {
</section>
{runtimeCount > 0 ? <p className="page-footnote">{t('authFiles.runtimeFootnote', { count: runtimeCount })}</p> : null}
{settingsName ? <AuthFileSettingsDialog key={settingsName} name={settingsName} onClose={() => setSettingsName(null)} onSaved={() => {
{settingsName ? <AuthFileSettingsDialog key={settingsName} name={settingsName} provider={providerKey(files.find((file) => fileName(file) === settingsName) ?? {})} onClose={() => setSettingsName(null)} onSaved={() => {
showNotice({ key: 'authFiles.settings.updated', variables: { name: settingsName } });
setSettingsName(null);
void loadFiles(false);
+56 -9
View File
@@ -1,16 +1,63 @@
.quota-page .quota-group-list { gap: 18px; }
.quota-page .quota-provider-group { gap: 8px; }
.quota-page .quota-group-heading { justify-content: flex-start; min-height: 24px; padding: 0 2px; }
.quota-page .quota-provider-title { min-width: 0; flex-wrap: wrap; gap: 8px; }
.quota-page .quota-group-heading h2 { font-size: var(--font-size-label); }
.quota-page .quota-group-heading .provider-logo { width: 18px; height: 18px; }
.quota-page .quota-provider-count {
padding: 2px 7px;
.quota-page .quota-category-filter {
display: flex;
flex-wrap: nowrap;
gap: 8px;
align-items: center;
min-width: 0;
overflow-x: auto;
padding: 2px 2px 4px;
scrollbar-width: thin;
}
.quota-page .quota-category-filter button {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 7px;
height: 36px;
margin: 0;
padding: 0 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
border-radius: 999px;
background: var(--bg-card);
color: var(--text-secondary);
box-shadow: var(--shadow-sm);
font-size: var(--font-size-label);
font-weight: var(--font-weight-medium);
line-height: 1;
white-space: nowrap;
}
.quota-page .quota-category-filter button:hover {
border-color: color-mix(in srgb, var(--ui-accent) 35%, var(--border-color));
color: var(--text-primary);
}
.quota-page .quota-category-filter button[aria-checked='true'] {
border-color: color-mix(in srgb, var(--ui-accent) 28%, transparent);
background: color-mix(in srgb, var(--ui-accent) 10%, var(--bg-card));
color: var(--ui-accent-strong);
font-weight: var(--font-weight-semibold);
box-shadow: none;
}
.quota-page .quota-category-filter img {
width: 16px;
height: 16px;
flex: none;
object-fit: contain;
}
.quota-page .quota-filter-count {
min-width: 1.25rem;
padding: 2px 6px;
border-radius: 999px;
background: var(--count-badge-bg);
color: var(--text-tertiary);
font-size: 11px;
font-variant-numeric: tabular-nums;
font-weight: var(--font-weight-semibold);
line-height: 1.3;
text-align: center;
}
.quota-page .quota-category-filter button[aria-checked='true'] .quota-filter-count {
background: color-mix(in srgb, var(--ui-accent) 16%, transparent);
color: var(--ui-accent-strong);
}
/* Keep each card compact; available content width determines the column count. */
+36 -14
View File
@@ -1,6 +1,6 @@
import { ResetCreditExpiries } from '../components/ResetCreditExpiries';
import { MessageNotice } from '../appNotice';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from 'react';
import { AlertCircle, Gauge, ListRestart, LoaderCircle, RefreshCw } from 'lucide-react';
import { useConfirmation } from '../components/ConfirmationDialog';
import { OAuthPageToolbar } from '../components/OAuthPageToolbar';
@@ -58,6 +58,7 @@ export function QuotaPage() {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState('');
const [category, setCategory] = useState<'all' | QuotaProvider>('all');
const querying = Object.values(quotas).some((quota) => quota.status === 'loading');
const loadFiles = useCallback(async () => {
@@ -142,6 +143,23 @@ export function QuotaPage() {
return items ? [[provider, items] as const] : [];
});
}, [files, quotas]);
const activeCategory = category !== 'all' && grouped.some(([provider]) => provider === category) ? category : 'all';
const visibleItems = (activeCategory === 'all' ? grouped : grouped.filter(([provider]) => provider === activeCategory))
.flatMap(([provider, items]) => items.map((item) => ({ provider, ...item })));
const moveCategory = (event: KeyboardEvent<HTMLDivElement>) => {
const buttons = [...event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="radio"]')];
if (!buttons.length) return;
const current = Math.max(0, buttons.findIndex((button) => button === document.activeElement));
let next = current;
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') next = (current + 1) % buttons.length;
else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') next = (current - 1 + buttons.length) % buttons.length;
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = buttons.length - 1;
else return;
event.preventDefault();
buttons[next]?.focus();
buttons[next]?.click();
};
return (
<section className="page management-page quota-page" aria-label={t('quota.title')}>
@@ -163,20 +181,24 @@ export function QuotaPage() {
) : grouped.length === 0 ? (
<div className="management-empty"><AlertCircle size={24} /><strong>{t('quota.empty.title')}</strong><span>{t('quota.empty.description')}</span></div>
) : (
<div className="quota-group-list">
{grouped.map(([provider, items]) => (
<section className="quota-provider-group" key={provider}>
<div className="quota-group-heading">
<div className="quota-provider-title">
<img src={providerMeta[provider].icon} alt="" className={provider === 'devin' ? 'provider-logo devin-logo' : 'provider-logo'} />
<h2>{providerMeta[provider].label}</h2>
<span className="quota-provider-count">{t(items.length === 1 ? 'quota.credentials.one' : 'quota.credentials.other', { count: items.length })}</span>
</div>
</div>
<div className="real-quota-grid">{items.map(({ file, quota }) => <QuotaCard key={quotaKey(file)} file={file} quota={quota} onRefresh={() => void refreshOne(file)} onReset={provider === 'codex' || provider === 'claude' ? () => void resetQuota(file, quota) : undefined} />)}</div>
</section>
))}
<>
<div className="quota-category-filter" role="radiogroup" aria-label={t('quota.filter.label')} onKeyDown={moveCategory}>
{([['all', files.length] as const, ...grouped.map(([provider, items]) => [provider, items.length] as const)]).map(([provider, count]) => {
const selected = activeCategory === provider;
const label = provider === 'all' ? t('quota.filter.all') : providerMeta[provider].label;
return (
<button key={provider} type="button" role="radio" aria-checked={selected} tabIndex={selected ? 0 : -1}
aria-label={`${label}, ${t(count === 1 ? 'quota.credentials.one' : 'quota.credentials.other', { count })}`}
onClick={() => setCategory(provider)}>
{provider === 'all' ? null : <img src={providerMeta[provider].icon} alt="" className={provider === 'devin' ? 'provider-logo devin-logo' : 'provider-logo'} />}
<span>{label}</span>
<span className="quota-filter-count">{count}</span>
</button>
);
})}
</div>
<div className="real-quota-grid">{visibleItems.map(({ provider, file, quota }) => <QuotaCard key={quotaKey(file)} file={file} quota={quota} onRefresh={() => void refreshOne(file)} onReset={provider === 'codex' || provider === 'claude' ? () => void resetQuota(file, quota) : undefined} />)}</div>
</>
)}
</section>
);
+13
View File
@@ -162,6 +162,19 @@
margin-top: 2px;
}
.usage-request-log .usage-events-table .usage-td-model > .usage-model-effort {
display: block;
width: fit-content;
max-width: 100%;
margin-top: 2px;
overflow: hidden;
color: var(--usage-log-muted);
font-size: 10px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.usage-request-log .usage-events-table .usage-td-model > .usage-response-model {
color: var(--usage-log-response-model);
}
+36 -23
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { save } from '@tauri-apps/plugin-dialog';
import { ArrowDown, ArrowUp, Brain, ChevronLeft, ChevronRight, Columns3Cog, Database, DatabaseZap, Download, RotateCcw, TriangleAlert, X } from 'lucide-react';
import { getCurrentLocale, useI18n } from '../i18n';
import type { MessageKey } from '../i18n/resources';
@@ -73,7 +74,6 @@ type EventColumnKey =
| 'result'
| 'latency'
| 'speed'
| 'effort'
| 'request';
type EventColumnDef = {
@@ -96,7 +96,6 @@ const EVENT_COLUMNS: readonly EventColumnDef[] = [
{ key: 'cost', labelKey: 'usage.column.cost', defaultWidth: 96, minWidth: 86, align: 'left' },
{ key: 'key', labelKey: 'usage.column.key', defaultWidth: 112, minWidth: 96, align: 'left' },
{ key: 'source', labelKey: 'usage.column.source', defaultWidth: 128, minWidth: 104, align: 'left' },
{ key: 'effort', labelKey: 'usage.column.effort', defaultWidth: 76, minWidth: 64, align: 'left' },
{ key: 'request', labelKey: 'usage.column.request', defaultWidth: 128, minWidth: 88, align: 'left' },
] as const;
@@ -106,6 +105,9 @@ const DEFAULT_EVENT_VISIBLE_COLUMNS: readonly EventColumnKey[] = [
const PREVIOUS_DEFAULT_EVENT_VISIBLE_COLUMNS: readonly string[] = [
'time', 'provider', 'key', 'source', 'model', 'effort', 'result', 'request', 'latency', 'speed', 'total', 'cache', 'cost',
];
const PREVIOUS_EFFORT_DEFAULT_EVENT_VISIBLE_COLUMNS: readonly string[] = [
'time', 'model', 'provider', 'result', 'total', 'cache', 'latency', 'speed', 'cost', 'effort',
];
const LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS: readonly string[] = [
'time', 'model', 'input', 'output', 'cache', 'cacheRate', 'total', 'speed', 'ttft', 'latency', 'result', 'provider', 'source',
];
@@ -115,12 +117,12 @@ const LEGACY_EVENT_COL_WIDTHS_STORAGE_KEY = 'cpa-gui.usage-events-col-widths.v3'
const PREVIOUS_COMPACT_WIDTHS: Partial<Record<EventColumnKey, number>> = {
key: 120, source: 180, model: 132, request: 112,
};
const PREVIOUS_EVENT_COLUMN_WIDTHS: Record<EventColumnKey, number> = {
const PREVIOUS_EVENT_COLUMN_WIDTHS: Record<string, number> = {
time: 105, key: 150, source: 205, model: 150, effort: 100, result: 95,
request: 145, latency: 125, speed: 110, total: 145, cache: 135, provider: 135,
cost: 112,
};
const PREVIOUS_GENEROUS_DEFAULT_WIDTHS: Record<EventColumnKey, number> = {
const PREVIOUS_GENEROUS_DEFAULT_WIDTHS: Record<string, number> = {
time: 84, model: 160, provider: 108, result: 80, total: 120, cache: 104,
latency: 112, speed: 88, cost: 112, key: 112, source: 128, effort: 76, request: 128,
};
@@ -152,13 +154,12 @@ const getInitialVisibleColumns = (): EventColumnKey[] => {
const wasPreviousDefault = currentRaw === null
&& (parsed.length === PREVIOUS_DEFAULT_EVENT_VISIBLE_COLUMNS.length
&& PREVIOUS_DEFAULT_EVENT_VISIBLE_COLUMNS.every((key) => parsed.includes(key))
|| parsed.length === PREVIOUS_EFFORT_DEFAULT_EVENT_VISIBLE_COLUMNS.length
&& PREVIOUS_EFFORT_DEFAULT_EVENT_VISIBLE_COLUMNS.every((key) => parsed.includes(key))
|| parsed.length === LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS.length
&& LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS.every((key) => parsed.includes(key)));
if (wasPreviousDefault) return [...DEFAULT_EVENT_VISIBLE_COLUMNS];
if (currentRaw === null) {
if (!savedKeys.includes('effort')) savedKeys.push('effort');
if (previousRaw === null && !savedKeys.includes('cost')) savedKeys.push('cost');
}
if (currentRaw === null && previousRaw === null && !savedKeys.includes('cost')) savedKeys.push('cost');
return savedKeys;
}
}
@@ -219,7 +220,13 @@ type UsageEventQuery = {
page_size?: number;
};
function downloadUsageEvents(records: UsageRecord[]) {
function usageEventsExportName(date = new Date()) {
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `usage-events-${date.getFullYear()}-${month}-${day}.csv`;
}
function usageEventsCsv(records: UsageRecord[]) {
const headers = ['id', 'row_id', 'timestamp', 'api_key_display', 'api_key_remark', 'api_key_hash', 'source', 'source_display', 'provider', 'model', 'alias', 'response_model', 'reasoning_effort', 'endpoint', 'failed', 'canceled', 'failure_status', 'failure_body', 'latency_ms', 'ttft_ms', 'input_tokens', 'output_tokens', 'reasoning_tokens', 'cache_read_tokens', 'cache_creation_tokens', 'total_tokens', 'estimated_cost_usd', 'pricing_model'];
const csvCell = (value: string | number | boolean | null) => {
const text = value == null ? '' : String(value);
@@ -227,15 +234,7 @@ function downloadUsageEvents(records: UsageRecord[]) {
return `"${safe.replace(/"/g, '""')}"`;
};
const rows = records.map((record) => [record.id, record.row_id, record.timestamp, record.api_key_display, record.api_key_remark, record.api_key_hash, record.source, record.source_display, record.provider, record.model, record.alias, record.response_model ?? '', record.reasoning_effort, record.endpoint, record.failed, record.canceled, record.failure_status, record.failure_body, record.latency_ms, record.ttft_ms, record.tokens.input_tokens, record.tokens.output_tokens, record.tokens.reasoning_tokens, record.tokens.cache_read_tokens, record.tokens.cache_creation_tokens, record.tokens.total_tokens, record.cost?.total ?? null, record.cost?.pricing_model ?? '']);
const csv = '\uFEFF' + [headers, ...rows].map((row) => row.map(csvCell).join(',')).join('\r\n');
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8;' }));
const link = document.createElement('a');
link.href = url;
link.download = `usage-events-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
return '\uFEFF' + [headers, ...rows].map((row) => row.map(csvCell).join(',')).join('\r\n');
}
function TableTopScrollbar({
@@ -362,16 +361,19 @@ function UsageEventCell({
);
case 'model': {
const model = usageModelDetails(record.model, record.alias, record.response_model);
const effort = record.reasoning_effort || 'auto';
const modelTitle = [
`${t('usage.model.request')}: ${model.requested}`,
model.showResolved ? `${t('usage.model.upstream')}: ${model.resolved}` : '',
model.showResponseInTooltip ? `${t('usage.model.response')}: ${model.response}` : '',
model.mismatch ? t('usage.model.mismatch') : '',
model.showResponseInTooltip ? t('usage.model.responseHint') : '',
`${t('usage.column.effort')}: ${effort}`,
].filter(Boolean).join('\n');
return (
<td className="usage-stacked-cell usage-td-model align-left" title={modelTitle}>
<strong title={modelTitle}>{model.requested}</strong>
<small className="usage-model-effort" title={effort}>{effort}</small>
{model.showResolved ? <small title={modelTitle}>{model.resolved}</small> : null}
{model.mismatch ? (
<small className="usage-response-model" title={modelTitle}>
@@ -392,8 +394,6 @@ function UsageEventCell({
{!cost ? <small>{t('usage.cost.unpriced')}</small> : null}
</td>;
}
case 'effort':
return <td className="usage-stacked-cell align-left" title={record.reasoning_effort || 'auto'}><strong>{record.reasoning_effort || 'auto'}</strong></td>;
case 'request': {
const path = record.endpoint.trim().replace(/^(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|CONNECT|TRACE)\s+/i, '');
return <td className="usage-stacked-cell usage-td-request align-left" title={path || undefined}><strong>{path || '—'}</strong></td>;
@@ -652,10 +652,23 @@ export function EventsView({
if (exporting || events.total === 0) return;
setExporting(true);
try {
const exported = await invoke<UsageEventPage>('get_usage_events', {
query: { ...query, page: 1, page_size: Math.max(events.total, 1) },
const path = await save({
title: t('usage.events.exportDialogTitle'),
defaultPath: usageEventsExportName(),
filters: [{ name: t('usage.events.exportFileType'), extensions: ['csv'] }],
});
downloadUsageEvents(exported.items);
if (!path) return;
const items: UsageRecord[] = [];
const pageSize = 5000;
for (let page = 1; ; page += 1) {
const exported = await invoke<UsageEventPage>('get_usage_events', {
query: { ...query, page, page_size: pageSize },
});
if (exported.page !== page || exported.items.length === 0) break;
items.push(...exported.items);
if (items.length >= exported.total || page >= exported.totalPages) break;
}
await invoke('save_usage_events_export', { path, contents: usageEventsCsv(items) });
} catch (error) {
window.alert(String(error));
} finally {
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { CredentialAdvancedFields, CredentialHeadersEditor } from '../src/components/CredentialAdvancedFields';
import { I18nProvider } from '../src/i18n';
const renderAdvanced = (name: string, advanced: Record<string, unknown>, provider = '') => renderToStaticMarkup(
<I18nProvider>
<CredentialAdvancedFields name={name} provider={provider} advanced={advanced} onChange={() => {}} />
</I18nProvider>,
);
describe('credential settings interaction', () => {
it('lets a Codex credential edit aliases directly and hides Claude cloaking', () => {
const html = renderAdvanced('codex-cbb7bd85-user-team.json', {});
expect(html).toContain('Add alias');
expect(html).toContain('Credential timezone');
expect(html).toContain('Asia/Shanghai');
expect(html).not.toContain('Request cloaking');
expect(html).not.toContain('Inherit / not set');
expect(html).not.toContain('Model aliases and Claude compatibility');
});
it('shows Claude controls as normal fields, including an existing override on another provider', () => {
expect(renderAdvanced('claude-user.json', {})).toContain('Request cloaking');
const overridden = renderAdvanced('codex-user.json', { cloak_mode: 'always' });
expect(overridden).toContain('Request cloaking');
expect(overridden).toContain('value="always" selected');
});
it('edits headers as rows instead of an empty JSON object', () => {
const html = renderToStaticMarkup(<I18nProvider><CredentialHeadersEditor value="{}" onChange={() => {}} /></I18nProvider>);
expect(html).toContain('No custom headers.');
expect(html).toContain('Add header');
expect(html).not.toContain('<textarea');
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { CredentialAdvancedFields } from '../src/components/CredentialAdvancedFields';
import { I18nProvider } from '../src/i18n';
const html = renderToStaticMarkup(
<I18nProvider>
<CredentialAdvancedFields name="codex-user.json" advanced={{ model_aliases: [{ name: 'gpt-5', alias: '' }] }}
models={[{ name: 'gpt-5', displayName: 'GPT 5' }]} onChange={() => {}} />
</I18nProvider>,
);
describe('credential alias model picker', () => {
it('uses the searchable model picker for both model fields', () => {
expect(html.match(/role="combobox"/g)).toHaveLength(2);
expect(html).toContain('value="gpt-5"');
expect(html).toContain('Search or enter a model');
expect(html).not.toContain('<textarea');
});
});
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Credential settings fixture</title></head>
<body><div id="root"></div><script type="module" src="./credential-settings.tsx"></script></body>
</html>
+24
View File
@@ -0,0 +1,24 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { mockIPC } from '@tauri-apps/api/mocks';
import { I18nProvider } from '../../src/i18n';
import { AuthFileSettingsDialog } from '../../src/components/AuthFileSettingsDialog';
import '../../src/styles/index.css';
localStorage.setItem('easy-cli-proxy-api.locale', 'zh-CN');
const models = Array.from({ length: 20 }, (_, index) => ({ id: `gpt-${index + 1}`, display_name: `GPT ${index + 1}` }));
mockIPC(async (cmd, rawArgs) => {
if (cmd === 'set_app_locale') return null;
if (cmd !== 'management_request') return null;
const request = (rawArgs as { request?: { path?: string } }).request;
if (request?.path === '/credentials/download') {
return { prefix: '', proxy_url: 'socks5://127.0.0.1:1080', priority: 0, weight: 1, excluded_models: ['gpt-example', 'claude-*'], headers: {}, note: '' };
}
if (request?.path === '/credentials/models') return { models };
return { status: 'ok' };
});
createRoot(document.getElementById('root')!).render(
<I18nProvider>
<AuthFileSettingsDialog name="codex-cbb7bd85-lzt404rum@gmail.com-team.json" provider="codex" onClose={() => {}} onSaved={() => {}} />
</I18nProvider>,
);
+10 -8
View File
@@ -35,13 +35,13 @@ function componentFiles(directory: string): string[] {
}
describe('UI localization boundaries', () => {
it('preserves the raw reasoning effort in usage text and tooltips', () => {
it('preserves the raw reasoning effort under the usage model name', () => {
const file = join(sourceRoot, 'pages', 'UsageEventsView.tsx');
const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
let effortCase: ts.CaseClause | undefined;
let modelCase: ts.CaseClause | undefined;
const visit = (node: ts.Node) => {
if (ts.isCaseClause(node) && ts.isStringLiteral(node.expression) && node.expression.text === 'effort') {
effortCase = node;
if (ts.isCaseClause(node) && ts.isStringLiteral(node.expression) && node.expression.text === 'model') {
modelCase = node;
} else {
ts.forEachChild(node, visit);
}
@@ -60,10 +60,12 @@ describe('UI localization boundaries', () => {
}
ts.forEachChild(node, inspectEffort);
};
expect(effortCase).toBeDefined();
if (effortCase) inspectEffort(effortCase);
expect(displayedExpressions).toContain("record.reasoning_effort || 'auto'");
expect(titleExpressions).toContain("{record.reasoning_effort || 'auto'}");
expect(modelCase).toBeDefined();
if (modelCase) inspectEffort(modelCase);
expect(displayedExpressions).toContain('effort');
expect(titleExpressions).toContain('{effort}');
expect(source.getText()).toContain("const effort = record.reasoning_effort || 'auto'");
expect(source.getText()).not.toContain("case 'effort'");
});
it('routes visible prose and accessibility labels through translations', () => {