抽离 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:
@@ -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';
|
||||
+217
-389
File diff suppressed because it is too large
Load Diff
+38
@@ -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>
|
||||
);
|
||||
}
|
||||
+9
-9
@@ -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;
|
||||
+187
@@ -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>
|
||||
);
|
||||
}
|
||||
+3
-3
@@ -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;
|
||||
+81
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+127
@@ -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
-4
@@ -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"
|
||||
+2
-2
@@ -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,
|
||||
+2
-2
@@ -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';
|
||||
|
||||
/**
|
||||
* 工具调用折叠块的纯文案计算:汇总 / 行文案。
|
||||
+764
File diff suppressed because it is too large
Load Diff
+133
@@ -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,
|
||||
};
|
||||
}
|
||||
+192
@@ -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,
|
||||
};
|
||||
}
|
||||
+92
@@ -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('已终止本次回合');
|
||||
}
|
||||
+48
@@ -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();
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -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
-8
@@ -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
Reference in New Issue
Block a user