mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
24 Commits
@@ -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.
|
||||
|
||||
@@ -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 等提供商的配额上限与使用情况。
|
||||
|
||||
@@ -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]
|
||||
@@ -658,8 +663,42 @@ export function VisualConfigEditor({
|
||||
disabled={disabled}
|
||||
onChange={(loggingToFile) => onChange({ loggingToFile })}
|
||||
/>
|
||||
<ToggleRow
|
||||
title={t('config_management.visual.sections.system.plugins_enabled')}
|
||||
description={t('config_management.visual.sections.system.plugins_enabled_desc')}
|
||||
checked={values.pluginsEnabled}
|
||||
disabled={disabled}
|
||||
onChange={(pluginsEnabled) => onChange({ pluginsEnabled })}
|
||||
/>
|
||||
</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')}
|
||||
@@ -813,6 +852,17 @@ export function VisualConfigEditor({
|
||||
disabled={disabled}
|
||||
/>
|
||||
</SectionGrid>
|
||||
<SectionGrid>
|
||||
<ToggleRow
|
||||
title={t('config_management.visual.sections.headers.codex_identity_confuse')}
|
||||
description={t(
|
||||
'config_management.visual.sections.headers.codex_identity_confuse_desc'
|
||||
)}
|
||||
checked={values.codexIdentityConfuse}
|
||||
disabled={disabled}
|
||||
onChange={(codexIdentityConfuse) => onChange({ codexIdentityConfuse })}
|
||||
/>
|
||||
</SectionGrid>
|
||||
</SectionStack>
|
||||
</SectionSubsection>
|
||||
|
||||
@@ -931,6 +981,16 @@ export function VisualConfigEditor({
|
||||
}
|
||||
/>
|
||||
</FieldShell>
|
||||
<Input
|
||||
label={t('config_management.visual.sections.network.gpt_image_2_base_model')}
|
||||
placeholder="gpt-5.4-mini"
|
||||
value={values.gptImage2BaseModel}
|
||||
onChange={(e) => onChange({ gptImage2BaseModel: e.target.value })}
|
||||
disabled={disabled}
|
||||
hint={t(
|
||||
'config_management.visual.sections.network.gpt_image_2_base_model_hint'
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
label={t('config_management.visual.sections.network.session_affinity_ttl')}
|
||||
placeholder="1h"
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from '@/stores';
|
||||
import {
|
||||
collectPluginResourceEntries,
|
||||
PLUGIN_RESOURCES_REFRESH_EVENT,
|
||||
resolvePluginAssetURL,
|
||||
type PluginResourceEntry,
|
||||
} from '@/features/plugins/pluginResources';
|
||||
@@ -300,6 +301,7 @@ export function MainLayout() {
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const apiBase = useAuthStore((state) => state.apiBase);
|
||||
const supportsPlugin = useAuthStore((state) => state.supportsPlugin);
|
||||
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
@@ -430,7 +432,7 @@ export function MainLayout() {
|
||||
}, [fetchConfig]);
|
||||
|
||||
const loadPluginResources = useCallback(async () => {
|
||||
if (connectionStatus !== 'connected') {
|
||||
if (connectionStatus !== 'connected' || !supportsPlugin) {
|
||||
setPluginResources([]);
|
||||
return;
|
||||
}
|
||||
@@ -441,14 +443,19 @@ export function MainLayout() {
|
||||
} catch {
|
||||
setPluginResources([]);
|
||||
}
|
||||
}, [connectionStatus]);
|
||||
}, [connectionStatus, supportsPlugin]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void loadPluginResources();
|
||||
}, 0);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
window.addEventListener(PLUGIN_RESOURCES_REFRESH_EVENT, loadPluginResources);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener(PLUGIN_RESOURCES_REFRESH_EVENT, loadPluginResources);
|
||||
};
|
||||
}, [apiBase, loadPluginResources]);
|
||||
|
||||
const pluginResourceGroups = pluginResources.reduce<
|
||||
@@ -468,39 +475,39 @@ export function MainLayout() {
|
||||
return groups;
|
||||
}, []);
|
||||
|
||||
const pluginPageNavItems: SidebarNavItem[] = pluginResourceGroups.flatMap(
|
||||
(group): SidebarNavItem[] => {
|
||||
if (group.entries.length === 1) {
|
||||
const resource = group.entries[0];
|
||||
const pluginLogo = resolvePluginAssetURL(resource.pluginLogo, apiBase);
|
||||
const pluginPageNavItems: SidebarNavItem[] = supportsPlugin
|
||||
? pluginResourceGroups.flatMap((group): SidebarNavItem[] => {
|
||||
if (group.entries.length === 1) {
|
||||
const resource = group.entries[0];
|
||||
const pluginLogo = resolvePluginAssetURL(resource.pluginLogo, apiBase);
|
||||
return [
|
||||
{
|
||||
path: resource.route,
|
||||
label: resource.label,
|
||||
meta: resource.description,
|
||||
icon: <PluginSidebarIcon src={pluginLogo} />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const pluginLogo = resolvePluginAssetURL(group.entries[0]?.pluginLogo ?? '', apiBase);
|
||||
return [
|
||||
{
|
||||
path: resource.route,
|
||||
label: resource.label,
|
||||
meta: resource.description,
|
||||
kind: 'drawer',
|
||||
id: `plugin-pages-${group.pluginID}`,
|
||||
label: group.pluginTitle,
|
||||
meta: t('plugin_resource.page_count', { count: group.entries.length }),
|
||||
icon: <PluginSidebarIcon src={pluginLogo} />,
|
||||
children: group.entries.map((resource) => ({
|
||||
path: resource.route,
|
||||
label: resource.label,
|
||||
meta: resource.description,
|
||||
icon: <span className="nav-sub-dot" aria-hidden="true" />,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const pluginLogo = resolvePluginAssetURL(group.entries[0]?.pluginLogo ?? '', apiBase);
|
||||
return [
|
||||
{
|
||||
kind: 'drawer',
|
||||
id: `plugin-pages-${group.pluginID}`,
|
||||
label: group.pluginTitle,
|
||||
meta: t('plugin_resource.page_count', { count: group.entries.length }),
|
||||
icon: <PluginSidebarIcon src={pluginLogo} />,
|
||||
children: group.entries.map((resource) => ({
|
||||
path: resource.route,
|
||||
label: resource.label,
|
||||
meta: resource.description,
|
||||
icon: <span className="nav-sub-dot" aria-hidden="true" />,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
);
|
||||
})
|
||||
: [];
|
||||
|
||||
const navGroups: SidebarNavGroup[] = [
|
||||
{
|
||||
@@ -567,18 +574,22 @@ export function MainLayout() {
|
||||
metaKey: 'nav_meta.config_management',
|
||||
icon: sidebarIcons.config,
|
||||
},
|
||||
{
|
||||
path: '/plugins',
|
||||
labelKey: 'nav.plugins',
|
||||
metaKey: 'nav_meta.plugins',
|
||||
icon: sidebarIcons.plugins,
|
||||
},
|
||||
{
|
||||
path: '/plugin-store',
|
||||
labelKey: 'nav.plugin_store',
|
||||
metaKey: 'nav_meta.plugin_store',
|
||||
icon: sidebarIcons.pluginStore,
|
||||
},
|
||||
...(supportsPlugin
|
||||
? [
|
||||
{
|
||||
path: '/plugins',
|
||||
labelKey: 'nav.plugins',
|
||||
metaKey: 'nav_meta.plugins',
|
||||
icon: sidebarIcons.plugins,
|
||||
},
|
||||
{
|
||||
path: '/plugin-store',
|
||||
labelKey: 'nav.plugin_store',
|
||||
metaKey: 'nav_meta.plugin_store',
|
||||
icon: sidebarIcons.pluginStore,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
path: '/system',
|
||||
labelKey: 'nav.system_info',
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getErrorMessage, isRecord } from '@/utils/helpers';
|
||||
import type { PluginListResponse } from '@/types';
|
||||
import {
|
||||
collectPluginResourceEntries,
|
||||
PLUGIN_RESOURCES_REFRESH_EVENT,
|
||||
resolvePluginAssetURL,
|
||||
} from './pluginResources';
|
||||
import styles from './PluginResourcePage.module.scss';
|
||||
@@ -78,6 +79,14 @@ export function PluginResourcePage() {
|
||||
void loadResource();
|
||||
}, [loadResource]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener(PLUGIN_RESOURCES_REFRESH_EVENT, loadResource);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(PLUGIN_RESOURCES_REFRESH_EVENT, loadResource);
|
||||
};
|
||||
}, [loadResource]);
|
||||
|
||||
const resource = useMemo(() => {
|
||||
const entries = collectPluginResourceEntries(data?.plugins ?? []);
|
||||
return entries.find((entry) => entry.pluginID === pluginID && entry.menuIndex === menuIndex);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -21,8 +21,17 @@ import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { pluginsApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import { getErrorMessage, isRecord } from '@/utils/helpers';
|
||||
import type { PluginConfigField, PluginListEntry, PluginListResponse } from '@/types';
|
||||
import { getPluginTitle, resolvePluginAssetURL } from './pluginResources';
|
||||
import type {
|
||||
PluginConfigField,
|
||||
PluginConfigObject,
|
||||
PluginListEntry,
|
||||
PluginListResponse,
|
||||
} from '@/types';
|
||||
import {
|
||||
getPluginTitle,
|
||||
notifyPluginResourcesChanged,
|
||||
resolvePluginAssetURL,
|
||||
} from './pluginResources';
|
||||
import styles from './PluginsPage.module.scss';
|
||||
|
||||
type PluginDraftValue = string | boolean | string[];
|
||||
@@ -34,6 +43,13 @@ interface PluginConfigDraft {
|
||||
errors: Record<string, string>;
|
||||
}
|
||||
|
||||
const PLUGIN_ENABLE_REFRESH_DELAY_MS = 1600;
|
||||
|
||||
const wait = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
function PluginCardLogo({ src }: { src: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const showImage = Boolean(src) && !failed;
|
||||
@@ -45,29 +61,17 @@ function PluginCardLogo({ src }: { src: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const cloneRecord = (value: unknown): Record<string, unknown> =>
|
||||
isRecord(value) ? { ...value } : {};
|
||||
|
||||
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 getPluginsConfigMap = (rawConfig: Record<string, unknown>): Record<string, unknown> => {
|
||||
const plugins = rawConfig.plugins;
|
||||
if (!isRecord(plugins)) return {};
|
||||
const configs = plugins.configs;
|
||||
return isRecord(configs) ? configs : {};
|
||||
};
|
||||
|
||||
const getPluginRawConfig = (
|
||||
rawConfig: Record<string, unknown>,
|
||||
pluginID: string
|
||||
): Record<string, unknown> => {
|
||||
const configs = getPluginsConfigMap(rawConfig);
|
||||
return cloneRecord(configs[pluginID]);
|
||||
};
|
||||
|
||||
const stringifyArrayItem = (value: unknown): string => {
|
||||
if (value === undefined || value === null) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
@@ -98,7 +102,7 @@ const getFieldDraftValue = (field: PluginConfigField, value: unknown): PluginDra
|
||||
|
||||
const buildDraft = (
|
||||
plugin: PluginListEntry,
|
||||
currentConfig: Record<string, unknown>
|
||||
currentConfig: PluginConfigObject
|
||||
): PluginConfigDraft => {
|
||||
const enabled = typeof currentConfig.enabled === 'boolean' ? currentConfig.enabled : plugin.enabled;
|
||||
const priority =
|
||||
@@ -146,11 +150,11 @@ const parseJSONField = (
|
||||
const buildConfigPayload = (
|
||||
draft: PluginConfigDraft,
|
||||
fields: PluginConfigField[],
|
||||
currentConfig: Record<string, unknown>,
|
||||
currentConfig: PluginConfigObject,
|
||||
t: (key: string, options?: Record<string, unknown>) => string
|
||||
) => {
|
||||
const errors: Record<string, string> = {};
|
||||
const nextConfig: Record<string, unknown> = { ...currentConfig };
|
||||
const nextConfig: PluginConfigObject = { ...currentConfig };
|
||||
const priorityText = draft.priority.trim();
|
||||
|
||||
nextConfig.enabled = draft.enabled;
|
||||
@@ -235,18 +239,21 @@ export function PluginsPage() {
|
||||
const navigate = useNavigate();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const apiBase = useAuthStore((state) => state.apiBase);
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
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 [rawConfig, setRawConfig] = useState<Record<string, unknown>>({});
|
||||
const [filter, setFilter] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [editingPlugin, setEditingPlugin] = useState<PluginListEntry | null>(null);
|
||||
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);
|
||||
|
||||
const connected = connectionStatus === 'connected';
|
||||
|
||||
@@ -260,12 +267,8 @@ export function PluginsPage() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [plugins, config] = await Promise.all([
|
||||
pluginsApi.list(),
|
||||
fetchConfig(undefined, true).catch(() => null),
|
||||
]);
|
||||
const plugins = await pluginsApi.list();
|
||||
setData(plugins);
|
||||
setRawConfig(config?.raw ?? {});
|
||||
} catch (err: unknown) {
|
||||
setError(
|
||||
hasStatus(err, 404)
|
||||
@@ -275,7 +278,17 @@ export function PluginsPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [connected, fetchConfig, t]);
|
||||
}, [connected, t]);
|
||||
|
||||
const loadPluginsAfterMutation = useCallback(
|
||||
async (waitForRegistration: boolean) => {
|
||||
if (waitForRegistration) {
|
||||
await wait(PLUGIN_ENABLE_REFRESH_DELAY_MS);
|
||||
}
|
||||
await loadPlugins();
|
||||
},
|
||||
[loadPlugins]
|
||||
);
|
||||
|
||||
useHeaderRefresh(loadPlugins, connected);
|
||||
|
||||
@@ -320,15 +333,48 @@ export function PluginsPage() {
|
||||
[apiBase]
|
||||
);
|
||||
|
||||
const openConfigSheet = (plugin: PluginListEntry) => {
|
||||
const currentConfig = getPluginRawConfig(rawConfig, plugin.id);
|
||||
const openConfigSheet = async (plugin: PluginListEntry) => {
|
||||
if (openingConfigID || mutatingID || deletingID) return;
|
||||
|
||||
const requestSeq = configRequestSeq.current + 1;
|
||||
configRequestSeq.current = requestSeq;
|
||||
setOpeningConfigID(plugin.id);
|
||||
setEditingPlugin(plugin);
|
||||
setDraft(buildDraft(plugin, currentConfig));
|
||||
setEditingConfig({});
|
||||
setDraft(null);
|
||||
|
||||
try {
|
||||
const currentConfig = await pluginsApi.getConfig(plugin.id);
|
||||
if (configRequestSeq.current !== requestSeq) return;
|
||||
|
||||
setEditingConfig(currentConfig);
|
||||
setDraft(buildDraft(plugin, currentConfig));
|
||||
} catch (err: unknown) {
|
||||
if (configRequestSeq.current !== requestSeq) return;
|
||||
|
||||
setEditingPlugin(null);
|
||||
setEditingConfig({});
|
||||
setDraft(null);
|
||||
showNotification(
|
||||
hasStatus(err, 404)
|
||||
? t('plugin_management.config_not_found')
|
||||
: `${t('plugin_management.config_load_failed')}: ${getErrorMessage(
|
||||
err,
|
||||
t('plugin_management.config_load_failed')
|
||||
)}`,
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
if (configRequestSeq.current === requestSeq) {
|
||||
setOpeningConfigID('');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const closeConfigSheet = () => {
|
||||
if (mutatingID) return;
|
||||
if (mutatingID || openingConfigID || deletingID) return;
|
||||
setEditingPlugin(null);
|
||||
setEditingConfig({});
|
||||
setDraft(null);
|
||||
};
|
||||
|
||||
@@ -337,11 +383,13 @@ export function PluginsPage() {
|
||||
};
|
||||
|
||||
const handleTogglePlugin = async (plugin: PluginListEntry, enabled: boolean) => {
|
||||
if (deletingID) return;
|
||||
setMutatingID(plugin.id);
|
||||
try {
|
||||
await pluginsApi.updateEnabled(plugin.id, enabled);
|
||||
clearConfigCache();
|
||||
await loadPlugins();
|
||||
await loadPluginsAfterMutation(enabled);
|
||||
notifyPluginResourcesChanged();
|
||||
showNotification(t('plugin_management.toggle_success'), 'success');
|
||||
} catch (err: unknown) {
|
||||
showNotification(
|
||||
@@ -356,13 +404,55 @@ 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) return;
|
||||
const currentConfig = getPluginRawConfig(rawConfig, editingPlugin.id);
|
||||
if (!editingPlugin || !draft || openingConfigID || mutatingID || deletingID) return;
|
||||
const { nextConfig, errors } = buildConfigPayload(
|
||||
draft,
|
||||
editingPlugin.configFields,
|
||||
currentConfig,
|
||||
editingConfig,
|
||||
t
|
||||
);
|
||||
|
||||
@@ -376,8 +466,12 @@ export function PluginsPage() {
|
||||
try {
|
||||
await pluginsApi.putConfig(editingPlugin.id, nextConfig);
|
||||
clearConfigCache();
|
||||
await loadPlugins();
|
||||
await loadPluginsAfterMutation(
|
||||
nextConfig.enabled === true && editingPlugin.enabled !== true
|
||||
);
|
||||
notifyPluginResourcesChanged();
|
||||
setEditingPlugin(null);
|
||||
setEditingConfig({});
|
||||
setDraft(null);
|
||||
showNotification(t('plugin_management.save_success'), 'success');
|
||||
} catch (err: unknown) {
|
||||
@@ -665,7 +759,7 @@ export function PluginsPage() {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={loadPlugins}
|
||||
disabled={!connected || loading}
|
||||
disabled={!connected || loading || Boolean(mutatingID || deletingID)}
|
||||
loading={loading}
|
||||
>
|
||||
<IconRefreshCw size={16} />
|
||||
@@ -706,7 +800,9 @@ export function PluginsPage() {
|
||||
{visiblePlugins.map((plugin) => {
|
||||
const logo = resolvePluginAsset(plugin.logo || plugin.metadata?.logo || '');
|
||||
const github = plugin.metadata?.githubRepository.trim();
|
||||
const mutating = mutatingID === plugin.id;
|
||||
const openingConfig = openingConfigID === plugin.id;
|
||||
const deletingPlugin = deletingID === plugin.id;
|
||||
const actionBusy = Boolean(mutatingID || openingConfigID || deletingID);
|
||||
const version = plugin.metadata?.version;
|
||||
const author = plugin.metadata?.author;
|
||||
|
||||
@@ -780,18 +876,31 @@ export function PluginsPage() {
|
||||
<ToggleSwitch
|
||||
checked={plugin.enabled}
|
||||
onChange={(enabled) => handleTogglePlugin(plugin, enabled)}
|
||||
disabled={!connected || mutating}
|
||||
disabled={!connected || actionBusy}
|
||||
ariaLabel={t('plugin_management.enabled')}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => openConfigSheet(plugin)}
|
||||
disabled={!connected}
|
||||
disabled={!connected || actionBusy}
|
||||
loading={openingConfig}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
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';
|
||||
|
||||
export const notifyPluginResourcesChanged = () => {
|
||||
window.dispatchEvent(new Event(PLUGIN_RESOURCES_REFRESH_EVENT));
|
||||
};
|
||||
|
||||
export interface PluginResourceEntry {
|
||||
pluginID: string;
|
||||
pluginTitle: string;
|
||||
@@ -35,10 +41,56 @@ 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[] =>
|
||||
plugins.flatMap((plugin) => {
|
||||
if (!plugin.effectiveEnabled) return [];
|
||||
|
||||
const pluginTitle = getPluginTitle(plugin);
|
||||
const pluginLogo = plugin.logo || plugin.metadata?.logo || '';
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -56,8 +56,13 @@ const emptyApiKeyEntry = (): ApiKeyEntryInput => ({
|
||||
const stripDisableAllRule = (list?: string[]): string[] =>
|
||||
(list ?? []).filter((s) => s.trim() !== '*');
|
||||
|
||||
const formatJsonObject = (value?: Record<string, unknown>): string => {
|
||||
if (!value || Object.keys(value).length === 0) return '';
|
||||
return JSON.stringify(value, null, 2);
|
||||
};
|
||||
|
||||
function buildInitialForm(
|
||||
brand: Exclude<ProviderBrand, 'ampcode'>,
|
||||
brand: ProviderBrand,
|
||||
resource: ProviderResource | null,
|
||||
mode: 'create' | 'edit'
|
||||
): ProviderEntryFormInput {
|
||||
@@ -69,15 +74,22 @@ function buildInitialForm(
|
||||
proxyUrl: '',
|
||||
prefix: '',
|
||||
disabled: false,
|
||||
disableCooling: false,
|
||||
priority: undefined,
|
||||
models: [emptyModel()],
|
||||
headers: [emptyHeader()],
|
||||
excludedModelsText: '',
|
||||
websockets: brand === 'codex' ? false : undefined,
|
||||
cloak:
|
||||
brand === 'claude' ? { mode: '', strictMode: false, sensitiveWordsText: '' } : undefined,
|
||||
brand === 'claude'
|
||||
? { mode: '', strictMode: false, sensitiveWordsText: '', cacheUserId: false }
|
||||
: 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,
|
||||
@@ -94,6 +106,7 @@ function buildInitialForm(
|
||||
proxyUrl: '',
|
||||
prefix: cfg.prefix ?? '',
|
||||
disabled: cfg.disabled === true,
|
||||
disableCooling: cfg.disableCooling === true,
|
||||
priority: cfg.priority,
|
||||
models: cfg.models?.length
|
||||
? cfg.models.map((m) => ({
|
||||
@@ -101,6 +114,8 @@ function buildInitialForm(
|
||||
alias: m.alias ?? '',
|
||||
priority: m.priority,
|
||||
testModel: m.testModel,
|
||||
image: m.image === true,
|
||||
thinkingJson: formatJsonObject(m.thinking),
|
||||
}))
|
||||
: [emptyModel()],
|
||||
headers: cfg.headers
|
||||
@@ -133,6 +148,7 @@ function buildInitialForm(
|
||||
proxyUrl: cfg.proxyUrl ?? '',
|
||||
prefix: cfg.prefix ?? '',
|
||||
disabled,
|
||||
disableCooling: cfg.disableCooling === true,
|
||||
priority: cfg.priority,
|
||||
models: cfg.models?.length
|
||||
? cfg.models.map((m) => ({
|
||||
@@ -153,9 +169,14 @@ function buildInitialForm(
|
||||
mode: (cfg as ProviderKeyConfig).cloak?.mode ?? '',
|
||||
strictMode: (cfg as ProviderKeyConfig).cloak?.strictMode === true,
|
||||
sensitiveWordsText: (cfg as ProviderKeyConfig).cloak?.sensitiveWords?.join('\n') ?? '',
|
||||
cacheUserId: (cfg as ProviderKeyConfig).cloak?.cacheUserId === true,
|
||||
}
|
||||
: undefined,
|
||||
testModel: brand === 'claude' || brand === 'gemini' ? '' : undefined,
|
||||
experimentalCchSigning:
|
||||
brand === 'claude'
|
||||
? (cfg as ProviderKeyConfig).experimentalCchSigning === true
|
||||
: undefined,
|
||||
testModel: brand === 'codex' || brand === 'claude' || brand === 'gemini' ? '' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -368,7 +389,12 @@ export function BaseProviderForm({
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
cloak: {
|
||||
...(prev.cloak ?? { mode: '', strictMode: false, sensitiveWordsText: '' }),
|
||||
...(prev.cloak ?? {
|
||||
mode: '',
|
||||
strictMode: false,
|
||||
sensitiveWordsText: '',
|
||||
cacheUserId: false,
|
||||
}),
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
@@ -418,8 +444,16 @@ export function BaseProviderForm({
|
||||
[form.apiKeyEntries]
|
||||
);
|
||||
const actualApiKeyEntries = form.apiKeyEntries ?? [];
|
||||
const supportsDisableCooling =
|
||||
brand === 'gemini' ||
|
||||
brand === 'codex' ||
|
||||
brand === 'claude' ||
|
||||
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 }
|
||||
@@ -444,6 +478,20 @@ export function BaseProviderForm({
|
||||
);
|
||||
};
|
||||
|
||||
const updateModelEntry = (idx: number, patch: Partial<ModelEntryInput>) => {
|
||||
updateField(
|
||||
'models',
|
||||
modelsList.map((it, i) => (i === idx ? { ...it, ...patch } : it))
|
||||
);
|
||||
};
|
||||
|
||||
const removeModelEntry = (idx: number) => {
|
||||
updateField(
|
||||
'models',
|
||||
modelsList.filter((_, i) => i !== idx)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form id={formId} className={styles.form} onSubmit={handleSubmit} noValidate>
|
||||
{/* 基础字段 */}
|
||||
@@ -587,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')}
|
||||
@@ -661,6 +709,22 @@ export function BaseProviderForm({
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{supportsDisableCooling ? (
|
||||
<label className={styles.checkboxRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={styles.checkboxBox}
|
||||
checked={form.disableCooling ?? false}
|
||||
disabled={mutating}
|
||||
onChange={(e) => updateField('disableCooling', e.target.checked)}
|
||||
/>
|
||||
<span className={styles.checkboxText}>
|
||||
<span>{t('providersPage.form.disableCooling')}</span>
|
||||
<small>{t('providersPage.form.disableCoolingHint')}</small>
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 高级折叠区 */}
|
||||
@@ -906,50 +970,97 @@ export function BaseProviderForm({
|
||||
onClose={closeDiscovery}
|
||||
/>
|
||||
) : null}
|
||||
{modelsList.map((entry, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 8 }}
|
||||
>
|
||||
<input
|
||||
className={styles.input}
|
||||
placeholder="model-name"
|
||||
value={entry.name}
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
'models',
|
||||
modelsList.map((it, i) => (i === idx ? { ...it, name: e.target.value } : it))
|
||||
)
|
||||
}
|
||||
disabled={mutating}
|
||||
/>
|
||||
<input
|
||||
className={styles.input}
|
||||
placeholder="alias (optional)"
|
||||
value={entry.alias ?? ''}
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
'models',
|
||||
modelsList.map((it, i) => (i === idx ? { ...it, alias: e.target.value } : it))
|
||||
)
|
||||
}
|
||||
disabled={mutating}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeBtn}
|
||||
disabled={mutating || modelsList.length <= 1}
|
||||
onClick={() =>
|
||||
updateField(
|
||||
'models',
|
||||
modelsList.filter((_, i) => i !== idx)
|
||||
)
|
||||
}
|
||||
{modelsList.map((entry, idx) =>
|
||||
supportsOpenAIModelOptions ? (
|
||||
<div key={idx} className={styles.entryCard}>
|
||||
<div className={styles.entryCardHeader}>
|
||||
<span>{t('providersPage.form.modelEntry', { index: idx + 1 })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeBtn}
|
||||
disabled={mutating || modelsList.length <= 1}
|
||||
onClick={() => removeModelEntry(idx)}
|
||||
>
|
||||
<IconX size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<input
|
||||
className={styles.input}
|
||||
placeholder="model-name"
|
||||
value={entry.name}
|
||||
onChange={(e) => updateModelEntry(idx, { name: e.target.value })}
|
||||
disabled={mutating}
|
||||
/>
|
||||
<input
|
||||
className={styles.input}
|
||||
placeholder="alias (optional)"
|
||||
value={entry.alias ?? ''}
|
||||
onChange={(e) => updateModelEntry(idx, { alias: e.target.value })}
|
||||
disabled={mutating}
|
||||
/>
|
||||
</div>
|
||||
<label className={styles.checkboxRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={styles.checkboxBox}
|
||||
checked={entry.image === true}
|
||||
disabled={mutating}
|
||||
onChange={(e) => updateModelEntry(idx, { image: e.target.checked })}
|
||||
/>
|
||||
<span className={styles.checkboxText}>
|
||||
<span>{t('providersPage.form.modelImage')}</span>
|
||||
<small>{t('providersPage.form.modelImageHint')}</small>
|
||||
</span>
|
||||
</label>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>
|
||||
{t('providersPage.form.thinkingConfig')}
|
||||
<span className={styles.labelHint}>
|
||||
{' '}
|
||||
· {t('providersPage.form.thinkingConfigHint')}
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
rows={4}
|
||||
value={entry.thinkingJson ?? ''}
|
||||
onChange={(e) => updateModelEntry(idx, { thinkingJson: e.target.value })}
|
||||
disabled={mutating}
|
||||
placeholder={'{"levels":["low","medium","high"]}'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key={idx}
|
||||
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 8 }}
|
||||
>
|
||||
<IconX size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<input
|
||||
className={styles.input}
|
||||
placeholder="model-name"
|
||||
value={entry.name}
|
||||
onChange={(e) => updateModelEntry(idx, { name: e.target.value })}
|
||||
disabled={mutating}
|
||||
/>
|
||||
<input
|
||||
className={styles.input}
|
||||
placeholder="alias (optional)"
|
||||
value={entry.alias ?? ''}
|
||||
onChange={(e) => updateModelEntry(idx, { alias: e.target.value })}
|
||||
disabled={mutating}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeBtn}
|
||||
disabled={mutating || modelsList.length <= 1}
|
||||
onClick={() => removeModelEntry(idx)}
|
||||
>
|
||||
<IconX size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addBtn}
|
||||
@@ -1004,6 +1115,32 @@ export function BaseProviderForm({
|
||||
<span>{t('providersPage.form.cloakStrict')}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className={styles.checkboxRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={styles.checkboxBox}
|
||||
checked={form.cloak.cacheUserId}
|
||||
disabled={mutating}
|
||||
onChange={(e) => updateCloak('cacheUserId', e.target.checked)}
|
||||
/>
|
||||
<span className={styles.checkboxText}>
|
||||
<span>{t('providersPage.form.cloakCacheUserId')}</span>
|
||||
<small>{t('providersPage.form.cloakCacheUserIdHint')}</small>
|
||||
</span>
|
||||
</label>
|
||||
<label className={styles.checkboxRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={styles.checkboxBox}
|
||||
checked={form.experimentalCchSigning ?? false}
|
||||
disabled={mutating}
|
||||
onChange={(e) => updateField('experimentalCchSigning', e.target.checked)}
|
||||
/>
|
||||
<span className={styles.checkboxText}>
|
||||
<span>{t('providersPage.form.experimentalCchSigning')}</span>
|
||||
<small>{t('providersPage.form.experimentalCchSigningHint')}</small>
|
||||
</span>
|
||||
</label>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>{t('providersPage.form.cloakSensitiveWords')}</label>
|
||||
<textarea
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -87,6 +84,8 @@ export interface ModelEntryInput {
|
||||
alias?: string;
|
||||
priority?: number;
|
||||
testModel?: string;
|
||||
image?: boolean;
|
||||
thinkingJson?: string;
|
||||
}
|
||||
|
||||
export interface ApiKeyEntryInput {
|
||||
@@ -100,6 +99,7 @@ export interface CloakInput {
|
||||
mode: string;
|
||||
strictMode: boolean;
|
||||
sensitiveWordsText: string;
|
||||
cacheUserId: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderEntryFormInput {
|
||||
@@ -111,6 +111,7 @@ export interface ProviderEntryFormInput {
|
||||
proxyUrl: string;
|
||||
prefix: string;
|
||||
disabled: boolean;
|
||||
disableCooling?: boolean;
|
||||
priority?: number;
|
||||
|
||||
/** 高级折叠区 */
|
||||
@@ -122,6 +123,7 @@ export interface ProviderEntryFormInput {
|
||||
websockets?: boolean;
|
||||
/** Claude 专属 */
|
||||
cloak?: CloakInput;
|
||||
experimentalCchSigning?: boolean;
|
||||
/** OpenAI persists this; Gemini/Claude use it for one-off connectivity tests. */
|
||||
testModel?: string;
|
||||
apiKeyEntries?: ApiKeyEntryInput[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -69,6 +66,16 @@ const headersFromEntries = (
|
||||
return out;
|
||||
};
|
||||
|
||||
const parseThinkingJson = (value: string | undefined): Record<string, unknown> | undefined => {
|
||||
const trimmed = (value ?? '').trim();
|
||||
if (!trimmed) return undefined;
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('Thinking config must be a JSON object');
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const buildExcludedModels = (
|
||||
textValue: string,
|
||||
disabled: boolean,
|
||||
@@ -110,6 +117,7 @@ const buildProviderKeyConfig = (
|
||||
models: models.length ? models : undefined,
|
||||
headers: Object.keys(headers).length ? headers : undefined,
|
||||
excludedModels: excluded,
|
||||
disableCooling: input.disableCooling === true,
|
||||
authIndex: existing?.authIndex,
|
||||
};
|
||||
if (brand === 'codex' && input.websockets !== undefined) {
|
||||
@@ -120,8 +128,12 @@ const buildProviderKeyConfig = (
|
||||
mode: input.cloak.mode.trim() || undefined,
|
||||
strictMode: input.cloak.strictMode,
|
||||
sensitiveWords: parseTextList(input.cloak.sensitiveWordsText),
|
||||
cacheUserId: input.cloak.cacheUserId === true,
|
||||
};
|
||||
}
|
||||
if (brand === 'claude') {
|
||||
next.experimentalCchSigning = input.experimentalCchSigning === true;
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
@@ -136,6 +148,8 @@ const buildOpenAIConfig = (
|
||||
alias: m.alias?.trim() || undefined,
|
||||
priority: m.priority,
|
||||
testModel: m.testModel,
|
||||
image: m.image === true,
|
||||
thinking: parseThinkingJson(m.thinkingJson),
|
||||
}))
|
||||
.filter((m) => m.name);
|
||||
const apiKeyEntries =
|
||||
@@ -158,6 +172,7 @@ const buildOpenAIConfig = (
|
||||
prefix: input.prefix.trim() || undefined,
|
||||
apiKeyEntries,
|
||||
disabled: input.disabled,
|
||||
disableCooling: input.disableCooling === true,
|
||||
headers: Object.keys(headers).length ? headers : undefined,
|
||||
models: models.length ? models : undefined,
|
||||
priority: input.priority,
|
||||
@@ -190,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') {
|
||||
@@ -202,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 || []);
|
||||
}
|
||||
@@ -250,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,
|
||||
@@ -331,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 {
|
||||
@@ -381,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 {
|
||||
@@ -425,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 {
|
||||
@@ -481,8 +478,6 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
|
||||
list[idx] = { ...current, disabled };
|
||||
updateConfigValue('openai-compatibility', list);
|
||||
}
|
||||
} else if (brand === 'ampcode') {
|
||||
/* ampcode toggle 不支持,跳过 */
|
||||
}
|
||||
refreshSnapshot();
|
||||
} finally {
|
||||
@@ -500,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,
|
||||
@@ -554,7 +507,6 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
|
||||
updateProvider,
|
||||
deleteProvider,
|
||||
toggleDisabled,
|
||||
saveAmpcode,
|
||||
mutating,
|
||||
refreshSnapshot,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
@@ -157,6 +166,18 @@ function setDisableImageGenerationInDoc(
|
||||
if (docHas(doc, path)) doc.setIn(path, false);
|
||||
}
|
||||
|
||||
const PAYLOAD_DIRTY_FIELDS = [
|
||||
'payloadDefaultRules',
|
||||
'payloadDefaultRawRules',
|
||||
'payloadOverrideRules',
|
||||
'payloadOverrideRawRules',
|
||||
'payloadFilterRules',
|
||||
] as const;
|
||||
|
||||
function hasPayloadDirtyFields(dirtyFields: Set<string>): boolean {
|
||||
return PAYLOAD_DIRTY_FIELDS.some((field) => dirtyFields.has(field));
|
||||
}
|
||||
|
||||
function getNonNegativeIntegerError(value: string): 'non_negative_integer' | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
@@ -729,9 +750,11 @@ function getNextDirtyFields(
|
||||
'errorLogsMaxFiles',
|
||||
'usageStatisticsEnabled',
|
||||
'redisUsageQueueRetentionSeconds',
|
||||
'pluginsEnabled',
|
||||
'passthroughHeaders',
|
||||
'disableCooling',
|
||||
'disableImageGeneration',
|
||||
'gptImage2BaseModel',
|
||||
'authAutoRefreshWorkers',
|
||||
'enableGeminiCliEndpoint',
|
||||
'antigravitySignatureCacheEnabled',
|
||||
@@ -745,6 +768,7 @@ function getNextDirtyFields(
|
||||
'claudeHeaderStabilizeDeviceProfile',
|
||||
'codexHeaderUserAgent',
|
||||
'codexHeaderBetaFeatures',
|
||||
'codexIdentityConfuse',
|
||||
'host',
|
||||
'port',
|
||||
'tlsEnable',
|
||||
@@ -775,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',
|
||||
@@ -912,6 +943,8 @@ export function useVisualConfig() {
|
||||
const routing = asRecord(parsed.routing);
|
||||
const payload = asRecord(parsed.payload);
|
||||
const streaming = asRecord(parsed.streaming);
|
||||
const plugins = asRecord(parsed.plugins);
|
||||
const codex = asRecord(parsed.codex);
|
||||
const claudeHeaderDefaults = asRecord(parsed['claude-header-defaults']);
|
||||
const codexHeaderDefaults = asRecord(parsed['codex-header-defaults']);
|
||||
|
||||
@@ -939,6 +972,8 @@ 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']),
|
||||
@@ -958,6 +993,10 @@ export function useVisualConfig() {
|
||||
maxRetryInterval: String(parsed['max-retry-interval'] ?? ''),
|
||||
disableCooling: Boolean(parsed['disable-cooling']),
|
||||
disableImageGeneration: parseDisableImageGenerationMode(parsed['disable-image-generation']),
|
||||
gptImage2BaseModel:
|
||||
typeof parsed['gpt-image-2-base-model'] === 'string'
|
||||
? parsed['gpt-image-2-base-model']
|
||||
: '',
|
||||
authAutoRefreshWorkers: String(parsed['auth-auto-refresh-workers'] ?? ''),
|
||||
wsAuth: Boolean(parsed['ws-auth']),
|
||||
enableGeminiCliEndpoint: Boolean(parsed['enable-gemini-cli-endpoint']),
|
||||
@@ -994,6 +1033,7 @@ export function useVisualConfig() {
|
||||
typeof codexHeaderDefaults?.['beta-features'] === 'string'
|
||||
? codexHeaderDefaults['beta-features']
|
||||
: '',
|
||||
codexIdentityConfuse: Boolean(codex?.['identity-confuse']),
|
||||
|
||||
quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? true),
|
||||
quotaSwitchPreviewModel: Boolean(quotaExceeded?.['switch-preview-model'] ?? true),
|
||||
@@ -1100,6 +1140,34 @@ export function useVisualConfig() {
|
||||
}
|
||||
deleteLegacyApiKeysProvider(doc);
|
||||
|
||||
if (
|
||||
docHas(doc, ['plugins']) ||
|
||||
values.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']);
|
||||
}
|
||||
|
||||
setBooleanInDoc(doc, ['debug'], values.debug);
|
||||
|
||||
setBooleanInDoc(doc, ['commercial-mode'], values.commercialMode);
|
||||
@@ -1125,6 +1193,17 @@ export function useVisualConfig() {
|
||||
['disable-image-generation'],
|
||||
values.disableImageGeneration
|
||||
);
|
||||
if (
|
||||
values.gptImage2BaseModel.trim() ||
|
||||
shouldWriteManagedField(
|
||||
doc,
|
||||
['gpt-image-2-base-model'],
|
||||
dirtyFields,
|
||||
'gptImage2BaseModel'
|
||||
)
|
||||
) {
|
||||
setStringInDoc(doc, ['gpt-image-2-base-model'], values.gptImage2BaseModel);
|
||||
}
|
||||
setIntFromStringInDoc(doc, ['auth-auto-refresh-workers'], values.authAutoRefreshWorkers);
|
||||
setBooleanInDoc(doc, ['ws-auth'], values.wsAuth);
|
||||
setBooleanInDoc(doc, ['enable-gemini-cli-endpoint'], values.enableGeminiCliEndpoint);
|
||||
@@ -1195,6 +1274,21 @@ export function useVisualConfig() {
|
||||
deleteIfMapEmpty(doc, ['codex-header-defaults']);
|
||||
}
|
||||
|
||||
if (
|
||||
docHas(doc, ['codex']) ||
|
||||
values.codexIdentityConfuse ||
|
||||
shouldWriteManagedField(
|
||||
doc,
|
||||
['codex', 'identity-confuse'],
|
||||
dirtyFields,
|
||||
'codexIdentityConfuse'
|
||||
)
|
||||
) {
|
||||
ensureMapInDoc(doc, ['codex']);
|
||||
setBooleanInDoc(doc, ['codex', 'identity-confuse'], values.codexIdentityConfuse);
|
||||
deleteIfMapEmpty(doc, ['codex']);
|
||||
}
|
||||
|
||||
if (
|
||||
docHas(doc, ['quota-exceeded']) ||
|
||||
!values.quotaSwitchProject ||
|
||||
@@ -1262,14 +1356,7 @@ export function useVisualConfig() {
|
||||
|
||||
setIntFromStringInDoc(doc, ['nonstream-keepalive-interval'], nonstreamKeepaliveInterval);
|
||||
|
||||
if (
|
||||
docHas(doc, ['payload']) ||
|
||||
values.payloadDefaultRules.length > 0 ||
|
||||
values.payloadDefaultRawRules.length > 0 ||
|
||||
values.payloadOverrideRules.length > 0 ||
|
||||
values.payloadOverrideRawRules.length > 0 ||
|
||||
values.payloadFilterRules.length > 0
|
||||
) {
|
||||
if (hasPayloadDirtyFields(dirtyFields)) {
|
||||
ensureMapInDoc(doc, ['payload']);
|
||||
if (values.payloadDefaultRules.length > 0) {
|
||||
doc.setIn(
|
||||
|
||||
+59
-31
@@ -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",
|
||||
@@ -918,6 +918,13 @@
|
||||
"commercial_mode_desc": "Disable high-overhead middleware to support high concurrency",
|
||||
"logging_to_file": "Log to File",
|
||||
"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",
|
||||
@@ -942,7 +949,9 @@
|
||||
"timeout": "Timeout",
|
||||
"stabilize_device": "Stabilize Device Profile",
|
||||
"stabilize_device_desc": "Pin OS/Arch and stabilize the software fingerprint per credential/API key",
|
||||
"beta_features": "Beta Features"
|
||||
"beta_features": "Beta Features",
|
||||
"codex_identity_confuse": "Codex Identity Confuse",
|
||||
"codex_identity_confuse_desc": "When fill-first routing or session affinity is used, remap Codex prompt_cache_key and installation identity for the selected auth"
|
||||
},
|
||||
"network": {
|
||||
"title": "Network Configuration",
|
||||
@@ -959,6 +968,8 @@
|
||||
"disable_image_generation_false": "false (enabled)",
|
||||
"disable_image_generation_true": "true (disabled everywhere)",
|
||||
"disable_image_generation_chat": "chat (disable chat injection only)",
|
||||
"gpt_image_2_base_model": "GPT Image 2 Base Model",
|
||||
"gpt_image_2_base_model_hint": "Mainline model used when proxying gpt-image-2 image requests; backend uses its default when empty or invalid",
|
||||
"routing_strategy": "Routing Strategy",
|
||||
"routing_strategy_hint": "Select credential selection strategy",
|
||||
"strategy_round_robin": "Round Robin",
|
||||
@@ -1105,6 +1116,8 @@
|
||||
"description": "Review discovered and registered plugins, then manage instance toggles, config fields, and resource links.",
|
||||
"refresh": "Refresh",
|
||||
"load_failed": "Failed to load plugins",
|
||||
"config_load_failed": "Failed to read plugin config",
|
||||
"config_not_found": "Unable to read plugin config because the backend could not find this plugin.",
|
||||
"unsupported_backend": "The current backend does not expose the plugin management API. Use a newer backend build that includes plugin management endpoints, then restart the service.",
|
||||
"global_status": "Global status",
|
||||
"global_enabled": "Enabled",
|
||||
@@ -1141,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",
|
||||
@@ -1157,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",
|
||||
@@ -1198,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",
|
||||
@@ -1293,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",
|
||||
@@ -1369,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",
|
||||
@@ -1391,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}}",
|
||||
@@ -1462,31 +1495,26 @@
|
||||
"addApiKeyEntry": "Add key entry",
|
||||
"showApiKey": "Show API key",
|
||||
"hideApiKey": "Hide API key",
|
||||
"disableCooling": "Disable cooling",
|
||||
"disableCoolingHint": "Disable failure cooldown windows only for this credential or provider",
|
||||
"modelEntry": "Model #{{index}}",
|
||||
"modelImage": "Allow image endpoints",
|
||||
"modelImageHint": "Allow this model on /v1/images/generations and /v1/images/edits",
|
||||
"thinkingConfig": "Thinking config (JSON)",
|
||||
"thinkingConfigHint": "Supports levels, min, max, zero_allowed, dynamic_allowed",
|
||||
"cloakCacheUserId": "Cache user_id",
|
||||
"cloakCacheUserIdHint": "Reuse the Claude cloak user_id per API key",
|
||||
"experimentalCchSigning": "Experimental CCH signing",
|
||||
"experimentalCchSigningHint": "Sign the final cloaked Claude /v1/messages body with CCH",
|
||||
"validation": {
|
||||
"nameRequired": "Name is required",
|
||||
"apiKeyRequired": "At least one API key is required",
|
||||
"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",
|
||||
|
||||
+58
-30
@@ -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": "Стратегия маршрутизации",
|
||||
@@ -905,6 +905,13 @@
|
||||
"commercial_mode_desc": "Отключить тяжёлое промежуточное ПО для поддержки высокой нагрузки",
|
||||
"logging_to_file": "Журналировать в файл",
|
||||
"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": "Включить статистику использования в памяти",
|
||||
@@ -929,7 +936,9 @@
|
||||
"timeout": "Timeout",
|
||||
"stabilize_device": "Стабилизировать профиль устройства",
|
||||
"stabilize_device_desc": "Фиксировать OS/Arch и стабилизировать программный отпечаток для учётных данных/API-ключа",
|
||||
"beta_features": "Beta Features"
|
||||
"beta_features": "Beta Features",
|
||||
"codex_identity_confuse": "Codex identity-confuse",
|
||||
"codex_identity_confuse_desc": "При fill-first routing или session affinity переопределяет Codex prompt_cache_key и installation identity под выбранную auth-запись"
|
||||
},
|
||||
"network": {
|
||||
"title": "Сетевые настройки",
|
||||
@@ -946,6 +955,8 @@
|
||||
"disable_image_generation_false": "false (включено)",
|
||||
"disable_image_generation_true": "true (отключено везде)",
|
||||
"disable_image_generation_chat": "chat (только отключить chat-инъекцию)",
|
||||
"gpt_image_2_base_model": "Базовая модель GPT Image 2",
|
||||
"gpt_image_2_base_model_hint": "Основная модель для проксирования gpt-image-2 image-запросов; если пусто или неверно, backend использует значение по умолчанию",
|
||||
"routing_strategy": "Стратегия маршрутизации",
|
||||
"routing_strategy_hint": "Выберите стратегию подбора учётных данных",
|
||||
"strategy_round_robin": "По кругу",
|
||||
@@ -1092,6 +1103,8 @@
|
||||
"description": "Просматривайте найденные и зарегистрированные плагины, управляйте переключателями экземпляров, полями конфигурации и ссылками на ресурсы.",
|
||||
"refresh": "Обновить",
|
||||
"load_failed": "Не удалось загрузить плагины",
|
||||
"config_load_failed": "Не удалось прочитать конфигурацию плагина",
|
||||
"config_not_found": "Не удалось прочитать конфигурацию плагина: backend не нашёл этот плагин.",
|
||||
"unsupported_backend": "Текущий backend не предоставляет API управления плагинами. Используйте более новую сборку backend с эндпоинтами управления плагинами и перезапустите сервис.",
|
||||
"global_status": "Глобальный статус",
|
||||
"global_enabled": "Включено",
|
||||
@@ -1128,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": "Конфигурация плагина сохранена",
|
||||
@@ -1144,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": "Не удалось загрузить магазин плагинов",
|
||||
@@ -1185,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": "Информация о центре управления",
|
||||
@@ -1270,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-ключ перед тестированием",
|
||||
@@ -1346,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": "Ключ",
|
||||
@@ -1368,12 +1403,10 @@
|
||||
"metrics": {
|
||||
"models": "Модели",
|
||||
"keys": "Ключи",
|
||||
"headers": "Заголовки",
|
||||
"mappings": "Сопоставления"
|
||||
"headers": "Заголовки"
|
||||
},
|
||||
"websocketsTag": "WebSockets",
|
||||
"cloakTag": "Cloak",
|
||||
"noFallbackKey": "Резервный ключ не задан",
|
||||
"empty": "Нет ресурсов, нажмите \"Создать\".",
|
||||
"filterPlaceholder": "Поиск по ключам, URL, префиксам…",
|
||||
"description": "Управление ресурсами {{route}}",
|
||||
@@ -1439,31 +1472,26 @@
|
||||
"addApiKeyEntry": "Добавить ключ",
|
||||
"showApiKey": "Показать ключ API",
|
||||
"hideApiKey": "Скрыть ключ API",
|
||||
"disableCooling": "Отключить cooldown",
|
||||
"disableCoolingHint": "Отключает окна охлаждения после ошибок только для этой записи",
|
||||
"modelEntry": "Модель #{{index}}",
|
||||
"modelImage": "Разрешить image endpoints",
|
||||
"modelImageHint": "Разрешить модель для /v1/images/generations и /v1/images/edits",
|
||||
"thinkingConfig": "Thinking config (JSON)",
|
||||
"thinkingConfigHint": "Поддерживает levels, min, max, zero_allowed, dynamic_allowed",
|
||||
"cloakCacheUserId": "Кэшировать user_id",
|
||||
"cloakCacheUserIdHint": "Переиспользовать Claude cloak user_id для каждого API-ключа",
|
||||
"experimentalCchSigning": "Экспериментальная CCH-подпись",
|
||||
"experimentalCchSigningHint": "Подписывать финальное тело cloaked Claude /v1/messages через CCH",
|
||||
"validation": {
|
||||
"nameRequired": "Название обязательно",
|
||||
"apiKeyRequired": "Нужен хотя бы один API-ключ",
|
||||
"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": "Создано",
|
||||
|
||||
+59
-31
@@ -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 额度",
|
||||
@@ -918,6 +918,13 @@
|
||||
"commercial_mode_desc": "禁用高开销中间件以支持高并发",
|
||||
"logging_to_file": "写入日志文件",
|
||||
"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": "启用内存用量统计",
|
||||
@@ -942,7 +949,9 @@
|
||||
"timeout": "Timeout",
|
||||
"stabilize_device": "稳定设备指纹",
|
||||
"stabilize_device_desc": "固定 OS/Arch,并按凭据/API Key 稳定软件指纹",
|
||||
"beta_features": "Beta Features"
|
||||
"beta_features": "Beta Features",
|
||||
"codex_identity_confuse": "Codex 身份混淆",
|
||||
"codex_identity_confuse_desc": "在 fill-first 或会话粘性路由下,按所选认证重映射 Codex prompt_cache_key 与安装身份"
|
||||
},
|
||||
"network": {
|
||||
"title": "网络配置",
|
||||
@@ -959,6 +968,8 @@
|
||||
"disable_image_generation_false": "false(启用)",
|
||||
"disable_image_generation_true": "true(全部禁用)",
|
||||
"disable_image_generation_chat": "chat(仅禁用聊天注入)",
|
||||
"gpt_image_2_base_model": "GPT Image 2 基础模型",
|
||||
"gpt_image_2_base_model_hint": "代理 gpt-image-2 图片请求时使用的主线模型;留空或非法时后端使用默认值",
|
||||
"routing_strategy": "路由策略",
|
||||
"routing_strategy_hint": "选择凭据选择策略",
|
||||
"strategy_round_robin": "轮询 (Round Robin)",
|
||||
@@ -1105,6 +1116,8 @@
|
||||
"description": "查看已发现和已注册的插件,管理实例开关、配置字段和资源入口。",
|
||||
"refresh": "刷新",
|
||||
"load_failed": "加载插件失败",
|
||||
"config_load_failed": "读取插件配置失败",
|
||||
"config_not_found": "无法读取插件配置,后端未找到该插件。",
|
||||
"unsupported_backend": "当前后端未暴露插件管理 API。请使用包含插件管理接口的新后端构建,并重启服务。",
|
||||
"global_status": "全局状态",
|
||||
"global_enabled": "已启用",
|
||||
@@ -1141,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": "插件配置已保存",
|
||||
@@ -1157,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": "插件商店加载失败",
|
||||
@@ -1198,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": "插件页面",
|
||||
@@ -1293,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 密钥以进行测试",
|
||||
@@ -1369,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": "密钥",
|
||||
@@ -1391,12 +1426,10 @@
|
||||
"metrics": {
|
||||
"models": "模型",
|
||||
"keys": "密钥",
|
||||
"headers": "请求头",
|
||||
"mappings": "映射"
|
||||
"headers": "请求头"
|
||||
},
|
||||
"websocketsTag": "WebSockets",
|
||||
"cloakTag": "Cloak",
|
||||
"noFallbackKey": "未设置兜底密钥",
|
||||
"empty": "尚未添加配置,点击右上角新建。",
|
||||
"filterPlaceholder": "搜索密钥、地址、前缀…",
|
||||
"description": "在 {{route}} 下管理资源",
|
||||
@@ -1462,31 +1495,26 @@
|
||||
"addApiKeyEntry": "添加密钥条目",
|
||||
"showApiKey": "显示密钥",
|
||||
"hideApiKey": "隐藏密钥",
|
||||
"disableCooling": "禁用冷却调度",
|
||||
"disableCoolingHint": "仅对当前凭据或提供商禁用失败后的冷却窗口",
|
||||
"modelEntry": "模型 #{{index}}",
|
||||
"modelImage": "允许图片端点",
|
||||
"modelImageHint": "允许该模型用于 /v1/images/generations 和 /v1/images/edits",
|
||||
"thinkingConfig": "Thinking 配置(JSON)",
|
||||
"thinkingConfigHint": "可配置 levels、min、max、zero_allowed、dynamic_allowed",
|
||||
"cloakCacheUserId": "缓存 user_id",
|
||||
"cloakCacheUserIdHint": "按 API key 复用 Claude cloak 生成的 user_id",
|
||||
"experimentalCchSigning": "实验性 CCH 签名",
|
||||
"experimentalCchSigningHint": "对 cloaked Claude /v1/messages 最终请求体启用 CCH 签名",
|
||||
"validation": {
|
||||
"nameRequired": "名称必填",
|
||||
"apiKeyRequired": "至少填写一个 API 密钥",
|
||||
"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": "创建成功",
|
||||
|
||||
+59
-31
@@ -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 配額",
|
||||
@@ -944,6 +944,13 @@
|
||||
"commercial_mode_desc": "停用高開銷中介軟體以支援高並行",
|
||||
"logging_to_file": "寫入記錄檔",
|
||||
"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": "啟用記憶體用量統計",
|
||||
@@ -968,7 +975,9 @@
|
||||
"timeout": "Timeout",
|
||||
"stabilize_device": "穩定設備指紋",
|
||||
"stabilize_device_desc": "固定 OS/Arch,並按憑證/API Key 穩定軟體指紋",
|
||||
"beta_features": "Beta Features"
|
||||
"beta_features": "Beta Features",
|
||||
"codex_identity_confuse": "Codex 身分混淆",
|
||||
"codex_identity_confuse_desc": "在 fill-first 或會話黏性路由下,按所選驗證重映射 Codex prompt_cache_key 與安裝身分"
|
||||
},
|
||||
"network": {
|
||||
"title": "網路設定",
|
||||
@@ -985,6 +994,8 @@
|
||||
"disable_image_generation_false": "false(啟用)",
|
||||
"disable_image_generation_true": "true(全部停用)",
|
||||
"disable_image_generation_chat": "chat(僅停用聊天注入)",
|
||||
"gpt_image_2_base_model": "GPT Image 2 基礎模型",
|
||||
"gpt_image_2_base_model_hint": "代理 gpt-image-2 圖片請求時使用的主線模型;留空或非法時後端使用預設值",
|
||||
"routing_strategy": "路由策略",
|
||||
"routing_strategy_hint": "選擇憑證選擇策略",
|
||||
"strategy_round_robin": "輪詢(Round Robin)",
|
||||
@@ -1131,6 +1142,8 @@
|
||||
"description": "查看已發現和已註冊的插件,管理實例開關、設定欄位和資源入口。",
|
||||
"refresh": "重新整理",
|
||||
"load_failed": "載入插件失敗",
|
||||
"config_load_failed": "讀取插件設定失敗",
|
||||
"config_not_found": "無法讀取插件設定,後端未找到該插件。",
|
||||
"unsupported_backend": "目前後端未暴露插件管理 API。請使用包含插件管理介面的新版後端建置,並重新啟動服務。",
|
||||
"global_status": "全域狀態",
|
||||
"global_enabled": "已啟用",
|
||||
@@ -1167,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": "插件設定已儲存",
|
||||
@@ -1183,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": "插件商店載入失敗",
|
||||
@@ -1224,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": "插件頁面",
|
||||
@@ -1319,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 金鑰以進行測試",
|
||||
@@ -1395,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": "金鑰",
|
||||
@@ -1417,12 +1452,10 @@
|
||||
"metrics": {
|
||||
"models": "模型",
|
||||
"keys": "金鑰",
|
||||
"headers": "請求標頭",
|
||||
"mappings": "映射"
|
||||
"headers": "請求標頭"
|
||||
},
|
||||
"websocketsTag": "WebSockets",
|
||||
"cloakTag": "Cloak",
|
||||
"noFallbackKey": "未設定備援金鑰",
|
||||
"empty": "尚未新增設定,點擊右上角新增。",
|
||||
"filterPlaceholder": "搜尋金鑰、位址、前綴…",
|
||||
"description": "在 {{route}} 下管理資源",
|
||||
@@ -1488,31 +1521,26 @@
|
||||
"addApiKeyEntry": "新增金鑰條目",
|
||||
"showApiKey": "顯示金鑰",
|
||||
"hideApiKey": "隱藏金鑰",
|
||||
"disableCooling": "停用冷卻調度",
|
||||
"disableCoolingHint": "僅對目前憑證或提供商停用失敗後的冷卻視窗",
|
||||
"modelEntry": "模型 #{{index}}",
|
||||
"modelImage": "允許圖片端點",
|
||||
"modelImageHint": "允許該模型用於 /v1/images/generations 和 /v1/images/edits",
|
||||
"thinkingConfig": "Thinking 設定(JSON)",
|
||||
"thinkingConfigHint": "可設定 levels、min、max、zero_allowed、dynamic_allowed",
|
||||
"cloakCacheUserId": "快取 user_id",
|
||||
"cloakCacheUserIdHint": "按 API key 複用 Claude cloak 產生的 user_id",
|
||||
"experimentalCchSigning": "實驗性 CCH 簽名",
|
||||
"experimentalCchSigningHint": "對 cloaked Claude /v1/messages 最終請求體啟用 CCH 簽名",
|
||||
"validation": {
|
||||
"nameRequired": "名稱必填",
|
||||
"apiKeyRequired": "至少填寫一個 API 金鑰",
|
||||
"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": "建立成功",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -12,8 +12,9 @@ import { PluginStorePage } from '@/features/plugins/PluginStorePage';
|
||||
import { ConfigPage } from '@/pages/ConfigPage';
|
||||
import { LogsPage } from '@/pages/LogsPage';
|
||||
import { SystemPage } from '@/pages/SystemPage';
|
||||
import { useAuthStore } from '@/stores';
|
||||
|
||||
const mainRoutes = [
|
||||
const createMainRoutes = (supportsPlugin: boolean) => [
|
||||
{ path: '/', element: <DashboardPage /> },
|
||||
{ path: '/dashboard', element: <DashboardPage /> },
|
||||
{ path: '/settings', element: <Navigate to="/config" replace /> },
|
||||
@@ -25,10 +26,18 @@ const mainRoutes = [
|
||||
{ path: '/auth-files/oauth-model-alias', element: <AuthFilesOAuthModelAliasEditPage /> },
|
||||
{ path: '/oauth', element: <OAuthPage /> },
|
||||
{ path: '/quota', element: <QuotaPage /> },
|
||||
{ path: '/plugin-pages/:pluginId/:menuIndex', element: <PluginResourcePage /> },
|
||||
{ path: '/plugins', element: <PluginsPage /> },
|
||||
{ path: '/plugin-store', element: <PluginStorePage /> },
|
||||
{ path: '/plugins/*', element: <Navigate to="/plugins" replace /> },
|
||||
...(supportsPlugin
|
||||
? [
|
||||
{ path: '/plugin-pages/:pluginId/:menuIndex', element: <PluginResourcePage /> },
|
||||
{ path: '/plugins', element: <PluginsPage /> },
|
||||
{ path: '/plugin-store', element: <PluginStorePage /> },
|
||||
{ path: '/plugins/*', element: <Navigate to="/plugins" replace /> },
|
||||
]
|
||||
: [
|
||||
{ path: '/plugin-pages/*', element: <Navigate to="/" replace /> },
|
||||
{ path: '/plugins/*', element: <Navigate to="/" replace /> },
|
||||
{ path: '/plugin-store', element: <Navigate to="/" replace /> },
|
||||
]),
|
||||
{ path: '/config', element: <ConfigPage /> },
|
||||
{ path: '/logs', element: <LogsPage /> },
|
||||
{ path: '/system', element: <SystemPage /> },
|
||||
@@ -36,5 +45,6 @@ const mainRoutes = [
|
||||
];
|
||||
|
||||
export function MainRoutes({ location }: { location?: Location }) {
|
||||
return useRoutes(mainRoutes, location);
|
||||
const supportsPlugin = useAuthStore((state) => state.supportsPlugin);
|
||||
return useRoutes(createMainRoutes(supportsPlugin), location);
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import type { ApiClientConfig, ApiError } from '@/types';
|
||||
import {
|
||||
BUILD_DATE_HEADER_KEYS,
|
||||
CPA_BUILD_DATE_HEADER_KEYS,
|
||||
CPA_SUPPORT_PLUGIN_HEADER_KEYS,
|
||||
CPA_VERSION_HEADER_KEYS,
|
||||
HOME_BUILD_DATE_HEADER_KEYS,
|
||||
HOME_VERSION_HEADER_KEYS,
|
||||
@@ -87,6 +88,19 @@ class ApiClient {
|
||||
return null;
|
||||
}
|
||||
|
||||
private readBooleanHeader(
|
||||
headers: Record<string, unknown> | undefined,
|
||||
keys: string[]
|
||||
): boolean | null {
|
||||
const value = this.readHeader(headers, keys);
|
||||
if (value === null) return null;
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置请求/响应拦截器
|
||||
*/
|
||||
@@ -122,6 +136,7 @@ class ApiClient {
|
||||
const version = homeVersion || cpaVersion || this.readHeader(headers, VERSION_HEADER_KEYS);
|
||||
const buildDate =
|
||||
homeBuildDate || cpaBuildDate || this.readHeader(headers, BUILD_DATE_HEADER_KEYS);
|
||||
const supportsPlugin = this.readBooleanHeader(headers, CPA_SUPPORT_PLUGIN_HEADER_KEYS);
|
||||
const runtimeKind: ServerRuntimeKind | null =
|
||||
homeVersion || homeBuildDate ? 'home' : cpaVersion || cpaBuildDate ? 'cpa' : null;
|
||||
|
||||
@@ -133,6 +148,13 @@ class ApiClient {
|
||||
})
|
||||
);
|
||||
}
|
||||
if (supportsPlugin !== null) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('server-plugin-support-update', {
|
||||
detail: { supportsPlugin }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { apiClient } from './client';
|
||||
import { isRecord } from '@/utils/helpers';
|
||||
import type {
|
||||
PluginConfigField,
|
||||
PluginConfigObject,
|
||||
PluginDeleteResult,
|
||||
PluginListEntry,
|
||||
PluginListResponse,
|
||||
PluginMetadata,
|
||||
@@ -9,6 +11,7 @@ import type {
|
||||
PluginStoreEntry,
|
||||
PluginStoreInstallResult,
|
||||
PluginStoreResponse,
|
||||
PluginStoreSource,
|
||||
} from '@/types';
|
||||
|
||||
const asString = (value: unknown): string => {
|
||||
@@ -114,16 +117,37 @@ 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(),
|
||||
@@ -145,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,
|
||||
};
|
||||
};
|
||||
@@ -162,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(),
|
||||
@@ -179,10 +222,20 @@ export const pluginsApi = {
|
||||
updateEnabled: (id: string, enabled: boolean) =>
|
||||
apiClient.patch(`/plugins/${encodeURIComponent(id)}/enabled`, { enabled }),
|
||||
|
||||
putConfig: (id: string, config: Record<string, unknown>) =>
|
||||
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);
|
||||
},
|
||||
|
||||
putConfig: (id: string, config: PluginConfigObject) =>
|
||||
apiClient.put(`/plugins/${encodeURIComponent(id)}/config`, config),
|
||||
|
||||
patchConfig: (id: string, patch: Record<string, unknown>) =>
|
||||
patchConfig: (id: string, patch: PluginConfigObject) =>
|
||||
apiClient.patch(`/plugins/${encodeURIComponent(id)}/config`, patch),
|
||||
};
|
||||
|
||||
@@ -192,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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,23 +22,35 @@ const serializeHeaders = (headers?: Record<string, string>) =>
|
||||
|
||||
const RESPONSE_ONLY_FIELDS = ['auth-index'] as const;
|
||||
|
||||
const PROVIDER_KEY_FIELDS = [
|
||||
const PROVIDER_COMMON_KEY_FIELDS = [
|
||||
'api-key',
|
||||
'priority',
|
||||
'prefix',
|
||||
'base-url',
|
||||
'websockets',
|
||||
'proxy-url',
|
||||
'headers',
|
||||
'models',
|
||||
'excluded-models',
|
||||
'cloak',
|
||||
'disable-cooling',
|
||||
] as const;
|
||||
|
||||
const GEMINI_KEY_FIELDS = PROVIDER_KEY_FIELDS.filter(
|
||||
(field) => field !== 'websockets' && field !== 'cloak'
|
||||
);
|
||||
const VERTEX_KEY_FIELDS = GEMINI_KEY_FIELDS;
|
||||
const GEMINI_KEY_FIELDS = PROVIDER_COMMON_KEY_FIELDS;
|
||||
const CODEX_KEY_FIELDS = [...PROVIDER_COMMON_KEY_FIELDS, 'websockets'] as const;
|
||||
const CLAUDE_KEY_FIELDS = [
|
||||
...PROVIDER_COMMON_KEY_FIELDS,
|
||||
'cloak',
|
||||
'experimental-cch-signing',
|
||||
] as const;
|
||||
const VERTEX_KEY_FIELDS = [
|
||||
'api-key',
|
||||
'priority',
|
||||
'prefix',
|
||||
'base-url',
|
||||
'proxy-url',
|
||||
'headers',
|
||||
'models',
|
||||
'excluded-models',
|
||||
] as const;
|
||||
|
||||
const OPENAI_PROVIDER_FIELDS = [
|
||||
'name',
|
||||
@@ -50,13 +62,15 @@ const OPENAI_PROVIDER_FIELDS = [
|
||||
'headers',
|
||||
'models',
|
||||
'test-model',
|
||||
'disable-cooling',
|
||||
] as const;
|
||||
|
||||
const MODEL_ALIAS_FIELDS = ['name', 'alias', 'priority', 'test-model'] as const;
|
||||
const OPENAI_MODEL_ALIAS_FIELDS = [...MODEL_ALIAS_FIELDS, 'image', 'thinking'] as const;
|
||||
|
||||
const API_KEY_ENTRY_FIELDS = ['api-key', 'proxy-url'] as const;
|
||||
|
||||
const CLOAK_FIELDS = ['mode', 'strict-mode', 'sensitive-words'] as const;
|
||||
const CLOAK_FIELDS = ['mode', 'strict-mode', 'sensitive-words', 'cache-user-id'] as const;
|
||||
|
||||
const getStringField = (record: Record<string, unknown>, keys: readonly string[]) => {
|
||||
for (const key of keys) {
|
||||
@@ -170,12 +184,16 @@ const getRawSectionList = (rawConfig: unknown, section: string): unknown[] => {
|
||||
return Array.isArray(value) ? value : [];
|
||||
};
|
||||
|
||||
const mergeModelPayloads = (raw: unknown, models: unknown) =>
|
||||
const mergeModelPayloads = (
|
||||
raw: unknown,
|
||||
models: unknown,
|
||||
knownFields: readonly string[] = MODEL_ALIAS_FIELDS
|
||||
) =>
|
||||
Array.isArray(models)
|
||||
? mergeKnownRecordList(
|
||||
isRecord(raw) ? raw.models : undefined,
|
||||
models.filter(isRecord),
|
||||
MODEL_ALIAS_FIELDS,
|
||||
knownFields,
|
||||
modelIdentity,
|
||||
false
|
||||
)
|
||||
@@ -211,7 +229,7 @@ const mergeOpenAIProviderPayload = (raw: unknown, payload: Record<string, unknow
|
||||
apiKeyEntryIdentity
|
||||
);
|
||||
}
|
||||
const models = mergeModelPayloads(raw, payload.models);
|
||||
const models = mergeModelPayloads(raw, payload.models, OPENAI_MODEL_ALIAS_FIELDS);
|
||||
if (models) next.models = models;
|
||||
return next;
|
||||
};
|
||||
@@ -252,7 +270,7 @@ const buildProviderDeleteQuery = (apiKey: string, baseUrl?: string) => {
|
||||
return `?${params.toString()}`;
|
||||
};
|
||||
|
||||
const serializeModelAliases = (models?: ModelAlias[]) =>
|
||||
const serializeModelAliases = (models?: ModelAlias[], includeOpenAIFields = false) =>
|
||||
Array.isArray(models)
|
||||
? models
|
||||
.map((model) => {
|
||||
@@ -267,6 +285,14 @@ const serializeModelAliases = (models?: ModelAlias[]) =>
|
||||
if (model.testModel) {
|
||||
payload['test-model'] = model.testModel;
|
||||
}
|
||||
if (includeOpenAIFields) {
|
||||
if (model.image) {
|
||||
payload.image = true;
|
||||
}
|
||||
if (model.thinking) {
|
||||
payload.thinking = model.thinking;
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
})
|
||||
.filter(Boolean)
|
||||
@@ -285,6 +311,7 @@ const serializeProviderKey = (config: ProviderKeyConfig) => {
|
||||
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
||||
if (config.websockets !== undefined) payload.websockets = config.websockets;
|
||||
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
|
||||
if (config.disableCooling) payload['disable-cooling'] = true;
|
||||
const headers = serializeHeaders(config.headers);
|
||||
if (headers) payload.headers = headers;
|
||||
const models = serializeModelAliases(config.models);
|
||||
@@ -301,10 +328,16 @@ const serializeProviderKey = (config: ProviderKeyConfig) => {
|
||||
if (config.cloak.sensitiveWords && config.cloak.sensitiveWords.length) {
|
||||
cloakPayload['sensitive-words'] = config.cloak.sensitiveWords;
|
||||
}
|
||||
if (config.cloak.cacheUserId) {
|
||||
cloakPayload['cache-user-id'] = true;
|
||||
}
|
||||
if (Object.keys(cloakPayload).length) {
|
||||
payload.cloak = cloakPayload;
|
||||
}
|
||||
}
|
||||
if (config.experimentalCchSigning) {
|
||||
payload['experimental-cch-signing'] = true;
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
@@ -342,6 +375,7 @@ const serializeGeminiKey = (config: GeminiKeyConfig) => {
|
||||
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
|
||||
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
||||
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
|
||||
if (config.disableCooling) payload['disable-cooling'] = true;
|
||||
const headers = serializeHeaders(config.headers);
|
||||
if (headers) payload.headers = headers;
|
||||
const models = serializeModelAliases(config.models);
|
||||
@@ -364,10 +398,11 @@ const serializeOpenAIProvider = (provider: OpenAIProviderConfig) => {
|
||||
if (provider.disabled !== undefined) payload.disabled = provider.disabled;
|
||||
const headers = serializeHeaders(provider.headers);
|
||||
if (headers) payload.headers = headers;
|
||||
const models = serializeModelAliases(provider.models);
|
||||
const models = serializeModelAliases(provider.models, true);
|
||||
if (models && models.length) payload.models = models;
|
||||
if (provider.priority !== undefined) payload.priority = provider.priority;
|
||||
if (provider.testModel) payload['test-model'] = provider.testModel;
|
||||
if (provider.disableCooling) payload['disable-cooling'] = true;
|
||||
return payload;
|
||||
};
|
||||
|
||||
@@ -408,7 +443,7 @@ export const providersApi = {
|
||||
'codex-api-key',
|
||||
configs,
|
||||
serializeProviderKey,
|
||||
(raw, payload) => mergeProviderKeyPayload(raw, payload, PROVIDER_KEY_FIELDS),
|
||||
(raw, payload) => mergeProviderKeyPayload(raw, payload, CODEX_KEY_FIELDS),
|
||||
providerKeyIdentity
|
||||
)
|
||||
),
|
||||
@@ -431,7 +466,7 @@ export const providersApi = {
|
||||
'claude-api-key',
|
||||
configs,
|
||||
serializeProviderKey,
|
||||
(raw, payload) => mergeProviderKeyPayload(raw, payload, PROVIDER_KEY_FIELDS),
|
||||
(raw, payload) => mergeProviderKeyPayload(raw, payload, CLAUDE_KEY_FIELDS),
|
||||
providerKeyIdentity
|
||||
)
|
||||
),
|
||||
|
||||
@@ -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';
|
||||
@@ -16,6 +13,9 @@ import { isRecord } from '@/utils/helpers';
|
||||
const normalizeBoolean = (value: unknown): boolean | undefined =>
|
||||
typeof value === 'boolean' ? value : undefined;
|
||||
|
||||
const normalizeRecord = (value: unknown): Record<string, unknown> | undefined =>
|
||||
isRecord(value) ? value : undefined;
|
||||
|
||||
const normalizeModelAliases = (models: unknown): ModelAlias[] => {
|
||||
if (!Array.isArray(models)) return [];
|
||||
return models
|
||||
@@ -32,6 +32,8 @@ const normalizeModelAliases = (models: unknown): ModelAlias[] => {
|
||||
const alias = item.alias;
|
||||
const priority = item.priority;
|
||||
const testModel = item['test-model'];
|
||||
const image = normalizeBoolean(item.image);
|
||||
const thinking = normalizeRecord(item.thinking);
|
||||
const entry: ModelAlias = { name: String(name) };
|
||||
if (alias && alias !== name) {
|
||||
entry.alias = String(alias);
|
||||
@@ -45,6 +47,12 @@ const normalizeModelAliases = (models: unknown): ModelAlias[] => {
|
||||
if (testModel) {
|
||||
entry.testModel = String(testModel);
|
||||
}
|
||||
if (image !== undefined) {
|
||||
entry.image = image;
|
||||
}
|
||||
if (thinking) {
|
||||
entry.thinking = thinking;
|
||||
}
|
||||
return entry;
|
||||
})
|
||||
.filter(Boolean) as ModelAlias[];
|
||||
@@ -130,6 +138,8 @@ const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null =>
|
||||
const websockets = normalizeBoolean(record?.websockets);
|
||||
if (websockets !== undefined) config.websockets = websockets;
|
||||
if (proxyUrl) config.proxyUrl = String(proxyUrl);
|
||||
const disableCooling = normalizeBoolean(record?.['disable-cooling']);
|
||||
if (disableCooling !== undefined) config.disableCooling = disableCooling;
|
||||
const headers = normalizeHeaders(record?.headers);
|
||||
if (headers) config.headers = headers;
|
||||
const models = normalizeModelAliases(record?.models);
|
||||
@@ -154,10 +164,18 @@ const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null =>
|
||||
if (sensitiveWords.length) {
|
||||
cloak.sensitiveWords = sensitiveWords;
|
||||
}
|
||||
const cacheUserId = normalizeBoolean(cloakRaw['cache-user-id']);
|
||||
if (cacheUserId !== undefined) {
|
||||
cloak.cacheUserId = cacheUserId;
|
||||
}
|
||||
if (Object.keys(cloak).length) {
|
||||
config.cloak = cloak;
|
||||
}
|
||||
}
|
||||
const experimentalCchSigning = normalizeBoolean(record?.['experimental-cch-signing']);
|
||||
if (experimentalCchSigning !== undefined) {
|
||||
config.experimentalCchSigning = experimentalCchSigning;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -186,6 +204,8 @@ const normalizeGeminiKeyConfig = (item: unknown): GeminiKeyConfig | null => {
|
||||
if (baseUrl) config.baseUrl = String(baseUrl);
|
||||
const proxyUrl = record?.['proxy-url'];
|
||||
if (proxyUrl) config.proxyUrl = String(proxyUrl);
|
||||
const disableCooling = normalizeBoolean(record?.['disable-cooling']);
|
||||
if (disableCooling !== undefined) config.disableCooling = disableCooling;
|
||||
const models = normalizeModelAliases(record?.models);
|
||||
if (models.length) config.models = models;
|
||||
const headers = normalizeHeaders(record?.headers);
|
||||
@@ -222,6 +242,8 @@ const normalizeOpenAIProvider = (provider: unknown): OpenAIProviderConfig | null
|
||||
|
||||
const disabled = normalizeBoolean(provider.disabled);
|
||||
if (disabled !== undefined) result.disabled = disabled;
|
||||
const disableCooling = normalizeBoolean(provider['disable-cooling']);
|
||||
if (disableCooling !== undefined) result.disableCooling = disableCooling;
|
||||
const prefix = normalizePrefix(provider.prefix);
|
||||
if (prefix) result.prefix = prefix;
|
||||
if (headers) result.headers = headers;
|
||||
@@ -247,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 返回值
|
||||
*/
|
||||
@@ -410,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;
|
||||
@@ -430,8 +374,5 @@ export {
|
||||
normalizeOpenAIProvider,
|
||||
normalizeProviderKeyConfig,
|
||||
normalizeHeaders,
|
||||
normalizeExcludedModels,
|
||||
normalizeAmpcodeConfig,
|
||||
normalizeAmpcodeModelMappings,
|
||||
normalizeAmpcodeUpstreamApiKeys
|
||||
normalizeExcludedModels
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ interface AuthStoreState extends AuthState {
|
||||
runtimeKind?: ServerRuntimeKind | null
|
||||
) => void;
|
||||
updateServerRuntimeKind: (runtimeKind: ServerRuntimeKind) => void;
|
||||
updateServerPluginSupport: (supportsPlugin: boolean) => void;
|
||||
updateConnectionStatus: (status: ConnectionStatus, error?: string | null) => void;
|
||||
}
|
||||
|
||||
@@ -54,6 +55,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
serverVersion: null,
|
||||
serverBuildDate: null,
|
||||
serverRuntimeKind: 'unknown',
|
||||
supportsPlugin: false,
|
||||
connectionStatus: 'disconnected',
|
||||
connectionError: null,
|
||||
|
||||
@@ -113,7 +115,8 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
connectionStatus: 'connecting',
|
||||
serverVersion: null,
|
||||
serverBuildDate: null,
|
||||
serverRuntimeKind: 'unknown'
|
||||
serverRuntimeKind: 'unknown',
|
||||
supportsPlugin: false
|
||||
});
|
||||
useModelsStore.getState().clearCache();
|
||||
|
||||
@@ -169,6 +172,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
serverVersion: null,
|
||||
serverBuildDate: null,
|
||||
serverRuntimeKind: 'unknown',
|
||||
supportsPlugin: false,
|
||||
connectionStatus: 'disconnected',
|
||||
connectionError: null
|
||||
});
|
||||
@@ -186,6 +190,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
try {
|
||||
// 重新配置客户端
|
||||
apiClient.setConfig({ apiBase, managementKey });
|
||||
set({ supportsPlugin: false });
|
||||
|
||||
// 验证连接
|
||||
await useConfigStore.getState().fetchConfig();
|
||||
@@ -201,7 +206,8 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
} catch {
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
connectionStatus: 'error'
|
||||
connectionStatus: 'error',
|
||||
supportsPlugin: false
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@@ -220,6 +226,10 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
set({ serverRuntimeKind: runtimeKind });
|
||||
},
|
||||
|
||||
updateServerPluginSupport: (supportsPlugin) => {
|
||||
set({ supportsPlugin });
|
||||
},
|
||||
|
||||
// 更新连接状态
|
||||
updateConnectionStatus: (status, error = null) => {
|
||||
set({
|
||||
@@ -273,4 +283,11 @@ if (typeof window !== 'undefined') {
|
||||
.updateServerVersion(detail.version || null, detail.buildDate || null, runtimeKind);
|
||||
}) as EventListener
|
||||
);
|
||||
|
||||
window.addEventListener(
|
||||
'server-plugin-support-update',
|
||||
((e: CustomEvent) => {
|
||||
useAuthStore.getState().updateServerPluginSupport(e.detail?.supportsPlugin === true);
|
||||
}) as EventListener
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export interface AuthState {
|
||||
serverVersion: string | null;
|
||||
serverBuildDate: string | null;
|
||||
serverRuntimeKind: ServerRuntimeKind;
|
||||
supportsPlugin: boolean;
|
||||
}
|
||||
|
||||
// 连接状态
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface PluginConfigField {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type PluginConfigObject = Record<string, unknown>;
|
||||
|
||||
export interface PluginMetadata {
|
||||
name: string;
|
||||
version: string;
|
||||
@@ -49,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;
|
||||
@@ -70,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;
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface ModelAlias {
|
||||
alias?: string;
|
||||
priority?: number;
|
||||
testModel?: string;
|
||||
image?: boolean;
|
||||
thinking?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ApiKeyEntry {
|
||||
@@ -20,6 +22,7 @@ export interface CloakConfig {
|
||||
mode?: string;
|
||||
strictMode?: boolean;
|
||||
sensitiveWords?: string[];
|
||||
cacheUserId?: boolean;
|
||||
}
|
||||
|
||||
export interface GeminiKeyConfig {
|
||||
@@ -31,6 +34,7 @@ export interface GeminiKeyConfig {
|
||||
models?: ModelAlias[];
|
||||
headers?: Record<string, string>;
|
||||
excludedModels?: string[];
|
||||
disableCooling?: boolean;
|
||||
authIndex?: string;
|
||||
}
|
||||
|
||||
@@ -44,7 +48,9 @@ export interface ProviderKeyConfig {
|
||||
headers?: Record<string, string>;
|
||||
models?: ModelAlias[];
|
||||
excludedModels?: string[];
|
||||
disableCooling?: boolean;
|
||||
cloak?: CloakConfig;
|
||||
experimentalCchSigning?: boolean;
|
||||
authIndex?: string;
|
||||
}
|
||||
|
||||
@@ -58,6 +64,7 @@ export interface OpenAIProviderConfig {
|
||||
models?: ModelAlias[];
|
||||
priority?: number;
|
||||
testModel?: string;
|
||||
disableCooling?: boolean;
|
||||
authIndex?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -80,6 +80,8 @@ export type VisualConfigValues = {
|
||||
rmPanelRepo: string;
|
||||
authDir: string;
|
||||
apiKeysText: string;
|
||||
pluginsEnabled: boolean;
|
||||
pluginStoreSources: string[];
|
||||
debug: boolean;
|
||||
commercialMode: boolean;
|
||||
loggingToFile: boolean;
|
||||
@@ -95,6 +97,7 @@ export type VisualConfigValues = {
|
||||
maxRetryInterval: string;
|
||||
disableCooling: boolean;
|
||||
disableImageGeneration: DisableImageGenerationMode;
|
||||
gptImage2BaseModel: string;
|
||||
authAutoRefreshWorkers: string;
|
||||
quotaSwitchProject: boolean;
|
||||
quotaSwitchPreviewModel: boolean;
|
||||
@@ -115,6 +118,7 @@ export type VisualConfigValues = {
|
||||
claudeHeaderStabilizeDeviceProfile: boolean;
|
||||
codexHeaderUserAgent: string;
|
||||
codexHeaderBetaFeatures: string;
|
||||
codexIdentityConfuse: boolean;
|
||||
payloadDefaultRules: PayloadRule[];
|
||||
payloadDefaultRawRules: PayloadRule[];
|
||||
payloadOverrideRules: PayloadRule[];
|
||||
@@ -141,6 +145,8 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
|
||||
rmPanelRepo: '',
|
||||
authDir: '',
|
||||
apiKeysText: '',
|
||||
pluginsEnabled: false,
|
||||
pluginStoreSources: [],
|
||||
debug: false,
|
||||
commercialMode: false,
|
||||
loggingToFile: false,
|
||||
@@ -156,6 +162,7 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
|
||||
maxRetryInterval: '',
|
||||
disableCooling: false,
|
||||
disableImageGeneration: 'false',
|
||||
gptImage2BaseModel: '',
|
||||
authAutoRefreshWorkers: '',
|
||||
quotaSwitchProject: true,
|
||||
quotaSwitchPreviewModel: true,
|
||||
@@ -176,6 +183,7 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
|
||||
claudeHeaderStabilizeDeviceProfile: false,
|
||||
codexHeaderUserAgent: '',
|
||||
codexHeaderBetaFeatures: '',
|
||||
codexIdentityConfuse: false,
|
||||
payloadDefaultRules: [],
|
||||
payloadDefaultRawRules: [],
|
||||
payloadOverrideRules: [],
|
||||
|
||||
@@ -18,6 +18,7 @@ export const MANAGEMENT_API_PREFIX = '/v0/management';
|
||||
export const REQUEST_TIMEOUT_MS = 30 * 1000;
|
||||
export const CPA_VERSION_HEADER_KEYS = ['x-cpa-version'];
|
||||
export const CPA_BUILD_DATE_HEADER_KEYS = ['x-cpa-build-date'];
|
||||
export const CPA_SUPPORT_PLUGIN_HEADER_KEYS = ['x-cpa-support-plugin'];
|
||||
export const HOME_VERSION_HEADER_KEYS = ['x-cpa-home-version'];
|
||||
export const HOME_BUILD_DATE_HEADER_KEYS = ['x-cpa-home-build-date'];
|
||||
export const VERSION_HEADER_KEYS = [
|
||||
|
||||
Reference in New Issue
Block a user