Merge branch 'master' into fix/ref-persistent
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web 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 / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
This commit is contained in:
@@ -482,6 +482,43 @@ fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result<PathBuf,
|
||||
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))
|
||||
}
|
||||
|
||||
/// 校验用户选择的项目创建目录。
|
||||
///
|
||||
/// 目录必须已经存在(原生目录选择器返回的结果),并且先过 AGC 私有路径门禁:门禁失败时
|
||||
/// 这里就拒绝,避免项目被建到 AGC 无法加固、后续无法打开的位置。
|
||||
pub(crate) fn validate_requested_game_project_creation_root(
|
||||
requested: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
let requested = requested.trim();
|
||||
let root = Path::new(requested);
|
||||
if requested.is_empty() || !root.is_absolute() {
|
||||
return Err("项目创建目录必须是绝对路径".to_string());
|
||||
}
|
||||
if project_path_has_control_chars(root) {
|
||||
return Err("项目创建目录不能包含控制字符".to_string());
|
||||
}
|
||||
let metadata = fs::symlink_metadata(root)
|
||||
.map_err(|error| format!("读取项目创建目录失败:{}: {error}", root.display()))?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return Err("项目创建目录必须是普通文件夹".to_string());
|
||||
}
|
||||
// 用户选择的外部目录仍走显式的项目根准备:保留 user-selected 范围的一次性修复,
|
||||
// 同时不放弃 reparse point / 非普通目录的失败关闭。
|
||||
prepare_game_creator_project_root_for_read(root, true, "项目创建目录")?;
|
||||
Ok(root.to_path_buf())
|
||||
}
|
||||
|
||||
/// 解析本次建项要使用的根目录:没选就用 AGC 管理的默认目录,选了就用用户指定的目录。
|
||||
pub(crate) fn resolve_game_project_creation_root(
|
||||
app: &tauri::AppHandle,
|
||||
requested: Option<&str>,
|
||||
) -> Result<PathBuf, String> {
|
||||
match requested.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
Some(requested) => validate_requested_game_project_creation_root(requested),
|
||||
None => automatic_local_game_projects_root(app),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn create_automatic_local_game_project_at(
|
||||
projects_root: &Path,
|
||||
requested_name: Option<&str>,
|
||||
@@ -551,9 +588,10 @@ pub(crate) fn create_automatic_local_game_project(
|
||||
app: tauri::AppHandle,
|
||||
name: Option<String>,
|
||||
planning: Option<bool>,
|
||||
projects_root: Option<String>,
|
||||
) -> Result<InitLocalProjectResult, String> {
|
||||
create_automatic_local_game_project_at(
|
||||
&automatic_local_game_projects_root(&app)?,
|
||||
&resolve_game_project_creation_root(&app, projects_root.as_deref())?,
|
||||
name.as_deref(),
|
||||
planning.unwrap_or(false),
|
||||
)
|
||||
@@ -764,13 +802,30 @@ pub(crate) fn recent_game_creator_run_trace(root: &Path) -> Option<GameCreationA
|
||||
serde_json::from_str::<GameCreationAgentRunTrace>(&content).ok()
|
||||
}
|
||||
|
||||
/// 目录选择器标题:调用方只能给短标题,其余(超长、含控制字符、空白)一律回退默认文案。
|
||||
fn pick_project_directory_title(title: Option<&str>) -> &str {
|
||||
const MAX_TITLE_CHARS: usize = 24;
|
||||
title
|
||||
.map(str::trim)
|
||||
.filter(|value| {
|
||||
!value.is_empty()
|
||||
&& value.chars().count() <= MAX_TITLE_CHARS
|
||||
&& !value.chars().any(char::is_control)
|
||||
})
|
||||
.unwrap_or("选择游戏项目目录")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn pick_local_project_directory(
|
||||
app: tauri::AppHandle,
|
||||
initial_path: Option<String>,
|
||||
title: Option<String>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||
let mut dialog = app.dialog().file().set_title("选择游戏项目目录");
|
||||
let mut dialog = app
|
||||
.dialog()
|
||||
.file()
|
||||
.set_title(pick_project_directory_title(title.as_deref()));
|
||||
if let Some(initial_path) = initial_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
|
||||
@@ -880,12 +880,9 @@ pub(crate) async fn create_automatic_local_game_project_from_template(
|
||||
template_version: String,
|
||||
name: Option<String>,
|
||||
planning: Option<bool>,
|
||||
projects_root: Option<String>,
|
||||
) -> Result<InitLocalProjectResult, String> {
|
||||
let projects_root = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map(|root| root.join("projects"))
|
||||
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))?;
|
||||
let projects_root = crate::resolve_game_project_creation_root(&app, projects_root.as_deref())?;
|
||||
let cache_root = template_cache_root(&app)?;
|
||||
ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?;
|
||||
let record =
|
||||
|
||||
@@ -1549,6 +1549,63 @@ fn automatic_local_game_project_allocates_unique_initialized_workspaces() {
|
||||
fs::remove_dir_all(projects_root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_project_creation_root_accepts_only_an_absolute_regular_directory() {
|
||||
let root = unique_project_path();
|
||||
fs::create_dir_all(&root).expect("create creation-root fixture");
|
||||
let not_a_directory = root.join("not-a-directory.txt");
|
||||
fs::write(¬_a_directory, b"x").expect("write file fixture");
|
||||
|
||||
assert_eq!(
|
||||
validate_requested_game_project_creation_root(" ").expect_err("blank root is rejected"),
|
||||
"项目创建目录必须是绝对路径"
|
||||
);
|
||||
assert_eq!(
|
||||
validate_requested_game_project_creation_root("relative/projects")
|
||||
.expect_err("relative root is rejected"),
|
||||
"项目创建目录必须是绝对路径"
|
||||
);
|
||||
assert_eq!(
|
||||
validate_requested_game_project_creation_root(&format!("{}\\pro\nject", root.display()))
|
||||
.expect_err("control character is rejected"),
|
||||
"项目创建目录不能包含控制字符"
|
||||
);
|
||||
assert_eq!(
|
||||
validate_requested_game_project_creation_root(¬_a_directory.to_string_lossy())
|
||||
.expect_err("file root is rejected"),
|
||||
"项目创建目录必须是普通文件夹"
|
||||
);
|
||||
assert_eq!(
|
||||
validate_requested_game_project_creation_root(&format!(" {}\n", root.display()))
|
||||
.expect("trimmed directory root is accepted"),
|
||||
root
|
||||
);
|
||||
assert!(
|
||||
validate_requested_game_project_creation_root(&root.join("missing").to_string_lossy())
|
||||
.is_err(),
|
||||
"a not-yet-existing creation root must fail instead of being created silently"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_local_game_project_creates_inside_the_requested_creation_root() {
|
||||
let projects_root = unique_project_path();
|
||||
fs::create_dir_all(&projects_root).expect("create creation-root fixture");
|
||||
|
||||
let requested = validate_requested_game_project_creation_root(&projects_root.to_string_lossy())
|
||||
.expect("valid creation root");
|
||||
let result = create_automatic_local_game_project_at(&requested, None, false)
|
||||
.expect("create workspace in requested root");
|
||||
|
||||
let project_root = PathBuf::from(&result.project_path);
|
||||
assert_eq!(project_root.parent(), Some(projects_root.as_path()));
|
||||
assert!(project_root.join(".agent/manifest.json").is_file());
|
||||
|
||||
fs::remove_dir_all(projects_root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_local_game_project_accepts_only_a_safe_custom_name() {
|
||||
let projects_root = unique_project_path();
|
||||
|
||||
@@ -17,10 +17,15 @@ import type {
|
||||
ProjectAgentRuntimeSummary,
|
||||
} from '../../view/project-development';
|
||||
import type { ProjectManifestSnapshotMetadata } from '../../view/project-development/projectResourceLiveUpdateModel';
|
||||
import { isAbsoluteProjectPath } from '../project-summary/projectSummary';
|
||||
import {
|
||||
isAbsoluteProjectPath,
|
||||
projectPathHasControlCharacter,
|
||||
} from '../project-summary/projectSummary';
|
||||
|
||||
const RECENT_WORKSPACES_STORAGE_KEY =
|
||||
'genarrative-ai-game-creator.recent-workspaces.v1';
|
||||
const PROJECT_CREATION_DIRECTORY_STORAGE_KEY =
|
||||
'genarrative-ai-game-creator.project-creation-directory.v1';
|
||||
const SUPERVISOR_CHAT_DRAFT_STORAGE_PREFIX =
|
||||
'genarrative.supervisor-chat.draft';
|
||||
|
||||
@@ -156,6 +161,57 @@ export function removeRecentWorkspace(path: string) {
|
||||
return recent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「项目创建目录」偏好:空串表示沿用 AGC 管理的默认位置(应用数据目录下的 projects)。
|
||||
*
|
||||
* 这里只保存用户意图,不是授权凭据:目录授权来自原生目录选择器,并由 Rust 侧私有路径门禁
|
||||
* 在每次建项时重新复核,所以存储被改坏最坏只是退回默认位置或拿到一次可见的建项失败。
|
||||
*/
|
||||
export function normalizeProjectCreationDirectory(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || projectPathHasControlCharacter(trimmed)) {
|
||||
return '';
|
||||
}
|
||||
const withoutTrailingSeparator = trimmed.replace(/[\\/]+$/, '');
|
||||
// `C:\` 这类盘根只去掉分隔符会变成相对路径 `C:`,必须补回来。
|
||||
return /^[a-zA-Z]:$/.test(withoutTrailingSeparator)
|
||||
? `${withoutTrailingSeparator}\\`
|
||||
: withoutTrailingSeparator;
|
||||
}
|
||||
|
||||
export function readProjectCreationDirectory() {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(
|
||||
PROJECT_CREATION_DIRECTORY_STORAGE_KEY,
|
||||
);
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : '';
|
||||
if (typeof parsed !== 'string') {
|
||||
return '';
|
||||
}
|
||||
const directory = normalizeProjectCreationDirectory(parsed);
|
||||
return isAbsoluteProjectPath(directory) ? directory : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function writeProjectCreationDirectory(path: string) {
|
||||
const directory = normalizeProjectCreationDirectory(path);
|
||||
try {
|
||||
if (directory) {
|
||||
window.localStorage.setItem(
|
||||
PROJECT_CREATION_DIRECTORY_STORAGE_KEY,
|
||||
JSON.stringify(directory),
|
||||
);
|
||||
} else {
|
||||
window.localStorage.removeItem(PROJECT_CREATION_DIRECTORY_STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
// WebView storage can be unavailable in restricted test shells.
|
||||
}
|
||||
return directory;
|
||||
}
|
||||
|
||||
export function isTransientProjectOpenMessage(
|
||||
message: ChatMessage,
|
||||
projectPath: string,
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
isAbsoluteProjectPath,
|
||||
projectPathHasControlCharacter,
|
||||
} from '../project-summary/projectSummary';
|
||||
import { readProjectCreationDirectory } from './model';
|
||||
import { resolveSessionPreviewOnProjectOpen } from './sessionPreview';
|
||||
|
||||
/** 首页输入框当前的纯文本(Lexical 编辑器状态 -> 文本);没有输入就返回空串。 */
|
||||
@@ -828,6 +829,9 @@ export function useHomeProjectCreation({
|
||||
{
|
||||
name: suggestedName,
|
||||
planning: startMode === 'planning',
|
||||
// 用户在首页选过「项目创建目录」就用它;没选传 null,由 Rust 侧回落到
|
||||
// AGC 管理的默认位置(应用数据目录下的 projects)。
|
||||
projectsRoot: readProjectCreationDirectory() || null,
|
||||
},
|
||||
);
|
||||
createdProjectPath = result.projectPath;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import {
|
||||
readProjectCreationDirectory,
|
||||
writeProjectCreationDirectory,
|
||||
} from './model';
|
||||
|
||||
/**
|
||||
* 「项目创建目录」用户偏好。
|
||||
*
|
||||
* 默认沿用 AGC 管理的应用数据目录(`<app_data>/projects`);用户改选时必须走原生目录
|
||||
* 选择器,因为只有它构成"用户显式选择"边界:选择结果当场按用户选择范围加固,后续建项
|
||||
* 再由 Rust 侧私有路径门禁复核一次。
|
||||
*/
|
||||
export function useProjectCreationDirectory() {
|
||||
const [projectCreationDirectory, setProjectCreationDirectory] = useState(
|
||||
readProjectCreationDirectory,
|
||||
);
|
||||
const [projectCreationDirectoryBusy, setProjectCreationDirectoryBusy] =
|
||||
useState(false);
|
||||
const [projectCreationDirectoryStatus, setProjectCreationDirectoryStatus] =
|
||||
useState('');
|
||||
const pickInFlightRef = useRef(false);
|
||||
|
||||
const pickProjectCreationDirectory = useCallback(async () => {
|
||||
if (pickInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
setProjectCreationDirectoryStatus('需要在陶泥儿客户端内运行');
|
||||
return;
|
||||
}
|
||||
pickInFlightRef.current = true;
|
||||
setProjectCreationDirectoryBusy(true);
|
||||
setProjectCreationDirectoryStatus('正在选择项目创建目录');
|
||||
try {
|
||||
const selected = await invoke<string | null>(
|
||||
'pick_local_project_directory',
|
||||
{
|
||||
title: '选择项目创建目录',
|
||||
...(projectCreationDirectory
|
||||
? { initialPath: projectCreationDirectory }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
if (!selected) {
|
||||
setProjectCreationDirectoryStatus('已取消');
|
||||
return;
|
||||
}
|
||||
setProjectCreationDirectory(writeProjectCreationDirectory(selected));
|
||||
setProjectCreationDirectoryStatus('已更新项目创建目录');
|
||||
} catch (error) {
|
||||
setProjectCreationDirectoryStatus(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
pickInFlightRef.current = false;
|
||||
setProjectCreationDirectoryBusy(false);
|
||||
}
|
||||
}, [projectCreationDirectory]);
|
||||
|
||||
const resetProjectCreationDirectory = useCallback(() => {
|
||||
writeProjectCreationDirectory('');
|
||||
setProjectCreationDirectory('');
|
||||
setProjectCreationDirectoryStatus('已恢复默认位置');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
projectCreationDirectory,
|
||||
projectCreationDirectoryBusy,
|
||||
projectCreationDirectoryStatus,
|
||||
pickProjectCreationDirectory,
|
||||
resetProjectCreationDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
export type ProjectCreationDirectoryController = ReturnType<
|
||||
typeof useProjectCreationDirectory
|
||||
>;
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
CircleAlert,
|
||||
FolderOpen,
|
||||
Info,
|
||||
LoaderCircle,
|
||||
Pencil,
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
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';
|
||||
@@ -77,6 +79,7 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = {
|
||||
|
||||
type RuntimeSettingsSection =
|
||||
| 'general'
|
||||
| 'workspace'
|
||||
| 'agents'
|
||||
| 'extensions'
|
||||
| 'advanced'
|
||||
@@ -96,6 +99,12 @@ const runtimeSettingsSections = [
|
||||
description: '运行方式与输出偏好',
|
||||
icon: Settings2,
|
||||
},
|
||||
{
|
||||
id: 'workspace',
|
||||
label: '工作区',
|
||||
description: '项目创建目录',
|
||||
icon: FolderOpen,
|
||||
},
|
||||
{
|
||||
id: 'agents',
|
||||
label: 'Agent 分工',
|
||||
@@ -224,6 +233,11 @@ export function RuntimeConfigDialog({
|
||||
const [runtimeConfigBusy, setRuntimeConfigBusy] = useState(false);
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<RuntimeSettingsSection>('general');
|
||||
/**
|
||||
* 「项目创建目录」是客户端本地偏好(localStorage),不随下面的配置文件一起保存:
|
||||
* 选择目录当场生效,做游戏 / 做方案与模板建项下一次建项就落在该目录下。
|
||||
*/
|
||||
const projectCreationDirectory = useProjectCreationDirectory();
|
||||
const [clientExtensions, setClientExtensions] = useState<
|
||||
ClientExtensionItem[]
|
||||
>([]);
|
||||
@@ -875,6 +889,53 @@ export function RuntimeConfigDialog({
|
||||
) : 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' ? (
|
||||
<>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type { InitLocalProjectResult } from '../../app/types';
|
||||
import { readProjectCreationDirectory } from '../app-shell/model';
|
||||
import {
|
||||
collectGameTemplateRuntimes,
|
||||
collectGameTemplateTags,
|
||||
@@ -153,6 +154,8 @@ export function useTemplateLibrary({
|
||||
templateVersion: template.templateVersion,
|
||||
name: null,
|
||||
planning: false,
|
||||
// 与首页自动建项共用一个「项目创建目录」偏好;没选时由 Rust 侧回落到默认位置。
|
||||
projectsRoot: readProjectCreationDirectory() || null,
|
||||
},
|
||||
);
|
||||
await onProjectCreated(result);
|
||||
|
||||
@@ -4247,7 +4247,15 @@ h2 {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.runtime-settings-field-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.runtime-settings-section-actions button,
|
||||
.runtime-settings-field-actions button,
|
||||
.runtime-settings-extension-actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -4264,12 +4272,14 @@ h2 {
|
||||
}
|
||||
|
||||
.runtime-settings-section-actions button:hover,
|
||||
.runtime-settings-field-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-field-actions button:disabled,
|
||||
.runtime-settings-extension-actions button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
|
||||
@@ -1631,6 +1631,7 @@ export function registerHomeProjectCreationTests() {
|
||||
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
|
||||
name: null,
|
||||
planning: false,
|
||||
projectsRoot: null,
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', {
|
||||
projectPath: automaticProjectPath,
|
||||
@@ -1736,6 +1737,7 @@ export function registerHomeProjectCreationTests() {
|
||||
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
|
||||
name: '角色参考游戏',
|
||||
planning: false,
|
||||
projectsRoot: null,
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('upload_local_asset', {
|
||||
projectPath: automaticProjectPath,
|
||||
@@ -3543,4 +3545,102 @@ export function registerRecentProjectsTests() {
|
||||
);
|
||||
expect(window.localStorage.length).toBe(0);
|
||||
});
|
||||
|
||||
it('creates the automatic workspace inside the project creation directory picked in settings', async () => {
|
||||
const automaticProjectPath =
|
||||
'F:\\Projects\\我的游戏\\gameagent-chosen-directory';
|
||||
const creationDirectory = 'F:\\Projects\\我的游戏';
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'home-creation-directory-project',
|
||||
'自选目录项目',
|
||||
);
|
||||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||||
projectPath: automaticProjectPath,
|
||||
initialSessionExists: false,
|
||||
});
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_game_creator_app_config') {
|
||||
return {
|
||||
path: 'C:\\Users\\tester\\AppData\\Roaming\\genarrative\\config.json',
|
||||
config: {
|
||||
agentMode: 'codex_app_server',
|
||||
llm: {
|
||||
apiKey: '',
|
||||
baseUrl: 'https://llm.example.test/v1',
|
||||
model: 'gpt-creation-directory',
|
||||
apiKind: 'openai_responses',
|
||||
reasoningEffort: 'high',
|
||||
stream: true,
|
||||
webSearchEnabled: false,
|
||||
contextWindowTokens: 128000,
|
||||
autoCompactTokenLimit: 64000,
|
||||
toolOutputTokenLimit: 12000,
|
||||
requestTimeoutMs: 180000,
|
||||
maxRetries: 2,
|
||||
retryBackoffMs: 500,
|
||||
},
|
||||
agentLlm: {},
|
||||
editorApi: { baseUrl: 'http://127.0.0.1:8082', apiKey: '' },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (command === 'pick_local_project_directory') {
|
||||
return creationDirectory;
|
||||
}
|
||||
if (command === 'create_automatic_local_game_project') {
|
||||
return {
|
||||
projectPath: automaticProjectPath,
|
||||
manifestPath: `${automaticProjectPath}\\.agent\\manifest.json`,
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
if (command === 'chat_with_game_creator_direct_codex') {
|
||||
return '收到,开始搭建。';
|
||||
}
|
||||
return supervisorHarness.invoke(command, args);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: { invoke },
|
||||
event: { listen: supervisorHarness.listen },
|
||||
};
|
||||
renderLauncherAt('/?launcher', 'home', true);
|
||||
|
||||
// 设置 → 工作区:默认位置就是不选目录,仍然落在 AGC 管理的应用数据目录。
|
||||
fireEvent.click(screen.getByRole('button', { name: '配置' }));
|
||||
const settings = await screen.findByRole('dialog', { name: '运行时配置' });
|
||||
fireEvent.click(within(settings).getByRole('button', { name: /工作区/ }));
|
||||
expect(within(settings).getByText('默认位置')).not.toBeNull();
|
||||
fireEvent.click(within(settings).getByRole('button', { name: '选择目录' }));
|
||||
await waitFor(() => {
|
||||
expect(within(settings).getByText(creationDirectory)).not.toBeNull();
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('pick_local_project_directory', {
|
||||
title: '选择项目创建目录',
|
||||
});
|
||||
expect(
|
||||
window.localStorage.getItem(
|
||||
'genarrative-ai-game-creator.project-creation-directory.v1',
|
||||
),
|
||||
).toBe(JSON.stringify(creationDirectory));
|
||||
fireEvent.click(
|
||||
within(settings).getByRole('button', { name: '关闭 Agent 设置' }),
|
||||
);
|
||||
|
||||
const promptInput = screen.getByLabelText('创作想法');
|
||||
nativeClipboardMock.text = '做一个花园经营游戏';
|
||||
fireEvent.paste(promptInput);
|
||||
await waitFor(() => {
|
||||
expect(promptInput.textContent).toContain('做一个花园经营游戏');
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
|
||||
|
||||
expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
|
||||
name: null,
|
||||
planning: false,
|
||||
projectsRoot: creationDirectory,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -360,6 +360,70 @@ export function registerRuntimeSettingsTests() {
|
||||
expect(screen.getByText('桌面客户端')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the project creation directory in the workspace settings section', async () => {
|
||||
const creationDirectory = 'F:\\Projects\\陶泥儿游戏';
|
||||
const storageKey =
|
||||
'genarrative-ai-game-creator.project-creation-directory.v1';
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'read_game_creator_app_config') {
|
||||
return {
|
||||
path: '/home/test/AppData/game-creator.config.json',
|
||||
config: {
|
||||
agentMode: 'codex_app_server',
|
||||
llm: {
|
||||
apiKey: '',
|
||||
baseUrl: 'https://llm.example.test/v1',
|
||||
model: 'gpt-workspace',
|
||||
apiKind: 'openai_responses',
|
||||
reasoningEffort: 'high',
|
||||
stream: true,
|
||||
webSearchEnabled: false,
|
||||
contextWindowTokens: 128000,
|
||||
autoCompactTokenLimit: 64000,
|
||||
toolOutputTokenLimit: 12000,
|
||||
requestTimeoutMs: 180000,
|
||||
maxRetries: 2,
|
||||
retryBackoffMs: 500,
|
||||
},
|
||||
agentLlm: {},
|
||||
editorApi: { baseUrl: 'http://127.0.0.1:8082', apiKey: '' },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (command === 'pick_local_project_directory') {
|
||||
return creationDirectory;
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
renderLauncherAt('/?launcher');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '配置' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '运行时配置' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /工作区/ }));
|
||||
|
||||
// 不选目录时是默认位置,且本地不写任何偏好。
|
||||
expect(within(dialog).getByText('默认位置')).not.toBeNull();
|
||||
expect(window.localStorage.getItem(storageKey)).toBeNull();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '选择目录' }));
|
||||
expect(await within(dialog).findByText(creationDirectory)).not.toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith('pick_local_project_directory', {
|
||||
title: '选择项目创建目录',
|
||||
});
|
||||
expect(window.localStorage.getItem(storageKey)).toBe(
|
||||
JSON.stringify(creationDirectory),
|
||||
);
|
||||
expect(within(dialog).getByText('已更新项目创建目录')).not.toBeNull();
|
||||
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '恢复默认位置' }),
|
||||
);
|
||||
expect(within(dialog).getByText('默认位置')).not.toBeNull();
|
||||
expect(window.localStorage.getItem(storageKey)).toBeNull();
|
||||
expect(within(dialog).getByText('已恢复默认位置')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('locks the Agent mode and official LLM route while dropping legacy credentials', async () => {
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
normalizeProjectCreationDirectory,
|
||||
readProjectCreationDirectory,
|
||||
writeProjectCreationDirectory,
|
||||
} from '../src/features/app-shell/model';
|
||||
|
||||
const STORAGE_KEY = 'genarrative-ai-game-creator.project-creation-directory.v1';
|
||||
|
||||
describe('项目创建目录偏好', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('去掉首尾空白与多余分隔符,并保留盘根', () => {
|
||||
expect(normalizeProjectCreationDirectory(' ')).toBe('');
|
||||
expect(normalizeProjectCreationDirectory(' F:\\Projects\\游戏\\ ')).toBe(
|
||||
'F:\\Projects\\游戏',
|
||||
);
|
||||
expect(normalizeProjectCreationDirectory('F:/Projects/游戏/')).toBe(
|
||||
'F:/Projects/游戏',
|
||||
);
|
||||
expect(normalizeProjectCreationDirectory('C:\\')).toBe('C:\\');
|
||||
// 首尾空白按 trim 处理;目录中间的控制字符必须整条拒绝。
|
||||
expect(normalizeProjectCreationDirectory('F:\\游戏\n')).toBe('F:\\游戏');
|
||||
expect(normalizeProjectCreationDirectory('F:\\游\n戏')).toBe('');
|
||||
});
|
||||
|
||||
it('保存选中的目录,并在恢复默认位置时清空', () => {
|
||||
expect(readProjectCreationDirectory()).toBe('');
|
||||
|
||||
expect(writeProjectCreationDirectory(' F:\\Projects\\游戏 ')).toBe(
|
||||
'F:\\Projects\\游戏',
|
||||
);
|
||||
expect(readProjectCreationDirectory()).toBe('F:\\Projects\\游戏');
|
||||
|
||||
expect(writeProjectCreationDirectory(' ')).toBe('');
|
||||
expect(readProjectCreationDirectory()).toBe('');
|
||||
});
|
||||
|
||||
it('忽略存储里不可用的值,退回默认位置', () => {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify('relative/games'));
|
||||
expect(readProjectCreationDirectory()).toBe('');
|
||||
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(42));
|
||||
expect(readProjectCreationDirectory()).toBe('');
|
||||
|
||||
window.localStorage.setItem(STORAGE_KEY, '{not json');
|
||||
expect(readProjectCreationDirectory()).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user