mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
9 Commits
@@ -658,6 +658,13 @@ 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>
|
||||
|
||||
<SectionGrid>
|
||||
@@ -813,6 +820,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 +949,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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -363,6 +363,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,4 +1,4 @@
|
||||
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';
|
||||
@@ -37,6 +37,8 @@ const getErrorDetailMessage = (error: unknown): string => {
|
||||
return typeof message === 'string' ? message.trim() : '';
|
||||
};
|
||||
|
||||
const DESCRIPTION_COLLAPSED_LINES = 2;
|
||||
|
||||
const getStoreEntryTitle = (entry: PluginStoreEntry) => entry.name || entry.id;
|
||||
|
||||
function StoreCardLogo({ src }: { src: string }) {
|
||||
@@ -66,6 +68,9 @@ export function PluginStorePage() {
|
||||
const [statusFilter, setStatusFilter] = useState<StoreStatusFilter>('all');
|
||||
const [installingID, setInstallingID] = useState('');
|
||||
const [restartRequiredIDs, setRestartRequiredIDs] = useState<string[]>([]);
|
||||
const [expandedDescriptionIDs, setExpandedDescriptionIDs] = useState<string[]>([]);
|
||||
const [overflowingDescriptionIDs, setOverflowingDescriptionIDs] = useState<string[]>([]);
|
||||
const descriptionRefs = useRef<Record<string, HTMLParagraphElement | null>>({});
|
||||
|
||||
const connected = connectionStatus === 'connected';
|
||||
|
||||
@@ -168,6 +173,68 @@ export function PluginStorePage() {
|
||||
|
||||
const hasActiveFilters = Boolean(filter.trim()) || statusFilter !== 'all';
|
||||
|
||||
const expandedDescriptionIDSet = useMemo(
|
||||
() => new Set(expandedDescriptionIDs),
|
||||
[expandedDescriptionIDs]
|
||||
);
|
||||
const overflowingDescriptionIDSet = useMemo(
|
||||
() => new Set(overflowingDescriptionIDs),
|
||||
[overflowingDescriptionIDs]
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
setOverflowingDescriptionIDs((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) => {
|
||||
setExpandedDescriptionIDs((current) =>
|
||||
current.includes(id) ? current.filter((currentID) => currentID !== id) : [...current, id]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleInstall = (entry: PluginStoreEntry) => {
|
||||
const isUpdate = entry.installed && entry.updateAvailable;
|
||||
const title = getStoreEntryTitle(entry);
|
||||
@@ -223,6 +290,9 @@ export function PluginStorePage() {
|
||||
? `v${entry.version}`
|
||||
: '';
|
||||
const metaItems = [versionText, entry.author, entry.license].filter(Boolean);
|
||||
const isDescriptionExpanded = expandedDescriptionIDSet.has(entry.id);
|
||||
const isDescriptionOverflowing = overflowingDescriptionIDSet.has(entry.id);
|
||||
const descriptionID = `plugin-store-desc-${entry.id}`;
|
||||
|
||||
return (
|
||||
<article key={entry.id} className={styles.card}>
|
||||
@@ -246,7 +316,34 @@ 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(entry.id, node)}
|
||||
className={`${styles.cardDesc} ${
|
||||
isDescriptionExpanded ? styles.cardDescExpanded : ''
|
||||
}`}
|
||||
>
|
||||
{entry.description}
|
||||
</p>
|
||||
{isDescriptionOverflowing ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cardDescToggle}
|
||||
onClick={() => toggleDescription(entry.id)}
|
||||
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}>
|
||||
|
||||
@@ -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,11 @@ 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 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 +96,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 +144,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 +233,19 @@ 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 [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 [openingConfigID, setOpeningConfigID] = useState('');
|
||||
const configRequestSeq = useRef(0);
|
||||
|
||||
const connected = connectionStatus === 'connected';
|
||||
|
||||
@@ -260,12 +259,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 +270,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 +325,48 @@ export function PluginsPage() {
|
||||
[apiBase]
|
||||
);
|
||||
|
||||
const openConfigSheet = (plugin: PluginListEntry) => {
|
||||
const currentConfig = getPluginRawConfig(rawConfig, plugin.id);
|
||||
const openConfigSheet = async (plugin: PluginListEntry) => {
|
||||
if (openingConfigID || mutatingID) 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) return;
|
||||
setEditingPlugin(null);
|
||||
setEditingConfig({});
|
||||
setDraft(null);
|
||||
};
|
||||
|
||||
@@ -341,7 +379,8 @@ export function PluginsPage() {
|
||||
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(
|
||||
@@ -357,12 +396,11 @@ export function PluginsPage() {
|
||||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
if (!editingPlugin || !draft) return;
|
||||
const currentConfig = getPluginRawConfig(rawConfig, editingPlugin.id);
|
||||
if (!editingPlugin || !draft || openingConfigID || mutatingID) return;
|
||||
const { nextConfig, errors } = buildConfigPayload(
|
||||
draft,
|
||||
editingPlugin.configFields,
|
||||
currentConfig,
|
||||
editingConfig,
|
||||
t
|
||||
);
|
||||
|
||||
@@ -376,8 +414,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 +707,7 @@ export function PluginsPage() {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={loadPlugins}
|
||||
disabled={!connected || loading}
|
||||
disabled={!connected || loading || Boolean(mutatingID)}
|
||||
loading={loading}
|
||||
>
|
||||
<IconRefreshCw size={16} />
|
||||
@@ -706,7 +748,8 @@ 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 actionBusy = Boolean(mutatingID || openingConfigID);
|
||||
const version = plugin.metadata?.version;
|
||||
const author = plugin.metadata?.author;
|
||||
|
||||
@@ -780,14 +823,15 @@ 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')}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { PluginListEntry, PluginMenu } 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;
|
||||
@@ -39,6 +45,8 @@ export const collectPluginResourceEntries = (
|
||||
plugins: PluginListEntry[]
|
||||
): PluginResourceEntry[] =>
|
||||
plugins.flatMap((plugin) => {
|
||||
if (!plugin.effectiveEnabled) return [];
|
||||
|
||||
const pluginTitle = getPluginTitle(plugin);
|
||||
const pluginLogo = plugin.logo || plugin.metadata?.logo || '';
|
||||
|
||||
|
||||
@@ -56,6 +56,11 @@ 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'>,
|
||||
resource: ProviderResource | null,
|
||||
@@ -69,13 +74,17 @@ 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'
|
||||
? ''
|
||||
@@ -94,6 +103,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 +111,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 +145,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,8 +166,13 @@ 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,
|
||||
experimentalCchSigning:
|
||||
brand === 'claude'
|
||||
? (cfg as ProviderKeyConfig).experimentalCchSigning === true
|
||||
: undefined,
|
||||
testModel: brand === 'claude' || brand === 'gemini' ? '' : undefined,
|
||||
};
|
||||
}
|
||||
@@ -368,7 +386,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,6 +441,12 @@ 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'
|
||||
? { status: connectivity.geminiStatus, run: connectivity.runGemini }
|
||||
@@ -444,6 +473,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>
|
||||
{/* 基础字段 */}
|
||||
@@ -661,6 +704,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 +965,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 +1110,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
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface ModelEntryInput {
|
||||
alias?: string;
|
||||
priority?: number;
|
||||
testModel?: string;
|
||||
image?: boolean;
|
||||
thinkingJson?: string;
|
||||
}
|
||||
|
||||
export interface ApiKeyEntryInput {
|
||||
@@ -100,6 +102,7 @@ export interface CloakInput {
|
||||
mode: string;
|
||||
strictMode: boolean;
|
||||
sensitiveWordsText: string;
|
||||
cacheUserId: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderEntryFormInput {
|
||||
@@ -111,6 +114,7 @@ export interface ProviderEntryFormInput {
|
||||
proxyUrl: string;
|
||||
prefix: string;
|
||||
disabled: boolean;
|
||||
disableCooling?: boolean;
|
||||
priority?: number;
|
||||
|
||||
/** 高级折叠区 */
|
||||
@@ -122,6 +126,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[];
|
||||
|
||||
@@ -69,6 +69,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 +120,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 +131,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 +151,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 +175,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,
|
||||
|
||||
@@ -157,6 +157,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 +741,11 @@ function getNextDirtyFields(
|
||||
'errorLogsMaxFiles',
|
||||
'usageStatisticsEnabled',
|
||||
'redisUsageQueueRetentionSeconds',
|
||||
'pluginsEnabled',
|
||||
'passthroughHeaders',
|
||||
'disableCooling',
|
||||
'disableImageGeneration',
|
||||
'gptImage2BaseModel',
|
||||
'authAutoRefreshWorkers',
|
||||
'enableGeminiCliEndpoint',
|
||||
'antigravitySignatureCacheEnabled',
|
||||
@@ -745,6 +759,7 @@ function getNextDirtyFields(
|
||||
'claudeHeaderStabilizeDeviceProfile',
|
||||
'codexHeaderUserAgent',
|
||||
'codexHeaderBetaFeatures',
|
||||
'codexIdentityConfuse',
|
||||
'host',
|
||||
'port',
|
||||
'tlsEnable',
|
||||
@@ -912,6 +927,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 +956,7 @@ export function useVisualConfig() {
|
||||
|
||||
authDir: typeof parsed['auth-dir'] === 'string' ? parsed['auth-dir'] : '',
|
||||
apiKeysText: resolveApiKeysText(parsed),
|
||||
pluginsEnabled: Boolean(plugins?.enabled),
|
||||
|
||||
debug: Boolean(parsed.debug),
|
||||
commercialMode: Boolean(parsed['commercial-mode']),
|
||||
@@ -958,6 +976,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 +1016,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 +1123,16 @@ export function useVisualConfig() {
|
||||
}
|
||||
deleteLegacyApiKeysProvider(doc);
|
||||
|
||||
if (
|
||||
docHas(doc, ['plugins']) ||
|
||||
values.pluginsEnabled ||
|
||||
shouldWriteManagedField(doc, ['plugins', 'enabled'], dirtyFields, 'pluginsEnabled')
|
||||
) {
|
||||
ensureMapInDoc(doc, ['plugins']);
|
||||
setBooleanInDoc(doc, ['plugins', 'enabled'], values.pluginsEnabled);
|
||||
deleteIfMapEmpty(doc, ['plugins']);
|
||||
}
|
||||
|
||||
setBooleanInDoc(doc, ['debug'], values.debug);
|
||||
|
||||
setBooleanInDoc(doc, ['commercial-mode'], values.commercialMode);
|
||||
@@ -1125,6 +1158,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 +1239,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 +1321,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(
|
||||
|
||||
@@ -918,6 +918,8 @@
|
||||
"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",
|
||||
"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 +944,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 +963,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 +1111,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",
|
||||
@@ -1157,6 +1165,8 @@
|
||||
"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",
|
||||
"refresh": "Refresh",
|
||||
"retry": "Retry",
|
||||
"load_failed": "Failed to load the plugin store",
|
||||
@@ -1462,6 +1472,17 @@
|
||||
"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",
|
||||
|
||||
@@ -905,6 +905,8 @@
|
||||
"commercial_mode_desc": "Отключить тяжёлое промежуточное ПО для поддержки высокой нагрузки",
|
||||
"logging_to_file": "Журналировать в файл",
|
||||
"logging_to_file_desc": "Сохранять журналы в файлы",
|
||||
"plugins_enabled": "Включить систему плагинов",
|
||||
"plugins_enabled_desc": "Включает загрузку стандартных dynamic-library плагинов; отдельные экземпляры управляются на странице плагинов",
|
||||
"logs_max_size": "Максимальный размер файла журнала (МБ)",
|
||||
"error_logs_max_files": "Файлов журнала ошибок",
|
||||
"usage_statistics_enabled": "Включить статистику использования в памяти",
|
||||
@@ -929,7 +931,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 +950,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": "По кругу",
|
||||
@@ -1144,6 +1150,8 @@
|
||||
"plugin_store": {
|
||||
"title": "Магазин плагинов",
|
||||
"description": "Просматривайте реестр плагинов, устанавливайте и обновляйте плагины для текущего бэкенда.",
|
||||
"description_show_more": "Показать больше",
|
||||
"description_show_less": "Свернуть",
|
||||
"refresh": "Обновить",
|
||||
"retry": "Повторить",
|
||||
"load_failed": "Не удалось загрузить магазин плагинов",
|
||||
@@ -1439,6 +1447,17 @@
|
||||
"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-ключ",
|
||||
|
||||
@@ -918,6 +918,8 @@
|
||||
"commercial_mode_desc": "禁用高开销中间件以支持高并发",
|
||||
"logging_to_file": "写入日志文件",
|
||||
"logging_to_file_desc": "将日志保存到文件",
|
||||
"plugins_enabled": "启用插件系统",
|
||||
"plugins_enabled_desc": "启用标准动态库插件加载;具体插件实例仍在插件管理页启停",
|
||||
"logs_max_size": "日志文件大小限制 (MB)",
|
||||
"error_logs_max_files": "错误日志保留文件数",
|
||||
"usage_statistics_enabled": "启用内存用量统计",
|
||||
@@ -942,7 +944,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 +963,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 +1111,8 @@
|
||||
"description": "查看已发现和已注册的插件,管理实例开关、配置字段和资源入口。",
|
||||
"refresh": "刷新",
|
||||
"load_failed": "加载插件失败",
|
||||
"config_load_failed": "读取插件配置失败",
|
||||
"config_not_found": "无法读取插件配置,后端未找到该插件。",
|
||||
"unsupported_backend": "当前后端未暴露插件管理 API。请使用包含插件管理接口的新后端构建,并重启服务。",
|
||||
"global_status": "全局状态",
|
||||
"global_enabled": "已启用",
|
||||
@@ -1157,6 +1165,8 @@
|
||||
"plugin_store": {
|
||||
"title": "插件商店",
|
||||
"description": "浏览插件注册表,为当前后端安装或更新插件。",
|
||||
"description_show_more": "展开描述",
|
||||
"description_show_less": "收起描述",
|
||||
"refresh": "刷新",
|
||||
"retry": "重试",
|
||||
"load_failed": "插件商店加载失败",
|
||||
@@ -1462,6 +1472,17 @@
|
||||
"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 密钥",
|
||||
|
||||
@@ -944,6 +944,8 @@
|
||||
"commercial_mode_desc": "停用高開銷中介軟體以支援高並行",
|
||||
"logging_to_file": "寫入記錄檔",
|
||||
"logging_to_file_desc": "將記錄儲存到檔案",
|
||||
"plugins_enabled": "啟用插件系統",
|
||||
"plugins_enabled_desc": "啟用標準動態庫插件載入;具體插件實例仍在插件管理頁啟停",
|
||||
"logs_max_size": "記錄檔大小限制(MB)",
|
||||
"error_logs_max_files": "錯誤記錄保留檔案數",
|
||||
"usage_statistics_enabled": "啟用記憶體用量統計",
|
||||
@@ -968,7 +970,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 +989,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 +1137,8 @@
|
||||
"description": "查看已發現和已註冊的插件,管理實例開關、設定欄位和資源入口。",
|
||||
"refresh": "重新整理",
|
||||
"load_failed": "載入插件失敗",
|
||||
"config_load_failed": "讀取插件設定失敗",
|
||||
"config_not_found": "無法讀取插件設定,後端未找到該插件。",
|
||||
"unsupported_backend": "目前後端未暴露插件管理 API。請使用包含插件管理介面的新版後端建置,並重新啟動服務。",
|
||||
"global_status": "全域狀態",
|
||||
"global_enabled": "已啟用",
|
||||
@@ -1183,6 +1191,8 @@
|
||||
"plugin_store": {
|
||||
"title": "插件商店",
|
||||
"description": "瀏覽插件註冊表,為目前後端安裝或更新插件。",
|
||||
"description_show_more": "展開描述",
|
||||
"description_show_less": "收起描述",
|
||||
"refresh": "重新整理",
|
||||
"retry": "重試",
|
||||
"load_failed": "插件商店載入失敗",
|
||||
@@ -1488,6 +1498,17 @@
|
||||
"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 金鑰",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -62,6 +62,21 @@ 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 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);
|
||||
@@ -95,9 +110,7 @@ const normalizeCPALogs = (data: Record<string, unknown>): LogsResponse => {
|
||||
};
|
||||
|
||||
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)
|
||||
@@ -145,9 +158,62 @@ 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;
|
||||
}
|
||||
|
||||
let nextOffset = firstOffset + records.length;
|
||||
const targetCount = Math.min(requestedLimit, Math.max(total - firstOffset, 0));
|
||||
|
||||
while (records.length < targetCount && nextOffset < total) {
|
||||
const remaining = targetCount - records.length;
|
||||
const data = await apiClient.get('/logs', {
|
||||
params: {
|
||||
...params,
|
||||
limit: Math.min(pageLimit, remaining),
|
||||
offset: nextOffset,
|
||||
},
|
||||
timeout: LOGS_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (!isRecord(data) || !Array.isArray(data.logs)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const pageRecords = homeRecordsFromPayload(data);
|
||||
if (pageRecords.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
records.push(...pageRecords);
|
||||
nextOffset += pageRecords.length;
|
||||
}
|
||||
|
||||
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,7 @@ import { apiClient } from './client';
|
||||
import { isRecord } from '@/utils/helpers';
|
||||
import type {
|
||||
PluginConfigField,
|
||||
PluginConfigObject,
|
||||
PluginListEntry,
|
||||
PluginListResponse,
|
||||
PluginMetadata,
|
||||
@@ -114,6 +115,9 @@ const normalizePluginList = (value: unknown): PluginListResponse => {
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePluginConfig = (value: unknown): PluginConfigObject =>
|
||||
isRecord(value) ? { ...value } : {};
|
||||
|
||||
const normalizeStoreEntry = (value: unknown): PluginStoreEntry | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
const id = asString(value.id).trim();
|
||||
@@ -179,10 +183,15 @@ export const pluginsApi = {
|
||||
updateEnabled: (id: string, enabled: boolean) =>
|
||||
apiClient.patch(`/plugins/${encodeURIComponent(id)}/enabled`, { enabled }),
|
||||
|
||||
putConfig: (id: string, config: Record<string, unknown>) =>
|
||||
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),
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
),
|
||||
|
||||
@@ -16,6 +16,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 +35,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 +50,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 +141,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 +167,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 +207,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 +245,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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface AuthState {
|
||||
serverVersion: string | null;
|
||||
serverBuildDate: string | null;
|
||||
serverRuntimeKind: ServerRuntimeKind;
|
||||
supportsPlugin: boolean;
|
||||
}
|
||||
|
||||
// 连接状态
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface PluginConfigField {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type PluginConfigObject = Record<string, unknown>;
|
||||
|
||||
export interface PluginMetadata {
|
||||
name: string;
|
||||
version: 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,7 @@ export type VisualConfigValues = {
|
||||
rmPanelRepo: string;
|
||||
authDir: string;
|
||||
apiKeysText: string;
|
||||
pluginsEnabled: boolean;
|
||||
debug: boolean;
|
||||
commercialMode: boolean;
|
||||
loggingToFile: boolean;
|
||||
@@ -95,6 +96,7 @@ export type VisualConfigValues = {
|
||||
maxRetryInterval: string;
|
||||
disableCooling: boolean;
|
||||
disableImageGeneration: DisableImageGenerationMode;
|
||||
gptImage2BaseModel: string;
|
||||
authAutoRefreshWorkers: string;
|
||||
quotaSwitchProject: boolean;
|
||||
quotaSwitchPreviewModel: boolean;
|
||||
@@ -115,6 +117,7 @@ export type VisualConfigValues = {
|
||||
claudeHeaderStabilizeDeviceProfile: boolean;
|
||||
codexHeaderUserAgent: string;
|
||||
codexHeaderBetaFeatures: string;
|
||||
codexIdentityConfuse: boolean;
|
||||
payloadDefaultRules: PayloadRule[];
|
||||
payloadDefaultRawRules: PayloadRule[];
|
||||
payloadOverrideRules: PayloadRule[];
|
||||
@@ -141,6 +144,7 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
|
||||
rmPanelRepo: '',
|
||||
authDir: '',
|
||||
apiKeysText: '',
|
||||
pluginsEnabled: false,
|
||||
debug: false,
|
||||
commercialMode: false,
|
||||
loggingToFile: false,
|
||||
@@ -156,6 +160,7 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
|
||||
maxRetryInterval: '',
|
||||
disableCooling: false,
|
||||
disableImageGeneration: 'false',
|
||||
gptImage2BaseModel: '',
|
||||
authAutoRefreshWorkers: '',
|
||||
quotaSwitchProject: true,
|
||||
quotaSwitchPreviewModel: true,
|
||||
@@ -176,6 +181,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