完善总控纯聊天交互边界

在独立聊天窗口复用工具确认与结构化追问控件

按项目保留未发送草稿并展示Runtime恢复确认

补齐草稿、恢复、确认和追问的界面回归测试
This commit is contained in:
AIGameCreator App
2026-07-20 21:31:31 +08:00
parent a3769978a7
commit a7323572a0
3 changed files with 416 additions and 5 deletions
+135 -5
View File
@@ -2971,9 +2971,6 @@ function ProjectSupervisorRuntimePanel({
projectSupervisorCollaboratingAgentCount(runtime, runtimeByAgentId),
)
: '';
const pendingToolAction = runtime?.pendingToolAction ?? null;
const userInputRequest = runtime?.userInputRequest ?? null;
const needsUserInput = agentRuntimeNeedsUserInput(runtime);
return (
<section
@@ -2987,6 +2984,40 @@ function ProjectSupervisorRuntimePanel({
{compactProgress ? (
<small aria-label="项目总控 Agent 进度">{compactProgress}</small>
) : null}
<ProjectSupervisorRuntimeControls
runtime={runtime}
controlBusy={controlBusy}
onToolAction={onToolAction}
onUserInput={onUserInput}
/>
</section>
);
}
function ProjectSupervisorRuntimeControls({
runtime,
controlBusy,
onToolAction,
onUserInput,
}: {
runtime: AgentRuntimeState | null;
controlBusy: boolean;
onToolAction: (decision: 'confirm' | 'reject') => void | Promise<void>;
onUserInput: (
request: AgentRuntimeUserInputRequest,
responseId: string,
answers: Record<string, string>,
) => void | Promise<void>;
}) {
const pendingToolAction = runtime?.pendingToolAction ?? null;
const userInputRequest = runtime?.userInputRequest ?? null;
const needsUserInput = agentRuntimeNeedsUserInput(runtime);
if (!pendingToolAction && !userInputRequest && !needsUserInput) {
return null;
}
return (
<>
{userInputRequest ? (
<AgentRuntimeUserInputCard
key={`${userInputRequest.requestId}:${userInputRequest.responseId ?? 'pending'}`}
@@ -3026,7 +3057,7 @@ function ProjectSupervisorRuntimePanel({
</button>
</div>
) : null}
</section>
</>
);
}
@@ -3060,6 +3091,46 @@ function readInitialProjectPath() {
return params.get('projectPath') ?? '';
}
const SUPERVISOR_CHAT_DRAFT_STORAGE_PREFIX =
'genarrative.supervisor-chat.draft';
function supervisorChatDraftStorageKey(projectPath: string) {
return `${SUPERVISOR_CHAT_DRAFT_STORAGE_PREFIX}:${projectPath}`;
}
function readSupervisorChatDraft(projectPath: string) {
const normalizedProjectPath = projectPath.trim();
if (!normalizedProjectPath) {
return '';
}
try {
return (
window.sessionStorage.getItem(
supervisorChatDraftStorageKey(normalizedProjectPath),
) ?? ''
);
} catch {
return '';
}
}
function persistSupervisorChatDraft(projectPath: string, draft: string) {
const normalizedProjectPath = projectPath.trim();
if (!normalizedProjectPath) {
return;
}
try {
const storageKey = supervisorChatDraftStorageKey(normalizedProjectPath);
if (draft) {
window.sessionStorage.setItem(storageKey, draft);
} else {
window.sessionStorage.removeItem(storageKey);
}
} catch {
// A disabled session store must not block the development chat surface.
}
}
const defaultRuntimeConfigDraft: GameCreatorAppConfig = {
llm: {
apiKey: '',
@@ -17061,7 +17132,11 @@ export function App({
);
const [preview, setPreview] = useState<LocalPreviewResult | null>(null);
const [previewStatus, setPreviewStatus] = useState('未启动');
const [chatInput, setChatInput] = useState('');
const [chatInput, setChatInput] = useState(() =>
supervisorChatOnly && initialProjectPath
? readSupervisorChatDraft(initialProjectPath)
: '',
);
const [chatAgentBusy, setChatAgentBusy] = useState(false);
const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState<
string | null
@@ -17612,6 +17687,13 @@ export function App({
supervisorChatOnly,
]);
useEffect(() => {
if (!supervisorChatOnly) {
return;
}
persistSupervisorChatDraft(initialProjectPath, chatInput);
}, [chatInput, initialProjectPath, supervisorChatOnly]);
useEffect(() => {
latestMessagesRef.current = messages;
const invoke = resolveTauriInvoke();
@@ -18367,6 +18449,12 @@ export function App({
return '';
}
const message = error instanceof Error ? error.message : String(error);
if (
message.includes('项目权限策略要求用户确认:agent.resume') ||
message.includes('项目权限策略拒绝执行:agent.resume')
) {
return '';
}
if (isRuntimeConfigMissingError(message)) {
requestRuntimeConfigOpen();
}
@@ -26619,6 +26707,11 @@ export function App({
const projectSupervisorNeedsUserInput = agentRuntimeNeedsUserInput(
projectSupervisorRuntime,
);
const projectSupervisorHasConversationControls = Boolean(
projectSupervisorRuntime?.pendingToolAction ||
projectSupervisorRuntime?.userInputRequest ||
projectSupervisorNeedsUserInput,
);
const visibleAgentConversationMessages = latestVisibleItems(
agentConversationMessages,
agentConversationVisibleCount,
@@ -26816,6 +26909,43 @@ export function App({
</div>
</div>
) : null}
{projectSupervisorHasConversationControls ? (
<div className="supervisor-chat-only-runtime-controls">
<ProjectSupervisorRuntimeControls
runtime={projectSupervisorRuntime}
controlBusy={chatAgentBusy}
onToolAction={handleProjectSupervisorToolAction}
onUserInput={handleProjectSupervisorUserInput}
/>
</div>
) : null}
{pendingUiConfirmation ? (
<div className="supervisor-chat-only-runtime-controls">
<div
className="pending-command"
aria-label="项目总控 Agent 待确认操作"
>
<span>
{pendingUiConfirmation.commandId}
<small>{pendingUiConfirmation.detail}</small>
</span>
<button
type="button"
disabled={chatAgentBusy}
onClick={cancelUiCommandConfirmation}
>
</button>
<button
type="button"
disabled={chatAgentBusy}
onClick={confirmUiCommand}
>
</button>
</div>
</div>
) : null}
</div>
<form
className="supervisor-chat-only-composer"
+25
View File
@@ -1589,6 +1589,31 @@ textarea {
align-self: flex-start;
}
.supervisor-chat-only-runtime-controls {
align-self: stretch;
width: 100%;
max-width: 720px;
}
.supervisor-chat-only-runtime-controls .agent-runtime-user-input {
display: grid;
gap: 10px;
padding: 14px;
border: 1px solid #d8dde5;
border-radius: 8px;
background: #f7f8fa;
}
.supervisor-chat-only-runtime-controls .agent-runtime-user-input header {
display: grid;
gap: 3px;
}
.supervisor-chat-only-runtime-controls .pending-command,
.supervisor-chat-only-runtime-controls .agent-runtime-user-input {
margin: 0;
}
.supervisor-chat-only-composer {
display: grid;
grid-template-columns: minmax(0, 1fr) 42px;
@@ -606,6 +606,7 @@ afterEach(() => {
cleanup();
window.history.pushState({}, '', '/');
window.localStorage.clear();
window.sessionStorage.clear();
delete window.__TAURI__;
vi.restoreAllMocks();
});
@@ -6179,6 +6180,261 @@ describe('AI 游戏创作 App 界面边界', () => {
);
});
it('keeps an unsent standalone Project Supervisor draft across window navigation reloads', async () => {
const projectPath = '/tmp/supervisor-chat-only-draft';
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
const renderSupervisorChat = () =>
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
supervisorChatOnly: true,
}),
);
renderSupervisorChat();
const firstSurface = await screen.findByLabelText(
'项目总控 Agent 纯聊天',
);
await within(firstSurface).findByLabelText('项目总控消息');
fireEvent.change(
within(firstSurface).getByLabelText('项目总控对话内容'),
{ target: { value: '这条草稿还没有发送' } },
);
cleanup();
renderSupervisorChat();
const restoredInput = await screen.findByLabelText('项目总控对话内容');
expect(restoredInput).toHaveProperty('value', '这条草稿还没有发送');
});
it('shows and handles runtime recovery confirmation in the standalone Project Supervisor chat', async () => {
const projectPath = '/tmp/supervisor-chat-only-resume';
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'resume_game_creator_agent_runtime_tasks') {
throw new Error('项目权限策略要求用户确认:agent.resume');
}
if (command === 'confirm_resume_game_creator_agent_runtime_tasks') {
return [];
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
supervisorChatOnly: true,
}),
);
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
const detail = await within(surface).findByText(
`恢复 ${projectPath} 中未完成的 Agent Runtime 任务`,
);
expect(within(surface).queryByText(/项目总控 Agent 恢复失败/)).toBeNull();
const confirmation = detail.closest('.pending-command');
expect(confirmation).not.toBeNull();
fireEvent.click(
within(confirmation as HTMLElement).getByRole('button', {
name: '确认',
}),
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'confirm_resume_game_creator_agent_runtime_tasks',
{ projectPath },
);
expect(
within(surface).queryByText(
`恢复 ${projectPath} 中未完成的 Agent Runtime 任务`,
),
).toBeNull();
});
});
it('confirms and rejects pending actions in the standalone Project Supervisor chat', async () => {
const projectPath = '/tmp/supervisor-chat-only-confirmation';
const runId = 'supervisor-chat-only-confirmation-run';
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
initialRuntime: {
runId,
status: 'waiting-for-confirmation',
phase: 'waiting-for-confirmation',
pendingToolAction: {
actionId: 'standalone-action-confirm',
actionFingerprint: 'standalone-fingerprint-confirm',
tool: 'file.write',
inputSummary: 'game/index.html',
reason: null,
requestedAt: 3000,
},
},
});
harness.setConfirmRuntime(
harness.runtimeState({
runId,
status: 'waiting-for-confirmation',
phase: 'waiting-for-confirmation',
pendingToolAction: {
actionId: 'standalone-action-reject',
actionFingerprint: 'standalone-fingerprint-reject',
tool: 'command.exec',
inputSummary: 'npm test',
reason: null,
requestedAt: 4000,
},
}),
);
harness.setRejectRuntime(
harness.runtimeState({
runId,
status: 'running',
phase: 'planning',
pendingToolAction: null,
}),
);
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
supervisorChatOnly: true,
}),
);
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
let pendingAction = await within(surface).findByLabelText(
'项目总控 Agent 待确认动作',
);
expect(within(pendingAction).getByText('file.write')).not.toBeNull();
expect(within(pendingAction).getByText('game/index.html')).not.toBeNull();
fireEvent.click(
within(pendingAction).getByRole('button', { name: '确认' }),
);
await waitFor(() => {
expect(harness.invoke).toHaveBeenCalledWith(
'confirm_game_creator_agent_runtime_task',
{
projectPath,
agentId: 'project-supervisor',
runId,
actionId: 'standalone-action-confirm',
note: '用户已确认待执行工具动作',
},
);
});
pendingAction = await within(surface).findByLabelText(
'项目总控 Agent 待确认动作',
);
expect(within(pendingAction).getByText('command.exec')).not.toBeNull();
expect(within(pendingAction).getByText('npm test')).not.toBeNull();
fireEvent.click(
within(pendingAction).getByRole('button', { name: '拒绝' }),
);
await waitFor(() => {
expect(harness.invoke).toHaveBeenCalledWith(
'reject_game_creator_agent_runtime_task',
{
projectPath,
agentId: 'project-supervisor',
runId,
actionId: 'standalone-action-reject',
note: '用户已拒绝待执行工具动作',
},
);
});
});
it('answers structured questions in the standalone Project Supervisor chat', async () => {
const projectPath = '/tmp/supervisor-chat-only-user-input';
const sessionId = 'supervisor-chat-only-user-input-session';
const runId = 'supervisor-chat-only-user-input-run';
const request = agentRuntimeUserInputRequest({
agentId: 'project-supervisor',
sessionId,
runId,
});
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
sessionId,
initialRuntime: {
runId,
status: 'waiting-for-user-input',
phase: 'waiting-for-user-input',
currentTask: '准备首版角色规范图',
currentAction: '等待用户补充关键信息',
waitingOn: '你的澄清回答',
nextStep: '提交全部回答后继续同一 Run',
userInputRequest: request,
updatedAt: 6000,
},
});
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
supervisorChatOnly: true,
}),
);
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
const card = await within(surface).findByLabelText('Needs input');
expect(within(card).getByText('1. 美术方向')).not.toBeNull();
expect(
within(card).getByText('首版角色规范图采用哪种美术方向?'),
).not.toBeNull();
expect(
within(card).getByText('优先验证轮廓与动作可读性。'),
).not.toBeNull();
expect(
(within(surface).getByLabelText('项目总控对话内容') as HTMLTextAreaElement)
.disabled,
).toBe(true);
fireEvent.click(within(card).getByRole('button', { name: /像素风/ }));
fireEvent.click(within(card).getByRole('button', { name: '提交回答' }));
await waitFor(() => {
expect(harness.invoke).toHaveBeenCalledWith(
'answer_game_creator_agent_runtime_user_input',
{
projectPath,
agentId: 'project-supervisor',
runId,
actionId: request.actionId,
requestId: request.requestId,
responseId: expect.stringMatching(/^app-user-input-/),
answers: { visual_direction: '像素风' },
},
);
});
await waitFor(() => {
expect(within(surface).queryByLabelText('Needs input')).toBeNull();
});
});
it('opens an existing project into the active Supervisor Session, restores history, then starts and steers the same run', async () => {
const projectPath = '/tmp/launcher-supervisor-game';
const manifest = createGameCreationAppManifest(