1709 lines
63 KiB
TypeScript
1709 lines
63 KiB
TypeScript
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>
|
||
导入 Skill、MCP 配置或标准 Plugin 后会显示在这里。
|
||
</span>
|
||
</div>
|
||
)}
|
||
</section>
|
||
) : null}
|
||
{activeSection === 'about' ? (
|
||
<section
|
||
className="runtime-settings-about"
|
||
aria-label="关于客户端"
|
||
>
|
||
<div className="runtime-settings-about-hero">
|
||
<div
|
||
className="runtime-settings-about-mark"
|
||
aria-hidden="true"
|
||
>
|
||
<img
|
||
src={BRAND_ICON}
|
||
alt=""
|
||
data-testid="runtime-settings-app-logo"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<span className="runtime-settings-about-kicker">
|
||
GENARRATIVE
|
||
</span>
|
||
<h4>{APP_NAME}</h4>
|
||
<p>面向游戏创作者的智能创作客户端</p>
|
||
</div>
|
||
</div>
|
||
<dl className="runtime-settings-about-meta">
|
||
<div>
|
||
<dt>客户端版本</dt>
|
||
<dd data-testid="runtime-settings-app-version">
|
||
v{APP_VERSION}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>运行环境</dt>
|
||
<dd>桌面客户端</dd>
|
||
</div>
|
||
</dl>
|
||
<div className="runtime-settings-about-update">
|
||
<button
|
||
type="button"
|
||
onClick={() => void checkAppUpdateManually()}
|
||
disabled={appUpdateChecking}
|
||
>
|
||
{appUpdateChecking ? '正在检查…' : '检查更新'}
|
||
</button>
|
||
<span role="status" aria-live="polite">
|
||
{appUpdateStatus}
|
||
</span>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
</div>
|
||
</main>
|
||
</div>
|
||
<footer className="runtime-settings-footer">
|
||
<p
|
||
className="status-line"
|
||
data-tone={runtimeConfigStatusTone}
|
||
role="status"
|
||
aria-live="polite"
|
||
>
|
||
{runtimeConfigStatusTone === 'busy' ? (
|
||
<LoaderCircle
|
||
className="is-spinning"
|
||
size={15}
|
||
aria-hidden="true"
|
||
/>
|
||
) : runtimeConfigStatusTone === 'success' ? (
|
||
<CheckCircle2 size={15} aria-hidden="true" />
|
||
) : runtimeConfigStatusTone === 'warning' ? (
|
||
<CircleAlert size={15} aria-hidden="true" />
|
||
) : null}
|
||
{runtimeConfigStatus}
|
||
</p>
|
||
<div className="runtime-settings-footer-actions">
|
||
<button
|
||
type="button"
|
||
aria-label="读取"
|
||
disabled={runtimeConfigBusy}
|
||
onClick={readRuntimeConfig}
|
||
>
|
||
重新读取
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={runtimeConfigBusy}
|
||
onClick={resetRuntimeConfigDraft}
|
||
>
|
||
<RotateCcw size={15} aria-hidden="true" />
|
||
恢复默认
|
||
</button>
|
||
<button
|
||
className="runtime-settings-save"
|
||
type="submit"
|
||
aria-label="保存"
|
||
disabled={runtimeConfigBusy}
|
||
>
|
||
<Save size={15} aria-hidden="true" />
|
||
{runtimeConfigStatus === '正在保存'
|
||
? '正在保存'
|
||
: runtimeConfigBusy
|
||
? '请稍候'
|
||
: '保存设置'}
|
||
</button>
|
||
</div>
|
||
</footer>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|