mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-08-29 00:41:25 +08:00
refactor: streamline excluded models management and enhance localization
- Removed deprecated localization keys related to excluded models in zh-TW.json. - Introduced a new structure for excluded models with improved UI components. - Refactored AuthFilesOAuthExcludedEditPage to utilize ExcludedModelsPicker for better handling of model exclusions. - Consolidated styles in AuthFilesOAuthExcludedEditPage.module.scss by removing unused classes. - Added comprehensive tests for excluded model rules and their matching logic. - Deleted obsolete tests for OAuth excluded rules and replaced them with more relevant tests for the new structure. - Ensured that the disabled state of providers is correctly managed and reflected in the UI.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
.chipRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 4px 5px 4px 9px;
|
||||
border-radius: $radius-full;
|
||||
color: var(--text-primary);
|
||||
font-family: $font-mono;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* 显式勾选:实线 + primary 染色,读起来是「我选的」。 */
|
||||
.exact {
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color) 45%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, var(--bg-primary));
|
||||
}
|
||||
|
||||
/* 规则派生:虚线 = 「不是逐个挑的,是某条规则算出来的」。 */
|
||||
.wildcard {
|
||||
border: 1px dashed color-mix(in srgb, var(--primary-color) 38%, var(--border-color));
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 目录外的精确规则:同样虚线,但更弱——它指向一个我们无法确认存在的模型。 */
|
||||
.unknown {
|
||||
border: 1px dashed var(--border-color);
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.remove {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
|
||||
color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
|
||||
transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.remove {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.remove:active:not(:disabled) {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { IconX } from '@/components/ui/icons';
|
||||
import styles from './ExcludedModelRuleChip.module.scss';
|
||||
|
||||
/**
|
||||
* 排除项 chip —— 按**来源**区分三种形态,取代两处近乎重复的手写标记
|
||||
* (`AuthFileDetailsSheet.module.scss` 的 `.excludedModelChip` 与
|
||||
* `AuthFilesOAuthExcludedEditPage.module.scss` 的 `.customRuleChip`)。
|
||||
*
|
||||
* - `exact` 实线 primary 染色:用户显式勾选的模型,可直接移除。
|
||||
* - `wildcard` 虚线:由通配符规则派生出的模型。没有 ✕——要移除得去改那条规则,
|
||||
* 直接给个 ✕ 会承诺一件它做不到的事。
|
||||
* - `unknown` 虚线弱化:精确规则但目录里没有(如已下线的模型 id),可移除。
|
||||
*/
|
||||
export type ExcludedModelChipVariant = 'exact' | 'wildcard' | 'unknown';
|
||||
|
||||
export interface ExcludedModelRuleChipProps {
|
||||
label: string;
|
||||
variant?: ExcludedModelChipVariant;
|
||||
/** 次要说明,例如派生该 chip 的规则。 */
|
||||
detail?: string;
|
||||
/** 省略即不渲染 ✕。 */
|
||||
onRemove?: () => void;
|
||||
removeAriaLabel?: string;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/** chip 的换行容器。单独导出,免得每个消费方各写一遍 flex-wrap。 */
|
||||
export function ExcludedModelChipRow({ children }: { children: ReactNode }) {
|
||||
return <div className={styles.chipRow}>{children}</div>;
|
||||
}
|
||||
|
||||
export function ExcludedModelRuleChip({
|
||||
label,
|
||||
variant = 'exact',
|
||||
detail,
|
||||
onRemove,
|
||||
removeAriaLabel,
|
||||
disabled = false,
|
||||
title,
|
||||
}: ExcludedModelRuleChipProps) {
|
||||
return (
|
||||
<span
|
||||
className={`${styles.chip} ${styles[variant]}`}
|
||||
title={title ?? (detail ? `${label} — ${detail}` : label)}
|
||||
>
|
||||
<span className={styles.label}>{label}</span>
|
||||
{detail ? <span className={styles.detail}>{detail}</span> : null}
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.remove}
|
||||
onClick={onRemove}
|
||||
disabled={disabled}
|
||||
aria-label={removeAriaLabel ?? label}
|
||||
>
|
||||
<IconX size={12} />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconCheck, IconSearch } from '@/components/ui/icons';
|
||||
import {
|
||||
getModelExclusionState,
|
||||
type ExclusionStats,
|
||||
type ModelExclusionState,
|
||||
} from './excludedModelRules';
|
||||
import styles from './ExcludedModelsPicker.module.scss';
|
||||
|
||||
export interface ExcludedModelCandidate {
|
||||
id: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
interface ExcludedModelsPanelProps {
|
||||
rules: readonly string[];
|
||||
candidates: readonly ExcludedModelCandidate[];
|
||||
/** 由 Picker 算好传下来,避免在 footer 里把整个目录再扫一遍。 */
|
||||
stats: ExclusionStats;
|
||||
onToggle: (modelId: string, excluded: boolean) => void;
|
||||
onSelectAll: () => void;
|
||||
onClear: () => void;
|
||||
disabled: boolean;
|
||||
listboxId: string;
|
||||
/** 展开后是否把焦点送进搜索框(键盘展开时为 true,鼠标点开时也为 true)。 */
|
||||
autoFocus: boolean;
|
||||
/** 收起面板并把焦点还给 trigger。 */
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
const matchesQuery = (candidate: ExcludedModelCandidate, query: string): boolean =>
|
||||
candidate.id.toLowerCase().includes(query) ||
|
||||
(candidate.displayName ?? '').toLowerCase().includes(query);
|
||||
|
||||
export function ExcludedModelsPanel({
|
||||
rules,
|
||||
candidates,
|
||||
stats,
|
||||
onToggle,
|
||||
onSelectAll,
|
||||
onClear,
|
||||
disabled,
|
||||
listboxId,
|
||||
autoFocus,
|
||||
onDismiss,
|
||||
}: ExcludedModelsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState('');
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return candidates;
|
||||
return candidates.filter((candidate) => matchesQuery(candidate, normalized));
|
||||
}, [candidates, query]);
|
||||
|
||||
// 高亮永远钳在可见范围内:过滤后列表变短,旧索引会指向不存在的行。
|
||||
const activeIndex = visible.length === 0 ? -1 : Math.min(highlight, visible.length - 1);
|
||||
const activeId = activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!autoFocus) return;
|
||||
// preventScroll:裸 focus() 会把外层 Sheet 的滚动猛拽过来,动画中途还会把面板顶出视野。
|
||||
inputRef.current?.focus({ preventScroll: true });
|
||||
}, [autoFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex < 0) return;
|
||||
document
|
||||
.getElementById(`${listboxId}-opt-${activeIndex}`)
|
||||
?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeIndex, listboxId]);
|
||||
|
||||
const toggleAt = (index: number) => {
|
||||
const candidate = visible[index];
|
||||
if (!candidate || disabled) return;
|
||||
const current = getModelExclusionState(rules, candidate.id);
|
||||
// 纯通配符命中的行不可直接切换——它的排除权属于那条规则。行内副文本常驻解释原因。
|
||||
if (current.state === 'excluded' && current.by === 'wildcard') return;
|
||||
onToggle(candidate.id, current.state !== 'excluded');
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
setHighlight((prev) => Math.min(prev + 1, visible.length - 1));
|
||||
return;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
setHighlight((prev) => Math.max(prev - 1, 0));
|
||||
return;
|
||||
case 'Home':
|
||||
if (visible.length === 0) return;
|
||||
event.preventDefault();
|
||||
setHighlight(0);
|
||||
return;
|
||||
case 'End':
|
||||
if (visible.length === 0) return;
|
||||
event.preventDefault();
|
||||
setHighlight(visible.length - 1);
|
||||
return;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
if (activeIndex >= 0) toggleAt(activeIndex);
|
||||
return;
|
||||
case 'Escape':
|
||||
// 外层 Sheet 在 document 上、OAuth 页在 window 上都听 Escape。
|
||||
// 不拦住就会「关面板 = 关 Sheet / 离开页面 + 触发未保存弹窗」。
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (query) {
|
||||
setQuery('');
|
||||
setHighlight(0);
|
||||
return;
|
||||
}
|
||||
onDismiss();
|
||||
return;
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.searchRow}>
|
||||
<IconSearch size={14} className={styles.searchIcon} aria-hidden="true" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={styles.search}
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setHighlight(0);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('excluded_models.search_placeholder')}
|
||||
aria-label={t('excluded_models.search_aria')}
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={activeId}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={listboxId}
|
||||
className={styles.list}
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
aria-label={t('excluded_models.list_aria')}
|
||||
>
|
||||
{visible.length === 0 ? (
|
||||
<p className={styles.noResults}>
|
||||
{query.trim()
|
||||
? t('excluded_models.no_results', { query: query.trim() })
|
||||
: t('excluded_models.catalog_empty')}
|
||||
</p>
|
||||
) : (
|
||||
visible.map((candidate, index) => (
|
||||
<ExcludedModelRow
|
||||
key={candidate.id.toLowerCase()}
|
||||
id={`${listboxId}-opt-${index}`}
|
||||
candidate={candidate}
|
||||
state={getModelExclusionState(rules, candidate.id)}
|
||||
highlighted={index === activeIndex}
|
||||
onHover={() => setHighlight(index)}
|
||||
onToggle={() => toggleAt(index)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<span className={styles.footerCount}>
|
||||
{t('excluded_models.footer_count', { excluded: stats.excluded, total: stats.total })}
|
||||
</span>
|
||||
<span className={styles.footerActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.footerButton}
|
||||
onClick={onSelectAll}
|
||||
disabled={disabled || candidates.length === 0}
|
||||
>
|
||||
{t('excluded_models.select_all')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.footerButton}
|
||||
onClick={onClear}
|
||||
disabled={disabled}
|
||||
aria-label={t('excluded_models.clear_aria')}
|
||||
>
|
||||
{t('excluded_models.clear')}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExcludedModelRowProps {
|
||||
id: string;
|
||||
candidate: ExcludedModelCandidate;
|
||||
state: ModelExclusionState;
|
||||
highlighted: boolean;
|
||||
onHover: () => void;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function ExcludedModelRow({
|
||||
id,
|
||||
candidate,
|
||||
state,
|
||||
highlighted,
|
||||
onHover,
|
||||
onToggle,
|
||||
}: ExcludedModelRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const excluded = state.state === 'excluded';
|
||||
const lockedByRule = state.state === 'excluded' && state.by === 'wildcard';
|
||||
// 把「哪条规则、用哪句话解释」在一处收敛好,下面的 JSX 就不必再做类型收窄。
|
||||
const wildcardReason =
|
||||
state.state === 'excluded' && (state.by === 'wildcard' || state.by === 'both')
|
||||
? {
|
||||
rule: state.rule,
|
||||
text:
|
||||
state.by === 'wildcard'
|
||||
? t('excluded_models.wildcard_locked', { rule: state.rule })
|
||||
: t('excluded_models.also_wildcard', { rule: state.rule }),
|
||||
muted: state.by === 'both',
|
||||
}
|
||||
: null;
|
||||
|
||||
const rowClass = [
|
||||
styles.row,
|
||||
excluded ? styles.rowExcluded : '',
|
||||
lockedByRule ? styles.rowLocked : '',
|
||||
highlighted ? styles.rowHighlighted : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
role="option"
|
||||
// 行永不进 tab 序:外层 Sheet 的焦点陷阱每次 Tab 都枚举全部可聚焦元素,
|
||||
// 几十个可聚焦的行会把它拖垮。漫游全靠 aria-activedescendant。
|
||||
tabIndex={-1}
|
||||
aria-selected={excluded}
|
||||
aria-disabled={lockedByRule || undefined}
|
||||
className={rowClass}
|
||||
onMouseEnter={onHover}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className={styles.checkbox} aria-hidden="true">
|
||||
{excluded ? <IconCheck size={12} /> : null}
|
||||
</span>
|
||||
<span className={styles.rowText}>
|
||||
<span className={styles.rowId}>{candidate.id}</span>
|
||||
{candidate.displayName && candidate.displayName !== candidate.id ? (
|
||||
<span className={styles.rowDisplayName}>{candidate.displayName}</span>
|
||||
) : null}
|
||||
{wildcardReason ? <span className={styles.rowReason}>{wildcardReason.text}</span> : null}
|
||||
</span>
|
||||
{wildcardReason ? (
|
||||
<span className={`${styles.badge} ${wildcardReason.muted ? styles.badgeMuted : ''}`.trim()}>
|
||||
{t('excluded_models.badge_wildcard')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Trigger —— 摘要 + 计量条,取代「把计数塞进 placeholder」 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.trigger {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
|
||||
background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
|
||||
transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
|
||||
|
||||
/* 整条 40px 宽元素上 0.97 太橡皮;0.99 足够被感知又不显廉价。 */
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba($primary-color, 0.18);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
&:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--primary-color) 40%, var(--border-color));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.triggerText {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.triggerSpinner {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
animation: excluded-spin 900ms linear infinite;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
transition: transform var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
|
||||
}
|
||||
|
||||
.triggerOpen .chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 底边发丝计量条:零成本地长期回答「我到底排除了多少」。 */
|
||||
.meter {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.meterFill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: color-mix(in srgb, var(--primary-color) 70%, var(--text-primary));
|
||||
transition: width 360ms var(--ease-out-strong, ease);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 内联展开:grid 0fr→1fr。搜索框会在展开状态下过滤列表,每次击键都改高度, */
|
||||
/* grid 轨道自动重解,无需测量,也没有 ResizeObserver 要去跟击键搏斗。 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.disclosure {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
/* 退场更快 + 加速。themes.scss 无 --ease-in* token,这里用关键字而非发明全局 token。 */
|
||||
transition: grid-template-rows 120ms ease-in;
|
||||
}
|
||||
|
||||
.disclosureOpen {
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
|
||||
}
|
||||
|
||||
.disclosureInner {
|
||||
/* 必需:grid item 默认 min-height:auto,漏了它面板收不回去。 */
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
background: var(--bg-secondary);
|
||||
transform-origin: top;
|
||||
animation: excluded-panel-in var(--dur-hover, 200ms) var(--ease-out-strong, ease-out) both;
|
||||
}
|
||||
|
||||
/* 只写 from,让元素的静止样式定义终点(与 toolbar-popover-in 同一写法)。 */
|
||||
@keyframes excluded-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
/* 0.98 而非 0.95——面板是宽内联块,600px 下 0.95 是 30px 的横向蠕动。 */
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes excluded-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 搜索 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.searchRow {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
color: var(--text-tertiary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 100%;
|
||||
padding: 6px 10px 6px 32px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-sm;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba($primary-color, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 列表 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 260px;
|
||||
padding: 6px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
padding: 7px 8px;
|
||||
border-radius: $radius-sm;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.rowHighlighted {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.rowExcluded .checkbox {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-color);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* 纯规则命中:压暗且不可切换,但仍可聚焦、仍会朗读原因。 */
|
||||
.rowLocked {
|
||||
cursor: default;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-sm;
|
||||
background: var(--bg-primary);
|
||||
transition:
|
||||
background-color var(--dur-press, 160ms) var(--ease-out-strong, ease-out),
|
||||
border-color var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
|
||||
}
|
||||
|
||||
.rowText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rowId {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-family: $font-mono;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowDisplayName,
|
||||
.rowReason {
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 6px;
|
||||
border: 1px dashed color-mix(in srgb, var(--primary-color) 38%, var(--border-color));
|
||||
border-radius: $radius-full;
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.badgeMuted {
|
||||
border-style: dotted;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.noResults {
|
||||
margin: 0;
|
||||
padding: $spacing-lg $spacing-sm;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 吸底摘要 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.footerCount {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.footerButton {
|
||||
padding: 4px 8px;
|
||||
border: 0;
|
||||
border-radius: $radius-sm;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
|
||||
color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
|
||||
transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 无目录降级 / 规则编辑器 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.catalogNotice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
margin-top: 6px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.retryButton {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-sm;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
|
||||
|
||||
&:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
.chipsMore {
|
||||
align-self: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ruleEditor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ruleLabel {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ruleMatches {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
code {
|
||||
color: var(--text-secondary);
|
||||
font-family: $font-mono;
|
||||
}
|
||||
}
|
||||
|
||||
/* 零命中是 warning 不是 error:规则可以合法地指向目录不认识的模型。 */
|
||||
.ruleMatchNone {
|
||||
color: var(--warning-color, #{$warning-color});
|
||||
}
|
||||
|
||||
.ruleWarning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
color: var(--warning-color, #{$warning-color});
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.disclosure,
|
||||
.disclosureOpen {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.panel {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.trigger,
|
||||
.chevron,
|
||||
.meterFill,
|
||||
.row,
|
||||
.checkbox,
|
||||
.footerButton,
|
||||
.retryButton {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.triggerSpinner {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.trigger:active:not(:disabled),
|
||||
.footerButton:active:not(:disabled),
|
||||
.retryButton:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useCallback, useId, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconAlertTriangle, IconChevronDown, IconLoader2 } from '@/components/ui/icons';
|
||||
import { ExcludedModelChipRow, ExcludedModelRuleChip } from './ExcludedModelRuleChip';
|
||||
import { ExcludedModelsPanel, type ExcludedModelCandidate } from './ExcludedModelsPanel';
|
||||
import {
|
||||
formatExcludedRulesText,
|
||||
getModelExclusionState,
|
||||
matchedModelsByRule,
|
||||
normalizeExcludedRules,
|
||||
replaceCustomExcludedRules,
|
||||
splitExcludedRules,
|
||||
summarizeExclusion,
|
||||
toggleExcludedRule,
|
||||
} from './excludedModelRules';
|
||||
import styles from './ExcludedModelsPicker.module.scss';
|
||||
|
||||
export type { ExcludedModelCandidate };
|
||||
|
||||
export type ExcludedModelsCatalogState = 'ready' | 'loading' | 'unavailable' | 'error';
|
||||
|
||||
/** 派生 chip 的上限——超过这个数就只报总数,否则 chip 行会淹没整个字段。 */
|
||||
const DERIVED_CHIP_LIMIT = 8;
|
||||
|
||||
export interface ExcludedModelsPickerProps {
|
||||
/** 规范的规则列表。调用方内部存文本/Set 都行,在边界上适配一次即可。 */
|
||||
value: readonly string[];
|
||||
onChange: (next: string[]) => void;
|
||||
|
||||
candidates: readonly ExcludedModelCandidate[];
|
||||
catalogState?: ExcludedModelsCatalogState;
|
||||
onRetryCatalog?: () => void;
|
||||
|
||||
/** 真实禁用(未连接 / 保存中)。**绝不要**因为目录为空就传 true。 */
|
||||
disabled?: boolean;
|
||||
|
||||
/** picker 不得读写、也不许用户输入的规则。provider 表单传 `['*']`。 */
|
||||
reservedRules?: readonly string[];
|
||||
reservedRuleMessage?: string;
|
||||
|
||||
/** 关掉通配符规则编辑器。 */
|
||||
showRuleEditor?: boolean;
|
||||
|
||||
labelledBy?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ExcludedModelsPicker({
|
||||
value,
|
||||
onChange,
|
||||
candidates,
|
||||
catalogState = 'ready',
|
||||
onRetryCatalog,
|
||||
disabled = false,
|
||||
reservedRules,
|
||||
reservedRuleMessage,
|
||||
showRuleEditor = true,
|
||||
labelledBy,
|
||||
className,
|
||||
}: ExcludedModelsPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const baseId = useId();
|
||||
const panelId = `${baseId}-panel`;
|
||||
const listboxId = `${baseId}-listbox`;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reservedHit, setReservedHit] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const reservedKeys = useMemo(
|
||||
() => new Set((reservedRules ?? []).map((rule) => rule.trim().toLowerCase())),
|
||||
[reservedRules]
|
||||
);
|
||||
|
||||
/**
|
||||
* 保留规则在**入口**就被剥掉,因此 picker 内部从不见到它,也就不可能把它写回去。
|
||||
* provider 表单的 `'*'`(= 已停用)由 disabled 开关独占,排除面无权触碰。
|
||||
*/
|
||||
const rules = useMemo(
|
||||
() =>
|
||||
normalizeExcludedRules(value).filter((rule) => !reservedKeys.has(rule.trim().toLowerCase())),
|
||||
[reservedKeys, value]
|
||||
);
|
||||
|
||||
const candidateIds = useMemo(() => candidates.map((c) => c.id), [candidates]);
|
||||
const stats = useMemo(() => summarizeExclusion(rules, candidateIds), [candidateIds, rules]);
|
||||
const { exactRules, unknownRules, customRules } = useMemo(
|
||||
() => splitExcludedRules(rules, candidateIds),
|
||||
[candidateIds, rules]
|
||||
);
|
||||
|
||||
const commit = useCallback(
|
||||
(next: readonly string[]) => {
|
||||
// 出口再滤一次保留规则:纵深防御,规则编辑器里手打的 `*` 到不了调用方。
|
||||
onChange(next.filter((rule) => !reservedKeys.has(rule.trim().toLowerCase())));
|
||||
},
|
||||
[onChange, reservedKeys]
|
||||
);
|
||||
|
||||
const hasCatalog = catalogState === 'ready' && candidates.length > 0;
|
||||
|
||||
/** 通配符派生出的模型(排除掉已显式勾选的,那些走实线 chip)。 */
|
||||
const derivedModels = useMemo(() => {
|
||||
if (!hasCatalog) return [];
|
||||
const out: Array<{ id: string; rule: string }> = [];
|
||||
candidateIds.forEach((id) => {
|
||||
const state = getModelExclusionState(rules, id);
|
||||
if (state.state === 'excluded' && state.by === 'wildcard') out.push({ id, rule: state.rule });
|
||||
});
|
||||
return out;
|
||||
}, [candidateIds, hasCatalog, rules]);
|
||||
|
||||
const ruleSummaries = useMemo(
|
||||
() => (hasCatalog ? matchedModelsByRule(customRules, candidateIds) : []),
|
||||
[candidateIds, customRules, hasCatalog]
|
||||
);
|
||||
|
||||
const handleToggle = (modelId: string, excluded: boolean) =>
|
||||
commit(toggleExcludedRule(rules, modelId, excluded));
|
||||
|
||||
const handleSelectAll = () => commit(normalizeExcludedRules([...rules, ...candidateIds]));
|
||||
|
||||
/** 只清精确勾选,通配符规则留给它自己的编辑器——否则一次点击会抹掉用户手写的规则。 */
|
||||
const handleClear = () => commit(customRules);
|
||||
|
||||
const handleRuleEditorChange = (text: string) => {
|
||||
const typedReserved = text
|
||||
.split(/\r?\n/)
|
||||
.some((line) => reservedKeys.has(line.trim().toLowerCase()));
|
||||
setReservedHit(typedReserved);
|
||||
commit(replaceCustomExcludedRules(rules, candidateIds, text));
|
||||
};
|
||||
|
||||
const dismissPanel = useCallback(() => {
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
|
||||
const summaryText = () => {
|
||||
if (catalogState === 'loading') return t('excluded_models.catalog_loading');
|
||||
if (hasCatalog) {
|
||||
if (stats.excluded === 0 && rules.length === 0) return t('excluded_models.trigger_empty');
|
||||
return t('excluded_models.trigger_summary', {
|
||||
excluded: stats.excluded,
|
||||
available: stats.available,
|
||||
});
|
||||
}
|
||||
// 无目录:只能诚实地报规则条数,不能假装知道「还剩几个可用」。
|
||||
if (rules.length === 0) return t('excluded_models.trigger_empty');
|
||||
return t('excluded_models.trigger_summary_rules', { n: rules.length });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${styles.root} ${className ?? ''}`.trim()}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={`${styles.trigger} ${open ? styles.triggerOpen : ''}`.trim()}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown' && !open) {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
} else if (event.key === 'Escape' && open) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? panelId : undefined}
|
||||
aria-labelledby={labelledBy}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className={styles.triggerText}>
|
||||
{catalogState === 'loading' ? (
|
||||
<IconLoader2 size={13} className={styles.triggerSpinner} aria-hidden="true" />
|
||||
) : null}
|
||||
{summaryText()}
|
||||
</span>
|
||||
<IconChevronDown size={14} className={styles.chevron} aria-hidden="true" />
|
||||
{hasCatalog ? (
|
||||
<span
|
||||
className={styles.meter}
|
||||
role="img"
|
||||
aria-label={t('excluded_models.meter_aria', {
|
||||
excluded: stats.excluded,
|
||||
total: stats.total,
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={styles.meterFill}
|
||||
style={{ width: `${stats.total ? (stats.excluded / stats.total) * 100 : 0}%` }}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
<div
|
||||
id={panelId}
|
||||
className={`${styles.disclosure} ${open ? styles.disclosureOpen : ''}`.trim()}
|
||||
>
|
||||
<div className={styles.disclosureInner} inert={!open}>
|
||||
{catalogState === 'ready' || candidates.length > 0 ? (
|
||||
<ExcludedModelsPanel
|
||||
rules={rules}
|
||||
candidates={candidates}
|
||||
stats={stats}
|
||||
onToggle={handleToggle}
|
||||
onSelectAll={handleSelectAll}
|
||||
onClear={handleClear}
|
||||
disabled={disabled}
|
||||
listboxId={listboxId}
|
||||
autoFocus={open}
|
||||
onDismiss={dismissPanel}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.catalogNotice}>
|
||||
<span>
|
||||
{catalogState === 'loading'
|
||||
? t('excluded_models.catalog_loading')
|
||||
: catalogState === 'error'
|
||||
? t('excluded_models.catalog_error')
|
||||
: t('excluded_models.catalog_unavailable')}
|
||||
</span>
|
||||
{onRetryCatalog && catalogState !== 'loading' ? (
|
||||
<button type="button" className={styles.retryButton} onClick={onRetryCatalog}>
|
||||
{t('excluded_models.catalog_retry')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{exactRules.length > 0 || derivedModels.length > 0 || unknownRules.length > 0 ? (
|
||||
<ExcludedModelChipRow>
|
||||
{exactRules.map((rule) => (
|
||||
<ExcludedModelRuleChip
|
||||
key={`exact-${rule.toLowerCase()}`}
|
||||
label={rule}
|
||||
variant="exact"
|
||||
onRemove={() => commit(toggleExcludedRule(rules, rule, false))}
|
||||
removeAriaLabel={t('excluded_models.chip_remove', { rule })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
{derivedModels.slice(0, DERIVED_CHIP_LIMIT).map((item) => (
|
||||
<ExcludedModelRuleChip
|
||||
key={`derived-${item.id.toLowerCase()}`}
|
||||
label={item.id}
|
||||
variant="wildcard"
|
||||
detail={item.rule}
|
||||
title={t('excluded_models.wildcard_reason', { rule: item.rule })}
|
||||
/>
|
||||
))}
|
||||
{derivedModels.length > DERIVED_CHIP_LIMIT ? (
|
||||
<span className={styles.chipsMore}>
|
||||
{t('excluded_models.chips_more', { n: derivedModels.length - DERIVED_CHIP_LIMIT })}
|
||||
</span>
|
||||
) : null}
|
||||
{unknownRules.map((rule) => (
|
||||
<ExcludedModelRuleChip
|
||||
key={`unknown-${rule.toLowerCase()}`}
|
||||
label={rule}
|
||||
variant="unknown"
|
||||
detail={t('excluded_models.badge_unknown')}
|
||||
onRemove={() => commit(toggleExcludedRule(rules, rule, false))}
|
||||
removeAriaLabel={t('excluded_models.chip_remove', { rule })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</ExcludedModelChipRow>
|
||||
) : null}
|
||||
|
||||
{showRuleEditor ? (
|
||||
<div className={styles.ruleEditor}>
|
||||
<label className={styles.ruleLabel} htmlFor={`${baseId}-rules`}>
|
||||
{t('excluded_models.rules_label')}
|
||||
</label>
|
||||
<textarea
|
||||
id={`${baseId}-rules`}
|
||||
className="input"
|
||||
value={formatExcludedRulesText(customRules)}
|
||||
placeholder={t('excluded_models.rules_placeholder')}
|
||||
rows={3}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
onChange={(event) => handleRuleEditorChange(event.target.value)}
|
||||
/>
|
||||
|
||||
{reservedHit ? (
|
||||
<p className={styles.ruleWarning}>
|
||||
<IconAlertTriangle size={12} aria-hidden="true" />
|
||||
{reservedRuleMessage ?? t('excluded_models.rules_reserved')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{hasCatalog ? (
|
||||
<ul className={styles.ruleMatches}>
|
||||
{ruleSummaries.map((summary) => (
|
||||
<li
|
||||
key={summary.rule.toLowerCase()}
|
||||
className={summary.matchCount === 0 ? styles.ruleMatchNone : undefined}
|
||||
>
|
||||
<code>{summary.rule}</code>
|
||||
{summary.matchCount === 0
|
||||
? t('excluded_models.rules_match_none')
|
||||
: t('excluded_models.rules_match_count', { n: summary.matchCount })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<p className="hint">{t('excluded_models.rules_hint')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 排除模型规则 —— 唯一的纯逻辑源。
|
||||
*
|
||||
* 合并自 `features/authFiles/excludedModelSelection.ts`(文本/数组式)与
|
||||
* `features/authFiles/oauthExcludedRules.ts`(Set 式)。两者是同一个领域模型写了两遍:
|
||||
* `parseExcludedModelRules(t)` 与 `normalizeOAuthExcludedRules(t.split(/\r?\n/))` 逻辑逐字相同。
|
||||
*
|
||||
* 规则语义(与后端一致):
|
||||
* - 大小写不敏感;
|
||||
* - `*` 匹配任意字符,其余字符按字面量处理(`gpt-4.1` 里的 `.` 不是正则通配符);
|
||||
* - 去重按小写 key,但**保留首次出现的拼写**。
|
||||
*/
|
||||
|
||||
/** 后端「停用整个 provider」的编码。只属于 provider 表单的 disabled 开关,排除面永不产出它。 */
|
||||
export const DISABLE_ALL_RULE = '*';
|
||||
|
||||
const ruleKey = (value: string): string => value.trim().toLowerCase();
|
||||
|
||||
export const isWildcardRule = (rule: string): boolean => rule.includes('*');
|
||||
|
||||
export function normalizeExcludedRules(values: Iterable<string>): string[] {
|
||||
const seen = new Set<string>();
|
||||
const rules: string[] = [];
|
||||
|
||||
for (const value of values) {
|
||||
const rule = value.trim();
|
||||
const key = ruleKey(rule);
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
rules.push(rule);
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
export const parseExcludedRulesText = (text: string): string[] =>
|
||||
normalizeExcludedRules(text.split(/\r?\n/));
|
||||
|
||||
export const formatExcludedRulesText = (rules: readonly string[]): string => rules.join('\n');
|
||||
|
||||
export function matchesExcludedRule(rule: string, modelId: string): boolean {
|
||||
const normalizedRule = ruleKey(rule);
|
||||
const normalizedModel = ruleKey(modelId);
|
||||
if (!normalizedRule || !normalizedModel) return false;
|
||||
if (!isWildcardRule(normalizedRule)) return normalizedRule === normalizedModel;
|
||||
|
||||
// 按 `*` 切开,逐段转义正则元字符,再用 `.*` 接回——只有 `*` 是通配符。
|
||||
const escaped = normalizedRule
|
||||
.split('*')
|
||||
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('.*');
|
||||
return new RegExp(`^${escaped}$`, 'i').test(normalizedModel);
|
||||
}
|
||||
|
||||
/** 该模型是否被某条**通配符**规则命中(精确规则不算)。 */
|
||||
export const isMatchedByWildcardRule = (rules: Iterable<string>, modelId: string): boolean =>
|
||||
Array.from(rules).some((rule) => isWildcardRule(rule) && matchesExcludedRule(rule, modelId));
|
||||
|
||||
/** 规则列表里是否存在与 candidate 字面相等(忽略大小写)的一条。不做通配符展开。 */
|
||||
export function hasExcludedRule(rules: Iterable<string>, candidate: string): boolean {
|
||||
const candidateKey = ruleKey(candidate);
|
||||
if (!candidateKey) return false;
|
||||
return Array.from(rules).some((rule) => ruleKey(rule) === candidateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 增删一条字面规则。
|
||||
*
|
||||
* 注意:这里按 key 过滤,**不**豁免含 `*` 的规则。旧的 `toggleExcludedModel` 曾拒绝删除
|
||||
* 通配符规则,那是因为调用点有两个互不知情的写入面(列表管精确、textarea 管通配符)需要
|
||||
* 互不践踏。统一组件里两个面同属一个组件,该守卫属于组件而非纯函数。
|
||||
*/
|
||||
export function toggleExcludedRule(
|
||||
rules: Iterable<string>,
|
||||
candidate: string,
|
||||
excluded: boolean
|
||||
): string[] {
|
||||
const candidateRule = candidate.trim();
|
||||
const candidateKey = ruleKey(candidateRule);
|
||||
const next = normalizeExcludedRules(rules).filter((rule) => ruleKey(rule) !== candidateKey);
|
||||
|
||||
if (excluded && candidateKey) next.push(candidateRule);
|
||||
return next;
|
||||
}
|
||||
|
||||
export interface SplitExcludedRules {
|
||||
/** 精确命中目录的规则,**改写为目录的拼写**(勾选框驱动,id 应当规范化)。 */
|
||||
exactRules: string[];
|
||||
/** 含 `*` 的规则,保留配置里的拼写。 */
|
||||
wildcardRules: string[];
|
||||
/** 精确但目录里没有的规则(如已下线的模型 id),保留配置里的拼写。 */
|
||||
unknownRules: string[];
|
||||
/** `wildcardRules ∪ unknownRules`,但按**原始出现顺序**——textarea 的内容与顺序敏感的 diff 都依赖它。 */
|
||||
customRules: string[];
|
||||
}
|
||||
|
||||
export function splitExcludedRules(
|
||||
rules: Iterable<string>,
|
||||
candidateIds: readonly string[]
|
||||
): SplitExcludedRules {
|
||||
const candidateByKey = new Map(candidateIds.map((id) => [ruleKey(id), id]));
|
||||
const exactRules: string[] = [];
|
||||
const wildcardRules: string[] = [];
|
||||
const unknownRules: string[] = [];
|
||||
const customRules: string[] = [];
|
||||
|
||||
normalizeExcludedRules(rules).forEach((rule) => {
|
||||
if (isWildcardRule(rule)) {
|
||||
wildcardRules.push(rule);
|
||||
customRules.push(rule);
|
||||
return;
|
||||
}
|
||||
const candidate = candidateByKey.get(ruleKey(rule));
|
||||
if (candidate) {
|
||||
exactRules.push(candidate);
|
||||
return;
|
||||
}
|
||||
unknownRules.push(rule);
|
||||
customRules.push(rule);
|
||||
});
|
||||
|
||||
return { exactRules, wildcardRules, unknownRules, customRules };
|
||||
}
|
||||
|
||||
/** 用一段文本整体替换「自定义」半边(通配符 + 目录外精确规则),保留精确勾选的那一半。 */
|
||||
export function replaceCustomExcludedRules(
|
||||
rules: Iterable<string>,
|
||||
candidateIds: readonly string[],
|
||||
text: string
|
||||
): string[] {
|
||||
const { exactRules } = splitExcludedRules(rules, candidateIds);
|
||||
return normalizeExcludedRules([...exactRules, ...parseExcludedRulesText(text)]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 展示用派生量 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 单个模型的排除态。
|
||||
*
|
||||
* `both` 是最微妙的一档:模型既被显式勾选、又被某条通配符规则命中。取消勾选后它**依然
|
||||
* 被排除**,所以那一行不能在视觉上「取消打勾」,否则用户会以为点击失败。旧 UI 把这一档
|
||||
* 完全藏了起来。
|
||||
*/
|
||||
export type ModelExclusionState =
|
||||
| { state: 'included' }
|
||||
| { state: 'excluded'; by: 'exact' }
|
||||
| { state: 'excluded'; by: 'wildcard'; rule: string }
|
||||
| { state: 'excluded'; by: 'both'; rule: string };
|
||||
|
||||
export function getModelExclusionState(
|
||||
rules: readonly string[],
|
||||
modelId: string
|
||||
): ModelExclusionState {
|
||||
const modelKey = ruleKey(modelId);
|
||||
if (!modelKey) return { state: 'included' };
|
||||
|
||||
let hasExact = false;
|
||||
let wildcard: string | undefined;
|
||||
|
||||
for (const rule of rules) {
|
||||
if (isWildcardRule(rule)) {
|
||||
if (wildcard === undefined && matchesExcludedRule(rule, modelId)) wildcard = rule;
|
||||
} else if (!hasExact && ruleKey(rule) === modelKey) {
|
||||
hasExact = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasExact && wildcard !== undefined) return { state: 'excluded', by: 'both', rule: wildcard };
|
||||
if (hasExact) return { state: 'excluded', by: 'exact' };
|
||||
if (wildcard !== undefined) return { state: 'excluded', by: 'wildcard', rule: wildcard };
|
||||
return { state: 'included' };
|
||||
}
|
||||
|
||||
export const isModelExcluded = (rules: readonly string[], modelId: string): boolean =>
|
||||
getModelExclusionState(rules, modelId).state === 'excluded';
|
||||
|
||||
export interface RuleMatchSummary {
|
||||
rule: string;
|
||||
/** 该规则命中的目录模型,按目录顺序。 */
|
||||
matched: string[];
|
||||
matchCount: number;
|
||||
}
|
||||
|
||||
/** 每条规则各命中了目录里的哪些模型——通配符编辑器的实时反馈就靠它。 */
|
||||
export const matchedModelsByRule = (
|
||||
rules: readonly string[],
|
||||
candidateIds: readonly string[]
|
||||
): RuleMatchSummary[] =>
|
||||
rules.map((rule) => {
|
||||
const matched = candidateIds.filter((id) => matchesExcludedRule(rule, id));
|
||||
return { rule, matched, matchCount: matched.length };
|
||||
});
|
||||
|
||||
export interface ExclusionStats {
|
||||
total: number;
|
||||
excluded: number;
|
||||
available: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 摘要行与计量条的数据源。
|
||||
*
|
||||
* `excluded` 数的是**目录内被任意规则命中的模型数**,不是 `rules.length`——一条
|
||||
* `gpt-5-*` 可能命中 6 个模型,也可能一个都不命中。用规则数当分子会在新地方复刻
|
||||
* 旧 UI 那个谎言:分子分母必须同源,计量条才是诚实的。
|
||||
*/
|
||||
export function summarizeExclusion(
|
||||
rules: readonly string[],
|
||||
candidateIds: readonly string[]
|
||||
): ExclusionStats {
|
||||
const total = candidateIds.length;
|
||||
const excluded = candidateIds.reduce(
|
||||
(count, id) => (isModelExcluded(rules, id) ? count + 1 : count),
|
||||
0
|
||||
);
|
||||
return { total, excluded, available: total - excluded };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export {
|
||||
ExcludedModelsPicker,
|
||||
type ExcludedModelCandidate,
|
||||
type ExcludedModelsCatalogState,
|
||||
type ExcludedModelsPickerProps,
|
||||
} from './ExcludedModelsPicker';
|
||||
export {
|
||||
ExcludedModelChipRow,
|
||||
ExcludedModelRuleChip,
|
||||
type ExcludedModelChipVariant,
|
||||
type ExcludedModelRuleChipProps,
|
||||
} from './ExcludedModelRuleChip';
|
||||
export {
|
||||
DISABLE_ALL_RULE,
|
||||
formatExcludedRulesText,
|
||||
getModelExclusionState,
|
||||
hasExcludedRule,
|
||||
isMatchedByWildcardRule,
|
||||
isModelExcluded,
|
||||
isWildcardRule,
|
||||
matchedModelsByRule,
|
||||
matchesExcludedRule,
|
||||
normalizeExcludedRules,
|
||||
parseExcludedRulesText,
|
||||
replaceCustomExcludedRules,
|
||||
splitExcludedRules,
|
||||
summarizeExclusion,
|
||||
toggleExcludedRule,
|
||||
type ExclusionStats,
|
||||
type ModelExclusionState,
|
||||
type RuleMatchSummary,
|
||||
type SplitExcludedRules,
|
||||
} from './excludedModelRules';
|
||||
@@ -66,69 +66,6 @@
|
||||
border-color: var(--danger-color) !important;
|
||||
}
|
||||
|
||||
.excludedModelChips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.excludedModelChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
gap: 4px;
|
||||
padding: 4px 5px 4px 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color) 45%, var(--border-color));
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, var(--bg-primary));
|
||||
color: var(--text-primary);
|
||||
font-family: $font-mono;
|
||||
font-size: 11px;
|
||||
|
||||
> span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.excludedRulesLabel {
|
||||
margin-top: 2px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.invalidPreview {
|
||||
margin: 0;
|
||||
max-height: 240px;
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Select, type SelectOption } from '@/components/ui/Select';
|
||||
import { IconX } from '@/components/ui/icons';
|
||||
import {
|
||||
ExcludedModelsPicker,
|
||||
formatExcludedRulesText,
|
||||
parseExcludedRulesText,
|
||||
type ExcludedModelsCatalogState,
|
||||
} from '@/components/excludedModels';
|
||||
import { authFilesApi } from '@/services/api';
|
||||
import type { AuthFileModelItem } from '@/features/authFiles/constants';
|
||||
import {
|
||||
isModelExcludedByWildcard,
|
||||
parseExcludedModelRules,
|
||||
replaceCustomExcludedModelRules,
|
||||
splitExcludedModelRules,
|
||||
toggleExcludedModel,
|
||||
} from '@/features/authFiles/excludedModelSelection';
|
||||
import styles from './AuthFileDetailsSheet.module.scss';
|
||||
|
||||
interface AuthFileExcludedModelsFieldProps {
|
||||
fileName: string;
|
||||
/** 换行分隔的规则文本——凭证编辑器的 dirty diff 依赖这个形状,不要改成数组。 */
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const modelOptionLabel = (model: AuthFileModelItem): string => {
|
||||
const displayName = model.display_name?.trim();
|
||||
return displayName && displayName !== model.id ? `${model.id} — ${displayName}` : model.id;
|
||||
};
|
||||
|
||||
export function AuthFileExcludedModelsField({
|
||||
fileName,
|
||||
value,
|
||||
@@ -32,6 +24,8 @@ export function AuthFileExcludedModelsField({
|
||||
onChange,
|
||||
}: AuthFileExcludedModelsFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
// 凭证文件名可能含点/斜杠等字符,不适合直接当 HTML id。
|
||||
const labelId = `${useId()}-excluded-models-label`;
|
||||
const latestValueRef = useRef(value);
|
||||
const [models, setModels] = useState<AuthFileModelItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -56,7 +50,8 @@ export function AuthFileExcludedModelsField({
|
||||
const id = item.id?.trim();
|
||||
if (id) byId.set(id.toLowerCase(), { ...item, id });
|
||||
});
|
||||
parseExcludedModelRules(latestValueRef.current).forEach((rule) => {
|
||||
// 已配置但目录里没有的精确规则也塞进候选,否则它们会在列表里凭空消失。
|
||||
parseExcludedRulesText(latestValueRef.current).forEach((rule) => {
|
||||
if (!rule.includes('*') && !byId.has(rule.toLowerCase())) {
|
||||
byId.set(rule.toLowerCase(), { id: rule });
|
||||
}
|
||||
@@ -79,82 +74,28 @@ export function AuthFileExcludedModelsField({
|
||||
};
|
||||
}, [fileName]);
|
||||
|
||||
const rules = useMemo(() => parseExcludedModelRules(value), [value]);
|
||||
const candidateIds = useMemo(() => models.map((model) => model.id), [models]);
|
||||
const { selectedIds, customRules } = useMemo(
|
||||
() => splitExcludedModelRules(rules, candidateIds),
|
||||
[candidateIds, rules]
|
||||
const rules = useMemo(() => parseExcludedRulesText(value), [value]);
|
||||
const candidates = useMemo(
|
||||
() => models.map((model) => ({ id: model.id, displayName: model.display_name })),
|
||||
[models]
|
||||
);
|
||||
const selectedKeys = useMemo(
|
||||
() => new Set(selectedIds.map((id) => id.toLowerCase())),
|
||||
[selectedIds]
|
||||
);
|
||||
const availableOptions = useMemo<SelectOption[]>(
|
||||
() =>
|
||||
models
|
||||
.filter(
|
||||
(model) =>
|
||||
!selectedKeys.has(model.id.toLowerCase()) &&
|
||||
!isModelExcludedByWildcard(customRules, model.id)
|
||||
)
|
||||
.map((model) => ({ value: model.id, label: modelOptionLabel(model) })),
|
||||
[customRules, models, selectedKeys]
|
||||
);
|
||||
|
||||
const commitRules = (nextRules: string[]) => onChange(nextRules.join('\n'));
|
||||
const catalogState: ExcludedModelsCatalogState = loading
|
||||
? 'loading'
|
||||
: loadFailed
|
||||
? 'error'
|
||||
: 'ready';
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
<label>{t('auth_files.excluded_models_label')}</label>
|
||||
<Select
|
||||
value=""
|
||||
options={availableOptions}
|
||||
onChange={(modelId) => commitRules(toggleExcludedModel(rules, modelId, true))}
|
||||
placeholder={
|
||||
loading
|
||||
? t('auth_files.excluded_models_loading')
|
||||
: t('auth_files.excluded_models_select', { count: selectedIds.length })
|
||||
}
|
||||
ariaLabel={t('auth_files.excluded_models_select_label')}
|
||||
disabled={disabled || loading || availableOptions.length === 0}
|
||||
/>
|
||||
|
||||
{selectedIds.length > 0 ? (
|
||||
<div className={styles.excludedModelChips}>
|
||||
{selectedIds.map((modelId) => (
|
||||
<span key={modelId.toLowerCase()} className={styles.excludedModelChip}>
|
||||
<span>{modelId}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commitRules(toggleExcludedModel(rules, modelId, false))}
|
||||
disabled={disabled}
|
||||
aria-label={t('auth_files.excluded_models_remove', { model: modelId })}
|
||||
>
|
||||
<IconX size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className={styles.excludedRulesLabel}>
|
||||
{t('auth_files.excluded_models_custom_label')}
|
||||
</label>
|
||||
<textarea
|
||||
className="input"
|
||||
value={customRules.join('\n')}
|
||||
placeholder={t('auth_files.excluded_models_custom_placeholder')}
|
||||
rows={3}
|
||||
<label id={labelId}>{t('auth_files.excluded_models_label')}</label>
|
||||
<ExcludedModelsPicker
|
||||
value={rules}
|
||||
onChange={(next) => onChange(formatExcludedRulesText(next))}
|
||||
candidates={candidates}
|
||||
catalogState={catalogState}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
commitRules(replaceCustomExcludedModelRules(rules, candidateIds, event.target.value))
|
||||
}
|
||||
labelledBy={labelId}
|
||||
/>
|
||||
<div className="hint">
|
||||
{loadFailed
|
||||
? t('auth_files.excluded_models_load_failed')
|
||||
: t('auth_files.excluded_models_hint')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
export const parseExcludedModelRules = (text: string): string[] => {
|
||||
const seen = new Set<string>();
|
||||
const rules: string[] = [];
|
||||
text.split(/\r?\n/).forEach((raw) => {
|
||||
const rule = raw.trim();
|
||||
const key = rule.toLowerCase();
|
||||
if (!rule || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
rules.push(rule);
|
||||
});
|
||||
return rules;
|
||||
};
|
||||
|
||||
export const matchesExcludedModelRule = (rule: string, modelId: string): boolean => {
|
||||
const normalizedRule = rule.trim().toLowerCase();
|
||||
const normalizedModel = modelId.trim().toLowerCase();
|
||||
if (!normalizedRule || !normalizedModel) return false;
|
||||
if (!normalizedRule.includes('*')) return normalizedRule === normalizedModel;
|
||||
|
||||
const escaped = normalizedRule
|
||||
.split('*')
|
||||
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('.*');
|
||||
return new RegExp(`^${escaped}$`, 'i').test(normalizedModel);
|
||||
};
|
||||
|
||||
export const isModelExcludedByWildcard = (rules: readonly string[], modelId: string): boolean =>
|
||||
rules.some((rule) => rule.includes('*') && matchesExcludedModelRule(rule, modelId));
|
||||
|
||||
export const splitExcludedModelRules = (
|
||||
rules: readonly string[],
|
||||
candidateIds: readonly string[]
|
||||
): { selectedIds: string[]; customRules: string[] } => {
|
||||
const candidateByKey = new Map(candidateIds.map((id) => [id.trim().toLowerCase(), id]));
|
||||
const selectedIds: string[] = [];
|
||||
const customRules: string[] = [];
|
||||
|
||||
rules.forEach((rule) => {
|
||||
const candidate = !rule.includes('*') ? candidateByKey.get(rule.toLowerCase()) : undefined;
|
||||
if (candidate) selectedIds.push(candidate);
|
||||
else customRules.push(rule);
|
||||
});
|
||||
|
||||
return { selectedIds, customRules };
|
||||
};
|
||||
|
||||
export const toggleExcludedModel = (
|
||||
rules: readonly string[],
|
||||
modelId: string,
|
||||
excluded: boolean
|
||||
): string[] => {
|
||||
const key = modelId.trim().toLowerCase();
|
||||
const next = rules.filter((rule) => rule.includes('*') || rule.toLowerCase() !== key);
|
||||
if (excluded && key) next.push(modelId.trim());
|
||||
return parseExcludedModelRules(next.join('\n'));
|
||||
};
|
||||
|
||||
export const replaceCustomExcludedModelRules = (
|
||||
rules: readonly string[],
|
||||
candidateIds: readonly string[],
|
||||
customText: string
|
||||
): string[] => {
|
||||
const { selectedIds } = splitExcludedModelRules(rules, candidateIds);
|
||||
return parseExcludedModelRules(
|
||||
[...selectedIds, ...parseExcludedModelRules(customText)].join('\n')
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
const getRuleKey = (value: string): string => value.trim().toLowerCase();
|
||||
|
||||
export function normalizeOAuthExcludedRules(values: Iterable<string>): string[] {
|
||||
const seen = new Set<string>();
|
||||
const rules: string[] = [];
|
||||
|
||||
for (const value of values) {
|
||||
const rule = value.trim();
|
||||
const key = getRuleKey(rule);
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
rules.push(rule);
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
export function getEffectiveOAuthExcludedRules(
|
||||
selectedRules: Iterable<string>,
|
||||
customRule: string
|
||||
): string[] {
|
||||
return normalizeOAuthExcludedRules([...selectedRules, customRule]);
|
||||
}
|
||||
|
||||
export function hasOAuthExcludedRule(values: Iterable<string>, candidate: string): boolean {
|
||||
const candidateKey = getRuleKey(candidate);
|
||||
if (!candidateKey) return false;
|
||||
return Array.from(values).some((value) => getRuleKey(value) === candidateKey);
|
||||
}
|
||||
|
||||
export function updateOAuthExcludedRule(
|
||||
values: Iterable<string>,
|
||||
candidate: string,
|
||||
selected: boolean
|
||||
): string[] {
|
||||
const candidateRule = candidate.trim();
|
||||
const candidateKey = getRuleKey(candidateRule);
|
||||
const rules = normalizeOAuthExcludedRules(values).filter(
|
||||
(value) => getRuleKey(value) !== candidateKey
|
||||
);
|
||||
|
||||
if (selected && candidateKey) rules.push(candidateRule);
|
||||
return rules;
|
||||
}
|
||||
|
||||
export function getCustomOAuthExcludedRules(
|
||||
selectedRules: Iterable<string>,
|
||||
catalogRules: Iterable<string>
|
||||
): string[] {
|
||||
const catalogKeys = new Set(
|
||||
normalizeOAuthExcludedRules(catalogRules).map((value) => getRuleKey(value))
|
||||
);
|
||||
return normalizeOAuthExcludedRules(selectedRules).filter(
|
||||
(value) => !catalogKeys.has(getRuleKey(value))
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,13 @@ import {
|
||||
} from '@/components/ui/icons';
|
||||
import { Collapsible } from '@/components/ui/Collapsible';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import {
|
||||
DISABLE_ALL_RULE,
|
||||
ExcludedModelsPicker,
|
||||
formatExcludedRulesText,
|
||||
parseExcludedRulesText,
|
||||
type ExcludedModelsCatalogState,
|
||||
} from '@/components/excludedModels';
|
||||
import { hasDisableAllModelsRule } from '@/components/providers/utils';
|
||||
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
@@ -32,6 +39,9 @@ import styles from './sharedForm.module.scss';
|
||||
import { CLAUDE_API_BASE_URL } from '../../claudeApi';
|
||||
import { MAX_CREDENTIAL_WEIGHT } from '@/utils/credentialWeight';
|
||||
|
||||
/** 模块级常量,免得每次渲染都给 picker 一个新数组引用。 */
|
||||
const DISABLE_ALL_RULES = [DISABLE_ALL_RULE];
|
||||
|
||||
interface BaseProviderFormProps {
|
||||
brand: ProviderBrand;
|
||||
resource: ProviderResource | null;
|
||||
@@ -434,6 +444,39 @@ export function BaseProviderForm({
|
||||
form.apiKeyEntries && form.apiKeyEntries.length ? form.apiKeyEntries : [emptyApiKeyEntry()],
|
||||
[form.apiKeyEntries]
|
||||
);
|
||||
|
||||
const excludedRules = useMemo(
|
||||
() => parseExcludedRulesText(form.excludedModelsText),
|
||||
[form.excludedModelsText]
|
||||
);
|
||||
/**
|
||||
* 候选目录 = discovery 发现的模型 ∪ 表单里已配置的模型名。
|
||||
*
|
||||
* 两者都可能为空——`vertex` 支持排除模型却不在 MODEL_DISCOVERY_BRANDS 里,永远没有
|
||||
* discovery;其余 brand 在用户手动跑一次发现之前也没有。因此**无目录是常态**,
|
||||
* picker 必须能在没有目录时退化成纯规则编辑器。
|
||||
*/
|
||||
const excludedCandidates = useMemo(() => {
|
||||
const byKey = new Map<string, { id: string; displayName?: string }>();
|
||||
discovery.models.forEach((model) => {
|
||||
const id = model.name?.trim();
|
||||
if (id) byKey.set(id.toLowerCase(), { id, displayName: model.alias || undefined });
|
||||
});
|
||||
form.models.forEach((model) => {
|
||||
const id = model.name?.trim();
|
||||
if (id && !byKey.has(id.toLowerCase())) byKey.set(id.toLowerCase(), { id });
|
||||
});
|
||||
return [...byKey.values()].sort((left, right) =>
|
||||
left.id.localeCompare(right.id, undefined, { sensitivity: 'base' })
|
||||
);
|
||||
}, [discovery.models, form.models]);
|
||||
const excludedCatalogState: ExcludedModelsCatalogState = discovery.loading
|
||||
? 'loading'
|
||||
: discovery.error
|
||||
? 'error'
|
||||
: excludedCandidates.length === 0
|
||||
? 'unavailable'
|
||||
: 'ready';
|
||||
const actualApiKeyEntries = form.apiKeyEntries ?? [];
|
||||
const supportsDisableCooling =
|
||||
brand === 'gemini' ||
|
||||
@@ -878,14 +921,17 @@ export function BaseProviderForm({
|
||||
{descriptor.supportsExcludedModels ? (
|
||||
<Collapsible label={t('providersPage.form.excludedSection')}>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.labelHint}>{t('providersPage.form.excludedHint')}</span>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
rows={4}
|
||||
value={form.excludedModelsText}
|
||||
onChange={(e) => updateField('excludedModelsText', e.target.value)}
|
||||
<ExcludedModelsPicker
|
||||
value={excludedRules}
|
||||
onChange={(next) => updateField('excludedModelsText', formatExcludedRulesText(next))}
|
||||
candidates={excludedCandidates}
|
||||
catalogState={excludedCatalogState}
|
||||
onRetryCatalog={discovery.available ? () => void discovery.fetch() : undefined}
|
||||
disabled={mutating}
|
||||
placeholder="model-1 model-2"
|
||||
// `'*'` = 该 provider 已停用,唯一所有者是下面的 Disabled 开关。
|
||||
// 传进来后 picker 双向过滤它,用户手打 `*` 也会被拦下并解释原因。
|
||||
reservedRules={DISABLE_ALL_RULES}
|
||||
reservedRuleMessage={t('providersPage.form.excludedDisabledNote')}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible>
|
||||
|
||||
@@ -110,7 +110,13 @@ const parseThinkingJson = (value: string | undefined): Record<string, unknown> |
|
||||
return parsed as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const buildExcludedModels = (
|
||||
/**
|
||||
* `'*'` 是「该 provider 已停用」的编码,其唯一所有者是 `form.disabled`:
|
||||
* 载入时 `stripDisableAllModelsRule` 把它剥进该 flag,保存时仅凭该 flag 重新追加。
|
||||
* 因此这里必须过滤掉用户在文本里手打的 `'*'`——排除模型的编辑面永远不该能开关停用。
|
||||
* 导出仅为让 tests/providerExcludedModelsDisableRule.test.ts 钉住这个不变量。
|
||||
*/
|
||||
export const buildExcludedModels = (
|
||||
textValue: string,
|
||||
disabled: boolean,
|
||||
brand: ProviderBrand
|
||||
|
||||
+33
-19
@@ -354,14 +354,6 @@
|
||||
"note_placeholder": "Enter a note, e.g.: John's account",
|
||||
"note_hint": "Optional. Used to describe the purpose or owner of this credential; leave empty to omit.",
|
||||
"excluded_models_label": "Excluded models (excluded_models)",
|
||||
"excluded_models_loading": "Loading credential models…",
|
||||
"excluded_models_select": "Select models to exclude ({{count}} selected)",
|
||||
"excluded_models_select_label": "Select credential models to exclude",
|
||||
"excluded_models_remove": "Stop excluding model {{model}}",
|
||||
"excluded_models_custom_label": "Custom rules",
|
||||
"excluded_models_custom_placeholder": "One rule per line, e.g. model-prefix-*",
|
||||
"excluded_models_hint": "Select credential models directly; custom rules support wildcards (*).",
|
||||
"excluded_models_load_failed": "Credential models could not be loaded; custom rules are still available.",
|
||||
"headers_label": "Custom Headers (headers)",
|
||||
"headers_placeholder": "{\n \"Header-Name\": \"value\"\n}",
|
||||
"headers_hint": "Enter custom HTTP headers as a JSON object, e.g., {\"X-My-Header\": \"value\"}",
|
||||
@@ -552,16 +544,6 @@
|
||||
"provider_placeholder": "e.g. codex / openai",
|
||||
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
|
||||
"models_label": "Models to disable",
|
||||
"models_loading": "Loading models...",
|
||||
"models_unsupported": "Current CPA version does not support fetching model lists.",
|
||||
"models_loaded": "{{count}} models loaded. Check the models to disable.",
|
||||
"no_models_available": "No models available for this provider.",
|
||||
"custom_rule_label": "Custom model rule",
|
||||
"custom_rule_hint": "Enter an exact model ID or a wildcard rule such as gpt-*.",
|
||||
"custom_rule_placeholder": "e.g. gpt-* / custom-model",
|
||||
"custom_rule_add": "Add rule",
|
||||
"custom_rules_label": "Configured custom rules",
|
||||
"custom_rule_remove": "Remove {{rule}}",
|
||||
"save": "Save/Update",
|
||||
"save_success": "Model disablement updated",
|
||||
"save_failed": "Failed to update model disablement",
|
||||
@@ -577,6 +559,38 @@
|
||||
"upgrade_required_title": "Please upgrade CLI Proxy API",
|
||||
"upgrade_required_desc": "The current server version does not support fetching OAuth model disablement. Please upgrade to the latest CPA (CLI Proxy API) version and try again."
|
||||
},
|
||||
"excluded_models": {
|
||||
"trigger_summary": "{{excluded}} excluded · {{available}} available",
|
||||
"trigger_summary_rules": "{{n}} rules",
|
||||
"trigger_empty": "No models excluded",
|
||||
"meter_aria": "{{excluded}} of {{total}} models excluded",
|
||||
"search_placeholder": "Search models…",
|
||||
"search_aria": "Search models to exclude",
|
||||
"list_aria": "Models",
|
||||
"no_results": "No model matches “{{query}}”",
|
||||
"catalog_loading": "Loading models…",
|
||||
"catalog_unavailable": "Model list unavailable — rules still apply.",
|
||||
"catalog_error": "Could not load models.",
|
||||
"catalog_retry": "Retry",
|
||||
"catalog_empty": "No models to list.",
|
||||
"footer_count": "{{excluded}} / {{total}} excluded",
|
||||
"select_all": "Exclude all",
|
||||
"clear": "Clear",
|
||||
"clear_aria": "Clear selected models (wildcard rules are kept)",
|
||||
"badge_wildcard": "By rule",
|
||||
"badge_unknown": "Not in list",
|
||||
"wildcard_reason": "Matched by rule {{rule}}",
|
||||
"wildcard_locked": "Excluded by rule {{rule}} — edit the rule below",
|
||||
"also_wildcard": "Also matched by {{rule}}",
|
||||
"chips_more": "+{{n}} more",
|
||||
"chip_remove": "Stop excluding {{rule}}",
|
||||
"rules_label": "Wildcard rules",
|
||||
"rules_placeholder": "One rule per line, e.g. gpt-5-*",
|
||||
"rules_hint": "* matches any characters. Matching is case-insensitive.",
|
||||
"rules_match_count": "matches {{n}} models",
|
||||
"rules_match_none": "matches no model in the list",
|
||||
"rules_reserved": "“*” disables the whole provider — use the Disabled switch instead"
|
||||
},
|
||||
"oauth_model_alias": {
|
||||
"title": "OAuth Model Aliases",
|
||||
"add": "Add Alias",
|
||||
@@ -1551,7 +1565,7 @@
|
||||
"headersSection": "Request headers",
|
||||
"addHeader": "Add header",
|
||||
"excludedSection": "Excluded models",
|
||||
"excludedHint": "One model per line; matched models won't be routed",
|
||||
"excludedDisabledNote": "“*” means the whole provider is disabled — use the Disabled switch above instead.",
|
||||
"cloakSection": "Cloak settings",
|
||||
"cloakMode": "Mode",
|
||||
"cloakStrict": "Strict mode",
|
||||
|
||||
+33
-19
@@ -353,14 +353,6 @@
|
||||
"note_placeholder": "Введите заметку, например: аккаунт Ивана",
|
||||
"note_hint": "Необязательно. Используется для описания назначения или владельца учётных данных; оставьте пустым, чтобы не записывать.",
|
||||
"excluded_models_label": "Исключённые модели (excluded_models)",
|
||||
"excluded_models_loading": "Загрузка моделей учётных данных…",
|
||||
"excluded_models_select": "Выберите исключаемые модели (выбрано: {{count}})",
|
||||
"excluded_models_select_label": "Выберите модели учётных данных для исключения",
|
||||
"excluded_models_remove": "Отменить исключение модели {{model}}",
|
||||
"excluded_models_custom_label": "Пользовательские правила",
|
||||
"excluded_models_custom_placeholder": "Одно правило на строку, например model-prefix-*",
|
||||
"excluded_models_hint": "Выбирайте модели напрямую; пользовательские правила поддерживают подстановочный знак (*).",
|
||||
"excluded_models_load_failed": "Не удалось загрузить модели; пользовательские правила по-прежнему доступны.",
|
||||
"prefix_proxy_invalid_json": "Этот файл авторизации не является JSON-объектом, поэтому поля нельзя редактировать.",
|
||||
"prefix_proxy_html_challenge": "Скачанное содержимое является HTML-страницей проверки, а не JSON-объектом авторизации. Повторно авторизуйтесь или замените файл перед редактированием полей.",
|
||||
"prefix_proxy_saved_success": "Файл авторизации \"{{name}}\" успешно обновлён",
|
||||
@@ -539,16 +531,6 @@
|
||||
"provider_placeholder": "например: codex / openai",
|
||||
"provider_hint": "По умолчанию используется текущий фильтр; выберите существующего провайдера или введите новое имя.",
|
||||
"models_label": "Отключаемые модели",
|
||||
"models_loading": "Загрузка моделей...",
|
||||
"models_unsupported": "Текущая версия CPA не поддерживает загрузку списка моделей.",
|
||||
"models_loaded": "Загружено моделей: {{count}}. Отметьте модели, которые нужно отключить.",
|
||||
"no_models_available": "Для этого провайдера нет доступных моделей.",
|
||||
"custom_rule_label": "Пользовательское правило модели",
|
||||
"custom_rule_hint": "Введите точный ID модели или шаблон, например gpt-*.",
|
||||
"custom_rule_placeholder": "например: gpt-* / custom-model",
|
||||
"custom_rule_add": "Добавить правило",
|
||||
"custom_rules_label": "Настроенные пользовательские правила",
|
||||
"custom_rule_remove": "Удалить {{rule}}",
|
||||
"save": "Сохранить/обновить",
|
||||
"save_success": "Отключение моделей обновлено",
|
||||
"save_failed": "Не удалось обновить отключение моделей",
|
||||
@@ -564,6 +546,38 @@
|
||||
"upgrade_required_title": "Пожалуйста, обновите CLI Proxy API",
|
||||
"upgrade_required_desc": "Текущая версия сервера не поддерживает получение отключения OAuth-моделей. Обновите CPA (CLI Proxy API) до последней версии и повторите попытку."
|
||||
},
|
||||
"excluded_models": {
|
||||
"trigger_summary": "Исключено: {{excluded}} · доступно: {{available}}",
|
||||
"trigger_summary_rules": "Правил: {{n}}",
|
||||
"trigger_empty": "Нет исключённых моделей",
|
||||
"meter_aria": "Исключено {{excluded}} из {{total}} моделей",
|
||||
"search_placeholder": "Поиск моделей…",
|
||||
"search_aria": "Поиск моделей для исключения",
|
||||
"list_aria": "Модели",
|
||||
"no_results": "Нет моделей по запросу «{{query}}»",
|
||||
"catalog_loading": "Загрузка моделей…",
|
||||
"catalog_unavailable": "Список моделей недоступен — правила всё равно применяются.",
|
||||
"catalog_error": "Не удалось загрузить модели.",
|
||||
"catalog_retry": "Повторить",
|
||||
"catalog_empty": "Нет моделей для отображения.",
|
||||
"footer_count": "Исключено {{excluded}} / {{total}}",
|
||||
"select_all": "Исключить все",
|
||||
"clear": "Очистить",
|
||||
"clear_aria": "Очистить выбранные модели (правила с подстановкой останутся)",
|
||||
"badge_wildcard": "По правилу",
|
||||
"badge_unknown": "Нет в списке",
|
||||
"wildcard_reason": "Соответствует правилу {{rule}}",
|
||||
"wildcard_locked": "Исключено правилом {{rule}} — измените правило ниже",
|
||||
"also_wildcard": "Также соответствует {{rule}}",
|
||||
"chips_more": "+{{n}}",
|
||||
"chip_remove": "Не исключать {{rule}}",
|
||||
"rules_label": "Правила с подстановкой",
|
||||
"rules_placeholder": "По одному правилу в строке, например gpt-5-*",
|
||||
"rules_hint": "* соответствует любым символам. Регистр не учитывается.",
|
||||
"rules_match_count": "соответствует моделям: {{n}}",
|
||||
"rules_match_none": "не соответствует ни одной модели из списка",
|
||||
"rules_reserved": "«*» отключает провайдера целиком — используйте переключатель «Отключено»"
|
||||
},
|
||||
"oauth_model_alias": {
|
||||
"title": "Псевдонимы моделей OAuth",
|
||||
"add": "Добавить псевдоним",
|
||||
@@ -1529,7 +1543,7 @@
|
||||
"headersSection": "Заголовки запроса",
|
||||
"addHeader": "Добавить заголовок",
|
||||
"excludedSection": "Исключённые модели",
|
||||
"excludedHint": "По одной модели в строке; совпавшие модели не будут направлены",
|
||||
"excludedDisabledNote": "«*» означает, что провайдер отключён целиком — используйте переключатель «Отключено» выше.",
|
||||
"cloakSection": "Настройки Cloak",
|
||||
"cloakMode": "Режим",
|
||||
"cloakStrict": "Строгий режим",
|
||||
|
||||
+33
-19
@@ -354,14 +354,6 @@
|
||||
"note_placeholder": "输入备注信息,例如:张三的账号",
|
||||
"note_hint": "可选,用于标记凭证用途或归属;留空则不写入。",
|
||||
"excluded_models_label": "排除模型(excluded_models)",
|
||||
"excluded_models_loading": "正在加载凭证模型…",
|
||||
"excluded_models_select": "选择要排除的模型(已选 {{count}} 个)",
|
||||
"excluded_models_select_label": "选择要排除的凭证模型",
|
||||
"excluded_models_remove": "取消排除模型 {{model}}",
|
||||
"excluded_models_custom_label": "自定义规则",
|
||||
"excluded_models_custom_placeholder": "每行一个规则,例如 model-prefix-*",
|
||||
"excluded_models_hint": "可直接选择凭证模型;自定义规则支持通配符(*)。",
|
||||
"excluded_models_load_failed": "无法加载凭证模型,仍可使用自定义规则。",
|
||||
"headers_label": "自定义请求头(headers)",
|
||||
"headers_placeholder": "{\n \"Header-Name\": \"value\"\n}",
|
||||
"headers_hint": "以 JSON 对象格式输入自定义 HTTP 请求头,例如:{\"X-My-Header\": \"value\"}",
|
||||
@@ -552,16 +544,6 @@
|
||||
"provider_placeholder": "例如 codex / openai",
|
||||
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
|
||||
"models_label": "禁用的模型",
|
||||
"models_loading": "正在加载模型列表...",
|
||||
"models_unsupported": "当前 CPA 版本不支持获取模型列表。",
|
||||
"models_loaded": "已加载 {{count}} 个模型,勾选要禁用的模型。",
|
||||
"no_models_available": "该提供商暂无可用模型列表。",
|
||||
"custom_rule_label": "自定义模型规则",
|
||||
"custom_rule_hint": "可填写精确模型 ID,或使用 gpt-* 这样的通配符规则。",
|
||||
"custom_rule_placeholder": "例如 gpt-* / custom-model",
|
||||
"custom_rule_add": "添加规则",
|
||||
"custom_rules_label": "已配置的自定义规则",
|
||||
"custom_rule_remove": "移除 {{rule}}",
|
||||
"save": "保存/更新",
|
||||
"save_success": "模型禁用已更新",
|
||||
"save_failed": "更新模型禁用失败",
|
||||
@@ -577,6 +559,38 @@
|
||||
"upgrade_required_title": "需要升级 CPA 版本",
|
||||
"upgrade_required_desc": "当前服务器版本不支持获取 OAuth 模型禁用功能,请升级到最新版本的 CPA(CLI Proxy API)后重试。"
|
||||
},
|
||||
"excluded_models": {
|
||||
"trigger_summary": "已排除 {{excluded}} · {{available}} 可用",
|
||||
"trigger_summary_rules": "{{n}} 条规则",
|
||||
"trigger_empty": "未排除任何模型",
|
||||
"meter_aria": "共 {{total}} 个模型,已排除 {{excluded}} 个",
|
||||
"search_placeholder": "搜索模型…",
|
||||
"search_aria": "搜索要排除的模型",
|
||||
"list_aria": "模型",
|
||||
"no_results": "没有匹配“{{query}}”的模型",
|
||||
"catalog_loading": "正在加载模型…",
|
||||
"catalog_unavailable": "无法获取模型列表,规则仍然生效。",
|
||||
"catalog_error": "模型加载失败。",
|
||||
"catalog_retry": "重试",
|
||||
"catalog_empty": "暂无可列出的模型。",
|
||||
"footer_count": "{{excluded}} / {{total}} 已排除",
|
||||
"select_all": "全部排除",
|
||||
"clear": "清空",
|
||||
"clear_aria": "清空已选模型(保留通配符规则)",
|
||||
"badge_wildcard": "由规则",
|
||||
"badge_unknown": "不在列表中",
|
||||
"wildcard_reason": "由规则 {{rule}} 匹配",
|
||||
"wildcard_locked": "由规则 {{rule}} 排除,请在下方修改该规则",
|
||||
"also_wildcard": "另由 {{rule}} 匹配",
|
||||
"chips_more": "还有 {{n}} 个",
|
||||
"chip_remove": "取消排除 {{rule}}",
|
||||
"rules_label": "通配符规则",
|
||||
"rules_placeholder": "每行一个规则,例如 gpt-5-*",
|
||||
"rules_hint": "* 匹配任意字符,匹配不区分大小写。",
|
||||
"rules_match_count": "匹配 {{n}} 个模型",
|
||||
"rules_match_none": "未匹配到列表中的任何模型",
|
||||
"rules_reserved": "“*” 会停用整个 provider,请改用「已停用」开关"
|
||||
},
|
||||
"oauth_model_alias": {
|
||||
"title": "OAuth 模型别名",
|
||||
"add": "新增别名",
|
||||
@@ -1551,7 +1565,7 @@
|
||||
"headersSection": "请求头",
|
||||
"addHeader": "添加请求头",
|
||||
"excludedSection": "排除模型",
|
||||
"excludedHint": "每行一个模型名,匹配后将不会被路由到此提供商",
|
||||
"excludedDisabledNote": "“*” 表示整个 provider 已停用,请改用上方的「已停用」开关。",
|
||||
"cloakSection": "Cloak 配置",
|
||||
"cloakMode": "模式",
|
||||
"cloakStrict": "严格模式",
|
||||
|
||||
+33
-19
@@ -354,14 +354,6 @@
|
||||
"note_placeholder": "輸入備註資訊,例如:張三的帳號",
|
||||
"note_hint": "選填,用於標記憑證用途或歸屬;留空則不寫入。",
|
||||
"excluded_models_label": "排除模型(excluded_models)",
|
||||
"excluded_models_loading": "正在載入憑證模型…",
|
||||
"excluded_models_select": "選擇要排除的模型(已選 {{count}} 個)",
|
||||
"excluded_models_select_label": "選擇要排除的憑證模型",
|
||||
"excluded_models_remove": "取消排除模型 {{model}}",
|
||||
"excluded_models_custom_label": "自訂規則",
|
||||
"excluded_models_custom_placeholder": "每行一個規則,例如 model-prefix-*",
|
||||
"excluded_models_hint": "可直接選擇憑證模型;自訂規則支援萬用字元(*)。",
|
||||
"excluded_models_load_failed": "無法載入憑證模型,仍可使用自訂規則。",
|
||||
"headers_label": "自訂請求標頭(headers)",
|
||||
"headers_placeholder": "{\n \"Header-Name\": \"value\"\n}",
|
||||
"headers_hint": "以 JSON 物件格式輸入自訂 HTTP 請求標頭,例如:{\"X-My-Header\": \"value\"}",
|
||||
@@ -552,16 +544,6 @@
|
||||
"provider_placeholder": "例如 codex / openai",
|
||||
"provider_hint": "預設選取目前篩選的供應商,也可直接輸入或選擇其他名稱。",
|
||||
"models_label": "停用的模型",
|
||||
"models_loading": "正在載入模型清單...",
|
||||
"models_unsupported": "目前 CPA 版本不支援取得模型清單。",
|
||||
"models_loaded": "已載入 {{count}} 個模型,勾選要停用的模型。",
|
||||
"no_models_available": "該供應商暫無可用模型清單。",
|
||||
"custom_rule_label": "自訂模型規則",
|
||||
"custom_rule_hint": "可填寫精確模型 ID,或使用 gpt-* 這類萬用字元規則。",
|
||||
"custom_rule_placeholder": "例如 gpt-* / custom-model",
|
||||
"custom_rule_add": "新增規則",
|
||||
"custom_rules_label": "已設定的自訂規則",
|
||||
"custom_rule_remove": "移除 {{rule}}",
|
||||
"save": "儲存/更新",
|
||||
"save_success": "模型停用已更新",
|
||||
"save_failed": "更新模型停用失敗",
|
||||
@@ -577,6 +559,38 @@
|
||||
"upgrade_required_title": "需要升級 CPA 版本",
|
||||
"upgrade_required_desc": "目前伺服器版本不支援取得 OAuth 模型停用功能,請升級到最新版本的 CPA(CLI Proxy API)後重試。"
|
||||
},
|
||||
"excluded_models": {
|
||||
"trigger_summary": "已排除 {{excluded}} · {{available}} 可用",
|
||||
"trigger_summary_rules": "{{n}} 條規則",
|
||||
"trigger_empty": "未排除任何模型",
|
||||
"meter_aria": "共 {{total}} 個模型,已排除 {{excluded}} 個",
|
||||
"search_placeholder": "搜尋模型…",
|
||||
"search_aria": "搜尋要排除的模型",
|
||||
"list_aria": "模型",
|
||||
"no_results": "沒有符合「{{query}}」的模型",
|
||||
"catalog_loading": "正在載入模型…",
|
||||
"catalog_unavailable": "無法取得模型清單,規則仍然生效。",
|
||||
"catalog_error": "模型載入失敗。",
|
||||
"catalog_retry": "重試",
|
||||
"catalog_empty": "暫無可列出的模型。",
|
||||
"footer_count": "{{excluded}} / {{total}} 已排除",
|
||||
"select_all": "全部排除",
|
||||
"clear": "清空",
|
||||
"clear_aria": "清空已選模型(保留萬用字元規則)",
|
||||
"badge_wildcard": "由規則",
|
||||
"badge_unknown": "不在清單中",
|
||||
"wildcard_reason": "由規則 {{rule}} 比對",
|
||||
"wildcard_locked": "由規則 {{rule}} 排除,請在下方修改該規則",
|
||||
"also_wildcard": "另由 {{rule}} 比對",
|
||||
"chips_more": "還有 {{n}} 個",
|
||||
"chip_remove": "取消排除 {{rule}}",
|
||||
"rules_label": "萬用字元規則",
|
||||
"rules_placeholder": "每行一個規則,例如 gpt-5-*",
|
||||
"rules_hint": "* 比對任意字元,比對不區分大小寫。",
|
||||
"rules_match_count": "比對 {{n}} 個模型",
|
||||
"rules_match_none": "未比對到清單中的任何模型",
|
||||
"rules_reserved": "「*」會停用整個 provider,請改用「已停用」開關"
|
||||
},
|
||||
"oauth_model_alias": {
|
||||
"title": "OAuth 模型別名",
|
||||
"add": "新增別名",
|
||||
@@ -1577,7 +1591,7 @@
|
||||
"headersSection": "請求標頭",
|
||||
"addHeader": "新增請求標頭",
|
||||
"excludedSection": "排除模型",
|
||||
"excludedHint": "每行一個模型名稱,匹配後將不會被路由到此提供商",
|
||||
"excludedDisabledNote": "「*」表示整個 provider 已停用,請改用上方的「已停用」開關。",
|
||||
"cloakSection": "Cloak 設定",
|
||||
"cloakMode": "模式",
|
||||
"cloakStrict": "嚴格模式",
|
||||
|
||||
@@ -141,160 +141,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.modelsHint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.customRuleSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
@include mobile {
|
||||
padding-left: $spacing-md;
|
||||
padding-right: $spacing-md;
|
||||
}
|
||||
}
|
||||
|
||||
.customRuleHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.customRuleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
|
||||
@include mobile {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.customRuleInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.customRuleList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.customRuleListLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.customRuleChips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.customRuleChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
padding: 4px 6px 4px 9px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-full;
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.customRuleRemove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
border: 0;
|
||||
border-radius: $radius-full;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.loadingModels {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-sm;
|
||||
padding: $spacing-xl 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modelList {
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
padding: $spacing-sm $spacing-lg $spacing-lg;
|
||||
|
||||
@include mobile {
|
||||
padding-left: $spacing-md;
|
||||
padding-right: $spacing-md;
|
||||
}
|
||||
}
|
||||
|
||||
.modelItem {
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-radius: $radius-sm;
|
||||
transition: background-color $transition-fast;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.modelText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modelId {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.modelDisplayName {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.emptyModels {
|
||||
padding: $spacing-xl $spacing-lg;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -3,11 +3,14 @@ import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { AutocompleteInput } from '@/components/ui/AutocompleteInput';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { IconInfo, IconX } from '@/components/ui/icons';
|
||||
import { IconInfo } from '@/components/ui/icons';
|
||||
import {
|
||||
ExcludedModelsPicker,
|
||||
normalizeExcludedRules,
|
||||
type ExcludedModelsCatalogState,
|
||||
} from '@/components/excludedModels';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
|
||||
@@ -19,13 +22,6 @@ import {
|
||||
normalizeProviderKey,
|
||||
} from '@/features/authFiles/constants';
|
||||
import { getStringSetSignature, isOAuthEditorDirty } from '@/features/authFiles/oauthEditorState';
|
||||
import {
|
||||
getCustomOAuthExcludedRules,
|
||||
getEffectiveOAuthExcludedRules,
|
||||
hasOAuthExcludedRule,
|
||||
normalizeOAuthExcludedRules,
|
||||
updateOAuthExcludedRule,
|
||||
} from '@/features/authFiles/oauthExcludedRules';
|
||||
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
|
||||
import { getErrorMessage } from '@/utils/helpers';
|
||||
import styles from './AuthFilesOAuthExcludedEditPage.module.scss';
|
||||
@@ -60,7 +56,6 @@ export function AuthFilesOAuthExcludedEditPage() {
|
||||
const [modelsList, setModelsList] = useState<AuthFileModelItem[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<'unsupported' | null>(null);
|
||||
const [customRule, setCustomRule] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,26 +84,25 @@ export function AuthFilesOAuthExcludedEditPage() {
|
||||
return Object.prototype.hasOwnProperty.call(excluded, resolvedProviderKey);
|
||||
}, [excluded, resolvedProviderKey]);
|
||||
const baselineModelsSignature = useMemo(
|
||||
() => getStringSetSignature(normalizeOAuthExcludedRules(excluded[resolvedProviderKey] ?? [])),
|
||||
() => getStringSetSignature(normalizeExcludedRules(excluded[resolvedProviderKey] ?? [])),
|
||||
[excluded, resolvedProviderKey]
|
||||
);
|
||||
const effectiveRules = useMemo(
|
||||
() => getEffectiveOAuthExcludedRules(selectedModels, customRule),
|
||||
[customRule, selectedModels]
|
||||
);
|
||||
/** 规则集就是选中集本身——「待添加的自定义规则」随 Add 按钮一起消失了。 */
|
||||
const effectiveRules = useMemo(() => normalizeExcludedRules(selectedModels), [selectedModels]);
|
||||
const effectiveRulesSignature = useMemo(
|
||||
() => getStringSetSignature(effectiveRules),
|
||||
[effectiveRules]
|
||||
);
|
||||
const contentDirty = baselineModelsSignature !== effectiveRulesSignature;
|
||||
const customRules = useMemo(
|
||||
() =>
|
||||
getCustomOAuthExcludedRules(
|
||||
selectedModels,
|
||||
modelsList.map((model) => model.id)
|
||||
),
|
||||
[modelsList, selectedModels]
|
||||
const candidates = useMemo(
|
||||
() => modelsList.map((model) => ({ id: model.id, displayName: model.display_name })),
|
||||
[modelsList]
|
||||
);
|
||||
const catalogState: ExcludedModelsCatalogState = modelsLoading
|
||||
? 'loading'
|
||||
: modelsError === 'unsupported'
|
||||
? 'unavailable'
|
||||
: 'ready';
|
||||
const isDirty = isOAuthEditorDirty(
|
||||
initialProviderKey,
|
||||
provider,
|
||||
@@ -222,8 +216,7 @@ export function AuthFilesOAuthExcludedEditPage() {
|
||||
return;
|
||||
}
|
||||
const existing = excluded[resolvedProviderKey] ?? [];
|
||||
setSelectedModels(new Set(normalizeOAuthExcludedRules(existing)));
|
||||
setCustomRule('');
|
||||
setSelectedModels(new Set(normalizeExcludedRules(existing)));
|
||||
}, [excluded, resolvedProviderKey]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -305,16 +298,10 @@ export function AuthFilesOAuthExcludedEditPage() {
|
||||
[applyProviderChange, contentDirty, resolvedProviderKey, showConfirmation, unsavedChangesDialog]
|
||||
);
|
||||
|
||||
const toggleModel = useCallback((modelId: string, checked: boolean) => {
|
||||
setSelectedModels((prev) => new Set(updateOAuthExcludedRule(prev, modelId, checked)));
|
||||
const handleRulesChange = useCallback((next: string[]) => {
|
||||
setSelectedModels(new Set(next));
|
||||
}, []);
|
||||
|
||||
const handleAddCustomRule = useCallback(() => {
|
||||
if (!customRule.trim()) return;
|
||||
setSelectedModels(new Set(effectiveRules));
|
||||
setCustomRule('');
|
||||
}, [customRule, effectiveRules]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const normalizedProvider = normalizeProviderKey(provider);
|
||||
if (!normalizedProvider) {
|
||||
@@ -437,118 +424,20 @@ export function AuthFilesOAuthExcludedEditPage() {
|
||||
|
||||
<Card className={styles.settingsCard}>
|
||||
<div className={styles.settingsHeader}>
|
||||
<div className={styles.settingsHeaderTitle}>{t('oauth_excluded.models_label')}</div>
|
||||
{resolvedProviderKey && (
|
||||
<div className={styles.modelsHint}>
|
||||
{modelsLoading ? (
|
||||
<>
|
||||
<LoadingSpinner size={14} />
|
||||
<span>{t('oauth_excluded.models_loading')}</span>
|
||||
</>
|
||||
) : modelsError === 'unsupported' ? (
|
||||
<span>{t('oauth_excluded.models_unsupported')}</span>
|
||||
) : modelsList.length > 0 ? (
|
||||
<span>{t('oauth_excluded.models_loaded', { count: modelsList.length })}</span>
|
||||
) : (
|
||||
<span>{t('oauth_excluded.no_models_available')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.settingsHeaderTitle} id="oauth-excluded-models-label">
|
||||
{t('oauth_excluded.models_label')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.customRuleSection}>
|
||||
<div className={styles.customRuleHeader}>
|
||||
<label className={styles.settingsLabel} htmlFor="oauth-excluded-custom-rule">
|
||||
{t('oauth_excluded.custom_rule_label')}
|
||||
</label>
|
||||
<div className={styles.settingsDesc}>{t('oauth_excluded.custom_rule_hint')}</div>
|
||||
</div>
|
||||
<div className={styles.customRuleRow}>
|
||||
<input
|
||||
id="oauth-excluded-custom-rule"
|
||||
className={`input ${styles.customRuleInput}`}
|
||||
value={customRule}
|
||||
onChange={(event) => setCustomRule(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleAddCustomRule();
|
||||
}
|
||||
}}
|
||||
placeholder={t('oauth_excluded.custom_rule_placeholder')}
|
||||
disabled={!resolvedProviderKey || disableControls || saving}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleAddCustomRule}
|
||||
disabled={!resolvedProviderKey || !customRule.trim() || disableControls || saving}
|
||||
>
|
||||
{t('oauth_excluded.custom_rule_add')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{customRules.length > 0 && (
|
||||
<div className={styles.customRuleList}>
|
||||
<div className={styles.customRuleListLabel}>
|
||||
{t('oauth_excluded.custom_rules_label')}
|
||||
</div>
|
||||
<div className={styles.customRuleChips}>
|
||||
{customRules.map((rule) => (
|
||||
<span key={rule.toLowerCase()} className={styles.customRuleChip}>
|
||||
<span>{rule}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.customRuleRemove}
|
||||
onClick={() => toggleModel(rule, false)}
|
||||
disabled={disableControls || saving}
|
||||
aria-label={t('oauth_excluded.custom_rule_remove', { rule })}
|
||||
>
|
||||
<IconX size={13} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modelsLoading ? (
|
||||
<div className={styles.loadingModels}>
|
||||
<LoadingSpinner size={16} />
|
||||
<span>{t('common.loading')}</span>
|
||||
</div>
|
||||
) : modelsList.length > 0 ? (
|
||||
<div className={styles.modelList}>
|
||||
{modelsList.map((model) => {
|
||||
const checked = hasOAuthExcludedRule(selectedModels, model.id);
|
||||
return (
|
||||
<SelectionCheckbox
|
||||
key={model.id}
|
||||
checked={checked}
|
||||
disabled={disableControls || saving}
|
||||
onChange={(value) => toggleModel(model.id, value)}
|
||||
className={styles.modelItem}
|
||||
labelClassName={styles.modelText}
|
||||
label={
|
||||
<>
|
||||
<span className={styles.modelId}>{model.id}</span>
|
||||
{model.display_name && model.display_name !== model.id && (
|
||||
<span className={styles.modelDisplayName}>{model.display_name}</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : resolvedProviderKey ? (
|
||||
<div className={styles.emptyModels}>
|
||||
{modelsError === 'unsupported'
|
||||
? t('oauth_excluded.models_unsupported')
|
||||
: t('oauth_excluded.no_models_available')}
|
||||
</div>
|
||||
{resolvedProviderKey ? (
|
||||
<ExcludedModelsPicker
|
||||
value={effectiveRules}
|
||||
onChange={handleRulesChange}
|
||||
candidates={candidates}
|
||||
catalogState={catalogState}
|
||||
disabled={disableControls || saving}
|
||||
labelledBy="oauth-excluded-models-label"
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.emptyModels}>{t('oauth_excluded.provider_required')}</div>
|
||||
)}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
isModelExcludedByWildcard,
|
||||
matchesExcludedModelRule,
|
||||
parseExcludedModelRules,
|
||||
replaceCustomExcludedModelRules,
|
||||
splitExcludedModelRules,
|
||||
toggleExcludedModel,
|
||||
} from '../src/features/authFiles/excludedModelSelection';
|
||||
|
||||
describe('auth-file excluded model selection', () => {
|
||||
test('normalizes lines and matches backend wildcard semantics case-insensitively', () => {
|
||||
expect(parseExcludedModelRules(' GPT-5-*\ngpt-5-*\nclaude-opus ')).toEqual([
|
||||
'GPT-5-*',
|
||||
'claude-opus',
|
||||
]);
|
||||
expect(matchesExcludedModelRule('gpt-5-*', 'GPT-5-Codex')).toBe(true);
|
||||
expect(matchesExcludedModelRule('*-preview', 'gemini-3-pro-preview')).toBe(true);
|
||||
expect(matchesExcludedModelRule('gpt-5-*', 'gpt-4.1')).toBe(false);
|
||||
});
|
||||
|
||||
test('separates selectable exact models from custom rules', () => {
|
||||
expect(
|
||||
splitExcludedModelRules(
|
||||
['GPT-5-Codex', 'gpt-5-*', 'unlisted-model'],
|
||||
['gpt-5-codex', 'claude-opus']
|
||||
)
|
||||
).toEqual({
|
||||
selectedIds: ['gpt-5-codex'],
|
||||
customRules: ['gpt-5-*', 'unlisted-model'],
|
||||
});
|
||||
});
|
||||
|
||||
test('adds and removes exact selections without changing wildcard rules', () => {
|
||||
const added = toggleExcludedModel(['gpt-5-*'], 'claude-opus', true);
|
||||
expect(added).toEqual(['gpt-5-*', 'claude-opus']);
|
||||
expect(toggleExcludedModel(added, 'CLAUDE-OPUS', false)).toEqual(['gpt-5-*']);
|
||||
expect(isModelExcludedByWildcard(added, 'gpt-5-mini')).toBe(true);
|
||||
});
|
||||
|
||||
test('updates custom rules while retaining selected credential models', () => {
|
||||
expect(
|
||||
replaceCustomExcludedModelRules(
|
||||
['gpt-5-codex', 'old-*'],
|
||||
['gpt-5-codex', 'claude-opus'],
|
||||
'new-*\nlegacy-model'
|
||||
)
|
||||
).toEqual(['gpt-5-codex', 'new-*', 'legacy-model']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
getModelExclusionState,
|
||||
isModelExcluded,
|
||||
matchedModelsByRule,
|
||||
summarizeExclusion,
|
||||
} from '../src/components/excludedModels/excludedModelRules';
|
||||
|
||||
const CATALOG = ['gpt-5-codex', 'gpt-5-mini', 'gpt-5-pro', 'claude-opus', 'gemini-3-pro'];
|
||||
|
||||
describe('getModelExclusionState', () => {
|
||||
test('included when no rule touches the model', () => {
|
||||
expect(getModelExclusionState(['claude-opus'], 'gpt-5-mini')).toEqual({ state: 'included' });
|
||||
});
|
||||
|
||||
test('exact when only a literal rule matches', () => {
|
||||
expect(getModelExclusionState(['gpt-5-mini'], 'gpt-5-mini')).toEqual({
|
||||
state: 'excluded',
|
||||
by: 'exact',
|
||||
});
|
||||
});
|
||||
|
||||
test('wildcard carries the responsible rule so the row can explain itself', () => {
|
||||
expect(getModelExclusionState(['gpt-5-*'], 'gpt-5-mini')).toEqual({
|
||||
state: 'excluded',
|
||||
by: 'wildcard',
|
||||
rule: 'gpt-5-*',
|
||||
});
|
||||
});
|
||||
|
||||
test('both when a model is explicitly picked AND caught by a wildcard', () => {
|
||||
expect(getModelExclusionState(['gpt-5-mini', 'gpt-5-*'], 'gpt-5-mini')).toEqual({
|
||||
state: 'excluded',
|
||||
by: 'both',
|
||||
rule: 'gpt-5-*',
|
||||
});
|
||||
});
|
||||
|
||||
test('order of the rules does not change the resolved state', () => {
|
||||
expect(getModelExclusionState(['gpt-5-*', 'gpt-5-mini'], 'gpt-5-mini')).toEqual({
|
||||
state: 'excluded',
|
||||
by: 'both',
|
||||
rule: 'gpt-5-*',
|
||||
});
|
||||
});
|
||||
|
||||
test('reports the first matching wildcard when several apply', () => {
|
||||
expect(getModelExclusionState(['gpt-*', 'gpt-5-*'], 'gpt-5-mini')).toEqual({
|
||||
state: 'excluded',
|
||||
by: 'wildcard',
|
||||
rule: 'gpt-*',
|
||||
});
|
||||
});
|
||||
|
||||
test('matching is case-insensitive in both directions', () => {
|
||||
expect(getModelExclusionState(['GPT-5-MINI'], 'gpt-5-mini')).toEqual({
|
||||
state: 'excluded',
|
||||
by: 'exact',
|
||||
});
|
||||
expect(getModelExclusionState(['gpt-5-mini'], 'GPT-5-MINI')).toEqual({
|
||||
state: 'excluded',
|
||||
by: 'exact',
|
||||
});
|
||||
});
|
||||
|
||||
test('a blank model id is never excluded', () => {
|
||||
expect(getModelExclusionState(['*-mini'], ' ')).toEqual({ state: 'included' });
|
||||
});
|
||||
|
||||
test('isModelExcluded collapses all three excluded variants', () => {
|
||||
expect(isModelExcluded(['gpt-5-mini'], 'gpt-5-mini')).toBe(true);
|
||||
expect(isModelExcluded(['gpt-5-*'], 'gpt-5-mini')).toBe(true);
|
||||
expect(isModelExcluded(['gpt-5-mini', 'gpt-5-*'], 'gpt-5-mini')).toBe(true);
|
||||
expect(isModelExcluded(['claude-opus'], 'gpt-5-mini')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchedModelsByRule', () => {
|
||||
test('reports what each rule actually catches, in catalog order', () => {
|
||||
expect(matchedModelsByRule(['gpt-5-*'], CATALOG)).toEqual([
|
||||
{ rule: 'gpt-5-*', matched: ['gpt-5-codex', 'gpt-5-mini', 'gpt-5-pro'], matchCount: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a rule matching nothing is reported with a zero count, not omitted', () => {
|
||||
expect(matchedModelsByRule(['retired-*'], CATALOG)).toEqual([
|
||||
{ rule: 'retired-*', matched: [], matchCount: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('an exact rule matches exactly its own model', () => {
|
||||
expect(matchedModelsByRule(['claude-opus'], CATALOG)).toEqual([
|
||||
{ rule: 'claude-opus', matched: ['claude-opus'], matchCount: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('overlapping rules each report the full set they catch', () => {
|
||||
expect(matchedModelsByRule(['gpt-*', 'gpt-5-pro'], CATALOG)).toEqual([
|
||||
{ rule: 'gpt-*', matched: ['gpt-5-codex', 'gpt-5-mini', 'gpt-5-pro'], matchCount: 3 },
|
||||
{ rule: 'gpt-5-pro', matched: ['gpt-5-pro'], matchCount: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('preserves the given rule order and length', () => {
|
||||
expect(matchedModelsByRule(['b-*', 'a-*'], CATALOG).map((s) => s.rule)).toEqual(['b-*', 'a-*']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeExclusion', () => {
|
||||
test('counts catalog models hit by any rule, not the rules themselves', () => {
|
||||
// One rule, three models — a rule count would say 1 and the meter would lie.
|
||||
expect(summarizeExclusion(['gpt-5-*'], CATALOG)).toEqual({
|
||||
total: 5,
|
||||
excluded: 3,
|
||||
available: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('a model caught by both an exact and a wildcard rule counts once', () => {
|
||||
expect(summarizeExclusion(['gpt-5-mini', 'gpt-5-*'], CATALOG)).toEqual({
|
||||
total: 5,
|
||||
excluded: 3,
|
||||
available: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('rules that match nothing in the catalog do not inflate the count', () => {
|
||||
expect(summarizeExclusion(['retired-model', 'gone-*'], CATALOG)).toEqual({
|
||||
total: 5,
|
||||
excluded: 0,
|
||||
available: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test('available is always total minus excluded', () => {
|
||||
const stats = summarizeExclusion(['gpt-*', 'claude-opus'], CATALOG);
|
||||
expect(stats.available).toBe(stats.total - stats.excluded);
|
||||
expect(stats).toEqual({ total: 5, excluded: 4, available: 1 });
|
||||
});
|
||||
|
||||
test('an empty catalog yields all zeroes rather than NaN', () => {
|
||||
expect(summarizeExclusion(['gpt-5-*'], [])).toEqual({ total: 0, excluded: 0, available: 0 });
|
||||
});
|
||||
|
||||
test('no rules means nothing excluded', () => {
|
||||
expect(summarizeExclusion([], CATALOG)).toEqual({ total: 5, excluded: 0, available: 5 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
formatExcludedRulesText,
|
||||
hasExcludedRule,
|
||||
isMatchedByWildcardRule,
|
||||
isWildcardRule,
|
||||
matchesExcludedRule,
|
||||
normalizeExcludedRules,
|
||||
parseExcludedRulesText,
|
||||
replaceCustomExcludedRules,
|
||||
splitExcludedRules,
|
||||
toggleExcludedRule,
|
||||
} from '../src/components/excludedModels/excludedModelRules';
|
||||
|
||||
describe('normalizeExcludedRules / parseExcludedRulesText', () => {
|
||||
test('trims, drops blanks, and removes case-insensitive duplicates', () => {
|
||||
expect(normalizeExcludedRules([' gpt-* ', 'GPT-*', '', 'claude-3'])).toEqual([
|
||||
'gpt-*',
|
||||
'claude-3',
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps the first spelling of a case-insensitive duplicate', () => {
|
||||
expect(normalizeExcludedRules(['GPT-4o', 'gpt-4O'])).toEqual(['GPT-4o']);
|
||||
});
|
||||
|
||||
test('parsing text is the same operation as normalizing its lines', () => {
|
||||
const text = ' GPT-5-*\ngpt-5-*\nclaude-opus ';
|
||||
expect(parseExcludedRulesText(text)).toEqual(['GPT-5-*', 'claude-opus']);
|
||||
expect(parseExcludedRulesText(text)).toEqual(normalizeExcludedRules(text.split(/\r?\n/)));
|
||||
});
|
||||
|
||||
test('handles CRLF line endings', () => {
|
||||
expect(parseExcludedRulesText('a-*\r\nb-model\r\n')).toEqual(['a-*', 'b-model']);
|
||||
});
|
||||
|
||||
test('round-trips through formatExcludedRulesText', () => {
|
||||
const rules = ['GPT-5-*', 'claude-opus'];
|
||||
expect(parseExcludedRulesText(formatExcludedRulesText(rules))).toEqual(rules);
|
||||
});
|
||||
|
||||
/**
|
||||
* 凭证编辑器把 excluded_models 存成换行文本,保存时用 `JSON.stringify` 做**顺序敏感**的
|
||||
* diff(useAuthFilesPrefixProxyEditor.ts:327)。picker 只要在读写之间保持顺序不变,
|
||||
* 「打开但不修改就保存」就永远不会写出与原文件不同的内容。
|
||||
*/
|
||||
test('parse→format is a fixed point for already-normalized input (order preserved)', () => {
|
||||
const fromBackend = ['GPT-5-Codex', 'gpt-5-*', 'retired-model'];
|
||||
const text = fromBackend.join('\n');
|
||||
|
||||
expect(formatExcludedRulesText(parseExcludedRulesText(text))).toBe(text);
|
||||
// 再跑一轮仍是同一个不动点。
|
||||
expect(parseExcludedRulesText(formatExcludedRulesText(parseExcludedRulesText(text)))).toEqual(
|
||||
fromBackend
|
||||
);
|
||||
});
|
||||
|
||||
test('normalization never reorders surviving rules', () => {
|
||||
expect(normalizeExcludedRules(['z-model', 'a-model', 'm-*'])).toEqual([
|
||||
'z-model',
|
||||
'a-model',
|
||||
'm-*',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesExcludedRule', () => {
|
||||
test('matches backend wildcard semantics case-insensitively', () => {
|
||||
expect(matchesExcludedRule('gpt-5-*', 'GPT-5-Codex')).toBe(true);
|
||||
expect(matchesExcludedRule('*-preview', 'gemini-3-pro-preview')).toBe(true);
|
||||
expect(matchesExcludedRule('gpt-5-*', 'gpt-4.1')).toBe(false);
|
||||
});
|
||||
|
||||
test('treats regex metacharacters in the rule as literals', () => {
|
||||
// The `.` must be a literal dot, not "any character".
|
||||
expect(matchesExcludedRule('gpt-4.1', 'gpt-4.1')).toBe(true);
|
||||
expect(matchesExcludedRule('gpt-4.1', 'gpt-4x1')).toBe(false);
|
||||
expect(matchesExcludedRule('gpt-4.*', 'gpt-4.1-mini')).toBe(true);
|
||||
expect(matchesExcludedRule('gpt-4.*', 'gpt-4x1-mini')).toBe(false);
|
||||
});
|
||||
|
||||
test('anchors at both ends', () => {
|
||||
expect(matchesExcludedRule('gpt-5', 'gpt-5-codex')).toBe(false);
|
||||
expect(matchesExcludedRule('gpt-5*', 'gpt-5-codex')).toBe(true);
|
||||
});
|
||||
|
||||
test('a blank rule or model never matches', () => {
|
||||
expect(matchesExcludedRule('', 'gpt-5')).toBe(false);
|
||||
expect(matchesExcludedRule('gpt-5', ' ')).toBe(false);
|
||||
});
|
||||
|
||||
test('isWildcardRule / isMatchedByWildcardRule ignore exact rules', () => {
|
||||
expect(isWildcardRule('gpt-5-*')).toBe(true);
|
||||
expect(isWildcardRule('gpt-5-codex')).toBe(false);
|
||||
expect(isMatchedByWildcardRule(['gpt-5-*'], 'gpt-5-mini')).toBe(true);
|
||||
// An exact rule matching the model is not a *wildcard* match.
|
||||
expect(isMatchedByWildcardRule(['gpt-5-mini'], 'gpt-5-mini')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasExcludedRule', () => {
|
||||
test('compares literally and case-insensitively, without wildcard expansion', () => {
|
||||
expect(hasExcludedRule(['GPT-4o'], 'gpt-4O')).toBe(true);
|
||||
expect(hasExcludedRule(['gpt-5-*'], 'gpt-5-codex')).toBe(false);
|
||||
expect(hasExcludedRule(['gpt-5-*'], 'GPT-5-*')).toBe(true);
|
||||
expect(hasExcludedRule(['gpt-4o'], ' ')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleExcludedRule', () => {
|
||||
test('adds and removes exact rules without touching wildcard rules', () => {
|
||||
const added = toggleExcludedRule(['gpt-5-*'], 'claude-opus', true);
|
||||
expect(added).toEqual(['gpt-5-*', 'claude-opus']);
|
||||
expect(toggleExcludedRule(added, 'CLAUDE-OPUS', false)).toEqual(['gpt-5-*']);
|
||||
});
|
||||
|
||||
test('removes a wildcard rule by name (the old auth-file helper refused to)', () => {
|
||||
expect(toggleExcludedRule(['gpt-5-*', 'claude-opus'], 'GPT-5-*', false)).toEqual([
|
||||
'claude-opus',
|
||||
]);
|
||||
});
|
||||
|
||||
test('adding an existing rule moves it to the end rather than duplicating', () => {
|
||||
expect(toggleExcludedRule(['a', 'b'], 'A', true)).toEqual(['b', 'A']);
|
||||
});
|
||||
|
||||
test('a blank candidate is a no-op add', () => {
|
||||
expect(toggleExcludedRule(['a'], ' ', true)).toEqual(['a']);
|
||||
});
|
||||
|
||||
test('trims the candidate when removing, down to an empty list', () => {
|
||||
expect(toggleExcludedRule(['GPT-4o'], ' gpt-4O ', false)).toEqual([]);
|
||||
});
|
||||
|
||||
test('trims the candidate when adding', () => {
|
||||
expect(toggleExcludedRule(['gpt-4o'], ' gpt-* ', true)).toEqual(['gpt-4o', 'gpt-*']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitExcludedRules', () => {
|
||||
test('partitions into exact / wildcard / unknown', () => {
|
||||
expect(
|
||||
splitExcludedRules(
|
||||
['GPT-5-Codex', 'gpt-5-*', 'unlisted-model'],
|
||||
['gpt-5-codex', 'claude-opus']
|
||||
)
|
||||
).toEqual({
|
||||
exactRules: ['gpt-5-codex'],
|
||||
wildcardRules: ['gpt-5-*'],
|
||||
unknownRules: ['unlisted-model'],
|
||||
customRules: ['gpt-5-*', 'unlisted-model'],
|
||||
});
|
||||
});
|
||||
|
||||
test('rewrites exact rules to the catalog spelling', () => {
|
||||
expect(splitExcludedRules(['GPT-5-CODEX'], ['gpt-5-codex']).exactRules).toEqual([
|
||||
'gpt-5-codex',
|
||||
]);
|
||||
});
|
||||
|
||||
test('preserves the configured spelling for wildcard and unknown rules', () => {
|
||||
const { wildcardRules, unknownRules } = splitExcludedRules(
|
||||
['GPT-5-*', 'Retired-Model'],
|
||||
['gpt-5-codex']
|
||||
);
|
||||
expect(wildcardRules).toEqual(['GPT-5-*']);
|
||||
expect(unknownRules).toEqual(['Retired-Model']);
|
||||
});
|
||||
|
||||
test('customRules keeps the original interleaved order, not bucket order', () => {
|
||||
// `unlisted` appears before `a-*`; concatenating the buckets would reverse them.
|
||||
expect(splitExcludedRules(['unlisted', 'a-*'], ['gpt-5-codex']).customRules).toEqual([
|
||||
'unlisted',
|
||||
'a-*',
|
||||
]);
|
||||
});
|
||||
|
||||
test('an empty catalog makes every exact rule unknown', () => {
|
||||
expect(splitExcludedRules(['a', 'b-*'], [])).toEqual({
|
||||
exactRules: [],
|
||||
wildcardRules: ['b-*'],
|
||||
unknownRules: ['a'],
|
||||
customRules: ['a', 'b-*'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceCustomExcludedRules', () => {
|
||||
test('swaps the custom half while retaining exact selections', () => {
|
||||
expect(
|
||||
replaceCustomExcludedRules(
|
||||
['gpt-5-codex', 'old-*'],
|
||||
['gpt-5-codex', 'claude-opus'],
|
||||
'new-*\nlegacy-model'
|
||||
)
|
||||
).toEqual(['gpt-5-codex', 'new-*', 'legacy-model']);
|
||||
});
|
||||
|
||||
test('clearing the text leaves only the exact selections', () => {
|
||||
expect(replaceCustomExcludedRules(['gpt-5-codex', 'old-*'], ['gpt-5-codex'], '')).toEqual([
|
||||
'gpt-5-codex',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a custom rule duplicating an exact selection does not double it', () => {
|
||||
expect(replaceCustomExcludedRules(['gpt-5-codex'], ['gpt-5-codex'], 'GPT-5-CODEX')).toEqual([
|
||||
'gpt-5-codex',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
getCustomOAuthExcludedRules,
|
||||
getEffectiveOAuthExcludedRules,
|
||||
hasOAuthExcludedRule,
|
||||
normalizeOAuthExcludedRules,
|
||||
updateOAuthExcludedRule,
|
||||
} from '../src/features/authFiles/oauthExcludedRules';
|
||||
|
||||
describe('OAuth excluded rules', () => {
|
||||
test('keeps wildcard rules and removes case-insensitive duplicates', () => {
|
||||
expect(normalizeOAuthExcludedRules([' gpt-* ', 'GPT-*', '', 'claude-3'])).toEqual([
|
||||
'gpt-*',
|
||||
'claude-3',
|
||||
]);
|
||||
});
|
||||
|
||||
test('finds and toggles rules case-insensitively', () => {
|
||||
expect(hasOAuthExcludedRule(['GPT-4o'], 'gpt-4O')).toBe(true);
|
||||
expect(updateOAuthExcludedRule(['GPT-4o'], ' gpt-4O ', false)).toEqual([]);
|
||||
expect(updateOAuthExcludedRule(['gpt-4o'], ' gpt-* ', true)).toEqual(['gpt-4o', 'gpt-*']);
|
||||
});
|
||||
|
||||
test('returns configured rules that are absent from the static model catalog', () => {
|
||||
expect(
|
||||
getCustomOAuthExcludedRules(
|
||||
['gpt-4o', 'gpt-*', 'retired-model', 'CLAUDE-3'],
|
||||
['GPT-4O', 'claude-3']
|
||||
)
|
||||
).toEqual(['gpt-*', 'retired-model']);
|
||||
});
|
||||
|
||||
test('includes a pending custom rule in the effective rules', () => {
|
||||
expect(getEffectiveOAuthExcludedRules(['gpt-4o'], ' gpt-* ')).toEqual(['gpt-4o', 'gpt-*']);
|
||||
});
|
||||
|
||||
test('ignores a blank pending custom rule', () => {
|
||||
expect(getEffectiveOAuthExcludedRules(['gpt-4o'], ' ')).toEqual(['gpt-4o']);
|
||||
});
|
||||
|
||||
test('keeps the original spelling for a case-insensitive duplicate', () => {
|
||||
expect(getEffectiveOAuthExcludedRules(['GPT-4o'], 'gpt-4O')).toEqual(['GPT-4o']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { buildExcludedModels } from '../src/features/providers/useProviderWorkbench';
|
||||
|
||||
/**
|
||||
* `excluded-models: ['*']` 是「该 provider 已停用」的后端编码。
|
||||
* 它的唯一所有者是表单的 `disabled` 开关:载入时被剥离进该 flag,保存时仅凭该 flag 重新追加。
|
||||
*
|
||||
* 这些断言把该不变量钉死,好让排除模型的编辑面(textarea → ExcludedModelsPicker)
|
||||
* 无论怎么重写都不可能污染停用语义。
|
||||
*/
|
||||
describe('buildExcludedModels — the "*" disable-rule invariant', () => {
|
||||
test('appends "*" when disabled', () => {
|
||||
expect(buildExcludedModels('a\nb', true, 'gemini')).toEqual(['a', 'b', '*']);
|
||||
});
|
||||
|
||||
test('omits "*" when not disabled', () => {
|
||||
expect(buildExcludedModels('a\nb', false, 'gemini')).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('a hand-typed "*" never duplicates the disable rule', () => {
|
||||
expect(buildExcludedModels('a\n*\nb', true, 'gemini')).toEqual(['a', 'b', '*']);
|
||||
});
|
||||
|
||||
test('a hand-typed "*" never switches the provider to disabled', () => {
|
||||
expect(buildExcludedModels('a\n*\nb', false, 'gemini')).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('disabled with no rules yields exactly the disable rule', () => {
|
||||
expect(buildExcludedModels('', true, 'gemini')).toEqual(['*']);
|
||||
});
|
||||
|
||||
test('no rules and not disabled yields undefined, not an empty array', () => {
|
||||
expect(buildExcludedModels('', false, 'gemini')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('openaiCompatibility never receives the disable rule', () => {
|
||||
expect(buildExcludedModels('a', true, 'openaiCompatibility')).toEqual(['a']);
|
||||
expect(buildExcludedModels('', true, 'openaiCompatibility')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user