03a4410272
主要工作是共享同一套“画布内核与通用 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>
1830 lines
66 KiB
TypeScript
1830 lines
66 KiB
TypeScript
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>
|
||
);
|
||
}
|