Files
Genarrative/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx
T
kdletters 34b1ad5d5c 新增 Godot 模板并补按模板建项的 Godot 分流
- 新增 4 个仓库内手写 Godot 4.7 模板源:godot-empty-2d、godot-empty-3d、godot-hello-world、godot-platformer-2d,entry 统一为 project.godot,各自带 meta.json 与 960×540 封面
- template_library.rs:复制模板后按 project.godot 识别 Godot 模板,改写工程显示名后走既有 Godot 导入并写入 godotProjectRoot,不再落到 Web 占位入口
- manifest.rs:新增 apply_godot_project_display_name,只改 [application] 段的 config/name 一行,其余字节逐字保留
- manifest.rs:放宽 Godot 工作区发现,一层子目录多命中按目录名排序取第一个,project.godot 允许是符号链接 / reparse point / 硬链接
- commands.rs:同步 Godot 导入错误文案,去掉「普通文件」限定
- RuntimeConfigDialog.tsx:内置插件行去掉手动启动 / 停止按钮,启动由前端自动启动承担、停止走该行启用开关
- import_tests.rs 与 tests/project.rs:补显示名改写、多命中确定性、链接标记与错误文案的定向回归
- 同步更新 PRD、模板包组织指南、模板库与模板建项技术方案、AGC Godot 编辑器插件接入方案、实施计划与共享决策记录
2026-09-21 17:25:34 +08:00

1464 lines
54 KiB
TypeScript

import {
Bot,
CheckCircle2,
CircleAlert,
FolderOpen,
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 { appUpdateCheckEnabled } from '../../app/featureFlags';
import { resolveTauriInvoke } from '../../app/tauri';
import {
type AgcPluginPanel,
type AgcPluginSummary,
type ClientExtensionImportResult,
type ClientExtensionItem,
type GameCreatorAppConfig,
type GameCreatorAppConfigView,
type GameCreatorLlmReasoningEffort,
gameCreatorLlmReasoningEfforts,
} from '../../app/types';
import { checkForAppUpdate } from '../../services/appUpdate';
import { notifyLlmConfigChanged } from '../../services/llmModelCatalog';
import {
listAgcExtensions,
reloadAgcPlugin,
setAgcPluginEnabled,
setAgcPluginProjectPath,
startAgcPlugin,
stopAgcPlugin,
} from '../../services/pluginHost';
import { useProjectCreationDirectory } from '../app-shell/useProjectCreationDirectory';
import { PluginPanelHost } from '../plugins/PluginPanelHost';
import { reasoningEffortLabel } from '../project-workspace/composerReasoningEffort';
import { CustomLlmSettings } from './CustomLlmSettings';
const defaultRuntimeConfigDraft: GameCreatorAppConfig = {
schemaVersion: 'game-creator-config.v2',
agentMode: 'codex_app_server',
llm: {
customEnabled: false,
visibleModels: [],
apiKey: '',
baseUrl: '',
model: '',
apiKind: 'openai_responses',
reasoningEffort: 'high',
stream: true,
webSearchEnabled: true,
contextWindowTokens: 128000,
autoCompactTokenLimit: 64000,
toolOutputTokenLimit: 12000,
requestTimeoutMs: 180000,
maxRetries: 10,
retryBackoffMs: 500,
},
agentLlm: {},
editorApi: {
baseUrl: 'https://dev.genarrative.world',
apiKey: '',
},
};
type RuntimeSettingsSection =
| 'general'
| 'workspace'
| '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: 'workspace',
label: '工作区',
description: '项目创建目录',
icon: FolderOpen,
},
{
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;
}>;
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: config.llm.customEnabled ? config.llm.apiKey : '',
baseUrl: config.llm.customEnabled ? config.llm.baseUrl : '',
model: config.llm.customEnabled ? config.llm.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),
},
// 客户端统一使用全局连接,设置不保留独立的 Agent 路由覆盖。
agentLlm: {},
editorApi: allowAdvancedExternalEditorConfig
? { ...defaultRuntimeConfigDraft.editorApi, ...config.editorApi }
: { ...defaultRuntimeConfigDraft.editorApi },
};
}
export function RuntimeConfigDialog({
projectPath,
allowAdvancedExternalEditorConfig = false,
onClose,
onLog,
}: {
projectPath?: string;
allowAdvancedExternalEditorConfig?: boolean;
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 [activeSection, setActiveSection] =
useState<RuntimeSettingsSection>('general');
/**
* 「项目创建目录」是客户端本地偏好(localStorage),不随下面的配置文件一起保存:
* 选择目录当场生效,做游戏 / 做方案与模板建项下一次建项就落在该目录下。
*/
const projectCreationDirectory = useProjectCreationDirectory();
const [clientExtensions, setClientExtensions] = useState<
ClientExtensionItem[]
>([]);
const [clientExtensionsBusy, setClientExtensionsBusy] = useState(false);
const [clientExtensionsLoadState, setClientExtensionsLoadState] =
useState<ClientExtensionsLoadState>('loading');
const [clientExtensionsStatus, setClientExtensionsStatus] = useState('');
const [agcPlugins, setAgcPlugins] = useState<AgcPluginSummary[]>([]);
const [agcPluginsBusy, setAgcPluginsBusy] = useState(false);
const [agcPluginsStatus, setAgcPluginsStatus] = useState('');
const [mountedPluginPanel, setMountedPluginPanel] = useState<{
pluginId: string;
panel: AgcPluginPanel;
} | null>(null);
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(
mountedPluginPanel ? () => setMountedPluginPanel(null) : onClose,
);
useEffect(() => {
void setAgcPluginProjectPath(projectPath ?? null).catch(() => undefined);
}, [projectPath]);
useEffect(() => {
if (activeSection !== 'extensions') return;
const timer = window.setInterval(() => void readAgcPlugins(), 1000);
return () => window.clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSection]);
useEffect(() => {
if (
mountedPluginPanel &&
!agcPlugins.some(
(plugin) =>
plugin.id === mountedPluginPanel.pluginId &&
plugin.status === 'running',
)
) {
setMountedPluginPanel(null);
}
}, [agcPlugins, mountedPluginPanel]);
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();
void readAgcPlugins();
// The dialog reads once on mount; subsequent reads are explicit user actions.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function readAgcPlugins() {
try {
const extensions = await listAgcExtensions();
setAgcPlugins(
extensions.flatMap((extension) =>
extension.kind === 'plugin' && extension.plugin
? [extension.plugin]
: [],
),
);
setAgcPluginsStatus('');
} catch (error) {
setAgcPluginsStatus(
error instanceof Error ? error.message : String(error),
);
}
}
async function toggleAgcPlugin(plugin: AgcPluginSummary) {
if (agcPluginsBusy) return;
setAgcPluginsBusy(true);
try {
const updated =
plugin.status === 'running'
? await stopAgcPlugin(plugin.id)
: await startAgcPlugin(plugin.id);
setAgcPlugins((current) =>
current.map((item) => (item.id === updated.id ? updated : item)),
);
setAgcPluginsStatus('');
} catch (error) {
setAgcPluginsStatus(
error instanceof Error ? error.message : String(error),
);
} finally {
setAgcPluginsBusy(false);
}
}
async function reloadPlugin(plugin: AgcPluginSummary) {
if (agcPluginsBusy) return;
setAgcPluginsBusy(true);
try {
const updated = await reloadAgcPlugin(plugin.id);
setAgcPlugins((current) =>
current.map((item) => (item.id === updated.id ? updated : item)),
);
setAgcPluginsStatus('插件已重新加载');
} catch (error) {
setAgcPluginsStatus(
error instanceof Error ? error.message : String(error),
);
} finally {
setAgcPluginsBusy(false);
}
}
/** 内置插件没有卸载入口,只用可用开关控制是否对客户端和 Agent 生效。 */
async function setBuiltinPluginEnabled(
plugin: AgcPluginSummary,
enabled: boolean,
) {
if (agcPluginsBusy) return;
setAgcPluginsBusy(true);
try {
const plugins = await setAgcPluginEnabled(plugin.id, enabled);
setAgcPlugins(plugins);
await readClientExtensions();
setAgcPluginsStatus(enabled ? '内置插件已启用' : '内置插件已禁用');
} catch (error) {
setAgcPluginsStatus(
error instanceof Error ? error.message : String(error),
);
} finally {
setAgcPluginsBusy(false);
}
}
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);
await readAgcPlugins();
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,
),
);
await readAgcPlugins();
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,
),
);
await readClientExtensions();
await readAgcPlugins();
} 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 });
await readClientExtensions();
await readAgcPlugins();
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}`);
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);
notifyLlmConfigChanged();
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,
llm: {
...defaultRuntimeConfigDraft.llm,
customEnabled: runtimeConfigDraft.llm.customEnabled,
},
});
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>
{!runtimeConfigDraft.llm.customEnabled ? (
<small>需求将由官方智能服务执行</small>
) : null}
</div>
<div className="runtime-settings-readonly-field">
<span>智能服务</span>
<strong>
{runtimeConfigDraft.llm.customEnabled
? '自定义 LLM'
: '官方账号服务(固定)'}
</strong>
{!runtimeConfigDraft.llm.customEnabled ? (
<small>登录后自动使用当前账号权限。</small>
) : null}
</div>
{runtimeConfigDraft.llm.customEnabled ? (
<>
<div className="runtime-settings-readonly-field">
<span>协议</span>
<strong>OpenAI Responses</strong>
<small>自定义端点需兼容该协议。</small>
</div>
<div className="runtime-settings-readonly-field">
<span>推理档</span>
<strong>
{reasoningEffortLabel(
runtimeConfigDraft.llm.reasoningEffort,
)}
</strong>
<small>在对话输入盒的模型旁按回合调整。</small>
</div>
<CustomLlmSettings
llm={runtimeConfigDraft.llm}
disabled={runtimeConfigBusy}
onChange={(llm) =>
setRuntimeConfigDraft((current) => ({
...current,
llm,
}))
}
/>
</>
) : null}
{runtimeConfigDraft.agentMode !== 'codex_cli' ? (
<>
{/* 推理档已下移到对话输入盒的模型选择器旁(按回合生效),
设置里不再重复一份。 */}
<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 === 'workspace' ? (
<div className="runtime-settings-readonly-field">
<span>项目创建目录</span>
<strong
title={
projectCreationDirectory.projectCreationDirectory ||
undefined
}
>
{projectCreationDirectory.projectCreationDirectory ||
'默认位置'}
</strong>
<div className="runtime-settings-field-actions">
<button
type="button"
disabled={
projectCreationDirectory.projectCreationDirectoryBusy
}
onClick={() =>
void projectCreationDirectory.pickProjectCreationDirectory()
}
>
<FolderOpen size={14} aria-hidden="true" />
选择目录
</button>
{projectCreationDirectory.projectCreationDirectory ? (
<button
type="button"
disabled={
projectCreationDirectory.projectCreationDirectoryBusy
}
onClick={
projectCreationDirectory.resetProjectCreationDirectory
}
>
<RotateCcw size={14} aria-hidden="true" />
恢复默认位置
</button>
) : null}
</div>
{projectCreationDirectory.projectCreationDirectoryStatus ? (
<small>
{projectCreationDirectory.projectCreationDirectoryStatus}
</small>
) : null}
</div>
) : 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 === '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}
{agcPlugins.some((plugin) => plugin.builtin) ? (
<div className="runtime-settings-extension-list">
{agcPlugins
.filter((plugin) => plugin.builtin)
.map((plugin) => (
<article
className="runtime-settings-extension-item"
key={plugin.id}
>
<div className="runtime-settings-extension-main">
<strong title={plugin.name}>{plugin.name}</strong>
<span>内置 Plugin · 不可卸载</span>
{plugin.lastError ? (
<small title={plugin.lastError}>
{plugin.lastError}
</small>
) : null}
</div>
<div className="runtime-settings-extension-actions">
<span>
{!plugin.enabled
? '已禁用'
: plugin.status === 'running'
? '运行中'
: plugin.status === 'invalid'
? '当前不可用'
: '已启用'}
</span>
{plugin.enabled && plugin.hasRuntime ? (
<button
type="button"
disabled={
agcPluginsBusy ||
plugin.status !== 'running'
}
onClick={() => void reloadPlugin(plugin)}
>
重载
</button>
) : null}
{plugin.status === 'running'
? plugin.panels.map((panel) => (
<button
type="button"
key={panel.id}
onClick={() =>
setMountedPluginPanel({
pluginId: plugin.id,
panel,
})
}
>
{panel.title}
</button>
))
: null}
<button
type="button"
role="switch"
aria-checked={plugin.enabled}
aria-label={`${plugin.name} ${
plugin.enabled ? '禁用' : '启用'
}`}
disabled={agcPluginsBusy}
onClick={() =>
void setBuiltinPluginEnabled(
plugin,
!plugin.enabled,
)
}
>
{plugin.enabled ? '禁用' : '启用'}
</button>
</div>
</article>
))}
</div>
) : 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
.filter(
(item) =>
!(
item.extensionType === 'plugin' &&
agcPlugins.some(
(plugin) =>
plugin.builtin && plugin.id === item.id,
)
),
)
.map((item) => {
const editing = editingExtensionId === item.id;
const plugin = agcPlugins.find(
(plugin) => plugin.id === item.id,
);
const typeLabel =
item.extensionType === 'plugin'
? 'Plugin'
: 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>
{plugin?.status === 'running'
? '运行中'
: statusLabel}
</span>
{plugin?.enabled && plugin.hasRuntime ? (
<>
<button
type="button"
disabled={
agcPluginsBusy ||
clientExtensionsBusy ||
plugin.status === 'invalid'
}
onClick={() =>
void toggleAgcPlugin(plugin)
}
>
{plugin.status === 'running'
? '停止'
: '启动'}
</button>
<button
type="button"
disabled={
agcPluginsBusy ||
plugin.status !== 'running'
}
onClick={() => void reloadPlugin(plugin)}
>
重载
</button>
</>
) : null}
{plugin?.status === 'running'
? plugin.panels.map((panel) => (
<button
type="button"
key={panel.id}
onClick={() =>
setMountedPluginPanel({
pluginId: plugin.id,
panel,
})
}
>
{panel.title}
</button>
))
: null}
{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>
)}
{agcPluginsStatus ? (
<p className="runtime-settings-inline-status" role="status">
{agcPluginsStatus}
</p>
) : null}
</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>
{appUpdateCheckEnabled ? (
<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>
) : null}
</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>
{mountedPluginPanel ? (
<PluginPanelHost
{...mountedPluginPanel}
onClose={() => setMountedPluginPanel(null)}
/>
) : null}
</div>
);
}