Files
Genarrative/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx
T
kdletters 524e2f0f4f
Project CI / Repository checks (push) Successful in 2m34s
Project CI / Frontend tests (push) Successful in 3m3s
Project CI / Backend tests (push) Successful in 6m53s
Project CI / Native shell tests (push) Successful in 17m53s
删除连接与工具设置页
移除运行设置中的连接与工具导航及页面内容
更新运行设置测试以校验该入口已删除
同步配置门禁,防止已删除页面重新出现
2026-09-05 17:39:05 +08:00

1709 lines
63 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Bot,
CheckCircle2,
CircleAlert,
Info,
LoaderCircle,
Pencil,
RotateCcw,
Save,
Settings2,
SlidersHorizontal,
Trash2,
Upload,
X,
} from 'lucide-react';
import { type FormEvent, useEffect, useRef, useState } from 'react';
import { createGameCreationAppSeedTasks } from '../../../../../packages/shared/src/contracts/gameCreationApp';
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 GameCreatorAgentLlmConfig,
type GameCreatorAppConfig,
type GameCreatorAppConfigView,
type GameCreatorLlmApiKind,
type GameCreatorLlmConfig,
type GameCreatorLlmReasoningEffort,
gameCreatorLlmReasoningEfforts,
type RuntimeAgentLlmProviderPresetId,
type RuntimeLlmProviderPresetId,
} from '../../app/types';
import { checkForAppUpdate } from '../../services/appUpdate';
const runtimeAgentReasoningEffortDefaults = {
'project-supervisor': 'high',
planner: 'high',
orchestrator: 'medium',
generator: 'high',
evaluator: 'high',
'design-director': 'medium',
'design-foundation': 'high',
'balance-director': 'medium',
'balance-seed': 'medium',
'art-director': 'high',
'art-asset-plan': 'high',
'art-polish': 'medium',
'audio-director': 'low',
'audio-asset-plan': 'medium',
'code-director': 'medium',
'code-prototype': 'high',
'quality-review': 'high',
'preview-readiness': 'low',
'preview-playtest': 'low',
'publish-strategy': 'low',
'publish-package': 'medium',
} as const satisfies Record<string, GameCreatorLlmReasoningEffort>;
const defaultRuntimeConfigDraft: GameCreatorAppConfig = {
agentMode: 'codex_app_server',
llm: {
apiKey: '',
baseUrl: 'https://dev.genarrative.world/gpt/v1',
model: 'gpt-5.6-sol',
apiKind: 'openai_responses',
reasoningEffort: 'max',
stream: true,
webSearchEnabled: false,
contextWindowTokens: 128000,
autoCompactTokenLimit: 64000,
toolOutputTokenLimit: 12000,
requestTimeoutMs: 180000,
maxRetries: 2,
retryBackoffMs: 500,
},
agentLlm: {},
editorApi: {
baseUrl: 'https://dev.genarrative.world',
apiKey: '',
},
};
type RuntimeSettingsSection =
| 'general'
| 'agents'
| '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: '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;
}>;
const runtimeCoreAgentLlmRows = [
{ id: 'planner', label: 'Planner' },
{ id: 'orchestrator', label: 'Orchestrator' },
{ id: 'generator', label: 'Generator' },
{ id: 'evaluator', label: 'Evaluator' },
] as const;
const runtimeAgentLlmRows = [
{ id: 'project-supervisor', label: '项目总控 Agent' },
...runtimeCoreAgentLlmRows,
...createGameCreationAppSeedTasks().map((task) => ({
id: task.id,
label: `${task.title} (${task.group}/${task.role})`,
})),
] satisfies Array<{ id: string; label: string }>;
const runtimeLlmProviderPresets: Array<{
id: RuntimeLlmProviderPresetId;
label: string;
baseUrl: string;
model: string;
apiKind: GameCreatorLlmApiKind;
}> = [
{
id: 'openai',
label: 'OpenAI',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4.1',
apiKind: 'openai_responses',
},
{
id: 'deepseek',
label: 'DeepSeek',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-chat',
apiKind: 'openai_chat',
},
{
id: 'anthropic',
label: 'Anthropic',
baseUrl: 'https://api.anthropic.com',
model: 'claude-3-5-sonnet-latest',
apiKind: 'anthropic',
},
{
id: 'ark',
label: '火山 Ark',
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
model: 'doubao-seed-1-6',
apiKind: 'openai_chat',
},
];
function findRuntimeLlmProviderPreset(
config: Pick<GameCreatorLlmConfig, 'baseUrl' | 'model' | 'apiKind'>,
) {
return runtimeLlmProviderPresets.find(
(preset) =>
preset.baseUrl === config.baseUrl &&
preset.model === config.model &&
preset.apiKind === config.apiKind,
);
}
function resolveRuntimeLlmProviderPresetId(
config: Pick<GameCreatorLlmConfig, 'baseUrl' | 'model' | 'apiKind'>,
): RuntimeLlmProviderPresetId {
return findRuntimeLlmProviderPreset(config)?.id ?? 'custom';
}
function resolveRuntimeAgentLlmProviderPresetId(
config: GameCreatorAgentLlmConfig,
): RuntimeAgentLlmProviderPresetId {
if (!config.baseUrl && !config.model && !config.apiKind) {
return 'inherit';
}
return (
findRuntimeLlmProviderPreset({
baseUrl: config.baseUrl ?? '',
model: config.model ?? '',
apiKind: config.apiKind ?? defaultRuntimeConfigDraft.llm.apiKind,
})?.id ?? 'custom'
);
}
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 normalizeRuntimeAgentLlmConfig(
config: GameCreatorAgentLlmConfig | undefined,
): GameCreatorAgentLlmConfig {
if (!config) {
return {};
}
const normalized: GameCreatorAgentLlmConfig = {};
if (typeof config.apiKey === 'string' && config.apiKey.trim()) {
normalized.apiKey = config.apiKey;
}
if (typeof config.baseUrl === 'string' && config.baseUrl.trim()) {
normalized.baseUrl = config.baseUrl;
}
if (typeof config.model === 'string' && config.model.trim()) {
normalized.model = config.model;
}
if (
config.apiKind &&
['openai_responses', 'openai_chat', 'anthropic'].includes(config.apiKind)
) {
normalized.apiKind = config.apiKind;
}
if (isGameCreatorLlmReasoningEffort(config.reasoningEffort)) {
normalized.reasoningEffort = config.reasoningEffort;
}
if (typeof config.stream === 'boolean') {
normalized.stream = config.stream;
}
if (typeof config.webSearchEnabled === 'boolean') {
normalized.webSearchEnabled = config.webSearchEnabled;
}
if (typeof config.contextWindowTokens === 'number') {
normalized.contextWindowTokens = clampRuntimeConfigNumber(
config.contextWindowTokens,
1,
);
}
if (typeof config.autoCompactTokenLimit === 'number') {
normalized.autoCompactTokenLimit = clampRuntimeConfigNumber(
config.autoCompactTokenLimit,
1,
);
}
if (typeof config.toolOutputTokenLimit === 'number') {
normalized.toolOutputTokenLimit = clampRuntimeConfigNumber(
config.toolOutputTokenLimit,
1,
);
}
if (typeof config.requestTimeoutMs === 'number') {
normalized.requestTimeoutMs = clampRuntimeConfigNumber(
config.requestTimeoutMs,
1000,
);
}
if (typeof config.maxRetries === 'number') {
normalized.maxRetries = clampRuntimeConfigNumber(config.maxRetries, 0);
}
if (typeof config.retryBackoffMs === 'number') {
normalized.retryBackoffMs = clampRuntimeConfigNumber(
config.retryBackoffMs,
1,
);
}
return normalized;
}
function normalizeRuntimeConfigDraft(
config: GameCreatorAppConfig,
allowAdvancedExternalEditorConfig: boolean,
): GameCreatorAppConfig {
const apiKind: GameCreatorLlmApiKind = [
'openai_responses',
'openai_chat',
'anthropic',
].includes(config.llm.apiKind)
? config.llm.apiKind
: defaultRuntimeConfigDraft.llm.apiKind;
const reasoningEffort = isGameCreatorLlmReasoningEffort(
config.llm.reasoningEffort,
)
? config.llm.reasoningEffort
: defaultRuntimeConfigDraft.llm.reasoningEffort;
const agentLlm: Record<string, GameCreatorAgentLlmConfig> = {};
for (const [agentId, agentConfig] of Object.entries(config.agentLlm ?? {})) {
const normalized = normalizeRuntimeAgentLlmConfig(agentConfig);
if (Object.keys(normalized).length > 0) {
agentLlm[agentId] = normalized;
}
}
return {
...config,
agentMode: config.agentMode,
llm: {
...config.llm,
apiKind,
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),
},
agentLlm,
editorApi: allowAdvancedExternalEditorConfig
? { ...defaultRuntimeConfigDraft.editorApi, ...config.editorApi }
: { ...defaultRuntimeConfigDraft.editorApi },
};
}
export function RuntimeConfigDialog({
allowAdvancedExternalEditorConfig = false,
onClose,
onLog,
}: {
projectPath?: string;
allowAdvancedExternalEditorConfig?: boolean;
onClose: () => void;
onLog?: (entry: string) => void;
}) {
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 [activeSection, setActiveSection] =
useState<RuntimeSettingsSection>('general');
const [expandedAgentIds, setExpandedAgentIds] = useState<string[]>([]);
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(() => {
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,
},
}));
}
function updateRuntimeLlmProviderPreset(
presetId: RuntimeLlmProviderPresetId,
) {
const preset = runtimeLlmProviderPresets.find(
(candidate) => candidate.id === presetId,
);
if (!preset) {
return;
}
setRuntimeConfigDraft((current) => ({
...current,
llm: {
...current.llm,
baseUrl: preset.baseUrl,
model: preset.model,
apiKind: preset.apiKind,
},
}));
}
function updateRuntimeAgentLlmConfig<
K extends keyof GameCreatorAgentLlmConfig,
>(agentId: string, key: K, value: GameCreatorAgentLlmConfig[K]) {
setRuntimeConfigDraft((current) => ({
...current,
agentLlm: {
...(current.agentLlm ?? {}),
[agentId]: {
...(current.agentLlm?.[agentId] ?? {}),
[key]: value,
},
},
}));
}
function updateRuntimeAgentLlmProviderPreset(
agentId: string,
presetId: RuntimeAgentLlmProviderPresetId,
) {
if (presetId === 'inherit') {
setRuntimeConfigDraft((current) => {
const nextConfig = { ...(current.agentLlm?.[agentId] ?? {}) };
delete nextConfig.baseUrl;
delete nextConfig.model;
delete nextConfig.apiKind;
return {
...current,
agentLlm: {
...(current.agentLlm ?? {}),
[agentId]: nextConfig,
},
};
});
return;
}
const preset = runtimeLlmProviderPresets.find(
(candidate) => candidate.id === presetId,
);
if (!preset) {
return;
}
setRuntimeConfigDraft((current) => ({
...current,
agentLlm: {
...(current.agentLlm ?? {}),
[agentId]: {
...(current.agentLlm?.[agentId] ?? {}),
baseUrl: preset.baseUrl,
model: preset.model,
apiKind: preset.apiKind,
},
},
}));
}
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,
allowAdvancedExternalEditorConfig,
);
setRuntimeConfigDraft(config);
setRuntimeConfigStatus(`已读取:${result.path}`);
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: allowAdvancedExternalEditorConfig
? runtimeConfigDraft.editorApi
: defaultRuntimeConfigDraft.editorApi,
},
allowAdvancedExternalEditorConfig,
);
const result = await invoke<GameCreatorAppConfigView>(
'write_game_creator_app_config',
{ config },
);
setRuntimeConfigPath(result.path);
const savedConfig = normalizeRuntimeConfigDraft(
result.config,
allowAdvancedExternalEditorConfig,
);
setRuntimeConfigDraft(savedConfig);
setRuntimeConfigStatus(`已保存:${result.path}`);
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 configuredAgentCount = Object.values(
runtimeConfigDraft.agentLlm ?? {},
).filter((config) =>
Object.values(config).some((value) => value !== undefined && value !== ''),
).length;
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 === 'agents' ? (
<span>{configuredAgentCount} 个角色已覆盖</span>
) : 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>Agent 模式</span>
<strong>陶泥儿智能创作(固定)</strong>
<small>需求将由陶泥儿智能创作服务执行</small>
</div>
{runtimeConfigDraft.agentMode !== 'codex_cli' ? (
<>
<label>
LLM Provider
<select
aria-label="LLM Provider"
value={resolveRuntimeLlmProviderPresetId(
runtimeConfigDraft.llm,
)}
onChange={(event) =>
updateRuntimeLlmProviderPreset(
event.currentTarget
.value as RuntimeLlmProviderPresetId,
)
}
>
<option value="custom">自定义</option>
{runtimeLlmProviderPresets.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.label}
</option>
))}
</select>
</label>
<label>
LLM API Key
<input
aria-label="LLM API Key"
autoComplete="off"
type="password"
value={runtimeConfigDraft.llm.apiKey}
onChange={(event) =>
updateRuntimeLlmConfig(
'apiKey',
event.currentTarget.value,
)
}
/>
</label>
<label>
LLM Base URL
<input
aria-label="LLM Base URL"
value={runtimeConfigDraft.llm.baseUrl}
onChange={(event) =>
updateRuntimeLlmConfig(
'baseUrl',
event.currentTarget.value,
)
}
/>
</label>
<label>
LLM 模型
<input
aria-label="LLM 模型"
value={runtimeConfigDraft.llm.model}
onChange={(event) =>
updateRuntimeLlmConfig(
'model',
event.currentTarget.value,
)
}
/>
</label>
<label>
LLM API 类型
<select
aria-label="LLM API 类型"
value={runtimeConfigDraft.llm.apiKind}
onChange={(event) =>
updateRuntimeLlmConfig(
'apiKind',
event.currentTarget
.value as GameCreatorLlmApiKind,
)
}
>
<option value="openai_responses">
openai_responses
</option>
<option value="openai_chat">openai_chat</option>
<option value="anthropic">anthropic</option>
</select>
</label>
<label>
LLM 推理档
<select
aria-label="LLM 推理档"
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="LLM 流式请求"
type="checkbox"
checked={runtimeConfigDraft.llm.stream}
onChange={(event) =>
updateRuntimeLlmConfig(
'stream',
event.currentTarget.checked,
)
}
/>
LLM 流式请求
</label>
<label className="settings-checkbox">
<input
aria-label="LLM 联网检索"
type="checkbox"
checked={runtimeConfigDraft.llm.webSearchEnabled}
onChange={(event) =>
updateRuntimeLlmConfig(
'webSearchEnabled',
event.currentTarget.checked,
)
}
/>
LLM 联网检索
</label>
</>
) : null}
</>
) : null}
{activeSection === 'advanced' &&
runtimeConfigDraft.agentMode !== 'codex_cli' ? (
<>
<label>
LLM 上下文窗口 tokens
<input
aria-label="LLM 上下文窗口 tokens"
type="number"
min="1"
value={runtimeConfigDraft.llm.contextWindowTokens}
onChange={(event) =>
updateRuntimeLlmConfig(
'contextWindowTokens',
Math.max(1, Number(event.currentTarget.value) || 1),
)
}
/>
</label>
<label>
LLM 自动压缩阈值 tokens
<input
aria-label="LLM 自动压缩阈值 tokens"
type="number"
min="1"
value={runtimeConfigDraft.llm.autoCompactTokenLimit}
onChange={(event) =>
updateRuntimeLlmConfig(
'autoCompactTokenLimit',
Math.max(1, Number(event.currentTarget.value) || 1),
)
}
/>
</label>
<label>
LLM 工具输出上限 tokens
<input
aria-label="LLM 工具输出上限 tokens"
type="number"
min="1"
value={runtimeConfigDraft.llm.toolOutputTokenLimit}
onChange={(event) =>
updateRuntimeLlmConfig(
'toolOutputTokenLimit',
Math.max(1, Number(event.currentTarget.value) || 1),
)
}
/>
</label>
<label>
LLM 超时 ms
<input
aria-label="LLM 超时 ms"
type="number"
min="1000"
value={runtimeConfigDraft.llm.requestTimeoutMs}
onChange={(event) =>
updateRuntimeLlmConfig(
'requestTimeoutMs',
Math.max(
1000,
Number(event.currentTarget.value) || 1000,
),
)
}
/>
</label>
<label>
LLM 重试次数
<input
aria-label="LLM 重试次数"
type="number"
min="0"
value={runtimeConfigDraft.llm.maxRetries}
onChange={(event) =>
updateRuntimeLlmConfig(
'maxRetries',
Math.max(0, Number(event.currentTarget.value) || 0),
)
}
/>
</label>
<label>
LLM 退避 ms
<input
aria-label="LLM 退避 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' &&
runtimeConfigDraft.agentMode !== 'codex_cli' ? (
<div className="runtime-agent-list">
{runtimeAgentLlmRows.map((agent) => {
const agentLlm =
runtimeConfigDraft.agentLlm?.[agent.id] ?? {};
const defaultReasoningEffort =
runtimeAgentReasoningEffortDefaults[
agent.id as keyof typeof runtimeAgentReasoningEffortDefaults
];
return (
<article className="runtime-agent-card" key={agent.id}>
<button
className="runtime-agent-card-summary"
type="button"
aria-expanded={expandedAgentIds.includes(agent.id)}
onClick={() =>
setExpandedAgentIds((current) =>
current.includes(agent.id)
? current.filter((id) => id !== agent.id)
: [...current, agent.id],
)
}
>
<span>
<strong>{agent.label}</strong>
<small>{agent.id}</small>
</span>
<span>
{resolveRuntimeAgentLlmProviderPresetId(
agentLlm,
) === 'inherit'
? `继承默认 · ${defaultReasoningEffort}`
: `${agentLlm.model || '自定义模型'} · ${agentLlm.reasoningEffort || defaultReasoningEffort}`}
</span>
</button>
{expandedAgentIds.includes(agent.id) ? (
<div className="runtime-agent-card-fields">
<label>
{agent.label} LLM Provider
<select
aria-label={`${agent.label} LLM Provider`}
value={resolveRuntimeAgentLlmProviderPresetId(
agentLlm,
)}
onChange={(event) =>
updateRuntimeAgentLlmProviderPreset(
agent.id,
event.currentTarget
.value as RuntimeAgentLlmProviderPresetId,
)
}
>
<option value="inherit">继承</option>
<option value="custom">自定义</option>
{runtimeLlmProviderPresets.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.label}
</option>
))}
</select>
</label>
<label>
{agent.label} LLM API Key
<input
aria-label={`${agent.label} LLM API Key`}
autoComplete="off"
type="password"
value={agentLlm.apiKey ?? ''}
onChange={(event) =>
updateRuntimeAgentLlmConfig(
agent.id,
'apiKey',
event.currentTarget.value,
)
}
/>
</label>
<label>
{agent.label} LLM Base URL
<input
aria-label={`${agent.label} LLM Base URL`}
value={agentLlm.baseUrl ?? ''}
onChange={(event) =>
updateRuntimeAgentLlmConfig(
agent.id,
'baseUrl',
event.currentTarget.value,
)
}
/>
</label>
<label>
{agent.label} LLM 模型
<input
aria-label={`${agent.label} LLM 模型`}
value={agentLlm.model ?? ''}
onChange={(event) =>
updateRuntimeAgentLlmConfig(
agent.id,
'model',
event.currentTarget.value,
)
}
/>
</label>
<label>
{agent.label} LLM API 类型
<select
aria-label={`${agent.label} LLM API 类型`}
value={agentLlm.apiKind ?? ''}
onChange={(event) =>
updateRuntimeAgentLlmConfig(
agent.id,
'apiKind',
event.currentTarget.value
? (event.currentTarget
.value as GameCreatorLlmApiKind)
: undefined,
)
}
>
<option value="">继承</option>
<option value="openai_responses">
openai_responses
</option>
<option value="openai_chat">openai_chat</option>
<option value="anthropic">anthropic</option>
</select>
</label>
<label>
{agent.label} LLM 推理档
<select
aria-label={`${agent.label} LLM 推理档`}
value={agentLlm.reasoningEffort ?? ''}
onChange={(event) =>
updateRuntimeAgentLlmConfig(
agent.id,
'reasoningEffort',
event.currentTarget.value
? (event.currentTarget
.value as GameCreatorLlmReasoningEffort)
: undefined,
)
}
>
<option value="">
Agent 默认({defaultReasoningEffort}
</option>
{gameCreatorLlmReasoningEfforts.map(
(effort) => (
<option key={effort} value={effort}>
{effort}
</option>
),
)}
</select>
</label>
<label>
{agent.label} LLM 流式请求
<select
aria-label={`${agent.label} LLM 流式请求`}
value={
agentLlm.stream === undefined
? ''
: agentLlm.stream
? 'true'
: 'false'
}
onChange={(event) =>
updateRuntimeAgentLlmConfig(
agent.id,
'stream',
event.currentTarget.value
? event.currentTarget.value === 'true'
: undefined,
)
}
>
<option value="">继承</option>
<option value="true">开启</option>
<option value="false">关闭</option>
</select>
</label>
<label>
{agent.label} LLM 联网检索
<select
aria-label={`${agent.label} LLM 联网检索`}
value={
agentLlm.webSearchEnabled === undefined
? ''
: agentLlm.webSearchEnabled
? 'true'
: 'false'
}
onChange={(event) =>
updateRuntimeAgentLlmConfig(
agent.id,
'webSearchEnabled',
event.currentTarget.value
? event.currentTarget.value === 'true'
: undefined,
)
}
>
<option value="">继承</option>
<option value="true">开启</option>
<option value="false">关闭</option>
</select>
</label>
<label>
{agent.label} 上下文窗口 tokens
<input
aria-label={`${agent.label} 上下文窗口 tokens`}
type="number"
min="1"
placeholder="继承"
value={agentLlm.contextWindowTokens ?? ''}
onChange={(event) =>
updateRuntimeAgentLlmConfig(
agent.id,
'contextWindowTokens',
event.currentTarget.value
? Math.max(
1,
Number(event.currentTarget.value) ||
1,
)
: undefined,
)
}
/>
</label>
<label>
{agent.label} 自动压缩阈值 tokens
<input
aria-label={`${agent.label} 自动压缩阈值 tokens`}
type="number"
min="1"
placeholder="继承"
value={agentLlm.autoCompactTokenLimit ?? ''}
onChange={(event) =>
updateRuntimeAgentLlmConfig(
agent.id,
'autoCompactTokenLimit',
event.currentTarget.value
? Math.max(
1,
Number(event.currentTarget.value) ||
1,
)
: undefined,
)
}
/>
</label>
<label>
{agent.label} 工具输出上限 tokens
<input
aria-label={`${agent.label} 工具输出上限 tokens`}
type="number"
min="1"
placeholder="继承"
value={agentLlm.toolOutputTokenLimit ?? ''}
onChange={(event) =>
updateRuntimeAgentLlmConfig(
agent.id,
'toolOutputTokenLimit',
event.currentTarget.value
? Math.max(
1,
Number(event.currentTarget.value) ||
1,
)
: undefined,
)
}
/>
</label>
</div>
) : null}
</article>
);
})}
</div>
) : 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>
导入 SkillMCP 配置或标准 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>
);
}