新增总控Agent纯聊天开发窗口

新增开发态独立窗口路由与专业Agent开发页入口
固定复用project-supervisor活动会话、Runtime和持久对话
提供纯消息区、流式等待状态、输入框与运行时设置
补齐窗口编码、前端交互、开发态门禁和原生壳验证
同步AI游戏创作实施计划与项目决策记录
This commit is contained in:
AIGameCreator App
2026-07-20 17:25:58 +08:00
parent a3337f4493
commit 36734e158e
11 changed files with 607 additions and 11 deletions
@@ -646,6 +646,10 @@ for (const snippet of [
'open_developer_window(app.handle())?',
'tauri::WebviewWindowBuilder::new(app, "developer", developer_window_url())',
'index.html?agent-chat',
'supervisorChatMode',
'supervisorChatOnly',
'open_project_supervisor_chat_window',
'index.html?supervisor-chat&projectPath=',
]) {
if (!`${appEntrypointSource}\n${tauriRustSource}`.includes(snippet)) {
throw new Error(
@@ -1749,6 +1749,7 @@ fn main() {
write_project_permission_policy,
open_game_creator_workspace_window,
open_game_creator_launcher_window,
open_project_supervisor_chat_window,
start_local_game_preview,
open_local_game_preview,
stop_local_game_preview,
@@ -48860,6 +48860,14 @@ fn developer_window_uses_agent_chat_route() {
assert_eq!(developer_window_url().to_string(), "index.html?agent-chat");
}
#[test]
fn supervisor_chat_window_carries_encoded_project_path() {
assert_eq!(
supervisor_chat_window_url("/tmp/AI Game 项目").to_string(),
"index.html?supervisor-chat&projectPath=%2Ftmp%2FAI%20Game%20%E9%A1%B9%E7%9B%AE"
);
}
#[test]
fn workspace_window_url_carries_encoded_project_path() {
assert_eq!(
@@ -15,6 +15,13 @@ pub(crate) fn developer_window_url() -> tauri::WebviewUrl {
tauri::WebviewUrl::App(PathBuf::from("index.html?agent-chat"))
}
pub(crate) fn supervisor_chat_window_url(project_path: &str) -> tauri::WebviewUrl {
tauri::WebviewUrl::App(PathBuf::from(format!(
"index.html?supervisor-chat&projectPath={}",
percent_encode_query_value(project_path)
)))
}
pub(crate) fn validate_workspace_window_project_path(project_path: &str) -> Result<&str, String> {
let project_path = project_path.trim();
if project_path.is_empty() {
@@ -81,6 +88,37 @@ pub(crate) fn open_game_creator_launcher_window(
Ok(())
}
#[tauri::command]
pub(crate) fn open_project_supervisor_chat_window(
app: tauri::AppHandle,
project_path: String,
) -> Result<(), String> {
let project_path = validate_workspace_window_project_path(&project_path)?;
#[cfg(not(debug_assertions))]
{
let _ = app;
let _ = project_path;
return Err("项目总控对话窗口仅在开发构建中可用".to_string());
}
#[cfg(debug_assertions)]
{
if let Some(existing) = app.get_webview_window("supervisor-chat") {
existing.close().map_err(|error| error.to_string())?;
}
tauri::WebviewWindowBuilder::new(
&app,
"supervisor-chat",
supervisor_chat_window_url(project_path),
)
.title("项目总控 Agent 对话")
.inner_size(820.0, 720.0)
.min_inner_size(560.0, 480.0)
.build()
.map_err(|error| error.to_string())?;
Ok(())
}
}
#[cfg(all(debug_assertions, not(test)))]
pub(crate) fn open_developer_window(app: &tauri::AppHandle) -> Result<(), String> {
if let Some(existing) = app.get_webview_window("developer") {
+210 -1
View File
@@ -1642,6 +1642,22 @@ function agentRuntimeConversationStatus(runtime: AgentRuntimeState) {
return waitingOn ? `Agent 正在运行,等待${waitingOn}` : 'Agent 正在运行';
}
function projectSupervisorChatRuntimeStatus(runtime: AgentRuntimeState) {
if (runtime.status === 'idle' || runtime.phase === 'idle') {
return '等待输入';
}
if (isAgentRuntimeTerminalState(runtime)) {
if (runtime.status === 'failed' || runtime.phase === 'failed') {
return runtime.error || 'Agent 运行失败';
}
if (runtime.status === 'cancelled' || runtime.phase === 'cancelled') {
return '本轮已取消';
}
return '本轮已完成';
}
return agentRuntimeConversationStatus(runtime);
}
function formatAgentRuntimeEvent(event: AgentRuntimeEventRecord) {
const summary = event.summary || event.detail || event.runId;
const detail =
@@ -6561,6 +6577,32 @@ export function WorkspaceLauncher({
agentChatGoalDialog !== null && !agentChatBackgroundBusy,
);
async function handleOpenProjectSupervisorChatWindow() {
if (agentChatBusy || agentChatBackgroundBusy) {
return;
}
const projectPathForChat = validateAgentChatProjectPath();
if (!projectPathForChat) {
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setAgentChatStatus('需要在 Tauri App 内运行');
return;
}
setAgentChatStatus('正在打开项目总控对话');
try {
await invoke('open_project_supervisor_chat_window', {
projectPath: projectPathForChat,
});
setAgentChatStatus('已打开项目总控对话');
} catch (error) {
setAgentChatStatus(
error instanceof Error ? error.message : String(error),
);
}
}
async function handleAgentChatPickProjectDirectory() {
if (agentChatBusy || agentChatBackgroundBusy) {
return;
@@ -8896,6 +8938,13 @@ export function WorkspaceLauncher({
<p>{agentChatStatus}</p>
</div>
<div className="launcher-project-list-actions">
<button
type="button"
disabled={agentChatBusy || agentChatBackgroundBusy}
onClick={() => void handleOpenProjectSupervisorChatWindow()}
>
总控对话
</button>
<button
type="button"
disabled={agentChatBusy || agentChatBackgroundBusy}
@@ -16974,12 +17023,14 @@ type AppProps = {
initialProjectPath?: string;
initialProjectManifest?: GameCreationAppManifest;
projectSupervisorOnly?: boolean;
supervisorChatOnly?: boolean;
};
export function App({
initialProjectPath: initialProjectPathOverride = '',
initialProjectManifest,
projectSupervisorOnly = false,
supervisorChatOnly = false,
}: AppProps = {}) {
const [devMode] = useState(() =>
projectSupervisorOnly ? false : isDeveloperMode(),
@@ -17022,6 +17073,8 @@ export function App({
const [projectSupervisorRuntimeError, setProjectSupervisorRuntimeError] =
useState('');
const chatInputRef = useRef<HTMLInputElement | null>(null);
const supervisorChatMessagesRef = useRef<HTMLDivElement | null>(null);
const supervisorChatShouldFollowLatestRef = useRef(true);
const [assetStatus, setAssetStatus] = useState('未上传');
const [uploadedAssets, setUploadedAssets] = useState<
UploadLocalAssetResult[]
@@ -17178,7 +17231,7 @@ export function App({
const pendingUiConfirmationActionRef = useRef<(() => void) | null>(null);
function requestRuntimeConfigOpen() {
if (projectSupervisorOnly) {
if (projectSupervisorOnly && !supervisorChatOnly) {
return;
}
setRuntimeConfigOpen(true);
@@ -17543,6 +17596,22 @@ export function App({
projectSupervisorRuntime?.status,
]);
useLayoutEffect(() => {
if (!supervisorChatOnly || !supervisorChatShouldFollowLatestRef.current) {
return;
}
const messageList = supervisorChatMessagesRef.current;
if (messageList) {
messageList.scrollTop = messageList.scrollHeight;
}
}, [
messages,
projectSupervisorResponseStream?.sequence,
projectSupervisorRuntime?.updatedAt,
projectSupervisorRuntimeError,
supervisorChatOnly,
]);
useEffect(() => {
latestMessagesRef.current = messages;
const invoke = resolveTauriInvoke();
@@ -26599,6 +26668,17 @@ export function App({
}
}
function handleSupervisorChatScroll(event: UIEvent<HTMLDivElement>) {
handleConversationScroll(event);
const messageList = event.currentTarget;
const distanceFromBottom =
messageList.scrollHeight -
messageList.scrollTop -
messageList.clientHeight;
supervisorChatShouldFollowLatestRef.current =
distanceFromBottom <= AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD;
}
function handleAgentConversationScroll(event: UIEvent<HTMLElement>) {
if (hiddenAgentConversationCount === 0) {
return;
@@ -26636,6 +26716,9 @@ export function App({
if (!prompt || chatAgentBusy) {
return;
}
if (supervisorChatOnly) {
supervisorChatShouldFollowLatestRef.current = true;
}
setChatInput('');
setMessages((current) => [
...current,
@@ -26650,6 +26733,132 @@ export function App({
(agent.runtimeStatus !== null || agent.hasRecentEvidence),
);
if (projectSupervisorOnly && supervisorChatOnly) {
const projectSupervisorChatRunning = Boolean(
chatAgentBusy ||
(projectSupervisorRuntime &&
!isAgentRuntimeTerminalState(projectSupervisorRuntime)),
);
const projectSupervisorChatStatus = projectSupervisorRuntimeError
? projectSupervisorRuntimeError
: projectSupervisorRuntime
? projectSupervisorChatRuntimeStatus(projectSupervisorRuntime)
: workspaceStatus;
const supervisorProjectPath =
localProject?.projectPath || initialProjectPath || projectPath;
return (
<>
<main
className="supervisor-chat-only-shell"
aria-label="项目总控 Agent 纯聊天"
>
<header className="supervisor-chat-only-header">
<div>
<strong>项目总控 Agent</strong>
<span>{projectNameFromPath(supervisorProjectPath)}</span>
</div>
<div className="supervisor-chat-only-header-status">
<span aria-live="polite">{projectSupervisorChatStatus}</span>
<button
type="button"
aria-label="设置"
title="设置"
onClick={() => setRuntimeConfigOpen(true)}
>
<Settings size={17} aria-hidden="true" />
</button>
</div>
</header>
<div
ref={supervisorChatMessagesRef}
className="message-list supervisor-chat-only-message-list"
aria-label="项目总控消息"
onScroll={handleSupervisorChatScroll}
>
{hiddenConversationCount > 0 ? (
<button
type="button"
className="message-history-more"
onClick={showEarlierConversationMessages}
>
{`显示更早 · 还有 ${hiddenConversationCount} 条对话`}
</button>
) : null}
{visibleMessages.map((message, index) => (
<p
key={message.messageId ?? `${message.role}-${index}`}
className={`message message--${message.role}`}
>
{message.text}
</p>
))}
{projectSupervisorTransientReply ? (
<p
className="message message--assistant supervisor-chat-only-stream"
aria-label="项目总控 Agent 实时回复"
aria-live="polite"
data-runtime-owned="true"
>
{projectSupervisorTransientReply}
</p>
) : null}
{projectSupervisorChatRunning &&
!projectSupervisorTransientReply ? (
<div
className="launcher-agent-chat-waiting supervisor-chat-only-waiting"
role="status"
aria-live="polite"
>
<span aria-hidden="true" />
<div>
<strong>{projectSupervisorChatStatus}</strong>
<small>总控 Agent 正在继续处理</small>
</div>
</div>
) : null}
</div>
<form
className="supervisor-chat-only-composer"
onSubmit={handleProjectSupervisorOnlySubmit}
>
<textarea
aria-label="项目总控对话内容"
disabled={chatAgentBusy || projectSupervisorNeedsUserInput}
rows={3}
value={chatInput}
placeholder="给项目总控 Agent 发消息"
onChange={(event) => setChatInput(event.currentTarget.value)}
onKeyDown={(event) => {
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
/>
<button
type="submit"
aria-label="发送"
title="发送"
disabled={chatAgentBusy || projectSupervisorNeedsUserInput}
>
<Send size={18} aria-hidden="true" />
</button>
</form>
</main>
{runtimeConfigOpen ? (
<RuntimeConfigDialog
projectPath={supervisorProjectPath}
onClose={() => setRuntimeConfigOpen(false)}
/>
) : null}
</>
);
}
if (projectSupervisorOnly) {
return (
<>
+23 -10
View File
@@ -1,12 +1,17 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { AuthenticatedClient, WorkspaceLauncher } from './App';
import { App, AuthenticatedClient, WorkspaceLauncher } from './App';
import './styles.css';
const initialSearchParams = new URLSearchParams(window.location.search);
const supervisorChatMode =
import.meta.env.DEV && initialSearchParams.has('supervisor-chat');
const supervisorChatProjectPath =
initialSearchParams.get('projectPath')?.trim() ?? '';
function resolveInitialLauncherView() {
return import.meta.env.DEV &&
new URLSearchParams(window.location.search).has('agent-chat')
return import.meta.env.DEV && initialSearchParams.has('agent-chat')
? 'agent-chat'
: 'home';
}
@@ -16,13 +21,21 @@ const initialLauncherView = resolveInitialLauncherView();
createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<AuthenticatedClient>
{({ user, logout }) => (
<WorkspaceLauncher
currentUser={user}
initialView={initialLauncherView}
onLogout={logout}
/>
)}
{({ user, logout }) =>
supervisorChatMode ? (
<App
initialProjectPath={supervisorChatProjectPath}
projectSupervisorOnly
supervisorChatOnly
/>
) : (
<WorkspaceLauncher
currentUser={user}
initialView={initialLauncherView}
onLogout={logout}
/>
)
}
</AuthenticatedClient>
</React.StrictMode>,
);
+182
View File
@@ -1480,6 +1480,188 @@ textarea {
font-size: 12px;
}
.supervisor-chat-only-shell {
display: grid;
grid-template-rows: 64px minmax(0, 1fr) auto;
width: 100%;
height: 100vh;
min-width: 0;
min-height: 480px;
overflow: hidden;
background: #f5f6f8;
color: #111827;
}
.supervisor-chat-only-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
padding: 0 18px;
border-bottom: 1px solid #e1e5eb;
background: #fff;
}
.supervisor-chat-only-header > div,
.supervisor-chat-only-header-status {
display: flex;
align-items: center;
min-width: 0;
}
.supervisor-chat-only-header > div:first-child {
gap: 10px;
}
.supervisor-chat-only-header strong {
flex: 0 0 auto;
font-size: 15px;
}
.supervisor-chat-only-header span {
min-width: 0;
overflow: hidden;
color: #6b7280;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.supervisor-chat-only-header-status {
justify-content: flex-end;
gap: 10px;
max-width: 52%;
}
.supervisor-chat-only-header-status button {
display: grid;
width: 34px;
height: 34px;
flex: 0 0 auto;
padding: 0;
border: 1px solid #d8dde5;
border-radius: 6px;
background: #fff;
color: #374151;
place-items: center;
}
.supervisor-chat-only-message-list {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
min-height: 0;
padding: 22px clamp(16px, 4vw, 40px);
border: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.supervisor-chat-only-message-list .message {
align-self: flex-start;
width: fit-content;
max-width: min(720px, 86%);
margin: 0;
padding: 10px 12px;
border: 1px solid #dfe3e9;
border-radius: 8px;
background: #fff;
color: #263142;
line-height: 1.55;
}
.supervisor-chat-only-message-list .message--user {
align-self: flex-end;
border-color: #cfd6df;
background: #e9edf2;
color: #111827;
}
.supervisor-chat-only-message-list .supervisor-chat-only-stream {
border-color: #b9d8c5;
}
.supervisor-chat-only-waiting {
position: static;
align-self: flex-start;
}
.supervisor-chat-only-composer {
display: grid;
grid-template-columns: minmax(0, 1fr) 42px;
align-items: end;
gap: 10px;
padding: 14px clamp(16px, 4vw, 40px) 18px;
border-top: 1px solid #e1e5eb;
background: #fff;
}
.supervisor-chat-only-composer textarea {
width: 100%;
min-width: 0;
height: 74px;
max-height: 74px;
padding: 11px 12px;
overflow-y: auto;
resize: none;
border: 1px solid #cfd6df;
border-radius: 8px;
background: #fff;
color: #111827;
font: inherit;
line-height: 1.5;
}
.supervisor-chat-only-composer textarea:focus-visible {
border-color: #6b7280;
outline: 2px solid rgb(107 114 128 / 18%);
outline-offset: 1px;
}
.supervisor-chat-only-composer button {
display: grid;
width: 42px;
height: 42px;
padding: 0;
border: 0;
border-radius: 8px;
background: #111827;
color: #fff;
place-items: center;
}
.supervisor-chat-only-composer button:disabled,
.supervisor-chat-only-composer textarea:disabled {
cursor: not-allowed;
opacity: 0.55;
}
@media (max-width: 600px) {
.supervisor-chat-only-header {
padding: 0 12px;
}
.supervisor-chat-only-header > div:first-child > span {
display: none;
}
.supervisor-chat-only-header-status {
max-width: 58%;
}
.supervisor-chat-only-message-list .message {
max-width: 92%;
}
.supervisor-chat-only-composer {
padding-right: 12px;
padding-left: 12px;
}
}
.project-supervisor-surface {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 280px);
@@ -1623,6 +1623,45 @@ describe('AI 游戏创作 App 界面边界', () => {
);
});
it('opens the standalone Project Supervisor chat window with the absolute project path', async () => {
const projectPath = '/tmp/supervisor-chat-window-game';
const invoke = vi.fn(async (command: string) => {
if (command === 'check_game_creator_llm_config') {
return {
configured: true,
apiKeyPresent: true,
baseUrl: 'https://llm.example.test/v1',
model: 'gpt-test',
apiKind: 'openai_responses',
reasoningEffort: 'high',
stream: true,
webSearchEnabled: false,
error: null,
agents: [],
};
}
if (command === 'open_project_supervisor_chat_window') {
return null;
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherAgentChatAt('/?agent-chat');
fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), {
target: { value: projectPath },
});
fireEvent.click(screen.getByRole('button', { name: '总控对话' }));
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'open_project_supervisor_chat_window',
{ projectPath },
);
});
expect(screen.getByText('已打开项目总控对话')).not.toBeNull();
});
it('persists and shows an upstream stream error without issuing a normal retry', async () => {
const persistedMessages: Array<{
role: 'user' | 'assistant' | 'tool';
@@ -6065,6 +6104,81 @@ describe('AI 游戏创作 App 界面边界', () => {
);
});
it('loads and continues the active Project Supervisor Session in the standalone chat surface', async () => {
const projectPath = '/tmp/supervisor-chat-only-game';
const historyMessage = '已持久化的项目总控历史';
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
supervisorMessages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: historyMessage,
agentId: 'project-supervisor',
messageId: 'supervisor-chat-only-history',
updatedAt: 2000,
},
],
});
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 messageList = within(surface).getByLabelText('项目总控消息');
expect(await within(messageList).findByText(historyMessage)).not.toBeNull();
expect(harness.invoke).toHaveBeenCalledWith(
'list_game_creator_agent_sessions',
{
projectPath,
agentId: 'project-supervisor',
},
);
expect(harness.invoke).toHaveBeenCalledWith('read_local_conversation', {
projectPath,
agentId: 'project-supervisor',
sessionId: harness.sessionId,
});
expect(within(surface).getByRole('button', { name: '设置' })).not.toBeNull();
expect(screen.queryByLabelText('选择 Agent')).toBeNull();
expect(screen.queryByLabelText('项目总控 Agent 状态')).toBeNull();
expect(screen.queryByLabelText('专业 Agent 协作状态')).toBeNull();
fireEvent.change(within(surface).getByLabelText('项目总控对话内容'), {
target: { value: '继续完成可玩原型' },
});
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(harness.invoke).toHaveBeenCalledWith(
'start_game_creator_agent_runtime_task',
{
projectPath,
agentId: 'project-supervisor',
sessionId: harness.sessionId,
task: '继续完成可玩原型',
runId: expect.stringMatching(/^project-supervisor-task-/),
},
);
});
expect(harness.invoke).not.toHaveBeenCalledWith(
'create_game_creator_agent_session',
expect.anything(),
);
expect(harness.invoke).not.toHaveBeenCalledWith(
'chat_with_game_creator_agent',
expect.anything(),
);
});
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(