bbf9500bea
合并客户端扩展、应用更新、格式化门禁和后台表查询等主线改动。 运行时设置页保留官方账号服务与安全元数据,同时接入主线扩展管理与更新能力。 DirectProject 文档合并主线第三方 MCP 支持与官方路由、受控搜索边界。 修复合并后的后台 API Key 详情 Eye 图标导入。
1233 lines
44 KiB
TypeScript
1233 lines
44 KiB
TypeScript
import {
|
|
Bot,
|
|
Cable,
|
|
CheckCircle2,
|
|
CircleAlert,
|
|
Info,
|
|
LoaderCircle,
|
|
Pencil,
|
|
RotateCcw,
|
|
Save,
|
|
Settings2,
|
|
SlidersHorizontal,
|
|
Trash2,
|
|
Upload,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { type FormEvent, useEffect, useRef, useState } from 'react';
|
|
|
|
import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png';
|
|
import { APP_NAME, APP_VERSION } from '../../app/appMetadata';
|
|
import {
|
|
closeDialogOnBackdropMouseDown,
|
|
closeDialogOnEscape,
|
|
useEscapeToClose,
|
|
} from '../../app/dialogs';
|
|
import { resolveTauriInvoke } from '../../app/tauri';
|
|
import {
|
|
type ClientExtensionImportResult,
|
|
type ClientExtensionItem,
|
|
type GameCreatorAppConfig,
|
|
type GameCreatorAppConfigView,
|
|
type GameCreatorLlmConfigStatus,
|
|
type GameCreatorLlmReasoningEffort,
|
|
gameCreatorLlmReasoningEfforts,
|
|
} from '../../app/types';
|
|
import { checkForAppUpdate } from '../../services/appUpdate';
|
|
|
|
const defaultRuntimeConfigDraft: GameCreatorAppConfig = {
|
|
schemaVersion: 'game-creator-config.v2',
|
|
agentMode: 'codex_app_server',
|
|
llm: {
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
model: '',
|
|
apiKind: 'openai_responses',
|
|
reasoningEffort: 'max',
|
|
stream: true,
|
|
webSearchEnabled: true,
|
|
contextWindowTokens: 128000,
|
|
autoCompactTokenLimit: 64000,
|
|
toolOutputTokenLimit: 12000,
|
|
requestTimeoutMs: 180000,
|
|
maxRetries: 2,
|
|
retryBackoffMs: 500,
|
|
},
|
|
agentLlm: {},
|
|
editorApi: {
|
|
baseUrl: 'https://dev.genarrative.world',
|
|
apiKey: '',
|
|
},
|
|
};
|
|
|
|
function formatAccountCredentialState(
|
|
status: GameCreatorLlmConfigStatus | null,
|
|
) {
|
|
switch (status?.accountCredentialState) {
|
|
case 'ready':
|
|
return { label: '账号权限已就绪', tone: 'success' as const };
|
|
case 'login_required':
|
|
return { label: '请先登录陶泥儿账号', tone: 'warning' as const };
|
|
case 'permission_required':
|
|
case 'permission_denied':
|
|
case 'forbidden':
|
|
return {
|
|
label: '账号权限不足,请重新登录',
|
|
tone: 'warning' as const,
|
|
};
|
|
case 'revoked':
|
|
return {
|
|
label: '账号授权已失效,请重新登录',
|
|
tone: 'warning' as const,
|
|
};
|
|
case 'acl_repair_required':
|
|
return { label: '本机凭据权限需要修复', tone: 'warning' as const };
|
|
case 'unavailable':
|
|
return {
|
|
label: '账号权限暂不可用,请稍后重试',
|
|
tone: 'warning' as const,
|
|
};
|
|
case 'not_required':
|
|
return {
|
|
label: '当前发行版不需要本地凭据',
|
|
tone: 'neutral' as const,
|
|
};
|
|
default:
|
|
return status?.configured
|
|
? { label: '账号权限已就绪', tone: 'success' as const }
|
|
: { label: '尚未检查账号权限', tone: 'neutral' as const };
|
|
}
|
|
}
|
|
|
|
type RuntimeSettingsSection =
|
|
| 'general'
|
|
| 'agents'
|
|
| 'connections'
|
|
| 'extensions'
|
|
| 'advanced'
|
|
| 'about';
|
|
|
|
type RuntimeConfigToast = {
|
|
tone: 'success' | 'error';
|
|
message: string;
|
|
};
|
|
|
|
type ClientExtensionsLoadState = 'loading' | 'ready' | 'error';
|
|
|
|
const runtimeSettingsSections = [
|
|
{
|
|
id: 'general',
|
|
label: '常用设置',
|
|
description: '运行方式与输出偏好',
|
|
icon: Settings2,
|
|
},
|
|
{
|
|
id: 'agents',
|
|
label: 'Agent 分工',
|
|
description: '查看各角色运行状态',
|
|
icon: Bot,
|
|
},
|
|
{
|
|
id: 'connections',
|
|
label: '连接与工具',
|
|
description: '外部服务',
|
|
icon: Cable,
|
|
},
|
|
{
|
|
id: 'extensions',
|
|
label: '扩展',
|
|
description: 'Skill 与 MCP',
|
|
icon: Upload,
|
|
},
|
|
{
|
|
id: 'advanced',
|
|
label: '高级参数',
|
|
description: '上下文、超时与重试',
|
|
icon: SlidersHorizontal,
|
|
},
|
|
{
|
|
id: 'about',
|
|
label: '关于',
|
|
description: '客户端信息',
|
|
icon: Info,
|
|
},
|
|
] as const satisfies ReadonlyArray<{
|
|
id: RuntimeSettingsSection;
|
|
label: string;
|
|
description: string;
|
|
icon: typeof Settings2;
|
|
}>;
|
|
|
|
function clampRuntimeConfigNumber(value: number, minimum: number) {
|
|
const numericValue = Number(value);
|
|
return Number.isFinite(numericValue) && numericValue >= minimum
|
|
? numericValue
|
|
: minimum;
|
|
}
|
|
|
|
function isGameCreatorLlmReasoningEffort(
|
|
value: unknown,
|
|
): value is GameCreatorLlmReasoningEffort {
|
|
return gameCreatorLlmReasoningEfforts.some((effort) => effort === value);
|
|
}
|
|
|
|
function normalizeRuntimeConfigDraft(
|
|
config: GameCreatorAppConfig,
|
|
allowAdvancedExternalEditorConfig: boolean,
|
|
): GameCreatorAppConfig {
|
|
const reasoningEffort = isGameCreatorLlmReasoningEffort(
|
|
config.llm.reasoningEffort,
|
|
)
|
|
? config.llm.reasoningEffort
|
|
: defaultRuntimeConfigDraft.llm.reasoningEffort;
|
|
return {
|
|
...config,
|
|
agentMode: 'codex_app_server',
|
|
llm: {
|
|
...config.llm,
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
model: '',
|
|
apiKind: 'openai_responses',
|
|
reasoningEffort,
|
|
webSearchEnabled:
|
|
typeof config.llm.webSearchEnabled === 'boolean'
|
|
? config.llm.webSearchEnabled
|
|
: defaultRuntimeConfigDraft.llm.webSearchEnabled,
|
|
contextWindowTokens: clampRuntimeConfigNumber(
|
|
typeof config.llm.contextWindowTokens === 'number'
|
|
? config.llm.contextWindowTokens
|
|
: defaultRuntimeConfigDraft.llm.contextWindowTokens,
|
|
1,
|
|
),
|
|
autoCompactTokenLimit: clampRuntimeConfigNumber(
|
|
typeof config.llm.autoCompactTokenLimit === 'number'
|
|
? config.llm.autoCompactTokenLimit
|
|
: defaultRuntimeConfigDraft.llm.autoCompactTokenLimit,
|
|
1,
|
|
),
|
|
toolOutputTokenLimit: clampRuntimeConfigNumber(
|
|
typeof config.llm.toolOutputTokenLimit === 'number'
|
|
? config.llm.toolOutputTokenLimit
|
|
: defaultRuntimeConfigDraft.llm.toolOutputTokenLimit,
|
|
1,
|
|
),
|
|
requestTimeoutMs: clampRuntimeConfigNumber(
|
|
config.llm.requestTimeoutMs,
|
|
1000,
|
|
),
|
|
maxRetries: clampRuntimeConfigNumber(config.llm.maxRetries, 0),
|
|
retryBackoffMs: clampRuntimeConfigNumber(config.llm.retryBackoffMs, 1),
|
|
},
|
|
// Official AGC builds have one account-backed route. Drop every legacy
|
|
// per-agent override (including non-sensitive tuning) at the UI boundary
|
|
// so it cannot be persisted or accidentally re-exposed as a route.
|
|
agentLlm: {},
|
|
editorApi: allowAdvancedExternalEditorConfig
|
|
? { ...defaultRuntimeConfigDraft.editorApi, ...config.editorApi }
|
|
: { ...defaultRuntimeConfigDraft.editorApi },
|
|
};
|
|
}
|
|
|
|
export function RuntimeConfigDialog({
|
|
allowAdvancedExternalEditorConfig = false,
|
|
llmConfigStatus = null,
|
|
onClose,
|
|
onLog,
|
|
}: {
|
|
projectPath?: string;
|
|
allowAdvancedExternalEditorConfig?: boolean;
|
|
llmConfigStatus?: GameCreatorLlmConfigStatus | null;
|
|
onClose: () => void;
|
|
onLog?: (entry: string) => void;
|
|
}) {
|
|
// The prop is intentionally not sufficient to expose developer credentials:
|
|
// only an explicitly opted-in Vite development build may render the legacy
|
|
// External Editor fields. Release bundles therefore ignore accidental or
|
|
// stale callers that pass the prop.
|
|
const advancedExternalEditorConfigEnabled =
|
|
allowAdvancedExternalEditorConfig &&
|
|
import.meta.env.DEV &&
|
|
import.meta.env.VITE_AGC_ENABLE_ADVANCED_EXTERNAL_EDITOR_CONFIG === '1';
|
|
const [runtimeConfigPath, setRuntimeConfigPath] = useState('');
|
|
const [runtimeConfigStatus, setRuntimeConfigStatus] = useState('未读取');
|
|
const [runtimeConfigToast, setRuntimeConfigToast] =
|
|
useState<RuntimeConfigToast | null>(null);
|
|
const [runtimeConfigDraft, setRuntimeConfigDraft] =
|
|
useState<GameCreatorAppConfig>(defaultRuntimeConfigDraft);
|
|
const [runtimeConfigBusy, setRuntimeConfigBusy] = useState(false);
|
|
const [effectiveLlmConfigStatus, setEffectiveLlmConfigStatus] =
|
|
useState<GameCreatorLlmConfigStatus | null>(llmConfigStatus);
|
|
const [activeSection, setActiveSection] =
|
|
useState<RuntimeSettingsSection>('general');
|
|
const [clientExtensions, setClientExtensions] = useState<
|
|
ClientExtensionItem[]
|
|
>([]);
|
|
const [clientExtensionsBusy, setClientExtensionsBusy] = useState(false);
|
|
const [clientExtensionsLoadState, setClientExtensionsLoadState] =
|
|
useState<ClientExtensionsLoadState>('loading');
|
|
const [clientExtensionsStatus, setClientExtensionsStatus] = useState('');
|
|
const [editingExtensionId, setEditingExtensionId] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [editingExtensionName, setEditingExtensionName] = useState('');
|
|
const [appUpdateStatus, setAppUpdateStatus] = useState('');
|
|
const [appUpdateChecking, setAppUpdateChecking] = useState(false);
|
|
const runtimeConfigBusyRef = useRef(false);
|
|
|
|
useEscapeToClose(onClose);
|
|
|
|
useEffect(() => {
|
|
const htmlOverflow = document.documentElement.style.overflow;
|
|
const bodyOverflow = document.body.style.overflow;
|
|
document.documentElement.style.overflow = 'hidden';
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
return () => {
|
|
document.documentElement.style.overflow = htmlOverflow;
|
|
document.body.style.overflow = bodyOverflow;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setEffectiveLlmConfigStatus(llmConfigStatus);
|
|
}, [llmConfigStatus]);
|
|
|
|
useEffect(() => {
|
|
void readRuntimeConfig();
|
|
void readClientExtensions();
|
|
// The dialog reads once on mount; subsequent reads are explicit user actions.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
async function readClientExtensions() {
|
|
const invoke = resolveTauriInvoke();
|
|
if (!invoke) {
|
|
setClientExtensionsLoadState('error');
|
|
setClientExtensionsStatus('需要在 Tauri App 内运行');
|
|
return;
|
|
}
|
|
setClientExtensionsLoadState('loading');
|
|
setClientExtensionsBusy(true);
|
|
try {
|
|
const result = await invoke<ClientExtensionItem[]>(
|
|
'list_client_extensions',
|
|
);
|
|
setClientExtensions(result);
|
|
setClientExtensionsLoadState('ready');
|
|
setClientExtensionsStatus('');
|
|
} catch (error) {
|
|
setClientExtensionsLoadState('error');
|
|
setClientExtensionsStatus(
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
} finally {
|
|
setClientExtensionsBusy(false);
|
|
}
|
|
}
|
|
|
|
async function importClientExtension(kind: 'file' | 'directory') {
|
|
const invoke = resolveTauriInvoke();
|
|
if (!invoke) {
|
|
setClientExtensionsStatus('需要在 Tauri App 内运行');
|
|
return;
|
|
}
|
|
if (clientExtensionsBusy) {
|
|
return;
|
|
}
|
|
setClientExtensionsBusy(true);
|
|
setClientExtensionsStatus('正在导入');
|
|
try {
|
|
const selectedPath =
|
|
kind === 'file'
|
|
? await invoke<string | null>('pick_client_extension_file')
|
|
: await invoke<string | null>('pick_client_extension_directory');
|
|
if (!selectedPath) {
|
|
setClientExtensionsStatus('');
|
|
return;
|
|
}
|
|
const result = await invoke<ClientExtensionImportResult>(
|
|
'import_client_extension',
|
|
{ sourcePath: selectedPath },
|
|
);
|
|
const refreshed = await invoke<ClientExtensionItem[]>(
|
|
'list_client_extensions',
|
|
);
|
|
setClientExtensions(refreshed);
|
|
setClientExtensionsLoadState('ready');
|
|
const importedCount = result.imported.length;
|
|
setClientExtensionsStatus(
|
|
importedCount > 0
|
|
? `已导入 ${importedCount} 个扩展${result.renamed ? ',同名项已自动追加编号' : ''}`
|
|
: '未发现可管理的扩展',
|
|
);
|
|
} catch (error) {
|
|
setClientExtensionsStatus(
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
} finally {
|
|
setClientExtensionsBusy(false);
|
|
}
|
|
}
|
|
|
|
function beginRenameClientExtension(item: ClientExtensionItem) {
|
|
setEditingExtensionId(item.id);
|
|
setEditingExtensionName(item.name);
|
|
setClientExtensionsStatus('');
|
|
}
|
|
|
|
function cancelRenameClientExtension() {
|
|
setEditingExtensionId(null);
|
|
setEditingExtensionName('');
|
|
}
|
|
|
|
async function saveClientExtensionName(item: ClientExtensionItem) {
|
|
const invoke = resolveTauriInvoke();
|
|
if (!invoke || clientExtensionsBusy) {
|
|
return;
|
|
}
|
|
setClientExtensionsBusy(true);
|
|
try {
|
|
const updated = await invoke<ClientExtensionItem>(
|
|
'rename_client_extension',
|
|
{ id: item.id, name: editingExtensionName },
|
|
);
|
|
setClientExtensions((current) =>
|
|
current.map((candidate) =>
|
|
candidate.id === updated.id ? updated : candidate,
|
|
),
|
|
);
|
|
const requestedName = editingExtensionName.trim();
|
|
setClientExtensionsStatus(
|
|
updated.name === requestedName
|
|
? '扩展名称已更新'
|
|
: `扩展名称已调整为“${updated.name}”`,
|
|
);
|
|
cancelRenameClientExtension();
|
|
} catch (error) {
|
|
setClientExtensionsStatus(
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
} finally {
|
|
setClientExtensionsBusy(false);
|
|
}
|
|
}
|
|
|
|
async function setClientExtensionEnabled(
|
|
item: ClientExtensionItem,
|
|
enabled: boolean,
|
|
) {
|
|
const invoke = resolveTauriInvoke();
|
|
if (!invoke || clientExtensionsBusy || item.extensionType === 'unknown') {
|
|
return;
|
|
}
|
|
setClientExtensionsBusy(true);
|
|
try {
|
|
const updated = await invoke<ClientExtensionItem>(
|
|
'set_client_extension_enabled',
|
|
{ id: item.id, enabled },
|
|
);
|
|
setClientExtensions((current) =>
|
|
current.map((candidate) =>
|
|
candidate.id === updated.id ? updated : candidate,
|
|
),
|
|
);
|
|
} catch (error) {
|
|
setClientExtensionsStatus(
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
} finally {
|
|
setClientExtensionsBusy(false);
|
|
}
|
|
}
|
|
|
|
async function removeClientExtension(item: ClientExtensionItem) {
|
|
const invoke = resolveTauriInvoke();
|
|
if (!invoke || clientExtensionsBusy) {
|
|
return;
|
|
}
|
|
setClientExtensionsBusy(true);
|
|
try {
|
|
await invoke('remove_client_extension', { id: item.id });
|
|
setClientExtensions((current) =>
|
|
current.filter((candidate) => candidate.id !== item.id),
|
|
);
|
|
if (editingExtensionId === item.id) {
|
|
cancelRenameClientExtension();
|
|
}
|
|
setClientExtensionsStatus('扩展已移除');
|
|
} catch (error) {
|
|
setClientExtensionsStatus(
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
} finally {
|
|
setClientExtensionsBusy(false);
|
|
}
|
|
}
|
|
|
|
function updateRuntimeLlmConfig<K extends keyof GameCreatorAppConfig['llm']>(
|
|
key: K,
|
|
value: GameCreatorAppConfig['llm'][K],
|
|
) {
|
|
setRuntimeConfigDraft((current) => ({
|
|
...current,
|
|
llm: {
|
|
...current.llm,
|
|
[key]: value,
|
|
},
|
|
}));
|
|
}
|
|
|
|
async function readRuntimeConfig() {
|
|
if (runtimeConfigBusyRef.current) {
|
|
return;
|
|
}
|
|
const invoke = resolveTauriInvoke();
|
|
if (!invoke) {
|
|
setRuntimeConfigStatus('需要在 Tauri App 内运行');
|
|
return;
|
|
}
|
|
|
|
runtimeConfigBusyRef.current = true;
|
|
setRuntimeConfigBusy(true);
|
|
setRuntimeConfigStatus('正在读取');
|
|
try {
|
|
const result = await invoke<GameCreatorAppConfigView>(
|
|
'read_game_creator_app_config',
|
|
);
|
|
setRuntimeConfigPath(result.path);
|
|
const config = normalizeRuntimeConfigDraft(
|
|
result.config,
|
|
advancedExternalEditorConfigEnabled,
|
|
);
|
|
setRuntimeConfigDraft(config);
|
|
setRuntimeConfigStatus(`已读取:${result.path}`);
|
|
try {
|
|
const status = await invoke<GameCreatorLlmConfigStatus>(
|
|
'check_game_creator_llm_config',
|
|
);
|
|
setEffectiveLlmConfigStatus(status);
|
|
} catch {
|
|
// Older test fixtures and non-Tauri previews may not expose the
|
|
// diagnostic command; the config read itself remains usable.
|
|
}
|
|
onLog?.('runtime_config.read');
|
|
} catch (error) {
|
|
setRuntimeConfigStatus(
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
} finally {
|
|
runtimeConfigBusyRef.current = false;
|
|
setRuntimeConfigBusy(false);
|
|
}
|
|
}
|
|
|
|
async function handleRuntimeConfigSave(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
if (runtimeConfigBusyRef.current) {
|
|
return;
|
|
}
|
|
const invoke = resolveTauriInvoke();
|
|
if (!invoke) {
|
|
setRuntimeConfigStatus('需要在 Tauri App 内运行');
|
|
return;
|
|
}
|
|
|
|
runtimeConfigBusyRef.current = true;
|
|
setRuntimeConfigBusy(true);
|
|
setRuntimeConfigToast(null);
|
|
setRuntimeConfigStatus('正在保存');
|
|
try {
|
|
const config = normalizeRuntimeConfigDraft(
|
|
{
|
|
...runtimeConfigDraft,
|
|
agentMode: 'codex_app_server',
|
|
editorApi: advancedExternalEditorConfigEnabled
|
|
? runtimeConfigDraft.editorApi
|
|
: defaultRuntimeConfigDraft.editorApi,
|
|
},
|
|
advancedExternalEditorConfigEnabled,
|
|
);
|
|
const result = await invoke<GameCreatorAppConfigView>(
|
|
'write_game_creator_app_config',
|
|
{ config },
|
|
);
|
|
setRuntimeConfigPath(result.path);
|
|
const savedConfig = normalizeRuntimeConfigDraft(
|
|
result.config,
|
|
advancedExternalEditorConfigEnabled,
|
|
);
|
|
setRuntimeConfigDraft(savedConfig);
|
|
setRuntimeConfigStatus(`已保存:${result.path}`);
|
|
try {
|
|
const status = await invoke<GameCreatorLlmConfigStatus>(
|
|
'check_game_creator_llm_config',
|
|
);
|
|
setEffectiveLlmConfigStatus(status);
|
|
} catch {
|
|
// Keep the last safe status when the optional diagnostic refresh is
|
|
// unavailable in a preview or test fixture.
|
|
}
|
|
setRuntimeConfigToast({
|
|
tone: 'success',
|
|
message: '保存成功,新的运行时配置已生效',
|
|
});
|
|
onLog?.('runtime_config.save');
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
setRuntimeConfigStatus(message);
|
|
setRuntimeConfigToast({
|
|
tone: 'error',
|
|
message: `保存失败:${message}`,
|
|
});
|
|
} finally {
|
|
runtimeConfigBusyRef.current = false;
|
|
setRuntimeConfigBusy(false);
|
|
}
|
|
}
|
|
|
|
function resetRuntimeConfigDraft() {
|
|
setRuntimeConfigDraft(defaultRuntimeConfigDraft);
|
|
setRuntimeConfigStatus('已恢复默认配置,保存后生效');
|
|
}
|
|
|
|
async function checkAppUpdateManually() {
|
|
if (appUpdateChecking) return;
|
|
setAppUpdateChecking(true);
|
|
setAppUpdateStatus('正在检查更新…');
|
|
try {
|
|
const update = await checkForAppUpdate({ force: true });
|
|
setAppUpdateStatus(
|
|
update
|
|
? `发现新版本 v${update.version},可在右上角下载`
|
|
: '当前已是最新版本',
|
|
);
|
|
} catch {
|
|
setAppUpdateStatus('检查更新失败,请稍后重试');
|
|
} finally {
|
|
setAppUpdateChecking(false);
|
|
}
|
|
}
|
|
|
|
const selectedSection =
|
|
runtimeSettingsSections.find((section) => section.id === activeSection) ??
|
|
runtimeSettingsSections[0];
|
|
const runtimeConfigStatusTone = runtimeConfigBusy
|
|
? 'busy'
|
|
: /^(已保存|已读取|已恢复默认)/.test(runtimeConfigStatus)
|
|
? 'success'
|
|
: runtimeConfigStatus === '未读取'
|
|
? 'neutral'
|
|
: 'warning';
|
|
|
|
return (
|
|
<div
|
|
className="settings-overlay"
|
|
role="presentation"
|
|
onMouseDown={(event) => closeDialogOnBackdropMouseDown(event, onClose)}
|
|
>
|
|
{runtimeConfigToast ? (
|
|
<div
|
|
className="runtime-settings-toast"
|
|
data-tone={runtimeConfigToast.tone}
|
|
role={runtimeConfigToast.tone === 'error' ? 'alert' : 'status'}
|
|
aria-label="运行时配置提示"
|
|
aria-live={
|
|
runtimeConfigToast.tone === 'error' ? 'assertive' : 'polite'
|
|
}
|
|
>
|
|
{runtimeConfigToast.tone === 'error' ? (
|
|
<CircleAlert size={16} aria-hidden="true" />
|
|
) : (
|
|
<CheckCircle2 size={16} aria-hidden="true" />
|
|
)}
|
|
<span>{runtimeConfigToast.message}</span>
|
|
</div>
|
|
) : null}
|
|
<form
|
|
className="settings-panel runtime-settings-panel"
|
|
role="dialog"
|
|
aria-label="运行时配置"
|
|
aria-modal="true"
|
|
onSubmit={handleRuntimeConfigSave}
|
|
onKeyDown={(event) => closeDialogOnEscape(event, onClose)}
|
|
>
|
|
<header className="runtime-settings-header">
|
|
<div>
|
|
<span className="runtime-settings-eyebrow">AI GAME CREATOR</span>
|
|
<h2>Agent 设置</h2>
|
|
</div>
|
|
<button
|
|
className="runtime-settings-close"
|
|
type="button"
|
|
aria-label="关闭 Agent 设置"
|
|
onClick={onClose}
|
|
>
|
|
<X size={18} aria-hidden="true" />
|
|
</button>
|
|
</header>
|
|
<div className="runtime-settings-layout">
|
|
<aside className="runtime-settings-nav" aria-label="设置分类">
|
|
<nav>
|
|
{runtimeSettingsSections.map((section) => {
|
|
const Icon = section.icon;
|
|
return (
|
|
<button
|
|
key={section.id}
|
|
type="button"
|
|
className={activeSection === section.id ? 'is-active' : ''}
|
|
aria-current={
|
|
activeSection === section.id ? 'page' : undefined
|
|
}
|
|
onClick={() => setActiveSection(section.id)}
|
|
>
|
|
<Icon size={17} aria-hidden="true" />
|
|
<span>
|
|
<strong>{section.label}</strong>
|
|
<small>{section.description}</small>
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
<div className="runtime-settings-config-state">
|
|
<span>配置文件</span>
|
|
<strong>{runtimeConfigPath ? '已载入' : '等待载入'}</strong>
|
|
{runtimeConfigPath ? (
|
|
<small title={runtimeConfigPath}>{runtimeConfigPath}</small>
|
|
) : null}
|
|
</div>
|
|
</aside>
|
|
<main className="runtime-settings-content">
|
|
<header className="runtime-settings-section-header">
|
|
<div>
|
|
<h3>{selectedSection.label}</h3>
|
|
<p>{selectedSection.description}</p>
|
|
</div>
|
|
{activeSection === 'extensions' ? (
|
|
<div className="runtime-settings-section-actions">
|
|
<button
|
|
type="button"
|
|
disabled={clientExtensionsBusy}
|
|
onClick={() => void importClientExtension('file')}
|
|
>
|
|
<Upload size={14} aria-hidden="true" />
|
|
导入文件或 zip
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={clientExtensionsBusy}
|
|
onClick={() => void importClientExtension('directory')}
|
|
>
|
|
<Upload size={14} aria-hidden="true" />
|
|
导入目录
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</header>
|
|
<div className="settings-grid runtime-settings-fields">
|
|
{activeSection === 'general' ? (
|
|
<>
|
|
<div className="runtime-settings-readonly-field">
|
|
<span>工作方式</span>
|
|
<strong>陶泥儿智能创作(固定)</strong>
|
|
<small>需求将由官方智能服务执行</small>
|
|
</div>
|
|
<div className="runtime-settings-readonly-field">
|
|
<span>智能服务</span>
|
|
<strong>官方账号服务(固定)</strong>
|
|
<small>登录后自动使用当前账号权限。</small>
|
|
</div>
|
|
{(() => {
|
|
const credential = formatAccountCredentialState(
|
|
effectiveLlmConfigStatus,
|
|
);
|
|
return (
|
|
<div
|
|
className="runtime-settings-readonly-field"
|
|
data-tone={credential.tone}
|
|
data-testid="runtime-settings-account-credential-state"
|
|
>
|
|
<span>账号权限</span>
|
|
<strong>{credential.label}</strong>
|
|
<small>
|
|
{credential.tone === 'success'
|
|
? '当前账号已具备智能创作权限。'
|
|
: '请返回登录页完成登录或重新登录;普通用户无需填写任何智能服务凭据。'}
|
|
</small>
|
|
</div>
|
|
);
|
|
})()}
|
|
{runtimeConfigDraft.agentMode !== 'codex_cli' ? (
|
|
<>
|
|
<label>
|
|
推理档
|
|
<select
|
|
aria-label="推理档"
|
|
value={runtimeConfigDraft.llm.reasoningEffort}
|
|
onChange={(event) =>
|
|
updateRuntimeLlmConfig(
|
|
'reasoningEffort',
|
|
event.currentTarget
|
|
.value as GameCreatorLlmReasoningEffort,
|
|
)
|
|
}
|
|
>
|
|
{gameCreatorLlmReasoningEfforts.map((effort) => (
|
|
<option key={effort} value={effort}>
|
|
{effort}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="settings-checkbox">
|
|
<input
|
|
aria-label="流式输出"
|
|
type="checkbox"
|
|
checked={runtimeConfigDraft.llm.stream}
|
|
onChange={(event) =>
|
|
updateRuntimeLlmConfig(
|
|
'stream',
|
|
event.currentTarget.checked,
|
|
)
|
|
}
|
|
/>
|
|
流式输出
|
|
</label>
|
|
<label className="settings-checkbox">
|
|
<input
|
|
aria-label="联网检索"
|
|
type="checkbox"
|
|
checked={runtimeConfigDraft.llm.webSearchEnabled}
|
|
onChange={(event) =>
|
|
updateRuntimeLlmConfig(
|
|
'webSearchEnabled',
|
|
event.currentTarget.checked,
|
|
)
|
|
}
|
|
/>
|
|
联网检索
|
|
</label>
|
|
</>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
{activeSection === 'advanced' &&
|
|
runtimeConfigDraft.agentMode !== 'codex_cli' ? (
|
|
<>
|
|
<label>
|
|
上下文窗口 tokens
|
|
<input
|
|
aria-label="上下文窗口 tokens"
|
|
type="number"
|
|
min="1"
|
|
value={runtimeConfigDraft.llm.contextWindowTokens}
|
|
onChange={(event) =>
|
|
updateRuntimeLlmConfig(
|
|
'contextWindowTokens',
|
|
Math.max(1, Number(event.currentTarget.value) || 1),
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label>
|
|
自动压缩阈值 tokens
|
|
<input
|
|
aria-label="自动压缩阈值 tokens"
|
|
type="number"
|
|
min="1"
|
|
value={runtimeConfigDraft.llm.autoCompactTokenLimit}
|
|
onChange={(event) =>
|
|
updateRuntimeLlmConfig(
|
|
'autoCompactTokenLimit',
|
|
Math.max(1, Number(event.currentTarget.value) || 1),
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label>
|
|
工具输出上限 tokens
|
|
<input
|
|
aria-label="工具输出上限 tokens"
|
|
type="number"
|
|
min="1"
|
|
value={runtimeConfigDraft.llm.toolOutputTokenLimit}
|
|
onChange={(event) =>
|
|
updateRuntimeLlmConfig(
|
|
'toolOutputTokenLimit',
|
|
Math.max(1, Number(event.currentTarget.value) || 1),
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label>
|
|
请求超时 ms
|
|
<input
|
|
aria-label="请求超时 ms"
|
|
type="number"
|
|
min="1000"
|
|
value={runtimeConfigDraft.llm.requestTimeoutMs}
|
|
onChange={(event) =>
|
|
updateRuntimeLlmConfig(
|
|
'requestTimeoutMs',
|
|
Math.max(
|
|
1000,
|
|
Number(event.currentTarget.value) || 1000,
|
|
),
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label>
|
|
重试次数
|
|
<input
|
|
aria-label="重试次数"
|
|
type="number"
|
|
min="0"
|
|
value={runtimeConfigDraft.llm.maxRetries}
|
|
onChange={(event) =>
|
|
updateRuntimeLlmConfig(
|
|
'maxRetries',
|
|
Math.max(0, Number(event.currentTarget.value) || 0),
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label>
|
|
重试退避 ms
|
|
<input
|
|
aria-label="重试退避 ms"
|
|
type="number"
|
|
min="1"
|
|
value={runtimeConfigDraft.llm.retryBackoffMs}
|
|
onChange={(event) =>
|
|
updateRuntimeLlmConfig(
|
|
'retryBackoffMs',
|
|
Math.max(1, Number(event.currentTarget.value) || 1),
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</>
|
|
) : null}
|
|
{activeSection === 'agents' ? (
|
|
<div className="runtime-settings-readonly-field">
|
|
<span>角色服务</span>
|
|
<strong>所有角色使用统一账号服务</strong>
|
|
<small>普通用户无需配置服务连接信息。</small>
|
|
</div>
|
|
) : null}
|
|
{activeSection === 'connections' ? (
|
|
<section
|
|
className="runtime-settings-section"
|
|
aria-label="外部连接"
|
|
>
|
|
<header className="runtime-settings-section-header">
|
|
<div>
|
|
<h3>外部连接</h3>
|
|
<p>配置图片编辑器使用的外部服务</p>
|
|
</div>
|
|
</header>
|
|
{advancedExternalEditorConfigEnabled ? (
|
|
<div className="runtime-settings-fields">
|
|
<label>
|
|
External Editor Base URL
|
|
<input
|
|
aria-label="External Editor Base URL"
|
|
value={runtimeConfigDraft.editorApi.baseUrl}
|
|
onChange={(event) =>
|
|
setRuntimeConfigDraft((current) => ({
|
|
...current,
|
|
editorApi: {
|
|
...current.editorApi,
|
|
baseUrl: event.currentTarget.value,
|
|
},
|
|
}))
|
|
}
|
|
/>
|
|
</label>
|
|
<label>
|
|
External Editor API Key
|
|
<input
|
|
aria-label="External Editor API Key"
|
|
autoComplete="off"
|
|
type="password"
|
|
value={runtimeConfigDraft.editorApi.apiKey}
|
|
onChange={(event) =>
|
|
setRuntimeConfigDraft((current) => ({
|
|
...current,
|
|
editorApi: {
|
|
...current.editorApi,
|
|
apiKey: event.currentTarget.value,
|
|
},
|
|
}))
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
) : null}
|
|
{activeSection === 'extensions' ? (
|
|
<section
|
|
className="runtime-settings-section runtime-settings-extensions"
|
|
aria-label="客户端扩展"
|
|
>
|
|
{clientExtensionsStatus &&
|
|
clientExtensionsLoadState !== 'error' ? (
|
|
<p className="runtime-settings-inline-status" role="status">
|
|
{clientExtensionsStatus}
|
|
</p>
|
|
) : null}
|
|
{clientExtensionsLoadState === 'loading' ? (
|
|
<div className="runtime-settings-empty-state" role="status">
|
|
<strong>正在加载扩展</strong>
|
|
<span>正在读取已导入的 Skill 和 MCP。</span>
|
|
</div>
|
|
) : clientExtensionsLoadState === 'error' ? (
|
|
<div className="runtime-settings-empty-state" role="alert">
|
|
<strong>扩展列表加载失败</strong>
|
|
<span>
|
|
{clientExtensionsStatus || '暂时无法读取扩展列表。'}
|
|
</span>
|
|
</div>
|
|
) : clientExtensions.length > 0 ? (
|
|
<div className="runtime-settings-extension-list">
|
|
{clientExtensions.map((item) => {
|
|
const editing = editingExtensionId === item.id;
|
|
const typeLabel =
|
|
item.extensionType === 'skill'
|
|
? 'Skill'
|
|
: item.extensionType === 'mcp'
|
|
? 'MCP'
|
|
: '未识别';
|
|
const statusLabel =
|
|
item.status === 'enabled'
|
|
? '已启用'
|
|
: item.status === 'disabled'
|
|
? '已禁用'
|
|
: item.status === 'startup-failed'
|
|
? '启动失败'
|
|
: '当前不可用';
|
|
return (
|
|
<article
|
|
className="runtime-settings-extension-item"
|
|
key={item.id}
|
|
>
|
|
<div className="runtime-settings-extension-main">
|
|
{editing ? (
|
|
<input
|
|
aria-label={`${item.name} 新名称`}
|
|
autoFocus
|
|
value={editingExtensionName}
|
|
onChange={(event) =>
|
|
setEditingExtensionName(
|
|
event.currentTarget.value,
|
|
)
|
|
}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
void saveClientExtensionName(item);
|
|
} else if (event.key === 'Escape') {
|
|
event.stopPropagation();
|
|
cancelRenameClientExtension();
|
|
}
|
|
}}
|
|
/>
|
|
) : (
|
|
<strong title={item.name}>{item.name}</strong>
|
|
)}
|
|
<span>
|
|
{typeLabel} · 来自 {item.sourceName}
|
|
</span>
|
|
{item.lastError ? (
|
|
<small title={item.lastError}>
|
|
{item.lastError}
|
|
</small>
|
|
) : null}
|
|
</div>
|
|
<div className="runtime-settings-extension-actions">
|
|
<span>{statusLabel}</span>
|
|
{editing ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
disabled={clientExtensionsBusy}
|
|
onClick={() =>
|
|
void saveClientExtensionName(item)
|
|
}
|
|
>
|
|
保存
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={clientExtensionsBusy}
|
|
onClick={cancelRenameClientExtension}
|
|
>
|
|
取消
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
aria-label={`重命名 ${item.name}`}
|
|
disabled={clientExtensionsBusy}
|
|
onClick={() =>
|
|
beginRenameClientExtension(item)
|
|
}
|
|
>
|
|
<Pencil size={14} aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
{item.extensionType === 'unknown' ? null : (
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={item.enabled}
|
|
aria-label={`${item.name} ${item.enabled ? '禁用' : '启用'}`}
|
|
disabled={clientExtensionsBusy}
|
|
onClick={() =>
|
|
void setClientExtensionEnabled(
|
|
item,
|
|
!item.enabled,
|
|
)
|
|
}
|
|
>
|
|
{item.enabled ? '禁用' : '启用'}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
aria-label={`删除 ${item.name}`}
|
|
disabled={clientExtensionsBusy}
|
|
onClick={() => void removeClientExtension(item)}
|
|
>
|
|
<Trash2 size={14} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="runtime-settings-empty-state">
|
|
<strong>还没有导入扩展</strong>
|
|
<span>
|
|
导入 Skill、MCP 配置或标准 Plugin 后会显示在这里。
|
|
</span>
|
|
</div>
|
|
)}
|
|
</section>
|
|
) : null}
|
|
{activeSection === 'about' ? (
|
|
<section
|
|
className="runtime-settings-about"
|
|
aria-label="关于客户端"
|
|
>
|
|
<div className="runtime-settings-about-hero">
|
|
<div
|
|
className="runtime-settings-about-mark"
|
|
aria-hidden="true"
|
|
>
|
|
<img
|
|
src={BRAND_ICON}
|
|
alt=""
|
|
data-testid="runtime-settings-app-logo"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<span className="runtime-settings-about-kicker">
|
|
GENARRATIVE
|
|
</span>
|
|
<h4>{APP_NAME}</h4>
|
|
<p>面向游戏创作者的智能创作客户端</p>
|
|
</div>
|
|
</div>
|
|
<dl className="runtime-settings-about-meta">
|
|
<div>
|
|
<dt>客户端版本</dt>
|
|
<dd data-testid="runtime-settings-app-version">
|
|
v{APP_VERSION}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>运行环境</dt>
|
|
<dd>桌面客户端</dd>
|
|
</div>
|
|
</dl>
|
|
<div className="runtime-settings-about-update">
|
|
<button
|
|
type="button"
|
|
onClick={() => void checkAppUpdateManually()}
|
|
disabled={appUpdateChecking}
|
|
>
|
|
{appUpdateChecking ? '正在检查…' : '检查更新'}
|
|
</button>
|
|
<span role="status" aria-live="polite">
|
|
{appUpdateStatus}
|
|
</span>
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
<footer className="runtime-settings-footer">
|
|
<p
|
|
className="status-line"
|
|
data-tone={runtimeConfigStatusTone}
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
{runtimeConfigStatusTone === 'busy' ? (
|
|
<LoaderCircle
|
|
className="is-spinning"
|
|
size={15}
|
|
aria-hidden="true"
|
|
/>
|
|
) : runtimeConfigStatusTone === 'success' ? (
|
|
<CheckCircle2 size={15} aria-hidden="true" />
|
|
) : runtimeConfigStatusTone === 'warning' ? (
|
|
<CircleAlert size={15} aria-hidden="true" />
|
|
) : null}
|
|
{runtimeConfigStatus}
|
|
</p>
|
|
<div className="runtime-settings-footer-actions">
|
|
<button
|
|
type="button"
|
|
aria-label="读取"
|
|
disabled={runtimeConfigBusy}
|
|
onClick={readRuntimeConfig}
|
|
>
|
|
重新读取
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={runtimeConfigBusy}
|
|
onClick={resetRuntimeConfigDraft}
|
|
>
|
|
<RotateCcw size={15} aria-hidden="true" />
|
|
恢复默认
|
|
</button>
|
|
<button
|
|
className="runtime-settings-save"
|
|
type="submit"
|
|
aria-label="保存"
|
|
disabled={runtimeConfigBusy}
|
|
>
|
|
<Save size={15} aria-hidden="true" />
|
|
{runtimeConfigStatus === '正在保存'
|
|
? '正在保存'
|
|
: runtimeConfigBusy
|
|
? '请稍候'
|
|
: '保存设置'}
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|