合并 origin/master:策划附件导入接入策划聊天入口
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m55s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m54s
Project CI / Backend tests (pull_request) Successful in 5m4s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 10m18s
Project CI / Native shell tests (pull_request) Successful in 6m34s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m32s
Project CI / Frontend tests (pull_request) Successful in 3m15s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m29s
Project CI / Repository checks (pull_request) Successful in 1m42s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m55s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m54s
Project CI / Backend tests (pull_request) Successful in 5m4s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 10m18s
Project CI / Native shell tests (pull_request) Successful in 6m34s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m32s
Project CI / Frontend tests (pull_request) Successful in 3m15s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m29s
Project CI / Repository checks (pull_request) Successful in 1m42s
- 保留 DirectProject 独立聊天容器与 Supervisor 退役结果,不为冲突恢复运行时兼容分支 - 策划工作区文件导入接入 PlanningChatView 输入盒,导入期间暂缓发送、阶段批准、澄清与重试 - 工作台壳按项目记录导入进行态,导入未完成时不切换游戏运行态并提示等待 - 首页策划附件导入结果落到工作台 fileImportNotice 提示条,可单独关闭 - 设计态输入盒样式选择器由 project-supervisor-* 收敛为 project-chat-* - 策划导入回归用例适配退役后的 harness 与 chatProps 命名
This commit is contained in:
@@ -13,7 +13,7 @@ mod codex_app_server;
|
||||
mod codex_cli;
|
||||
mod codex_provider_proxy;
|
||||
mod design_runtime;
|
||||
mod design_tools;
|
||||
pub(crate) mod design_tools;
|
||||
mod direct_codex_attachments;
|
||||
mod direct_codex_audit;
|
||||
mod direct_codex_user_item;
|
||||
|
||||
@@ -4,10 +4,12 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::Manager;
|
||||
|
||||
const DESIGN_WORKSPACE_ROOT: &str = "design_artifacts";
|
||||
const DESIGN_REFERENCES_ROOT: &str = "references";
|
||||
const SEARCH_HIT_LIMIT: usize = 200;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -547,6 +549,123 @@ pub(crate) fn read_design_workspace_file_at(root: &Path, path: &str) -> Result<S
|
||||
fs::read_to_string(&target).map_err(|error| format!("读取失败:{error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn import_design_workspace_file(
|
||||
app: tauri::AppHandle,
|
||||
project_path: String,
|
||||
file_name: String,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<String, String> {
|
||||
let root = PathBuf::from(project_path.trim());
|
||||
let relative_path = import_design_workspace_file_at(&root, &file_name, &bytes)?;
|
||||
let _ = app.emit(
|
||||
"design-agent-update",
|
||||
serde_json::json!({
|
||||
"projectPath": root.to_string_lossy(),
|
||||
"clientTurnId": "",
|
||||
"kind": "workspace",
|
||||
"messageId": null,
|
||||
"text": null,
|
||||
"view": null,
|
||||
}),
|
||||
);
|
||||
Ok(relative_path)
|
||||
}
|
||||
|
||||
fn import_design_workspace_file_at(
|
||||
root: &Path,
|
||||
file_name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<String, String> {
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
read_existing_manifest_for_project(root)?;
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"conversation.write",
|
||||
)?;
|
||||
|
||||
if file_name.contains(['/', '\\']) {
|
||||
return Err("附件文件名必须是单个文件名,不能包含目录".to_string());
|
||||
}
|
||||
let normalized_name =
|
||||
normalize_relative_path(file_name).map_err(|error| format!("附件文件名无效:{error}"))?;
|
||||
if normalized_name != file_name {
|
||||
return Err("附件文件名无效".to_string());
|
||||
}
|
||||
|
||||
let (_, references) = resolve_design_workspace_path(root, DESIGN_REFERENCES_ROOT)?;
|
||||
crate::ensure_game_creator_private_directory_tree(&references, "策划参考附件目录")?;
|
||||
crate::prepare_game_creator_private_path_for_read(&references, true, "策划参考附件目录")?;
|
||||
|
||||
let mut sequence = 1_u64;
|
||||
loop {
|
||||
let candidate_name = design_reference_file_name(&normalized_name, sequence);
|
||||
let relative_path = format!("{DESIGN_REFERENCES_ROOT}/{candidate_name}");
|
||||
let (_, target) = resolve_design_workspace_path(root, &relative_path)?;
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.custom_flags(libc::O_NOFOLLOW).mode(0o600);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
|
||||
}
|
||||
let mut file = match options.open(&target) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
sequence = sequence
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| "无法为同名附件分配新序号".to_string())?;
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"创建策划参考附件失败:{}: {error}",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
};
|
||||
if let Err(error) =
|
||||
crate::harden_new_game_creator_private_path(&target, false, "策划参考附件")
|
||||
{
|
||||
drop(file);
|
||||
let _ = fs::remove_file(&target);
|
||||
return Err(error);
|
||||
}
|
||||
let write_result = file.write_all(bytes).and_then(|_| file.sync_all());
|
||||
drop(file);
|
||||
if let Err(error) = write_result {
|
||||
let _ = fs::remove_file(&target);
|
||||
return Err(format!(
|
||||
"写入策划参考附件失败:{}: {error}",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
return Ok(relative_path);
|
||||
}
|
||||
}
|
||||
|
||||
fn design_reference_file_name(file_name: &str, sequence: u64) -> String {
|
||||
if sequence == 1 {
|
||||
return file_name.to_string();
|
||||
}
|
||||
let path = Path::new(file_name);
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or(file_name);
|
||||
match path.extension().and_then(|value| value.to_str()) {
|
||||
Some(extension) if !extension.is_empty() => {
|
||||
format!("{stem} ({sequence}).{extension}")
|
||||
}
|
||||
_ => format!("{stem} ({sequence})"),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_design_catalog(root: &Path) -> Result<Vec<DesignCatalogItem>, String> {
|
||||
let catalog_path = root.join("resources/catalog.json");
|
||||
let data: DesignCatalogFile = serde_json::from_str(
|
||||
@@ -756,6 +875,13 @@ mod tests {
|
||||
tempfile::tempdir().expect("tempdir")
|
||||
}
|
||||
|
||||
fn initialized_test_root() -> tempfile::TempDir {
|
||||
let temp = test_root();
|
||||
init_local_game_project_at(temp.path(), "design-import-test", "策划附件导入测试")
|
||||
.expect("init project");
|
||||
temp
|
||||
}
|
||||
|
||||
fn pack_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("design-agent")
|
||||
}
|
||||
@@ -868,6 +994,138 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_reference_is_visible_to_workspace_list_and_read() {
|
||||
let temp = initialized_test_root();
|
||||
let root = temp.path();
|
||||
let manifest_before = read_existing_manifest_for_project(root).expect("read manifest");
|
||||
let revision_before =
|
||||
read_game_creator_agent_runtime_project_revision(root).expect("read revision");
|
||||
|
||||
let relative =
|
||||
import_design_workspace_file_at(root, "玩法构想.txt", "横版解谜\n".as_bytes())
|
||||
.expect("import text reference");
|
||||
|
||||
assert_eq!(relative, "references/玩法构想.txt");
|
||||
assert!(list_design_workspace_files(root)
|
||||
.expect("list workspace")
|
||||
.iter()
|
||||
.any(|entry| entry.path == relative && entry.kind == "file"));
|
||||
assert_eq!(
|
||||
read_design_workspace_file_at(root, &relative).expect("read imported reference"),
|
||||
"横版解谜\n"
|
||||
);
|
||||
assert_eq!(
|
||||
read_existing_manifest_for_project(root).expect("read manifest after import"),
|
||||
manifest_before
|
||||
);
|
||||
assert_eq!(
|
||||
read_game_creator_agent_runtime_project_revision(root)
|
||||
.expect("read revision after import"),
|
||||
revision_before
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_keeps_existing_names_and_accepts_empty_and_binary_bytes() {
|
||||
let temp = initialized_test_root();
|
||||
let root = temp.path();
|
||||
|
||||
let first = import_design_workspace_file_at(root, "brief.md", b"first")
|
||||
.expect("import first reference");
|
||||
let second = import_design_workspace_file_at(root, "brief.md", b"second")
|
||||
.expect("import repeated reference");
|
||||
let empty = import_design_workspace_file_at(root, "empty.bin", b"")
|
||||
.expect("import empty reference");
|
||||
let binary_bytes = [0_u8, 0xff, 0x10, 0x80];
|
||||
let binary = import_design_workspace_file_at(root, "bytes.bin", &binary_bytes)
|
||||
.expect("import binary reference");
|
||||
|
||||
assert_eq!(first, "references/brief.md");
|
||||
assert_eq!(second, "references/brief (2).md");
|
||||
assert_eq!(empty, "references/empty.bin");
|
||||
assert_eq!(binary, "references/bytes.bin");
|
||||
assert_eq!(
|
||||
fs::read(root.join("design_artifacts").join(&first)).expect("read first"),
|
||||
b"first"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(root.join("design_artifacts").join(&second)).expect("read second"),
|
||||
b"second"
|
||||
);
|
||||
assert!(fs::read(root.join("design_artifacts").join(&empty))
|
||||
.expect("read empty")
|
||||
.is_empty());
|
||||
assert_eq!(
|
||||
fs::read(root.join("design_artifacts").join(&binary)).expect("read binary"),
|
||||
binary_bytes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_rejects_unsafe_names_and_denied_project_permission() {
|
||||
let temp = initialized_test_root();
|
||||
let root = temp.path();
|
||||
for file_name in [
|
||||
"../outside.txt",
|
||||
"nested/file.txt",
|
||||
r"nested\file.txt",
|
||||
"C:stream",
|
||||
] {
|
||||
let error = import_design_workspace_file_at(root, file_name, b"blocked")
|
||||
.expect_err("reject unsafe file name");
|
||||
assert!(error.contains("文件名"), "unexpected error: {error}");
|
||||
}
|
||||
assert!(!root.join("outside.txt").exists());
|
||||
|
||||
let mut policy = ProjectPermissionPolicy::default();
|
||||
policy
|
||||
.denied_commands
|
||||
.push("conversation.write".to_string());
|
||||
write_project_permission_policy_at(root, policy).expect("deny conversation write");
|
||||
let error = import_design_workspace_file_at(root, "denied.txt", b"blocked")
|
||||
.expect_err("respect project permission policy");
|
||||
assert!(error.contains("conversation.write"));
|
||||
assert!(!root.join("design_artifacts/references/denied.txt").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn import_rejects_linked_references_directory() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = initialized_test_root();
|
||||
let root = temp.path();
|
||||
let outside = tempfile::tempdir().expect("outside tempdir");
|
||||
fs::create_dir_all(root.join("design_artifacts")).expect("create workspace");
|
||||
symlink(outside.path(), root.join("design_artifacts/references"))
|
||||
.expect("link references directory");
|
||||
|
||||
let error = import_design_workspace_file_at(root, "escape.txt", b"blocked")
|
||||
.expect_err("reject linked references directory");
|
||||
assert!(error.contains("符号链接") || error.contains("reparse point"));
|
||||
assert!(!outside.path().join("escape.txt").exists());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn import_rejects_windows_linked_references_directory_when_supported() {
|
||||
use std::os::windows::fs::symlink_dir;
|
||||
|
||||
let temp = initialized_test_root();
|
||||
let root = temp.path();
|
||||
let outside = tempfile::tempdir().expect("outside tempdir");
|
||||
fs::create_dir_all(root.join("design_artifacts")).expect("create workspace");
|
||||
if symlink_dir(outside.path(), root.join("design_artifacts/references")).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let error = import_design_workspace_file_at(root, "escape.txt", b"blocked")
|
||||
.expect_err("reject linked references directory");
|
||||
assert!(error.contains("符号链接") || error.contains("reparse point"));
|
||||
assert!(!outside.path().join("escape.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_context_injects_current_skill_only() {
|
||||
let resources = DesignResources::new(pack_root()).expect("pack");
|
||||
|
||||
@@ -157,6 +157,7 @@ mod tool_plan_handoff;
|
||||
mod user_input;
|
||||
mod windows;
|
||||
|
||||
use agent::design_tools::*;
|
||||
use agent::*;
|
||||
use agent_native_tools::*;
|
||||
use asset_generation_tasks::*;
|
||||
@@ -2613,6 +2614,7 @@ fn main() {
|
||||
debug_fast_forward_design_session,
|
||||
continue_design_agent_session,
|
||||
decide_design_phase,
|
||||
import_design_workspace_file,
|
||||
list_design_workspace,
|
||||
read_design_workspace_file,
|
||||
start_game_creator_agent_runtime_task,
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
projectPathHasControlCharacter,
|
||||
} from './features/project-summary/projectSummary';
|
||||
import { parseAgentRunTrace } from './features/project-workspace/agentRunTrace';
|
||||
import { importDesignFiles } from './features/project-workspace/importDesignFiles';
|
||||
import { parseRememberInput } from './features/project-workspace/memoryCommands';
|
||||
import {
|
||||
isAgentTraceFilePath,
|
||||
@@ -171,6 +172,7 @@ type AppProps = {
|
||||
initialPlanningPromptClaimScope?: string;
|
||||
initialCreationType?: HomeCreationType | null;
|
||||
initialAttachments?: LauncherImportedAttachment[];
|
||||
onDesignFilesImportingChange?: ProjectChatComponentProps['onDesignFilesImportingChange'];
|
||||
playRequest?: ProjectChatComponentProps['playRequest'];
|
||||
onPlayRequestHandled?: ProjectChatComponentProps['onPlayRequestHandled'];
|
||||
onManifestChange?: (
|
||||
@@ -206,6 +208,7 @@ export function App({
|
||||
initialPlanningPromptClaimScope = '',
|
||||
initialCreationType = null,
|
||||
initialAttachments = [],
|
||||
onDesignFilesImportingChange,
|
||||
playRequest = null,
|
||||
onPlayRequestHandled,
|
||||
onManifestChange,
|
||||
@@ -683,6 +686,8 @@ export function App({
|
||||
}
|
||||
}
|
||||
|
||||
const [chatFilesImporting, setChatFilesImporting] = useState(false);
|
||||
const [chatFileImportNotice, setChatFileImportNotice] = useState('');
|
||||
const planningChatMessagesRef = useRef<HTMLDivElement | null>(null);
|
||||
const planningChatShouldFollowLatestRef = useRef(true);
|
||||
const directProjectChatRef = useRef<DirectProjectChatHandle | null>(null);
|
||||
@@ -1311,6 +1316,8 @@ export function App({
|
||||
* 策划会话与设计 Agent 视图都属于上一个项目;项目身份一变就不能留到下一个项目里。
|
||||
*/
|
||||
function resetChatState() {
|
||||
setChatFilesImporting(false);
|
||||
setChatFileImportNotice('');
|
||||
setProjectChatError('');
|
||||
setDesignAgentTransientReplyTarget('');
|
||||
designAgentPendingViewRef.current = null;
|
||||
@@ -2166,6 +2173,34 @@ export function App({
|
||||
distanceFromBottom <= AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
* 策划输入盒导入本地文件:文件直接写进策划工作区(`references/`),既不进游戏资源
|
||||
* 清单,也不生成回合附件;导入期间通知工作台壳暂缓切换游戏运行态。
|
||||
*/
|
||||
async function handleDesignComposerUploadFiles(files: readonly File[]) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
const nextProjectPath = resolveChatProjectPath(localProject);
|
||||
if (!invoke || !nextProjectPath) {
|
||||
setChatFileImportNotice('需要先打开本地项目,才能导入文件');
|
||||
return;
|
||||
}
|
||||
if (chatFilesImporting || files.length === 0) {
|
||||
return;
|
||||
}
|
||||
setChatFilesImporting(true);
|
||||
onDesignFilesImportingChange?.(nextProjectPath, true);
|
||||
setChatFileImportNotice('正在导入文件');
|
||||
try {
|
||||
const notice = await importDesignFiles(invoke, nextProjectPath, files);
|
||||
if (localProjectPathRef.current === nextProjectPath) {
|
||||
setChatFileImportNotice(notice);
|
||||
}
|
||||
} finally {
|
||||
setChatFilesImporting(false);
|
||||
onDesignFilesImportingChange?.(nextProjectPath, false);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlanningChatSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const content = chatComposerRef.current?.getDraft().content ?? [];
|
||||
@@ -2345,6 +2380,9 @@ export function App({
|
||||
}
|
||||
error={projectChatError}
|
||||
controlBusy={chatAgentBusy}
|
||||
attachmentNotice={chatFileImportNotice}
|
||||
importingFiles={chatFilesImporting}
|
||||
onUploadFiles={(files) => void handleDesignComposerUploadFiles(files)}
|
||||
versions={chatProjectVersions}
|
||||
/>
|
||||
{runtimeConfigOpen ? (
|
||||
|
||||
@@ -143,6 +143,8 @@ export type LauncherProjectContext = {
|
||||
startMode: ProjectStartMode | null;
|
||||
initialPrompt: string;
|
||||
attachments: LauncherImportedAttachment[];
|
||||
/** 仅用于工作区界面,不进入 Agent 消息。 */
|
||||
fileImportNotice?: string;
|
||||
recentRunStatus: string | null;
|
||||
recentRunStopReason: string | null;
|
||||
createdAt: number;
|
||||
|
||||
@@ -607,7 +607,28 @@ export function WorkspaceLauncherShell({
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 策划文件导入工作区是「写进项目目录」的动作,导入期间切游戏运行态会读到半份工作区;
|
||||
* 这里只记导入中的项目,切运行时先挡一次。
|
||||
*/
|
||||
const designImportProjectRef = useRef<string | null>(null);
|
||||
const handleDesignFilesImportingChange = useCallback(
|
||||
(projectPath: string, importing: boolean) => {
|
||||
if (importing) designImportProjectRef.current = projectPath;
|
||||
else if (designImportProjectRef.current === projectPath)
|
||||
designImportProjectRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
async function switchToGameRuntime(nextProjectPath: string) {
|
||||
if (designImportProjectRef.current === nextProjectPath) {
|
||||
setLauncherNotice({
|
||||
title: '正在导入文件',
|
||||
message: '文件导入完成后,再做成游戏。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) throw new Error('需要在陶泥儿客户端内运行');
|
||||
await invoke('set_design_agent_runtime_mode', {
|
||||
@@ -712,6 +733,25 @@ export function WorkspaceLauncherShell({
|
||||
/>
|
||||
) : launcherView === 'project-development' && currentProjectContext ? (
|
||||
<>
|
||||
{currentProjectContext.fileImportNotice ? (
|
||||
<div className="launcher-manifest-merge-notices">
|
||||
<div className="game-resource-live-notice" role="status">
|
||||
<span>{currentProjectContext.fileImportNotice}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setCurrentProjectContext((current) =>
|
||||
current
|
||||
? { ...current, fileImportNotice: undefined }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{manifestMergeNotice ? (
|
||||
<div className="launcher-manifest-merge-notices">
|
||||
<div
|
||||
@@ -826,6 +866,9 @@ export function WorkspaceLauncherShell({
|
||||
setActiveProjectAgentRuntimeSummaries
|
||||
}
|
||||
onAgentResultsChange={setActiveProjectAgentResults}
|
||||
onDesignFilesImportingChange={
|
||||
handleDesignFilesImportingChange
|
||||
}
|
||||
onSwitchToGameRuntime={switchToGameRuntime}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -42,6 +42,11 @@ export type ProjectChatComponentProps = {
|
||||
initialPlanningPromptClaimScope?: string;
|
||||
initialCreationType?: HomeCreationType | null;
|
||||
initialAttachments?: LauncherImportedAttachment[];
|
||||
/** 策划文件导入工作区的进行态:导入期间工作台壳暂缓切换游戏运行态。 */
|
||||
onDesignFilesImportingChange?: (
|
||||
projectPath: string,
|
||||
importing: boolean,
|
||||
) => void;
|
||||
planningStartMode?: boolean;
|
||||
/**
|
||||
* C7 当前游戏版本:由工作台壳持有,策划聊天里的 `@` 面板按它切「当前版本素材」。
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
isAbsoluteProjectPath,
|
||||
projectPathHasControlCharacter,
|
||||
} from '../project-summary/projectSummary';
|
||||
import { importDesignFiles } from '../project-workspace/importDesignFiles';
|
||||
import {
|
||||
ensureHomeWebCreationEnvironment,
|
||||
HOME_WEB_PREFLIGHT_FAILURE,
|
||||
@@ -320,11 +321,18 @@ export function useHomeProjectCreation({
|
||||
activeRuntime: 'design',
|
||||
});
|
||||
}
|
||||
const importedAttachments = await importHomeAttachments(
|
||||
invoke,
|
||||
result.projectPath,
|
||||
attachments,
|
||||
);
|
||||
const fileImportNotice =
|
||||
startMode === 'planning'
|
||||
? await importDesignFiles(
|
||||
invoke,
|
||||
result.projectPath,
|
||||
attachments.map((item) => item.file),
|
||||
)
|
||||
: undefined;
|
||||
const importedAttachments =
|
||||
startMode === 'planning'
|
||||
? []
|
||||
: await importHomeAttachments(invoke, result.projectPath, attachments);
|
||||
await enterProjectDevelopment({
|
||||
projectPath: result.projectPath,
|
||||
projectName:
|
||||
@@ -339,10 +347,11 @@ export function useHomeProjectCreation({
|
||||
startMode,
|
||||
initialPrompt:
|
||||
prompt.trim() ||
|
||||
(attachments.length > 0
|
||||
(attachments.length > 0 && startMode !== 'planning'
|
||||
? '用户上传了参考附件,等待后续补充需求。'
|
||||
: ''),
|
||||
attachments: importedAttachments,
|
||||
fileImportNotice,
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
createdAt: Date.now(),
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { TauriInvoke } from '../../app/types';
|
||||
|
||||
/** 策划附件只写工作区;返回界面提示,不生成回合附件或资源清单。 */
|
||||
export async function importDesignFiles(
|
||||
invoke: TauriInvoke,
|
||||
projectPath: string,
|
||||
files: readonly File[],
|
||||
): Promise<string> {
|
||||
if (files.length === 0) return '';
|
||||
let imported = 0;
|
||||
const failures: string[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
await invoke<string>('import_design_workspace_file', {
|
||||
projectPath,
|
||||
fileName: file.name,
|
||||
bytes: Array.from(new Uint8Array(await file.arrayBuffer())),
|
||||
});
|
||||
imported += 1;
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
`${file.name}:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const summary = `已导入 ${imported} 个文件到策划工作区`;
|
||||
return failures.length > 0
|
||||
? `${summary};${failures.length} 个文件导入失败:${failures.join(';')}。可重新选择失败文件重试。`
|
||||
: summary;
|
||||
}
|
||||
@@ -10585,6 +10585,7 @@ button.design-workspace-tree__entry:hover,
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.game-workbench-layout--design .project-chat-composer-controls-left,
|
||||
.game-workbench-layout--design .project-chat-composer-controls-right,
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
@@ -10665,6 +10666,9 @@ button.design-workspace-tree__entry:hover,
|
||||
|
||||
/* 控制排三只方钮(`+` 附件 / `@` 引用 / 发送)共用一套尺寸。发送钮单独加圆角与主色,
|
||||
见下面两条规则。 */
|
||||
.game-workbench-layout--design
|
||||
.project-chat-composer-controls
|
||||
.project-chat-attachment-trigger,
|
||||
.game-workbench-layout--design
|
||||
.project-chat-composer-controls
|
||||
.project-chat-submit-button,
|
||||
@@ -10698,6 +10702,9 @@ button.design-workspace-tree__entry:hover,
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.game-workbench-layout--design
|
||||
.project-chat-composer-controls
|
||||
.project-chat-attachment-trigger,
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
@@ -10710,6 +10717,12 @@ button.design-workspace-tree__entry:hover,
|
||||
color: var(--platform-text-soft);
|
||||
}
|
||||
|
||||
.game-workbench-layout--design
|
||||
.project-chat-composer-controls
|
||||
.project-chat-attachment-trigger:hover:not(:disabled),
|
||||
.game-workbench-layout--design
|
||||
.project-chat-composer-controls
|
||||
.project-chat-attachment-trigger:focus-visible,
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
@@ -11006,6 +11019,7 @@ button.design-workspace-tree__entry:hover,
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.game-workbench-layout--design .project-chat-composer-notice,
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
|
||||
+59
-3
@@ -1,5 +1,6 @@
|
||||
import { ArrowUp, Loader2 } from 'lucide-react';
|
||||
import { ArrowUp, FileUp, Loader2 } from 'lucide-react';
|
||||
import type { FormEventHandler, RefObject, UIEventHandler } from 'react';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { AgentMessageContent } from '../../../../../../packages/shared/src/components/AgentMessageContent';
|
||||
import type {
|
||||
@@ -59,6 +60,11 @@ type PlanningChatViewProps = {
|
||||
composerRef?: RefObject<ResourceReferenceInputHandle | null>;
|
||||
controlBusy: boolean;
|
||||
error: string;
|
||||
/** 导入工作区的提示文案(导入中与结果共用一条;空串不渲染)。 */
|
||||
attachmentNotice?: string;
|
||||
/** 工作区文件导入进行中:发送与阶段操作都等它结束。 */
|
||||
importingFiles?: boolean;
|
||||
onUploadFiles?: (files: readonly File[]) => void;
|
||||
hiddenConversationCount: number;
|
||||
hasEarlierConversationMessages?: boolean;
|
||||
initialPlanningPrompt?: string;
|
||||
@@ -95,6 +101,9 @@ export function PlanningChatView({
|
||||
composerRef,
|
||||
controlBusy,
|
||||
error,
|
||||
attachmentNotice = '',
|
||||
importingFiles = false,
|
||||
onUploadFiles,
|
||||
hiddenConversationCount,
|
||||
hasEarlierConversationMessages = false,
|
||||
initialPlanningPrompt = '',
|
||||
@@ -120,6 +129,7 @@ export function PlanningChatView({
|
||||
onDesignClarify,
|
||||
onDesignRetry,
|
||||
}: PlanningChatViewProps) {
|
||||
const designFileInputRef = useRef<HTMLInputElement>(null);
|
||||
let submitLabel = '发送';
|
||||
if (needsUserInput) {
|
||||
submitLabel = '等待回答';
|
||||
@@ -130,12 +140,13 @@ export function PlanningChatView({
|
||||
// 输入区与提交按钮共用同一个禁用判据,避免两处条件各改一半而漂移。
|
||||
const composerDisabled =
|
||||
controlBusy ||
|
||||
importingFiles ||
|
||||
needsUserInput ||
|
||||
Boolean(designView?.session.pendingApproval) ||
|
||||
Boolean(designView?.session.pendingClarification);
|
||||
// 顶部状态条与底部待办区渲染的是同一份 Design Agent 状态,判据与回调兜底必须同源。
|
||||
const designMode = Boolean(designView || onDesignApprove);
|
||||
const designActionsBusy = controlBusy;
|
||||
const designActionsBusy = controlBusy || importingFiles;
|
||||
const handleDesignApprove = onDesignApprove ?? (() => undefined);
|
||||
const handleDesignClarify = onDesignClarify ?? (() => undefined);
|
||||
const handleDesignRetry = onDesignRetry ?? (() => undefined);
|
||||
@@ -279,7 +290,17 @@ export function PlanningChatView({
|
||||
onRetry={handleDesignRetry}
|
||||
/>
|
||||
) : null}
|
||||
<form className="project-chat-composer" onSubmit={onSubmit}>
|
||||
<form
|
||||
className="project-chat-composer"
|
||||
onSubmit={(event) => {
|
||||
// 导入没结束就提交,等于让 Agent 读半份工作区;挡住这一次提交。
|
||||
if (importingFiles) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onSubmit(event);
|
||||
}}
|
||||
>
|
||||
<ResourceReferenceInput
|
||||
ref={composerRef}
|
||||
ariaLabel="项目需求"
|
||||
@@ -294,6 +315,36 @@ export function PlanningChatView({
|
||||
placeholder=""
|
||||
/>
|
||||
<div className="project-chat-composer-controls">
|
||||
{onUploadFiles ? (
|
||||
<div className="project-chat-composer-controls-left">
|
||||
<input
|
||||
ref={designFileInputRef}
|
||||
type="file"
|
||||
hidden
|
||||
multiple
|
||||
className="project-chat-upload-input"
|
||||
data-chat-composer-upload="true"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
disabled={importingFiles}
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.currentTarget.files ?? []);
|
||||
event.currentTarget.value = '';
|
||||
if (files.length > 0) onUploadFiles(files);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="project-chat-attachment-trigger"
|
||||
aria-label="导入文件到策划工作区"
|
||||
title="导入文件到策划工作区"
|
||||
disabled={importingFiles}
|
||||
onClick={() => designFileInputRef.current?.click()}
|
||||
>
|
||||
<FileUp size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="project-chat-composer-controls-right">
|
||||
{/* 两条产品路径共用行为中立的配置控件;策划入口不接管提交校验。 */}
|
||||
<ComposerReasoningEffortSelect disabled={needsUserInput} />
|
||||
@@ -320,6 +371,11 @@ export function PlanningChatView({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{attachmentNotice ? (
|
||||
<p className="project-chat-composer-notice" role="status">
|
||||
{attachmentNotice}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
act,
|
||||
App,
|
||||
createProjectChatRuntimeHarness,
|
||||
expect,
|
||||
@@ -121,6 +122,140 @@ function designHistoryView() {
|
||||
}
|
||||
|
||||
export function registerDesignAgentSurfaceTests() {
|
||||
it('imports planning chat files into the workspace and retries failed files without turn attachments', async () => {
|
||||
const harness = createProjectChatRuntimeHarness({
|
||||
designAgentView: designConversationView(),
|
||||
designAgentContinueView: designConversationView(),
|
||||
});
|
||||
const originalInvoke = harness.invoke.getMockImplementation()!;
|
||||
let failImport = true;
|
||||
let finishImport!: () => void;
|
||||
const importGate = new Promise<void>((resolve) => {
|
||||
finishImport = resolve;
|
||||
});
|
||||
harness.invoke.mockImplementation(async (command, args) => {
|
||||
if (command === 'import_design_workspace_file') {
|
||||
await importGate;
|
||||
if (args?.fileName === '补充.md' && failImport)
|
||||
throw new Error('磁盘写入失败');
|
||||
return `references/${args?.fileName}`;
|
||||
}
|
||||
return originalInvoke(command, args);
|
||||
});
|
||||
renderDesignAgent(harness);
|
||||
await expectDesignModelReady();
|
||||
const button = screen.getByRole('button', { name: '导入文件到策划工作区' });
|
||||
expect(button).not.toBeNull();
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
'[data-chat-composer-upload]',
|
||||
)!;
|
||||
const files = ['需求.md', '补充.md'].map((name) => {
|
||||
const file = new File(['# 参考'], name, { type: 'text/markdown' });
|
||||
Object.defineProperty(file, 'arrayBuffer', {
|
||||
value: async () => new Uint8Array([35, 32, 65]).buffer,
|
||||
});
|
||||
return file;
|
||||
});
|
||||
const manifestReads = () =>
|
||||
harness.invoke.mock.calls.filter(
|
||||
([command]) => command === 'get_local_game_manifest',
|
||||
).length;
|
||||
const readsBefore = manifestReads();
|
||||
fireEvent.change(input, { target: { files } });
|
||||
await screen.findByText('正在导入文件');
|
||||
const composer = screen.getByLabelText('项目需求');
|
||||
await setComposerText(composer, '阅读工作区文件');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '发送' }).hasAttribute('disabled'),
|
||||
).toBe(true);
|
||||
fireEvent.submit(composer.closest('form')!);
|
||||
expect(
|
||||
harness.invoke.mock.calls.some(
|
||||
([command]) => command === 'continue_design_agent_session',
|
||||
),
|
||||
).toBe(false);
|
||||
await act(async () => finishImport());
|
||||
await screen.findByText(
|
||||
'已导入 1 个文件到策划工作区;1 个文件导入失败:补充.md:磁盘写入失败。可重新选择失败文件重试。',
|
||||
);
|
||||
expect(harness.invoke).toHaveBeenCalledWith(
|
||||
'import_design_workspace_file',
|
||||
{
|
||||
projectPath: harness.projectPath,
|
||||
fileName: '需求.md',
|
||||
bytes: [35, 32, 65],
|
||||
},
|
||||
);
|
||||
expect(manifestReads()).toBe(readsBefore);
|
||||
expect(
|
||||
harness.invoke.mock.calls.some(
|
||||
([command]) => command === 'upload_local_asset',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(screen.queryByLabelText('待发送附件')).toBeNull();
|
||||
|
||||
failImport = false;
|
||||
fireEvent.change(input, { target: { files: [files[1]] } });
|
||||
await screen.findByText('已导入 1 个文件到策划工作区');
|
||||
expect(
|
||||
harness.invoke.mock.calls.filter(
|
||||
([command]) => command === 'import_design_workspace_file',
|
||||
),
|
||||
).toHaveLength(3);
|
||||
|
||||
await setComposerText(composer, '阅读工作区文件');
|
||||
fireEvent.submit(composer.closest('form')!);
|
||||
await waitFor(() =>
|
||||
expect(harness.invoke).toHaveBeenCalledWith(
|
||||
'continue_design_agent_session',
|
||||
expect.objectContaining({
|
||||
input: { type: 'message', text: '阅读工作区文件' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
const turn = harness.invoke.mock.calls.find(
|
||||
([command]) => command === 'continue_design_agent_session',
|
||||
);
|
||||
expect(JSON.stringify(turn?.[1])).not.toContain('references/');
|
||||
expect(turn?.[1]).not.toHaveProperty('attachments');
|
||||
});
|
||||
|
||||
it('waits for planning file imports before allowing phase approval', async () => {
|
||||
const harness = createProjectChatRuntimeHarness({
|
||||
designAgentView: designApprovalView(),
|
||||
});
|
||||
const originalInvoke = harness.invoke.getMockImplementation()!;
|
||||
harness.invoke.mockImplementation(async (command, args) =>
|
||||
command === 'import_design_workspace_file'
|
||||
? 'references/需求.md'
|
||||
: originalInvoke(command, args),
|
||||
);
|
||||
renderDesignAgent(harness);
|
||||
const approve = await screen.findByRole('button', { name: '批准' });
|
||||
const file = new File(['# 参考'], '需求.md');
|
||||
let finishRead!: (value: ArrayBuffer) => void;
|
||||
Object.defineProperty(file, 'arrayBuffer', {
|
||||
value: () =>
|
||||
new Promise<ArrayBuffer>((resolve) => {
|
||||
finishRead = resolve;
|
||||
}),
|
||||
});
|
||||
fireEvent.change(document.querySelector('[data-chat-composer-upload]')!, {
|
||||
target: { files: [file] },
|
||||
});
|
||||
await screen.findByText('正在导入文件');
|
||||
expect(approve.hasAttribute('disabled')).toBe(true);
|
||||
fireEvent.click(approve);
|
||||
expect(
|
||||
harness.invoke.mock.calls.some(
|
||||
([command]) => command === 'decide_design_phase',
|
||||
),
|
||||
).toBe(false);
|
||||
await act(async () => finishRead(new Uint8Array([65]).buffer));
|
||||
await screen.findByText('已导入 1 个文件到策划工作区');
|
||||
expect(approve.hasAttribute('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('persists Design Agent model and reasoning controls and reads them on reentry', async () => {
|
||||
const harness = createProjectChatRuntimeHarness({
|
||||
designAgentView: designConversationView(),
|
||||
|
||||
@@ -1945,99 +1945,136 @@ export function registerHomeProjectCreationTests() {
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps 做方案 first turn on the planning lane without a Direct attachment sidecar', async () => {
|
||||
const projectPath = '/tmp/home-planning-attachment';
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'home-planning-attachment',
|
||||
'首页策划附件',
|
||||
);
|
||||
const chatHarness = createProjectChatRuntimeHarness({
|
||||
projectPath,
|
||||
designAgentContinueView: homeDesignContinueView({
|
||||
prompt: '整理一份可玩原型',
|
||||
}),
|
||||
});
|
||||
const fileBytes = Array.from(new TextEncoder().encode('png'));
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'create_automatic_local_game_project') {
|
||||
return {
|
||||
projectPath,
|
||||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
if (command === 'upload_local_asset') {
|
||||
return {
|
||||
id: 'asset-upload-plan-1',
|
||||
localPath: 'assets/uploads/reference.png',
|
||||
absolutePath: `${projectPath}/assets/uploads/reference.png`,
|
||||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||||
};
|
||||
}
|
||||
return chatHarness.invoke(command, args);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: { invoke },
|
||||
event: { listen: chatHarness.listen },
|
||||
};
|
||||
renderLauncherAt('/?launcher', 'home', true);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '做方案' }));
|
||||
const fileInput =
|
||||
document.querySelector<HTMLInputElement>('input[type="file"]');
|
||||
expect(fileInput).not.toBeNull();
|
||||
const attachment = new File(['png'], '角色参考.png', {
|
||||
type: 'image/png',
|
||||
lastModified: 1,
|
||||
});
|
||||
Object.defineProperty(attachment, 'arrayBuffer', {
|
||||
value: async () => new Uint8Array(fileBytes).buffer,
|
||||
});
|
||||
fireEvent.change(fileInput!, { target: { files: [attachment] } });
|
||||
|
||||
const promptInput = screen.getByLabelText('创作想法');
|
||||
nativeClipboardMock.text = '整理一份可玩原型';
|
||||
fireEvent.paste(promptInput);
|
||||
await waitFor(() => {
|
||||
expect(promptInput.textContent).toContain('整理一份可玩原型');
|
||||
expect(promptInput.textContent).toContain('角色参考.png');
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '进入立项策划' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'continue_design_agent_session',
|
||||
expect.objectContaining({
|
||||
projectPath,
|
||||
input: expect.objectContaining({ type: 'message' }),
|
||||
}),
|
||||
it.each(['success', 'partial-failure', 'files-only'])(
|
||||
'imports 做方案 references into the workspace: %s',
|
||||
async (scenario) => {
|
||||
const partialFailure = scenario === 'partial-failure';
|
||||
const filesOnly = scenario === 'files-only';
|
||||
const projectPath = `/tmp/home-planning-attachment-${scenario}`;
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'home-planning-attachment',
|
||||
'首页策划附件',
|
||||
);
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('upload_local_asset', {
|
||||
projectPath,
|
||||
fileName: '角色参考.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: fileBytes,
|
||||
});
|
||||
const startCall = invoke.mock.calls.find(
|
||||
([command]) => command === 'continue_design_agent_session',
|
||||
);
|
||||
expect(startCall?.[1]).not.toHaveProperty('attachments');
|
||||
expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain('本轮用户附件');
|
||||
expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain(
|
||||
'assets/uploads/reference.png',
|
||||
);
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'chat_with_game_creator_direct_codex',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'chat_with_game_creator_agent',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
const chatHarness = createProjectChatRuntimeHarness({
|
||||
projectPath,
|
||||
designAgentContinueView: homeDesignContinueView({
|
||||
prompt: '整理一份可玩原型',
|
||||
}),
|
||||
});
|
||||
const fileBytes = Array.from(new TextEncoder().encode('png'));
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'create_automatic_local_game_project') {
|
||||
return {
|
||||
projectPath,
|
||||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
if (command === 'import_design_workspace_file') {
|
||||
if (args?.fileName === '失败.txt') throw new Error('磁盘写入失败');
|
||||
return 'references/角色参考.png';
|
||||
}
|
||||
return chatHarness.invoke(command, args);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: { invoke },
|
||||
event: { listen: chatHarness.listen },
|
||||
};
|
||||
renderLauncherAt('/?launcher', 'home', true);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '做方案' }));
|
||||
const fileInput =
|
||||
document.querySelector<HTMLInputElement>('input[type="file"]');
|
||||
expect(fileInput).not.toBeNull();
|
||||
const attachment = new File(['png'], '角色参考.png', {
|
||||
type: 'image/png',
|
||||
lastModified: 1,
|
||||
});
|
||||
Object.defineProperty(attachment, 'arrayBuffer', {
|
||||
value: async () => new Uint8Array(fileBytes).buffer,
|
||||
});
|
||||
const failedAttachment = new File(['失败'], '失败.txt');
|
||||
Object.defineProperty(failedAttachment, 'arrayBuffer', {
|
||||
value: async () => new Uint8Array([1]).buffer,
|
||||
});
|
||||
fireEvent.change(fileInput!, {
|
||||
target: {
|
||||
files: partialFailure ? [attachment, failedAttachment] : [attachment],
|
||||
},
|
||||
});
|
||||
|
||||
const promptInput = screen.getByLabelText('创作想法');
|
||||
if (!filesOnly) {
|
||||
nativeClipboardMock.text = '整理一份可玩原型';
|
||||
fireEvent.paste(promptInput);
|
||||
}
|
||||
await waitFor(() => {
|
||||
if (!filesOnly)
|
||||
expect(promptInput.textContent).toContain('整理一份可玩原型');
|
||||
expect(promptInput.textContent).toContain('角色参考.png');
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '进入立项策划' }));
|
||||
|
||||
await screen.findByText(
|
||||
partialFailure
|
||||
? '已导入 1 个文件到策划工作区;1 个文件导入失败:失败.txt:磁盘写入失败。可重新选择失败文件重试。'
|
||||
: '已导入 1 个文件到策划工作区',
|
||||
);
|
||||
if (!filesOnly)
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'continue_design_agent_session',
|
||||
expect.objectContaining({
|
||||
projectPath,
|
||||
input: expect.objectContaining({ type: 'message' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('import_design_workspace_file', {
|
||||
projectPath,
|
||||
fileName: '角色参考.png',
|
||||
bytes: fileBytes,
|
||||
});
|
||||
expect(
|
||||
invoke.mock.calls.some(([command]) => command === 'upload_local_asset'),
|
||||
).toBe(false);
|
||||
expect(
|
||||
screen.getByText(
|
||||
partialFailure
|
||||
? '已导入 1 个文件到策划工作区;1 个文件导入失败:失败.txt:磁盘写入失败。可重新选择失败文件重试。'
|
||||
: '已导入 1 个文件到策划工作区',
|
||||
),
|
||||
).not.toBeNull();
|
||||
const commands = invoke.mock.calls.map(([command]) => command);
|
||||
if (filesOnly) {
|
||||
expect(commands).not.toContain('continue_design_agent_session');
|
||||
} else {
|
||||
expect(
|
||||
commands.lastIndexOf('import_design_workspace_file'),
|
||||
).toBeLessThan(commands.indexOf('continue_design_agent_session'));
|
||||
}
|
||||
const startCall = invoke.mock.calls.find(
|
||||
([command]) => command === 'continue_design_agent_session',
|
||||
);
|
||||
expect(startCall?.[1] ?? {}).not.toHaveProperty('attachments');
|
||||
expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain(
|
||||
'本轮用户附件',
|
||||
);
|
||||
expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain(
|
||||
'references/角色参考.png',
|
||||
);
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'chat_with_game_creator_direct_codex',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'chat_with_game_creator_agent',
|
||||
expect.anything(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('surfaces the planning clarification card after 做方案 creates the project from home', async () => {
|
||||
// 上面那条只断言到「run 起来了、source 对」。真实故障恰好落在它之后:plan 根 run
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
@@ -22,6 +23,60 @@ afterEach(() => {
|
||||
delete window.__TAURI__;
|
||||
});
|
||||
|
||||
it('shows imported references after a workspace event without reloading the design session', async () => {
|
||||
let imported = false;
|
||||
let onUpdate:
|
||||
| ((event: { payload: { projectPath: string; kind: string } }) => void)
|
||||
| undefined;
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'is_design_agent_debug_enabled') return false;
|
||||
if (command === 'hydrate_design_agent_session') return null;
|
||||
if (command === 'list_design_workspace')
|
||||
return imported
|
||||
? [
|
||||
{ path: 'references', kind: 'directory' },
|
||||
{ path: 'references/需求.md', kind: 'file' },
|
||||
]
|
||||
: [];
|
||||
if (command === 'read_design_workspace_file') return '# 导入的需求';
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
window.__TAURI__ = {
|
||||
core: { invoke },
|
||||
event: {
|
||||
listen: vi.fn(async (_name, callback) => {
|
||||
onUpdate = callback;
|
||||
return () => undefined;
|
||||
}),
|
||||
},
|
||||
} as unknown as typeof window.__TAURI__;
|
||||
render(<DesignWorkspacePanel projectPath="imported-project" />);
|
||||
await waitFor(() => expect(onUpdate).toBeDefined());
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith('list_design_workspace', {
|
||||
projectPath: 'imported-project',
|
||||
}),
|
||||
);
|
||||
imported = true;
|
||||
act(() =>
|
||||
onUpdate!({
|
||||
payload: { projectPath: 'imported-project', kind: 'workspace' },
|
||||
}),
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '需求.md' }));
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith('read_design_workspace_file', {
|
||||
projectPath: 'imported-project',
|
||||
path: 'references/需求.md',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'hydrate_design_agent_session',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('prepares debug fixtures from the header and refreshes the phase and files without a manual refresh', async () => {
|
||||
let prepared = false;
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
|
||||
@@ -157,6 +157,37 @@ async function pushDivergentEqualRevisionSnapshot() {
|
||||
}
|
||||
|
||||
describe('清单快照被拒收时的用户可见性与恢复', () => {
|
||||
it('keeps the planning workspace open until reference imports finish', async () => {
|
||||
const invoke = installInvokeMock();
|
||||
const originalInvoke = invoke.getMockImplementation()!;
|
||||
invoke.mockImplementation(async (command, args) => {
|
||||
if (command === 'get_design_agent_runtime_mode')
|
||||
return { activeRuntime: 'design' };
|
||||
if (command === 'set_design_agent_runtime_mode')
|
||||
return { activeRuntime: 'game' };
|
||||
return originalInvoke(command, args);
|
||||
});
|
||||
await openProjectThroughLauncher();
|
||||
await act(async () => {
|
||||
captured.chatProps?.onDesignFilesImportingChange?.(PROJECT_PATH, true);
|
||||
await captured.chatProps?.onSwitchToGameRuntime?.(PROJECT_PATH);
|
||||
});
|
||||
expect(screen.getByText('文件导入完成后,再做成游戏。')).not.toBeNull();
|
||||
expect(
|
||||
invoke.mock.calls.some(
|
||||
([command]) => command === 'set_design_agent_runtime_mode',
|
||||
),
|
||||
).toBe(false);
|
||||
await act(async () => {
|
||||
captured.chatProps?.onDesignFilesImportingChange?.(PROJECT_PATH, false);
|
||||
await captured.chatProps?.onSwitchToGameRuntime?.(PROJECT_PATH);
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('set_design_agent_runtime_mode', {
|
||||
projectPath: PROJECT_PATH,
|
||||
activeRuntime: 'game',
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 每一轮都重新查一次节点。
|
||||
*
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
|
||||
- 策划 Agent 复用现有模型/推理档控件,宿主不另加模型检查或自动换模型。用户发起执行时采样全局选择,同轮工具循环和自动重试固定使用回合快照;自动恢复复用该快照,旧记录保留已知模型并补齐一次推理档。只持久化模型和档位,不保存连接凭据;GameAgent 保持原逻辑。详见策划 Agent 生产迁移与工作区浏览方案 §4.1。
|
||||
|
||||
- 策划附件通过独立文件导入命令保存到 `design_artifacts/references/`,同名另存;首页和聊天框只展示导入结果,不登记游戏资产、不修改清单或 revision、不注入回合附件及路径。Agent 通过现有工作区工具自行发现,只有文件时不合成消息。详见策划 Agent 生产迁移与工作区浏览方案 §4.2。
|
||||
|
||||
- AGC 思考与执行入口共用共享单行摘要骨架;Markdown 在展开正文走既有安全渲染,折叠预览使用纯文本。耗时统一复用中文时分秒格式(不足一分钟一位小数,达到分钟后整数秒),格式化与各层计时边界分离。过程行在运行中和完成后的折叠层内保持同一紧凑间距;失败状态按明确终态与非零退出码呈现红色。
|
||||
|
||||
- Direct 对话计时区分条目展示时间与生命周期事件时间:整轮用用户发送到明确终态的跨度,工具用各自开始/完成边界;运行时用 100ms 叶子时钟刷新一位小数,终态冻结,旧历史缺边界不推测。不得用整秒时间的大小比较取代 Thread Manager 的事件顺序判定新回合。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 策划 Agent 生产迁移与工作区浏览方案
|
||||
|
||||
更新时间:2026-09-20
|
||||
更新时间:2026-09-21
|
||||
状态:已完成(2026-09-18)
|
||||
|
||||
> 现状说明(2026-09-18):本文记录的迁移已完成,当前策划入口统一使用 Design Agent。旧 Planning V1/V2 会话、专用命令、审批卡和展示适配已删除;文中提到的 V2 文件仅代表迁移时的参考来源,不得作为现行实现、回退路径或测试迁移目标。
|
||||
@@ -174,6 +174,18 @@ Runtime 不维护文档版本号,不解析文档版本,不提供版本回退
|
||||
|
||||
验收至少覆盖:保存后重新进入策划显示一致;旧会话切换模型后实际请求改变;当前执行不被中途改档;下一次发送/澄清/审批继续/主动重试生效;自动恢复不重复工具副作用;GameAgent 原有选择、发送和运行行为不回归。真实 Provider 或桌面环境缺失时明确记为未验证,不用 fixture 冒充实机结果。
|
||||
|
||||
### 4.2 策划参考附件导入
|
||||
|
||||
- 首页策划入口与策划聊天框将用户附件按原始字节导入 `design_artifacts/references/`,Agent 通过现有工作区文件工具自行发现。有用户文字时,导入完成后才开始首页首轮策划;只有附件时只进入工作区,不合成消息或自动启动策划。聊天框导入中暂缓发送、阶段批准、澄清与重试,并提示等待导入完成再切换游戏制作,避免新回合读取未完成文件或模式切换丢失导入结果。
|
||||
- 文件名保留安全的原始名称;同名自动追加序号,禁止覆盖已有文件。沿用项目权限与工作区路径边界,禁止通过文件名、符号链接或目录链接写出工作区。空文件也可导入。
|
||||
- 导入只写策划工作区,不登记游戏资产、不更新项目资源清单或游戏 revision,不触发上传后的清单刷新。不组装回合附件、不把文件路径或清单注入 Agent 消息。
|
||||
- 每个文件独立返回结果;部分失败不丢弃成功文件,界面显示成功数量及失败文件和原因。用户可重新选择失败文件重试;成功导入不自动重放,再次手动导入同名文件视为新副本。
|
||||
- 游戏入口继续使用现有资产上传链路。已有 `assets/uploads/` 文件不自动搬迁;转入游戏制作时沿用现有策划工作区登记行为。
|
||||
- 本次不修改 Agent 提示词、工具或消息协议,不增加 PDF、Word 等二进制文档解析;保存成功不代表格式可由现有 UTF-8 文件读取工具解析。
|
||||
- 验收覆盖:首页与聊天框导入、多文件部分失败、同名与空文件、工作区工具可列出并读取文本、路径与链接边界、资源清单和 revision 保持不变、游戏上传兼容。证据由定向前端测试、本地 Rust 文件往返测试、类型检查和编码检查提供;真实桌面交互另行记录。
|
||||
|
||||
验证证据(2026-09-21):`appSurface.test.ts` 中策划导入、导入中审批、首页游戏附件与模型控件定向用例通过;`designWorkspaceDebug.test.tsx`、`designProjectRestore.test.tsx` 与 `workspaceLauncherManifestMerge.test.tsx` 通过,覆盖文件事件刷新、项目恢复和导入中模式切换。Rust `agent::design_tools::tests::` 8 项通过,覆盖文件往返及写入边界。AGC typecheck(含命令注册检查)、定向 ESLint、Rust 格式、编码、文档索引与差异检查通过。未运行安装包桌面点击验证或真实 Provider;Windows 链接用例在宿主不支持建链接时跳过。
|
||||
|
||||
## 5. Agent Runtime
|
||||
|
||||
新的 Runtime 应提供自由工具循环:
|
||||
|
||||
@@ -461,6 +461,9 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
|
||||
.to_bytes();
|
||||
let list: Value = serde_json::from_slice(&list_body).unwrap();
|
||||
assert_eq!(list["deployments"], json!([]));
|
||||
wait_for_persisted_status(&state.config, &id, super::DeploymentStatus::Stopped)
|
||||
.await
|
||||
.expect("persisted deployment reaches stopped");
|
||||
let state_file = state.config.state_file.clone();
|
||||
let mut recovered_config = test_config(state.config.jenkins_root_url.clone());
|
||||
recovered_config.state_file = state_file.clone();
|
||||
@@ -659,6 +662,24 @@ async fn wait_for_status(
|
||||
Err(())
|
||||
}
|
||||
|
||||
async fn wait_for_persisted_status(
|
||||
config: &Config,
|
||||
id: &str,
|
||||
expected: super::DeploymentStatus,
|
||||
) -> Result<(), ()> {
|
||||
for _ in 0..100 {
|
||||
if super::load_deployments(config)
|
||||
.ok()
|
||||
.and_then(|deployments| deployments.get(id).map(|record| record.public.status))
|
||||
.is_some_and(|status| status == expected)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
Err(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_web_url_is_derived_from_instance_id_and_configured_domain() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user