diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs index 19cf64e8d..b65f25ba4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -4,7 +4,7 @@ use ts_rs::TS; /// DirectProject 本轮 user input 的唯一结构化入口。 #[derive(Clone, Debug, Deserialize, Serialize, TS)] #[serde(tag = "type", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectCodexUserItem { #[serde(rename = "message")] Message(DirectCodexUserMessageItem), @@ -12,7 +12,7 @@ pub(crate) enum DirectCodexUserItem { #[derive(Clone, Debug, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectCodexUserMessageItem { pub(crate) role: DirectCodexUserRole, pub(crate) content: Vec, @@ -21,14 +21,14 @@ pub(crate) struct DirectCodexUserMessageItem { #[derive(Clone, Debug, Deserialize, Serialize, TS)] #[serde(rename_all = "lowercase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectCodexUserRole { User, } #[derive(Clone, Debug, Deserialize, Serialize, TS)] #[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectCodexUserContentPart { #[serde(rename = "input_text")] InputText { text: String }, @@ -45,7 +45,7 @@ pub(crate) enum DirectCodexUserContentPart { #[derive(Clone, Debug, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectCodexUserAttachmentReferencePart { pub(crate) name: String, pub(crate) media_type: String, @@ -57,7 +57,7 @@ pub(crate) struct DirectCodexUserAttachmentReferencePart { #[derive(Clone, Debug, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectCodexUserRuntimeRegionPart { pub(crate) label: String, #[serde(default)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index e180bd4c8..ba635aeda 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -4,7 +4,7 @@ use super::model::{ use super::validation::validate_direct_codex_user_item; use crate::agent::{ read_manifest_for_project, sanitize_attachment_local_path, sanitize_attachment_media_type, - sanitize_attachment_name,GameCreationAppManifest, + sanitize_attachment_name, GameCreationAppManifest, }; use serde_json::Value; use std::path::Path; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs index f6b781218..aaa050cfc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs @@ -1,6 +1,6 @@ //! DirectProject 聊天事件的线上模型与投影。 //! -//! 前端消费的类型由 ts-rs 导出到 `src/features/project-workspace/generated/`, +//! 前端消费的类型由 ts-rs 导出到 `src/view/project-development/chat/generated/`, //! 与 Rust 定义同源:加一个字段不会只改一边。 //! //! 本模块只做三件事:挑字段、脱敏、截断。工具卡片的 `kind`、标题、折叠摘要、可见性与 @@ -29,7 +29,7 @@ const DIRECT_THREAD_PATH_MAX_CHARS: usize = 300; /// 一条文件变更。 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectThreadFileChange { pub(crate) path: String, /// `add` | `update` | `delete` @@ -45,7 +45,7 @@ pub(crate) struct DirectThreadFileChange { /// 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(tag = "itemType", rename_all_fields = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectThreadItem { #[serde(rename = "message")] Message { @@ -162,7 +162,7 @@ impl DirectThreadItem { /// 增量正文属于哪类条目。 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectThreadDeltaKind { /// assistant 正文。 Message, @@ -173,7 +173,7 @@ pub(crate) enum DirectThreadDeltaKind { /// 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectThreadRequestKind { #[serde(rename = "approval.requested")] ApprovalRequested, @@ -203,7 +203,7 @@ impl DirectThreadRequestKind { /// 生命周期事件在序列中的位置给出,`turn_id` 对前端没有任何额外信息。 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectThreadEvent { #[serde(rename = "turn.started")] TurnStarted, @@ -283,7 +283,7 @@ impl DirectThreadEvent { #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectThreadSubscriptionBootstrap { pub(crate) subscription_id: String, /// 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。 @@ -295,14 +295,14 @@ pub(crate) struct DirectThreadSubscriptionBootstrap { #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectThreadConsumeResult { pub(crate) events: Vec, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectThreadHistorySlice { /// 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。 pub(crate) items: Vec, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 73a4c472c..98631759d 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -38,6 +38,7 @@ import { seedManifest, } from './app/constants'; import { useEscapeToClose } from './app/dialogs'; +import { claimInitialSupervisorMessageForPage } from './app/initialSupervisorMessageClaims'; import { resolveTauriInvoke } from './app/tauri'; import type { AgentBackgroundSubmitMode, @@ -123,7 +124,6 @@ import { normalizeAgentRuntimeState, projectNameFromPath, projectProfessionalAgentLabel, - projectRuntimeVisibleError, projectSupervisorPendingRepairMatchesProfessional, readProjectSupervisorActiveSessionId, resolveProjectSupervisorRuntimeSubmission, @@ -131,10 +131,6 @@ import { submitProjectSupervisorRuntimeTask, taskRowsFromManifest, } from './features/agent-runtime'; -import { - type DirectCodexTurnAttachment, - toDirectCodexTurnAttachments, -} from './features/app-shell/directCodexTurnAttachments'; import { isDeveloperMode, isTransientProjectOpenMessage, @@ -221,13 +217,6 @@ import { } from './features/project-workspace/agentRunTrace'; import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels'; import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels'; -import { directHistoryAnchorGateToWaitFor } from './features/project-workspace/directHistoryAnchorGate'; -import { readDirectHistoryPages } from './features/project-workspace/directHistoryPaging'; -import { selectDirectChatEntries } from './features/project-workspace/directThreadChat'; -import { - type DirectThreadHistorySlice, - type DirectThreadItem, -} from './features/project-workspace/directThreadEvents'; import { appendMemoryContent, memoryScopeLabel, @@ -265,9 +254,7 @@ import type { ChatReference, } from './features/project-workspace/resourceReferences'; import { - chatComposerDraftToDirectCodexUserItem, directCodexContentToPromptText, - directCodexUserItemFromContent, hasMeaningfulDirectCodexContent, RESOURCE_REFERENCE_INSERT_EVENT, resourceReferenceFromAsset, @@ -276,10 +263,6 @@ import { import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; import { captureAgentRuntimeError } from './services/errorReporting'; -import { - currentPlatformSessionGeneration, - requestPlatformSessionRefresh, -} from './services/platformSession'; import { setAgcPluginProjectPath, startAvailableAgcPlugin, @@ -293,89 +276,22 @@ import { type ProjectAgentResultSummary, type ProjectAgentRuntimeSummary, } from './view/project-development'; -import { DirectProjectChatView } from './view/project-development/chat/DirectProjectChatView'; +import { + createDirectProjectTurnId, + DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX, +} from './view/project-development/chat/conversation/directCodexConversation'; +import { + directCodexAttachmentContentParts, + toDirectCodexTurnAttachments, +} from './view/project-development/chat/conversation/directCodexTurnAttachments'; +import { + type DirectProjectChatHandle, + DirectProjectChatView, + type DirectProjectInitialTurn, +} from './view/project-development/chat/DirectProjectChatView'; import type { DirectCodexUserContentPart } from './view/project-development/chat/generated/DirectCodexUserContentPart'; -import { useDirectProjectChatController } from './view/project-development/chat/useDirectProjectChatController'; import type { ProjectManifestSnapshotMetadata } from './view/project-development/projectResourceLiveUpdateModel'; -const initialSupervisorMessageClaimsByPage = new WeakMap>(); -const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; -const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX = - 'direct-codex-turn-already-running:'; -/** 与 Rust 侧 `DirectTaonierActiveInvocationGuard::enter` 的 else 分支文案保持一致。 */ -const DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER = - '当前项目已有另一条 Direct 客户端回合正在运行'; -// Platform access tokens are short lived. DirectProject can spend several -// minutes in image generation, build and browser validation, so keep the -// client-owned native session current while a turn is running. The singleflight -// refresh in platformSession.ts coalesces this with any 401-triggered refresh. -const DIRECT_CODEX_SESSION_KEEPALIVE_MS = 5 * 60 * 1000; - -function isDirectCodexAuthenticationRequired(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return ( - message.includes('authentication-required') || - message.includes('codex-app-server-error:unauthorized') || - /kind=codex-app-server-unauthorized(?=\s|$)/.test(message) || - message.includes('登录已失效') - ); -} - -async function withDirectCodexSessionRefresh(operation: () => Promise) { - const generation = currentPlatformSessionGeneration(); - try { - return await operation(); - } catch (error) { - if (!isDirectCodexAuthenticationRequired(error)) throw error; - if (currentPlatformSessionGeneration() !== generation) throw error; - const refresh = await requestPlatformSessionRefresh(); - if (refresh.status === 'failed') throw error; - if ( - refresh.status !== 'refreshed' || - currentPlatformSessionGeneration() !== refresh.generation - ) { - throw new Error('登录账号已变化,原对话请求已停止'); - } - return operation(); - } -} - -function directCodexConversationMessageId( - turnId: string, - role: ChatMessage['role'], -) { - return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`; -} - -export const MAX_CHAT_COMPOSER_ATTACHMENTS = 8; - -export function isDirectCodexTurnAlreadyRunningError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return message - .trimStart() - .startsWith(DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX); -} - -/** - * 另一条 Direct 回合占着这个项目时的拒绝。它与上面那条同 clientTurnId 的拒绝分属不同 - * 错误分类(Rust 侧刻意不带前缀),但对界面是同一件事:本项目现在有一条我们没接管的 - * 回合在跑。所以这里单独判定,让它也走"接管它 + 告诉用户出口"的处理。 - */ -export function isDirectCodexAnotherTurnRunningError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return message.includes(DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER); -} - -/** - * 用户点了"终止"以后,正在 await 的回合命令会带着 app-server 的中断原因返回 - * (`Codex app-server turn 已中断`)。这类错误是用户主动取消,不是失败:界面要给 - * "已终止本次回合"而不是把中断当作异常写进运行错误与诊断。 - */ -export function isDirectCodexTurnInterruptedError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return message.includes('turn 已中断') || message.includes('已终止本次回合'); -} - function isPersistableDirectCodexConversationMessage(message: ChatMessage) { if (!message.runtimeOwned) { return false; @@ -395,20 +311,6 @@ function isPersistableDirectCodexConversationMessage(message: ChatMessage) { return /^[a-z0-9][a-z0-9-]{5,159}$/iu.test(turnId); } -function claimInitialSupervisorMessageForPage(projectPath: string, scope = '') { - let claimedProjectPaths = initialSupervisorMessageClaimsByPage.get(window); - if (!claimedProjectPaths) { - claimedProjectPaths = new Set(); - initialSupervisorMessageClaimsByPage.set(window, claimedProjectPaths); - } - const claimKey = `${projectPath}\u0000${scope}`; - if (claimedProjectPaths.has(claimKey)) { - return false; - } - claimedProjectPaths.add(claimKey); - return true; -} - /** * 历史回读与"尚未落盘的运行时消息"合并。 * @@ -492,29 +394,17 @@ type AppProps = { onMakeGameFromApprovedGdd?: (projectPath: string) => Promise; }; +/** + * 工作台壳里仍由 Supervisor / Design Agent / Planning V2 接管的提交入参。 + * + * DirectProject 的回合入参(`@` 引用、附件、权限确认重跑)归聊天容器自己的 + * `DirectProjectTurnInput`,不再从这里穿过。 + */ type ExecuteChatAgentReplyInput = { prompt: string; clientTurnId?: string; - creationType?: HomeCreationType | null; - attachments?: DirectCodexTurnAttachment[]; - directPolicyChecked?: boolean; - references?: ChatReference[]; - userItem?: ReturnType; }; -/** - * `conversation.write` 策略确认后重跑同一轮直连回合的入参。 - * - * 确认弹窗会在**同一轮输入**上二次进入 `executeChatAgentReply`。这里从首轮的入参整体派生, - * 而不是手写一遍字段:一旦重跑时漏掉某一项(历史缺陷就是漏了 `references`), - * 用户在确认之后拿到的就不是他原本提交的那一轮——`@` 引用会被静默丢掉。 - */ -export function directCodexPolicyRetryInput( - input: ExecuteChatAgentReplyInput, -): ExecuteChatAgentReplyInput { - return { ...input, directPolicyChecked: true }; -} - export function App({ initialProjectPath: initialProjectPathOverride = '', initialProjectManifest, @@ -1302,6 +1192,7 @@ export function App({ const chatInputRef = useRef(null); const supervisorChatMessagesRef = useRef(null); const supervisorChatShouldFollowLatestRef = useRef(true); + const directProjectChatRef = useRef(null); const [assetStatus, setAssetStatus] = useState('未上传'); const [uploadedAssets, setUploadedAssets] = useState< UploadLocalAssetResult[] @@ -1500,60 +1391,6 @@ export function App({ [], ); - const directChatController = useDirectProjectChatController({ - enabled: directProjectMode, - projectPath: localProject?.projectPath ?? null, - localProjectPathRef, - chatInput, - chatContent, - setChatInput, - setChatReferences, - setChatContent, - chatAgentBusy, - setChatAgentBusy, - setRuntimeError: setProjectSupervisorRuntimeError, - setWorkspaceStatus, - refreshManifest, - onStartTurn: startDirectCodexConversationTurn, - onReloadHistory: () => { - const nextProjectPath = requireChatProjectForUserAction(); - if (nextProjectPath) { - void loadProjectConversation(nextProjectPath, false, 'replace'); - } - }, - directCodexConversationMessageId, - directCodexUserItemFromContent, - hasMeaningfulContent: hasMeaningfulDirectCodexContent, - withDirectCodexSessionRefresh, - isTurnBusy: chatAgentBusy, - }); - const { - attachments: chatAttachments, - attachmentNotice: chatAttachmentNotice, - queuedTurns: chatTurnQueue, - composerNotice: chatComposerNotice, - setComposerNotice: setChatComposerNotice, - turnCancelling: directCodexTurnCancelling, - setTurnCancelling: setDirectCodexTurnCancelling, - directThreadChat, - directTurnRunning, - historyHasMore: directHistoryHasMore, - historyAnchorGateRef: directHistoryAnchorGateRef, - loadEarlierHistory: loadEarlierDirectHistory, - hydrateHistory: hydrateDirectHistory, - createTurnId: createDirectCodexConversationTurnId, - attachmentContentParts, - cancelQueuedTurn, - dispatchNextQueuedTurn, - uploadFiles: handleChatComposerUploadFiles, - removeAttachment: removeChatComposerAttachment, - cancelTurn: handleCancelDirectCodexTurn, - reset: resetDirectThreadChat, - submit: submitDirectProjectChat, - } = directChatController; - const supervisorChatBusy = - chatAgentBusy || (directProjectMode && directTurnRunning); - useEffect(() => { const refreshStates = manifestRefreshStatesRef.current; manifestRefreshMountedRef.current = true; @@ -1779,19 +1616,6 @@ export function App({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialProjectPath, projectSupervisorOnly]); - useEffect(() => { - if (!directProjectMode || !supervisorChatBusy) { - return; - } - const timer = window.setInterval(() => { - void requestPlatformSessionRefresh().catch(() => { - // The active DirectProject turn will surface the original auth error; - // keepalive must not replace it with an unrelated background error. - }); - }, DIRECT_CODEX_SESSION_KEEPALIVE_MS); - return () => window.clearInterval(timer); - }, [supervisorChatBusy, directProjectMode]); - useEffect(() => { if (projectSupervisorOnly && !directProjectMode) { return; @@ -2341,7 +2165,6 @@ export function App({ projectSupervisorResponseStream?.sequence, projectSupervisorRuntime?.updatedAt, projectSupervisorRuntimeError, - directThreadChat, directProjectMode, planningV2Active, projectSupervisorOnly, @@ -3362,71 +3185,15 @@ export function App({ ? null : await readProjectSupervisorActiveSession(invoke, nextProjectPath); let runtimeError = ''; - let loadedDirectHistoryHasMore = false; - let loadedDirectHistoryFirstItemId: string | null = null; - // 首屏连拉到的条目先暂存,待下方 staleness 守卫通过后再并入聊天 reducer: - // 迟到的切片属于已经切走的项目,不能在守卫之前就写进全局聊天状态。 - let loadedDirectHistoryItems: DirectThreadItem[] = []; - // 首屏切片的新端边界只认订阅回执里的 `lastCompletedItemId`(含该条):比它更新的条目 - // 只能来自运行态事件。打开项目时订阅 effect 还没跑,这里就先把闸门开好,订阅侧会复用 - // 同一道闸门并 settle 它。同一道闸门只锚定一次:同一订阅下再读一次(例如重开同一个项目) - // 没有新回执可等,退回"按当前文件尾取尾屏",与 `/history` 手动重读一致。 - let directHistoryThroughItemId: string | null = null; - if (directProjectMode && mode !== 'replace') { - const anchorGate = directHistoryAnchorGateToWaitFor( - directHistoryAnchorGateRef.current, - nextProjectPath, - ); - if (anchorGate) { - directHistoryAnchorGateRef.current = anchorGate; - directHistoryThroughItemId = await anchorGate.anchor; - anchorGate.consumed = true; - } - } - const projectConversation = directProjectMode - ? (() => { - // 首屏铺的就是这份视图的基线,"新回合"比较没有意义:判据退化为"这一页得有能渲染 - // 的条目",所以基线取空,免得拿上一份对话的残留状态去比。首屏失败仍按原有语义 - // 抛出,交给调用方的 catch 处理。 - return readDirectHistoryPages({ - existingEntries: [], - beforeItemId: null, - readSlice: (beforeItemId) => - invoke( - 'read_direct_project_history_slice', - beforeItemId - ? { - projectPath: nextProjectPath, - beforeItemId, - limit: CONVERSATION_INITIAL_VISIBLE_COUNT, - } - : { - projectPath: nextProjectPath, - ...(directHistoryThroughItemId - ? { throughItemId: directHistoryThroughItemId } - : {}), - limit: CONVERSATION_INITIAL_VISIBLE_COUNT, - }, - ), - }).then((pages) => { - if (pages.error) { - throw pages.error; - } - loadedDirectHistoryHasMore = pages.hasMore; - loadedDirectHistoryFirstItemId = pages.firstItemId; - loadedDirectHistoryItems = pages.items; - return { - path: nextProjectPath, - agentId: null, - messages: [], - } satisfies LocalConversationResult; + // DirectProject 的项目对话由聊天容器自己的订阅与切片读取拥有:工作台壳既不再 + // 读老的项目对话文件,也不把它混进 Supervisor 的会话回读。 + const resolvedProjectConversation: LocalConversationResult = + directProjectMode + ? { path: nextProjectPath, agentId: null, messages: [] } + : await invoke('read_local_conversation', { + projectPath: nextProjectPath, + agentId: null, }); - })() - : invoke('read_local_conversation', { - projectPath: nextProjectPath, - agentId: null, - }); - const resolvedProjectConversation = await projectConversation; // 首屏历史直接进聊天 reducer;`messages` 在 DirectProject 下只剩运行期本地消息。 let supervisorConversation: LocalConversationResult | null = null; let runtime: AgentRuntimeState | null = null; @@ -3485,13 +3252,6 @@ export function App({ setProjectSupervisorResponseStream(null); } setProjectSupervisorRuntimeError(runtimeError || resumeError); - if (directProjectMode) { - hydrateDirectHistory({ - items: loadedDirectHistoryItems, - hasMore: loadedDirectHistoryHasMore, - firstItemId: loadedDirectHistoryFirstItemId, - }); - } setMessages((current) => { // replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。 const nextConversationMessages = @@ -3586,7 +3346,6 @@ export function App({ const projectScopeVersion = projectScopeVersionRef.current + 1; projectScopeVersionRef.current = projectScopeVersion; resetProjectSupervisorState(); - resetDirectThreadChat(); updateClientPreview(null); setPreviewStatus('未启动'); setWorkspaceStatus('正在打开'); @@ -6064,12 +5823,8 @@ export function App({ return; } - const clientTurnId = createDirectCodexConversationTurnId(); - const userItem = chatComposerDraftToDirectCodexUserItem( - { text: prompt, references, content: chatContent }, - directCodexConversationMessageId(clientTurnId, 'user'), - ); - void executeChatAgentReply({ prompt, references, userItem, clientTurnId }); + const clientTurnId = createDirectProjectTurnId(); + void executeChatAgentReply({ prompt, clientTurnId }); } async function executeLlmConfigStatus() { @@ -6201,15 +5956,111 @@ export function App({ return sessionId; } - async function refreshDirectProjectManifest(nextProjectPath: string) { + /** + * DirectProject 聊天的首轮需求:入口 latch 里那份原文与附件,项目匹配后由聊天认领。 + * + * 认领记录与 Supervisor / Design / Planning 入口共用一份(按页面保存),因此同一条 + * 首轮需求不会被两个入口各发一次。 + */ + const initialDirectTurn: DirectProjectInitialTurn | null = (() => { + const latch = initialSupervisorMessageLatchRef.current; + if (!latch.prompt || !latch.projectPath) return null; + return { + projectPath: latch.projectPath, + // 首轮需求按 Composer 的 canonical content 形状交给聊天:文本与附件引用内联在 + // 同一份 content 里,`@` 引用与附件的处理不需要两套入参。 + content: [ + { type: 'input_text', text: latch.prompt }, + ...directCodexAttachmentContentParts(latch.attachments), + ], + creationType: latch.creationType, + claimScope: latch.claimScope, + }; + })(); + + /** + * DirectProject 读自己项目对话的门:策略要求确认时入队确认,读取由回调继续。 + */ + async function ensureDirectHistoryReadAllowed(input: { + projectPath: string; + onConfirmed: () => void; + }) { + const invoke = resolveTauriInvoke(); + if (!invoke) return false; try { - await refreshManifest(nextProjectPath); + const policyView = await invoke( + 'read_project_permission_policy', + { projectPath: input.projectPath }, + ); + if (!policyView.policy.confirmCommands.includes('conversation.read')) { + return true; + } + requestProjectPolicyConfirmation( + 'conversation.read', + input.projectPath, + `读取 ${input.projectPath} 的项目对话历史`, + input.onConfirmed, + ); + setWorkspaceStatus('等待确认'); + return false; } catch { - // The direct turn result is still authoritative for the chat. A missing - // or legacy manifest must not hide the Codex reply. + return false; } } + /** + * DirectProject 写自己项目对话的门:策略要求确认时入队确认,确认后重跑同一轮。 + */ + async function ensureDirectTurnWriteAllowed(input: { + projectPath: string; + onConfirmed: () => void; + }) { + const invoke = resolveTauriInvoke(); + if (!invoke) return false; + if (projectConversationWriteConfirmedRef.current === input.projectPath) { + return true; + } + try { + const paused = await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'conversation.write', + input.projectPath, + '写入 DirectProject 对话历史', + 'DirectProject 对话写入需要确认。', + () => { + projectConversationWriteConfirmedRef.current = input.projectPath; + input.onConfirmed(); + }, + ); + if (paused) return false; + projectConversationWriteConfirmedRef.current = input.projectPath; + return true; + } catch (error) { + if (localProjectPathRef.current === input.projectPath) { + setProjectSupervisorRuntimeError( + `DirectProject 对话权限检查失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + return false; + } + } + + /** + * 工作台壳要把一句结果说给用户在项目对话里听。 + * + * DirectProject 的会话由聊天容器持有,壳只把这句话交给聊天的本地消息流; + * Supervisor 路径仍写壳自己的 `messages`。 + */ + function announceProjectChatMessage(text: string) { + if (directProjectMode) { + directProjectChatRef.current?.announce(text); + return; + } + setMessages((current) => [...current, { role: 'assistant', text }]); + } + async function executePlanningV2Turn( nextProjectPath: string, prompt: string, @@ -6294,11 +6145,6 @@ export function App({ async function executeChatAgentReply({ prompt, clientTurnId: directConversationTurnId, - creationType, - attachments, - directPolicyChecked = false, - references, - userItem, }: ExecuteChatAgentReplyInput) { if (planningV2ActiveRef.current || planningStartMode) { const nextProjectPath = resolveChatProjectPath(localProject); @@ -6321,277 +6167,6 @@ export function App({ ); return; } - // Product default: send the conversation directly to Codex app-server. - // The legacy Supervisor/harness path remains below for rollback and tests. - if (directProjectMode) { - const directInvoke = resolveTauriInvoke(); - // Capture the project snapshot before any asynchronous policy/session work. - // `resolveChatProjectPath` only returns a path and TypeScript cannot infer - // that the source project is still non-null after an await; keeping the - // immutable snapshot also prevents a project switch from changing the - // projectId used by this turn halfway through submission. - const directProject = localProject; - const directProjectPath = resolveChatProjectPath(directProject); - const directProjectId = directProject?.manifest.projectId; - if (directProjectPath && directProjectId && directInvoke) { - const clientTurnId = - directConversationTurnId ?? createDirectCodexConversationTurnId(); - if (!userItem) { - setProjectSupervisorRuntimeError( - 'DirectProject 回合缺少 canonical user item,未发送本轮消息。', - ); - return; - } - const effectiveUserItem = userItem; - if ( - !directPolicyChecked && - projectConversationWriteConfirmedRef.current !== directProjectPath - ) { - // 首轮的入参整体留一份给「确认后重跑」用,不在回调里重列字段。 - const policyRetryInput = directCodexPolicyRetryInput({ - prompt, - clientTurnId, - creationType, - attachments, - references, - userItem: effectiveUserItem, - }); - try { - const policyPaused = await queueProjectPolicyConfirmationIfNeeded( - directInvoke, - 'conversation.write', - directProjectPath, - '写入 DirectProject 对话历史', - 'DirectProject 对话写入需要确认。', - () => { - projectConversationWriteConfirmedRef.current = - directProjectPath; - void executeChatAgentReply(policyRetryInput); - }, - ); - if (policyPaused) { - return; - } - projectConversationWriteConfirmedRef.current = directProjectPath; - } catch (error) { - if (localProjectPathRef.current === directProjectPath) { - setProjectSupervisorRuntimeError( - `DirectProject 对话权限检查失败:${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - return; - } - } - const directUserMessageId = directCodexConversationMessageId( - clientTurnId, - 'user', - ); - const directAssistantMessageId = directCodexConversationMessageId( - clientTurnId, - 'assistant', - ); - const appendDirectUserMessageIfMissing = ( - current: ChatMessage[], - ): ChatMessage[] => { - if ( - current.some((message) => message.messageId === directUserMessageId) - ) { - return current; - } - let optimisticIndex = -1; - for (let index = current.length - 1; index >= 0; index -= 1) { - const message = current[index]; - if ( - message?.role === 'user' && - message.text === prompt && - !message.messageId - ) { - optimisticIndex = index; - break; - } - } - if (optimisticIndex >= 0) { - return current.map((message, index) => - index === optimisticIndex - ? { - ...message, - runtimeOwned: true, - messageId: directUserMessageId, - updatedAt: Date.now(), - } - : message, - ); - } - return [ - ...current, - { - role: 'user' as const, - text: prompt, - runtimeOwned: true, - messageId: directUserMessageId, - updatedAt: Date.now(), - }, - ]; - }; - const appendDirectAssistantMessage = ( - current: ChatMessage[], - text: string, - failed = false, - ): ChatMessage[] => { - const messageId = failed - ? `direct-codex:${clientTurnId}:failure` - : directAssistantMessageId; - const nextMessage: ChatMessage = { - role: 'assistant', - text, - runtimeOwned: true, - messageId, - updatedAt: Date.now(), - }; - const withUser = appendDirectUserMessageIfMissing(current); - const existingIndex = withUser.findIndex( - (message) => message.messageId === messageId, - ); - if (existingIndex < 0) { - return [...withUser, nextMessage]; - } - return withUser.map((message, index) => - index === existingIndex ? nextMessage : message, - ); - }; - setChatAgentBusy(true); - setProjectSupervisorRuntimeError(''); - try { - const directTurnInput: { - projectPath: string; - prompt: string; - clientTurnId: string; - creationType?: HomeCreationType; - userItem: ReturnType; - } = { - projectPath: directProjectPath, - prompt, - clientTurnId, - userItem: effectiveUserItem, - }; - if (creationType) { - directTurnInput.creationType = creationType; - } - directTurnInput.userItem = effectiveUserItem; - // 回复正文按条目身份从线程事件 / 历史切片进聊天,本地不再补一条, - // 因此这里只等回合跑完,不接返回值。 - await withDirectCodexSessionRefresh(() => { - return directInvoke( - 'chat_with_game_creator_direct_codex', - directTurnInput, - ); - }); - // Rust already persisted the complete raw response items. Invalidate - // any history snapshot captured before the turn completed. - if (localProjectPathRef.current === directProjectPath) { - projectSupervisorHistoryLoadVersionRef.current += 1; - } - if (localProjectPathRef.current === directProjectPath) { - await refreshDirectProjectManifest(directProjectPath); - } - } catch (error) { - if ( - isDirectCodexTurnAlreadyRunningError(error) || - isDirectCodexAnotherTurnRunningError(error) - ) { - if (localProjectPathRef.current === directProjectPath) { - setProjectSupervisorRuntimeError( - '陶泥儿仍在处理上一条消息,可在输入盒点「终止」结束它,或等它结束后再发送。', - ); - } - return; - } - if (localProjectPathRef.current !== directProjectPath) { - return; - } - if (isDirectCodexTurnInterruptedError(error)) { - // 用户主动终止:不是失败,不写运行错误与诊断,只把回合标记成已终止。 - setProjectSupervisorRuntimeError(''); - setChatComposerNotice('已终止本次回合'); - setMessages((current) => - appendDirectAssistantMessage(current, '已终止本次回合。', true), - ); - return; - } - void captureAgentRuntimeError(error, PROJECT_SUPERVISOR_AGENT_ID); - const message = - error instanceof Error ? error.message : String(error); - let persistedDetail = ''; - const detailRef = message.match( - /详情:(\.agent\/runtime\/errors\/[^\s;]+)/, - )?.[1]; - if (detailRef && directInvoke) { - try { - persistedDetail = await directInvoke( - 'read_agent_runtime_error_detail', - { - projectPath: directProjectPath, - detailRef, - }, - ); - } catch { - persistedDetail = ''; - } - } - const visibleMessage = projectRuntimeVisibleError( - persistedDetail ? `${message}\n\n${persistedDetail}` : message, - '陶泥儿智能创作', - true, - ); - projectSupervisorHistoryLoadVersionRef.current += 1; - if (localProjectPathRef.current === directProjectPath) { - setProjectSupervisorRuntimeError(visibleMessage); - setMessages((current) => - appendDirectAssistantMessage(current, visibleMessage, true), - ); - } - } finally { - try { - if (localProjectPathRef.current === directProjectPath) { - await refreshManifest(directProjectPath); - } - } finally { - if (localProjectPathRef.current === directProjectPath) { - setChatAgentBusy(false); - setDirectCodexTurnCancelling(false); - // 本地 invoke 正常收尾仍可直接推进队列;恢复场景则由 turn.completed effect 推进。 - // 只有当前回合的收尾才能释放发送队列,不能覆盖后来启动的回合。 - dispatchNextQueuedTurn(); - } - } - } - return; - } - const message = directInvoke - ? '当前项目尚未准备好,无法启动智能创作。' - : '需要在 Tauri App 内运行,无法启动智能创作。'; - setProjectSupervisorRuntimeError(message); - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: message, - runtimeOwned: true, - ...(directConversationTurnId - ? { - messageId: directCodexConversationMessageId( - directConversationTurnId, - 'assistant', - ), - } - : {}), - updatedAt: Date.now(), - }, - ]); - return; - } if (agentRuntimeNeedsUserInput(projectSupervisorRuntimeRef.current)) { setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题'); return; @@ -6744,11 +6319,7 @@ export function App({ useEffect(() => { const latch = initialSupervisorMessageLatchRef.current; - if ( - (!directProjectMode && !planningStartMode) || - !latch.prompt || - !localProject - ) { + if (!planningStartMode || !latch.prompt || !localProject) { return; } if (localProject.projectPath !== latch.projectPath) { @@ -6764,47 +6335,21 @@ export function App({ return; } supervisorChatShouldFollowLatestRef.current = true; - const directConversationTurnId = directProjectMode - ? createDirectCodexConversationTurnId() - : undefined; setMessages((current) => [ ...current, { role: 'user', text: latch.prompt, runtimeOwned: true, - ...(directConversationTurnId - ? { - messageId: directCodexConversationMessageId( - directConversationTurnId, - 'user', - ), - } - : {}), updatedAt: Date.now(), }, ]); - const initialUserItem = directConversationTurnId - ? directCodexUserItemFromContent( - [ - { type: 'input_text', text: latch.prompt }, - ...attachmentContentParts(latch.attachments), - ], - directCodexConversationMessageId(directConversationTurnId, 'user'), - ) - : undefined; - void executeChatAgentReplyRef.current({ - prompt: latch.prompt, - clientTurnId: directConversationTurnId, - creationType: latch.creationType, - attachments: latch.attachments, - ...(initialUserItem ? { userItem: initialUserItem } : {}), - }); + // DirectProject 的首轮需求由聊天容器自己认领并发送(见 `DirectProjectChat`)。 + void executeChatAgentReplyRef.current({ prompt: latch.prompt }); // 初始消息 latch 只由入口状态驱动;controller 内部函数保持稳定语义。 // eslint-disable-next-line react-hooks/exhaustive-deps }, [ chatAgentBusy, - directProjectMode, initialCreationType, initialSupervisorMessage, localProject, @@ -9915,10 +9460,7 @@ export function App({ if (!invoke) { setLimitedCommandStatus('需要在 Tauri App 内运行'); if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); + announceProjectChatMessage('需要在 Tauri App 内运行。'); } return; } @@ -9926,10 +9468,7 @@ export function App({ if (!nextProjectPath) { setLimitedCommandStatus('请先初始化本地项目'); if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '请先用 /project 设置本地项目。' }, - ]); + announceProjectChatMessage('请先用 /project 设置本地项目。'); } return; } @@ -9960,22 +9499,15 @@ export function App({ void refreshAgentRunTrace(nextProjectPath); } if (announceToChat) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: `运行通过,已载入客户端运行视图:${previewResult.url}`, - }, - ]); + announceProjectChatMessage( + `运行通过,已载入客户端运行视图:${previewResult.url}`, + ); } } catch (error) { const message = error instanceof Error ? error.message : String(error); setLimitedCommandStatus(message); if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); + announceProjectChatMessage(message); } } } @@ -11707,8 +11239,7 @@ export function App({ 0, messages.length - visibleMessages.length, ); - const hasEarlierConversationMessages = - hiddenConversationCount > 0 || (directProjectMode && directHistoryHasMore); + const hasEarlierConversationMessages = hiddenConversationCount > 0; const projectSupervisorTransientReply = projectSupervisorResponseStream?.accumulatedText.trim() ?? ''; const projectSupervisorNeedsUserInput = agentRuntimeNeedsUserInput( @@ -11745,10 +11276,6 @@ export function App({ : null; async function showEarlierConversationMessages() { - if (directProjectMode && directHistoryHasMore) { - await loadEarlierDirectHistory(); - return; - } setConversationVisibleCount((current) => Math.min(messages.length, current + CONVERSATION_VISIBLE_STEP), ); @@ -11808,42 +11335,11 @@ export function App({ } }, [agentStatusCards, selectedAgent]); - /** - * 发起一轮 direct-codex 对话回合:提交与队列出队共用同一条路径,避免两条入口的 - * 消息落盘/回合 id/附件参数走样。 - */ - function startDirectCodexConversationTurn(input: { - clientTurnId: string; - userItem: ReturnType; - }) { - const prompt = directCodexContentToPromptText( - input.userItem.content, - manifest.assets, - ); - supervisorChatShouldFollowLatestRef.current = true; - setMessages((current) => [ - ...current, - { - role: 'user', - text: prompt, - runtimeOwned: true, - messageId: directCodexConversationMessageId(input.clientTurnId, 'user'), - updatedAt: Date.now(), - }, - ]); - void executeChatAgentReply({ - prompt, - clientTurnId: input.clientTurnId, - userItem: input.userItem, - }); - } - function handleProjectSupervisorOnlySubmit( event: FormEvent, ) { event.preventDefault(); const prompt = chatInput.trim(); - const references = chatReferences; const content = chatContent; const canonicalPrompt = directCodexContentToPromptText( content, @@ -11860,7 +11356,7 @@ export function App({ if (!hasMeaningfulDirectCodexContent(content)) { return; } - if (supervisorChatBusy) { + if (chatAgentBusy) { return; } if (planningV2ActiveRef.current || planningStartMode) { @@ -11895,7 +11391,7 @@ export function App({ updatedAt: Date.now(), }, ]); - void executeChatAgentReply({ prompt, references }); + void executeChatAgentReply({ prompt }); } const visibleProfessionalAgentCards = agentStatusCards.filter( @@ -11954,36 +11450,15 @@ export function App({ } if (directProjectMode) { + // 普通项目固定走 DirectProject 自己的聊天容器:订阅、历史、发送、队列和附件都由 + // 容器持有,工作台壳只提供项目身份、入口首轮需求和两条权限门。 return ( void handleCancelDirectCodexTurn()} - onChatInputChange={handleChatComposerChange} - onRemoveAttachment={removeChatComposerAttachment} - onScroll={handleSupervisorChatScroll} - onShowEarlierMessages={showEarlierConversationMessages} - onSubmit={submitDirectProjectChat} - onUploadFiles={(files) => void handleChatComposerUploadFiles(files)} - projectPath={localProject?.projectPath ?? projectPath} - queuedTurns={chatTurnQueue} - turnCancelling={directCodexTurnCancelling} - versions={chatProjectVersions} - workspaceStatus={workspaceStatus} - controlBusy={supervisorChatBusy} + ensureConversationReadAllowed={ensureDirectHistoryReadAllowed} + ensureConversationWriteAllowed={ensureDirectTurnWriteAllowed} + initialTurn={initialDirectTurn} + projectPath={localProject?.projectPath ?? projectPath ?? null} + ref={directProjectChatRef} /> ); } @@ -12150,8 +11625,7 @@ export function App({ runtime={projectSupervisorRuntime} error={projectSupervisorRuntimeError} runtimeByAgentId={agentRuntimeById} - controlBusy={directProjectMode ? supervisorChatBusy : chatAgentBusy} - readOnly={directProjectMode} + controlBusy={chatAgentBusy} versions={chatProjectVersions} professionalResultsByAgentId={professionalAgentResultsById} onToolAction={handleProjectSupervisorToolAction} diff --git a/apps/ai-game-creator-shell/src/app/initialSupervisorMessageClaims.ts b/apps/ai-game-creator-shell/src/app/initialSupervisorMessageClaims.ts new file mode 100644 index 000000000..bf4aa512e --- /dev/null +++ b/apps/ai-game-creator-shell/src/app/initialSupervisorMessageClaims.ts @@ -0,0 +1,25 @@ +/** + * 入口首轮需求的认领记录。 + * + * 首页/立项链路把同一条首轮需求带进工作台,Supervisor、Design Agent、Planning V2 与 + * DirectProject 是同一页面上的不同入口;认领按页面保存并按「项目路径 + claimScope」 + * 去重,保证同一条需求只被一个入口发出。 + */ +const initialSupervisorMessageClaimsByPage = new WeakMap>(); + +export function claimInitialSupervisorMessageForPage( + projectPath: string, + scope = '', +) { + let claimedProjectPaths = initialSupervisorMessageClaimsByPage.get(window); + if (!claimedProjectPaths) { + claimedProjectPaths = new Set(); + initialSupervisorMessageClaimsByPage.set(window, claimedProjectPaths); + } + const claimKey = `${projectPath}\u0000${scope}`; + if (claimedProjectPaths.has(claimKey)) { + return false; + } + claimedProjectPaths.add(claimKey); + return true; +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index b6229e007..7b548cd7a 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -19,6 +19,7 @@ import type { PlanGddStateViewV1, } from '../../app/types'; import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage'; +import { formatClockTime } from '../../view/project-development/chat/components/ToolCallGroup/toolCallGroupPresentation'; import { projectProfessionalAgentLabel, projectRuntimeVisibleError, @@ -45,7 +46,6 @@ import { type ResourceReferenceInputHandle, } from './ResourceReferenceInput'; import type { ChatComposerDraft, ChatReference } from './resourceReferences'; -import { formatClockTime } from './toolCallGroupPresentation'; function AgentReasoning({ text, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts deleted file mode 100644 index 8834882cc..000000000 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * DirectProject 运行态事件的线上类型。 - * - * 类型由 Rust 侧 ts-rs 导出(改完 Rust 模型后跑 `cargo test export_bindings`),这里只做 - * 入口转发:前端不再自己抄一份形状,字段增删必须改 Rust。 - */ - -export type { DirectThreadConsumeResult } from '../../view/project-development/chat/generated/DirectThreadConsumeResult'; -export type { DirectThreadDeltaKind } from '../../view/project-development/chat/generated/DirectThreadDeltaKind'; -export type { DirectThreadEvent } from '../../view/project-development/chat/generated/DirectThreadEvent'; -export type { DirectThreadFileChange } from '../../view/project-development/chat/generated/DirectThreadFileChange'; -export type { DirectThreadHistorySlice } from '../../view/project-development/chat/generated/DirectThreadHistorySlice'; -export type { DirectThreadItem } from '../../view/project-development/chat/generated/DirectThreadItem'; -export type { DirectThreadRequestKind } from '../../view/project-development/chat/generated/DirectThreadRequestKind'; -export type { DirectThreadSubscriptionBootstrap } from '../../view/project-development/chat/generated/DirectThreadSubscriptionBootstrap'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index d2bb8580f..d2002aa47 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -1,134 +1,190 @@ -import { ArrowUp, AtSign, Loader2, Settings } from 'lucide-react'; -import type { FormEventHandler, RefObject, UIEventHandler } from 'react'; -import { Fragment, useEffect, useRef, useState } from 'react'; +import type { UIEventHandler } from 'react'; +import type { Ref } from 'react'; +import { useEffect, useImperativeHandle, useRef, useState } from 'react'; +import { AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD } from '../../../app/constants'; +import { claimInitialSupervisorMessageForPage } from '../../../app/initialSupervisorMessageClaims'; +import { projectNameFromPath } from '../../../features/agent-runtime'; import { - AgentMessageContent, - type AgentMessageTone, -} from '../../../../../../packages/shared/src/components/AgentMessageContent'; -import type { ChatMessage } from '../../../app/types'; -import { ChatMarkdownMessage } from '../../../components/ChatMarkdownMessage'; -import { projectWorkspaceStatusForDisplay } from '../../../features/agent-runtime'; -import type { DirectCodexTurnAttachment } from '../../../features/app-shell/directCodexTurnAttachments'; -import type { QueuedChatTurn } from '../../../features/project-workspace/chatComposerQueue'; -import { - ComposerAttachmentMenu, - ComposerPendingAttachments, - ComposerReasoningEffortSelect, - ComposerStopButton, - ComposerTurnQueue, - ComposerVoiceButton, -} from '../../../features/project-workspace/ComposerControls'; -import { - ConversationModelSelect, - type ConversationModelSelectHandle, -} from '../../../features/project-workspace/ConversationModelSelect'; -import type { DirectChatEntry } from '../../../features/project-workspace/directThreadChat'; -import { - buildDirectChatTurns, - type DirectChatBlock, - type DirectChatTurn, - directMessageTimestamp, -} from '../../../features/project-workspace/directTurnPresentation'; -import { ProjectSupervisorSettingsDialog } from '../../../features/project-workspace/ProjectSupervisorSettingsDialog'; -import { - ResourceReferenceInput, - type ResourceReferenceInputHandle, -} from '../../../features/project-workspace/ResourceReferenceInput'; -import type { - ChatComposerDraft, - ChatReference, + directCodexContentToPromptText, + directCodexUserItemFromContent, } from '../../../features/project-workspace/resourceReferences'; -import { ToolCallGroup } from '../../../features/project-workspace/ToolCallGroup'; -import { - formatClockTime, - formatTurnDuration, -} from '../../../features/project-workspace/toolCallGroupPresentation'; import { type ApprovalMode, approvalModeLabel } from '../approvalMode'; import { ApprovalModeDialog } from '../ApprovalModeDialog'; +import { DirectProjectChatHeader } from './components/DirectProjectChatHeader/DirectProjectChatHeader'; +import { DirectProjectComposer } from './components/DirectProjectComposer/DirectProjectComposer'; +import { DirectProjectConversation } from './components/DirectProjectConversation/DirectProjectConversation'; +import { DirectProjectSettingsDialog } from './components/DirectProjectSettingsDialog/DirectProjectSettingsDialog'; +import { + type DirectProjectConversationReadGate, + type DirectProjectConversationWriteGate, + useDirectProjectChatController, +} from './controller/useDirectProjectChatController'; +import { useDirectProjectManifest } from './controller/useDirectProjectManifest'; +import { + createDirectProjectTurnId, + directCodexConversationMessageId, + type DirectProjectTurnInput, +} from './conversation/directCodexConversation'; +import { buildDirectChatTurns } from './conversation/directTurnPresentation'; +import type { DirectCodexUserContentPart } from './generated/DirectCodexUserContentPart'; -type AssetManifestEntry = - import('../../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry; -type GameIterationVersion = - import('../../../../../../packages/shared/src/contracts/gameCreationApp').GameIterationVersion; - -export type DirectProjectChatViewProps = { - activeVersionId?: string | null; - attachments: DirectCodexTurnAttachment[]; - attachmentNotice: string; - chatInput: string; - chatReferences: ChatReference[]; - chatProjectAssets: AssetManifestEntry[]; - composerRef: RefObject; - composerNotice: string; - conversationMessages: ChatMessage[]; - directEntries: DirectChatEntry[]; - directTurnRunning: boolean; - hiddenConversationCount: number; - hasEarlierConversationMessages: boolean; - messagesRef: RefObject; - onCancelQueuedTurn: (id: string) => void; - onCancelTurn: () => void; - onChatInputChange: (draft: ChatComposerDraft) => void; - onRemoveAttachment: (index: number) => void; - onScroll: UIEventHandler; - onShowEarlierMessages: () => void; - onSubmit: FormEventHandler; - onUploadFiles: (files: readonly File[]) => void; - projectPath: string; - queuedTurns: QueuedChatTurn[]; - turnCancelling: boolean; - versions?: GameIterationVersion[]; - workspaceStatus: string; - controlBusy: boolean; +/** + * DirectProject 对外只有这一条命令式出口:工作台壳跑完运行/预览这类工作台级动作后, + * 把结果告诉用户。聊天容器持有会话真相,壳不持有聊天消息,只把这句话交给聊天的本地 + * 消息流。 + */ +export type DirectProjectChatHandle = { + announce: (text: string) => void; }; /** - * DirectProject 的独立表现容器。 + * 首页(或立项链路)带过来的首轮需求:项目打开后由聊天自己认领并发出。 * - * 这个组件只处理 DirectProject 的输入框、回合分区、运行中状态和设置表现; - * Supervisor / Design Agent / Planning V2 的运行态不在这里出现。 + * 认领记录按「项目路径 + claimScope」与 Supervisor / Design / Planning 入口共用一份 + * (见 `app/initialSupervisorMessageClaims`),所以同一条首轮需求不会被两个入口各发一次。 */ +export type DirectProjectInitialTurn = { + projectPath: string; + /** + * 入口首轮需求的 canonical 内容:文本、`@` 素材引用和附件引用都内联在这一份 content + * 里,和 Composer 提交的回合保持同一种形状。 + */ + content: DirectCodexUserContentPart[]; + creationType?: DirectProjectTurnInput['creationType']; + claimScope: string; +}; + +/** + * DirectProject 的公开入参:只有项目身份、入口带进来的首轮需求和两条权限门。 + * + * 项目清单(素材/版本/projectId)、订阅、首屏锚点与历史分页、发送与 FIFO 队列、附件、 + * 终止、草稿、本地消息、状态文案和错误提示都由这个组件自己持有;工作台壳不再传 Direct + * 专属 state/ref/effect,也不再有 Supervisor / Design Agent / Planning V2 的运行态。 + */ +export type DirectProjectChatViewProps = { + /** 绑定的项目路径:项目身份、素材与版本都从这份项目清单读取。 */ + projectPath: string | null; + /** 入口带进来的首轮需求;未提供时不自动发送任何回合。 */ + initialTurn?: DirectProjectInitialTurn | null; + /** 项目对话读权限门:策略要求确认时由工作台壳入队,聊天不重复判定权限。 */ + ensureConversationReadAllowed?: DirectProjectConversationReadGate; + /** 项目对话写权限门:只在发起回合时检查。 */ + ensureConversationWriteAllowed?: DirectProjectConversationWriteGate; + /** 工作台壳把运行/预览这类动作结果交给聊天的出口。 */ + ref?: Ref; +}; + +const ALLOW_CONVERSATION_ACCESS: DirectProjectConversationReadGate = async () => + true; + +/** 首轮那条本地用户消息展示入口原话:附件与引用仍按 canonical content 进入回合。 */ +function directInitialTurnText(content: readonly DirectCodexUserContentPart[]) { + return content + .filter((part) => part.type === 'input_text') + .map((part) => part.text) + .join('') + .trim(); +} + export function DirectProjectChatView({ - activeVersionId = null, - attachments, - attachmentNotice, - chatInput, - chatReferences, - chatProjectAssets, - composerRef, - composerNotice, - conversationMessages, - directEntries, - directTurnRunning, - hiddenConversationCount, - hasEarlierConversationMessages, - messagesRef, - onCancelQueuedTurn, - onCancelTurn, - onChatInputChange, - onRemoveAttachment, - onScroll, - onShowEarlierMessages, - onSubmit, - onUploadFiles, projectPath, - queuedTurns, - turnCancelling, - versions, - workspaceStatus, - controlBusy, + initialTurn = null, + ensureConversationReadAllowed = ALLOW_CONVERSATION_ACCESS, + ensureConversationWriteAllowed = ALLOW_CONVERSATION_ACCESS, + ref, }: DirectProjectChatViewProps) { + const { assets, projectId, refresh, versions } = + useDirectProjectManifest(projectPath); + const messagesRef = useRef(null); + const shouldFollowLatestRef = useRef(true); + const [runtimeNotice, setRuntimeNotice] = useState(''); const [settingsOpen, setSettingsOpen] = useState(false); const [turnUsageNow, setTurnUsageNow] = useState(() => Date.now()); - const [voiceNotice, setVoiceNotice] = useState(''); const [approvalOpen, setApprovalOpen] = useState(false); const [approvalMode, setApprovalMode] = useState('strict'); const [approvalNotice, setApprovalNotice] = useState(''); - const [modelReady, setModelReady] = useState(false); - const [modelValidating, setModelValidating] = useState(false); - const modelSelectRef = useRef(null); - const modelValidateInFlightRef = useRef(false); + const chat = useDirectProjectChatController({ + assets, + enabled: Boolean(projectPath), + ensureConversationReadAllowed, + ensureConversationWriteAllowed, + onRuntimeError: setRuntimeNotice, + projectId, + projectPath, + refreshManifest: refresh, + }); + const { + appendLocalMessage, + attachments, + attachmentNotice, + cancelQueuedTurn, + cancelTurn, + chatInput, + chatReferences, + composerNotice, + directEntries, + directTurnRunning, + handleComposerDraft, + historyHasMore, + loadEarlierHistory, + localMessages, + queuedTurns, + removeAttachment, + startInitialTurn, + statusNotice, + submit, + turnBusy, + turnCancelling, + uploadFiles, + } = chat; + const busy = turnBusy || directTurnRunning; + const statusText = + runtimeNotice || + statusNotice || + (projectPath + ? `已打开:${projectNameFromPath(projectPath)}` + : '请选择工作区'); + + useEffect(() => { + if (!initialTurn || initialTurn.content.length === 0 || !projectPath) + return; + if (projectPath !== initialTurn.projectPath) return; + // projectId 来自项目清单:清单还没到位时不能先认领,否则首轮需求会被空项目吃掉。 + if (!projectId || busy) return; + if ( + !claimInitialSupervisorMessageForPage( + initialTurn.projectPath, + initialTurn.claimScope, + ) + ) { + return; + } + const clientTurnId = createDirectProjectTurnId(); + const userItem = directCodexUserItemFromContent( + initialTurn.content, + directCodexConversationMessageId(clientTurnId, 'user'), + ); + shouldFollowLatestRef.current = true; + startInitialTurn({ + prompt: directCodexContentToPromptText(userItem.content, assets), + // 首轮那条用户消息按入口原话展示,引用/附件仍按 canonical content 发给运行时。 + messageText: directInitialTurnText(initialTurn.content), + clientTurnId, + ...(initialTurn.creationType + ? { creationType: initialTurn.creationType } + : {}), + userItem, + }); + // 首轮需求只由入口 payload、项目身份、清单就绪和回合态驱动。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [busy, initialTurn, projectId, projectPath]); + + useEffect(() => { + if (!shouldFollowLatestRef.current) return; + const list = messagesRef.current; + if (list) list.scrollTop = list.scrollHeight; + }, [directEntries, localMessages]); useEffect(() => { if (!directTurnRunning) return; @@ -139,97 +195,26 @@ export function DirectProjectChatView({ const directTurns = buildDirectChatTurns({ entries: directEntries, - localMessages: conversationMessages, + localMessages, turnRunning: directTurnRunning, }); const activeTurnStartedAt = directTurns.find((turn) => turn.active)?.startedAt ?? 0; - const renderTurnUsage = (turn: DirectChatTurn) => { - if (turn.active || !turn.startedAt) return null; - const endedAt = Math.max(turn.endedAt, turn.startedAt); - return ( -

- {`本轮结束于 ${new Date(endedAt).toLocaleTimeString('zh-CN', { - hour12: false, - })} · 耗时 ${formatTurnDuration(endedAt - turn.startedAt) ?? '0秒'}`} -

- ); - }; + useImperativeHandle(ref, () => ({ + announce: (text: string) => { + appendLocalMessage({ role: 'assistant', text, updatedAt: Date.now() }); + }, + })); - const submitLabel = controlBusy ? '思考中' : '发送'; - const submitButton = ( - - ); - - const renderBlock = ( - turn: DirectChatTurn, - block: DirectChatBlock, - tone: AgentMessageTone = 'body', - streamingKey: string | null, - ) => { - if (block.kind === 'tools') { - return ( - - ); + const handleScroll: UIEventHandler = (event) => { + const list = event.currentTarget; + shouldFollowLatestRef.current = + list.scrollHeight - list.scrollTop - list.clientHeight <= + AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD; + if (historyHasMore && list.scrollTop <= 24) { + void loadEarlierHistory(); } - if (block.kind === 'reasoning') { - return ( - - 思考过程 -
{block.text}
-
- ); - } - const role = block.kind === 'user' ? 'user' : 'assistant'; - return ( -
- - - - {block.kind === 'user' && block.at > 0 ? ( - - ) : null} -
- ); }; return ( @@ -238,201 +223,44 @@ export function DirectProjectChatView({ aria-label="陶泥儿项目对话" >
-
- - - -
-
- {hiddenConversationCount > 0 || hasEarlierConversationMessages ? ( - - ) : null} - {directTurns.map((turn) => { - const streamingKey = turn.active - ? ([...turn.process] - .reverse() - .find((block) => block.kind === 'assistant')?.key ?? null) - : null; - return ( - - {turn.users.map((block) => - renderBlock(turn, block, 'body', streamingKey), - )} - {turn.process.length > 0 ? ( - turn.active ? ( - turn.process.map((block) => - renderBlock(turn, block, 'process', streamingKey), - ) - ) : ( -
- 执行过程 -
- {turn.process.map((block) => - renderBlock(turn, block, 'process', streamingKey), - )} -
-
- ) - ) : null} - {turn.finals.map((block) => - renderBlock(turn, block, 'body', streamingKey), - )} - {renderTurnUsage(turn)} -
- ); - })} -
- {directTurnRunning ? ( - -
-
-
- ) : null} -
{ - event.preventDefault(); - if (modelValidateInFlightRef.current) return; - modelValidateInFlightRef.current = true; - setModelValidating(true); - void (async () => { - try { - const ready = modelSelectRef.current - ? await modelSelectRef.current.ensureUsable() - : modelReady; - if (ready) onSubmit(event); - } finally { - modelValidateInFlightRef.current = false; - setModelValidating(false); - } - })(); - }} - > - - - -
-
- - composerRef.current?.openPicker()} - /> -
-
- - - composerRef.current?.insertText(text)} - onNotice={setVoiceNotice} - /> - {controlBusy ? ( - - ) : ( - submitButton - )} -
-
- {composerNotice || attachmentNotice || voiceNotice ? ( -

- {composerNotice || attachmentNotice || voiceNotice} -

- ) : null} - + setSettingsOpen(true)} + /> + void loadEarlierHistory()} + onScroll={handleScroll} + /> + void cancelTurn()} + onChatInputChange={handleComposerDraft} + onRemoveAttachment={removeAttachment} + onSubmit={submit} + onUploadFiles={(files) => void uploadFiles(files)} + />
{settingsOpen ? ( - setApprovalOpen(true)} onClose={() => setSettingsOpen(false)} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectChatHeader/DirectProjectChatHeader.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectChatHeader/DirectProjectChatHeader.tsx new file mode 100644 index 000000000..75b6f52a9 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectChatHeader/DirectProjectChatHeader.tsx @@ -0,0 +1,38 @@ +import { Settings } from 'lucide-react'; + +/** + * DirectProject 聊天头:运行状态与设置入口。 + * + * 状态文案由容器决定(忙态优先),这里只负责渲染;DirectProject 不再把工作台壳的 + * 全局状态文案搬进来。 + */ +export function DirectProjectChatHeader({ + busy, + statusText, + onOpenSettings, +}: { + busy: boolean; + statusText: string; + onOpenSettings: () => void; +}) { + return ( +
+ + + +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx similarity index 97% rename from apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx rename to apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx index 292ddd245..aa0bbb674 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx @@ -19,13 +19,18 @@ import { import type { RefObject } from 'react'; import { useEffect, useRef, useState } from 'react'; -import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -import { resolveTauriInvoke } from '../../app/tauri'; +import type { GameCreationAppAssetManifestEntry } from '../../../../../../../../packages/shared/src/contracts/gameCreationApp'; +import { resolveTauriInvoke } from '../../../../../app/tauri'; import type { GameCreatorAppConfigView, GameCreatorLlmReasoningEffort, -} from '../../app/types'; -import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments'; +} from '../../../../../app/types'; +import { + composerReasoningEffortOptions, + DEFAULT_COMPOSER_REASONING_EFFORT, + normalizeComposerReasoningEffort, +} from '../../../../../features/project-workspace/composerReasoningEffort'; +import type { DirectCodexTurnAttachment } from '../../conversation/directCodexTurnAttachments'; import type { QueuedChatTurn } from './chatComposerQueue'; import { queuedChatTurnLabel } from './chatComposerQueue'; import { @@ -37,11 +42,6 @@ import { type SpeechRecognitionLike, VOICE_INPUT_UNSUPPORTED_MESSAGE, } from './chatComposerVoice'; -import { - composerReasoningEffortOptions, - DEFAULT_COMPOSER_REASONING_EFFORT, - normalizeComposerReasoningEffort, -} from './composerReasoningEffort'; type ComposerAttachmentMenuProps = { disabled: boolean; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx new file mode 100644 index 000000000..aed4fa1c7 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx @@ -0,0 +1,187 @@ +import { ArrowUp, AtSign, Loader2 } from 'lucide-react'; +import type { FormEventHandler } from 'react'; +import { useRef, useState } from 'react'; + +import type { GameCreationAppAssetManifestEntry } from '../../../../../../../../packages/shared/src/contracts/gameCreationApp'; +import type { GameIterationVersion } from '../../../../../../../../packages/shared/src/contracts/gameCreationApp'; +import { + ConversationModelSelect, + type ConversationModelSelectHandle, +} from '../../../../../features/project-workspace/ConversationModelSelect'; +import { + ResourceReferenceInput, + type ResourceReferenceInputHandle, +} from '../../../../../features/project-workspace/ResourceReferenceInput'; +import type { + ChatComposerDraft, + ChatReference, +} from '../../../../../features/project-workspace/resourceReferences'; +import type { DirectCodexTurnAttachment } from '../../conversation/directCodexTurnAttachments'; +import type { QueuedChatTurn } from './chatComposerQueue'; +import { + ComposerAttachmentMenu, + ComposerPendingAttachments, + ComposerReasoningEffortSelect, + ComposerStopButton, + ComposerTurnQueue, + ComposerVoiceButton, +} from './ComposerControls'; + +/** + * DirectProject 输入区:队列、附件、`@` 引用输入框、模型/推理选择和发送/终止。 + * + * 模型可用性校验、语音提示这类输入区自己的瞬时状态留在这里;回合、队列和附件的事实源 + * 仍然在聊天容器里,这里只负责把用户动作交回去。 + */ +export function DirectProjectComposer({ + assets, + versions, + projectPath, + attachments, + attachmentNotice, + queuedTurns, + chatInput, + chatReferences, + composerNotice, + busy, + turnCancelling, + onCancelQueuedTurn, + onCancelTurn, + onChatInputChange, + onRemoveAttachment, + onSubmit, + onUploadFiles, +}: { + assets: GameCreationAppAssetManifestEntry[]; + versions?: GameIterationVersion[]; + projectPath: string | null; + attachments: DirectCodexTurnAttachment[]; + attachmentNotice: string; + queuedTurns: QueuedChatTurn[]; + chatInput: string; + chatReferences: ChatReference[]; + composerNotice: string; + busy: boolean; + turnCancelling: boolean; + onCancelQueuedTurn: (id: string) => void; + onCancelTurn: () => void; + onChatInputChange: (draft: ChatComposerDraft) => void; + onRemoveAttachment: (index: number) => void; + onSubmit: FormEventHandler; + onUploadFiles: (files: readonly File[]) => void; +}) { + const composerRef = useRef(null); + const modelSelectRef = useRef(null); + const modelValidateInFlightRef = useRef(false); + const [voiceNotice, setVoiceNotice] = useState(''); + const [modelReady, setModelReady] = useState(false); + const [modelValidating, setModelValidating] = useState(false); + const submitLabel = busy ? '思考中' : '发送'; + const submitButton = ( + + ); + + return ( +
{ + event.preventDefault(); + if (modelValidateInFlightRef.current) return; + modelValidateInFlightRef.current = true; + setModelValidating(true); + void (async () => { + try { + const ready = modelSelectRef.current + ? await modelSelectRef.current.ensureUsable() + : modelReady; + if (ready) onSubmit(event); + } finally { + modelValidateInFlightRef.current = false; + setModelValidating(false); + } + })(); + }} + > + + + +
+
+ + void onUploadFiles(files)} + onOpenReferencePicker={() => composerRef.current?.openPicker()} + /> +
+
+ + + composerRef.current?.insertText(text)} + onNotice={setVoiceNotice} + /> + {busy ? ( + + ) : ( + submitButton + )} +
+
+ {composerNotice || attachmentNotice || voiceNotice ? ( +

+ {composerNotice || attachmentNotice || voiceNotice} +

+ ) : null} + + ); +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/chatComposerQueue.ts similarity index 92% rename from apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/chatComposerQueue.ts index 2dab0b64c..ae629ff48 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/chatComposerQueue.ts @@ -4,9 +4,9 @@ * 回合运行中用户再次发送时,消息进入 FIFO 队列而不是被丢弃;当前回合结束后按入队顺序 * 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。 */ -import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -import type { DirectCodexUserItem } from '../../view/project-development/chat/generated/DirectCodexUserItem'; -import { directCodexContentToPromptText } from './resourceReferences'; +import type { GameCreationAppAssetManifestEntry } from '../../../../../../../../packages/shared/src/contracts/gameCreationApp'; +import { directCodexContentToPromptText } from '../../../../../features/project-workspace/resourceReferences'; +import type { DirectCodexUserItem } from '../../generated/DirectCodexUserItem'; /** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */ export const MAX_QUEUED_CHAT_TURNS = 5; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerVoice.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/chatComposerVoice.ts similarity index 100% rename from apps/ai-game-creator-shell/src/features/project-workspace/chatComposerVoice.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/chatComposerVoice.ts diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx new file mode 100644 index 000000000..63e2e0a57 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx @@ -0,0 +1,81 @@ +import type { RefObject, UIEventHandler } from 'react'; + +import { AgentMessageContent } from '../../../../../../../../packages/shared/src/components/AgentMessageContent'; +import type { DirectChatTurn } from '../../conversation/directTurnPresentation'; +import { formatTurnDuration } from '../ToolCallGroup/toolCallGroupPresentation'; +import { DirectProjectTurn } from './DirectProjectTurn'; + +/** + * 会话区:回合列表、更早历史入口和运行中过程卡。 + * + * 回合来自 DirectProject 自己的投影;这里不读历史、不发回合,只把容器给的状态渲染出来。 + */ +export function DirectProjectConversation({ + turns, + messagesRef, + historyHasMore, + running, + activeTurnStartedAt, + turnUsageNow, + onLoadEarlierHistory, + onScroll, +}: { + turns: DirectChatTurn[]; + messagesRef: RefObject; + historyHasMore: boolean; + running: boolean; + activeTurnStartedAt: number; + turnUsageNow: number; + onLoadEarlierHistory: () => void; + onScroll: UIEventHandler; +}) { + return ( + <> +
+ {historyHasMore ? ( + + ) : null} + {turns.map((turn) => ( + + ))} +
+ {running ? ( + +
+
+
+ ) : null} + + ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx new file mode 100644 index 000000000..06835a6b8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx @@ -0,0 +1,127 @@ +import { Fragment } from 'react'; + +import { + AgentMessageContent, + type AgentMessageTone, +} from '../../../../../../../../packages/shared/src/components/AgentMessageContent'; +import { ChatMarkdownMessage } from '../../../../../components/ChatMarkdownMessage'; +import { + type DirectChatBlock, + type DirectChatTurn, + directMessageTimestamp, +} from '../../conversation/directTurnPresentation'; +import { ToolCallGroup } from '../ToolCallGroup/ToolCallGroup'; +import { + formatClockTime, + formatTurnDuration, +} from '../ToolCallGroup/toolCallGroupPresentation'; + +/** + * 一个完整回合的分区表现:用户发言、执行过程(工具/思考)与最终答复。 + * + * 运行中的回合把执行过程平铺出来,已结束的回合折叠进「执行过程」;这一层只做投影到 + * 表现的渲染,不拥有任何回合状态。 + */ +export function DirectProjectTurn({ turn }: { turn: DirectChatTurn }) { + const streamingKey = turn.active + ? ([...turn.process].reverse().find((block) => block.kind === 'assistant') + ?.key ?? null) + : null; + return ( + + {turn.users.map((block) => + renderBlock(turn, block, 'body', streamingKey), + )} + {turn.process.length > 0 ? ( + turn.active ? ( + turn.process.map((block) => + renderBlock(turn, block, 'process', streamingKey), + ) + ) : ( +
+ 执行过程 +
+ {turn.process.map((block) => + renderBlock(turn, block, 'process', streamingKey), + )} +
+
+ ) + ) : null} + {turn.finals.map((block) => + renderBlock(turn, block, 'body', streamingKey), + )} + +
+ ); +} + +function DirectProjectTurnUsage({ turn }: { turn: DirectChatTurn }) { + if (turn.active || !turn.startedAt) return null; + const endedAt = Math.max(turn.endedAt, turn.startedAt); + return ( +

+ {`本轮结束于 ${new Date(endedAt).toLocaleTimeString('zh-CN', { + hour12: false, + })} · 耗时 ${formatTurnDuration(endedAt - turn.startedAt) ?? '0秒'}`} +

+ ); +} + +function renderBlock( + turn: DirectChatTurn, + block: DirectChatBlock, + tone: AgentMessageTone = 'body', + streamingKey: string | null, +) { + if (block.kind === 'tools') { + return ( + + ); + } + if (block.kind === 'reasoning') { + return ( + + 思考过程 +
{block.text}
+
+ ); + } + const role = block.kind === 'user' ? 'user' : 'assistant'; + return ( +
+ + + + {block.kind === 'user' && block.at > 0 ? ( + + ) : null} +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorSettingsDialog.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectSettingsDialog/DirectProjectSettingsDialog.tsx similarity index 93% rename from apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorSettingsDialog.tsx rename to apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectSettingsDialog/DirectProjectSettingsDialog.tsx index c45965822..1beeddc52 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorSettingsDialog.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectSettingsDialog/DirectProjectSettingsDialog.tsx @@ -4,8 +4,8 @@ import { useState } from 'react'; import { closeDialogOnBackdropMouseDown, useEscapeToClose, -} from '../../app/dialogs'; -import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog'; +} from '../../../../../app/dialogs'; +import { RuntimeConfigDialog } from '../../../../../features/runtime-config/RuntimeConfigDialog'; /** * 右侧对话面板 Codex 风格改造后,面板顶部只剩「状态点 + 状态文案 + 齿轮」。 @@ -17,7 +17,7 @@ import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog'; * - 「运行配置」打开既有 `RuntimeConfigDialog`(独立浮层,自己有 backdrop); * - 「操作权限」打开由 `ApprovalModeDialog` 提供的选择面板; */ -export function ProjectSupervisorSettingsDialog({ +export function DirectProjectSettingsDialog({ projectPath, currentApprovalLabel, onOpenApproval, @@ -47,7 +47,7 @@ export function ProjectSupervisorSettingsDialog({

对话设置

- 运行配置、操作权限与泥点 + 运行配置与操作权限