Files
Genarrative/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx
T
menghao 03a4410272
Project CI / Repository checks (push) Successful in 1m5s
Project CI / Frontend tests (push) Successful in 3m20s
Project CI / Backend tests (push) Successful in 4m1s
Project CI / Native shell tests (push) Failing after 11m56s
game agent无限画布开发 (#136)
主要工作是共享同一套“画布内核与通用 UI 源码”,网站和 Tauri 只分别实现宿主适配层。

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/136
Co-authored-by: menghao <mh18530625731@163.com>
Co-committed-by: menghao <mh18530625731@163.com>
2026-08-12 10:54:16 +08:00

1830 lines
66 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 { Plus, Trash2, Zap } from 'lucide-react';
import { type FormEvent, Fragment, useEffect, useRef, useState } from 'react';
import { createGameCreationAppSeedTasks } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
closeDialogOnBackdropMouseDown,
closeDialogOnEscape,
useEscapeToClose,
} from '../../app/dialogs';
import { resolveTauriInvoke } from '../../app/tauri';
import {
type GameCreatorAgentLlmConfig,
type GameCreatorAgentMode,
type GameCreatorAppConfig,
type GameCreatorAppConfigView,
type GameCreatorLlmApiKind,
type GameCreatorLlmConfig,
type GameCreatorLlmReasoningEffort,
gameCreatorLlmReasoningEfforts,
type GameCreatorMcpApprovalMode,
type GameCreatorMcpCatalog,
type GameCreatorMcpServerConfig,
type GameCreatorMcpToolConfig,
type GameCreatorMcpTransport,
type RuntimeAgentLlmProviderPresetId,
type RuntimeLlmProviderPresetId,
type RuntimeMcpStructuredDraft,
} from '../../app/types';
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://api.openai.com/v1',
model: 'gpt-4.1',
apiKind: 'openai_responses',
reasoningEffort: 'high',
stream: false,
webSearchEnabled: false,
contextWindowTokens: 128000,
autoCompactTokenLimit: 64000,
toolOutputTokenLimit: 12000,
requestTimeoutMs: 180000,
maxRetries: 2,
retryBackoffMs: 500,
},
agentLlm: {},
editorApi: {
baseUrl: 'http://127.0.0.1:8082',
apiKey: '',
},
mcpServers: {},
};
const runtimeAgentModes = new Set<GameCreatorAgentMode>([
'codex_app_server',
'codex_cli',
'provider',
]);
const defaultRuntimeMcpServerConfig: GameCreatorMcpServerConfig = {
enabled: true,
required: false,
transport: 'stdio',
command: '',
args: [],
cwd: '',
env: {},
url: '',
bearerToken: '',
httpHeaders: {},
allowInsecureLocalhost: false,
startupTimeoutMs: 10000,
toolTimeoutMs: 60000,
enabledTools: [],
disabledTools: [],
defaultApprovalMode: 'confirm',
tools: {},
};
function runtimeMcpStructuredDraft(
config: GameCreatorMcpServerConfig,
): RuntimeMcpStructuredDraft {
return {
args: JSON.stringify(config.args, null, 2),
env: JSON.stringify(config.env, null, 2),
httpHeaders: JSON.stringify(config.httpHeaders, null, 2),
enabledTools: JSON.stringify(config.enabledTools, null, 2),
disabledTools: JSON.stringify(config.disabledTools, null, 2),
tools: JSON.stringify(config.tools, null, 2),
};
}
function runtimeMcpStructuredDrafts(
servers: Record<string, GameCreatorMcpServerConfig>,
) {
return Object.fromEntries(
Object.entries(servers).map(([serverId, config]) => [
serverId,
runtimeMcpStructuredDraft(config),
]),
) as Record<string, RuntimeMcpStructuredDraft>;
}
function materializeRuntimeMcpServers(
servers: Record<string, GameCreatorMcpServerConfig>,
drafts: Record<string, RuntimeMcpStructuredDraft>,
) {
const parse = (serverId: string, field: string, source: string): unknown => {
try {
return JSON.parse(source);
} catch {
throw new Error(`MCP ${serverId} 的 ${field} 不是有效 JSON`);
}
};
const stringList = (serverId: string, field: string, source: string) => {
const value = parse(serverId, field, source);
if (
!Array.isArray(value) ||
value.some((item) => typeof item !== 'string')
) {
throw new Error(`MCP ${serverId} 的 ${field} 必须是字符串数组`);
}
return value as string[];
};
const stringMap = (serverId: string, field: string, source: string) => {
const value = parse(serverId, field, source);
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
Object.values(value).some((item) => typeof item !== 'string')
) {
throw new Error(`MCP ${serverId} 的 ${field} 必须是字符串对象`);
}
return value as Record<string, string>;
};
const toolMap = (serverId: string, source: string) => {
const value = parse(serverId, 'tools', source);
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`MCP ${serverId} 的 tools 必须是对象`);
}
for (const [toolName, tool] of Object.entries(value)) {
if (!tool || typeof tool !== 'object' || Array.isArray(tool)) {
throw new Error(`MCP ${serverId} 的 tools.${toolName} 必须是对象`);
}
const candidate = tool as Record<string, unknown>;
if (
candidate.enabled !== undefined &&
typeof candidate.enabled !== 'boolean'
) {
throw new Error(
`MCP ${serverId} 的 tools.${toolName}.enabled 必须是布尔值`,
);
}
if (
candidate.approvalMode !== undefined &&
!isGameCreatorMcpApprovalMode(candidate.approvalMode)
) {
throw new Error(
`MCP ${serverId} 的 tools.${toolName}.approvalMode 无效`,
);
}
}
return value as Record<string, GameCreatorMcpToolConfig>;
};
return Object.fromEntries(
Object.entries(servers).map(([serverId, config]) => {
const draft = drafts[serverId] ?? runtimeMcpStructuredDraft(config);
return [
serverId,
{
...config,
args: stringList(serverId, 'args', draft.args),
env: stringMap(serverId, 'env', draft.env),
httpHeaders: stringMap(serverId, 'httpHeaders', draft.httpHeaders),
enabledTools: stringList(
serverId,
'enabledTools',
draft.enabledTools,
),
disabledTools: stringList(
serverId,
'disabledTools',
draft.disabledTools,
),
tools: toolMap(serverId, draft.tools),
},
];
}),
) as Record<string, GameCreatorMcpServerConfig>;
}
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 isGameCreatorMcpApprovalMode(
value: unknown,
): value is GameCreatorMcpApprovalMode {
return ['auto', 'confirm', 'writes', 'deny'].includes(String(value));
}
function normalizeRuntimeMcpServerConfig(
config: Partial<GameCreatorMcpServerConfig> | undefined,
): GameCreatorMcpServerConfig {
const value = config ?? {};
const stringMap = (candidate: unknown) =>
candidate && typeof candidate === 'object' && !Array.isArray(candidate)
? Object.fromEntries(
Object.entries(candidate).filter(
(entry): entry is [string, string] =>
typeof entry[0] === 'string' && typeof entry[1] === 'string',
),
)
: {};
const stringList = (candidate: unknown) =>
Array.isArray(candidate)
? candidate.filter((item): item is string => typeof item === 'string')
: [];
const tools =
value.tools && typeof value.tools === 'object'
? Object.fromEntries(
Object.entries(value.tools).map(([toolName, tool]) => [
toolName,
{
...(typeof tool?.enabled === 'boolean'
? { enabled: tool.enabled }
: {}),
...(isGameCreatorMcpApprovalMode(tool?.approvalMode)
? { approvalMode: tool.approvalMode }
: {}),
},
]),
)
: {};
return {
...defaultRuntimeMcpServerConfig,
...value,
enabled: value.enabled !== false,
required: value.required === true,
transport:
value.transport === 'streamableHttp' ? 'streamableHttp' : 'stdio',
command: typeof value.command === 'string' ? value.command : '',
args: stringList(value.args),
cwd: typeof value.cwd === 'string' ? value.cwd : '',
env: stringMap(value.env),
url: typeof value.url === 'string' ? value.url : '',
bearerToken: typeof value.bearerToken === 'string' ? value.bearerToken : '',
httpHeaders: stringMap(value.httpHeaders),
allowInsecureLocalhost: value.allowInsecureLocalhost === true,
startupTimeoutMs: clampRuntimeConfigNumber(
Number(value.startupTimeoutMs ?? 10000),
250,
),
toolTimeoutMs: clampRuntimeConfigNumber(
Number(value.toolTimeoutMs ?? 60000),
250,
),
enabledTools: stringList(value.enabledTools),
disabledTools: stringList(value.disabledTools),
defaultApprovalMode: isGameCreatorMcpApprovalMode(value.defaultApprovalMode)
? value.defaultApprovalMode
: 'confirm',
tools,
};
}
function normalizeRuntimeConfigDraft(
config: GameCreatorAppConfig,
): 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;
}
}
const mcpServers = Object.fromEntries(
Object.entries(config.mcpServers ?? {}).map(([serverId, server]) => [
serverId,
normalizeRuntimeMcpServerConfig(server),
]),
);
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,
mcpServers,
};
}
export function RuntimeConfigDialog({
projectPath,
showDeveloperEditorApi = false,
onClose,
onLog,
}: {
projectPath?: string;
showDeveloperEditorApi?: boolean;
onClose: () => void;
onLog?: (entry: string) => void;
}) {
const [runtimeConfigPath, setRuntimeConfigPath] = useState('');
const [runtimeConfigStatus, setRuntimeConfigStatus] = useState('未读取');
const [runtimeConfigDraft, setRuntimeConfigDraft] =
useState<GameCreatorAppConfig>(defaultRuntimeConfigDraft);
const [runtimeConfigBusy, setRuntimeConfigBusy] = useState(false);
const [newMcpServerId, setNewMcpServerId] = useState('');
const [mcpStructuredDrafts, setMcpStructuredDrafts] = useState<
Record<string, RuntimeMcpStructuredDraft>
>({});
const [mcpCatalog, setMcpCatalog] = useState<GameCreatorMcpCatalog | null>(
null,
);
const runtimeConfigBusyRef = useRef(false);
useEscapeToClose(onClose);
useEffect(() => {
void readRuntimeConfig();
// The dialog reads once on mount; subsequent reads are explicit user actions.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function updateRuntimeLlmConfig<K extends keyof GameCreatorAppConfig['llm']>(
key: K,
value: GameCreatorAppConfig['llm'][K],
) {
setRuntimeConfigDraft((current) => ({
...current,
llm: {
...current.llm,
[key]: value,
},
}));
}
function updateRuntimeAgentMode(agentMode: GameCreatorAgentMode) {
setRuntimeConfigDraft((current) => ({ ...current, agentMode }));
}
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,
},
},
}));
}
function updateRuntimeEditorConfig<
K extends keyof GameCreatorAppConfig['editorApi'],
>(key: K, value: GameCreatorAppConfig['editorApi'][K]) {
setRuntimeConfigDraft((current) => ({
...current,
editorApi: {
...current.editorApi,
[key]: value,
},
}));
}
function updateRuntimeMcpServer<K extends keyof GameCreatorMcpServerConfig>(
serverId: string,
key: K,
value: GameCreatorMcpServerConfig[K],
) {
setRuntimeConfigDraft((current) => ({
...current,
mcpServers: {
...current.mcpServers,
[serverId]: {
...(current.mcpServers[serverId] ?? defaultRuntimeMcpServerConfig),
[key]: value,
},
},
}));
setMcpCatalog(null);
}
function updateRuntimeMcpStructuredDraft(
serverId: string,
key: keyof RuntimeMcpStructuredDraft,
value: string,
) {
setMcpStructuredDrafts((current) => ({
...current,
[serverId]: {
...(current[serverId] ??
runtimeMcpStructuredDraft(
runtimeConfigDraft.mcpServers[serverId] ??
defaultRuntimeMcpServerConfig,
)),
[key]: value,
},
}));
setMcpCatalog(null);
}
function addRuntimeMcpServer() {
const serverId = newMcpServerId.trim();
if (!/^[A-Za-z0-9._-]{1,64}$/.test(serverId)) {
setRuntimeConfigStatus(
'MCP server ID 只允许 1-64 个字母、数字、点、下划线或连字符',
);
return;
}
if (runtimeConfigDraft.mcpServers[serverId]) {
setRuntimeConfigStatus(`MCP server 已存在:${serverId}`);
return;
}
const server = { ...defaultRuntimeMcpServerConfig };
setRuntimeConfigDraft((current) => ({
...current,
mcpServers: { ...current.mcpServers, [serverId]: server },
}));
setMcpStructuredDrafts((current) => ({
...current,
[serverId]: runtimeMcpStructuredDraft(server),
}));
setNewMcpServerId('');
setRuntimeConfigStatus(`已添加 MCP server:${serverId}`);
}
function removeRuntimeMcpServer(serverId: string) {
setRuntimeConfigDraft((current) => {
const mcpServers = { ...current.mcpServers };
delete mcpServers[serverId];
return { ...current, mcpServers };
});
setMcpStructuredDrafts((current) => {
const next = { ...current };
delete next[serverId];
return next;
});
setMcpCatalog(null);
setRuntimeConfigStatus(`已移除 MCP server:${serverId}`);
}
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);
setRuntimeConfigDraft(config);
setMcpStructuredDrafts(runtimeMcpStructuredDrafts(config.mcpServers));
setMcpCatalog(null);
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);
setRuntimeConfigStatus('正在保存');
try {
const config = normalizeRuntimeConfigDraft({
...runtimeConfigDraft,
mcpServers: materializeRuntimeMcpServers(
runtimeConfigDraft.mcpServers,
mcpStructuredDrafts,
),
});
const result = await invoke<GameCreatorAppConfigView>(
'write_game_creator_app_config',
{ config },
);
setRuntimeConfigPath(result.path);
const savedConfig = normalizeRuntimeConfigDraft(result.config);
setRuntimeConfigDraft(savedConfig);
setMcpStructuredDrafts(
runtimeMcpStructuredDrafts(savedConfig.mcpServers),
);
setMcpCatalog(null);
setRuntimeConfigStatus(`已保存:${result.path}`);
onLog?.('runtime_config.save');
} catch (error) {
setRuntimeConfigStatus(
error instanceof Error ? error.message : String(error),
);
} finally {
runtimeConfigBusyRef.current = false;
setRuntimeConfigBusy(false);
}
}
function resetRuntimeConfigDraft() {
setRuntimeConfigDraft(defaultRuntimeConfigDraft);
setMcpStructuredDrafts({});
setMcpCatalog(null);
setRuntimeConfigStatus('已恢复默认配置,保存后生效');
}
async function testRuntimeMcpServers() {
if (!projectPath?.trim()) {
setRuntimeConfigStatus('请先打开一个本地项目,再测试 MCP 连接');
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setRuntimeConfigStatus('需要在 Tauri App 内运行');
return;
}
if (runtimeConfigBusyRef.current) {
return;
}
runtimeConfigBusyRef.current = true;
setRuntimeConfigBusy(true);
setRuntimeConfigStatus('Runner 正在连接 MCP server');
try {
const catalog = await invoke<GameCreatorMcpCatalog>(
'read_game_creator_mcp_catalog',
{ projectPath },
);
setMcpCatalog(catalog);
const connected = catalog.servers.filter(
(server) => server.connected,
).length;
setRuntimeConfigStatus(
`MCP 已连接 ${connected}/${catalog.servers.length} 个 server,发现 ${catalog.tools.length} 个工具`,
);
onLog?.('runtime_config.mcp_status');
} catch (error) {
setMcpCatalog(null);
setRuntimeConfigStatus(
error instanceof Error ? error.message : String(error),
);
} finally {
runtimeConfigBusyRef.current = false;
setRuntimeConfigBusy(false);
}
}
return (
<div
className="settings-overlay"
role="presentation"
onMouseDown={(event) => closeDialogOnBackdropMouseDown(event, onClose)}
>
<form
className="settings-panel"
role="dialog"
aria-label="运行时配置"
aria-modal="true"
onSubmit={handleRuntimeConfigSave}
onKeyDown={(event) => closeDialogOnEscape(event, onClose)}
>
<header className="panel-header">
<h2>运行时配置</h2>
<div className="panel-actions">
<button
type="button"
disabled={runtimeConfigBusy}
onClick={readRuntimeConfig}
>
读取
</button>
<button
type="button"
disabled={runtimeConfigBusy}
onClick={resetRuntimeConfigDraft}
>
恢复默认
</button>
<button type="button" onClick={onClose}>
关闭
</button>
<button type="submit" disabled={runtimeConfigBusy}>
保存
</button>
</div>
</header>
{runtimeConfigPath ? (
<p className="manifest-path">{runtimeConfigPath}</p>
) : null}
<div className="settings-grid">
<label>
Agent 模式
<select
aria-label="Agent 模式"
value={runtimeConfigDraft.agentMode}
onChange={(event) =>
updateRuntimeAgentMode(
event.currentTarget.value as GameCreatorAgentMode,
)
}
>
{!runtimeAgentModes.has(runtimeConfigDraft.agentMode) ? (
<option value={runtimeConfigDraft.agentMode}>
{runtimeConfigDraft.agentMode}(当前配置)
</option>
) : null}
<option value="codex_app_server">Codex App Server</option>
<option value="codex_cli">Codex CLI</option>
<option value="provider">HTTP Provider</option>
</select>
</label>
{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>
<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>
{runtimeAgentLlmRows.map((agent) => {
const agentLlm = runtimeConfigDraft.agentLlm?.[agent.id] ?? {};
const defaultReasoningEffort =
runtimeAgentReasoningEffortDefaults[
agent.id as keyof typeof runtimeAgentReasoningEffortDefaults
];
return (
<Fragment key={agent.id}>
<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>
</Fragment>
);
})}
</>
) : null}
{showDeveloperEditorApi ? (
<>
<label>
开发者 External Editor Base URL
<input
aria-label="开发者 External Editor Base URL"
value={runtimeConfigDraft.editorApi.baseUrl}
onChange={(event) =>
updateRuntimeEditorConfig(
'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) =>
updateRuntimeEditorConfig(
'apiKey',
event.currentTarget.value,
)
}
/>
</label>
</>
) : null}
<section className="runtime-mcp-section" aria-label="MCP servers">
<header className="runtime-mcp-header">
<div>
<h3>MCP servers</h3>
<span>{`${Object.keys(runtimeConfigDraft.mcpServers).length} 个配置`}</span>
</div>
<button
type="button"
disabled={runtimeConfigBusy || !projectPath?.trim()}
onClick={() => void testRuntimeMcpServers()}
>
<Zap size={15} aria-hidden="true" />
测试连接
</button>
</header>
<div className="runtime-mcp-add">
<label>
MCP server ID
<input
aria-label="MCP server ID"
disabled={runtimeConfigBusy}
maxLength={64}
value={newMcpServerId}
onChange={(event) =>
setNewMcpServerId(event.currentTarget.value)
}
/>
</label>
<button
type="button"
aria-label="添加 MCP server"
title="添加 MCP server"
disabled={runtimeConfigBusy || !newMcpServerId.trim()}
onClick={addRuntimeMcpServer}
>
<Plus size={16} aria-hidden="true" />
</button>
</div>
{Object.entries(runtimeConfigDraft.mcpServers).length === 0 ? (
<p className="runtime-mcp-empty">尚未配置 MCP server</p>
) : (
<div className="runtime-mcp-list">
{Object.entries(runtimeConfigDraft.mcpServers).map(
([serverId, server]) => {
const structuredDraft =
mcpStructuredDrafts[serverId] ??
runtimeMcpStructuredDraft(server);
const serverStatus = mcpCatalog?.servers.find(
(candidate) => candidate.serverId === serverId,
);
return (
<article className="runtime-mcp-server" key={serverId}>
<header>
<div>
<strong>{serverId}</strong>
<span
className={
serverStatus?.connected
? 'runtime-mcp-state is-connected'
: 'runtime-mcp-state'
}
title={serverStatus?.error ?? undefined}
>
{!server.enabled
? '已停用'
: serverStatus
? serverStatus.connected
? `已连接 · ${serverStatus.toolCount} 个工具`
: '连接失败'
: server.transport === 'stdio'
? 'STDIO · 未测试'
: 'HTTP · 未测试'}
</span>
</div>
<button
type="button"
aria-label={`删除 MCP server ${serverId}`}
title={`删除 ${serverId}`}
disabled={runtimeConfigBusy}
onClick={() => removeRuntimeMcpServer(serverId)}
>
<Trash2 size={15} aria-hidden="true" />
</button>
</header>
<details>
<summary>配置</summary>
<div className="runtime-mcp-grid">
<label className="settings-checkbox">
<input
aria-label={`${serverId} 启用`}
type="checkbox"
checked={server.enabled}
onChange={(event) =>
updateRuntimeMcpServer(
serverId,
'enabled',
event.currentTarget.checked,
)
}
/>
启用
</label>
<label className="settings-checkbox">
<input
aria-label={`${serverId} 必需`}
type="checkbox"
checked={server.required}
onChange={(event) =>
updateRuntimeMcpServer(
serverId,
'required',
event.currentTarget.checked,
)
}
/>
必需
</label>
<label>
Transport
<select
aria-label={`${serverId} Transport`}
value={server.transport}
onChange={(event) =>
updateRuntimeMcpServer(
serverId,
'transport',
event.currentTarget
.value as GameCreatorMcpTransport,
)
}
>
<option value="stdio">STDIO</option>
<option value="streamableHttp">
Streamable HTTP
</option>
</select>
</label>
<label>
默认审批
<select
aria-label={`${serverId} 默认审批`}
value={server.defaultApprovalMode}
onChange={(event) =>
updateRuntimeMcpServer(
serverId,
'defaultApprovalMode',
event.currentTarget
.value as GameCreatorMcpApprovalMode,
)
}
>
<option value="auto">自动</option>
<option value="confirm">每次确认</option>
<option value="writes">写操作确认</option>
<option value="deny">禁用</option>
</select>
</label>
{server.transport === 'stdio' ? (
<>
<label>
Command
<input
aria-label={`${serverId} Command`}
value={server.command}
onChange={(event) =>
updateRuntimeMcpServer(
serverId,
'command',
event.currentTarget.value,
)
}
/>
</label>
<label>
Working directory
<input
aria-label={`${serverId} Working directory`}
value={server.cwd}
onChange={(event) =>
updateRuntimeMcpServer(
serverId,
'cwd',
event.currentTarget.value,
)
}
/>
</label>
<label className="runtime-mcp-field-wide">
Args JSON
<textarea
aria-label={`${serverId} Args JSON`}
rows={4}
spellCheck={false}
value={structuredDraft.args}
onChange={(event) =>
updateRuntimeMcpStructuredDraft(
serverId,
'args',
event.currentTarget.value,
)
}
/>
</label>
<label className="runtime-mcp-field-wide">
Environment JSON
<textarea
aria-label={`${serverId} Environment JSON`}
autoComplete="off"
rows={5}
spellCheck={false}
value={structuredDraft.env}
onChange={(event) =>
updateRuntimeMcpStructuredDraft(
serverId,
'env',
event.currentTarget.value,
)
}
/>
</label>
</>
) : (
<>
<label className="runtime-mcp-field-wide">
URL
<input
aria-label={`${serverId} URL`}
value={server.url}
onChange={(event) =>
updateRuntimeMcpServer(
serverId,
'url',
event.currentTarget.value,
)
}
/>
</label>
<label>
Bearer token
<input
aria-label={`${serverId} Bearer token`}
autoComplete="off"
type="password"
value={server.bearerToken}
onChange={(event) =>
updateRuntimeMcpServer(
serverId,
'bearerToken',
event.currentTarget.value,
)
}
/>
</label>
<label className="settings-checkbox">
<input
aria-label={`${serverId} 允许不安全 localhost HTTP`}
type="checkbox"
checked={server.allowInsecureLocalhost}
onChange={(event) =>
updateRuntimeMcpServer(
serverId,
'allowInsecureLocalhost',
event.currentTarget.checked,
)
}
/>
允许不安全 localhost HTTP
</label>
<label className="runtime-mcp-field-wide">
HTTP headers JSON
<textarea
aria-label={`${serverId} HTTP headers JSON`}
autoComplete="off"
rows={5}
spellCheck={false}
value={structuredDraft.httpHeaders}
onChange={(event) =>
updateRuntimeMcpStructuredDraft(
serverId,
'httpHeaders',
event.currentTarget.value,
)
}
/>
</label>
</>
)}
<label>
启动超时 ms
<input
aria-label={`${serverId} 启动超时 ms`}
type="number"
min="250"
value={server.startupTimeoutMs}
onChange={(event) =>
updateRuntimeMcpServer(
serverId,
'startupTimeoutMs',
Math.max(
250,
Number(event.currentTarget.value) || 250,
),
)
}
/>
</label>
<label>
工具超时 ms
<input
aria-label={`${serverId} 工具超时 ms`}
type="number"
min="250"
value={server.toolTimeoutMs}
onChange={(event) =>
updateRuntimeMcpServer(
serverId,
'toolTimeoutMs',
Math.max(
250,
Number(event.currentTarget.value) || 250,
),
)
}
/>
</label>
<label className="runtime-mcp-field-wide">
允许工具 JSON
<textarea
aria-label={`${serverId} 允许工具 JSON`}
rows={4}
spellCheck={false}
value={structuredDraft.enabledTools}
onChange={(event) =>
updateRuntimeMcpStructuredDraft(
serverId,
'enabledTools',
event.currentTarget.value,
)
}
/>
</label>
<label className="runtime-mcp-field-wide">
禁用工具 JSON
<textarea
aria-label={`${serverId} 禁用工具 JSON`}
rows={4}
spellCheck={false}
value={structuredDraft.disabledTools}
onChange={(event) =>
updateRuntimeMcpStructuredDraft(
serverId,
'disabledTools',
event.currentTarget.value,
)
}
/>
</label>
<label className="runtime-mcp-field-wide">
工具覆盖 JSON
<textarea
aria-label={`${serverId} 工具覆盖 JSON`}
rows={6}
spellCheck={false}
value={structuredDraft.tools}
onChange={(event) =>
updateRuntimeMcpStructuredDraft(
serverId,
'tools',
event.currentTarget.value,
)
}
/>
</label>
</div>
</details>
</article>
);
},
)}
</div>
)}
{mcpCatalog ? (
<section
className="runtime-mcp-catalog"
aria-label="MCP 连接状态"
>
<header>
<strong>工具目录</strong>
<span>{`${mcpCatalog.tools.length} 个工具`}</span>
</header>
{mcpCatalog.tools.length === 0 ? (
<p className="runtime-mcp-empty">没有可用工具</p>
) : (
<div className="runtime-mcp-tools">
{mcpCatalog.tools.map((tool) => (
<article key={`${tool.serverId}:${tool.name}`}>
<div>
<strong>{`${tool.serverId} / ${tool.title || tool.name}`}</strong>
<span>{`${tool.effectiveApprovalMode}${tool.readOnlyHint ? ' · read-only' : ''}`}</span>
</div>
<pre>{JSON.stringify(tool.inputSchema, null, 2)}</pre>
</article>
))}
</div>
)}
</section>
) : null}
</section>
</div>
<p className="status-line">{runtimeConfigStatus}</p>
</form>
</div>
);
}