合并 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

- 保留 DirectProject 独立聊天容器与 Supervisor 退役结果,不为冲突恢复运行时兼容分支
- 策划工作区文件导入接入 PlanningChatView 输入盒,导入期间暂缓发送、阶段批准、澄清与重试
- 工作台壳按项目记录导入进行态,导入未完成时不切换游戏运行态并提示等待
- 首页策划附件导入结果落到工作台 fileImportNotice 提示条,可单独关闭
- 设计态输入盒样式选择器由 project-supervisor-* 收敛为 project-chat-*
- 策划导入回归用例适配退役后的 harness 与 chatProps 命名
This commit is contained in:
2026-09-22 02:24:47 +08:00
18 changed files with 853 additions and 103 deletions
@@ -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,
+38
View File
@@ -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;
}
+14
View File
@@ -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
@@ -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!(