完成DirectProject composer canonical content收口
输入区草稿只保留 Lexical content 数组 Direct 与 Planning 提交边界直接读取 canonical content 润色保留引用解析并保护附件节点 通过单向 DTO 适配 legacy text 引用与附件消费
This commit is contained in:
@@ -87,15 +87,10 @@ import {
|
||||
resolveChatProjectPath,
|
||||
} from './features/project-workspace/projectCommandPolicy';
|
||||
import type { ResourceReferenceInputHandle } from './features/project-workspace/ResourceReferenceInput';
|
||||
import type {
|
||||
ChatComposerDraft,
|
||||
ChatReference,
|
||||
} from './features/project-workspace/resourceReferences';
|
||||
import {
|
||||
directCodexContentToPromptText,
|
||||
directCodexContentToLegacyContentDto,
|
||||
hasMeaningfulDirectCodexContent,
|
||||
RESOURCE_REFERENCE_INSERT_EVENT,
|
||||
resourceReferenceFromAsset,
|
||||
type ResourceReferenceInsertEventDetail,
|
||||
} from './features/project-workspace/resourceReferences';
|
||||
import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog';
|
||||
@@ -122,7 +117,6 @@ import {
|
||||
DirectProjectChatView,
|
||||
type DirectProjectInitialTurn,
|
||||
} from './view/project-development/chat/DirectProjectChatView';
|
||||
import type { DirectCodexUserContentPart } from './view/project-development/chat/generated/DirectCodexUserContentPart';
|
||||
import { PlanningChatView } from './view/project-development/planning/PlanningChatView';
|
||||
import { planningStateNeedsRuntimeRefresh } from './view/project-development/planning/planningLane';
|
||||
import {
|
||||
@@ -375,15 +369,7 @@ export function App({
|
||||
);
|
||||
}
|
||||
|
||||
const [chatInput, setChatInput] = useState('');
|
||||
const [chatReferences, setChatReferences] = useState<ChatReference[]>([]);
|
||||
const [chatContent, setChatContent] = useState<DirectCodexUserContentPart[]>(
|
||||
[],
|
||||
);
|
||||
const chatComposerRef = useRef<ResourceReferenceInputHandle | null>(null);
|
||||
useEffect(() => {
|
||||
setChatContent([]);
|
||||
}, [localProject?.projectPath]);
|
||||
const [chatAgentBusy, setChatAgentBusy] = useState(false);
|
||||
const [planningRuntime, setPlanningRuntime] =
|
||||
useState<AgentRuntimeState | null>(null);
|
||||
@@ -1883,8 +1869,6 @@ export function App({
|
||||
setProjectPath(openedProject.projectPath);
|
||||
setWorkspaceProjectKind(projectKind);
|
||||
setLocalProject(openedProject);
|
||||
setChatReferences([]);
|
||||
setChatContent([]);
|
||||
setManifest(openedProject.manifest);
|
||||
setAgentRuntimeById({});
|
||||
setMessages(conversationMessages);
|
||||
@@ -1926,42 +1910,6 @@ export function App({
|
||||
}
|
||||
}
|
||||
|
||||
function handleChatComposerChange(draft: ChatComposerDraft) {
|
||||
const content = draft.content;
|
||||
setChatContent(content);
|
||||
setChatInput(directCodexContentToPromptText(content, manifest.assets));
|
||||
setChatReferences(
|
||||
content.flatMap((part) => {
|
||||
if (part.type === 'agc_resource_reference') {
|
||||
const asset = manifest.assets.find(
|
||||
(item) => item.id === part.resourceId,
|
||||
);
|
||||
return asset
|
||||
? [resourceReferenceFromAsset(asset, 'asset-picker')]
|
||||
: [];
|
||||
}
|
||||
if (part.type === 'agc_runtime_region_reference') {
|
||||
return [
|
||||
{
|
||||
type: 'runtime-region' as const,
|
||||
label: part.label,
|
||||
runId: part.runId ?? undefined,
|
||||
versionId: part.versionId ?? undefined,
|
||||
elementTag: part.elementTag ?? undefined,
|
||||
elementRole: part.elementRole ?? undefined,
|
||||
text: part.text ?? undefined,
|
||||
width: part.width ?? undefined,
|
||||
height: part.height ?? undefined,
|
||||
resourceIds: part.resourceIds,
|
||||
source: 'runtime-picker' as const,
|
||||
},
|
||||
] as ChatReference[];
|
||||
}
|
||||
return [] as ChatReference[];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleResourceReferenceInsert = (event: Event) => {
|
||||
const detail = (event as CustomEvent<ResourceReferenceInsertEventDetail>)
|
||||
@@ -2688,12 +2636,13 @@ export function App({
|
||||
|
||||
function handlePlanningChatSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const prompt = chatInput.trim();
|
||||
const content = chatContent;
|
||||
const canonicalPrompt = directCodexContentToPromptText(
|
||||
const content = chatComposerRef.current?.getDraft().content ?? [];
|
||||
const legacyContent = directCodexContentToLegacyContentDto(
|
||||
content,
|
||||
manifest.assets,
|
||||
);
|
||||
const prompt = legacyContent.text.trim();
|
||||
const canonicalPrompt = legacyContent.text;
|
||||
if (agentRuntimeNeedsUserInput(planningRuntimeRef.current)) {
|
||||
setProjectChatError('请先回答项目总控 Agent 当前的澄清问题');
|
||||
return;
|
||||
@@ -2710,7 +2659,7 @@ export function App({
|
||||
}
|
||||
planningChatShouldFollowLatestRef.current = true;
|
||||
const clientTurnId = createAgentChatRunId('planning-v2-turn');
|
||||
setChatInput('');
|
||||
chatComposerRef.current?.clear();
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{
|
||||
@@ -2724,9 +2673,7 @@ export function App({
|
||||
void executeChatAgentReply({ prompt: canonicalPrompt, clientTurnId });
|
||||
return;
|
||||
}
|
||||
setChatInput('');
|
||||
setChatReferences([]);
|
||||
setChatContent([]);
|
||||
chatComposerRef.current?.clear();
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{
|
||||
@@ -2761,8 +2708,6 @@ export function App({
|
||||
<PlanningChatView
|
||||
initialPlanningPrompt={initialPlanningPrompt}
|
||||
activeVersionId={chatActiveVersionId}
|
||||
chatInput={chatInput}
|
||||
chatReferences={chatReferences}
|
||||
composerRef={chatComposerRef}
|
||||
chatProjectAssets={chatProjectAssets}
|
||||
hiddenConversationCount={hiddenConversationCount}
|
||||
@@ -2770,7 +2715,6 @@ export function App({
|
||||
messagesRef={planningChatMessagesRef}
|
||||
needsUserInput={planningNeedsUserInput}
|
||||
onCancelConfirmation={cancelUiCommandConfirmation}
|
||||
onChatInputChange={handleChatComposerChange}
|
||||
onConfirmConfirmation={confirmUiCommand}
|
||||
onScroll={handlePlanningChatScroll}
|
||||
onShowEarlierMessages={showEarlierConversationMessages}
|
||||
|
||||
+37
-96
@@ -41,7 +41,6 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -104,8 +103,6 @@ import {
|
||||
import { usePromptPolish } from './usePromptPolish';
|
||||
|
||||
type ResourceReferenceInputProps = {
|
||||
value?: string;
|
||||
references?: ChatReference[];
|
||||
onChange?: (draft: ChatComposerDraft) => void;
|
||||
onEditorStateChange?: (editorState: EditorState) => void;
|
||||
initialContent?: DirectCodexUserContentPart[];
|
||||
@@ -241,20 +238,6 @@ type DraftProjection = {
|
||||
content: DirectCodexUserContentPart[];
|
||||
};
|
||||
|
||||
/** 引用在草稿文本里的稳定键:resource 用资源 id,skill 用技能名,其余用显示名。 */
|
||||
function referenceDraftKey(reference: ChatReference) {
|
||||
if (reference.type === 'resource') return reference.resourceId;
|
||||
if (reference.type === 'skill') return reference.name;
|
||||
return reference.label;
|
||||
}
|
||||
|
||||
/** 引用回读成草稿文本时的 token:skill 是 `$name`,resource 与 runtime region 是 `@显示名`。 */
|
||||
function referenceDraftToken(reference: ChatReference) {
|
||||
return reference.type === 'skill'
|
||||
? `$${reference.name}`
|
||||
: `@${reference.label}`;
|
||||
}
|
||||
|
||||
/** 仅供编辑器内部派生引用(重建文本草稿时用);对外只暴露 canonical content。 */
|
||||
function readDraftProjectionFromNodes(): DraftProjection {
|
||||
const references: ChatReference[] = [];
|
||||
@@ -269,31 +252,7 @@ function readDraftProjectionFromNodes(): DraftProjection {
|
||||
/** 按编辑器自己的口径读 canonical 草稿;只能在 Lexical 读/更新上下文里调用。 */
|
||||
function readDraftFromNodes(): ChatComposerDraft {
|
||||
const projection = readDraftProjectionFromNodes();
|
||||
const labels = new Map(
|
||||
projection.references.map((reference) => [
|
||||
referenceDraftKey(reference),
|
||||
referenceDraftToken(reference),
|
||||
]),
|
||||
);
|
||||
const text = projection.content
|
||||
// 与 directCodexContentToPromptText 保持同一口径:附件引用是 `@名称`,不是 `$名称`。
|
||||
.map((part) => {
|
||||
if (part.type === 'input_text') return part.text;
|
||||
if (part.type === 'agc_resource_reference') {
|
||||
return labels.get(part.resourceId) ?? `@${part.resourceId}`;
|
||||
}
|
||||
if (part.type === 'agc_runtime_region_reference') return `@${part.label}`;
|
||||
if (part.type === 'agc_attachment_reference') return `@${part.name}`;
|
||||
if (part.type === 'agc_skill_reference') return `$${part.name}`;
|
||||
return '';
|
||||
})
|
||||
.join('')
|
||||
.trim();
|
||||
return {
|
||||
text,
|
||||
references: projection.references,
|
||||
content: projection.content,
|
||||
};
|
||||
return { content: projection.content };
|
||||
}
|
||||
|
||||
// The pure projection is exported for submit-time reads and focused tests.
|
||||
@@ -302,7 +261,7 @@ export function readResourceReferenceDraft(
|
||||
editorState: EditorState | null,
|
||||
): ChatComposerDraft {
|
||||
if (!editorState) {
|
||||
return { text: '', references: [], content: [] };
|
||||
return { content: [] };
|
||||
}
|
||||
return editorState.read(readDraftFromNodes);
|
||||
}
|
||||
@@ -399,20 +358,39 @@ function buildDraftSegments(
|
||||
return lines;
|
||||
}
|
||||
|
||||
function applyDraftToRoot(value: string, references: ChatReference[]) {
|
||||
const root = $getRoot();
|
||||
root.clear();
|
||||
buildDraftSegments(value, references).forEach((segments) => {
|
||||
const paragraph = $createParagraphNode();
|
||||
segments.forEach((segment) => {
|
||||
if (segment.kind === 'text') {
|
||||
if (segment.text) paragraph.append($createTextNode(segment.text));
|
||||
return;
|
||||
/**
|
||||
* 润色回写仍按文本中的 token 精确恢复资源/Skill/runtime chip;附件没有可安全反解析的
|
||||
* 稳定 token,因此保留原 attachment part 并放在新文本之后。
|
||||
* TODO: 提出“润色精确恢复附件原位置”的产品能力后,再把附件位置纳入协议,不猜字符串。
|
||||
*/
|
||||
function applyPolishedTextToRoot(
|
||||
value: string,
|
||||
current: DraftProjection,
|
||||
assetsById: ReadonlyMap<string, GameCreationAppAssetManifestEntry>,
|
||||
) {
|
||||
const lines = value.split(/\r?\n/u);
|
||||
const rebuiltContent: DirectCodexUserContentPart[] = [];
|
||||
buildDraftSegments(value, current.references).forEach(
|
||||
(segments, lineIndex) => {
|
||||
segments.forEach((segment) => {
|
||||
if (segment.kind === 'text') {
|
||||
if (segment.text)
|
||||
rebuiltContent.push({ type: 'input_text', text: segment.text });
|
||||
return;
|
||||
}
|
||||
rebuiltContent.push(chatReferenceToContentPart(segment.reference));
|
||||
});
|
||||
if (lineIndex < lines.length - 1) {
|
||||
rebuiltContent.push({ type: 'input_text', text: '\n' });
|
||||
}
|
||||
paragraph.append($createResourceReferenceNode(segment.reference));
|
||||
});
|
||||
root.append(paragraph);
|
||||
});
|
||||
},
|
||||
);
|
||||
rebuiltContent.push(
|
||||
...current.content.filter(
|
||||
(part) => part.type === 'agc_attachment_reference',
|
||||
),
|
||||
);
|
||||
applyContentToRoot(rebuiltContent, assetsById);
|
||||
}
|
||||
|
||||
function referenceFromContentPart(
|
||||
@@ -567,8 +545,6 @@ function $staleResourceReferenceNodes(
|
||||
}
|
||||
|
||||
function ResourceReferenceEditor({
|
||||
value,
|
||||
references,
|
||||
onChange,
|
||||
onEditorStateChange,
|
||||
initialContent,
|
||||
@@ -833,7 +809,7 @@ function ResourceReferenceEditor({
|
||||
replaceText: (text: string) => {
|
||||
editor.update(() => {
|
||||
const current = readDraftProjectionFromNodes();
|
||||
applyDraftToRoot(text, current.references);
|
||||
applyPolishedTextToRoot(text, current, assetsById);
|
||||
$getRoot().selectEnd();
|
||||
});
|
||||
},
|
||||
@@ -873,39 +849,6 @@ function ResourceReferenceEditor({
|
||||
});
|
||||
}, [assetsById, editor, initialContent]);
|
||||
|
||||
// 受控聊天入口在清空/恢复草稿时同步编辑器;普通输入变更若已与当前内容一致则不重建,
|
||||
// 避免每个按键都把光标跳到末尾。
|
||||
//
|
||||
// 这里必须是 layout effect:`value` / `references` 只是宿主对编辑器草稿的回显,宿主状态
|
||||
// 更新会晚于编辑器的 Lexical 提交。用被动 effect 时,一次滞后渲染的回调可能在用户已经
|
||||
// 继续输入之后才执行,它读到的是旧 `value` 加新编辑器状态,就会把整份草稿重建掉
|
||||
// (编辑器再通过 onChange 把空草稿写回宿主,来回清空)。layout effect 与本次提交同帧执行,
|
||||
// 读到的 props 与编辑器状态属于同一次提交。
|
||||
useLayoutEffect(() => {
|
||||
if (value === undefined && references === undefined) return;
|
||||
const desiredValue = value ?? '';
|
||||
const desiredRefs = references ?? [];
|
||||
const current = readResourceReferenceDraft(editor.getEditorState());
|
||||
const currentRefKey = current.references
|
||||
?.map(
|
||||
(reference) =>
|
||||
`${reference.type}:${reference.type === 'resource' ? reference.resourceId : reference.type === 'skill' ? reference.name : reference.label}`,
|
||||
)
|
||||
.join('|');
|
||||
const desiredRefKey = desiredRefs
|
||||
.map(
|
||||
(reference) =>
|
||||
`${reference.type}:${reference.type === 'resource' ? reference.resourceId : reference.type === 'skill' ? reference.name : reference.label}`,
|
||||
)
|
||||
.join('|');
|
||||
if (current.text === desiredValue && currentRefKey === desiredRefKey)
|
||||
return;
|
||||
editor.update(() => {
|
||||
applyDraftToRoot(desiredValue, desiredRefs);
|
||||
$getRoot().selectEnd();
|
||||
});
|
||||
}, [editor, references, value]);
|
||||
|
||||
// 资源改名后刷新已有引用 chip 的显示名:改写节点会触发 OnChangePlugin,
|
||||
// 把带新显示名的草稿同步回父级,chip 与候选列表都不会残留旧名。
|
||||
// Lexical 的更新可能排到微任务里提交,这里额外挂一次更新监听兜底。
|
||||
@@ -1026,8 +969,6 @@ function ResourceReferenceEditor({
|
||||
const acknowledgedDraftKeyRef = useRef<string | null>(null);
|
||||
// 拦截表单提交需要读到最新草稿,用 ref 保存本次渲染的草稿与派生值,避免闭包读到旧值。
|
||||
const liveDraftRef = useRef<ChatComposerDraft>({
|
||||
text: directCodexContentToPromptText(initialContent ?? [], assets),
|
||||
references: [],
|
||||
content: initialContent ?? [],
|
||||
});
|
||||
const [draftText, setDraftText] = useState(() =>
|
||||
@@ -1043,11 +984,11 @@ function ResourceReferenceEditor({
|
||||
(text: string) => {
|
||||
editor.update(() => {
|
||||
const current = readDraftProjectionFromNodes();
|
||||
applyDraftToRoot(text, current.references);
|
||||
applyPolishedTextToRoot(text, current, assetsById);
|
||||
$getRoot().selectEnd();
|
||||
});
|
||||
},
|
||||
[editor],
|
||||
[assetsById, editor],
|
||||
);
|
||||
|
||||
const readPromptText = useCallback(
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
buildGameCreationAppAssetTagLibrary,
|
||||
type GameCreationAppAssetTagLibraryEntry,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationAppAssetTagLibrary';
|
||||
import type { DirectCodexUserAttachmentReferencePart } from '../../view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart';
|
||||
import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart';
|
||||
import type { DirectCodexUserItem } from '../../view/project-development/chat/generated/DirectCodexUserItem';
|
||||
|
||||
@@ -56,10 +57,15 @@ export type ChatReference =
|
||||
| SkillReference;
|
||||
|
||||
export type ChatComposerDraft = {
|
||||
/** Lexical 节点按顺序投影出的 canonical user content,唯一事实源。 */
|
||||
content: DirectCodexUserContentPart[];
|
||||
};
|
||||
|
||||
/** 仅供仍未迁移的旧调用方消费的单向投影,不得回传给 ResourceReferenceInput。 */
|
||||
export type DirectCodexLegacyContentDto = {
|
||||
text: string;
|
||||
references: ChatReference[];
|
||||
/** Lexical 顺序对应的 canonical user content,是草稿的唯一结构化来源。 */
|
||||
content: DirectCodexUserContentPart[];
|
||||
attachments: DirectCodexUserAttachmentReferencePart[];
|
||||
};
|
||||
|
||||
export const RESOURCE_REFERENCE_INSERT_EVENT = 'agc-resource-reference-insert';
|
||||
@@ -95,8 +101,6 @@ export function isResourceReferenceOverlayTarget(target: EventTarget | null) {
|
||||
}
|
||||
|
||||
export const EMPTY_CHAT_COMPOSER_DRAFT: ChatComposerDraft = {
|
||||
text: '',
|
||||
references: [],
|
||||
content: [],
|
||||
};
|
||||
|
||||
@@ -177,10 +181,49 @@ export function directCodexUserItemFromContent(
|
||||
content: readonly DirectCodexUserContentPart[],
|
||||
id: string,
|
||||
): DirectCodexUserItem {
|
||||
return chatComposerDraftToDirectCodexUserItem(
|
||||
{ text: '', references: [], content: [...content] },
|
||||
id,
|
||||
);
|
||||
return chatComposerDraftToDirectCodexUserItem({ content: [...content] }, id);
|
||||
}
|
||||
|
||||
/** canonical content → legacy text + reference + attachment DTO。只允许单向翻译。 */
|
||||
export function directCodexContentToLegacyContentDto(
|
||||
content: readonly DirectCodexUserContentPart[],
|
||||
assets: readonly GameCreationAppAssetManifestEntry[],
|
||||
): DirectCodexLegacyContentDto {
|
||||
const references: ChatReference[] = [];
|
||||
content.forEach((part) => {
|
||||
if (part.type === 'agc_resource_reference') {
|
||||
const asset = assets.find((item) => item.id === part.resourceId);
|
||||
if (asset)
|
||||
references.push(resourceReferenceFromAsset(asset, 'asset-picker'));
|
||||
return;
|
||||
}
|
||||
if (part.type === 'agc_runtime_region_reference') {
|
||||
references.push({
|
||||
type: 'runtime-region',
|
||||
label: part.label,
|
||||
runId: part.runId ?? undefined,
|
||||
versionId: part.versionId ?? undefined,
|
||||
elementTag: part.elementTag ?? undefined,
|
||||
elementRole: part.elementRole ?? undefined,
|
||||
text: part.text ?? undefined,
|
||||
width: part.width ?? undefined,
|
||||
height: part.height ?? undefined,
|
||||
resourceIds: part.resourceIds,
|
||||
source: 'runtime-picker',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (part.type === 'agc_skill_reference') {
|
||||
references.push({ type: 'skill', name: part.name });
|
||||
}
|
||||
});
|
||||
return {
|
||||
text: directCodexContentToPromptText(content, assets),
|
||||
references,
|
||||
attachments: content.flatMap((part) =>
|
||||
part.type === 'agc_attachment_reference' ? [part] : [],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) {
|
||||
|
||||
@@ -116,12 +116,9 @@ export function DirectProjectChatView({
|
||||
attachmentNotice,
|
||||
cancelQueuedTurn,
|
||||
cancelTurn,
|
||||
chatInput,
|
||||
chatReferences,
|
||||
composerNotice,
|
||||
directEntries,
|
||||
directTurnRunning,
|
||||
handleComposerDraft,
|
||||
historyHasMore,
|
||||
loadEarlierHistory,
|
||||
localMessages,
|
||||
@@ -229,14 +226,11 @@ export function DirectProjectChatView({
|
||||
attachments={attachments}
|
||||
attachmentNotice={attachmentNotice}
|
||||
queuedTurns={queuedTurns}
|
||||
chatInput={chatInput}
|
||||
chatReferences={chatReferences}
|
||||
composerNotice={composerNotice}
|
||||
busy={busy}
|
||||
turnCancelling={turnCancelling}
|
||||
onCancelQueuedTurn={cancelQueuedTurn}
|
||||
onCancelTurn={() => void cancelTurn()}
|
||||
onChatInputChange={handleComposerDraft}
|
||||
onRemoveAttachment={removeAttachment}
|
||||
onSubmit={submit}
|
||||
onUploadFiles={(files) => void uploadFiles(files)}
|
||||
|
||||
+12
-16
@@ -1,5 +1,4 @@
|
||||
import { ArrowUp, AtSign } from 'lucide-react';
|
||||
import type { FormEventHandler } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
@@ -14,11 +13,9 @@ 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 { directCodexAttachmentContentParts } from '../../conversation/directCodexTurnAttachments';
|
||||
import type { DirectCodexUserContentPart } from '../../generated/DirectCodexUserContentPart';
|
||||
import type { QueuedChatTurn } from './chatComposerQueue';
|
||||
import {
|
||||
ComposerAttachmentMenu,
|
||||
@@ -42,14 +39,11 @@ export function DirectProjectComposer({
|
||||
attachments,
|
||||
attachmentNotice,
|
||||
queuedTurns,
|
||||
chatInput,
|
||||
chatReferences,
|
||||
composerNotice,
|
||||
busy,
|
||||
turnCancelling,
|
||||
onCancelQueuedTurn,
|
||||
onCancelTurn,
|
||||
onChatInputChange,
|
||||
onRemoveAttachment,
|
||||
onSubmit,
|
||||
onUploadFiles,
|
||||
@@ -60,16 +54,13 @@ export function DirectProjectComposer({
|
||||
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>;
|
||||
onSubmit: (content: DirectCodexUserContentPart[]) => boolean;
|
||||
onUploadFiles: (files: readonly File[]) => void;
|
||||
}) {
|
||||
const composerRef = useRef<ResourceReferenceInputHandle | null>(null);
|
||||
@@ -104,7 +95,15 @@ export function DirectProjectComposer({
|
||||
const ready = modelSelectRef.current
|
||||
? await modelSelectRef.current.ensureUsable()
|
||||
: modelReady;
|
||||
if (ready) onSubmit(event);
|
||||
if (ready) {
|
||||
const content = [
|
||||
...(composerRef.current?.getDraft().content ?? []),
|
||||
...directCodexAttachmentContentParts(attachments),
|
||||
];
|
||||
if (onSubmit(content)) {
|
||||
composerRef.current?.clear();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
modelValidateInFlightRef.current = false;
|
||||
setModelValidating(false);
|
||||
@@ -129,10 +128,7 @@ export function DirectProjectComposer({
|
||||
projectPath={projectPath ?? ''}
|
||||
disabled={modelValidating}
|
||||
rows={3}
|
||||
value={chatInput}
|
||||
references={chatReferences}
|
||||
placeholder="描述你的想法,或 @ 引用素材"
|
||||
onChange={onChatInputChange}
|
||||
showTriggerButton={false}
|
||||
/>
|
||||
<div className="project-chat-composer-controls">
|
||||
|
||||
+8
-62
@@ -1,4 +1,4 @@
|
||||
import { type FormEvent, useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
CONVERSATION_INITIAL_VISIBLE_COUNT,
|
||||
@@ -9,12 +9,9 @@ import type { ChatMessage, DirectTurnCancelView } from '../../../../app/types';
|
||||
import { projectRuntimeVisibleError } from '../../../../features/agent-runtime';
|
||||
import { uploadLocalFilesAsAttachments } from '../../../../features/app-shell/useHomeProjectCreation';
|
||||
import {
|
||||
type ChatComposerDraft,
|
||||
type ChatReference,
|
||||
directCodexContentToPromptText,
|
||||
directCodexUserItemFromContent,
|
||||
hasMeaningfulDirectCodexContent,
|
||||
resourceReferenceFromAsset,
|
||||
} from '../../../../features/project-workspace/resourceReferences';
|
||||
import { captureAgentRuntimeError } from '../../../../services/errorReporting';
|
||||
import { requestPlatformSessionRefresh } from '../../../../services/platformSession';
|
||||
@@ -42,7 +39,6 @@ import {
|
||||
withDirectCodexSessionRefresh,
|
||||
} from '../conversation/directCodexSession';
|
||||
import {
|
||||
directCodexAttachmentContentParts,
|
||||
type DirectCodexTurnAttachment,
|
||||
toDirectCodexTurnAttachments,
|
||||
} from '../conversation/directCodexTurnAttachments';
|
||||
@@ -117,11 +113,6 @@ export function useDirectProjectChatController({
|
||||
const [statusNotice, setStatusNotice] = useState('');
|
||||
const [turnCancelling, setTurnCancelling] = useState(false);
|
||||
const [turnBusy, setTurnBusy] = useState(false);
|
||||
const [chatInput, setChatInput] = useState('');
|
||||
const [chatReferences, setChatReferences] = useState<ChatReference[]>([]);
|
||||
const [chatContent, setChatContent] = useState<DirectCodexUserContentPart[]>(
|
||||
[],
|
||||
);
|
||||
const [localMessages, setLocalMessages] = useState<ChatMessage[]>([]);
|
||||
// 订阅(subscribe/consume/notify)与聊天 reducer 状态在自己的 hook 里:
|
||||
// controller 只读投影后的条目与回合忙态,不再直接持有线程状态。
|
||||
@@ -151,7 +142,6 @@ export function useDirectProjectChatController({
|
||||
setStatusNotice('');
|
||||
setQueuedTurns([]);
|
||||
queuedTurnsRef.current = [];
|
||||
setChatContent([]);
|
||||
setLocalMessages([]);
|
||||
setHistoryHasMore(false);
|
||||
historyOldestItemIdRef.current = null;
|
||||
@@ -311,44 +301,7 @@ export function useDirectProjectChatController({
|
||||
);
|
||||
}
|
||||
|
||||
function handleComposerDraft(draft: ChatComposerDraft) {
|
||||
const content = draft.content;
|
||||
setChatContent(content);
|
||||
setChatInput(directCodexContentToPromptText(content, assets));
|
||||
setChatReferences(
|
||||
content.flatMap((part) => {
|
||||
if (part.type === 'agc_resource_reference') {
|
||||
const asset = assets.find((item) => item.id === part.resourceId);
|
||||
return asset
|
||||
? [resourceReferenceFromAsset(asset, 'asset-picker')]
|
||||
: [];
|
||||
}
|
||||
if (part.type === 'agc_runtime_region_reference') {
|
||||
return [
|
||||
{
|
||||
type: 'runtime-region' as const,
|
||||
label: part.label,
|
||||
runId: part.runId ?? undefined,
|
||||
versionId: part.versionId ?? undefined,
|
||||
elementTag: part.elementTag ?? undefined,
|
||||
elementRole: part.elementRole ?? undefined,
|
||||
text: part.text ?? undefined,
|
||||
width: part.width ?? undefined,
|
||||
height: part.height ?? undefined,
|
||||
resourceIds: part.resourceIds,
|
||||
source: 'runtime-picker' as const,
|
||||
},
|
||||
] as ChatReference[];
|
||||
}
|
||||
return [] as ChatReference[];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function clearDraftInput() {
|
||||
setChatInput('');
|
||||
setChatReferences([]);
|
||||
setChatContent([]);
|
||||
setAttachments([]);
|
||||
setAttachmentNotice('');
|
||||
}
|
||||
@@ -358,13 +311,8 @@ export function useDirectProjectChatController({
|
||||
setComposerNotice('');
|
||||
}
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const content = [
|
||||
...chatContent,
|
||||
...directCodexAttachmentContentParts(attachments),
|
||||
];
|
||||
if (!hasMeaningfulDirectCodexContent(content)) return;
|
||||
function submit(content: DirectCodexUserContentPart[]): boolean {
|
||||
if (!hasMeaningfulDirectCodexContent(content)) return false;
|
||||
if (turnBusyRef.current || currentTurnRunning) {
|
||||
const clientTurnId = createDirectProjectTurnId();
|
||||
if (
|
||||
@@ -378,14 +326,15 @@ export function useDirectProjectChatController({
|
||||
) {
|
||||
// 只清草稿:enqueueTurn 刚写入的「已加入发送队列」提示必须留给用户看到。
|
||||
clearDraftInput();
|
||||
return true;
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const prompt = chatInput.trim();
|
||||
const prompt = directCodexContentToPromptText(content, assets).trim();
|
||||
if (prompt === '/history') {
|
||||
clearPendingInput();
|
||||
void reloadHistory();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
const clientTurnId = createDirectProjectTurnId();
|
||||
const userItem = directCodexUserItemFromContent(
|
||||
@@ -398,6 +347,7 @@ export function useDirectProjectChatController({
|
||||
};
|
||||
clearPendingInput();
|
||||
startTurn(turn);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -758,13 +708,9 @@ export function useDirectProjectChatController({
|
||||
attachmentNotice,
|
||||
cancelQueuedTurn,
|
||||
cancelTurn,
|
||||
chatContent,
|
||||
chatInput,
|
||||
chatReferences,
|
||||
composerNotice,
|
||||
directEntries,
|
||||
directTurnRunning: currentTurnRunning,
|
||||
handleComposerDraft,
|
||||
historyHasMore,
|
||||
loadEarlierHistory,
|
||||
localMessages,
|
||||
|
||||
@@ -22,10 +22,6 @@ import {
|
||||
ResourceReferenceInput,
|
||||
type ResourceReferenceInputHandle,
|
||||
} from '../../../features/project-workspace/ResourceReferenceInput';
|
||||
import type {
|
||||
ChatComposerDraft,
|
||||
ChatReference,
|
||||
} from '../../../features/project-workspace/resourceReferences';
|
||||
import { formatClockTime } from '../chat/components/ToolCallGroup/toolCallGroupPresentation';
|
||||
import {
|
||||
DesignAgentPendingActions,
|
||||
@@ -85,8 +81,6 @@ function workspaceStatusForDisplay(workspaceStatus: string) {
|
||||
|
||||
type PlanningChatViewProps = {
|
||||
activeVersionId?: string | null;
|
||||
chatInput: string;
|
||||
chatReferences: ChatReference[];
|
||||
chatProjectAssets: import('../../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[];
|
||||
composerRef?: RefObject<ResourceReferenceInputHandle | null>;
|
||||
controlBusy: boolean;
|
||||
@@ -97,7 +91,6 @@ type PlanningChatViewProps = {
|
||||
messagesRef: RefObject<HTMLDivElement | null>;
|
||||
needsUserInput: boolean;
|
||||
onCancelConfirmation: () => void;
|
||||
onChatInputChange: (draft: ChatComposerDraft) => void;
|
||||
onConfirmConfirmation: () => void;
|
||||
onScroll: UIEventHandler<HTMLDivElement>;
|
||||
onShowEarlierMessages: () => void;
|
||||
@@ -140,8 +133,6 @@ type PlanningChatViewProps = {
|
||||
|
||||
export function PlanningChatView({
|
||||
activeVersionId = null,
|
||||
chatInput,
|
||||
chatReferences,
|
||||
chatProjectAssets,
|
||||
composerRef,
|
||||
controlBusy,
|
||||
@@ -152,7 +143,6 @@ export function PlanningChatView({
|
||||
messagesRef,
|
||||
needsUserInput,
|
||||
onCancelConfirmation,
|
||||
onChatInputChange,
|
||||
onConfirmConfirmation,
|
||||
onScroll,
|
||||
onShowEarlierMessages,
|
||||
@@ -368,12 +358,9 @@ export function PlanningChatView({
|
||||
projectPath={projectPath}
|
||||
disabled={composerDisabled}
|
||||
rows={3}
|
||||
value={chatInput}
|
||||
references={chatReferences}
|
||||
showTriggerButton={false}
|
||||
showPolishAction={false}
|
||||
placeholder=""
|
||||
onChange={onChatInputChange}
|
||||
/>
|
||||
<div className="project-chat-composer-controls">
|
||||
<div className="project-chat-composer-controls-right">
|
||||
|
||||
@@ -343,6 +343,48 @@ describe('ResourceReferenceInput', () => {
|
||||
expect(draftText(draft)).toBe('@hero\n把这一版改成夜景');
|
||||
});
|
||||
|
||||
test('润色回写保留原附件 part,不把 attachment chip 丢出 canonical content', async () => {
|
||||
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
|
||||
const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
|
||||
const attachment = {
|
||||
type: 'agc_attachment_reference' as const,
|
||||
name: '参考图.png',
|
||||
mediaType: 'image/png',
|
||||
size: 12,
|
||||
localPath: 'assets/reference.png',
|
||||
status: 'imported',
|
||||
};
|
||||
const composerRef = createRef<ResourceReferenceInputHandle>();
|
||||
render(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => composerRef.current?.replaceText('@hero 润色后的需求')}
|
||||
>
|
||||
模拟润色
|
||||
</button>
|
||||
<ResourceReferenceInput
|
||||
ref={composerRef}
|
||||
initialContent={[chatReferenceToContentPart(reference), attachment]}
|
||||
onChange={onChange}
|
||||
assets={assets}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="聊天"
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
await settleComposer();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '模拟润色' }));
|
||||
await settleComposer();
|
||||
const content = onChange.mock.calls.at(-1)?.[0].content;
|
||||
expect(content).toEqual([
|
||||
chatReferenceToContentPart(reference),
|
||||
{ type: 'input_text', text: ' 润色后的需求' },
|
||||
attachment,
|
||||
]);
|
||||
});
|
||||
|
||||
test('引用浮层打开时 Enter 不提交表单,关掉后恢复提交', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
Reference in New Issue
Block a user