Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx
T
k88936 c42e087a30
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 21s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 20s
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Merge remote-tracking branch 'origin/master' into refactor/extract-dep-from-ref-inputer
# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
2026-09-22 17:41:24 +08:00

287 lines
11 KiB
TypeScript

import type { UIEventHandler } from 'react';
import type { Ref } from 'react';
import {
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD } from '../../../app/constants';
import { claimInitialTurnForPage } from '../../../app/initialTurnClaims';
import { projectNameFromPath } from '../../../features/agent-runtime';
import { directCodexUserItemFromContent } from '../../../features/project-workspace/resourceReferences';
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 { useDirectProjectTurnStatus } from './controller/useDirectProjectTurnStatus';
import {
createDirectProjectTurnId,
directCodexConversationMessageId,
type DirectProjectTurnInput,
} from './conversation/directCodexConversation';
import { buildDirectChatTurns } from './conversation/directTurnPresentation';
import type { DirectCodexUserContentPart } from './generated/DirectCodexUserContentPart';
/**
* DirectProject 对外只有这一条命令式出口:工作台壳跑完运行/预览这类工作台级动作后,
* 把结果告诉用户。聊天容器持有会话真相,壳不持有聊天消息,只把这句话交给聊天的本地
* 消息流。
*/
export type DirectProjectChatHandle = {
announce: (text: string) => void;
};
/**
* 首页(或立项链路)带过来的首轮需求:项目打开后由聊天自己认领并发出。
*
* 认领记录按「项目路径 + claimScope」与 Design / Planning 入口共用一份
* (见 `app/initialTurnClaims`),所以同一条首轮需求不会被两个入口各发一次。
*/
export type DirectProjectInitialTurn = {
projectPath: string;
/**
* 入口首轮需求的 canonical 内容:文本、`@` 素材引用和附件引用都内联在这一份 content
* 里,和 Composer 提交的回合保持同一种形状。
*/
content: DirectCodexUserContentPart[];
creationType?: DirectProjectTurnInput['creationType'];
claimScope: string;
};
/**
* DirectProject 的公开入参:只有项目身份、入口带进来的首轮需求和两条权限门。
*
* 项目清单(素材/版本/projectId)、订阅、首屏锚点与历史分页、发送与 FIFO 队列、附件、
* 终止、草稿、本地消息、状态文案和错误提示都由这个组件自己持有;工作台壳不再传 Direct
* 专属 state/ref/effect,也不再有 Design Agent / Planning V2 的运行态。
*/
export type DirectProjectChatViewProps = {
/** 绑定的项目路径:项目身份、素材与版本都从这份项目清单读取。 */
projectPath: string | null;
/** 入口带进来的首轮需求;未提供时不自动发送任何回合。 */
initialTurn?: DirectProjectInitialTurn | null;
/**
* 项目对话读权限门:策略要求确认时由工作台壳入队,聊天不重复判定权限。
* 必填:这道门是聊天发起读取/回合的授权判据,缺省放行等于绕过工作台壳的权限判定。
*/
ensureConversationReadAllowed: DirectProjectConversationReadGate;
/** 项目对话写权限门:只在发起回合时检查;同样必填,缺省会让未传入的调用方静默跳过授权。 */
ensureConversationWriteAllowed: DirectProjectConversationWriteGate;
/** 发布入口:由工作台壳持有导出与发布面板,聊天只提供触发按钮。 */
onRequestGamePublish?: () => void;
/** 工作台壳把运行/预览这类动作结果交给聊天的出口。 */
ref?: Ref<DirectProjectChatHandle>;
};
/** 首轮那条本地用户消息展示入口原话:附件与引用仍按 canonical content 进入回合。 */
function directInitialTurnText(content: readonly DirectCodexUserContentPart[]) {
return content
.filter((part) => part.type === 'input_text')
.map((part) => part.text)
.join('')
.trim();
}
export function DirectProjectChatView({
onRequestGamePublish,
projectPath,
initialTurn = null,
ensureConversationReadAllowed,
ensureConversationWriteAllowed,
ref,
}: DirectProjectChatViewProps) {
const { assets, projectId, refresh, versions } =
useDirectProjectManifest(projectPath);
const messagesRef = useRef<HTMLDivElement | null>(null);
const shouldFollowLatestRef = useRef(true);
const [runtimeNotice, setRuntimeNotice] = useState('');
const [settingsOpen, setSettingsOpen] = useState(false);
const [approvalOpen, setApprovalOpen] = useState(false);
const [approvalMode, setApprovalMode] = useState<ApprovalMode>('strict');
const [approvalNotice, setApprovalNotice] = useState('');
const chat = useDirectProjectChatController({
assets,
enabled: Boolean(projectPath),
ensureConversationReadAllowed,
ensureConversationWriteAllowed,
onRuntimeError: setRuntimeNotice,
projectId,
projectPath,
refreshManifest: refresh,
});
const {
appendLocalMessage,
attachmentNotice,
attachmentsImporting,
cancelQueuedTurn,
cancelTurn,
composerNotice,
directEntries,
directTurnRunning,
historyHasMore,
loadEarlierHistory,
localMessages,
pendingUserItemId,
queuedTurns,
startInitialTurn,
statusNotice,
submit,
turnBusy,
turnCancelling,
uploadFiles,
} = chat;
// 这份投影要参与下游 memo 的判据,必须自己先缓存:不缓存的话每次渲染都是一份新数组,
// `useDirectProjectTurnStatus` 里以 `turns` 为判据的 `useMemo` 永远命中不了,那层 memo
// 就成了死代码,读代码的人还会误以为 `turnStatus` 是引用稳定的。
const directTurns = useMemo(
() =>
buildDirectChatTurns({
entries: directEntries,
localMessages,
turnRunning: directTurnRunning,
pendingUserItemId,
}),
[directEntries, localMessages, directTurnRunning, pendingUserItemId],
);
// 「这一轮在跑吗」只从这一个派生入口读:原生真相 / 本地命令在飞 / 最新一轮三态。
const turnStatus = useDirectProjectTurnStatus({
turnRunning: directTurnRunning,
turnBusy,
turns: directTurns,
});
const activeTurnStartedAt =
directTurns.find((turn) => turn.state === 'running')?.startedAt ?? 0;
const statusText =
runtimeNotice ||
statusNotice ||
(projectPath
? `已打开:${projectNameFromPath(projectPath)}`
: '请选择工作区');
useEffect(() => {
if (!initialTurn || initialTurn.content.length === 0 || !projectPath)
return;
if (projectPath !== initialTurn.projectPath) return;
// projectId 来自项目清单:清单还没到位时不能先认领,否则首轮需求会被空项目吃掉。
if (!projectId || turnStatus.displayBusy) return;
if (
!claimInitialTurnForPage(initialTurn.projectPath, initialTurn.claimScope)
) {
return;
}
const clientTurnId = createDirectProjectTurnId();
const userItem = directCodexUserItemFromContent(
initialTurn.content,
directCodexConversationMessageId(clientTurnId, 'user'),
);
shouldFollowLatestRef.current = true;
startInitialTurn({
// 首轮那条用户消息按入口原话展示,引用/附件仍按 canonical content 发给运行时。
messageText: directInitialTurnText(initialTurn.content),
clientTurnId,
...(initialTurn.creationType
? { creationType: initialTurn.creationType }
: {}),
userItem,
});
// 首轮需求只由入口 payload、项目身份、清单就绪和回合忙态驱动。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [turnStatus.displayBusy, initialTurn, projectId, projectPath]);
useEffect(() => {
if (!shouldFollowLatestRef.current) return;
const list = messagesRef.current;
if (list) list.scrollTop = list.scrollHeight;
}, [directEntries, localMessages]);
useImperativeHandle(ref, () => ({
announce: (text: string) => {
appendLocalMessage({ role: 'assistant', text, updatedAt: Date.now() });
},
}));
const handleScroll: UIEventHandler<HTMLDivElement> = (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();
}
};
return (
<section
className="project-chat-surface is-direct-codex"
aria-label="陶泥儿项目对话"
>
<div className="project-chat-conversation">
<DirectProjectChatHeader
busy={turnStatus.displayBusy}
statusText={statusText}
onOpenSettings={() => setSettingsOpen(true)}
onRequestGamePublish={onRequestGamePublish}
/>
<DirectProjectConversation
turns={directTurns}
messagesRef={messagesRef}
historyHasMore={historyHasMore}
nativeRunning={turnStatus.nativeRunning}
activeTurnStartedAt={activeTurnStartedAt}
onLoadEarlierHistory={() => void loadEarlierHistory()}
onScroll={handleScroll}
/>
<DirectProjectComposer
assets={assets}
{...(versions ? { versions } : {})}
projectPath={projectPath}
attachmentNotice={attachmentNotice}
attachmentsImporting={attachmentsImporting}
queuedTurns={queuedTurns}
composerNotice={composerNotice}
busy={turnStatus.displayBusy}
turnCancelling={turnCancelling}
onCancelQueuedTurn={cancelQueuedTurn}
onCancelTurn={() => void cancelTurn()}
onReferencePickerOpen={() => {
// 素材标签、改名、删除这些写入发生在资源画布,聊天侧收不到失效事件:
// 打开 `@` 选择器时按需重读聊天自己那份清单,别把旧标签当现状。
if (projectPath) void refresh(projectPath);
}}
onSubmit={submit}
onUploadFiles={uploadFiles}
/>
</div>
{settingsOpen ? (
<DirectProjectSettingsDialog
projectPath={projectPath ?? ''}
currentApprovalLabel={approvalModeLabel(approvalMode)}
onOpenApproval={() => setApprovalOpen(true)}
onClose={() => setSettingsOpen(false)}
/>
) : null}
{settingsOpen && approvalOpen ? (
<ApprovalModeDialog
approvalMode={approvalMode}
notice={approvalNotice}
onSelect={setApprovalMode}
onNotice={setApprovalNotice}
onClose={() => setApprovalOpen(false)}
closeOnEscape={false}
/>
) : null}
</section>
);
}