Files
Genarrative/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx
T
suzmii bbf9500bea
Project CI / Frontend tests (pull_request) Failing after 3m0s
Project CI / Repository checks (pull_request) Failing after 3m1s
Project CI / Backend tests (pull_request) Failing after 3m52s
Project CI / Native shell tests (pull_request) Failing after 5m55s
合并最新主线并保留官方路由锁定
合并客户端扩展、应用更新、格式化门禁和后台表查询等主线改动。

运行时设置页保留官方账号服务与安全元数据,同时接入主线扩展管理与更新能力。

DirectProject 文档合并主线第三方 MCP 支持与官方路由、受控搜索边界。

修复合并后的后台 API Key 详情 Eye 图标导入。
2026-09-02 15:34:30 +08:00

1233 lines
44 KiB
TypeScript

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