Compare commits

...

2 Commits

Author SHA1 Message Date
k88936 38ad2ce256 抽离项目开发 DirectProject 聊天模块
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 5m42s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m31s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m9s
Project CI / Backend tests (pull_request) Failing after 30s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m16s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m54s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m32s
Project CI / Frontend tests (pull_request) Failing after 16m6s
Project CI / Native shell tests (pull_request) Successful in 24m45s
Project CI / AI game creator shell web tests (pull_request) Failing after 24m28s
将 DirectProject 表现与 controller 移到 view/project-development/chat

删除运行时 Direct feature flag 与 ProjectSupervisorView 的 Direct 行为分支

迁移 ts-rs 生成契约并删除无意义 generated/index.ts

将钱包入口提升到工作台布局并保留现有 Direct 测试覆盖
2026-09-18 22:06:15 +08:00
k88936 65f8e44a88 拆分 DirectProject 聊天架构文档
记录 DirectProject 独立聊天容器与工作台钱包布局决策

更新实施里程碑、计划和项目上下文,明确 DirectProject 固定属于项目开发工作台

记录代码与生成契约迁移的验收边界
2026-09-18 21:04:23 +08:00
36 changed files with 1421 additions and 1163 deletions
+8
View File
@@ -174,6 +174,14 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
## 项目开发对话(DirectProject
**DirectProject 专属聊天模块**:
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
_Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源
**项目工作台布局**:
承载本地项目的资源工作区、项目级工具和独立聊天产品路径的外层界面;布局拥有跨面板的账户/钱包入口,聊天模块只负责项目对话,不嵌套账户展示。
_Avoid_: 把钱包入口塞进聊天设置、让聊天组件拥有工作台级账户状态
**项目对话历史**:
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
import { $getNodeByKey, type NodeKey } from 'lexical';
import { Paperclip, X } from 'lucide-react';
import type { DirectCodexUserAttachmentReferencePart } from './generated';
import type { DirectCodexUserAttachmentReferencePart } from '../../view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart';
export function AttachmentReferenceChip({
attachment,
@@ -9,8 +9,8 @@ import {
} from 'lexical';
import type { ReactNode } from 'react';
import type { DirectCodexUserAttachmentReferencePart } from '../../view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart';
import { AttachmentReferenceChip } from './AttachmentReferenceChip';
import type { DirectCodexUserAttachmentReferencePart } from './generated';
export type SerializedAttachmentReferenceNode = Spread<
{
@@ -1,5 +1,4 @@
import { Settings2, ShieldCheck, Wallet, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { Settings2, ShieldCheck, X } from 'lucide-react';
import { useState } from 'react';
import {
@@ -17,19 +16,16 @@ import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
* 组成:
* - 「运行配置」打开既有 `RuntimeConfigDialog`(独立浮层,自己有 backdrop);
* - 「操作权限」打开由 `ApprovalModeDialog` 提供的选择面板;
* - 「泥点」直接渲染父级传入的 `walletEntry`(为空则整行不渲染)。
*/
export function ProjectSupervisorSettingsDialog({
projectPath,
currentApprovalLabel,
onOpenApproval,
walletEntry,
onClose,
}: {
projectPath: string;
currentApprovalLabel: string;
onOpenApproval: () => void;
walletEntry?: ReactNode;
onClose: () => void;
}) {
const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false);
@@ -90,18 +86,6 @@ export function ProjectSupervisorSettingsDialog({
</span>
</span>
</button>
{walletEntry ? (
<div className="project-supervisor-settings-row is-static">
<span className="project-supervisor-settings-row-main">
<Wallet size={16} aria-hidden="true" />
<span>
<strong></strong>
<small></small>
</span>
</span>
<div className="game-workbench-chat-wallet">{walletEntry}</div>
</div>
) : null}
</div>
</section>
</div>
File diff suppressed because it is too large Load Diff
@@ -61,6 +61,7 @@ import {
createProjectResourcePreviewRequestId,
createProjectResourcePreviewScopeId,
} from '../../services/projectResourcePreviewTransport';
import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart';
import {
$createAttachmentReferenceNode,
$isAttachmentReferenceNode,
@@ -73,7 +74,6 @@ import {
writeChatPromptPolishReminderDisabled,
} from './chatPromptPolish';
import { ChatPromptPolishReminder } from './ChatPromptPolishReminder';
import type { DirectCodexUserContentPart } from './generated';
import {
$createResourceReferenceNode,
$isResourceReferenceNode,
@@ -5,7 +5,7 @@
* 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。
*/
import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import type { DirectCodexUserItem } from './generated';
import type { DirectCodexUserItem } from '../../view/project-development/chat/generated/DirectCodexUserItem';
import { directCodexContentToPromptText } from './resourceReferences';
/** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */
@@ -1,5 +1,5 @@
import { resolveTauriInvoke } from '../../app/tauri';
import type { DirectCodexUserContentPart } from './generated';
import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart';
/**
* 「不再提醒」偏好存本机 localStorage,不进 manifest、不进后端。
@@ -5,13 +5,11 @@
* 入口转发:前端不再自己抄一份形状,字段增删必须改 Rust。
*/
export type {
DirectThreadConsumeResult,
DirectThreadDeltaKind,
DirectThreadEvent,
DirectThreadFileChange,
DirectThreadHistorySlice,
DirectThreadItem,
DirectThreadRequestKind,
DirectThreadSubscriptionBootstrap,
} from './generated';
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';
@@ -1,15 +0,0 @@
export type { DirectCodexUserContentPart } from './DirectCodexUserContentPart';
export type { DirectCodexUserAttachmentReferencePart } from './DirectCodexUserAttachmentReferencePart';
export type { DirectCodexUserItem } from './DirectCodexUserItem';
export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem';
export type { DirectCodexUserRole } from './DirectCodexUserRole';
export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart';
export type { DirectCodexUserMessageEnvelope } from './DirectCodexUserMessageEnvelope';
export type { DirectThreadConsumeResult } from './DirectThreadConsumeResult';
export type { DirectThreadDeltaKind } from './DirectThreadDeltaKind';
export type { DirectThreadEvent } from './DirectThreadEvent';
export type { DirectThreadFileChange } from './DirectThreadFileChange';
export type { DirectThreadHistorySlice } from './DirectThreadHistorySlice';
export type { DirectThreadItem } from './DirectThreadItem';
export type { DirectThreadRequestKind } from './DirectThreadRequestKind';
export type { DirectThreadSubscriptionBootstrap } from './DirectThreadSubscriptionBootstrap';
@@ -10,10 +10,8 @@ import {
buildGameCreationAppAssetTagLibrary,
type GameCreationAppAssetTagLibraryEntry,
} from '../../../../../packages/shared/src/contracts/gameCreationAppAssetTagLibrary';
import type {
DirectCodexUserContentPart,
DirectCodexUserItem,
} from './generated';
import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart';
import type { DirectCodexUserItem } from '../../view/project-development/chat/generated/DirectCodexUserItem';
export type ResourceReferenceSource =
| 'asset-picker'
+11
View File
@@ -5850,6 +5850,17 @@ iframe.preview-frame {
min-height: 0;
}
.game-workbench-layout-account {
position: absolute;
top: 8px;
right: 12px;
z-index: 4;
}
.game-workbench-layout-account .launcher-account-bar {
position: static;
}
.game-workbench-layout.is-ui-editor {
grid-template-columns: minmax(0, 1fr);
}
@@ -0,0 +1,453 @@
import { ArrowUp, AtSign, Loader2, Settings } from 'lucide-react';
import type { FormEventHandler, RefObject, UIEventHandler } from 'react';
import { Fragment, useEffect, useRef, useState } from 'react';
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,
} 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';
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<ResourceReferenceInputHandle | null>;
composerNotice: string;
conversationMessages: ChatMessage[];
directEntries: DirectChatEntry[];
directTurnRunning: boolean;
hiddenConversationCount: number;
hasEarlierConversationMessages: boolean;
messagesRef: RefObject<HTMLDivElement | null>;
onCancelQueuedTurn: (id: string) => void;
onCancelTurn: () => void;
onChatInputChange: (draft: ChatComposerDraft) => void;
onRemoveAttachment: (index: number) => void;
onScroll: UIEventHandler<HTMLDivElement>;
onShowEarlierMessages: () => void;
onSubmit: FormEventHandler<HTMLFormElement>;
onUploadFiles: (files: readonly File[]) => void;
projectPath: string;
queuedTurns: QueuedChatTurn[];
turnCancelling: boolean;
versions?: GameIterationVersion[];
workspaceStatus: string;
controlBusy: boolean;
};
/**
* DirectProject 的独立表现容器。
*
* 这个组件只处理 DirectProject 的输入框、回合分区、运行中状态和设置表现;
* Supervisor / Design Agent / Planning V2 的运行态不在这里出现。
*/
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,
}: DirectProjectChatViewProps) {
const [settingsOpen, setSettingsOpen] = useState(false);
const [turnUsageNow, setTurnUsageNow] = useState(() => Date.now());
const [voiceNotice, setVoiceNotice] = useState('');
const [approvalOpen, setApprovalOpen] = useState(false);
const [approvalMode, setApprovalMode] = useState<ApprovalMode>('strict');
const [approvalNotice, setApprovalNotice] = useState('');
const [modelReady, setModelReady] = useState(false);
const [modelValidating, setModelValidating] = useState(false);
const modelSelectRef = useRef<ConversationModelSelectHandle>(null);
const modelValidateInFlightRef = useRef(false);
useEffect(() => {
if (!directTurnRunning) return;
setTurnUsageNow(Date.now());
const timer = setInterval(() => setTurnUsageNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [directTurnRunning]);
const directTurns = buildDirectChatTurns({
entries: directEntries,
localMessages: conversationMessages,
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 (
<p
className="message-turn-usage"
data-testid="turn-usage"
data-turn-running="false"
>
{`本轮结束于 ${new Date(endedAt).toLocaleTimeString('zh-CN', {
hour12: false,
})} · 耗时 ${formatTurnDuration(endedAt - turn.startedAt) ?? '0秒'}`}
</p>
);
};
const submitLabel = controlBusy ? '思考中' : '发送';
const submitButton = (
<button
type="submit"
className="project-supervisor-submit-button"
aria-label={submitLabel}
title={submitLabel}
disabled={controlBusy || !modelReady || modelValidating}
>
{controlBusy ? (
<Loader2 size={16} aria-hidden="true" className="animate-spin" />
) : (
<ArrowUp size={16} aria-hidden="true" />
)}
</button>
);
const renderBlock = (
turn: DirectChatTurn,
block: DirectChatBlock,
tone: AgentMessageTone = 'body',
streamingKey: string | null,
) => {
if (block.kind === 'tools') {
return (
<ToolCallGroup
key={block.key}
calls={block.calls}
userSentAt={turn.startedAt}
active={turn.active}
className="message-tool-call"
/>
);
}
if (block.kind === 'reasoning') {
return (
<AgentMessageContent
key={block.key}
as="details"
tone="process"
className="design-agent-reasoning"
aria-label="思考过程"
>
<summary></summary>
<pre>{block.text}</pre>
</AgentMessageContent>
);
}
const role = block.kind === 'user' ? 'user' : 'assistant';
return (
<div key={block.key} className={`message message--${role}`}>
<AgentMessageContent tone={tone}>
<ChatMarkdownMessage
role={role}
text={block.text}
streaming={block.key === streamingKey}
/>
</AgentMessageContent>
{block.kind === 'user' && block.at > 0 ? (
<time
className="message-sent-at"
dateTime={new Date(directMessageTimestamp(block.at)).toISOString()}
title={`发送于 ${new Date(block.at).toLocaleString('zh-CN', { hour12: false })}`}
>
{formatClockTime(block.at)}
</time>
) : null}
</div>
);
};
return (
<section
className="project-supervisor-surface is-direct-codex"
aria-label="陶泥儿项目对话"
>
<div className="project-supervisor-conversation">
<header className="project-supervisor-topbar">
<span className="project-supervisor-topbar-status" aria-live="polite">
<span
className={`project-supervisor-topbar-dot${controlBusy ? ' is-busy' : ''}`}
aria-hidden="true"
/>
{controlBusy
? '陶泥儿正在处理'
: projectWorkspaceStatusForDisplay(workspaceStatus)}
</span>
<button
type="button"
className="project-supervisor-settings-trigger"
aria-label="设置"
title="设置"
onClick={() => setSettingsOpen(true)}
>
<Settings size={16} aria-hidden="true" />
</button>
</header>
<div
ref={messagesRef}
className="message-list project-supervisor-message-list"
aria-label="陶泥儿消息"
onScroll={onScroll}
>
{hiddenConversationCount > 0 || hasEarlierConversationMessages ? (
<button
type="button"
className="message-history-more"
onClick={onShowEarlierMessages}
>
{hiddenConversationCount > 0
? `显示更早 · 还有 ${hiddenConversationCount} 条对话`
: '显示更早的对话'}
</button>
) : null}
{directTurns.map((turn) => {
const streamingKey = turn.active
? ([...turn.process]
.reverse()
.find((block) => block.kind === 'assistant')?.key ?? null)
: null;
return (
<Fragment key={turn.key}>
{turn.users.map((block) =>
renderBlock(turn, block, 'body', streamingKey),
)}
{turn.process.length > 0 ? (
turn.active ? (
turn.process.map((block) =>
renderBlock(turn, block, 'process', streamingKey),
)
) : (
<details
className="message-turn-process"
data-testid="turn-process"
>
<summary></summary>
<div className="message-turn-process-body">
{turn.process.map((block) =>
renderBlock(turn, block, 'process', streamingKey),
)}
</div>
</details>
)
) : null}
{turn.finals.map((block) =>
renderBlock(turn, block, 'body', streamingKey),
)}
{renderTurnUsage(turn)}
</Fragment>
);
})}
</div>
{directTurnRunning ? (
<AgentMessageContent
as="section"
tone="process"
className="project-supervisor-process-card is-active"
aria-label="陶泥儿执行过程"
aria-live="polite"
aria-atomic="false"
role="status"
data-runtime-owned="true"
>
<header>
<span aria-hidden="true" />
<strong></strong>
{activeTurnStartedAt > 0 ? (
<em className="project-supervisor-process-elapsed">
{`已耗时 ${
formatTurnDuration(
Math.max(0, turnUsageNow - activeTurnStartedAt),
) ?? '0秒'
}`}
</em>
) : null}
</header>
</AgentMessageContent>
) : null}
<form
className="project-supervisor-composer is-direct-codex"
onSubmit={(event) => {
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);
}
})();
}}
>
<ComposerTurnQueue
turns={queuedTurns}
assets={chatProjectAssets}
onCancel={onCancelQueuedTurn}
/>
<ComposerPendingAttachments
attachments={attachments}
onRemove={onRemoveAttachment}
/>
<ResourceReferenceInput
ref={composerRef}
ariaLabel="陶泥儿对话内容"
activeVersionId={activeVersionId}
versions={versions}
assets={chatProjectAssets}
projectPath={projectPath}
disabled={modelValidating}
rows={3}
value={chatInput}
references={chatReferences}
placeholder="描述你的想法,或 @ 引用素材"
onChange={onChatInputChange}
/>
<div className="project-supervisor-composer-controls">
<div className="project-supervisor-composer-controls-left">
<button
type="button"
className="project-supervisor-reference-trigger"
aria-label="插入素材引用"
title="插入素材引用"
disabled={controlBusy}
onClick={() => composerRef.current?.openPicker()}
>
<AtSign size={15} aria-hidden="true" />
</button>
<ComposerAttachmentMenu
disabled={controlBusy || modelValidating}
onPickFiles={onUploadFiles}
onOpenReferencePicker={() => composerRef.current?.openPicker()}
/>
</div>
<div className="project-supervisor-composer-controls-right">
<ComposerReasoningEffortSelect disabled={controlBusy} />
<ConversationModelSelect
ref={modelSelectRef}
disabled={controlBusy}
onReady={setModelReady}
projectPath={projectPath}
/>
<ComposerVoiceButton
disabled={controlBusy || modelValidating}
onTranscript={(text) => composerRef.current?.insertText(text)}
onNotice={setVoiceNotice}
/>
{controlBusy ? (
<ComposerStopButton
cancelling={turnCancelling}
onCancel={onCancelTurn}
/>
) : (
submitButton
)}
</div>
</div>
{composerNotice || attachmentNotice || voiceNotice ? (
<p className="project-supervisor-composer-notice" role="status">
{composerNotice || attachmentNotice || voiceNotice}
</p>
) : null}
</form>
</div>
{settingsOpen ? (
<ProjectSupervisorSettingsDialog
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>
);
}

Some files were not shown because too many files have changed in this diff Show More