完成 DirectProject 扩展导入阶段一
新增客户端扩展来源存储和 Skill/MCP 结构发现。 接入独立扩展列表、默认启用、重命名、删除和导入入口。 补充阶段一定向测试并更新实施方案状态。
This commit is contained in:
+1
@@ -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>
|
||||
导入 Skill、MCP 配置或标准 Plugin 后会显示在这里。
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
{activeSection === 'about' ? (
|
||||
<section
|
||||
className="runtime-settings-about"
|
||||
|
||||
@@ -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` 和配置契约检查通过。
|
||||
|
||||
完成:
|
||||
|
||||
- 客户端存储;
|
||||
|
||||
Reference in New Issue
Block a user