完成 DirectProject 扩展导入阶段一
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled

新增客户端扩展来源存储和 Skill/MCP 结构发现。

接入独立扩展列表、默认启用、重命名、删除和导入入口。

补充阶段一定向测试并更新实施方案状态。
This commit is contained in:
2026-08-31 11:37:04 +00:00
parent bd812093f0
commit edf6e19b38
8 changed files with 1389 additions and 0 deletions
+1
View File
@@ -1741,6 +1741,7 @@ dependencies = [
"tauri-plugin-opener",
"tempfile",
"tokio",
"toml 0.8.2",
"ts-rs",
"ttf-parser",
"typed_floats",
@@ -49,6 +49,7 @@ tauri-plugin-dialog = "2.7.1"
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
tauri-plugin-opener = "2"
tempfile = "3"
toml = "0.8"
ttf-parser = "0.25.1"
tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] }
url = "2"
File diff suppressed because it is too large Load Diff
@@ -55,6 +55,7 @@ mod command_exec;
mod command_output;
mod command_sandbox;
mod command_sandbox_trampoline;
mod client_extensions;
mod commands;
mod config;
mod context_compaction;
@@ -92,6 +93,7 @@ use collaboration::*;
use command_exec::*;
use command_output::*;
use command_sandbox::*;
use client_extensions::*;
use commands::*;
use config::*;
use context_compaction::*;
@@ -2225,6 +2227,13 @@ fn main() {
inspect_local_project_directory,
pick_local_project_directory,
pick_local_file,
pick_client_extension_file,
pick_client_extension_directory,
list_client_extensions,
import_client_extension,
set_client_extension_enabled,
rename_client_extension,
remove_client_extension,
open_local_project_directory,
open_local_project_plan_gdd_markdown,
control_agent_run,
@@ -50,6 +50,27 @@ export type LauncherImportedAttachment = {
error?: string;
};
export type ClientExtensionType = 'skill' | 'mcp' | 'unknown';
export type ClientExtensionItem = {
id: string;
name: string;
originalName: string;
extensionType: ClientExtensionType;
sourceName: string;
sourceRelativePath: string;
enabled: boolean;
status: 'enabled' | 'disabled' | 'unknown' | 'startup-failed';
lastError: string | null;
};
export type ClientExtensionImportResult = {
imported: ClientExtensionItem[];
sourceName: string;
renamed: boolean;
duplicate: boolean;
};
export type LocalProjectKind = 'web' | 'godot';
export type ProjectStartMode = 'planning' | 'direct-build';
@@ -5,10 +5,13 @@ import {
CircleAlert,
Info,
LoaderCircle,
Pencil,
RotateCcw,
Save,
Settings2,
SlidersHorizontal,
Trash2,
Upload,
X,
} from 'lucide-react';
import { type FormEvent, useEffect, useRef, useState } from 'react';
@@ -23,6 +26,8 @@ import {
} from '../../app/dialogs';
import { resolveTauriInvoke } from '../../app/tauri';
import {
type ClientExtensionImportResult,
type ClientExtensionItem,
type GameCreatorAgentLlmConfig,
type GameCreatorAppConfig,
type GameCreatorAppConfigView,
@@ -86,6 +91,7 @@ type RuntimeSettingsSection =
| 'general'
| 'agents'
| 'connections'
| 'extensions'
| 'advanced'
| 'about';
@@ -113,6 +119,12 @@ const runtimeSettingsSections = [
description: '外部服务',
icon: Cable,
},
{
id: 'extensions',
label: '扩展',
description: 'Skill 与 MCP',
icon: Upload,
},
{
id: 'advanced',
label: '高级参数',
@@ -383,6 +395,15 @@ export function RuntimeConfigDialog({
const [activeSection, setActiveSection] =
useState<RuntimeSettingsSection>('general');
const [expandedAgentIds, setExpandedAgentIds] = useState<string[]>([]);
const [clientExtensions, setClientExtensions] = useState<
ClientExtensionItem[]
>([]);
const [clientExtensionsBusy, setClientExtensionsBusy] = useState(false);
const [clientExtensionsStatus, setClientExtensionsStatus] = useState('');
const [editingExtensionId, setEditingExtensionId] = useState<string | null>(
null,
);
const [editingExtensionName, setEditingExtensionName] = useState('');
const runtimeConfigBusyRef = useRef(false);
useEscapeToClose(onClose);
@@ -401,10 +422,171 @@ export function RuntimeConfigDialog({
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) {
setClientExtensionsStatus('需要在 Tauri App 内运行');
return;
}
setClientExtensionsBusy(true);
try {
const result = await invoke<ClientExtensionItem[]>(
'list_client_extensions',
);
setClientExtensions(result);
setClientExtensionsStatus('');
} catch (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);
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],
@@ -690,6 +872,25 @@ export function RuntimeConfigDialog({
</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">
@@ -1272,6 +1473,146 @@ export function RuntimeConfigDialog({
) : null}
</section>
) : null}
{activeSection === 'extensions' ? (
<section
className="runtime-settings-section runtime-settings-extensions"
aria-label="客户端扩展"
>
{clientExtensionsStatus ? (
<p className="runtime-settings-inline-status" role="status">
{clientExtensionsStatus}
</p>
) : null}
{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>
SkillMCP Plugin
</span>
</div>
)}
</section>
) : null}
{activeSection === 'about' ? (
<section
className="runtime-settings-about"
+148
View File
@@ -3553,6 +3553,136 @@ h2 {
font-size: 11px;
}
.runtime-settings-section-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 7px;
}
.runtime-settings-section-actions button,
.runtime-settings-extension-actions button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-height: 30px;
padding: 0 9px;
border: 1px solid var(--platform-surface-border);
border-radius: 8px;
background: var(--platform-button-ghost-fill);
color: var(--platform-text-base);
font-size: 11px;
cursor: pointer;
}
.runtime-settings-section-actions button:hover,
.runtime-settings-extension-actions button:hover {
border-color: var(--platform-surface-hover-border);
background: var(--platform-button-ghost-fill);
}
.runtime-settings-section-actions button:disabled,
.runtime-settings-extension-actions button:disabled {
cursor: default;
opacity: 0.55;
}
.runtime-settings-extensions {
display: grid;
gap: 10px;
}
.runtime-settings-inline-status {
margin: 0;
color: var(--platform-text-soft);
font-size: 11px;
}
.runtime-settings-extension-list {
display: grid;
gap: 8px;
}
.runtime-settings-extension-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
border: 1px solid var(--platform-subpanel-border);
border-radius: 12px;
background: var(--runtime-settings-subpanel-fill);
}
.runtime-settings-extension-main {
display: grid;
min-width: 0;
gap: 3px;
}
.runtime-settings-extension-main strong,
.runtime-settings-extension-main input {
min-width: 0;
color: var(--platform-text-strong);
font-size: 13px;
font-weight: 800;
}
.runtime-settings-extension-main input {
width: min(100%, 300px);
padding: 6px 8px;
border: 1px solid var(--platform-surface-border);
border-radius: 7px;
background: var(--platform-input-fill);
}
.runtime-settings-extension-main > span,
.runtime-settings-extension-main > small,
.runtime-settings-extension-actions > span {
overflow: hidden;
color: var(--platform-text-soft);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.runtime-settings-extension-main > small {
color: var(--platform-danger, #b45309);
}
.runtime-settings-extension-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 6px;
flex-shrink: 0;
}
.runtime-settings-extension-actions > span {
margin-right: 2px;
}
.runtime-settings-empty-state {
display: grid;
gap: 4px;
padding: 24px 14px;
border: 1px dashed var(--platform-subpanel-border);
border-radius: 12px;
color: var(--platform-text-soft);
text-align: center;
}
.runtime-settings-empty-state strong {
color: var(--platform-text-base);
font-size: 13px;
}
.runtime-settings-empty-state span {
font-size: 11px;
}
.runtime-settings-fields {
align-content: start;
}
@@ -4492,6 +4622,24 @@ iframe.preview-frame {
padding: 18px 14px 22px;
}
.runtime-settings-section-header {
align-items: flex-start;
flex-direction: column;
}
.runtime-settings-section-actions {
justify-content: flex-start;
}
.runtime-settings-extension-item {
align-items: stretch;
flex-direction: column;
}
.runtime-settings-extension-actions {
justify-content: flex-start;
}
.runtime-agent-card-fields {
grid-template-columns: 1fr;
}
@@ -388,6 +388,8 @@ remove_client_extension(id)
### 阶段 1:客户端导入和列表
当前实施状态:已完成。客户端侧已落地本地扩展索引、来源副本、文件/目录/zip 导入、标准 Skill/MCP 结构发现、独立项拆分、原生命名、重复导入命名、启用/禁用、重命名、删除和设置弹窗列表。DirectProject 运行时注入仍留在阶段 2/3,不属于本阶段。阶段 1 定向 Rust 测试 6 项全部通过,前端 `typecheck` 和配置契约检查通过。
完成:
- 客户端存储;