Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx
T
suzmii f4ada50612
Project CI / Backend tests (pull_request) Failing after 17s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m50s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m19s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m43s
Project CI / Repository checks (pull_request) Failing after 32s
Project CI / Frontend tests (pull_request) Successful in 2m35s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m23s
Project CI / Native shell tests (pull_request) Successful in 6m26s
自审加固:注册表按 ref 转发注册、插入成功语义与失败留痕对齐
ActiveChatComposerHandle.insertReferences 改为返回 boolean:由句柄回答这一批有没有真的递到输入区,注册表不再用「有句柄」冒充「插进去了」
insertChatReferences 增加第三种 false(句柄报落空),且只有插入成功才 focus,失败不抢焦点
DirectProjectChatView 改为注册「按 ref 转发」的句柄,注册时不再读 composerHandleRef.current,去掉对父子 effect 顺序的隐式依赖(内层输入区重挂载也不会留下死句柄)
DirectProjectComposer 与 PlanningChatView 的转发句柄同步返回 boolean
App.tsx 的 dev 失败线索文案改为「没有可用的聊天输入区(未挂载或已卸载)」,覆盖句柄落空这一种
新增 tests/activeChatComposer.test.ts:钉住注册表合同(空批次 / 无输入区 / 句柄报落空不聚焦 / 注销身份校验);反向证伪:去掉身份校验后该用例变红
同步 docs/【功能说明】AGC聊天素材引用-2026-09-08.md 与 shared-memory 决策记录
2026-10-04 00:12:24 +08:00

328 lines
13 KiB
TypeScript

import type { Ref } from 'react';
import {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { claimInitialTurnForPage } from '../../../app/initialTurnClaims';
import type { PendingUiConfirmation } from '../../../app/types';
import { projectNameFromPath } from '../../../features/agent-runtime';
import { registerActiveChatComposer } from '../../../features/project-workspace/activeChatComposer';
import { userItemFromContent } from '../../../features/project-workspace/resourceReferences';
import { type ApprovalMode, approvalModeLabel } from '../approvalMode';
import { ApprovalModeDialog } from '../ApprovalModeDialog';
import { DirectProjectChatHeader } from './components/DirectProjectChatHeader/DirectProjectChatHeader';
import {
DirectProjectComposer,
type DirectProjectComposerHandle,
} 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 { UserContentPart } from './generated/UserContentPart';
/**
* DirectProject 对外只有这一条命令式出口:工作台壳跑完运行/预览这类工作台级动作后,
* 把结果告诉用户。聊天容器持有会话真相,壳不持有聊天消息,只把这句话交给聊天的本地
* 消息流。
*/
export type DirectProjectChatHandle = {
announce: (text: string) => void;
};
/**
* 首页(或立项链路)带过来的首轮需求:项目打开后由聊天自己认领并发出。
*
* 认领记录按「项目路径 + claimScope」与 Design / Planning 入口共用一份
* (见 `app/initialTurnClaims`),所以同一条首轮需求不会被两个入口各发一次。
*/
export type DirectProjectInitialTurn = {
projectPath: string;
/**
* 入口首轮需求的 canonical 内容:文本、`@` 素材引用和附件引用都内联在这一份 content
* 里,和 Composer 提交的回合保持同一种形状。
*/
content: UserContentPart[];
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;
/**
* 工作台壳入队的项目权限确认:聊天只展示并转发确认/取消,
* 策略读取、拒绝判定和确认后的动作仍由工作台壳持有。
*/
pendingConfirmation?: PendingUiConfirmation | null;
onConfirmConfirmation?: () => void;
onCancelConfirmation?: () => void;
/** 工作台壳把运行/预览这类动作结果交给聊天的出口。 */
ref?: Ref<DirectProjectChatHandle>;
};
export function DirectProjectChatView({
onRequestGamePublish,
onConfirmConfirmation,
onCancelConfirmation,
pendingConfirmation = null,
projectPath,
initialTurn = null,
ensureConversationReadAllowed,
ensureConversationWriteAllowed,
ref,
}: DirectProjectChatViewProps) {
const { assets, projectId, refresh, versions } =
useDirectProjectManifest(projectPath);
const composerHandleRef = useRef<DirectProjectComposerHandle | null>(null);
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 handleRuntimeError = useCallback((message: string) => {
setRuntimeNotice(message);
}, []);
const chat = useDirectProjectChatController({
enabled: Boolean(projectPath),
ensureConversationReadAllowed,
ensureConversationWriteAllowed,
onRuntimeError: handleRuntimeError,
projectId,
projectPath,
refreshManifest: refresh,
});
const {
appendLocalMessage,
attachmentNotice,
attachmentsImporting,
cancelPendingTurn,
cancelTurn,
commandInFlight,
composerNotice,
directEntries,
directTurnRunning,
directTurnStartedAt,
historyError,
historyHasMore,
historyLoading,
loadEarlierHistory,
retryEarlierHistory,
localMessages,
pendingTurns,
startInitialTurn,
statusNotice,
submit,
turnCancelling,
uploadFiles,
} = chat;
// 这份投影要参与下游 memo 的判据,必须自己先缓存:不缓存的话每次渲染都是一份新数组,
// `useDirectProjectTurnStatus` 里以 `turns` 为判据的 `useMemo` 永远命中不了,那层 memo
// 就成了死代码,读代码的人还会误以为 `turnStatus` 是引用稳定的。
const directTurns = useMemo(
() =>
buildDirectChatTurns({
entries: directEntries,
localMessages,
turnRunning: directTurnRunning,
// 运行中回合的起点只在这里给:`turn.started.at` 是宿主的时间,条目上要等收口才盖。
turnStartedAt: directTurnStartedAt,
}),
[directEntries, localMessages, directTurnRunning, directTurnStartedAt],
);
// 「这一轮在跑吗」只从这一个派生入口读:原生真相 / IPC 在飞 / 待发消息条数 / 最新一轮两态。
const turnStatus = useDirectProjectTurnStatus({
turnRunning: directTurnRunning,
commandInFlight,
pendingTurns,
turns: directTurns,
});
// 状态条的起点只认**未结束**的最新一轮:只有它才拿得到本轮的 `turn.started.at`(运行中读实时值)。
// 只可能是最后一轮:原生 `turnRunning` 只赋给最新一轮。入队窗口里还没有这一轮的条目,
// 于是这里是 0,卡片只报"正在处理"、不读秒(宿主开始事件一到就开始读秒)。
const latestTurn = directTurns.at(-1) ?? null;
const activeTurnStartedAt =
latestTurn && latestTurn.state !== 'finished' ? latestTurn.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 = userItemFromContent(
initialTurn.content,
directCodexConversationMessageId(clientTurnId, 'user'),
);
startInitialTurn({
clientTurnId,
...(initialTurn.creationType
? { creationType: initialTurn.creationType }
: {}),
userItem,
});
// 首轮需求只由入口 payload、项目身份、清单就绪和回合忙态驱动。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [turnStatus.displayBusy, initialTurn, projectId, projectPath]);
useImperativeHandle(ref, () => ({
announce: (text: string) => {
appendLocalMessage({ role: 'assistant', text, updatedAt: Date.now() });
},
}));
/*
画布的「引用」与拖拽批量引用是 window 事件,唯一消费者在 `App.tsx`;它只认注册表里
**当前挂载**的输入区。普通项目固定渲染这里、策划链路渲染 `PlanningChatView`,两条链路
互斥,所以同一时刻注册表里只有一个句柄。
注册的是一个**按 ref 转发**的句柄、且不依赖「输入区此刻已挂上」:注册只表达「这个聊天面
在用」,插入成功与否由转发那一刻的实际情况回答(`DirectProjectComposerHandle.insertReferences`
返回 boolean)。这样挂载顺序、子组件重挂载都不会让注册表漏挂或指向死句柄。
*/
useEffect(
() =>
registerActiveChatComposer({
insertReferences: (references) => {
const handle = composerHandleRef.current;
if (!handle) return false;
return handle.insertReferences(references);
},
focus: () => composerHandleRef.current?.focus(),
}),
[],
);
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
conversationKey={projectPath ?? ''}
turns={directTurns}
historyHasMore={historyHasMore}
historyLoading={historyLoading}
historyError={historyError}
turnInFlight={turnStatus.displayBusy}
activeTurnStartedAt={activeTurnStartedAt}
onLoadEarlierHistory={() => void loadEarlierHistory()}
onRetryEarlierHistory={() => void retryEarlierHistory()}
/>
{pendingConfirmation &&
onConfirmConfirmation &&
onCancelConfirmation ? (
<div className="pending-command">
<span>
{pendingConfirmation.commandId}
<small>{pendingConfirmation.detail}</small>
</span>
<button type="button" onClick={onCancelConfirmation}>
取消
</button>
<button type="button" onClick={onConfirmConfirmation}>
确认
</button>
</div>
) : null}
<DirectProjectComposer
ref={composerHandleRef}
assets={assets}
{...(versions ? { versions } : {})}
projectPath={projectPath}
attachmentNotice={attachmentNotice}
attachmentsImporting={attachmentsImporting}
pendingTurns={pendingTurns}
composerNotice={composerNotice}
busy={turnStatus.displayBusy}
canStopTurn={turnStatus.canStopTurn}
turnCancelling={turnCancelling}
onCancelPendingTurn={(clientTurnId) =>
void cancelPendingTurn(clientTurnId)
}
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>
);
}