8701f43f75
- 删除渲染层传输层:services/clientApi.ts、clientHttp 的 fetchClientHttp 与超时封装整体退场,账户、认证、素材直传、错误上报与游戏分发发布改走 Rust typed command。 - 新增宿主封装:accountHost、editorAssetHost、platformSessionRetry、agentRuntimeErrorDetail 与 features/app-shell/workspacePreferences;渲染层不再读写 access token / refresh cookie,localStorage 只剩测试态旧键迁移 shim。 - 修复 DirectProject 失败文案 v2 映射与「查看详情」按需读取,画布标签面板高度夹紧、框选每帧重算、文档预览入口与场景标题栏 key、资源筛选面板样式。 - 补齐与调整回归:accountHost、agentRuntimeErrorDetail、clientAuthHost、clientHttp、platformSession、platformSessionRetry、resourceFilterPanelStyle、directThreadChatSubscription 等定向用例,并清理测试 harness 中无调用方的残留。
390 lines
15 KiB
TypeScript
390 lines
15 KiB
TypeScript
import type { UIEventHandler } from 'react';
|
|
import type { Ref } from 'react';
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
|
|
import { AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD } from '../../../app/constants';
|
|
import { claimInitialTurnForPage } from '../../../app/initialTurnClaims';
|
|
import { resolveTauriInvoke } from '../../../app/tauri';
|
|
import type { PendingUiConfirmation } from '../../../app/types';
|
|
import { projectNameFromPath } from '../../../features/agent-runtime';
|
|
import { directCodexUserItemFromContent } from '../../../features/project-workspace/resourceReferences';
|
|
import { readAgentRuntimeErrorDetailByRef } from '../../../services/agentRuntimeErrorDetail';
|
|
import { type ApprovalMode, approvalModeLabel } from '../approvalMode';
|
|
import { ApprovalModeDialog } from '../ApprovalModeDialog';
|
|
import { DirectProjectChatHeader } from './components/DirectProjectChatHeader/DirectProjectChatHeader';
|
|
import { DirectProjectComposer } from './components/DirectProjectComposer/DirectProjectComposer';
|
|
import { DirectProjectConversation } from './components/DirectProjectConversation/DirectProjectConversation';
|
|
import { DirectProjectSettingsDialog } from './components/DirectProjectSettingsDialog/DirectProjectSettingsDialog';
|
|
import {
|
|
type DirectProjectConversationReadGate,
|
|
type DirectProjectConversationWriteGate,
|
|
useDirectProjectChatController,
|
|
} from './controller/useDirectProjectChatController';
|
|
import { useDirectProjectManifest } from './controller/useDirectProjectManifest';
|
|
import { useDirectProjectTurnStatus } from './controller/useDirectProjectTurnStatus';
|
|
import {
|
|
createDirectProjectTurnId,
|
|
directCodexConversationMessageId,
|
|
type DirectProjectTurnInput,
|
|
} from './conversation/directCodexConversation';
|
|
import { buildDirectChatTurns } from './conversation/directTurnPresentation';
|
|
import type { DirectCodexUserContentPart } from './generated/DirectCodexUserContentPart';
|
|
|
|
/**
|
|
* DirectProject 对外只有这一条命令式出口:工作台壳跑完运行/预览这类工作台级动作后,
|
|
* 把结果告诉用户。聊天容器持有会话真相,壳不持有聊天消息,只把这句话交给聊天的本地
|
|
* 消息流。
|
|
*/
|
|
export type DirectProjectChatHandle = {
|
|
announce: (text: string) => void;
|
|
};
|
|
|
|
/**
|
|
* 首页(或立项链路)带过来的首轮需求:项目打开后由聊天自己认领并发出。
|
|
*
|
|
* 认领记录按「项目路径 + claimScope」与 Design / Planning 入口共用一份
|
|
* (见 `app/initialTurnClaims`),所以同一条首轮需求不会被两个入口各发一次。
|
|
*/
|
|
export type DirectProjectInitialTurn = {
|
|
projectPath: string;
|
|
/**
|
|
* 入口首轮需求的 canonical 内容:文本、`@` 素材引用和附件引用都内联在这一份 content
|
|
* 里,和 Composer 提交的回合保持同一种形状。
|
|
*/
|
|
content: DirectCodexUserContentPart[];
|
|
creationType?: DirectProjectTurnInput['creationType'];
|
|
claimScope: string;
|
|
};
|
|
|
|
/**
|
|
* DirectProject 的公开入参:只有项目身份、入口带进来的首轮需求和两条权限门。
|
|
*
|
|
* 项目清单(素材/版本/projectId)、订阅、首屏锚点与历史分页、发送与 FIFO 队列、附件、
|
|
* 终止、草稿、本地消息、状态文案和错误提示都由这个组件自己持有;工作台壳不再传 Direct
|
|
* 专属 state/ref/effect,也不再有 Design Agent / Planning V2 的运行态。
|
|
*/
|
|
export type DirectProjectChatViewProps = {
|
|
/** 绑定的项目路径:项目身份、素材与版本都从这份项目清单读取。 */
|
|
projectPath: string | null;
|
|
/** 入口带进来的首轮需求;未提供时不自动发送任何回合。 */
|
|
initialTurn?: DirectProjectInitialTurn | null;
|
|
/**
|
|
* 项目对话读权限门:策略要求确认时由工作台壳入队,聊天不重复判定权限。
|
|
* 必填:这道门是聊天发起读取/回合的授权判据,缺省放行等于绕过工作台壳的权限判定。
|
|
*/
|
|
ensureConversationReadAllowed: DirectProjectConversationReadGate;
|
|
/** 项目对话写权限门:只在发起回合时检查;同样必填,缺省会让未传入的调用方静默跳过授权。 */
|
|
ensureConversationWriteAllowed: DirectProjectConversationWriteGate;
|
|
/** 发布入口:由工作台壳持有导出与发布面板,聊天只提供触发按钮。 */
|
|
onRequestGamePublish?: () => void;
|
|
/**
|
|
* 工作台壳入队的项目权限确认:聊天只展示并转发确认/取消,
|
|
* 策略读取、拒绝判定和确认后的动作仍由工作台壳持有。
|
|
*/
|
|
pendingConfirmation?: PendingUiConfirmation | null;
|
|
onConfirmConfirmation?: () => void;
|
|
onCancelConfirmation?: () => void;
|
|
/** 工作台壳把运行/预览这类动作结果交给聊天的出口。 */
|
|
ref?: Ref<DirectProjectChatHandle>;
|
|
};
|
|
|
|
/** 首轮那条本地用户消息展示入口原话:附件与引用仍按 canonical content 进入回合。 */
|
|
function directInitialTurnText(content: readonly DirectCodexUserContentPart[]) {
|
|
return content
|
|
.filter((part) => part.type === 'input_text')
|
|
.map((part) => part.text)
|
|
.join('')
|
|
.trim();
|
|
}
|
|
|
|
export function DirectProjectChatView({
|
|
onRequestGamePublish,
|
|
onConfirmConfirmation,
|
|
onCancelConfirmation,
|
|
pendingConfirmation = null,
|
|
projectPath,
|
|
initialTurn = null,
|
|
ensureConversationReadAllowed,
|
|
ensureConversationWriteAllowed,
|
|
ref,
|
|
}: DirectProjectChatViewProps) {
|
|
const { assets, projectId, refresh, versions } =
|
|
useDirectProjectManifest(projectPath);
|
|
const messagesRef = useRef<HTMLDivElement | null>(null);
|
|
const shouldFollowLatestRef = useRef(true);
|
|
const [runtimeNotice, setRuntimeNotice] = useState('');
|
|
const [runtimeDetailRef, setRuntimeDetailRef] = useState<string | null>(null);
|
|
const [runtimeDetail, setRuntimeDetail] = useState('');
|
|
const [runtimeDetailOpen, setRuntimeDetailOpen] = useState(false);
|
|
const [runtimeDetailLoading, setRuntimeDetailLoading] = useState(false);
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
const [approvalOpen, setApprovalOpen] = useState(false);
|
|
const [approvalMode, setApprovalMode] = useState<ApprovalMode>('strict');
|
|
const [approvalNotice, setApprovalNotice] = useState('');
|
|
/**
|
|
* 运行态提示出口:提示文案与它指向的诊断引用分开持有。
|
|
*
|
|
* 提示换一条(或被清空)就丢掉上一条的正文与展开态:诊断正文永远属于当前提示。
|
|
*/
|
|
const handleRuntimeError = useCallback(
|
|
(message: string, detailRef?: string | null) => {
|
|
setRuntimeNotice(message);
|
|
setRuntimeDetailRef(detailRef ?? null);
|
|
setRuntimeDetail('');
|
|
setRuntimeDetailOpen(false);
|
|
setRuntimeDetailLoading(false);
|
|
},
|
|
[],
|
|
);
|
|
const chat = useDirectProjectChatController({
|
|
assets,
|
|
enabled: Boolean(projectPath),
|
|
ensureConversationReadAllowed,
|
|
ensureConversationWriteAllowed,
|
|
onRuntimeError: handleRuntimeError,
|
|
projectId,
|
|
projectPath,
|
|
refreshManifest: refresh,
|
|
});
|
|
const {
|
|
appendLocalMessage,
|
|
attachmentNotice,
|
|
attachmentsImporting,
|
|
cancelQueuedTurn,
|
|
cancelTurn,
|
|
composerNotice,
|
|
directEntries,
|
|
directTurnRunning,
|
|
historyHasMore,
|
|
loadEarlierHistory,
|
|
localMessages,
|
|
pendingUserItemId,
|
|
queuedTurns,
|
|
startInitialTurn,
|
|
statusNotice,
|
|
submit,
|
|
turnBusy,
|
|
turnCancelling,
|
|
uploadFiles,
|
|
} = chat;
|
|
// 这份投影要参与下游 memo 的判据,必须自己先缓存:不缓存的话每次渲染都是一份新数组,
|
|
// `useDirectProjectTurnStatus` 里以 `turns` 为判据的 `useMemo` 永远命中不了,那层 memo
|
|
// 就成了死代码,读代码的人还会误以为 `turnStatus` 是引用稳定的。
|
|
const directTurns = useMemo(
|
|
() =>
|
|
buildDirectChatTurns({
|
|
entries: directEntries,
|
|
localMessages,
|
|
turnRunning: directTurnRunning,
|
|
pendingUserItemId,
|
|
}),
|
|
[directEntries, localMessages, directTurnRunning, pendingUserItemId],
|
|
);
|
|
// 「这一轮在跑吗」只从这一个派生入口读:原生真相 / 本地命令在飞 / 最新一轮三态。
|
|
const turnStatus = useDirectProjectTurnStatus({
|
|
turnRunning: directTurnRunning,
|
|
turnBusy,
|
|
turns: directTurns,
|
|
});
|
|
const activeTurnStartedAt =
|
|
directTurns.find((turn) => turn.state === 'running')?.startedAt ?? 0;
|
|
const statusText =
|
|
runtimeNotice ||
|
|
statusNotice ||
|
|
(projectPath
|
|
? `已打开:${projectNameFromPath(projectPath)}`
|
|
: '请选择工作区');
|
|
|
|
useEffect(() => {
|
|
if (!initialTurn || initialTurn.content.length === 0 || !projectPath)
|
|
return;
|
|
if (projectPath !== initialTurn.projectPath) return;
|
|
// projectId 来自项目清单:清单还没到位时不能先认领,否则首轮需求会被空项目吃掉。
|
|
if (!projectId || turnStatus.displayBusy) return;
|
|
if (
|
|
!claimInitialTurnForPage(initialTurn.projectPath, initialTurn.claimScope)
|
|
) {
|
|
return;
|
|
}
|
|
const clientTurnId = createDirectProjectTurnId();
|
|
const userItem = directCodexUserItemFromContent(
|
|
initialTurn.content,
|
|
directCodexConversationMessageId(clientTurnId, 'user'),
|
|
);
|
|
shouldFollowLatestRef.current = true;
|
|
startInitialTurn({
|
|
// 首轮那条用户消息按入口原话展示,引用/附件仍按 canonical content 发给运行时。
|
|
messageText: directInitialTurnText(initialTurn.content),
|
|
clientTurnId,
|
|
...(initialTurn.creationType
|
|
? { creationType: initialTurn.creationType }
|
|
: {}),
|
|
userItem,
|
|
});
|
|
// 首轮需求只由入口 payload、项目身份、清单就绪和回合忙态驱动。
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [turnStatus.displayBusy, initialTurn, projectId, projectPath]);
|
|
|
|
useEffect(() => {
|
|
if (!shouldFollowLatestRef.current) return;
|
|
const list = messagesRef.current;
|
|
if (list) list.scrollTop = list.scrollHeight;
|
|
}, [directEntries, localMessages]);
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
announce: (text: string) => {
|
|
appendLocalMessage({ role: 'assistant', text, updatedAt: Date.now() });
|
|
},
|
|
}));
|
|
|
|
const handleScroll: UIEventHandler<HTMLDivElement> = (event) => {
|
|
const list = event.currentTarget;
|
|
shouldFollowLatestRef.current =
|
|
list.scrollHeight - list.scrollTop - list.clientHeight <=
|
|
AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD;
|
|
if (historyHasMore && list.scrollTop <= 24) {
|
|
void loadEarlierHistory();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 展开/收起运行诊断正文。
|
|
*
|
|
* 正文按需读取:读不到(sidecar 被删、权限拒绝、命令缺失)时只显示这句话,提示文案本身
|
|
* 不受影响——诊断是附加信息,读取失败不能升级成新的用户可见错误。
|
|
*/
|
|
const toggleRuntimeDetail = useCallback(async () => {
|
|
if (runtimeDetailOpen) {
|
|
setRuntimeDetailOpen(false);
|
|
return;
|
|
}
|
|
setRuntimeDetailOpen(true);
|
|
if (runtimeDetail || !runtimeDetailRef) return;
|
|
const invoke = resolveTauriInvoke();
|
|
if (!invoke || !projectPath) {
|
|
setRuntimeDetail('诊断详情需要在客户端内、且在项目打开时读取。');
|
|
return;
|
|
}
|
|
setRuntimeDetailLoading(true);
|
|
try {
|
|
const body = await readAgentRuntimeErrorDetailByRef(
|
|
invoke,
|
|
projectPath,
|
|
runtimeDetailRef,
|
|
);
|
|
setRuntimeDetail(body || '这条失败没有可读取的诊断正文。');
|
|
} finally {
|
|
setRuntimeDetailLoading(false);
|
|
}
|
|
}, [projectPath, runtimeDetail, runtimeDetailOpen, runtimeDetailRef]);
|
|
|
|
return (
|
|
<section
|
|
className="project-chat-surface is-direct-codex"
|
|
aria-label="陶泥儿项目对话"
|
|
>
|
|
<div className="project-chat-conversation">
|
|
<DirectProjectChatHeader
|
|
busy={turnStatus.displayBusy}
|
|
statusText={statusText}
|
|
onOpenSettings={() => setSettingsOpen(true)}
|
|
onRequestGamePublish={onRequestGamePublish}
|
|
/>
|
|
{runtimeNotice && runtimeDetailRef ? (
|
|
<div className="project-chat-runtime-detail">
|
|
<button
|
|
type="button"
|
|
className="project-chat-runtime-detail-toggle"
|
|
aria-expanded={runtimeDetailOpen}
|
|
disabled={runtimeDetailLoading}
|
|
onClick={() => void toggleRuntimeDetail()}
|
|
>
|
|
{runtimeDetailOpen ? '收起详情' : '查看详情'}
|
|
</button>
|
|
{runtimeDetailOpen ? (
|
|
<pre
|
|
className="project-chat-runtime-detail-body"
|
|
aria-label="运行诊断详情"
|
|
>
|
|
{runtimeDetailLoading
|
|
? '正在读取诊断…'
|
|
: runtimeDetail || '这条失败没有可读取的诊断正文。'}
|
|
</pre>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
<DirectProjectConversation
|
|
turns={directTurns}
|
|
messagesRef={messagesRef}
|
|
historyHasMore={historyHasMore}
|
|
nativeRunning={turnStatus.nativeRunning}
|
|
activeTurnStartedAt={activeTurnStartedAt}
|
|
onLoadEarlierHistory={() => void loadEarlierHistory()}
|
|
onScroll={handleScroll}
|
|
/>
|
|
{pendingConfirmation &&
|
|
onConfirmConfirmation &&
|
|
onCancelConfirmation ? (
|
|
<div className="pending-command">
|
|
<span>
|
|
{pendingConfirmation.commandId}
|
|
<small>{pendingConfirmation.detail}</small>
|
|
</span>
|
|
<button type="button" onClick={onCancelConfirmation}>
|
|
取消
|
|
</button>
|
|
<button type="button" onClick={onConfirmConfirmation}>
|
|
确认
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
<DirectProjectComposer
|
|
assets={assets}
|
|
{...(versions ? { versions } : {})}
|
|
projectPath={projectPath}
|
|
attachmentNotice={attachmentNotice}
|
|
attachmentsImporting={attachmentsImporting}
|
|
queuedTurns={queuedTurns}
|
|
composerNotice={composerNotice}
|
|
busy={turnStatus.displayBusy}
|
|
turnCancelling={turnCancelling}
|
|
onCancelQueuedTurn={cancelQueuedTurn}
|
|
onCancelTurn={() => void cancelTurn()}
|
|
onReferencePickerOpen={() => {
|
|
// 素材标签、改名、删除这些写入发生在资源画布,聊天侧收不到失效事件:
|
|
// 打开 `@` 选择器时按需重读聊天自己那份清单,别把旧标签当现状。
|
|
if (projectPath) void refresh(projectPath);
|
|
}}
|
|
onSubmit={submit}
|
|
onUploadFiles={uploadFiles}
|
|
/>
|
|
</div>
|
|
{settingsOpen ? (
|
|
<DirectProjectSettingsDialog
|
|
projectPath={projectPath ?? ''}
|
|
currentApprovalLabel={approvalModeLabel(approvalMode)}
|
|
onOpenApproval={() => setApprovalOpen(true)}
|
|
onClose={() => setSettingsOpen(false)}
|
|
/>
|
|
) : null}
|
|
{settingsOpen && approvalOpen ? (
|
|
<ApprovalModeDialog
|
|
approvalMode={approvalMode}
|
|
notice={approvalNotice}
|
|
onSelect={setApprovalMode}
|
|
onNotice={setApprovalNotice}
|
|
onClose={() => setApprovalOpen(false)}
|
|
closeOnEscape={false}
|
|
/>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|