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
# Conflicts: # docs/project-memory/shared-memory/decision-log.md
287 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|