Compare commits

..

17 Commits

45 changed files with 1465 additions and 1036 deletions
-1
View File
@@ -78,7 +78,6 @@ Check the CLI Proxy API server documentation/config comments for the full authen
- **AI Providers**:
- Gemini/Codex/Claude/Vertex key entries (base URL, headers, proxy, model aliases, excluded models, prefix).
- OpenAI-compatible providers (multiple API keys, custom headers, model alias import via `/v1/models`, optional browser-side "chat/completions" test).
- Ampcode integration (upstream URL/key, force mappings, model mapping table).
- **Auth Files**: upload/download/delete JSON credentials, filter/search/pagination, runtime-only indicators, view supported models per credential (when the server supports it), manage OAuth excluded models (supports `*` wildcards), configure OAuth model alias mappings.
- **OAuth**: start OAuth/device flows for Codex, Anthropic/Claude, Antigravity, Gemini CLI, Kimi, and xAI/Grok; poll status; submit callback URLs or xAI/Grok displayed codes; import Vertex JSON credentials and iFlow cookies.
- **Quota Management**: manage quota limits and usage for Claude, Antigravity, Codex, Gemini CLI, and other providers.
-1
View File
@@ -78,7 +78,6 @@ bun run build
- **AI 提供商**
- Gemini/Codex/Claude/Vertex 配置(Base URL、Headers、代理、模型别名、排除模型、Prefix)。
- OpenAI 兼容提供商(多 Key、Header、自助从 `/v1/models` 拉取并导入模型别名、可选浏览器侧 `chat/completions` 测试)。
- Ampcode 集成(上游地址/密钥、强制映射、模型映射表)。
- **认证文件**:上传/下载/删除 JSON 凭据,筛选/搜索/分页,标记 runtime-only;查看单个凭据可用模型(依赖后端支持);管理 OAuth 排除模型(支持 `*` 通配符);配置 OAuth 模型别名映射。
- **OAuth**:对 Codex、Anthropic/Claude、Antigravity、Gemini CLI、Kimi、xAI/Grok 发起 OAuth/设备码流程并轮询状态;支持提交回调 URL 或 xAI/Grok 页面显示的 code;包含 Vertex JSON 凭据导入与 iFlow Cookie 导入。
- **配额管理**:管理 Claude、Antigravity、Codex、Gemini CLI 等提供商的配额上限与使用情况。
-6
View File
@@ -1,6 +0,0 @@
<svg width="400" height="400" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.9197 13.61L17.3816 26.566L14.242 27.4049L11.2645 16.2643L0.119926 13.2906L0.957817 10.15L13.9197 13.61Z" fill="#F34E3F"/>
<path d="M13.7391 16.0892L4.88169 24.9056L2.58872 22.6019L11.4461 13.7865L13.7391 16.0892Z" fill="#F34E3F"/>
<path d="M18.9386 8.58315L22.4005 21.5392L19.2609 22.3781L16.2833 11.2374L5.13879 8.26381L5.97668 5.12318L18.9386 8.58315Z" fill="#F34E3F"/>
<path d="M23.9803 3.55632L27.4422 16.5124L24.3025 17.3512L21.325 6.21062L10.1805 3.23698L11.0183 0.0963593L23.9803 3.55632Z" fill="#F34E3F"/>
</svg>

Before

Width:  |  Height:  |  Size: 632 B

@@ -646,6 +646,10 @@
align-items: start;
}
.payloadRuleRawParamRow {
grid-template-columns: minmax(240px, 1fr) minmax(320px, 1fr) auto;
}
.payloadRuleParamGroup {
display: flex;
flex-direction: column;
@@ -673,6 +677,7 @@
.payloadRowActionButton {
flex: 0 0 auto;
justify-self: start;
}
.apiKeyModalInputRow {
@@ -37,6 +37,7 @@ import {
ApiKeysCardEditor,
PayloadFilterRulesEditor,
PayloadRulesEditor,
StringListEditor,
} from './VisualConfigEditorBlocks';
import styles from './VisualConfigEditor.module.scss';
@@ -218,6 +219,10 @@ export function VisualConfigEditor({
(apiKeysText: string) => onChange({ apiKeysText }),
[onChange]
);
const handlePluginStoreSourcesChange = useCallback(
(pluginStoreSources: string[]) => onChange({ pluginStoreSources }),
[onChange]
);
const handlePayloadDefaultRulesChange = useCallback(
(payloadDefaultRules: PayloadRule[]) => onChange({ payloadDefaultRules }),
[onChange]
@@ -667,6 +672,33 @@ export function VisualConfigEditor({
/>
</SectionGrid>
<SectionSubsection
title={t('config_management.visual.sections.system.plugin_store_sources')}
description={t(
'config_management.visual.sections.system.plugin_store_sources_desc'
)}
>
<div className={styles.fieldShell}>
<label className={styles.fieldLabel}>
{t('config_management.visual.sections.system.plugin_store_sources_label')}
</label>
<StringListEditor
value={values.pluginStoreSources}
disabled={disabled}
placeholder={t(
'config_management.visual.sections.system.plugin_store_sources_placeholder'
)}
inputAriaLabel={t(
'config_management.visual.sections.system.plugin_store_sources_label'
)}
onChange={handlePluginStoreSourcesChange}
/>
<div className={styles.fieldHint}>
{t('config_management.visual.sections.system.plugin_store_sources_hint')}
</div>
</div>
</SectionSubsection>
<SectionGrid>
<Input
label={t('config_management.visual.sections.system.logs_max_size')}
@@ -389,7 +389,7 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({
);
});
const StringListEditor = memo(function StringListEditor({
export const StringListEditor = memo(function StringListEditor({
value,
disabled,
placeholder,
@@ -1126,7 +1126,14 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
return (
<div key={param.id} className={styles.payloadRuleParamGroup}>
<div className={styles.payloadRuleParamRow}>
<div
className={[
styles.payloadRuleParamRow,
rawJsonValues ? styles.payloadRuleRawParamRow : '',
]
.filter(Boolean)
.join(' ')}
>
<ExpandableInput
placeholder={t('config_management.visual.payload_rules.json_path')}
ariaLabel={t('config_management.visual.payload_rules.json_path')}
+15
View File
@@ -63,6 +63,21 @@ export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
return `${trimmed}/chat/completions`;
};
export const buildCodexResponsesEndpoint = (baseUrl: string): string => {
const trimmed = normalizeUpstreamBaseUrl(baseUrl);
if (!trimmed) return '';
if (/\/v1\/responses$/i.test(trimmed)) {
return trimmed;
}
if (/\/v1\/models$/i.test(trimmed)) {
return trimmed.replace(/\/models$/i, '/responses');
}
if (/\/v1$/i.test(trimmed)) {
return `${trimmed}/responses`;
}
return `${trimmed}/v1/responses`;
};
export const buildClaudeMessagesEndpoint = (baseUrl: string): string => {
const trimmed = normalizeUpstreamBaseUrl(baseUrl, 'https://api.anthropic.com');
if (!trimmed) return '';
+37 -16
View File
@@ -248,12 +248,12 @@ const fetchAntigravityQuota = async (
const buildCodexQuotaWindows = (
payload: CodexUsagePayload,
t: TFunction,
planType?: string | null
t: TFunction
): CodexQuotaWindow[] => {
const FIVE_HOUR_SECONDS = 18000;
const WEEK_SECONDS = 604800;
const isTeamPlan = normalizePlanType(planType) === 'team';
const MIN_MONTH_SECONDS = 28 * 24 * 60 * 60;
const MAX_MONTH_SECONDS = 31 * 24 * 60 * 60;
const WINDOW_META = {
codeFiveHour: { id: 'five-hour', labelKey: 'codex_quota.primary_window' },
codeWeekly: { id: 'weekly', labelKey: 'codex_quota.secondary_window' },
@@ -307,6 +307,20 @@ const buildCodexQuotaWindows = (
return normalizeNumberValue(window.limit_window_seconds ?? window.limitWindowSeconds);
};
const isMonthlyWindow = (window?: CodexUsageWindow | null): boolean => {
const seconds = getWindowSeconds(window);
return seconds !== null && seconds >= MIN_MONTH_SECONDS && seconds <= MAX_MONTH_SECONDS;
};
const selectSecondaryWindowMeta = <
TWeekly extends { id: string; labelKey: string },
TMonthly extends { id: string; labelKey: string },
>(
window: CodexUsageWindow | null | undefined,
weeklyMeta: TWeekly,
monthlyMeta: TMonthly
): TWeekly | TMonthly => (isMonthlyWindow(window) ? monthlyMeta : weeklyMeta);
const rawLimitReached = rateLimit?.limit_reached ?? rateLimit?.limitReached;
const rawAllowed = rateLimit?.allowed;
@@ -327,7 +341,7 @@ const buildCodexQuotaWindows = (
const seconds = getWindowSeconds(window);
if (seconds === FIVE_HOUR_SECONDS && !fiveHourWindow) {
fiveHourWindow = window;
} else if (seconds === WEEK_SECONDS && !weeklyWindow) {
} else if ((seconds === WEEK_SECONDS || isMonthlyWindow(window)) && !weeklyWindow) {
weeklyWindow = window;
}
}
@@ -356,7 +370,11 @@ const buildCodexQuotaWindows = (
rawLimitReached,
rawAllowed
);
const codeSecondaryWindowMeta = isTeamPlan ? WINDOW_META.codeMonthly : WINDOW_META.codeWeekly;
const codeSecondaryWindowMeta = selectSecondaryWindowMeta(
rateWindows.weeklyWindow,
WINDOW_META.codeWeekly,
WINDOW_META.codeMonthly
);
addWindow(
codeSecondaryWindowMeta.id,
t(codeSecondaryWindowMeta.labelKey),
@@ -379,9 +397,11 @@ const buildCodexQuotaWindows = (
codeReviewLimitReached,
codeReviewAllowed
);
const codeReviewSecondaryWindowMeta = isTeamPlan
? WINDOW_META.codeReviewMonthly
: WINDOW_META.codeReviewWeekly;
const codeReviewSecondaryWindowMeta = selectSecondaryWindowMeta(
codeReviewWindows.weeklyWindow,
WINDOW_META.codeReviewWeekly,
WINDOW_META.codeReviewMonthly
);
addWindow(
codeReviewSecondaryWindowMeta.id,
t(codeReviewSecondaryWindowMeta.labelKey),
@@ -425,14 +445,15 @@ const buildCodexQuotaWindows = (
additionalLimitReached,
additionalAllowed
);
const additionalSecondaryLabelKey = isTeamPlan
? 'codex_quota.additional_team_secondary_window'
: 'codex_quota.additional_secondary_window';
const additionalSecondaryId = isTeamPlan ? 'monthly' : 'weekly';
const additionalSecondaryMeta = selectSecondaryWindowMeta(
additionalSecondaryWindow,
{ id: 'weekly', labelKey: 'codex_quota.additional_secondary_window' },
{ id: 'monthly', labelKey: 'codex_quota.additional_team_secondary_window' }
);
addWindow(
`${idPrefix}-${additionalSecondaryId}-${index}`,
t(additionalSecondaryLabelKey, { name: limitName }),
additionalSecondaryLabelKey,
`${idPrefix}-${additionalSecondaryMeta.id}-${index}`,
t(additionalSecondaryMeta.labelKey, { name: limitName }),
additionalSecondaryMeta.labelKey,
{ name: limitName },
additionalSecondaryWindow,
additionalLimitReached,
@@ -492,7 +513,7 @@ const fetchCodexQuota = async (
resetCredits?.available_count ?? resetCredits?.availableCount
);
const planType = planTypeFromUsage ?? planTypeFromFile;
const windows = buildCodexQuotaWindows(payload, t, planType);
const windows = buildCodexQuotaWindows(payload, t);
return {
planType,
subscriptionActiveUntil,
@@ -73,6 +73,43 @@
color: var(--text-primary);
}
// ─── Security Banner (third-party plugin risk) ──────────
.securityBanner {
display: flex;
align-items: flex-start;
gap: $spacing-sm;
padding: 12px 14px;
border-radius: $radius-md;
border: 1px solid color-mix(in srgb, var(--quota-medium-color, #e0aa14) 45%, var(--border-color));
background: color-mix(in srgb, var(--quota-medium-color, #e0aa14) 14%, var(--bg-secondary));
color: var(--text-primary);
> svg {
flex-shrink: 0;
margin-top: 1px;
color: var(--quota-medium-color, #e0aa14);
}
}
.securityBannerText {
min-width: 0;
strong {
display: block;
font-size: 14px;
font-weight: 700;
line-height: 1.4;
}
p {
margin: 2px 0 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary);
}
}
// ─── Status Bar ─────────────────────────────────────────
.statusBar {
@@ -323,7 +360,8 @@
.badge,
.badgeSuccess,
.badgeWarning {
.badgeWarning,
.badgeUntrusted {
display: inline-flex;
min-height: 22px;
align-items: center;
@@ -352,6 +390,17 @@
color: var(--warning-color);
}
.badgeUntrusted {
gap: 4px;
background: rgba($warning-color, 0.12);
border: 1px solid rgba($warning-color, 0.4);
color: var(--danger-color);
svg {
flex-shrink: 0;
}
}
.cardDesc {
display: -webkit-box;
margin: 0;
@@ -363,6 +412,43 @@
-webkit-line-clamp: 2;
}
.cardDescBlock {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.cardDescExpanded {
display: block;
overflow: visible;
-webkit-line-clamp: initial;
}
.cardDescToggle {
align-self: flex-start;
padding: 0;
border: 0;
background: transparent;
color: var(--primary-color);
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 650;
line-height: 1.4;
&:hover {
color: var(--primary-hover);
text-decoration: underline;
}
&:focus-visible {
outline: 2px solid color-mix(in srgb, var(--primary-color) 45%, transparent);
outline-offset: 3px;
border-radius: 4px;
}
}
.cardMeta {
display: flex;
align-items: center;
+211 -39
View File
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { Input } from '@/components/ui/Input';
import {
IconAlertTriangle,
IconDownload,
IconExternalLink,
IconGithub,
@@ -12,13 +13,20 @@ import {
IconRefreshCw,
IconSearch,
IconSettings,
IconShield,
} from '@/components/ui/icons';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { pluginStoreApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { getErrorMessage, isRecord } from '@/utils/helpers';
import type { PluginStoreEntry, PluginStoreResponse } from '@/types';
import { buildRepositoryURL, resolvePluginAssetURL } from './pluginResources';
import {
buildRepositoryURL,
isDefaultPluginStoreSource,
isOfficialPlugin,
resolvePluginAssetURL,
} from './pluginResources';
import { PluginInstallGateModal } from './components/PluginInstallGateModal';
import styles from './PluginStorePage.module.scss';
type StoreStatusFilter = 'all' | 'installed' | 'notInstalled' | 'updates';
@@ -37,7 +45,12 @@ const getErrorDetailMessage = (error: unknown): string => {
return typeof message === 'string' ? message.trim() : '';
};
const DESCRIPTION_COLLAPSED_LINES = 2;
const getStoreEntryTitle = (entry: PluginStoreEntry) => entry.name || entry.id;
const getStoreEntryKey = (entry: PluginStoreEntry) => entry.storeId || entry.id;
const getDescriptionDOMID = (entryKey: string) =>
`plugin-store-desc-${encodeURIComponent(entryKey)}`;
function StoreCardLogo({ src }: { src: string }) {
const [failed, setFailed] = useState(false);
@@ -64,8 +77,16 @@ export function PluginStorePage() {
const [error, setError] = useState<StoreLoadError | null>(null);
const [filter, setFilter] = useState('');
const [statusFilter, setStatusFilter] = useState<StoreStatusFilter>('all');
const [installingID, setInstallingID] = useState('');
const [restartRequiredIDs, setRestartRequiredIDs] = useState<string[]>([]);
const [installingKey, setInstallingKey] = useState('');
const [restartRequiredKeys, setRestartRequiredKeys] = useState<string[]>([]);
const [expandedDescriptionKeys, setExpandedDescriptionKeys] = useState<string[]>([]);
const [overflowingDescriptionKeys, setOverflowingDescriptionKeys] = useState<string[]>([]);
const descriptionRefs = useRef<Record<string, HTMLParagraphElement | null>>({});
// Multi-step install gauntlet, shown only for non-official (third-party) plugins.
const [gateOpen, setGateOpen] = useState(false);
const [gateEntry, setGateEntry] = useState<PluginStoreEntry | null>(null);
const [gateIsUpdate, setGateIsUpdate] = useState(false);
const connected = connectionStatus === 'connected';
@@ -140,6 +161,8 @@ export function PluginStorePage() {
plugin.description,
plugin.author,
plugin.repository,
plugin.sourceName,
plugin.sourceUrl,
plugin.license,
...plugin.tags,
]
@@ -161,19 +184,118 @@ export function PluginStorePage() {
{ key: 'updates', label: t('plugin_store.filter_updates'), count: stats.updates },
];
const restartNames = restartRequiredIDs.map((id) => {
const entry = data?.plugins.find((plugin) => plugin.id === id);
return entry ? getStoreEntryTitle(entry) : id;
const restartNames = restartRequiredKeys.map((key) => {
const entry = data?.plugins.find((plugin) => getStoreEntryKey(plugin) === key);
return entry ? getStoreEntryTitle(entry) : key;
});
const hasActiveFilters = Boolean(filter.trim()) || statusFilter !== 'all';
const expandedDescriptionKeySet = useMemo(
() => new Set(expandedDescriptionKeys),
[expandedDescriptionKeys]
);
const overflowingDescriptionKeySet = useMemo(
() => new Set(overflowingDescriptionKeys),
[overflowingDescriptionKeys]
);
const registerDescriptionRef = useCallback((id: string, node: HTMLParagraphElement | null) => {
if (node) {
descriptionRefs.current[id] = node;
} else {
delete descriptionRefs.current[id];
}
}, []);
const measureDescriptionOverflow = useCallback(() => {
const nextIDs = Object.entries(descriptionRefs.current)
.filter(([, node]) => {
if (!node) return false;
const computed = window.getComputedStyle(node);
const lineHeight = Number.parseFloat(computed.lineHeight);
if (!Number.isFinite(lineHeight) || lineHeight <= 0) {
return node.scrollHeight > node.clientHeight + 1;
}
return node.scrollHeight > lineHeight * DESCRIPTION_COLLAPSED_LINES + 1;
})
.map(([id]) => id);
setOverflowingDescriptionKeys((current) => {
if (current.length === nextIDs.length && current.every((id) => nextIDs.includes(id))) {
return current;
}
return nextIDs;
});
}, []);
useEffect(() => {
const frame = window.requestAnimationFrame(measureDescriptionOverflow);
return () => {
window.cancelAnimationFrame(frame);
};
}, [measureDescriptionOverflow, visiblePlugins]);
useEffect(() => {
const handleResize = () => {
window.requestAnimationFrame(measureDescriptionOverflow);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [measureDescriptionOverflow]);
const toggleDescription = useCallback((id: string) => {
setExpandedDescriptionKeys((current) =>
current.includes(id) ? current.filter((currentID) => currentID !== id) : [...current, id]
);
}, []);
const runInstall = useCallback(
async (entry: PluginStoreEntry, isUpdate: boolean) => {
const entryKey = getStoreEntryKey(entry);
const failedKey = isUpdate ? 'plugin_store.update_failed' : 'plugin_store.install_failed';
setInstallingKey(entryKey);
try {
const result = await pluginStoreApi.install(entry.id, entry.sourceId || undefined);
showNotification(
isUpdate ? t('plugin_store.update_success') : t('plugin_store.install_success'),
'success'
);
if (result.restartRequired) {
setRestartRequiredKeys((current) =>
current.includes(entryKey) ? current : [...current, entryKey]
);
showNotification(t('plugin_store.restart_required_notice'), 'warning');
}
clearConfigCache();
await loadStore();
} catch (err: unknown) {
showNotification(`${t(failedKey)}: ${getErrorMessage(err, t(failedKey))}`, 'error');
throw err;
} finally {
setInstallingKey('');
}
},
[clearConfigCache, loadStore, showNotification, t]
);
const handleInstall = (entry: PluginStoreEntry) => {
const isUpdate = entry.installed && entry.updateAvailable;
// Third-party plugins must clear the multi-step confirmation gauntlet first.
if (!isOfficialPlugin(entry)) {
setGateEntry(entry);
setGateIsUpdate(isUpdate);
setGateOpen(true);
return;
}
// Official router-for-me plugins keep the lightweight single-step confirm.
const title = getStoreEntryTitle(entry);
const target = entry.version ? `${title} v${entry.version}` : title;
const failedKey = isUpdate ? 'plugin_store.update_failed' : 'plugin_store.install_failed';
showConfirmation({
title: isUpdate
? t('plugin_store.update_confirm_title')
@@ -183,37 +305,25 @@ export function PluginStorePage() {
: t('plugin_store.install_confirm_message', { target }),
confirmText: isUpdate ? t('plugin_store.update') : t('plugin_store.install'),
variant: 'primary',
onConfirm: async () => {
setInstallingID(entry.id);
try {
const result = await pluginStoreApi.install(entry.id);
showNotification(
isUpdate ? t('plugin_store.update_success') : t('plugin_store.install_success'),
'success'
);
if (result.restartRequired) {
setRestartRequiredIDs((current) =>
current.includes(entry.id) ? current : [...current, entry.id]
);
showNotification(t('plugin_store.restart_required_notice'), 'warning');
}
clearConfigCache();
await loadStore();
} catch (err: unknown) {
showNotification(`${t(failedKey)}: ${getErrorMessage(err, t(failedKey))}`, 'error');
throw err;
} finally {
setInstallingID('');
}
},
onConfirm: () => runInstall(entry, isUpdate),
});
};
const handleGateConfirm = useCallback(async () => {
if (!gateEntry) return;
await runInstall(gateEntry, gateIsUpdate);
setGateOpen(false);
}, [gateEntry, gateIsUpdate, runInstall]);
const handleGateClose = useCallback(() => setGateOpen(false), []);
const renderCard = (entry: PluginStoreEntry) => {
const entryKey = getStoreEntryKey(entry);
const logo = resolvePluginAssetURL(entry.logo, apiBase);
const repositoryURL = buildRepositoryURL(entry.repository);
const homepageURL = /^https?:\/\//i.test(entry.homepage) ? entry.homepage : '';
const isUpdate = entry.installed && entry.updateAvailable;
const isOfficial = isOfficialPlugin(entry);
const versionText =
isUpdate && entry.installedVersion && entry.version
? t('plugin_store.version_arrow', { from: entry.installedVersion, to: entry.version })
@@ -222,10 +332,19 @@ export function PluginStorePage() {
: entry.version
? `v${entry.version}`
: '';
const metaItems = [versionText, entry.author, entry.license].filter(Boolean);
const sourceName = isDefaultPluginStoreSource(entry)
? t('plugin_store.cli_proxy_api_source')
: entry.sourceName;
const sourceText = sourceName ? t('plugin_store.source_name', { source: sourceName }) : '';
const metaItems = [versionText, sourceText, entry.author, entry.license].filter(Boolean);
const isInstalling = installingKey === entryKey;
const hasPendingInstall = Boolean(installingKey);
const isDescriptionExpanded = expandedDescriptionKeySet.has(entryKey);
const isDescriptionOverflowing = overflowingDescriptionKeySet.has(entryKey);
const descriptionID = getDescriptionDOMID(entryKey);
return (
<article key={entry.id} className={styles.card}>
<article key={entryKey} className={styles.card}>
<div className={styles.cardHeader}>
<div className={styles.logoBox} aria-hidden="true">
<StoreCardLogo src={logo} />
@@ -235,6 +354,12 @@ export function PluginStorePage() {
<span className={styles.cardId}>{entry.id}</span>
</div>
<div className={styles.cardBadges}>
{!isOfficial ? (
<span className={styles.badgeUntrusted}>
<IconAlertTriangle size={11} />
{t('plugin_store.badge_untrusted')}
</span>
) : null}
{isUpdate ? (
<span className={styles.badgeWarning}>{t('plugin_store.badge_update')}</span>
) : entry.installed ? (
@@ -246,12 +371,39 @@ export function PluginStorePage() {
</div>
</div>
{entry.description ? <p className={styles.cardDesc}>{entry.description}</p> : null}
{entry.description ? (
<div className={styles.cardDescBlock}>
<p
id={descriptionID}
ref={(node) => registerDescriptionRef(entryKey, node)}
className={`${styles.cardDesc} ${
isDescriptionExpanded ? styles.cardDescExpanded : ''
}`}
>
{entry.description}
</p>
{isDescriptionOverflowing ? (
<button
type="button"
className={styles.cardDescToggle}
onClick={() => toggleDescription(entryKey)}
aria-expanded={isDescriptionExpanded}
aria-controls={descriptionID}
>
{t(
isDescriptionExpanded
? 'plugin_store.description_show_less'
: 'plugin_store.description_show_more'
)}
</button>
) : null}
</div>
) : null}
{metaItems.length > 0 ? (
<div className={styles.cardMeta}>
{metaItems.map((item, index) => (
<span key={`${entry.id}-meta-${index}`} className={styles.metaItem}>
<span key={`${entryKey}-meta-${index}`} className={styles.metaItem}>
{index > 0 ? <span className={styles.metaDot} aria-hidden="true" /> : null}
{index === 0 && versionText ? <strong>{item}</strong> : item}
</span>
@@ -262,7 +414,7 @@ export function PluginStorePage() {
{entry.tags.length > 0 ? (
<div className={styles.tagRow}>
{entry.tags.map((tag) => (
<span key={`${entry.id}-tag-${tag}`} className={styles.tag}>
<span key={`${entryKey}-tag-${tag}`} className={styles.tag}>
{tag}
</span>
))}
@@ -275,7 +427,8 @@ export function PluginStorePage() {
<Button
size="sm"
onClick={() => handleInstall(entry)}
disabled={!connected || Boolean(installingID)}
disabled={!connected || (hasPendingInstall && !isInstalling)}
loading={isInstalling}
>
<IconDownload size={14} />
{t('plugin_store.install')}
@@ -286,7 +439,8 @@ export function PluginStorePage() {
<Button
size="sm"
onClick={() => handleInstall(entry)}
disabled={!connected || Boolean(installingID)}
disabled={!connected || (hasPendingInstall && !isInstalling)}
loading={isInstalling}
>
<IconRefreshCw size={14} />
{t('plugin_store.update')}
@@ -338,6 +492,15 @@ export function PluginStorePage() {
<p className={styles.description}>{t('plugin_store.description')}</p>
</div>
{/* ── Security Banner ── */}
<div className={styles.securityBanner} role="note">
<IconShield size={20} />
<div className={styles.securityBannerText}>
<strong>{t('plugin_store.security_banner_title')}</strong>
<p>{t('plugin_store.security_banner_text')}</p>
</div>
</div>
{/* ── Alerts ── */}
{error ? (
<div className={styles.errorBox}>
@@ -491,6 +654,15 @@ export function PluginStorePage() {
) : (
<div className={styles.cardGrid}>{visiblePlugins.map((entry) => renderCard(entry))}</div>
)}
<PluginInstallGateModal
open={gateOpen}
entry={gateEntry}
isUpdate={gateIsUpdate}
installing={gateEntry ? installingKey === getStoreEntryKey(gateEntry) : false}
onClose={handleGateClose}
onConfirm={handleGateConfirm}
/>
</div>
);
}
+70 -5
View File
@@ -64,6 +64,12 @@ function PluginCardLogo({ src }: { src: string }) {
const hasStatus = (error: unknown, status: number) =>
isRecord(error) && error.status === status;
const hasRestartRequired = (value: unknown) =>
isRecord(value) && value.restart_required === true;
const hasRestartRequiredError = (error: unknown) =>
isRecord(error) && (hasRestartRequired(error.details) || hasRestartRequired(error.data));
const normalizeFieldType = (field: PluginConfigField) => field.type.trim().toLowerCase();
const stringifyArrayItem = (value: unknown): string => {
@@ -235,6 +241,7 @@ export function PluginsPage() {
const apiBase = useAuthStore((state) => state.apiBase);
const clearConfigCache = useConfigStore((state) => state.clearCache);
const showNotification = useNotificationStore((state) => state.showNotification);
const showConfirmation = useNotificationStore((state) => state.showConfirmation);
const [data, setData] = useState<PluginListResponse | null>(null);
const [filter, setFilter] = useState('');
@@ -244,6 +251,7 @@ export function PluginsPage() {
const [editingConfig, setEditingConfig] = useState<PluginConfigObject>({});
const [draft, setDraft] = useState<PluginConfigDraft | null>(null);
const [mutatingID, setMutatingID] = useState('');
const [deletingID, setDeletingID] = useState('');
const [openingConfigID, setOpeningConfigID] = useState('');
const configRequestSeq = useRef(0);
@@ -326,7 +334,7 @@ export function PluginsPage() {
);
const openConfigSheet = async (plugin: PluginListEntry) => {
if (openingConfigID || mutatingID) return;
if (openingConfigID || mutatingID || deletingID) return;
const requestSeq = configRequestSeq.current + 1;
configRequestSeq.current = requestSeq;
@@ -364,7 +372,7 @@ export function PluginsPage() {
};
const closeConfigSheet = () => {
if (mutatingID || openingConfigID) return;
if (mutatingID || openingConfigID || deletingID) return;
setEditingPlugin(null);
setEditingConfig({});
setDraft(null);
@@ -375,6 +383,7 @@ export function PluginsPage() {
};
const handleTogglePlugin = async (plugin: PluginListEntry, enabled: boolean) => {
if (deletingID) return;
setMutatingID(plugin.id);
try {
await pluginsApi.updateEnabled(plugin.id, enabled);
@@ -395,8 +404,51 @@ export function PluginsPage() {
}
};
const handleDeletePlugin = (plugin: PluginListEntry) => {
if (!connected || mutatingID || openingConfigID || deletingID) return;
const name = getPluginTitle(plugin);
showConfirmation({
title: t('plugin_management.delete_confirm_title'),
message: t('plugin_management.delete_confirm_message', { name, id: plugin.id }),
variant: 'danger',
confirmText: t('plugin_management.delete_plugin'),
onConfirm: async () => {
setDeletingID(plugin.id);
setMutatingID(plugin.id);
try {
const result = await pluginsApi.deletePlugin(plugin.id);
clearConfigCache();
if (editingPlugin?.id === plugin.id) {
setEditingPlugin(null);
setEditingConfig({});
setDraft(null);
}
await loadPluginsAfterMutation(false);
notifyPluginResourcesChanged();
showNotification(t('plugin_management.delete_success'), 'success');
if (result.restartRequired) {
showNotification(t('plugin_management.delete_restart_required'), 'warning');
}
} catch (err: unknown) {
const restartRequired = hasRestartRequiredError(err);
const fallback = restartRequired
? t('plugin_management.delete_restart_required')
: t('plugin_management.delete_failed');
showNotification(
`${t('plugin_management.delete_failed')}: ${getErrorMessage(err, fallback)}`,
restartRequired ? 'warning' : 'error'
);
} finally {
setDeletingID('');
setMutatingID('');
}
},
});
};
const handleSaveConfig = async () => {
if (!editingPlugin || !draft || openingConfigID || mutatingID) return;
if (!editingPlugin || !draft || openingConfigID || mutatingID || deletingID) return;
const { nextConfig, errors } = buildConfigPayload(
draft,
editingPlugin.configFields,
@@ -707,7 +759,7 @@ export function PluginsPage() {
variant="secondary"
size="sm"
onClick={loadPlugins}
disabled={!connected || loading || Boolean(mutatingID)}
disabled={!connected || loading || Boolean(mutatingID || deletingID)}
loading={loading}
>
<IconRefreshCw size={16} />
@@ -749,7 +801,8 @@ export function PluginsPage() {
const logo = resolvePluginAsset(plugin.logo || plugin.metadata?.logo || '');
const github = plugin.metadata?.githubRepository.trim();
const openingConfig = openingConfigID === plugin.id;
const actionBusy = Boolean(mutatingID || openingConfigID);
const deletingPlugin = deletingID === plugin.id;
const actionBusy = Boolean(mutatingID || openingConfigID || deletingID);
const version = plugin.metadata?.version;
const author = plugin.metadata?.author;
@@ -836,6 +889,18 @@ export function PluginsPage() {
<IconSettings size={14} />
{t('plugin_management.edit_config')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => handleDeletePlugin(plugin)}
disabled={!connected || actionBusy}
loading={deletingPlugin}
title={t('plugin_management.delete_plugin')}
aria-label={t('plugin_management.delete_plugin')}
>
<IconTrash2 size={14} />
{t('plugin_management.delete_plugin')}
</Button>
{github ? (
<a
className={styles.iconLink}
@@ -0,0 +1,203 @@
// Multi-step install confirmation ("gauntlet") for third-party plugins.
.gateModal {
text-align: left;
}
// ── Plugin identity card (shown on every step) ──
.identity {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: $spacing-sm 0 $spacing-md;
text-align: center;
border-bottom: 1px solid var(--border-color);
}
.logoBox {
display: inline-flex;
width: 52px;
height: 52px;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
background: color-mix(in srgb, var(--bg-tertiary) 60%, transparent);
color: var(--text-secondary);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.name {
margin: 0;
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
overflow-wrap: anywhere;
}
.slug {
margin: 0;
font-family: $font-mono;
font-size: 13px;
color: var(--text-secondary);
overflow-wrap: anywhere;
}
.repoLink {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 4px;
margin: 0;
font-family: $font-mono;
font-size: 13px;
color: var(--text-secondary);
text-decoration: none;
span {
min-width: 0;
overflow-wrap: anywhere;
}
svg {
flex-shrink: 0;
color: var(--text-tertiary);
}
&:hover {
color: var(--accent-color);
text-decoration: underline;
svg {
color: currentColor;
}
}
&:focus-visible {
outline: 2px solid var(--focus-ring-color, var(--accent-color));
outline-offset: 3px;
border-radius: $radius-sm;
}
}
.source {
margin: 0;
font-size: 12px;
color: var(--text-tertiary);
overflow-wrap: anywhere;
}
// ── Step 2: caution banner + effects + untrusted alert ──
.warningBanner {
display: flex;
align-items: center;
gap: $spacing-sm;
margin-top: $spacing-lg;
padding: 12px 14px;
border-radius: $radius-md;
border: 1px solid color-mix(in srgb, var(--quota-medium-color, #e0aa14) 45%, var(--border-color));
background: color-mix(in srgb, var(--quota-medium-color, #e0aa14) 16%, var(--bg-secondary));
color: var(--text-primary);
font-weight: 600;
font-size: 14px;
line-height: 1.4;
svg {
flex-shrink: 0;
color: var(--quota-medium-color, #e0aa14);
}
}
.effects {
margin: $spacing-md 0 0;
padding-left: $spacing-md;
border-left: 2px solid var(--border-color);
list-style: none;
display: flex;
flex-direction: column;
gap: 12px;
li {
position: relative;
padding-left: 18px;
font-size: 14px;
line-height: 1.5;
color: var(--text-secondary);
&::before {
content: '';
position: absolute;
left: 0;
top: 7px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text-tertiary);
}
}
}
.untrustedAlert {
margin-top: $spacing-lg;
padding: 12px 14px;
border-radius: $radius-md;
border: 1px solid rgba($warning-color, 0.35);
background: rgba($warning-color, 0.1);
}
.untrustedText {
margin: 0;
font-size: 13.5px;
line-height: 1.5;
font-weight: 600;
color: var(--danger-color);
}
.originGrid {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
margin: 10px 0 0;
dt {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
dd {
margin: 0;
font-family: $font-mono;
font-size: 12px;
color: var(--text-primary);
overflow-wrap: anywhere;
}
}
// ── Step 3: type-to-confirm ──
.confirmBlock {
margin-top: $spacing-lg;
}
.confirmPrompt {
display: block;
margin-bottom: $spacing-sm;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.5;
overflow-wrap: anywhere;
}
.confirmHint {
margin: $spacing-sm 0 0;
font-size: 12px;
color: var(--text-tertiary);
}
@@ -0,0 +1,202 @@
import { useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { IconAlertTriangle, IconExternalLink, IconPlug } from '@/components/ui/icons';
import { useAuthStore } from '@/stores';
import type { PluginStoreEntry } from '@/types';
import {
buildRepositoryURL,
getPluginConfirmToken,
getPluginRepositorySlug,
isDefaultPluginStoreSource,
resolvePluginAssetURL,
} from '../pluginResources';
import styles from './PluginInstallGateModal.module.scss';
interface PluginInstallGateModalProps {
open: boolean;
entry: PluginStoreEntry | null;
isUpdate: boolean;
installing: boolean;
onClose: () => void;
onConfirm: () => void | Promise<void>;
}
function GateLogo({ src }: { src: string }) {
const [failed, setFailed] = useState(false);
return src && !failed ? (
<img src={src} alt="" onError={() => setFailed(true)} />
) : (
<IconPlug size={26} />
);
}
export function PluginInstallGateModal({
open,
entry,
isUpdate,
installing,
onClose,
onConfirm,
}: PluginInstallGateModalProps) {
const { t } = useTranslation();
const apiBase = useAuthStore((state) => state.apiBase);
const [step, setStep] = useState(1);
const [typed, setTyped] = useState('');
const [wasOpen, setWasOpen] = useState(false);
// Reset the gauntlet to step 1 on each fresh open. Adjusting state during render
// (React's "you might not need an effect" guidance) avoids a setState-in-effect.
if (open !== wasOpen) {
setWasOpen(open);
if (open) {
setStep(1);
setTyped('');
}
}
if (!entry) return null;
const title = entry.name || entry.id;
const repoSlug = getPluginRepositorySlug(entry.repository);
const repositoryURL = buildRepositoryURL(entry.repository);
const repoLabel = repoSlug || entry.id;
const token = getPluginConfirmToken(entry);
const logo = resolvePluginAssetURL(entry.logo, apiBase);
const rawSourceText = entry.sourceName || entry.sourceUrl;
const sourceText = isDefaultPluginStoreSource(entry)
? t('plugin_store.cli_proxy_api_source')
: rawSourceText;
const tokenMatches = typed.trim() === token;
const handleClose = () => {
if (installing) return;
onClose();
};
const handleFinalConfirm = async () => {
try {
await onConfirm();
} catch {
// The caller surfaces the error via a notification; stay on this step.
}
};
const identity = (
<div className={styles.identity}>
<div className={styles.logoBox} aria-hidden="true">
<GateLogo src={logo} />
</div>
<h3 className={styles.name}>{title}</h3>
{repositoryURL ? (
<a
className={styles.repoLink}
href={repositoryURL}
target="_blank"
rel="noreferrer"
title={t('plugin_store.open_repository')}
aria-label={t('plugin_store.open_repository')}
>
<span>{repoLabel}</span>
<IconExternalLink size={12} />
</a>
) : (
<p className={styles.slug}>{repoLabel}</p>
)}
{sourceText ? (
<p className={styles.source}>{t('plugin_store.source_name', { source: sourceText })}</p>
) : null}
</div>
);
let body: ReactNode;
let footer: ReactNode;
if (step === 1) {
body = identity;
footer = (
<Button variant="secondary" fullWidth onClick={() => setStep(2)}>
{t('plugin_store.gate_step1_action')}
</Button>
);
} else if (step === 2) {
body = (
<>
{identity}
<div className={styles.warningBanner}>
<IconAlertTriangle size={18} />
<span>{t('plugin_store.gate_warning')}</span>
</div>
<ul className={styles.effects}>
<li>{t('plugin_store.gate_effect_runs_code')}</li>
<li>{t('plugin_store.gate_effect_no_review')}</li>
<li>{t('plugin_store.gate_effect_restart')}</li>
</ul>
<div className={styles.untrustedAlert}>
<p className={styles.untrustedText}>{t('plugin_store.gate_untrusted_alert')}</p>
<dl className={styles.originGrid}>
<dt>{t('plugin_store.gate_repository_label')}</dt>
<dd>{repoSlug || entry.repository || '—'}</dd>
<dt>{t('plugin_store.gate_source_label')}</dt>
<dd>{sourceText || '—'}</dd>
</dl>
</div>
</>
);
footer = (
<Button variant="secondary" fullWidth onClick={() => setStep(3)}>
{t('plugin_store.gate_step2_action')}
</Button>
);
} else {
body = (
<>
{identity}
<div className={styles.confirmBlock}>
<label className={styles.confirmPrompt} htmlFor="plugin-gate-confirm">
{t('plugin_store.gate_step3_prompt', { token })}
</label>
<Input
id="plugin-gate-confirm"
value={typed}
onChange={(event) => setTyped(event.target.value)}
autoComplete="off"
spellCheck={false}
disabled={installing}
aria-label={t('plugin_store.gate_step3_prompt', { token })}
/>
<p className={styles.confirmHint}>{t('plugin_store.gate_step3_hint')}</p>
</div>
</>
);
footer = (
<Button
variant="danger"
fullWidth
onClick={handleFinalConfirm}
disabled={!tokenMatches || installing}
loading={installing}
>
{t('plugin_store.gate_step3_action')}
</Button>
);
}
return (
<Modal
open={open}
onClose={handleClose}
title={t(isUpdate ? 'plugin_store.gate_title_update' : 'plugin_store.gate_title', {
name: title,
})}
closeDisabled={installing}
footer={footer}
width={520}
className={styles.gateModal}
>
{body}
</Modal>
);
}
+45 -1
View File
@@ -1,4 +1,4 @@
import type { PluginListEntry, PluginMenu } from '@/types';
import type { PluginListEntry, PluginMenu, PluginStoreEntry } from '@/types';
import { normalizeApiBase } from '@/utils/connection';
export const PLUGIN_RESOURCES_REFRESH_EVENT = 'plugin-resources-refresh';
@@ -41,6 +41,50 @@ export const buildRepositoryURL = (repository: string) => {
return `https://github.com/${trimmed.replace(/^\/+/, '')}`;
};
// The exact, fully-qualified prefix every first-party repository lives under.
// Matching the whole URL (not just the extracted owner) prevents look-alike
// hosts like "https://github.com.evil.com/router-for-me/..." from being
// mistaken for the official org.
export const OFFICIAL_PLUGIN_REPO_PREFIX = 'https://github.com/router-for-me/';
export const DEFAULT_PLUGIN_STORE_SOURCE_ID = 'official';
const DEFAULT_PLUGIN_STORE_SOURCE_NAME = 'official';
// Normalize an "owner/repo" slug or repository URL to a bare "owner/repo".
export const getPluginRepositorySlug = (repository: string): string => {
const trimmed = repository.trim();
if (!trimmed) return '';
const withoutHost = /^https?:\/\/[^/]+\/(.+)$/i.exec(trimmed)?.[1] ?? trimmed;
const [owner = '', repo = ''] = withoutHost.replace(/^\/+/, '').split('/');
if (!owner) return '';
return repo ? `${owner}/${repo.replace(/\.git$/i, '')}` : owner;
};
// A repository is official only when its canonical github.com URL sits exactly
// under the router-for-me org prefix. Slugs ("router-for-me/repo") and full URLs
// are both normalized first; anything else (other hosts, look-alike domains,
// other owners) is untrusted.
export const isOfficialRepository = (repository: string): boolean =>
buildRepositoryURL(repository)
.toLowerCase()
.startsWith(OFFICIAL_PLUGIN_REPO_PREFIX);
// A plugin is official iff its code repository sits under the router-for-me org.
// Every first-party plugin lives there, so the repository URL is the single
// source of truth — see isOfficialRepository for the exact match.
export const isOfficialPlugin = (entry: PluginStoreEntry): boolean =>
isOfficialRepository(entry.repository);
export const isDefaultPluginStoreSource = (
entry: Pick<PluginStoreEntry, 'sourceId' | 'sourceName'>
): boolean =>
entry.sourceId.trim().toLowerCase() === DEFAULT_PLUGIN_STORE_SOURCE_ID ||
entry.sourceName.trim().toLowerCase() === DEFAULT_PLUGIN_STORE_SOURCE_NAME;
// The string a user must retype to confirm a risky install: the repo slug when
// available (most faithful to the source), otherwise the plugin id.
export const getPluginConfirmToken = (entry: PluginStoreEntry): string =>
getPluginRepositorySlug(entry.repository) || entry.id;
export const collectPluginResourceEntries = (
plugins: PluginListEntry[]
): PluginResourceEntry[] =>
@@ -79,7 +79,6 @@ const getResourceRecentSuccess = (
usageByProvider
).success;
}
if (resource.brand === 'ampcode') return 0;
return getProviderRecentWindowStats(
usageByProvider,
resource.brand,
@@ -285,15 +284,8 @@ export function ProvidersWorkbenchPage() {
const openCreate = useCallback(() => {
const brand = activeBrand;
if (brand === 'ampcode') {
// ampcode 走单例编辑
const r =
groups.find((g) => g.id === 'ampcode')?.resources[0] ?? null;
setSheetState({ open: true, brand: 'ampcode', mode: 'edit', resource: r });
} else {
setSheetState({ open: true, brand, mode: 'create', resource: null });
}
}, [activeBrand, groups]);
setSheetState({ open: true, brand, mode: 'create', resource: null });
}, [activeBrand]);
const openView = useCallback((resource: ProviderResource) => {
setSheetState({
@@ -319,20 +311,13 @@ export function ProvidersWorkbenchPage() {
const handleDelete = useCallback(
(resource: ProviderResource) => {
const isAmpcode = resource.brand === 'ampcode';
const name =
resource.name ?? resource.apiKeyPreview ?? resource.identifier ?? '';
showConfirmation({
title: isAmpcode
? t('providersPage.delete.ampcodeTitle')
: t('providersPage.delete.title'),
message: isAmpcode
? t('providersPage.delete.ampcodeConfirm')
: t('providersPage.delete.confirm', { name }),
title: t('providersPage.delete.title'),
message: t('providersPage.delete.confirm', { name }),
variant: 'danger',
confirmText: isAmpcode
? t('providersPage.actions.clear')
: t('providersPage.actions.delete'),
confirmText: t('providersPage.actions.delete'),
onConfirm: async () => {
try {
await workbench.deleteProvider(resource);
@@ -408,8 +393,6 @@ export function ProvidersWorkbenchPage() {
);
}
const ampcodeBrandActive = activeBrand === 'ampcode';
return (
<div className={styles.page}>
<ProviderHeaderCard
@@ -418,12 +401,8 @@ export function ProvidersWorkbenchPage() {
providerFamilies={providerFamilies}
updatedAtLabel={updatedAtLabel}
isFetching={workbench.isFetching}
isNewDisabled={disableMutations && !ampcodeBrandActive}
newLabel={
ampcodeBrandActive
? t('providersPage.actions.edit')
: t('providersPage.actions.new')
}
isNewDisabled={disableMutations}
newLabel={t('providersPage.actions.new')}
onRefresh={() => void handleRefresh()}
onNew={openCreate}
/>
+1 -51
View File
@@ -1,9 +1,4 @@
import type {
AmpcodeConfig,
GeminiKeyConfig,
OpenAIProviderConfig,
ProviderKeyConfig,
} from '@/types';
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
import {
hasDisableAllModelsRule,
stripDisableAllModelsRule,
@@ -27,17 +22,6 @@ const collectModelNames = (models?: Array<{ name?: string }>): string[] => {
return Array.from(seen);
};
const collectAmpcodeModelNames = (mappings: AmpcodeConfig['modelMappings']): string[] => {
const seen = new Set<string>();
(mappings ?? []).forEach((mapping) => {
const from = (mapping?.from ?? '').trim();
const to = (mapping?.to ?? '').trim();
if (from) seen.add(from);
if (to) seen.add(to);
});
return Array.from(seen);
};
const normalizePriority = (priority?: number): number =>
typeof priority === 'number' && Number.isFinite(priority) ? priority : 0;
@@ -146,37 +130,3 @@ export function openaiToResource(
raw: config,
};
}
export function ampcodeToResource(config?: AmpcodeConfig | null): ProviderResource {
const safe: AmpcodeConfig = config ?? {};
const upstreamApiKey = safe.upstreamApiKey ?? '';
const upstreamUrl = (safe.upstreamUrl ?? '').trim();
const hasUpstream = upstreamUrl.length > 0;
const upstreamKeyMappingsCount = safe.upstreamApiKeys?.length ?? 0;
return {
id: 'ampcode:singleton',
brand: 'ampcode',
originalIndex: 0,
name: null,
identifier: 'Amp CLI',
apiKeyPreview: upstreamApiKey ? maskApiKey(upstreamApiKey) : null,
apiKey: upstreamApiKey || null,
authIndex: null,
baseUrl: upstreamUrl || null,
proxyUrl: null,
prefix: null,
modelCount: safe.modelMappings?.length ?? 0,
models: collectAmpcodeModelNames(safe.modelMappings),
priority: 0,
headerCount: 0,
excludedModelCount: 0,
apiKeyEntryCount: upstreamKeyMappingsCount,
disabled: !hasUpstream,
flags: {
forceModelMappings: safe.forceModelMappings === true,
isPlaceholder: !hasUpstream && upstreamKeyMappingsCount === 0,
},
selector: { brand: 'ampcode' },
raw: safe,
};
}
-2
View File
@@ -1,4 +1,3 @@
import ampcodeLogo from '@/assets/icons/amp.svg';
import claudeLogo from '@/assets/icons/claude.svg';
import codexLogo from '@/assets/icons/codex.svg';
import geminiLogo from '@/assets/icons/gemini.svg';
@@ -17,5 +16,4 @@ export const PROVIDER_LOGOS: Record<ProviderBrand, ProviderBrandLogo> = {
codex: { src: codexLogo },
vertex: { src: vertexLogo },
openaiCompatibility: { src: openaiLogo, invertOnDark: true },
ampcode: { src: ampcodeLogo },
};
@@ -25,7 +25,7 @@ export function ProviderCategoryList({
const realResources = group.resources.filter(
(r) => !r.flags.isPlaceholder
);
const total = realResources.length || (group.id === 'ampcode' ? 1 : 0);
const total = realResources.length;
const activeCount = realResources.filter((r) => !r.disabled).length;
const logo = PROVIDER_LOGOS[group.id];
const itemClass = `${styles.item} ${active ? styles.active : ''}`;
@@ -52,25 +52,19 @@ export function ProviderCategoryList({
{t(`providersPage.providerNames.${group.id}`)}
</span>
<span className={styles.itemSubtitle}>
{group.id === 'ampcode'
? t(
group.resources[0]?.disabled
? 'providersPage.categories.ampcodeInactive'
: 'providersPage.categories.ampcodeActive'
)
: t('providersPage.categories.activeCount', {
active: activeCount,
total,
})}
{t('providersPage.categories.activeCount', {
active: activeCount,
total,
})}
</span>
</span>
</span>
<span
className={`${styles.badge} ${
group.id !== 'ampcode' && total === 0 ? styles.badgeAmber : ''
total === 0 ? styles.badgeAmber : ''
}`}
>
{group.id === 'ampcode' ? (group.resources[0]?.disabled ? '—' : '1') : total}
{total}
</span>
</button>
);
@@ -73,20 +73,18 @@ export function ProviderResourcePanel({
</h2>
</div>
</div>
{group.id !== 'ampcode' ? (
<div className={styles.searchWrap}>
<span className={styles.searchIcon} aria-hidden="true">
<IconSearch size={16} />
</span>
<input
type="search"
className={styles.searchInput}
value={filter}
onChange={(event) => onFilterChange(event.target.value)}
placeholder={t('providersPage.table.filterPlaceholder')}
/>
</div>
) : null}
<div className={styles.searchWrap}>
<span className={styles.searchIcon} aria-hidden="true">
<IconSearch size={16} />
</span>
<input
type="search"
className={styles.searchInput}
value={filter}
onChange={(event) => onFilterChange(event.target.value)}
placeholder={t('providersPage.table.filterPlaceholder')}
/>
</div>
</div>
{toolbarControls ? (
<div className={styles.headerToolbarRow}>
@@ -104,7 +102,7 @@ export function ProviderResourcePanel({
) : null}
</div>
{realResources.length === 0 && group.id !== 'ampcode' ? (
{realResources.length === 0 ? (
<div className={styles.empty}>
<div>{t('providersPage.table.empty')}</div>
<div className={styles.emptyAction}>
@@ -112,11 +112,6 @@ export function ProviderResourceTable({
renderMetric('keys', t('providersPage.table.metrics.keys'), r.apiKeyEntryCount),
renderMetric('headers', t('providersPage.table.metrics.headers'), r.headerCount),
);
} else if (r.brand === 'ampcode') {
items.push(
renderMetric('mappings', t('providersPage.table.metrics.mappings'), r.modelCount),
renderMetric('keys', t('providersPage.table.metrics.keys'), r.apiKeyEntryCount),
);
} else {
items.push(
renderMetric('models', t('providersPage.table.metrics.models'), r.modelCount),
@@ -133,14 +128,6 @@ export function ProviderResourceTable({
};
const renderStatus = (r: ProviderResource) => {
if (r.brand === 'ampcode' && r.flags.isPlaceholder) {
return (
<span className={`${styles.statusBadge} ${styles.statusDisabled}`}>
<IconAlertTriangle size={14} />
{t('providersPage.status.notConfigured')}
</span>
);
}
if (r.disabled) {
return (
<span className={`${styles.statusBadge} ${styles.statusDisabled}`}>
@@ -169,16 +156,6 @@ export function ProviderResourceTable({
</div>
);
}
if (r.brand === 'ampcode') {
return (
<div className={styles.primaryCell}>
<span className={styles.primaryName}>Amp CLI</span>
<span className={styles.primarySub}>
{r.apiKeyPreview ?? t('providersPage.table.noFallbackKey')}
</span>
</div>
);
}
return (
<div className={styles.primaryCell}>
<span className={styles.primaryName}>{r.apiKeyPreview ?? '—'}</span>
@@ -197,9 +174,6 @@ export function ProviderResourceTable({
</span>
);
}
if (r.brand === 'ampcode' && !r.baseUrl) {
return <span className={styles.baseUrl}>{t('providersPage.status.notConfigured')}</span>;
}
return (
<span className={styles.baseUrl}>
{r.baseUrl ?? t('providersPage.status.notSet')}
@@ -225,15 +199,12 @@ export function ProviderResourceTable({
</TableHeader>
<TableBody>
{resources.map((resource) => {
const isAmpcode = resource.brand === 'ampcode';
return (
<TableRow key={resource.id} selected={resource.id === selectedId}>
<TableCell>{renderPrimary(resource)}</TableCell>
<TableCell>{renderBaseUrl(resource)}</TableCell>
<TableCell>
{resource.brand === 'ampcode' ? (
<span className={styles.baseUrl}></span>
) : resource.prefix ? (
{resource.prefix ? (
<span className={styles.chip}>{resource.prefix}</span>
) : (
<span className={styles.baseUrl}>{t('providersPage.status.none')}</span>
@@ -243,7 +214,7 @@ export function ProviderResourceTable({
<TableCell>
<div className={styles.statusCell}>
{renderStatus(resource)}
{usageByProvider && resource.brand !== 'ampcode' ? (
{usageByProvider ? (
<>
{(() => {
const stats = resolveTotalStats(resource, usageByProvider);
@@ -268,7 +239,7 @@ export function ProviderResourceTable({
</TableCell>
<TableCell alignRight>
<div className={styles.actions}>
{!isAmpcode && onToggleDisabled ? (
{onToggleDisabled ? (
<span
className={styles.toggleWrap}
onClick={(e) => e.stopPropagation()}
@@ -312,35 +283,19 @@ export function ProviderResourceTable({
>
<IconPencil size={16} />
</button>
{isAmpcode ? (
<button
type="button"
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
aria-label={t('providersPage.actions.clear')}
title={t('providersPage.actions.clear')}
disabled={disableMutations || resource.flags.isPlaceholder}
onClick={(e) => {
e.stopPropagation();
onDelete(resource);
}}
>
<IconTrash2 size={16} />
</button>
) : (
<button
type="button"
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
aria-label={t('providersPage.actions.delete')}
title={t('providersPage.actions.delete')}
disabled={disableMutations}
onClick={(e) => {
e.stopPropagation();
onDelete(resource);
}}
>
<IconTrash2 size={16} />
</button>
)}
<button
type="button"
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
aria-label={t('providersPage.actions.delete')}
title={t('providersPage.actions.delete')}
disabled={disableMutations}
onClick={(e) => {
e.stopPropagation();
onDelete(resource);
}}
>
<IconTrash2 size={16} />
</button>
</div>
</TableCell>
</TableRow>
+1 -28
View File
@@ -17,7 +17,6 @@ export interface ProviderDescriptor {
supportsWebsockets: boolean;
supportsCloak: boolean;
supportsApiKeyEntries: boolean;
supportsAmpcodeMappings: boolean;
/** Sheet 默认宽度 */
sheetSize: 'md' | 'lg' | 'xl';
}
@@ -40,7 +39,6 @@ export const PROVIDER_DESCRIPTORS: Record<ProviderBrand, ProviderDescriptor> = {
supportsWebsockets: false,
supportsCloak: false,
supportsApiKeyEntries: false,
supportsAmpcodeMappings: false,
sheetSize: 'md',
},
codex: {
@@ -56,11 +54,10 @@ export const PROVIDER_DESCRIPTORS: Record<ProviderBrand, ProviderDescriptor> = {
supportsHeaders: true,
supportsExcludedModels: true,
supportsPriority: true,
supportsTestModel: false,
supportsTestModel: true,
supportsWebsockets: true,
supportsCloak: false,
supportsApiKeyEntries: false,
supportsAmpcodeMappings: false,
sheetSize: 'md',
},
claude: {
@@ -80,7 +77,6 @@ export const PROVIDER_DESCRIPTORS: Record<ProviderBrand, ProviderDescriptor> = {
supportsWebsockets: false,
supportsCloak: true,
supportsApiKeyEntries: false,
supportsAmpcodeMappings: false,
sheetSize: 'md',
},
vertex: {
@@ -100,7 +96,6 @@ export const PROVIDER_DESCRIPTORS: Record<ProviderBrand, ProviderDescriptor> = {
supportsWebsockets: false,
supportsCloak: false,
supportsApiKeyEntries: false,
supportsAmpcodeMappings: false,
sheetSize: 'md',
},
openaiCompatibility: {
@@ -120,27 +115,6 @@ export const PROVIDER_DESCRIPTORS: Record<ProviderBrand, ProviderDescriptor> = {
supportsWebsockets: false,
supportsCloak: false,
supportsApiKeyEntries: true,
supportsAmpcodeMappings: false,
sheetSize: 'lg',
},
ampcode: {
id: 'ampcode',
supportsName: false,
supportsApiKey: false,
supportsDisabled: false,
supportsBaseUrl: true,
baseUrlRequired: false,
supportsProxyUrl: false,
supportsPrefix: false,
supportsModels: false,
supportsHeaders: false,
supportsExcludedModels: false,
supportsPriority: false,
supportsTestModel: false,
supportsWebsockets: false,
supportsCloak: false,
supportsApiKeyEntries: false,
supportsAmpcodeMappings: true,
sheetSize: 'lg',
},
};
@@ -151,5 +125,4 @@ export const PROVIDER_BRAND_ORDER: ProviderBrand[] = [
'claude',
'vertex',
'openaiCompatibility',
'ampcode',
];
@@ -11,7 +11,6 @@ import type {
ProviderResource,
} from '../types';
import type { UseProviderWorkbenchResult } from '../useProviderWorkbench';
import { AmpcodeForm } from './forms/AmpcodeForm';
import { BaseProviderForm } from './forms/BaseProviderForm';
import { ResourceDetailView } from './ResourceDetailView';
import styles from './forms/sharedForm.module.scss';
@@ -70,7 +69,6 @@ export function ProviderSheet({
}, []);
const descriptor = PROVIDER_DESCRIPTORS[state.brand];
const isAmpcode = state.brand === 'ampcode';
const isEditingForm = state.mode === 'create' || state.mode === 'edit';
const formMutating = submitting || mutationDisabled;
const submitDisabled = formMutating || (state.mode === 'edit' && !isDirty);
@@ -141,20 +139,6 @@ export function ProviderSheet({
[isDirty, mutationDisabled, onUpdated, state.resource, workbench]
);
const handleAmpcodeSubmit = useCallback(
async (config: Parameters<UseProviderWorkbenchResult['saveAmpcode']>[0]) => {
if (mutationDisabled || !isDirty) return;
setSubmitting(true);
try {
await workbench.saveAmpcode(config);
onUpdated();
} finally {
setSubmitting(false);
}
},
[isDirty, mutationDisabled, onUpdated, workbench]
);
const renderBody = () => {
if (state.mode === 'detail') {
if (!state.resource) {
@@ -163,22 +147,10 @@ export function ProviderSheet({
return <ResourceDetailView resource={state.resource} usageByProvider={usageByProvider} />;
}
const formKey = `${state.brand}:${state.resource?.id ?? 'new'}:${state.mode}`;
if (isAmpcode) {
return (
<AmpcodeForm
key={formKey}
resource={state.resource}
mutating={formMutating}
formId={formId}
onSubmit={handleAmpcodeSubmit}
onDirtyChange={handleDirtyChange}
/>
);
}
return (
<BaseProviderForm
key={formKey}
brand={state.brand as Exclude<ProviderBrand, 'ampcode'>}
brand={state.brand}
resource={state.resource}
mode={state.mode}
mutating={formMutating}
@@ -1,315 +0,0 @@
import { useEffect, useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Collapsible } from '@/components/ui/Collapsible';
import { IconPlus, IconX } from '@/components/ui/icons';
import type { AmpcodeConfig, AmpcodeModelMapping, AmpcodeUpstreamApiKeyMapping } from '@/types';
import type { ProviderResource } from '../../types';
import styles from './sharedForm.module.scss';
interface AmpcodeFormState {
upstreamUrl: string;
upstreamApiKey: string;
forceModelMappings: boolean;
upstreamMappings: Array<{ upstreamApiKey: string; clientKeysText: string }>;
modelMappings: Array<{ from: string; to: string }>;
}
const emptyUpstream = () => ({ upstreamApiKey: '', clientKeysText: '' });
const emptyModelMapping = () => ({ from: '', to: '' });
function buildState(config?: AmpcodeConfig | null): AmpcodeFormState {
const safe = config ?? {};
const upstreamMappings = (safe.upstreamApiKeys ?? []).length
? (safe.upstreamApiKeys ?? []).map((m) => ({
upstreamApiKey: m.upstreamApiKey ?? '',
clientKeysText: (m.apiKeys ?? []).join('\n'),
}))
: [emptyUpstream()];
const modelMappings = (safe.modelMappings ?? []).length
? (safe.modelMappings ?? []).map((m) => ({ from: m.from ?? '', to: m.to ?? '' }))
: [emptyModelMapping()];
return {
upstreamUrl: safe.upstreamUrl ?? '',
upstreamApiKey: '',
forceModelMappings: safe.forceModelMappings === true,
upstreamMappings,
modelMappings,
};
}
const parseClientKeys = (text: string): string[] =>
text
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean);
interface AmpcodeFormProps {
resource: ProviderResource | null;
mutating: boolean;
formId: string;
onSubmit: (config: AmpcodeConfig) => Promise<void>;
onDirtyChange?: (dirty: boolean) => void;
}
export function AmpcodeForm({
resource,
mutating,
formId,
onSubmit,
onDirtyChange,
}: AmpcodeFormProps) {
const { t } = useTranslation();
const fid = useId();
const initialConfig = (resource?.raw as AmpcodeConfig | undefined) ?? {};
const [form, setForm] = useState<AmpcodeFormState>(() => buildState(initialConfig));
const [initialFormSignature] = useState<string>(() => JSON.stringify(buildState(initialConfig)));
const [error, setError] = useState<string | null>(null);
const isDirty = useMemo(
() => JSON.stringify(form) !== initialFormSignature,
[form, initialFormSignature]
);
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
try {
setError(null);
const upstreamApiKeys: AmpcodeUpstreamApiKeyMapping[] = [];
const seen = new Set<string>();
form.upstreamMappings.forEach((m) => {
const key = m.upstreamApiKey.trim();
if (!key || seen.has(key)) return;
const clientKeys = parseClientKeys(m.clientKeysText);
if (!clientKeys.length) return;
seen.add(key);
upstreamApiKeys.push({ upstreamApiKey: key, apiKeys: clientKeys });
});
const modelMappings: AmpcodeModelMapping[] = [];
const seenFrom = new Set<string>();
form.modelMappings.forEach((m) => {
const from = m.from.trim();
const to = m.to.trim();
if (!from || !to) return;
const id = from.toLowerCase();
if (seenFrom.has(id)) return;
seenFrom.add(id);
modelMappings.push({ from, to });
});
const next: AmpcodeConfig = {
upstreamUrl: form.upstreamUrl.trim() || undefined,
upstreamApiKey:
form.upstreamApiKey.trim() || initialConfig.upstreamApiKey?.trim() || undefined,
upstreamApiKeys: upstreamApiKeys.length ? upstreamApiKeys : undefined,
modelMappings: modelMappings.length ? modelMappings : undefined,
forceModelMappings: form.forceModelMappings,
};
await onSubmit(next);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
return (
<form id={formId} className={styles.form} onSubmit={handleSubmit} noValidate>
<div className={styles.section}>
<div className={styles.field}>
<label className={styles.label} htmlFor={`${fid}-url`}>
{t('providersPage.ampcode.upstreamUrl')}
</label>
<input
id={`${fid}-url`}
className={styles.input}
value={form.upstreamUrl}
onChange={(e) => setForm((s) => ({ ...s, upstreamUrl: e.target.value }))}
placeholder="https://api.ampcode.com"
disabled={mutating}
/>
</div>
<div className={styles.field}>
<label className={styles.label} htmlFor={`${fid}-key`}>
{t('providersPage.ampcode.upstreamApiKey')}
<span className={styles.labelHint}>
{' '}
· {t('providersPage.ampcode.upstreamApiKeyHint')}
</span>
</label>
<input
id={`${fid}-key`}
className={styles.input}
type="password"
value={form.upstreamApiKey}
onChange={(e) => setForm((s) => ({ ...s, upstreamApiKey: e.target.value }))}
autoComplete="new-password"
data-1p-ignore="true"
data-lpignore="true"
data-bwignore="true"
disabled={mutating}
/>
</div>
<label className={styles.checkboxRow}>
<input
type="checkbox"
className={styles.checkboxBox}
checked={form.forceModelMappings}
disabled={mutating}
onChange={(e) => setForm((s) => ({ ...s, forceModelMappings: e.target.checked }))}
/>
<span className={styles.checkboxText}>
<span>{t('providersPage.ampcode.forceModelMappings')}</span>
<small>{t('providersPage.ampcode.forceModelMappingsHint')}</small>
</span>
</label>
</div>
<Collapsible label={t('providersPage.ampcode.keyMappingsSection')} defaultOpen>
<div className={styles.entriesList}>
{form.upstreamMappings.map((m, idx) => (
<div key={idx} className={styles.entryCard}>
<div className={styles.entryCardHeader}>
<span>{t('providersPage.ampcode.mappingRow', { index: idx + 1 })}</span>
<button
type="button"
className={styles.removeBtn}
disabled={mutating || form.upstreamMappings.length <= 1}
onClick={() =>
setForm((s) => ({
...s,
upstreamMappings: s.upstreamMappings.filter((_, i) => i !== idx),
}))
}
>
<IconX size={12} />
</button>
</div>
<div className={styles.field}>
<label className={styles.label}>{t('providersPage.ampcode.upstreamApiKey')}</label>
<input
className={styles.input}
value={m.upstreamApiKey}
onChange={(e) =>
setForm((s) => ({
...s,
upstreamMappings: s.upstreamMappings.map((it, i) =>
i === idx ? { ...it, upstreamApiKey: e.target.value } : it
),
}))
}
disabled={mutating}
/>
</div>
<div className={styles.field}>
<label className={styles.label}>
{t('providersPage.ampcode.clientKeys')}
<span className={styles.labelHint}>
{' '}
· {t('providersPage.ampcode.clientKeysHint')}
</span>
</label>
<textarea
className={styles.textarea}
rows={3}
value={m.clientKeysText}
onChange={(e) =>
setForm((s) => ({
...s,
upstreamMappings: s.upstreamMappings.map((it, i) =>
i === idx ? { ...it, clientKeysText: e.target.value } : it
),
}))
}
disabled={mutating}
/>
</div>
</div>
))}
<button
type="button"
className={styles.addBtn}
disabled={mutating}
onClick={() =>
setForm((s) => ({
...s,
upstreamMappings: [...s.upstreamMappings, emptyUpstream()],
}))
}
>
<IconPlus size={12} />
<span>{t('providersPage.ampcode.addMapping')}</span>
</button>
</div>
</Collapsible>
<Collapsible label={t('providersPage.ampcode.modelMappingsSection')}>
<div className={styles.entriesList}>
{form.modelMappings.map((m, idx) => (
<div key={idx} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 8 }}>
<input
className={styles.input}
placeholder="from"
value={m.from}
onChange={(e) =>
setForm((s) => ({
...s,
modelMappings: s.modelMappings.map((it, i) =>
i === idx ? { ...it, from: e.target.value } : it
),
}))
}
disabled={mutating}
/>
<input
className={styles.input}
placeholder="to"
value={m.to}
onChange={(e) =>
setForm((s) => ({
...s,
modelMappings: s.modelMappings.map((it, i) =>
i === idx ? { ...it, to: e.target.value } : it
),
}))
}
disabled={mutating}
/>
<button
type="button"
className={styles.removeBtn}
disabled={mutating || form.modelMappings.length <= 1}
onClick={() =>
setForm((s) => ({
...s,
modelMappings: s.modelMappings.filter((_, i) => i !== idx),
}))
}
>
<IconX size={12} />
</button>
</div>
))}
<button
type="button"
className={styles.addBtn}
disabled={mutating}
onClick={() =>
setForm((s) => ({
...s,
modelMappings: [...s.modelMappings, emptyModelMapping()],
}))
}
>
<IconPlus size={12} />
<span>{t('providersPage.ampcode.addModelMapping')}</span>
</button>
</div>
</Collapsible>
{error ? <div className={styles.errorBox}>{error}</div> : null}
</form>
);
}
@@ -37,7 +37,7 @@ export interface BaseProviderFormHandle {
}
interface BaseProviderFormProps {
brand: Exclude<ProviderBrand, 'ampcode'>;
brand: ProviderBrand;
resource: ProviderResource | null;
mode: 'create' | 'edit';
mutating: boolean;
@@ -62,7 +62,7 @@ const formatJsonObject = (value?: Record<string, unknown>): string => {
};
function buildInitialForm(
brand: Exclude<ProviderBrand, 'ampcode'>,
brand: ProviderBrand,
resource: ProviderResource | null,
mode: 'create' | 'edit'
): ProviderEntryFormInput {
@@ -86,7 +86,10 @@ function buildInitialForm(
: undefined,
experimentalCchSigning: brand === 'claude' ? false : undefined,
testModel:
brand === 'openaiCompatibility' || brand === 'claude' || brand === 'gemini'
brand === 'openaiCompatibility' ||
brand === 'codex' ||
brand === 'claude' ||
brand === 'gemini'
? ''
: undefined,
apiKeyEntries: brand === 'openaiCompatibility' ? [emptyApiKeyEntry()] : undefined,
@@ -173,7 +176,7 @@ function buildInitialForm(
brand === 'claude'
? (cfg as ProviderKeyConfig).experimentalCchSigning === true
: undefined,
testModel: brand === 'claude' || brand === 'gemini' ? '' : undefined,
testModel: brand === 'codex' || brand === 'claude' || brand === 'gemini' ? '' : undefined,
};
}
@@ -448,7 +451,9 @@ export function BaseProviderForm({
brand === 'openaiCompatibility';
const supportsOpenAIModelOptions = brand === 'openaiCompatibility';
const singleConnectivity =
brand === 'gemini'
brand === 'codex'
? { status: connectivity.codexStatus, run: connectivity.runCodex }
: brand === 'gemini'
? { status: connectivity.geminiStatus, run: connectivity.runGemini }
: brand === 'claude'
? { status: connectivity.claudeStatus, run: connectivity.runClaude }
@@ -630,7 +635,7 @@ export function BaseProviderForm({
<div className={styles.field}>
<label className={styles.label} htmlFor={`${fid}-testModel`}>
{t('providersPage.form.testModel')}
{brand === 'claude' || brand === 'gemini' ? (
{brand === 'codex' || brand === 'claude' || brand === 'gemini' ? (
<span className={styles.labelHint}>
{' '}
· {t('providersPage.form.testModelClaudeHint')}
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
import {
buildCodexResponsesEndpoint,
buildClaudeMessagesEndpoint,
buildGeminiGenerateContentEndpoint,
buildOpenAIChatCompletionsEndpoint,
@@ -73,11 +74,13 @@ export interface ConnectivityErrorMessages {
export interface UseConnectivityTestResult {
openaiStatuses: ConnectivityStatus[];
codexStatus: ConnectivityStatus;
geminiStatus: ConnectivityStatus;
claudeStatus: ConnectivityStatus;
isTestingAny: boolean;
runOpenAIKey: (idx: number) => Promise<boolean>;
runOpenAIAllKeys: () => Promise<void>;
runCodex: () => Promise<void>;
runGemini: () => Promise<void>;
runClaude: () => Promise<void>;
}
@@ -103,6 +106,7 @@ export function useConnectivityTest(
const [openaiStatuses, setOpenaiStatuses] = useState<ConnectivityStatus[]>(() =>
Array.from({ length: entriesCount }, () => IDLE)
);
const [codexStatus, setCodexStatus] = useState<ConnectivityStatus>(IDLE);
const [geminiStatus, setGeminiStatus] = useState<ConnectivityStatus>(IDLE);
const [claudeStatus, setClaudeStatus] = useState<ConnectivityStatus>(IDLE);
const [inFlight, setInFlight] = useState(0);
@@ -160,6 +164,7 @@ export function useConnectivityTest(
if (lastSignatureRef.current === signature) return;
lastSignatureRef.current = signature;
setOpenaiStatuses((prev) => prev.map(() => IDLE));
setCodexStatus(IDLE);
setGeminiStatus(IDLE);
setClaudeStatus(IDLE);
}, [signature]);
@@ -277,6 +282,82 @@ export function useConnectivityTest(
await Promise.all(entries.map((_, idx) => runOpenAIKey(idx)));
}, [apiKeyEntries, brand, runOpenAIKey]);
const runCodex = useCallback(async (): Promise<void> => {
if (brand !== 'codex') return;
const trimmedBase = baseUrl.trim();
if (!trimmedBase) {
setCodexStatus({ state: 'error', message: messages.baseUrlRequired });
return;
}
const endpoint = buildCodexResponsesEndpoint(trimmedBase);
if (!endpoint) {
setCodexStatus({ state: 'error', message: messages.endpointInvalid });
return;
}
const model = pickModel(testModel, models);
if (!model) {
setCodexStatus({ state: 'error', message: messages.modelRequired });
return;
}
const customHeaders = buildHeaderObject(formHeaders);
const explicitKey = (apiKey ?? '').trim();
const persistedKey = (fallbackApiKey ?? '').trim();
const hasAuthorization = hasHeader(customHeaders, 'authorization');
const resolvedKey = explicitKey || persistedKey;
const resolvedAuthIndex = (authIndex ?? '').trim() || undefined;
if (!resolvedKey && !hasAuthorization && !resolvedAuthIndex) {
setCodexStatus({ state: 'error', message: messages.apiKeyRequired });
return;
}
const headerObj: Record<string, string> = {
'Content-Type': 'application/json',
...customHeaders,
};
if (!hasHeader(headerObj, 'authorization')) {
if (resolvedKey) {
headerObj.Authorization = `Bearer ${resolvedKey}`;
} else if (resolvedAuthIndex) {
headerObj.Authorization = 'Bearer $TOKEN$';
}
}
setCodexStatus({ state: 'loading', message: '' });
setInFlight((n) => n + 1);
try {
const result = await apiCallApi.request(
{
authIndex: resolvedAuthIndex,
method: 'POST',
url: endpoint,
header: headerObj,
data: JSON.stringify({
model,
input: 'Hi',
stream: false,
}),
},
{ timeout: DEFAULT_TIMEOUT_MS }
);
if (result.statusCode < 200 || result.statusCode >= 300) {
throw new Error(getApiCallErrorMessage(result));
}
setCodexStatus({ state: 'success', message: '' });
} catch (err) {
setCodexStatus({
state: 'error',
message: requestFailureMessage(err, messages),
});
} finally {
setInFlight((n) => n - 1);
}
}, [apiKey, authIndex, baseUrl, brand, fallbackApiKey, formHeaders, messages, models, testModel]);
const runGemini = useCallback(async (): Promise<void> => {
if (brand !== 'gemini') return;
@@ -419,11 +500,13 @@ export function useConnectivityTest(
return {
openaiStatuses,
codexStatus,
geminiStatus,
claudeStatus,
isTestingAny: inFlight > 0,
runOpenAIKey,
runOpenAIAllKeys,
runCodex,
runGemini,
runClaude,
};
+4 -7
View File
@@ -7,8 +7,7 @@ export type ProviderBrand =
| 'codex'
| 'claude'
| 'vertex'
| 'openaiCompatibility'
| 'ampcode';
| 'openaiCompatibility';
export const PROVIDER_SORT_BY_VALUES = ['name', 'priority', 'recent-success'] as const;
export type ProviderSortBy = (typeof PROVIDER_SORT_BY_VALUES)[number];
@@ -21,13 +20,11 @@ export type ProviderResourceSelector =
| { brand: 'codex'; apiKey: string; baseUrl?: string; index: number }
| { brand: 'claude'; apiKey: string; baseUrl?: string; index: number }
| { brand: 'vertex'; apiKey: string; baseUrl?: string; index: number }
| { brand: 'openaiCompatibility'; name: string; index: number }
| { brand: 'ampcode' };
| { brand: 'openaiCompatibility'; name: string; index: number };
export interface ProviderResourceFlags {
cloakEnabled?: boolean;
websockets?: boolean;
forceModelMappings?: boolean;
isPlaceholder?: boolean;
}
@@ -35,7 +32,7 @@ export interface ProviderResource {
/** 稳定 id,用作 React key 与选中态判断 */
id: string;
brand: ProviderBrand;
/** 在原数组中的下标。Ampcode 永远为 0 */
/** 在原数组中的下标 */
originalIndex: number;
/** 表格 key 列显示名(OpenAI=name,其余=null) */
name: string | null;
@@ -50,7 +47,7 @@ export interface ProviderResource {
proxyUrl: string | null;
prefix: string | null;
modelCount: number;
/** 去重后的模型名(ampcode 为映射两端), 供筛选/搜索用 */
/** 去重后的模型名, 供筛选/搜索用 */
models: string[];
/** 排序用优先级,未配置时为 0 */
priority: number;
+2 -68
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ampcodeApi, providersApi } from '@/services/api';
import { providersApi } from '@/services/api';
import { getErrorMessage } from '@/utils/helpers';
import { useAuthStore, useConfigStore } from '@/stores';
import {
@@ -7,13 +7,11 @@ import {
withoutDisableAllModelsRule,
} from '@/components/providers/utils';
import type {
AmpcodeConfig,
GeminiKeyConfig,
OpenAIProviderConfig,
ProviderKeyConfig,
} from '@/types';
import {
ampcodeToResource,
claudeToResource,
codexToResource,
geminiToResource,
@@ -42,7 +40,6 @@ export interface UseProviderWorkbenchResult {
updateProvider: (resource: ProviderResource, input: ProviderEntryFormInput) => Promise<void>;
deleteProvider: (resource: ProviderResource) => Promise<void>;
toggleDisabled: (resource: ProviderResource, disabled: boolean) => Promise<void>;
saveAmpcode: (config: AmpcodeConfig) => Promise<void>;
mutating: boolean;
refreshSnapshot: () => void;
}
@@ -208,10 +205,9 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
setIsFetching(true);
setErrorMessage(null);
try {
const [configResult, vertexResult, ampcodeResult, openaiResult] = await Promise.allSettled([
const [configResult, vertexResult, openaiResult] = await Promise.allSettled([
fetchConfig(undefined, true),
providersApi.getVertexConfigs(),
ampcodeApi.getAmpcode(),
providersApi.getOpenAIProviders(),
]);
if (configResult.status !== 'fulfilled') {
@@ -220,9 +216,6 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
if (vertexResult.status === 'fulfilled') {
updateConfigValue('vertex-api-key', vertexResult.value || []);
}
if (ampcodeResult.status === 'fulfilled') {
updateConfigValue('ampcode', ampcodeResult.value);
}
if (openaiResult.status === 'fulfilled') {
updateConfigValue('openai-compatibility', openaiResult.value || []);
}
@@ -268,9 +261,6 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
case 'openaiCompatibility':
resources = (config.openaiCompatibility ?? []).map((c, i) => openaiToResource(c, i));
break;
case 'ampcode':
resources = [ampcodeToResource(config.ampcode)];
break;
}
return {
id: brand,
@@ -349,8 +339,6 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
const next = [...(config?.openaiCompatibility ?? [])];
next.push(buildOpenAIConfig(input));
await persistOpenAIConfigs(next);
} else if (brand === 'ampcode') {
throw new Error('Use saveAmpcode for ampcode create/update');
}
refreshSnapshot();
} finally {
@@ -399,8 +387,6 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
const existing = list[idx];
list[idx] = buildOpenAIConfig(input, existing);
await persistOpenAIConfigs(list);
} else if (brand === 'ampcode') {
throw new Error('Use saveAmpcode for ampcode update');
}
refreshSnapshot();
} finally {
@@ -443,13 +429,6 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
await providersApi.deleteOpenAIProvider(sel.index);
const next = (config?.openaiCompatibility ?? []).filter((_, i) => i !== sel.index);
updateConfigValue('openai-compatibility', next);
} else if (sel.brand === 'ampcode') {
await Promise.allSettled([
ampcodeApi.clearUpstreamUrl(),
ampcodeApi.clearUpstreamApiKey(),
ampcodeApi.clearModelMappings(),
]);
updateConfigValue('ampcode', {});
}
refreshSnapshot();
} finally {
@@ -499,8 +478,6 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
list[idx] = { ...current, disabled };
updateConfigValue('openai-compatibility', list);
}
} else if (brand === 'ampcode') {
/* ampcode toggle 不支持,跳过 */
}
refreshSnapshot();
} finally {
@@ -518,48 +495,6 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
]
);
const saveAmpcode = useCallback(
async (next: AmpcodeConfig) => {
setMutating(true);
try {
// 细粒度 PUT 序列以保留兼容性
const url = (next.upstreamUrl ?? '').trim();
if (url) {
await ampcodeApi.updateUpstreamUrl(url);
} else {
await ampcodeApi.clearUpstreamUrl();
}
const fallbackKey = (next.upstreamApiKey ?? '').trim();
if (fallbackKey) {
await ampcodeApi.updateUpstreamApiKey(fallbackKey);
} else {
await ampcodeApi.clearUpstreamApiKey();
}
if (Array.isArray(next.upstreamApiKeys) && next.upstreamApiKeys.length) {
await ampcodeApi.saveUpstreamApiKeys(next.upstreamApiKeys);
} else {
await ampcodeApi.saveUpstreamApiKeys([]);
}
if (Array.isArray(next.modelMappings) && next.modelMappings.length) {
await ampcodeApi.saveModelMappings(next.modelMappings);
} else {
await ampcodeApi.clearModelMappings();
}
await ampcodeApi.updateForceModelMappings(next.forceModelMappings === true);
updateConfigValue('ampcode', next);
refreshSnapshot();
} finally {
setMutating(false);
}
},
[updateConfigValue, refreshSnapshot]
);
return {
connected,
isPending,
@@ -572,7 +507,6 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
updateProvider,
deleteProvider,
toggleDisabled,
saveAmpcode,
mutating,
refreshSnapshot,
};
+36 -1
View File
@@ -120,6 +120,15 @@ function setStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void
}
}
function setStringListInDoc(doc: YamlDocument, path: YamlPath, values: string[]): void {
const nextValues = values.map((value) => value.trim()).filter(Boolean);
if (nextValues.length > 0) {
doc.setIn(path, nextValues);
return;
}
if (docHas(doc, path)) doc.deleteIn(path);
}
function setIntFromStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void {
const safe = typeof value === 'string' ? value : '';
const trimmed = safe.trim();
@@ -790,6 +799,13 @@ function getNextDirtyFields(
] as Array<keyof VisualConfigValues>
).forEach(updateScalarDirty);
if (Object.prototype.hasOwnProperty.call(patch, 'pluginStoreSources')) {
updateDirty(
'pluginStoreSources',
areStringArraysEqual(nextValues.pluginStoreSources, baselineValues.pluginStoreSources)
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'payloadDefaultRules')) {
updateDirty(
'payloadDefaultRules',
@@ -957,6 +973,7 @@ export function useVisualConfig() {
authDir: typeof parsed['auth-dir'] === 'string' ? parsed['auth-dir'] : '',
apiKeysText: resolveApiKeysText(parsed),
pluginsEnabled: Boolean(plugins?.enabled),
pluginStoreSources: parseStringList(plugins?.['store-sources']),
debug: Boolean(parsed.debug),
commercialMode: Boolean(parsed['commercial-mode']),
@@ -1126,10 +1143,28 @@ export function useVisualConfig() {
if (
docHas(doc, ['plugins']) ||
values.pluginsEnabled ||
shouldWriteManagedField(doc, ['plugins', 'enabled'], dirtyFields, 'pluginsEnabled')
values.pluginStoreSources.length > 0 ||
shouldWriteManagedField(doc, ['plugins', 'enabled'], dirtyFields, 'pluginsEnabled') ||
shouldWriteManagedField(
doc,
['plugins', 'store-sources'],
dirtyFields,
'pluginStoreSources'
)
) {
ensureMapInDoc(doc, ['plugins']);
setBooleanInDoc(doc, ['plugins', 'enabled'], values.pluginsEnabled);
if (
values.pluginStoreSources.length > 0 ||
shouldWriteManagedField(
doc,
['plugins', 'store-sources'],
dirtyFields,
'pluginStoreSources'
)
) {
setStringListInDoc(doc, ['plugins', 'store-sources'], values.pluginStoreSources);
}
deleteIfMapEmpty(doc, ['plugins']);
}
+39 -30
View File
@@ -148,7 +148,7 @@
"quick_actions": "Quick Actions",
"current_config": "Current Configuration",
"management_keys": "Management Keys",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}} Amp:{{ampcode}}",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}}",
"oauth_credentials": "OAuth Credentials",
"edit_settings": "Edit Settings",
"routing_strategy": "Routing Strategy",
@@ -441,7 +441,7 @@
"additional_secondary_window": "{{name}} weekly limit",
"additional_team_secondary_window": "{{name}} monthly limit",
"plan_label": "Plan",
"expires_label": "Expires",
"expires_label": "Renewal time",
"reset_credits_label": "Manual resets",
"reset_button": "Reset quota",
"reset_confirm_title": "Reset Codex quota",
@@ -920,6 +920,11 @@
"logging_to_file_desc": "Save logs to files",
"plugins_enabled": "Enable Plugin System",
"plugins_enabled_desc": "Enable standard dynamic-library plugin loading; individual plugin instances are still managed on the Plugins page",
"plugin_store_sources": "Third-party Plugin Sources",
"plugin_store_sources_desc": "Append plugin-store registry sources; the built-in official source is always kept",
"plugin_store_sources_label": "Plugin source registry URL (plugins.store-sources)",
"plugin_store_sources_placeholder": "https://example.com/cliproxy-plugins/registry.json",
"plugin_store_sources_hint": "One registry.json URL per row. Empty rows are filtered on save",
"logs_max_size": "Log File Size Limit (MB)",
"error_logs_max_files": "Retained Error Log Files",
"usage_statistics_enabled": "Enable In-memory Usage Statistics",
@@ -1149,6 +1154,12 @@
"array_item_placeholder": "Enter array item",
"add_array_item": "Add array item",
"remove_array_item": "Remove array item",
"delete_plugin": "Delete",
"delete_confirm_title": "Delete plugin",
"delete_confirm_message": "Delete {{name}} ({{id}})? This removes the local plugin file and saved config.",
"delete_success": "Plugin deleted",
"delete_failed": "Failed to delete plugin",
"delete_restart_required": "The loaded plugin cannot be removed until the backend restarts.",
"toggle_success": "Plugin status updated",
"toggle_failed": "Failed to update plugin status",
"save_success": "Plugin config saved",
@@ -1165,6 +1176,10 @@
"plugin_store": {
"title": "Plugin Store",
"description": "Browse the plugin registry, then install or update plugins for the current backend.",
"description_show_more": "Show more",
"description_show_less": "Show less",
"source_name": "Source: {{source}}",
"cli_proxy_api_source": "CLIProxyAPI source",
"refresh": "Refresh",
"retry": "Retry",
"load_failed": "Failed to load the plugin store",
@@ -1206,7 +1221,24 @@
"no_plugins_desc": "The plugin registry did not return any plugins.",
"no_matches": "No matching plugins",
"no_matches_desc": "No plugins match the current search or filter.",
"clear_filters": "Clear filters"
"clear_filters": "Clear filters",
"security_banner_title": "Third-party plugins run with full backend access",
"security_banner_text": "Plugins execute code inside the proxy service and can read your credentials and traffic. Only install plugins you trust — be especially careful with any plugin not published by the official router-for-me organization.",
"badge_untrusted": "Third-party",
"gate_title": "Install {{name}}",
"gate_title_update": "Update {{name}}",
"gate_warning": "Unexpected and harmful things can happen if you don't read this.",
"gate_effect_runs_code": "This plugin will run uncertified third-party code inside your proxy backend. The system cannot isolate or audit its behavior, and it may have access to your sensitive credentials, network requests, and response data.",
"gate_effect_no_review": "This plugin is not published or reviewed by the official router-for-me organization. The official organization makes no express or implied warranty for this plugin's stability, security, compliance, or any behavior changes in future versions.",
"gate_effect_restart": "Installing it changes the local plugin directory and may require a service restart to take effect.",
"gate_untrusted_alert": "This plugin is not from the official router-for-me organization. Only continue if you fully trust its author and source.",
"gate_repository_label": "Repository",
"gate_source_label": "Source",
"gate_step1_action": "I want to install this plugin",
"gate_step2_action": "I have read and understand these risks",
"gate_step3_prompt": "To confirm, type \"{{token}}\" in the box below",
"gate_step3_action": "Install this plugin",
"gate_step3_hint": "Enter the exact identifier shown above to enable installation."
},
"plugin_resource": {
"title": "Plugin Page",
@@ -1301,8 +1333,6 @@
"openai_provider_added": "OpenAI provider added successfully",
"openai_provider_updated": "OpenAI provider updated successfully",
"openai_provider_deleted": "OpenAI provider deleted successfully",
"ampcode_updated": "Ampcode configuration updated",
"ampcode_upstream_api_key_cleared": "Ampcode upstream API key override cleared",
"openai_model_name_required": "Model name is required",
"openai_test_url_required": "Please provide a valid Base URL before testing",
"openai_test_key_required": "Please add at least one API key before testing",
@@ -1377,17 +1407,14 @@
},
"categories": {
"title": "Providers",
"activeCount": "{{active}}/{{total}} active",
"ampcodeActive": "Connected",
"ampcodeInactive": "Not configured"
"activeCount": "{{active}}/{{total}} active"
},
"providerNames": {
"gemini": "Gemini",
"codex": "Codex",
"claude": "Claude",
"vertex": "Vertex",
"openaiCompatibility": "OpenAI Compatible",
"ampcode": "Amp CLI"
"openaiCompatibility": "OpenAI Compatible"
},
"table": {
"key": "Key",
@@ -1399,12 +1426,10 @@
"metrics": {
"models": "Models",
"keys": "Keys",
"headers": "Headers",
"mappings": "Mappings"
"headers": "Headers"
},
"websocketsTag": "WebSockets",
"cloakTag": "Cloak",
"noFallbackKey": "No fallback key",
"empty": "No resources yet, click \"New\" to add.",
"filterPlaceholder": "Search keys, URLs, prefixes…",
"description": "Manage resources under {{route}}",
@@ -1487,25 +1512,9 @@
"baseUrlRequired": "Base URL is required"
}
},
"ampcode": {
"upstreamUrl": "Upstream URL",
"upstreamApiKey": "Upstream API key (fallback)",
"upstreamApiKeyHint": "Used when no key mapping matches",
"keyMappingsSection": "Upstream key mappings",
"mappingRow": "Mapping #{{index}}",
"clientKeys": "Client keys",
"clientKeysHint": "One per line; matched keys forward to the upstream key above",
"addMapping": "Add key mapping",
"modelMappingsSection": "Model mappings",
"addModelMapping": "Add model mapping",
"forceModelMappings": "Force model mappings",
"forceModelMappingsHint": "Requests that don't match a mapping are rejected"
},
"delete": {
"title": "Delete resource",
"confirm": "Delete {{name}}? This action cannot be undone.",
"ampcodeTitle": "Clear Amp CLI configuration",
"ampcodeConfirm": "Clear all Amp CLI configuration? Upstream URL, keys and model mappings will be removed."
"confirm": "Delete {{name}}? This action cannot be undone."
},
"toast": {
"created": "Created",
+40 -29
View File
@@ -147,7 +147,7 @@
"quick_actions": "Быстрые действия",
"current_config": "Текущая конфигурация",
"management_keys": "Ключи управления",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}} Amp:{{ampcode}}",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}}",
"oauth_credentials": "Учётные данные OAuth",
"edit_settings": "Изменить настройки",
"routing_strategy": "Стратегия маршрутизации",
@@ -907,6 +907,11 @@
"logging_to_file_desc": "Сохранять журналы в файлы",
"plugins_enabled": "Включить систему плагинов",
"plugins_enabled_desc": "Включает загрузку стандартных dynamic-library плагинов; отдельные экземпляры управляются на странице плагинов",
"plugin_store_sources": "Сторонние источники плагинов",
"plugin_store_sources_desc": "Добавляет registry-источники магазина плагинов; встроенный официальный источник всегда сохраняется",
"plugin_store_sources_label": "URL registry источника плагинов (plugins.store-sources)",
"plugin_store_sources_placeholder": "https://example.com/cliproxy-plugins/registry.json",
"plugin_store_sources_hint": "Один URL registry.json в строке. Пустые строки отфильтровываются при сохранении",
"logs_max_size": "Максимальный размер файла журнала (МБ)",
"error_logs_max_files": "Файлов журнала ошибок",
"usage_statistics_enabled": "Включить статистику использования в памяти",
@@ -1098,6 +1103,8 @@
"description": "Просматривайте найденные и зарегистрированные плагины, управляйте переключателями экземпляров, полями конфигурации и ссылками на ресурсы.",
"refresh": "Обновить",
"load_failed": "Не удалось загрузить плагины",
"config_load_failed": "Не удалось прочитать конфигурацию плагина",
"config_not_found": "Не удалось прочитать конфигурацию плагина: backend не нашёл этот плагин.",
"unsupported_backend": "Текущий backend не предоставляет API управления плагинами. Используйте более новую сборку backend с эндпоинтами управления плагинами и перезапустите сервис.",
"global_status": "Глобальный статус",
"global_enabled": "Включено",
@@ -1134,6 +1141,12 @@
"array_item_placeholder": "Введите элемент массива",
"add_array_item": "Добавить элемент массива",
"remove_array_item": "Удалить элемент массива",
"delete_plugin": "Удалить",
"delete_confirm_title": "Удалить плагин",
"delete_confirm_message": "Удалить {{name}} ({{id}})? Будут удалены локальный файл плагина и сохранённая конфигурация.",
"delete_success": "Плагин удалён",
"delete_failed": "Не удалось удалить плагин",
"delete_restart_required": "Загруженный плагин можно удалить только после перезапуска backend.",
"toggle_success": "Статус плагина обновлён",
"toggle_failed": "Не удалось обновить статус плагина",
"save_success": "Конфигурация плагина сохранена",
@@ -1150,6 +1163,10 @@
"plugin_store": {
"title": "Магазин плагинов",
"description": "Просматривайте реестр плагинов, устанавливайте и обновляйте плагины для текущего бэкенда.",
"description_show_more": "Показать больше",
"description_show_less": "Свернуть",
"source_name": "Источник: {{source}}",
"cli_proxy_api_source": "Источник CLIProxyAPI",
"refresh": "Обновить",
"retry": "Повторить",
"load_failed": "Не удалось загрузить магазин плагинов",
@@ -1191,7 +1208,24 @@
"no_plugins_desc": "Реестр плагинов не вернул ни одного плагина.",
"no_matches": "Нет подходящих плагинов",
"no_matches_desc": "Нет плагинов, соответствующих текущему поиску или фильтру.",
"clear_filters": "Сбросить фильтры"
"clear_filters": "Сбросить фильтры",
"security_banner_title": "Сторонние плагины работают с полным доступом к бэкенду",
"security_banner_text": "Плагины выполняют код внутри прокси-сервиса и могут читать ваши учётные данные и трафик. Устанавливайте только плагины, которым доверяете, — особенно осторожно относитесь к плагинам, не опубликованным официальной организацией router-for-me.",
"badge_untrusted": "Сторонний",
"gate_title": "Установка {{name}}",
"gate_title_update": "Обновление {{name}}",
"gate_warning": "Если вы не прочитаете это, могут произойти неожиданные и опасные последствия.",
"gate_effect_runs_code": "Этот плагин будет выполнять несертифицированный сторонний код внутри вашего прокси-бэкенда. Система не может изолировать или аудировать его поведение, и он может получить доступ к вашим конфиденциальным учётным данным, сетевым запросам и данным ответов.",
"gate_effect_no_review": "Этот плагин не опубликован и не проверен официальной организацией router-for-me. Официальная организация не предоставляет никаких явных или подразумеваемых гарантий стабильности, безопасности, соответствия требованиям или любых изменений поведения в последующих версиях этого плагина.",
"gate_effect_restart": "Установка изменяет локальный каталог плагинов и может потребовать перезапуска сервиса.",
"gate_untrusted_alert": "Этот плагин не от официальной организации router-for-me. Продолжайте, только если полностью доверяете его автору и источнику.",
"gate_repository_label": "Репозиторий",
"gate_source_label": "Источник",
"gate_step1_action": "Я хочу установить этот плагин",
"gate_step2_action": "Я прочитал и понимаю эти риски",
"gate_step3_prompt": "Для подтверждения введите «{{token}}» в поле ниже",
"gate_step3_action": "Установить этот плагин",
"gate_step3_hint": "Введите точный идентификатор, показанный выше, чтобы включить установку."
},
"system_info": {
"title": "Информация о центре управления",
@@ -1276,8 +1310,6 @@
"openai_provider_added": "Провайдер OpenAI успешно добавлен",
"openai_provider_updated": "Провайдер OpenAI успешно обновлён",
"openai_provider_deleted": "Провайдер OpenAI успешно удалён",
"ampcode_updated": "Настройки Ampcode обновлены",
"ampcode_upstream_api_key_cleared": "Переопределение upstream-ключа Ampcode очищено",
"openai_model_name_required": "Введите имя модели",
"openai_test_url_required": "Укажите корректный базовый URL перед тестированием",
"openai_test_key_required": "Добавьте хотя бы один API-ключ перед тестированием",
@@ -1352,17 +1384,14 @@
},
"categories": {
"title": "Провайдеры",
"activeCount": "{{active}}/{{total}} активных",
"ampcodeActive": "Подключено",
"ampcodeInactive": "Не настроено"
"activeCount": "{{active}}/{{total}} активных"
},
"providerNames": {
"gemini": "Gemini",
"codex": "Codex",
"claude": "Claude",
"vertex": "Vertex",
"openaiCompatibility": "OpenAI-совместимый",
"ampcode": "Amp CLI"
"openaiCompatibility": "OpenAI-совместимый"
},
"table": {
"key": "Ключ",
@@ -1374,12 +1403,10 @@
"metrics": {
"models": "Модели",
"keys": "Ключи",
"headers": "Заголовки",
"mappings": "Сопоставления"
"headers": "Заголовки"
},
"websocketsTag": "WebSockets",
"cloakTag": "Cloak",
"noFallbackKey": "Резервный ключ не задан",
"empty": "Нет ресурсов, нажмите \"Создать\".",
"filterPlaceholder": "Поиск по ключам, URL, префиксам…",
"description": "Управление ресурсами {{route}}",
@@ -1462,25 +1489,9 @@
"baseUrlRequired": "Base URL обязателен"
}
},
"ampcode": {
"upstreamUrl": "Upstream URL",
"upstreamApiKey": "Резервный API-ключ",
"upstreamApiKeyHint": "Используется, когда нет совпадений",
"keyMappingsSection": "Сопоставление ключей",
"mappingRow": "Сопоставление #{{index}}",
"clientKeys": "Клиентские ключи",
"clientKeysHint": "По одному в строке; совпавшие пересылаются на ключ выше",
"addMapping": "Добавить сопоставление",
"modelMappingsSection": "Сопоставление моделей",
"addModelMapping": "Добавить сопоставление",
"forceModelMappings": "Принудительное сопоставление",
"forceModelMappingsHint": "Запросы без совпадений отклоняются"
},
"delete": {
"title": "Удалить ресурс",
"confirm": "Удалить {{name}}? Действие необратимо.",
"ampcodeTitle": "Очистить настройки Amp CLI",
"ampcodeConfirm": "Удалить все настройки Amp CLI? Upstream URL, ключи и сопоставления будут удалены."
"confirm": "Удалить {{name}}? Действие необратимо."
},
"toast": {
"created": "Создано",
+39 -30
View File
@@ -148,7 +148,7 @@
"quick_actions": "快捷操作",
"current_config": "当前配置",
"management_keys": "管理密钥",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}} Amp:{{ampcode}}",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}}",
"oauth_credentials": "OAuth 凭证",
"edit_settings": "编辑设置",
"routing_strategy": "路由策略",
@@ -441,7 +441,7 @@
"additional_secondary_window": "{{name}} 周限额",
"additional_team_secondary_window": "{{name}} 月度限额",
"plan_label": "套餐",
"expires_label": "期时间",
"expires_label": "期时间",
"reset_credits_label": "主动重置次数",
"reset_button": "重置额度",
"reset_confirm_title": "重置 Codex 额度",
@@ -920,6 +920,11 @@
"logging_to_file_desc": "将日志保存到文件",
"plugins_enabled": "启用插件系统",
"plugins_enabled_desc": "启用标准动态库插件加载;具体插件实例仍在插件管理页启停",
"plugin_store_sources": "第三方插件源",
"plugin_store_sources_desc": "追加插件商店 registry 源;内置官方源始终保留",
"plugin_store_sources_label": "插件源 registry URL (plugins.store-sources)",
"plugin_store_sources_placeholder": "https://example.com/cliproxy-plugins/registry.json",
"plugin_store_sources_hint": "每行一个 registry.json URL,保存时会过滤空行",
"logs_max_size": "日志文件大小限制 (MB)",
"error_logs_max_files": "错误日志保留文件数",
"usage_statistics_enabled": "启用内存用量统计",
@@ -1149,6 +1154,12 @@
"array_item_placeholder": "输入数组项",
"add_array_item": "添加数组项",
"remove_array_item": "删除数组项",
"delete_plugin": "删除",
"delete_confirm_title": "删除插件",
"delete_confirm_message": "确定删除 {{name}}{{id}})吗?这会移除本地插件文件和已保存配置。",
"delete_success": "插件已删除",
"delete_failed": "插件删除失败",
"delete_restart_required": "已加载的插件需要重启后端后才能移除。",
"toggle_success": "插件状态已更新",
"toggle_failed": "插件状态更新失败",
"save_success": "插件配置已保存",
@@ -1165,6 +1176,10 @@
"plugin_store": {
"title": "插件商店",
"description": "浏览插件注册表,为当前后端安装或更新插件。",
"description_show_more": "展开描述",
"description_show_less": "收起描述",
"source_name": "来源:{{source}}",
"cli_proxy_api_source": "CLIProxyAPI源",
"refresh": "刷新",
"retry": "重试",
"load_failed": "插件商店加载失败",
@@ -1206,7 +1221,24 @@
"no_plugins_desc": "插件注册表未返回任何插件。",
"no_matches": "没有匹配的插件",
"no_matches_desc": "当前搜索或筛选条件下没有插件。",
"clear_filters": "清除筛选"
"clear_filters": "清除筛选",
"security_banner_title": "第三方插件以后端完整权限运行",
"security_banner_text": "插件会在代理服务内部执行代码,可读取你的凭据与流量。请仅安装你信任的插件——尤其要警惕任何并非由官方组织 router-for-me 发布的插件。",
"badge_untrusted": "第三方",
"gate_title": "安装 {{name}}",
"gate_title_update": "更新 {{name}}",
"gate_warning": "如果你不阅读以下内容,可能会发生意料之外的危险后果。",
"gate_effect_runs_code": "该插件将在您的代理后端内运行第三方未认证代码。系统无法对其行为进行隔离或审计,其具备访问您的敏感凭据、网络请求与响应数据的潜在权限。",
"gate_effect_no_review": "本插件并非由官方组织 router-for-me 发布或审核。官方不对该插件的稳定性、安全性、合规性以及后续版本的任意行为变更承担任何明示或暗示的担保责任。",
"gate_effect_restart": "安装会修改本地插件目录,并可能需要重启服务才能生效。",
"gate_untrusted_alert": "该插件并非来自官方组织 router-for-me。请仅在你完全信任其作者与来源时继续。",
"gate_repository_label": "仓库",
"gate_source_label": "来源",
"gate_step1_action": "我要安装此插件",
"gate_step2_action": "我已阅读并理解上述风险",
"gate_step3_prompt": "如需确认,请在下方输入框中键入 “{{token}}”",
"gate_step3_action": "安装此插件",
"gate_step3_hint": "请输入上方显示的完整标识以启用安装。"
},
"plugin_resource": {
"title": "插件页面",
@@ -1301,8 +1333,6 @@
"openai_provider_added": "OpenAI提供商添加成功",
"openai_provider_updated": "OpenAI提供商更新成功",
"openai_provider_deleted": "OpenAI提供商删除成功",
"ampcode_updated": "Ampcode 配置已更新",
"ampcode_upstream_api_key_cleared": "Ampcode upstream API key 覆盖已清除",
"openai_model_name_required": "请填写模型名称",
"openai_test_url_required": "请先填写有效的 Base URL 以进行测试",
"openai_test_key_required": "请至少填写一个 API 密钥以进行测试",
@@ -1377,17 +1407,14 @@
},
"categories": {
"title": "提供商",
"activeCount": "{{active}}/{{total}} 活跃",
"ampcodeActive": "已连接",
"ampcodeInactive": "未配置"
"activeCount": "{{active}}/{{total}} 活跃"
},
"providerNames": {
"gemini": "Gemini",
"codex": "Codex",
"claude": "Claude",
"vertex": "Vertex",
"openaiCompatibility": "OpenAI 兼容",
"ampcode": "Amp CLI"
"openaiCompatibility": "OpenAI 兼容"
},
"table": {
"key": "密钥",
@@ -1399,12 +1426,10 @@
"metrics": {
"models": "模型",
"keys": "密钥",
"headers": "请求头",
"mappings": "映射"
"headers": "请求头"
},
"websocketsTag": "WebSockets",
"cloakTag": "Cloak",
"noFallbackKey": "未设置兜底密钥",
"empty": "尚未添加配置,点击右上角新建。",
"filterPlaceholder": "搜索密钥、地址、前缀…",
"description": "在 {{route}} 下管理资源",
@@ -1487,25 +1512,9 @@
"baseUrlRequired": "服务地址必填"
}
},
"ampcode": {
"upstreamUrl": "上游 URL",
"upstreamApiKey": "上游 API 密钥(兜底)",
"upstreamApiKeyHint": "未匹配密钥映射时使用",
"keyMappingsSection": "上游密钥映射",
"mappingRow": "映射 #{{index}}",
"clientKeys": "客户端密钥",
"clientKeysHint": "每行一个,匹配后转发到上述上游密钥",
"addMapping": "添加密钥映射",
"modelMappingsSection": "模型映射",
"addModelMapping": "添加模型映射",
"forceModelMappings": "强制模型映射",
"forceModelMappingsHint": "启用后,所有请求必须命中模型映射规则"
},
"delete": {
"title": "删除资源",
"confirm": "确定要删除 {{name}} 吗?此操作不可撤销。",
"ampcodeTitle": "清空 Amp CLI 配置",
"ampcodeConfirm": "确定要清空 Amp CLI 配置吗?上游 URL、API 密钥与模型映射都会被移除。"
"confirm": "确定要删除 {{name}} 吗?此操作不可撤销。"
},
"toast": {
"created": "创建成功",
+39 -30
View File
@@ -148,7 +148,7 @@
"quick_actions": "快速操作",
"current_config": "目前設定",
"management_keys": "管理金鑰",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}} Amp:{{ampcode}}",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}}",
"oauth_credentials": "OAuth 憑證",
"edit_settings": "編輯設定",
"routing_strategy": "路由策略",
@@ -441,7 +441,7 @@
"additional_secondary_window": "{{name}} 週限額",
"additional_team_secondary_window": "{{name}} 月度限額",
"plan_label": "方案",
"expires_label": "期時間",
"expires_label": "期時間",
"reset_credits_label": "主動重置次數",
"reset_button": "重置配額",
"reset_confirm_title": "重置 Codex 配額",
@@ -946,6 +946,11 @@
"logging_to_file_desc": "將記錄儲存到檔案",
"plugins_enabled": "啟用插件系統",
"plugins_enabled_desc": "啟用標準動態庫插件載入;具體插件實例仍在插件管理頁啟停",
"plugin_store_sources": "第三方插件源",
"plugin_store_sources_desc": "追加插件商店 registry 源;內建官方源始終保留",
"plugin_store_sources_label": "插件源 registry URLplugins.store-sources",
"plugin_store_sources_placeholder": "https://example.com/cliproxy-plugins/registry.json",
"plugin_store_sources_hint": "每列一個 registry.json URL,儲存時會過濾空列",
"logs_max_size": "記錄檔大小限制(MB",
"error_logs_max_files": "錯誤記錄保留檔案數",
"usage_statistics_enabled": "啟用記憶體用量統計",
@@ -1175,6 +1180,12 @@
"array_item_placeholder": "輸入陣列項目",
"add_array_item": "新增陣列項目",
"remove_array_item": "刪除陣列項目",
"delete_plugin": "刪除",
"delete_confirm_title": "刪除插件",
"delete_confirm_message": "確定刪除 {{name}}{{id}})嗎?這會移除本機插件檔案和已儲存設定。",
"delete_success": "插件已刪除",
"delete_failed": "插件刪除失敗",
"delete_restart_required": "已載入的插件需要重新啟動後端後才能移除。",
"toggle_success": "插件狀態已更新",
"toggle_failed": "插件狀態更新失敗",
"save_success": "插件設定已儲存",
@@ -1191,6 +1202,10 @@
"plugin_store": {
"title": "插件商店",
"description": "瀏覽插件註冊表,為目前後端安裝或更新插件。",
"description_show_more": "展開描述",
"description_show_less": "收起描述",
"source_name": "來源:{{source}}",
"cli_proxy_api_source": "CLIProxyAPI來源",
"refresh": "重新整理",
"retry": "重試",
"load_failed": "插件商店載入失敗",
@@ -1232,7 +1247,24 @@
"no_plugins_desc": "插件註冊表未回傳任何插件。",
"no_matches": "沒有符合的插件",
"no_matches_desc": "目前搜尋或篩選條件下沒有插件。",
"clear_filters": "清除篩選"
"clear_filters": "清除篩選",
"security_banner_title": "第三方插件以後端完整權限執行",
"security_banner_text": "插件會在代理服務內部執行程式碼,可讀取你的憑證與流量。請僅安裝你信任的插件——尤其要警惕任何並非由官方組織 router-for-me 發布的插件。",
"badge_untrusted": "第三方",
"gate_title": "安裝 {{name}}",
"gate_title_update": "更新 {{name}}",
"gate_warning": "如果你不閱讀以下內容,可能會發生意料之外的危險後果。",
"gate_effect_runs_code": "該插件將在您的代理後端內執行第三方未認證程式碼。系統無法對其行為進行隔離或稽核,其具備存取您的敏感憑證、網路請求與回應資料的潛在權限。",
"gate_effect_no_review": "本插件並非由官方組織 router-for-me 發布或審核。官方不對該插件的穩定性、安全性、合規性以及後續版本的任意行為變更承擔任何明示或暗示的擔保責任。",
"gate_effect_restart": "安裝會修改本機插件目錄,並可能需要重新啟動服務才能生效。",
"gate_untrusted_alert": "該插件並非來自官方組織 router-for-me。請僅在你完全信任其作者與來源時繼續。",
"gate_repository_label": "儲存庫",
"gate_source_label": "來源",
"gate_step1_action": "我要安裝此插件",
"gate_step2_action": "我已閱讀並理解上述風險",
"gate_step3_prompt": "如需確認,請在下方輸入框中鍵入 “{{token}}”",
"gate_step3_action": "安裝此插件",
"gate_step3_hint": "請輸入上方顯示的完整識別碼以啟用安裝。"
},
"plugin_resource": {
"title": "插件頁面",
@@ -1327,8 +1359,6 @@
"openai_provider_added": "OpenAI 供應商新增成功",
"openai_provider_updated": "OpenAI 供應商更新成功",
"openai_provider_deleted": "OpenAI 供應商刪除成功",
"ampcode_updated": "Ampcode 設定已更新",
"ampcode_upstream_api_key_cleared": "Ampcode upstream API key 覆寫已清除",
"openai_model_name_required": "請填寫模型名稱",
"openai_test_url_required": "請先填寫有效的 Base URL 以進行測試",
"openai_test_key_required": "請至少填寫一個 API 金鑰以進行測試",
@@ -1403,17 +1433,14 @@
},
"categories": {
"title": "提供商",
"activeCount": "{{active}}/{{total}} 活躍",
"ampcodeActive": "已連線",
"ampcodeInactive": "未設定"
"activeCount": "{{active}}/{{total}} 活躍"
},
"providerNames": {
"gemini": "Gemini",
"codex": "Codex",
"claude": "Claude",
"vertex": "Vertex",
"openaiCompatibility": "OpenAI 相容",
"ampcode": "Amp CLI"
"openaiCompatibility": "OpenAI 相容"
},
"table": {
"key": "金鑰",
@@ -1425,12 +1452,10 @@
"metrics": {
"models": "模型",
"keys": "金鑰",
"headers": "請求標頭",
"mappings": "映射"
"headers": "請求標頭"
},
"websocketsTag": "WebSockets",
"cloakTag": "Cloak",
"noFallbackKey": "未設定備援金鑰",
"empty": "尚未新增設定,點擊右上角新增。",
"filterPlaceholder": "搜尋金鑰、位址、前綴…",
"description": "在 {{route}} 下管理資源",
@@ -1513,25 +1538,9 @@
"baseUrlRequired": "服務位址必填"
}
},
"ampcode": {
"upstreamUrl": "上游 URL",
"upstreamApiKey": "上游 API 金鑰(備援)",
"upstreamApiKeyHint": "未匹配金鑰映射時使用",
"keyMappingsSection": "上游金鑰映射",
"mappingRow": "映射 #{{index}}",
"clientKeys": "客戶端金鑰",
"clientKeysHint": "每行一個,匹配後轉發到上述上游金鑰",
"addMapping": "新增金鑰映射",
"modelMappingsSection": "模型映射",
"addModelMapping": "新增模型映射",
"forceModelMappings": "強制模型映射",
"forceModelMappingsHint": "啟用後,所有請求必須命中模型映射規則"
},
"delete": {
"title": "刪除資源",
"confirm": "確定要刪除 {{name}} 嗎?此操作不可復原。",
"ampcodeTitle": "清空 Amp CLI 設定",
"ampcodeConfirm": "確定要清空 Amp CLI 設定嗎?上游 URL、API 金鑰與模型映射都會被移除。"
"confirm": "確定要刪除 {{name}} 嗎?此操作不可復原。"
},
"toast": {
"created": "建立成功",
-14
View File
@@ -5,7 +5,6 @@ import { IconKey, IconBot, IconFileText, IconSatellite } from '@/components/ui/i
import { useAuthStore, useConfigStore, useModelsStore } from '@/stores';
import { authFilesApi } from '@/services/api';
import { useApiKeysForModels } from '@/hooks/useApiKeysForModels';
import type { AmpcodeConfig } from '@/types';
import { formatDateValue } from '@/utils/format';
import styles from './DashboardPage.module.scss';
@@ -20,17 +19,6 @@ interface QuickStat {
type TimeOfDay = 'morning' | 'afternoon' | 'evening' | 'night';
const countAmpcodeConfig = (value: AmpcodeConfig | undefined): number => {
if (!value) return 0;
const configured =
Boolean(value.upstreamUrl?.trim()) ||
Boolean(value.upstreamApiKey?.trim()) ||
(value.upstreamApiKeys?.length ?? 0) > 0 ||
(value.modelMappings?.length ?? 0) > 0 ||
value.forceModelMappings === true;
return configured ? 1 : 0;
};
function getTimeOfDay(): TimeOfDay {
const hour = new Date().getHours();
if (hour >= 5 && hour < 12) return 'morning';
@@ -121,7 +109,6 @@ export function DashboardPage() {
claude: config.claudeApiKeys?.length ?? 0,
vertex: config.vertexApiKeys?.length ?? 0,
openai: config.openaiCompatibility?.length ?? 0,
ampcode: countAmpcodeConfig(config.ampcode),
}
: null;
const totalProviderKeys = providerStats
@@ -150,7 +137,6 @@ export function DashboardPage() {
claude: providerStats.claude,
vertex: providerStats.vertex,
openai: providerStats.openai,
ampcode: providerStats.ampcode,
})
: undefined,
},
+56 -20
View File
@@ -52,9 +52,27 @@ const MAX_BUFFER_LINES = 10000;
const LONG_PRESS_MS = 650;
const LONG_PRESS_MOVE_THRESHOLD = 10;
const getIncrementalAfter = (cursor: LogsQuery['after']): LogsQuery['after'] => {
if (typeof cursor !== 'number') return cursor;
return cursor > 1 ? cursor - 1 : undefined;
type LogPosition = Pick<LogsQuery, 'after' | 'cursor'>;
const getIncrementalAfter = (after: LogsQuery['after']): LogsQuery['after'] => {
if (typeof after !== 'number') return after;
return after > 1 ? after - 1 : undefined;
};
const buildLogsQuery = (incremental: boolean, position: LogPosition): LogsQuery => {
const params: LogsQuery = { limit: MAX_BUFFER_LINES };
if (!incremental) return params;
if (position.cursor) {
params.cursor = position.cursor;
}
const after = getIncrementalAfter(position.after);
if (after !== undefined) {
params.after = after;
}
return params;
};
const findLineOverlap = (currentLines: string[], incomingLines: string[]): number => {
@@ -174,8 +192,29 @@ export function LogsPage() {
const logRequestInFlightRef = useRef(false);
const pendingFullReloadRef = useRef(false);
// 保存最新游标用于增量获取
const latestCursorRef = useRef<LogsQuery['after']>(undefined);
// 保存最新游标用于增量获取;新 CPA 后端优先使用 cursor,旧接口和 Home 继续使用 after。
const logPositionRef = useRef<LogPosition>({});
const resetLogPosition = () => {
logPositionRef.current = {};
};
const updateLogPosition = (
data: Awaited<ReturnType<typeof logsApi.fetchLogs>>,
incremental: boolean
) => {
const currentPosition = logPositionRef.current;
const nextPosition: LogPosition = {};
if (data.nextCursor) {
nextPosition.cursor = data.nextCursor;
}
if (data.latestAfter !== undefined) {
nextPosition.after = data.latestAfter;
} else if (incremental && currentPosition.after !== undefined) {
nextPosition.after = currentPosition.after;
}
logPositionRef.current = nextPosition;
};
const disableControls = connectionStatus !== 'connected';
const refreshDisabled = disableControls || loading || cpaNeedsFileLogging;
@@ -190,7 +229,7 @@ export function LogsPage() {
if (cpaNeedsFileLogging) {
if (!incremental) {
latestCursorRef.current = undefined;
resetLogPosition();
requestLogHomeIpByIdRef.current = {};
setFileLoggingRequired(false);
setLogState({ buffer: [], visibleFrom: 0 });
@@ -222,19 +261,12 @@ export function LogsPage() {
scrollerInstance?.requestScrollToBottom();
}
const params: LogsQuery =
incremental && latestCursorRef.current
? { after: getIncrementalAfter(latestCursorRef.current), limit: MAX_BUFFER_LINES }
: { limit: MAX_BUFFER_LINES };
const params = buildLogsQuery(incremental, logPositionRef.current);
const data = await logsApi.fetchLogs(params);
setFileLoggingRequired(false);
// 更新游标
if (data.latestCursor) {
latestCursorRef.current = data.latestCursor;
} else if (!incremental) {
latestCursorRef.current = undefined;
}
updateLogPosition(data, incremental);
if (data.requestLogHomeIpById) {
requestLogHomeIpByIdRef.current = incremental
? { ...requestLogHomeIpByIdRef.current, ...data.requestLogHomeIpById }
@@ -245,7 +277,11 @@ export function LogsPage() {
const newLines = Array.isArray(data.lines) ? data.lines : [];
if (incremental && newLines.length > 0) {
if (incremental && data.cursorReset) {
const buffer = newLines.slice(-MAX_BUFFER_LINES);
const visibleFrom = Math.max(buffer.length - INITIAL_DISPLAY_LINES, 0);
setLogState({ buffer, visibleFrom });
} else if (incremental && newLines.length > 0) {
// 增量更新:追加新日志并限制缓冲区大小(避免内存与渲染膨胀)
setLogState((prev) => {
const prevRenderedCount = prev.buffer.length - prev.visibleFrom;
@@ -271,7 +307,7 @@ export function LogsPage() {
console.error('Failed to load logs:', err);
if (isLoggingToFileDisabledError(err)) {
if (!incremental) {
latestCursorRef.current = undefined;
resetLogPosition();
requestLogHomeIpByIdRef.current = {};
setFileLoggingRequired(true);
setLogState({ buffer: [], visibleFrom: 0 });
@@ -318,7 +354,7 @@ export function LogsPage() {
try {
await logsApi.clearLogs();
setLogState({ buffer: [], visibleFrom: 0 });
latestCursorRef.current = undefined;
resetLogPosition();
requestLogHomeIpByIdRef.current = {};
setFileLoggingRequired(false);
showNotification(t('logs.clear_success'), 'success');
@@ -429,7 +465,7 @@ export function LogsPage() {
useEffect(() => {
if (connectionStatus === 'connected') {
latestCursorRef.current = undefined;
resetLogPosition();
requestLogHomeIpByIdRef.current = {};
setFileLoggingRequired(false);
loadLogs(false);
-59
View File
@@ -1,59 +0,0 @@
/**
* Amp CLI Integration (ampcode) API
*/
import { apiClient } from './client';
import {
normalizeAmpcodeConfig,
normalizeAmpcodeModelMappings,
normalizeAmpcodeUpstreamApiKeys,
} from './transformers';
import type { AmpcodeConfig, AmpcodeModelMapping, AmpcodeUpstreamApiKeyMapping } from '@/types';
const serializeUpstreamApiKeyMappings = (mappings: AmpcodeUpstreamApiKeyMapping[]) =>
mappings.map((mapping) => ({
'upstream-api-key': mapping.upstreamApiKey,
'api-keys': mapping.apiKeys,
}));
export const ampcodeApi = {
async getAmpcode(): Promise<AmpcodeConfig> {
const data = await apiClient.get('/ampcode');
return normalizeAmpcodeConfig(data) ?? {};
},
updateUpstreamUrl: (url: string) => apiClient.put('/ampcode/upstream-url', { value: url }),
clearUpstreamUrl: () => apiClient.delete('/ampcode/upstream-url'),
updateUpstreamApiKey: (apiKey: string) => apiClient.put('/ampcode/upstream-api-key', { value: apiKey }),
clearUpstreamApiKey: () => apiClient.delete('/ampcode/upstream-api-key'),
async getUpstreamApiKeys(): Promise<AmpcodeUpstreamApiKeyMapping[]> {
const data = await apiClient.get<Record<string, unknown>>('/ampcode/upstream-api-keys');
const list = data?.['upstream-api-keys'] ?? data?.upstreamApiKeys ?? data?.items ?? data;
return normalizeAmpcodeUpstreamApiKeys(list);
},
saveUpstreamApiKeys: (mappings: AmpcodeUpstreamApiKeyMapping[]) =>
apiClient.put('/ampcode/upstream-api-keys', { value: serializeUpstreamApiKeyMappings(mappings) }),
patchUpstreamApiKeys: (mappings: AmpcodeUpstreamApiKeyMapping[]) =>
apiClient.patch('/ampcode/upstream-api-keys', { value: serializeUpstreamApiKeyMappings(mappings) }),
deleteUpstreamApiKeys: (upstreamApiKeys: string[]) =>
apiClient.delete('/ampcode/upstream-api-keys', { data: { value: upstreamApiKeys } }),
async getModelMappings(): Promise<AmpcodeModelMapping[]> {
const data = await apiClient.get<Record<string, unknown>>('/ampcode/model-mappings');
const list = data?.['model-mappings'] ?? data?.modelMappings ?? data?.items ?? data;
return normalizeAmpcodeModelMappings(list);
},
saveModelMappings: (mappings: AmpcodeModelMapping[]) =>
apiClient.put('/ampcode/model-mappings', { value: mappings }),
patchModelMappings: (mappings: AmpcodeModelMapping[]) =>
apiClient.patch('/ampcode/model-mappings', { value: mappings }),
clearModelMappings: () => apiClient.delete('/ampcode/model-mappings'),
deleteModelMappings: (fromList: string[]) =>
apiClient.delete('/ampcode/model-mappings', { data: { value: fromList } }),
updateForceModelMappings: (enabled: boolean) => apiClient.put('/ampcode/force-model-mappings', { value: enabled })
};
-1
View File
@@ -4,7 +4,6 @@ export * from './apiKeyUsage';
export * from './config';
export * from './configFile';
export * from './apiKeys';
export * from './ampcode';
export * from './providers';
export * from './authFiles';
export * from './oauth';
+79 -8
View File
@@ -11,6 +11,7 @@ export type LogBackendKind = 'unknown' | 'file' | 'home-db';
export interface LogsQuery {
after?: LogCursor;
cursor?: string;
limit?: number;
offset?: number;
}
@@ -42,7 +43,9 @@ export interface HomeLogsResponse {
export interface LogsResponse {
lines: string[];
lineCount: number;
latestCursor?: LogCursor;
latestAfter?: LogCursor;
nextCursor?: string;
cursorReset?: boolean;
logBackendKind: LogBackendKind;
requestLogHomeIpById?: Record<string, string>;
total?: number;
@@ -62,6 +65,24 @@ export interface ErrorLogsResponse {
const stringValue = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
const numberValue = (value: unknown): number | undefined => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
};
const booleanValue = (value: unknown): boolean =>
value === true || (typeof value === 'string' && value.trim().toLowerCase() === 'true');
const positiveNumberValue = (value: unknown): number | undefined => {
const parsed = numberValue(value);
return parsed !== undefined && parsed > 0 ? parsed : undefined;
};
const homeRecordsFromPayload = (data: Record<string, unknown>): HomeLogRecord[] =>
Array.isArray(data.logs)
? data.logs.filter((entry): entry is HomeLogRecord => isRecord(entry))
: [];
const unixSecondsFromValue = (value: unknown): number => {
if (typeof value === 'number' && Number.isFinite(value)) return value;
const text = stringValue(value);
@@ -89,15 +110,15 @@ const normalizeCPALogs = (data: Record<string, unknown>): LogsResponse => {
return {
lines,
lineCount: Number.isFinite(lineCount) ? lineCount : lines.length,
latestCursor: latestTimestamp > 0 ? latestTimestamp : undefined,
logBackendKind: 'file'
latestAfter: latestTimestamp > 0 ? latestTimestamp : undefined,
nextCursor: stringValue(data['next-cursor']) || undefined,
cursorReset: booleanValue(data['cursor-reset']),
logBackendKind: 'file',
};
};
const normalizeHomeLogs = (data: Record<string, unknown>): LogsResponse => {
const rawLogs = Array.isArray(data.logs)
? data.logs.filter((entry): entry is HomeLogRecord => isRecord(entry))
: [];
const rawLogs = homeRecordsFromPayload(data);
const orderedLogs = [...rawLogs].reverse();
const lines = orderedLogs
.map((record) => record.line)
@@ -127,12 +148,12 @@ const normalizeHomeLogs = (data: Record<string, unknown>): LogsResponse => {
return {
lines,
lineCount: Number.isFinite(total) ? total : lines.length,
latestCursor,
latestAfter: latestCursor,
logBackendKind: 'home-db',
requestLogHomeIpById,
total: Number.isFinite(total) ? total : undefined,
limit: Number.isFinite(limit) ? limit : undefined,
offset: Number.isFinite(offset) ? offset : undefined
offset: Number.isFinite(offset) ? offset : undefined,
};
};
@@ -145,9 +166,59 @@ const normalizeLogsResponse = (data: unknown): LogsResponse => {
return { lines: [], lineCount: 0, logBackendKind: 'unknown' };
};
const fetchCompleteHomeLogs = async (
firstPage: Record<string, unknown>,
params: LogsQuery
): Promise<Record<string, unknown>> => {
const requestedLimit = positiveNumberValue(params.limit);
const firstPageLimit = positiveNumberValue(firstPage.limit);
const pageLimit = firstPageLimit ?? requestedLimit;
const total = numberValue(firstPage.total);
const firstOffset = numberValue(firstPage.offset) ?? numberValue(params.offset) ?? 0;
const records = homeRecordsFromPayload(firstPage);
if (requestedLimit === undefined || pageLimit === undefined || total === undefined) {
return firstPage;
}
const targetCount = Math.min(requestedLimit, Math.max(total - firstOffset, 0));
if (records.length >= targetCount) {
return { ...firstPage, logs: records, limit: records.length, offset: firstOffset };
}
const remaining = targetCount - records.length;
const baseOffset = firstOffset + records.length;
const pageRequests: Array<{ offset: number; limit: number }> = [];
let collected = 0;
while (collected < remaining && baseOffset + collected < total) {
const pageSize = Math.min(pageLimit, remaining - collected);
pageRequests.push({ offset: baseOffset + collected, limit: pageSize });
collected += pageSize;
}
const pages = await Promise.all(
pageRequests.map(async ({ offset, limit }) => {
const data = await apiClient.get('/logs', {
params: { ...params, limit, offset },
timeout: LOGS_TIMEOUT_MS,
});
if (!isRecord(data) || !Array.isArray(data.logs)) return [];
return homeRecordsFromPayload(data);
})
);
pages.forEach((pageRecords) => records.push(...pageRecords));
return { ...firstPage, logs: records, limit: records.length, offset: firstOffset };
};
export const logsApi = {
async fetchLogs(params: LogsQuery = {}): Promise<LogsResponse> {
const data = await apiClient.get('/logs', { params, timeout: LOGS_TIMEOUT_MS });
if (isRecord(data) && Array.isArray(data.logs)) {
return normalizeLogsResponse(await fetchCompleteHomeLogs(data, params));
}
return normalizeLogsResponse(data);
},
+48 -2
View File
@@ -3,6 +3,7 @@ import { isRecord } from '@/utils/helpers';
import type {
PluginConfigField,
PluginConfigObject,
PluginDeleteResult,
PluginListEntry,
PluginListResponse,
PluginMetadata,
@@ -10,6 +11,7 @@ import type {
PluginStoreEntry,
PluginStoreInstallResult,
PluginStoreResponse,
PluginStoreSource,
} from '@/types';
const asString = (value: unknown): string => {
@@ -118,16 +120,34 @@ const normalizePluginList = (value: unknown): PluginListResponse => {
const normalizePluginConfig = (value: unknown): PluginConfigObject =>
isRecord(value) ? { ...value } : {};
const normalizeDeleteResult = (value: unknown): PluginDeleteResult => {
const source = isRecord(value) ? value : {};
return {
status: asString(source.status).trim(),
id: asString(source.id).trim(),
path: asString(source.path).trim(),
fileDeleted: asBoolean(source.file_deleted),
configuredRemoved: asBoolean(source.configured_removed),
restartRequired: asBoolean(source.restart_required),
};
};
const normalizeStoreEntry = (value: unknown): PluginStoreEntry | null => {
if (!isRecord(value)) return null;
const id = asString(value.id).trim();
if (!id) return null;
const sourceId = asString(value.source_id).trim();
const storeId = asString(value.store_id).trim() || (sourceId ? `${sourceId}/${id}` : id);
const tags = Array.isArray(value.tags)
? value.tags.map((item) => asString(item).trim()).filter(Boolean)
: [];
return {
storeId,
sourceId,
sourceName: asString(value.source_name).trim(),
sourceUrl: asString(value.source_url).trim(),
id,
name: asString(value.name).trim(),
description: asString(value.description).trim(),
@@ -149,15 +169,31 @@ const normalizeStoreEntry = (value: unknown): PluginStoreEntry | null => {
};
};
const normalizeStoreSource = (value: unknown): PluginStoreSource | null => {
if (!isRecord(value)) return null;
const id = asString(value.id).trim();
const url = asString(value.url).trim();
if (!id && !url) return null;
return {
id,
name: asString(value.name).trim(),
url,
};
};
const normalizeStoreList = (value: unknown): PluginStoreResponse => {
const source = isRecord(value) ? value : {};
const plugins = Array.isArray(source.plugins)
? source.plugins.map((item) => normalizeStoreEntry(item)).filter(Boolean) as PluginStoreEntry[]
: [];
const sources = Array.isArray(source.sources)
? source.sources.map((item) => normalizeStoreSource(item)).filter(Boolean) as PluginStoreSource[]
: [];
return {
pluginsEnabled: asBoolean(source.plugins_enabled),
pluginsDir: asString(source.plugins_dir).trim() || 'plugins',
sources,
plugins,
};
};
@@ -166,6 +202,9 @@ const normalizeInstallResult = (value: unknown): PluginStoreInstallResult => {
const source = isRecord(value) ? value : {};
return {
status: asString(source.status).trim(),
sourceId: asString(source.source_id).trim(),
sourceName: asString(source.source_name).trim(),
sourceUrl: asString(source.source_url).trim(),
id: asString(source.id).trim(),
version: asString(source.version).trim(),
path: asString(source.path).trim(),
@@ -183,6 +222,11 @@ export const pluginsApi = {
updateEnabled: (id: string, enabled: boolean) =>
apiClient.patch(`/plugins/${encodeURIComponent(id)}/enabled`, { enabled }),
async deletePlugin(id: string): Promise<PluginDeleteResult> {
const data = await apiClient.delete(`/plugins/${encodeURIComponent(id)}`);
return normalizeDeleteResult(data);
},
async getConfig(id: string): Promise<PluginConfigObject> {
const data = await apiClient.get(`/plugins/${encodeURIComponent(id)}/config`);
return normalizePluginConfig(data);
@@ -201,8 +245,10 @@ export const pluginStoreApi = {
return normalizeStoreList(data);
},
async install(id: string): Promise<PluginStoreInstallResult> {
const data = await apiClient.post(`/plugin-store/${encodeURIComponent(id)}/install`);
async install(id: string, sourceId?: string): Promise<PluginStoreInstallResult> {
const path = `/plugin-store/${encodeURIComponent(id)}/install`;
const query = sourceId ? `?${new URLSearchParams({ source: sourceId }).toString()}` : '';
const data = await apiClient.post(`${path}${query}`);
return normalizeInstallResult(data);
},
};
+2 -86
View File
@@ -4,10 +4,7 @@ import type {
GeminiKeyConfig,
ModelAlias,
OpenAIProviderConfig,
ProviderKeyConfig,
AmpcodeConfig,
AmpcodeModelMapping,
AmpcodeUpstreamApiKeyMapping
ProviderKeyConfig
} from '@/types';
import type { Config } from '@/types/config';
import { buildHeaderObject } from '@/utils/headers';
@@ -272,79 +269,6 @@ const normalizeOauthExcluded = (payload: unknown): Record<string, string[]> | un
return map;
};
const normalizeAmpcodeModelMappings = (input: unknown): AmpcodeModelMapping[] => {
if (!Array.isArray(input)) return [];
const seen = new Set<string>();
const mappings: AmpcodeModelMapping[] = [];
input.forEach((entry) => {
if (!isRecord(entry)) return;
const from = String(entry.from ?? '').trim();
const to = String(entry.to ?? '').trim();
if (!from || !to) return;
const key = from.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
mappings.push({ from, to });
});
return mappings;
};
const normalizeAmpcodeUpstreamApiKeys = (input: unknown): AmpcodeUpstreamApiKeyMapping[] => {
if (!Array.isArray(input)) return [];
const seen = new Set<string>();
const mappings: AmpcodeUpstreamApiKeyMapping[] = [];
input.forEach((entry) => {
if (!isRecord(entry)) return;
const upstreamApiKey = String(entry['upstream-api-key'] ?? '').trim();
if (!upstreamApiKey || seen.has(upstreamApiKey)) return;
const rawApiKeys = entry['api-keys'] ?? [];
const apiKeys = Array.isArray(rawApiKeys)
? Array.from(new Set(rawApiKeys.map((item) => String(item ?? '').trim()).filter(Boolean)))
: [];
if (!apiKeys.length) return;
seen.add(upstreamApiKey);
mappings.push({ upstreamApiKey, apiKeys });
});
return mappings;
};
const normalizeAmpcodeConfig = (payload: unknown): AmpcodeConfig | undefined => {
const sourceRaw = isRecord(payload) ? (payload.ampcode ?? payload) : payload;
if (!isRecord(sourceRaw)) return undefined;
const source = sourceRaw;
const config: AmpcodeConfig = {};
const upstreamUrl = source['upstream-url'];
if (upstreamUrl) config.upstreamUrl = String(upstreamUrl);
const upstreamApiKey = source['upstream-api-key'];
if (upstreamApiKey) config.upstreamApiKey = String(upstreamApiKey);
const upstreamApiKeys = normalizeAmpcodeUpstreamApiKeys(source['upstream-api-keys']);
if (upstreamApiKeys.length) {
config.upstreamApiKeys = upstreamApiKeys;
}
const forceModelMappings = normalizeBoolean(source['force-model-mappings']);
if (forceModelMappings !== undefined) {
config.forceModelMappings = forceModelMappings;
}
const modelMappings = normalizeAmpcodeModelMappings(source['model-mappings']);
if (modelMappings.length) {
config.modelMappings = modelMappings;
}
return config;
};
/**
* /config
*/
@@ -435,11 +359,6 @@ export const normalizeConfigResponse = (raw: unknown): Config => {
.filter(Boolean) as OpenAIProviderConfig[];
}
const ampcode = normalizeAmpcodeConfig(raw.ampcode);
if (ampcode) {
config.ampcode = ampcode;
}
const oauthExcluded = normalizeOauthExcluded(raw['oauth-excluded-models']);
if (oauthExcluded) {
config.oauthExcludedModels = oauthExcluded;
@@ -455,8 +374,5 @@ export {
normalizeOpenAIProvider,
normalizeProviderKeyConfig,
normalizeHeaders,
normalizeExcludedModels,
normalizeAmpcodeConfig,
normalizeAmpcodeModelMappings,
normalizeAmpcodeUpstreamApiKeys
normalizeExcludedModels
};
-6
View File
@@ -45,7 +45,6 @@ const SECTION_KEYS: RawConfigSection[] = [
'force-model-prefix',
'routing/strategy',
'api-keys',
'ampcode',
'gemini-api-key',
'codex-api-key',
'claude-api-key',
@@ -79,8 +78,6 @@ const extractSectionValue = (config: Config | null, section?: RawConfigSection)
return config.routingStrategy;
case 'api-keys':
return config.apiKeys;
case 'ampcode':
return config.ampcode;
case 'gemini-api-key':
return config.geminiApiKeys;
case 'codex-api-key':
@@ -220,9 +217,6 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
case 'api-keys':
nextConfig.apiKeys = value as Config['apiKeys'];
break;
case 'ampcode':
nextConfig.ampcode = value as Config['ampcode'];
break;
case 'gemini-api-key':
nextConfig.geminiApiKeys = value as Config['geminiApiKeys'];
break;
-21
View File
@@ -1,21 +0,0 @@
/**
* Amp CLI Integration (ampcode)
*/
export interface AmpcodeModelMapping {
from: string;
to: string;
}
export interface AmpcodeUpstreamApiKeyMapping {
upstreamApiKey: string;
apiKeys: string[];
}
export interface AmpcodeConfig {
upstreamUrl?: string;
upstreamApiKey?: string;
upstreamApiKeys?: AmpcodeUpstreamApiKeyMapping[];
modelMappings?: AmpcodeModelMapping[];
forceModelMappings?: boolean;
}
-3
View File
@@ -4,7 +4,6 @@
*/
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from './provider';
import type { AmpcodeConfig } from './ampcode';
export interface QuotaExceededConfig {
switchProject?: boolean;
@@ -24,7 +23,6 @@ export interface Config {
forceModelPrefix?: boolean;
routingStrategy?: string;
apiKeys?: string[];
ampcode?: AmpcodeConfig;
geminiApiKeys?: GeminiKeyConfig[];
codexApiKeys?: ProviderKeyConfig[];
claudeApiKeys?: ProviderKeyConfig[];
@@ -46,7 +44,6 @@ export type RawConfigSection =
| 'force-model-prefix'
| 'routing/strategy'
| 'api-keys'
| 'ampcode'
| 'gemini-api-key'
| 'codex-api-key'
| 'claude-api-key'
-1
View File
@@ -7,7 +7,6 @@ export * from './api';
export * from './config';
export * from './auth';
export * from './provider';
export * from './ampcode';
export * from './authFile';
export * from './oauth';
export * from './log';
+23
View File
@@ -51,7 +51,20 @@ export interface PluginListResponse {
plugins: PluginListEntry[];
}
export interface PluginDeleteResult {
status: string;
id: string;
path: string;
fileDeleted: boolean;
configuredRemoved: boolean;
restartRequired: boolean;
}
export interface PluginStoreEntry {
storeId: string;
sourceId: string;
sourceName: string;
sourceUrl: string;
id: string;
name: string;
description: string;
@@ -72,14 +85,24 @@ export interface PluginStoreEntry {
updateAvailable: boolean;
}
export interface PluginStoreSource {
id: string;
name: string;
url: string;
}
export interface PluginStoreResponse {
pluginsEnabled: boolean;
pluginsDir: string;
sources: PluginStoreSource[];
plugins: PluginStoreEntry[];
}
export interface PluginStoreInstallResult {
status: string;
sourceId: string;
sourceName: string;
sourceUrl: string;
id: string;
version: string;
path: string;
+2
View File
@@ -81,6 +81,7 @@ export type VisualConfigValues = {
authDir: string;
apiKeysText: string;
pluginsEnabled: boolean;
pluginStoreSources: string[];
debug: boolean;
commercialMode: boolean;
loggingToFile: boolean;
@@ -145,6 +146,7 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
authDir: '',
apiKeysText: '',
pluginsEnabled: false,
pluginStoreSources: [],
debug: false,
commercialMode: false,
loggingToFile: false,