抽离 DirectProject 独立聊天容器并删除工作台壳 Direct 状态

- 新增 view/project-development/chat 模块:DirectProjectChatView 自己持有项目清单订阅、首屏锚点、历史分页、发送队列、附件、终止、草稿与本地消息
- DirectProjectChatView 入参收窄为项目路径、入口首轮需求(canonical content[])和两条权限门,Direct 状态不再穿过 App.tsx
- 线程订阅与聊天 reducer 提成 useDirectThreadChatSubscription:subscribe/consume/notify 单飞循环、订阅欠账、SUBSCRIPTION_EXPIRED 重订与 DirectThreadChatState
- chat/components 按「一个组件一个目录、逻辑与表现同目录」重组(ChatHeader、Composer、Conversation、SettingsDialog、ToolCallGroup)
- DirectProjectChatView 拆成聊天头、会话区、回合、输入盒四个组件
- App.tsx 删除 Direct 专属 state/ref/effect/handler 与 directCodex 分支,首轮需求按 canonical content 交给聊天
- 工作台级动作(运行本地预览等)经 DirectProjectChatHandle.announce 交给聊天自己的本地消息流
- Direct 首轮认领记录提成 app/initialSupervisorMessageClaims,供各入口共用
- Rust 侧 Direct 契约 ts-rs 导出目录改到 chat/generated
- 补齐 direct_codex_user_item/wire.rs 既有的 rustfmt 空格漂移
- 同步 ADR、实施计划、decision-log、pitfalls 与 Direct 技术文档
This commit is contained in:
2026-09-19 15:08:31 +08:00
parent 38ad2ce256
commit 99be1d76fe
44 changed files with 2202 additions and 1722 deletions
@@ -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<DirectCodexUserContentPart>,
@@ -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)]
@@ -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;
@@ -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<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 DirectThreadHistorySlice {
/// 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。
pub(crate) items: Vec<DirectThreadItem>,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
/**
* 入口首轮需求的认领记录。
*
* 首页/立项链路把同一条首轮需求带进工作台,Supervisor、Design Agent、Planning V2 与
* DirectProject 是同一页面上的不同入口;认领按页面保存并按「项目路径 + claimScope」
* 去重,保证同一条需求只被一个入口发出。
*/
const initialSupervisorMessageClaimsByPage = new WeakMap<Window, Set<string>>();
export function claimInitialSupervisorMessageForPage(
projectPath: string,
scope = '',
) {
let claimedProjectPaths = initialSupervisorMessageClaimsByPage.get(window);
if (!claimedProjectPaths) {
claimedProjectPaths = new Set<string>();
initialSupervisorMessageClaimsByPage.set(window, claimedProjectPaths);
}
const claimKey = `${projectPath}\u0000${scope}`;
if (claimedProjectPaths.has(claimKey)) {
return false;
}
claimedProjectPaths.add(claimKey);
return true;
}
@@ -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,
@@ -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';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
import { Settings } from 'lucide-react';
/**
* DirectProject 聊天头:运行状态与设置入口。
*
* 状态文案由容器决定(忙态优先),这里只负责渲染;DirectProject 不再把工作台壳的
* 全局状态文案搬进来。
*/
export function DirectProjectChatHeader({
busy,
statusText,
onOpenSettings,
}: {
busy: boolean;
statusText: string;
onOpenSettings: () => void;
}) {
return (
<header className="project-supervisor-topbar">
<span className="project-supervisor-topbar-status" aria-live="polite">
<span
className={`project-supervisor-topbar-dot${busy ? ' is-busy' : ''}`}
aria-hidden="true"
/>
{busy ? '陶泥儿正在处理' : statusText}
</span>
<button
type="button"
className="project-supervisor-settings-trigger"
aria-label="设置"
title="设置"
onClick={onOpenSettings}
>
<Settings size={16} aria-hidden="true" />
</button>
</header>
);
}
@@ -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;
@@ -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<HTMLFormElement>;
onUploadFiles: (files: readonly File[]) => void;
}) {
const composerRef = useRef<ResourceReferenceInputHandle | null>(null);
const modelSelectRef = useRef<ConversationModelSelectHandle>(null);
const modelValidateInFlightRef = useRef(false);
const [voiceNotice, setVoiceNotice] = useState('');
const [modelReady, setModelReady] = useState(false);
const [modelValidating, setModelValidating] = useState(false);
const submitLabel = busy ? '思考中' : '发送';
const submitButton = (
<button
type="submit"
className="project-supervisor-submit-button"
aria-label={submitLabel}
title={submitLabel}
disabled={busy || !modelReady || modelValidating}
>
{busy ? (
<Loader2 size={16} aria-hidden="true" className="animate-spin" />
) : (
<ArrowUp size={16} aria-hidden="true" />
)}
</button>
);
return (
<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={assets}
onCancel={onCancelQueuedTurn}
/>
<ComposerPendingAttachments
attachments={attachments}
onRemove={onRemoveAttachment}
/>
<ResourceReferenceInput
ref={composerRef}
ariaLabel="陶泥儿对话内容"
versions={versions}
assets={assets}
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={busy}
onClick={() => composerRef.current?.openPicker()}
>
<AtSign size={15} aria-hidden="true" />
</button>
<ComposerAttachmentMenu
disabled={busy || modelValidating}
onPickFiles={(files) => void onUploadFiles(files)}
onOpenReferencePicker={() => composerRef.current?.openPicker()}
/>
</div>
<div className="project-supervisor-composer-controls-right">
<ComposerReasoningEffortSelect disabled={busy} />
<ConversationModelSelect
ref={modelSelectRef}
disabled={busy}
onReady={setModelReady}
projectPath={projectPath ?? ''}
/>
<ComposerVoiceButton
disabled={busy || modelValidating}
onTranscript={(text) => composerRef.current?.insertText(text)}
onNotice={setVoiceNotice}
/>
{busy ? (
<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>
);
}
@@ -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;
@@ -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<HTMLDivElement | null>;
historyHasMore: boolean;
running: boolean;
activeTurnStartedAt: number;
turnUsageNow: number;
onLoadEarlierHistory: () => void;
onScroll: UIEventHandler<HTMLDivElement>;
}) {
return (
<>
<div
ref={messagesRef}
className="message-list project-supervisor-message-list"
aria-label="陶泥儿消息"
onScroll={onScroll}
>
{historyHasMore ? (
<button
type="button"
className="message-history-more"
onClick={onLoadEarlierHistory}
>
</button>
) : null}
{turns.map((turn) => (
<DirectProjectTurn key={turn.key} turn={turn} />
))}
</div>
{running ? (
<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}
</>
);
}
@@ -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 (
<Fragment>
{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),
)}
<DirectProjectTurnUsage turn={turn} />
</Fragment>
);
}
function DirectProjectTurnUsage({ turn }: { 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>
);
}
function 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>
);
}
@@ -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({
<header>
<div>
<h2 id="project-supervisor-settings-title"></h2>
<small></small>
<small></small>
</div>
<button
type="button"
@@ -8,8 +8,8 @@ import {
} from 'lucide-react';
import { useEffect, useId, useState } from 'react';
import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent';
import type { DirectChatToolCard } from './directThreadChat';
import { AgentMessageContent } from '../../../../../../../../packages/shared/src/components/AgentMessageContent';
import type { DirectChatToolCard } from '../../conversation/directThreadChat';
import {
formatToolCallDuration,
formatTurnDuration,
@@ -1,5 +1,5 @@
import type { GameCreatorDirectToolCallKind } from '../../app/types';
import type { DirectChatToolCard } from './directThreadChat';
import type { GameCreatorDirectToolCallKind } from '../../../../../app/types';
import type { DirectChatToolCard } from '../../conversation/directThreadChat';
/**
* /
@@ -0,0 +1,133 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { resolveTauriInvoke } from '../../../../app/tauri';
import { projectPathsMatchForInvalidation } from '../../../../features/project-summary/projectPath';
import {
canSubscribeTauriEvents,
subscribeTauriEvent,
} from '../../../../services/tauriEventSubscription';
type GameCreationAppAssetManifestEntry =
import('../../../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry;
type GameCreationAppManifest =
import('../../../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppManifest;
type GameIterationVersion =
import('../../../../../../../packages/shared/src/contracts/gameCreationApp').GameIterationVersion;
const NO_ASSETS: GameCreationAppAssetManifestEntry[] = [];
const NO_VERSIONS: GameIterationVersion[] = [];
/**
* `@` 引用与版本选择能看到的那部分素材:项目文件之外的本地产物,`.agent/` 下的内部文件
* 不出现在引用面板里。
*/
export function directProjectChatAssets(
manifest: GameCreationAppManifest | null,
): GameCreationAppAssetManifestEntry[] {
if (!manifest) return NO_ASSETS;
return manifest.assets.filter(
(asset) => asset.localPath && !asset.localPath.startsWith('.agent/'),
);
}
/**
* DirectProject 自己的项目清单订阅。
*
* 素材与版本(`@` 引用、模型/版本选择)和发起回合需要的 `projectId` 都来自这份清单,
* 清单失效事件也由聊天自己订阅;工作台壳不再把清单状态传进聊天。
*/
export function useDirectProjectManifest(projectPath: string | null) {
const [manifest, setManifest] = useState<GameCreationAppManifest | null>(
null,
);
const projectPathRef = useRef(projectPath);
projectPathRef.current = projectPath;
const mountedRef = useRef(true);
const inFlightRef = useRef(new Map<string, Promise<void>>());
useEffect(() => {
mountedRef.current = true;
const inFlight = inFlightRef.current;
return () => {
mountedRef.current = false;
inFlight.clear();
};
}, []);
const refresh = useCallback(async (nextProjectPath: string) => {
const invoke = resolveTauriInvoke();
if (!invoke || !nextProjectPath) return;
const inFlight = inFlightRef.current;
const started = inFlight.get(nextProjectPath);
if (started) return started;
const task = (async () => {
try {
const nextManifest = await invoke<GameCreationAppManifest>(
'get_local_game_manifest',
{ projectPath: nextProjectPath },
);
if (!mountedRef.current || projectPathRef.current !== nextProjectPath) {
return;
}
setManifest(nextManifest);
} catch {
// 清单读不到不能阻塞聊天:`@` 引用退化为空,回合仍以项目对话为准。
} finally {
inFlight.delete(nextProjectPath);
}
})();
inFlight.set(nextProjectPath, task);
return task;
}, []);
useEffect(() => {
if (!projectPath) {
setManifest(null);
return;
}
void refresh(projectPath);
}, [projectPath, refresh]);
useEffect(() => {
if (!projectPath || !canSubscribeTauriEvents()) return;
let disposed = false;
let unsubscribe: (() => void) | null = null;
void subscribeTauriEvent<{ projectPath?: string }>(
'game-creator-manifest-invalidated',
(event) => {
const activeProjectPath = projectPathRef.current;
if (
!activeProjectPath ||
!projectPathsMatchForInvalidation(
event.payload.projectPath ?? '',
activeProjectPath,
)
) {
return;
}
void refresh(activeProjectPath);
},
)
.then((release) => {
if (disposed) {
release();
return;
}
unsubscribe = release;
})
.catch(() => {
// 拿不到订阅通道时清单只在项目打开和回合结束时刷新。
});
return () => {
disposed = true;
unsubscribe?.();
};
}, [projectPath, refresh]);
return {
assets: directProjectChatAssets(manifest),
projectId: manifest?.projectId ?? null,
refresh,
versions: manifest?.versions ?? NO_VERSIONS,
};
}
@@ -0,0 +1,192 @@
import {
type MutableRefObject,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { resolveTauriInvoke } from '../../../../app/tauri';
import {
canSubscribeTauriEvents,
subscribeTauriEvent,
} from '../../../../services/tauriEventSubscription';
import {
applyDirectThreadConsumeResult,
type DirectChatEntry,
type DirectThreadChatState,
emptyDirectThreadChatState,
mergeDirectHistoryItems,
resolveDirectThreadBootstrap,
selectDirectChatEntries,
} from '../conversation/directThreadChat';
import type { DirectThreadConsumeResult } from '../generated/DirectThreadConsumeResult';
import type { DirectThreadItem } from '../generated/DirectThreadItem';
import type { DirectThreadSubscriptionBootstrap } from '../generated/DirectThreadSubscriptionBootstrap';
import {
type DirectHistoryAnchorGate,
reuseOrOpenDirectHistoryAnchorGate,
} from '../history/directHistoryAnchorGate';
export type DirectThreadChatSubscription = {
/** 聊天 reducer 的唯一状态:历史条目 + 运行态条目 + 回合忙态。 */
state: DirectThreadChatState;
/** 聊天投影结果:历史顺序 + 运行态覆盖。 */
entries: DirectChatEntry[];
turnRunning: boolean;
/** 订阅回执锚点闸门:首屏历史读取靠它拿到 `lastCompletedItemId`。 */
anchorGateRef: MutableRefObject<DirectHistoryAnchorGate | null>;
/** 历史切片并入同一个 reducer:条目只有这一份事实源。 */
mergeHistoryItems: (items: readonly DirectThreadItem[]) => void;
/** 终止成功(`released`)时手动放掉回合占用:订阅可能要等下一个事件才知道。 */
markTurnStopped: () => void;
};
/**
* DirectProject 线程订阅:`subscribe → consume → notify` 单飞循环与聊天 reducer 状态。
*
* 这一层是 DirectProject 运行态的唯一事实源:所有条目(历史 + 运行态)都由它持有,
* 消费回执先于订阅回执到达时记一笔欠账,回执到达后补一次 consume。上层只读 `entries`
* 和 `turnRunning`,历史分页通过 `mergeHistoryItems` 并入同一个 reducer。
*/
export function useDirectThreadChatSubscription({
enabled,
projectPath,
}: {
enabled: boolean;
projectPath: string | null;
}): DirectThreadChatSubscription {
const [state, setState] = useState<DirectThreadChatState>(() =>
emptyDirectThreadChatState(),
);
const anchorGateRef = useRef<DirectHistoryAnchorGate | null>(null);
// 项目切换时聊天容器整体换一份状态:上一个项目的条目不能落进新项目。
useEffect(() => {
setState(emptyDirectThreadChatState());
}, [projectPath]);
useEffect(() => {
if (!enabled) return;
const directInvoke = resolveTauriInvoke();
const anchorGate = reuseOrOpenDirectHistoryAnchorGate(
anchorGateRef.current,
projectPath ?? '',
);
anchorGateRef.current = anchorGate;
if (!projectPath || !directInvoke || !canSubscribeTauriEvents()) {
anchorGate.settle(null);
return;
}
const invoke = directInvoke;
let disposed = false;
let cleanup: (() => void) | null = null;
let subscriptionId: string | null = null;
let consuming = false;
let consumeAgain = false;
let notifyBeforeSubscription = false;
async function consume() {
if (!subscriptionId || disposed) return;
if (consuming) {
consumeAgain = true;
return;
}
consuming = true;
try {
do {
consumeAgain = false;
const result = await invoke<DirectThreadConsumeResult>(
'consume_direct_project_thread',
{ subscriptionId },
);
if (disposed) return;
setState((current) =>
applyDirectThreadConsumeResult(current, result),
);
} while (consumeAgain && !disposed);
} catch (error) {
if (!disposed && String(error).includes('SUBSCRIPTION_EXPIRED')) {
subscriptionId = null;
try {
await bootstrap();
} catch {
// 下一次通知再重试。
}
}
} finally {
consuming = false;
}
}
async function bootstrap() {
const result = await invoke<DirectThreadSubscriptionBootstrap>(
'subscribe_direct_project_thread',
{ projectPath },
);
if (disposed) {
anchorGate.settle(null);
return;
}
subscriptionId = result.subscriptionId;
anchorGate.settle(result.lastCompletedItemId ?? null);
setState((current) => resolveDirectThreadBootstrap(current, result));
if (notifyBeforeSubscription) {
notifyBeforeSubscription = false;
void consume();
}
}
const setup = async () => {
try {
const unlisten = await subscribeTauriEvent<{ subscriptionId: string }>(
'game-creator-direct-thread-notify',
(event) => {
if (event.payload.subscriptionId === subscriptionId) {
void consume();
} else if (!subscriptionId) {
notifyBeforeSubscription = true;
}
},
);
if (disposed) {
unlisten();
return;
}
cleanup = unlisten;
await bootstrap();
} catch {
anchorGate.settle(null);
}
};
void setup();
return () => {
disposed = true;
cleanup?.();
anchorGate.settle(null);
};
}, [enabled, projectPath]);
const mergeHistoryItems = useMemo(
() => (items: readonly DirectThreadItem[]) => {
setState((current) => mergeDirectHistoryItems(current, items));
},
[],
);
const markTurnStopped = useMemo(
() => () => {
setState((current) => ({ ...current, turnRunning: false }));
},
[],
);
const entries = useMemo(() => selectDirectChatEntries(state), [state]);
return {
state,
entries,
turnRunning: state.turnRunning,
anchorGateRef,
mergeHistoryItems,
markTurnStopped,
};
}
@@ -0,0 +1,92 @@
import type { ChatMessage } from '../../../../app/types';
import type { HomeCreationType } from '../../../home';
import type { DirectCodexUserItem } from '../generated/DirectCodexUserItem';
export 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 客户端回合正在运行';
/**
* 一轮 DirectProject 回合的完整入参。
*
* 这份入参在权限确认重跑时整体复用:漏掉任何一项(尤其是 canonical user item 里的
* `@` 引用与附件引用)都会让用户在确认之后拿到另一轮内容。
*/
export type DirectProjectTurnInput = {
prompt: string;
clientTurnId: string;
creationType?: HomeCreationType | null;
/**
* 这一轮的 canonical 用户条目:文本、`@` 素材引用、技能引用和附件引用都在同一份
* content 里内联,附件不再作为并排字段单独传递。
*/
userItem: DirectCodexUserItem;
/** 界面展示的这段话:默认按 canonical content 展开,首页首轮需求按用户原话展示。 */
messageText?: string;
/** 本轮已经通过项目写权限检查:确认后重跑时不再二次确认。 */
directPolicyChecked?: boolean;
};
/** 权限确认后重跑同一轮:只把「已检查」标记补上,其余入参原样带过去。 */
export function directCodexPolicyRetryInput(
input: DirectProjectTurnInput,
): DirectProjectTurnInput {
return { ...input, directPolicyChecked: true };
}
let directProjectTurnSequence = 0;
/**
* 一轮 DirectProject 回合的 client turn id。
*
* 界面重放、权限确认重跑和消息 id 都以它做关联键;没有 `crypto.randomUUID` 的
* WebView 回落到「时间戳 + 页内序号」,仍满足 Rust 侧的 id 形状要求。
*/
export function createDirectProjectTurnId() {
directProjectTurnSequence += 1;
let randomId = '';
try {
randomId = globalThis.crypto?.randomUUID?.().trim() ?? '';
} catch {
// WebView 没有 crypto 时使用时间戳和页内序号。
}
if (/^[a-z0-9][a-z0-9-]{5,159}$/iu.test(randomId)) return randomId;
return `${Date.now().toString(36)}-${directProjectTurnSequence.toString(36)}`;
}
export function directCodexConversationMessageId(
turnId: string,
role: ChatMessage['role'],
) {
return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`;
}
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('已终止本次回合');
}
@@ -0,0 +1,48 @@
import {
currentPlatformSessionGeneration,
requestPlatformSessionRefresh,
} from '../../../../services/platformSession';
/**
* 平台 access token 很短命。DirectProject 一个回合可能横跨图片生成、构建和浏览器验证,
* 所以回合运行期间由客户端保持原生会话新鲜;`platformSession.ts` 的 singleflight
* 会把这里的刷新与 401 触发的刷新合并成同一次请求。
*/
export 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('登录已失效')
);
}
/**
* 跑一轮 DirectProject 请求:只有 401/登录失效才刷新会话重试一次,其它错误原样抛出。
*
* 刷新期间账号代际变化(换号、登出)时必须放弃重试:带着旧身份的请求重放会把上一账号
* 的回合打进新账号的对话历史。
*/
export async function withDirectCodexSessionRefresh<T>(
operation: () => Promise<T>,
) {
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();
}
}
@@ -1,4 +1,4 @@
import type { LauncherImportedAttachment } from '../../app/types';
import type { LauncherImportedAttachment } from '../../../../app/types';
export type DirectCodexTurnAttachment = {
name: string;
@@ -36,3 +36,20 @@ export function toDirectCodexTurnAttachments(
return attachment;
});
}
/**
* canonical user content
* Rust
*/
export function directCodexAttachmentContentParts(
items: readonly DirectCodexTurnAttachment[],
) {
return items.map((attachment) => ({
type: 'agc_attachment_reference' as const,
name: attachment.name,
mediaType: attachment.mediaType,
size: attachment.size ?? 0,
localPath: attachment.localPath ?? '',
status: attachment.status ?? (attachment.localPath ? 'imported' : 'failed'),
}));
}
@@ -6,14 +6,12 @@
* `turn.started` / `turn.completed` "是否还在跑"
*/
import type { GameCreatorDirectToolCall } from '../../app/types';
import type {
DirectThreadConsumeResult,
DirectThreadEvent,
DirectThreadHistorySlice,
DirectThreadItem,
DirectThreadSubscriptionBootstrap,
} from './directThreadEvents';
import type { GameCreatorDirectToolCall } from '../../../../app/types';
import type { DirectThreadConsumeResult } from '../generated/DirectThreadConsumeResult';
import type { DirectThreadEvent } from '../generated/DirectThreadEvent';
import type { DirectThreadHistorySlice } from '../generated/DirectThreadHistorySlice';
import type { DirectThreadItem } from '../generated/DirectThreadItem';
import type { DirectThreadSubscriptionBootstrap } from '../generated/DirectThreadSubscriptionBootstrap';
import { projectDirectThreadItem } from './directThreadItemProjection';
export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool';

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