import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { ContentEditable } from '@lexical/react/LexicalContentEditable'; import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; import { LexicalTypeaheadMenuPlugin, MenuOption, type MenuRenderFn, useBasicTypeaheadTriggerMatch, } from '@lexical/react/LexicalTypeaheadMenuPlugin'; import { $createParagraphNode, $createTextNode, $getRoot, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_HIGH, type EditorState, KEY_ENTER_COMMAND, KEY_ESCAPE_COMMAND, type LexicalNode, type TextNode, } from 'lexical'; import { AtSign, Check, FileText, Image as ImageIcon, Loader2, Music2, RotateCcw, Sparkles, Video, } from 'lucide-react'; import { forwardRef, type Ref, type RefObject, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; import { createPortal } from 'react-dom'; import { PlatformResourceFilterBar } from '../../../../../packages/shared/src/components/PlatformResourceFilterBar'; import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; import { type GameCreationAppAssetManifestEntry, gameCreationAppAssetTags, type GameIterationVersion, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { resolveTauriInvoke } from '../../app/tauri'; import RichTextInput from '../../components/RichTextInput'; import { cancelLocalProjectResourcePreviewScope, createProjectResourcePreviewRequestId, createProjectResourcePreviewScopeId, } from '../../services/projectResourcePreviewTransport'; import { $createAttachmentReferenceNode, $isAttachmentReferenceNode, AttachmentReferenceNode, } from './AttachmentReferenceNode'; import { chatPromptDraftKey, readChatPromptPolishReminderDisabled, shouldRemindChatPromptPolish, writeChatPromptPolishReminderDisabled, } from './chatPromptPolish'; import { ChatPromptPolishReminder } from './ChatPromptPolishReminder'; import type { DirectCodexUserContentPart } from './generated'; import { $createResourceReferenceNode, $isResourceReferenceNode, ResourceReferenceNode, } from './ResourceReferenceNode'; import { type ChatComposerDraft, type ChatReference, chatReferenceToContentPart, currentIterationVersionAssets, dedupeChatReferences, directCodexContentToPromptText, refreshResourceReference, RESOURCE_REFERENCE_FILTERS, RESOURCE_REFERENCE_SCOPES, type ResourceReference, resourceReferenceCategoryLabel, type ResourceReferenceFilter, resourceReferenceFromAsset, resourceReferenceMatchesCategoryFilter, resourceReferenceMatchesQuery, resourceReferenceMatchesTagSelection, type ResourceReferenceScope, resourceReferenceTagLibrary, type SkillReference, } from './resourceReferences'; import { usePromptPolish } from './usePromptPolish'; type ResourceReferenceInputProps = { value?: string; references?: ChatReference[]; onChange?: (draft: ChatComposerDraft) => void; onEditorStateChange?: (editorState: EditorState) => void; initialContent?: DirectCodexUserContentPart[]; assets: GameCreationAppAssetManifestEntry[]; skills?: SkillReference[]; projectPath: string; /** * `@` 面板「当前版本素材」页签使用的版本 id。 * 没传 / `null` 时回退到 manifest `versions[]` 中最新的那个版本; * 版本不存在或没有绑定资源时该页签显示空态。 */ activeVersionId?: string | null; /** manifest 的正式版本列表,用于解析「当前版本素材」。 */ versions?: GameIterationVersion[]; disabled?: boolean; placeholder?: string; ariaLabel: string; multiline?: boolean; rows?: number; showTriggerButton?: boolean; /** * 内置「AI 润色 / 恢复原文」动作是否渲染,默认 `true`。 * * 聊天输入区用内置那一份(不带资源编辑场景上下文);资源侧快速编辑由宿主自己的 * `ResourcePromptPolishSlot` 承担润色(带场景上下文与长度上限),传 `false` * 避免同一个面板里出现两个润色入口。 */ showPolishAction?: boolean; inputRef?: RefObject; }; type ResourcePickerScopeState = { query: string; filter: ResourceReferenceFilter; selectedResourceIds: string[]; /** 已选标签(多选),与搜索词、功能分类三者叠加生效。 */ selectedTags: string[]; }; function createResourcePickerScopeState(): ResourcePickerScopeState { return { query: '', filter: 'all', selectedResourceIds: [], selectedTags: [], }; } function createResourcePickerScopeStates(): Record< ResourceReferenceScope, ResourcePickerScopeState > { return { 'current-version': createResourcePickerScopeState(), 'all-canvas': createResourcePickerScopeState(), }; } export type ResourceReferenceInputHandle = { insertReferences: (references: ChatReference[]) => void; insertText: (text: string) => void; openPicker: () => void; focus: () => void; clear: () => void; replaceText: (text: string) => void; /** 直接读取 Lexical 当前状态,不在宿主组件复制一份编辑器 state。 */ getDraft: () => ChatComposerDraft; }; class ResourceMentionOption extends MenuOption { reference: ResourceReference; constructor(reference: ResourceReference) { super(reference.resourceId); this.reference = reference; } } class SkillMentionOption extends MenuOption { skill: SkillReference; constructor(skill: SkillReference) { super(skill.name); this.skill = skill; } } const BUILTIN_SKILL_REFERENCES: SkillReference[] = [ 'agc-browser-playtest', 'agc-client-projection', 'agc-game-production-workflow', 'agc-project-structure', 'agc-web-game-development', 'taonier-art-assets', ].map((name) => ({ type: 'skill', name })); /** * 编辑器节点 → canonical content part,**原样透传**:段落分隔(root 子节点之间补的 * `\n`)、软换行、chip 后的分隔空格都各自成 part,不做空白过滤、不与相邻 part 合并。 * 有效输入只在整条 content 上判定(`hasMeaningfulDirectCodexContent` 与 Rust * `validate_direct_codex_user_item` 同口径),前端不替用户改写他输入了什么。 */ function collectDraftParts( node: LexicalNode, references: ChatReference[], content: DirectCodexUserContentPart[], ) { if ($isTextNode(node)) { const text = node.getTextContent(); // Lexical 不会留下空 TextNode;这只防「空串 part」落进 app-server 输入。 if (text) { content.push({ type: 'input_text', text }); } return; } if ($isLineBreakNode(node)) { content.push({ type: 'input_text', text: '\n' }); return; } if ($isAttachmentReferenceNode(node)) { content.push({ type: 'agc_attachment_reference', ...node.__attachment }); return; } if ($isResourceReferenceNode(node)) { references.push(node.__reference); content.push(chatReferenceToContentPart(node.__reference)); return; } if ($isElementNode(node)) { node.getChildren().forEach((child, index) => { if (index > 0 && node.getType() === 'root') { content.push({ type: 'input_text', text: '\n' }); } collectDraftParts(child, references, content); }); } } type DraftProjection = { references: ChatReference[]; content: DirectCodexUserContentPart[]; }; /** 仅供编辑器内部派生引用(重建文本草稿时用);对外只暴露 canonical content。 */ function readDraftProjectionFromNodes(): DraftProjection { const references: ChatReference[] = []; const content: DirectCodexUserContentPart[] = []; collectDraftParts($getRoot(), references, content); return { references: dedupeChatReferences(references), content, }; } /** 按编辑器自己的口径读 canonical 草稿;只能在 Lexical 读/更新上下文里调用。 */ function readDraftFromNodes(): ChatComposerDraft { const projection = readDraftProjectionFromNodes(); const labels = new Map( projection.references.map((reference) => [ reference.type === 'resource' ? reference.resourceId : reference.label, `@${reference.label}`, ]), ); const text = projection.content .map((part) => part.type === 'input_text' ? part.text : part.type === 'agc_resource_reference' ? (labels.get(part.resourceId) ?? `@${part.resourceId}`) : part.type === 'agc_runtime_region_reference' ? `@${part.label}` : `@${part.name}`, ) .join('') .trim(); return { text, references: projection.references, content: projection.content, }; } // The pure projection is exported for submit-time reads and focused tests. // eslint-disable-next-line react-refresh/only-export-components export function readResourceReferenceDraft( editorState: EditorState | null, ): ChatComposerDraft { if (!editorState) { return { text: '', references: [], content: [] }; } return editorState.read(readDraftFromNodes); } type DraftBuildSegment = | { kind: 'text'; text: string } | { kind: 'reference'; reference: ChatReference }; function isDraftMentionBoundary(character: string | undefined) { return character === undefined || character === '' || /\s/u.test(character); } /** * 在 `from` 之后找 `token` 的整标记位置:前后必须是行首 / 行尾或空白, * 避免 `@hero` 命中 `@hero2` 的前缀。 */ function findDraftMentionToken(line: string, token: string, from: number) { let index = line.indexOf(token, from); while (index >= 0) { if ( isDraftMentionBoundary(index === 0 ? undefined : line[index - 1]) && isDraftMentionBoundary(line[index + token.length]) ) { return index; } index = line.indexOf(token, index + 1); } return -1; } /** * 把「文本 + 引用列表」这对外部表示切成编辑区要写的内容。 * * 不变量:写入编辑器后,编辑器读回的草稿就是唯一当前值。`collectDraftParts` * 会把每个 chip 读成一段 `@显示名` / `$skill-name` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上, * 不能另起一段堆在末尾;编辑器本身是唯一 authority,不再通过 props 比较后反复重建。 * * 文本里已经没有对应 token 的引用(例如 AI 润色整体改写了文本)内联补到最后一段末尾, * 引用不会凭空消失;这只发生在一次明确的初始草稿/润色写入中。 */ function buildDraftSegments( value: string, references: ChatReference[], ): DraftBuildSegment[][] { const pending = references.map((reference) => ({ reference, token: reference.type === 'skill' ? `$${reference.name}` : `@${reference.label}`, used: false, })); const lines: DraftBuildSegment[][] = []; for (const line of value.split(/\r?\n/u)) { const segments: DraftBuildSegment[] = []; let cursor = 0; for (;;) { const match = pending .filter((item) => !item.used) .map((item) => ({ item, index: findDraftMentionToken(line, item.token, cursor), })) .filter((candidate) => candidate.index >= 0) .sort((left, right) => left.index - right.index) .at(0); if (!match) break; if (match.index > cursor) { segments.push({ kind: 'text', text: line.slice(cursor, match.index) }); } match.item.used = true; segments.push({ kind: 'reference', reference: match.item.reference }); cursor = match.index + match.item.token.length; } if (cursor < line.length) { segments.push({ kind: 'text', text: line.slice(cursor) }); } lines.push(segments); } const orphans = pending .filter((item) => !item.used) .map( (item): DraftBuildSegment => ({ kind: 'reference', reference: item.reference, }), ); if (orphans.length > 0) { const lastLine = lines.at(-1); if (!lastLine) return [orphans]; if (lastLine.length > 0) { lastLine.push({ kind: 'text', text: ' ' }); } lastLine.push(...orphans); } 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; } paragraph.append($createResourceReferenceNode(segment.reference)); }); root.append(paragraph); }); } function referenceFromContentPart( part: DirectCodexUserContentPart, assetsById: ReadonlyMap, ): ChatReference | null { if (part.type === 'agc_resource_reference') { const asset = assetsById.get(part.resourceId); return asset ? resourceReferenceFromAsset(asset, 'asset-picker') : null; } if (part.type === 'agc_skill_reference') { return { type: 'skill', name: part.name }; } if (part.type === 'agc_runtime_region_reference') { return { 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 null; } function applyContentToRoot( content: readonly DirectCodexUserContentPart[], assetsById: ReadonlyMap, ) { const root = $getRoot(); root.clear(); let paragraph = $createParagraphNode(); root.append(paragraph); content.forEach((part) => { if (part.type === 'input_text') { const lines = part.text.split('\n'); lines.forEach((line, index) => { if (line) paragraph.append($createTextNode(line)); if (index < lines.length - 1) { paragraph = $createParagraphNode(); root.append(paragraph); } }); return; } if (part.type === 'agc_attachment_reference') { paragraph.append($createAttachmentReferenceNode(part)); return; } const reference = referenceFromContentPart(part, assetsById); if (reference) { paragraph.append($createResourceReferenceNode(reference)); } }); } function isMentionableAsset(asset: GameCreationAppAssetManifestEntry) { return Boolean(asset.localPath) && !asset.localPath.startsWith('.agent/'); } /** * 引用显示名只由 manifest 资产内容决定,而调用方每次渲染都会重建 assets 数组, * 这里用内容签名做依赖,避免与改名无关的渲染反复刷新已有引用。 */ function assetsSignature(assets: readonly GameCreationAppAssetManifestEntry[]) { return assets .map((asset) => [ asset.id, asset.kind, asset.mediaType, asset.localPath, asset.category ?? '', gameCreationAppAssetTags(asset).join(','), ].join('\u0000'), ) .join('\u0001'); } function mentionableAssetReferences( assets: GameCreationAppAssetManifestEntry[], source: ResourceReference['source'], ) { return assets .filter(isMentionableAsset) .map((asset) => resourceReferenceFromAsset(asset, source)); } /** * 版本列表的内容签名:「当前版本素材」只由版本 id、顺序与绑定资源决定, * 按签名取依赖才能跳过与版本无关的渲染。 */ function iterationsSignature( versions: readonly GameIterationVersion[] | undefined, ) { return (versions ?? []) .map( (version) => `${version.versionId}\u0002${version.resourceBindings .map((binding) => binding.resourceId) .join(',')}`, ) .join('\u0001'); } /** `@` 面板空态文案:先分「这个范围本来就没有素材」和「筛完没有命中」两件事。 */ function pickerEmptyMessage( scopeReferenceCount: number, scope: ResourceReferenceScope, ) { if (scopeReferenceCount > 0) { return '没有匹配的素材'; } return scope === 'current-version' ? '当前版本还没有绑定素材' : '当前项目还没有已登记素材'; } /** * 资源改名后把编辑区里已有的引用 chip 刷成 manifest 的最新显示名。 * 只改写仍能找到对应资产的引用;已删除资源保持原引用,不合成资源卡。 */ function $staleResourceReferenceNodes( assetsById: ReadonlyMap, ) { const staleNodes: { node: ResourceReferenceNode; reference: ChatReference; }[] = []; $getRoot() .getChildren() .forEach((block) => { if (!$isElementNode(block)) return; block.getChildren().forEach((child) => { if (!$isResourceReferenceNode(child)) return; if (child.__reference.type !== 'resource') return; const nextReference = refreshResourceReference( child.__reference, assetsById, ); if (nextReference === child.__reference) return; staleNodes.push({ node: child, reference: nextReference }); }); }); return staleNodes; } function ResourceReferenceEditor({ value, references, onChange, onEditorStateChange, initialContent, assets, skills = [], projectPath, activeVersionId = null, versions, disabled, multiline, showTriggerButton = true, showPolishAction = true, composerRef, rootRef, }: ResourceReferenceInputProps & { composerRef?: Ref; rootRef: RefObject; }) { const [editor] = useLexicalComposerContext(); const skipInitialDraftChangeRef = useRef(false); const [query, setQuery] = useState(null); const [skillQuery, setSkillQuery] = useState(null); const [clientSkills, setClientSkills] = useState([]); const skillCatalogRequestedRef = useRef(false); const [pickerOpen, setPickerOpen] = useState(false); // Enter 提交要避开候选浮层。用 ref 让命令处理器读到最新状态,不必因为 // query / pickerOpen 变化反复重注册 HIGH 优先级命令。 const mentionMenuOpenRef = useRef(false); const pickerVisibleRef = useRef(false); useEffect(() => { mentionMenuOpenRef.current = query !== null || skillQuery !== null; }, [query, skillQuery]); useEffect(() => { pickerVisibleRef.current = pickerOpen; }, [pickerOpen]); const [pickerScopeStates, setPickerScopeStates] = useState< Record >(createResourcePickerScopeStates); const [pickerScopeOverride, setPickerScopeOverride] = useState(null); const [pickerPosition, setPickerPosition] = useState<{ left: number; bottom: number; width: number; } | null>(null); // Skill 清单来自宿主:只在用户真正打开 `$` 候选时查一次。输入区挂载即发起 // Tauri 调用会让「工作区路径非法时不产生任何后端访问」的边界失效。 useEffect(() => { if (skillQuery === null || skillCatalogRequestedRef.current) return; const invoke = resolveTauriInvoke(); if (!invoke) return; skillCatalogRequestedRef.current = true; void invoke< Array<{ name: string; extensionType: string; enabled: boolean; status: string; }> >('list_client_extensions') .then((items) => { setClientSkills( items .filter( (item) => item.extensionType === 'skill' && item.enabled && item.status === 'enabled', ) .map((item) => ({ type: 'skill' as const, name: item.name })), ); }) .catch(() => { setClientSkills([]); }); }, [skillQuery]); const assetsContentSignature = assetsSignature(assets); const versionsContentSignature = iterationsSignature(versions); const assetsById = useMemo( () => new Map(assets.map((asset) => [asset.id, asset])), // 依赖内容签名:assets 数组身份每次渲染都会变,内容不变时没必要重建索引。 // eslint-disable-next-line react-hooks/exhaustive-deps [assetsContentSignature], ); const assetReferences = useMemo( () => mentionableAssetReferences(assets, 'asset-picker'), // 依赖内容签名:调用方每次渲染都会重建 assets 数组,内容不变时没必要重算。 // eslint-disable-next-line react-hooks/exhaustive-deps [assetsContentSignature], ); const currentVersionAssetReferences = useMemo( () => mentionableAssetReferences( currentIterationVersionAssets(assets, versions, activeVersionId), 'version-asset', ), // 同上:versions 数组身份同样每次渲染都换,按内容签名取依赖才真的能跳过。 // eslint-disable-next-line react-hooks/exhaustive-deps [activeVersionId, assetsContentSignature, versionsContentSignature], ); // 两个页签的默认落点:当前版本确实绑定了已登记素材时优先展示「当前版本素材」, // 否则落到「全部画布素材」,避免没有版本的项目一打开就是空列表。 const defaultPickerScope: ResourceReferenceScope = currentVersionAssetReferences.length > 0 ? 'current-version' : 'all-canvas'; const pickerScope = pickerScopeOverride ?? defaultPickerScope; const pickerScopeState = pickerScopeStates[pickerScope]; const pickerQuery = pickerScopeState.query; const pickerFilter = pickerScopeState.filter; const selectedResourceIds = pickerScopeState.selectedResourceIds; const selectedTags = pickerScopeState.selectedTags; const scopeReferences = pickerScope === 'current-version' ? currentVersionAssetReferences : assetReferences; // 标签库按当前页签的候选派生:两个页签各自一份,标签与计数都不跨页签串。 const scopeTagItems = useMemo( () => resourceReferenceTagLibrary(scopeReferences), [scopeReferences], ); const updatePickerScopeState = useCallback( (patch: Partial) => { setPickerScopeStates((current) => ({ ...current, [pickerScope]: { ...current[pickerScope], ...patch }, })); }, [pickerScope], ); const mentionOptions = useMemo(() => { if (query === null) return []; return assetReferences .filter((reference) => resourceReferenceMatchesQuery(reference, query)) .slice(0, 8) .map((reference) => new ResourceMentionOption(reference)); }, [assetReferences, query]); const skillOptions = useMemo(() => { if (skillQuery === null) return []; const normalized = skillQuery.trim().toLowerCase(); const allSkills = [...BUILTIN_SKILL_REFERENCES, ...clientSkills, ...skills]; const seen = new Set(); return allSkills .filter((skill) => { if (seen.has(skill.name)) return false; seen.add(skill.name); return ( !normalized || skill.name.toLowerCase().includes(normalized) || skill.description?.toLowerCase().includes(normalized) ); }) .slice(0, 8) .map((skill) => new SkillMentionOption(skill)); }, [clientSkills, skillQuery, skills]); const triggerFn = useBasicTypeaheadTriggerMatch('@', { minLength: 0, maxLength: 64, allowWhitespace: false, }); const skillTriggerFn = useBasicTypeaheadTriggerMatch('$', { minLength: 0, maxLength: 64, allowWhitespace: false, }); const insertReferences = useCallback( (nextReferences: ChatReference[]) => { if (nextReferences.length === 0) return; editor.update(() => { let selection = $getSelection(); // 跨会话恢复草稿后选区可能仍指向已被重建掉的节点,这里统一回落到草稿末尾, // 避免把引用插到一个已经不存在的位置。 if ( !$isRangeSelection(selection) || !selection.anchor.getNode().isAttached() ) { $getRoot().selectEnd(); selection = $getSelection(); } if ($isRangeSelection(selection)) { selection.insertNodes( nextReferences.flatMap((reference) => [ $createResourceReferenceNode(reference), $createTextNode(' '), ]), ); } }); editor.focus(); }, [editor], ); const openPicker = useCallback(() => { setPickerScopeStates(createResourcePickerScopeStates()); setPickerScopeOverride(null); setPickerOpen(true); }, []); const insertText = useCallback( (text: string) => { const insert = text.replace(/\s+$/u, ''); if (!insert.trim()) return; editor.update(() => { let selection = $getSelection(); if ( !$isRangeSelection(selection) || !selection.anchor.getNode().isAttached() ) { $getRoot().selectEnd(); selection = $getSelection(); } if ($isRangeSelection(selection)) { const rootText = $getRoot().getTextContent(); if (rootText && !/\s$/u.test(rootText)) selection.insertText(' '); selection.insertText(insert); } }); editor.focus(); }, [editor], ); useImperativeHandle( composerRef, () => ({ insertReferences, insertText, openPicker, focus: () => editor.focus(), clear: () => { editor.update(() => { applyContentToRoot([], assetsById); $getRoot().selectEnd(); }); }, replaceText: (text: string) => { editor.update(() => { const current = readDraftProjectionFromNodes(); applyDraftToRoot(text, current.references); $getRoot().selectEnd(); }); }, getDraft: () => readResourceReferenceDraft(editor.getEditorState()), }), [assetsById, editor, insertReferences, insertText, openPicker], ); useEffect(() => { editor.setEditable(!disabled); }, [disabled, editor]); const initialDraftAppliedRef = useRef(false); useEffect(() => { if (initialDraftAppliedRef.current || !initialContent) { return; } // Manifest assets can arrive after the initial editor mount. Do not mark the // draft as applied while resource references still have no lookup table; // otherwise the later assetsById update cannot restore those references. const hasUnresolvedResourceReference = initialContent.some( (part) => part.type === 'agc_resource_reference' && !assetsById.has(part.resourceId), ); if (hasUnresolvedResourceReference && assetsById.size === 0) { return; } // TODO: let the Lexical resource node resolve its asset label from the // manifest itself, so unresolved references can remain as nodes while the // manifest loads instead of waiting in this effect. initialDraftAppliedRef.current = true; editor.update(() => { applyContentToRoot(initialContent, assetsById); skipInitialDraftChangeRef.current = initialContent.length > 0; $getRoot().selectEnd(); }); }, [assetsById, editor, initialContent]); // 受控聊天入口在清空/恢复草稿时同步编辑器;普通输入变更若已与当前内容一致则不重建, // 避免每个按键都把光标跳到末尾。 useEffect(() => { 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.label}`, ) .join('|'); const desiredRefKey = desiredRefs .map( (reference) => `${reference.type}:${reference.type === 'resource' ? reference.resourceId : reference.label}`, ) .join('|'); if (current.text === desiredValue && currentRefKey === desiredRefKey) return; editor.update(() => { applyDraftToRoot(desiredValue, desiredRefs); $getRoot().selectEnd(); }); }, [editor, references, value]); // 资源改名后刷新已有引用 chip 的显示名:改写节点会触发 OnChangePlugin, // 把带新显示名的草稿同步回父级,chip 与候选列表都不会残留旧名。 // Lexical 的更新可能排到微任务里提交,这里额外挂一次更新监听兜底。 const refreshResourceReferenceLabels = useCallback(() => { if (assetsById.size === 0) return; const hasStaleReferences = editor .getEditorState() .read(() => $staleResourceReferenceNodes(assetsById).length > 0); if (!hasStaleReferences) return; editor.update(() => { $staleResourceReferenceNodes(assetsById).forEach( ({ node, reference }) => { node.replace($createResourceReferenceNode(reference)); }, ); }); }, [assetsById, editor]); useEffect(() => { refreshResourceReferenceLabels(); return editor.registerUpdateListener(() => { refreshResourceReferenceLabels(); }); }, [editor, refreshResourceReferenceLabels]); // Enter 提交只属于单行输入区(聊天 / 项目总控)。多行输入区(资源卡快速编辑提示词) // 的 Enter 必须留给换行,否则用户没法在提示词里分行。 useEffect(() => { if (multiline) return undefined; return editor.registerCommand( KEY_ENTER_COMMAND, (event) => { if ( !event || event.shiftKey || event.isComposing || // 候选菜单 / 选择器开着时 Enter 属于它们:返回 false 把按键让给 // LexicalTypeaheadMenuPlugin(它在 NORMAL 优先级选候选), // 而不是在 HIGH 优先级抢先提交表单。 mentionMenuOpenRef.current || pickerVisibleRef.current ) { return false; } event.preventDefault(); (event.target as HTMLElement | null)?.closest('form')?.requestSubmit(); return true; }, COMMAND_PRIORITY_HIGH, ); }, [editor, multiline]); useEffect( () => editor.registerCommand( KEY_ESCAPE_COMMAND, () => { if (!pickerOpen) return false; setPickerOpen(false); return true; }, COMMAND_PRIORITY_HIGH, ), [editor, pickerOpen], ); const handleSelectMention = useCallback( ( option: ResourceMentionOption, textNodeContainingQuery: TextNode | null, closeMenu: () => void, ) => { textNodeContainingQuery?.remove(); const selection = $getSelection(); if ($isRangeSelection(selection)) { selection.insertNodes([ $createResourceReferenceNode(option.reference), $createTextNode(' '), ]); } else { const paragraph = $createParagraphNode(); paragraph.append( $createResourceReferenceNode(option.reference), $createTextNode(' '), ); $getRoot().append(paragraph); } closeMenu(); }, [], ); const handleSelectSkill = useCallback( ( option: SkillMentionOption, textNodeContainingQuery: TextNode | null, closeMenu: () => void, ) => { textNodeContainingQuery?.remove(); const selection = $getSelection(); const node = $createResourceReferenceNode(option.skill); if ($isRangeSelection(selection)) { selection.insertNodes([node, $createTextNode(' ')]); } else { const paragraph = $createParagraphNode(); paragraph.append(node, $createTextNode(' ')); $getRoot().append(paragraph); } closeMenu(); }, [], ); // —— C8 AI 润色与发送前提醒 —— // 润色状态机抽到 `usePromptPolish`(资源侧两处入口共用同一份);这里只剩下 // 聊天特有的「发送前提醒」:提醒偏好、本轮已确认草稿指纹与表单拦截。 const [reminderOpen, setReminderOpen] = useState(false); const [reminderPolishing, setReminderPolishing] = useState(false); const [reminderDisabled, setReminderDisabled] = useState(() => readChatPromptPolishReminderDisabled(), ); const acknowledgedDraftKeyRef = useRef(null); // 拦截表单提交需要读到最新草稿,用 ref 保存本次渲染的草稿与派生值,避免闭包读到旧值。 const liveDraftRef = useRef({ text: directCodexContentToPromptText(initialContent ?? [], assets), references: [], content: initialContent ?? [], }); const [draftText, setDraftText] = useState(() => directCodexContentToPromptText(initialContent ?? [], assets), ); const reminderDisabledRef = useRef(reminderDisabled); reminderDisabledRef.current = reminderDisabled; // 提交拦截只注册一次,但草稿里的 @ 显示名依赖最新 assets;用 ref 让监听器拿到当前值。 const assetsRef = useRef(assets); assetsRef.current = assets; const applyPromptText = useCallback( (text: string) => { editor.update(() => { const current = readDraftProjectionFromNodes(); applyDraftToRoot(text, current.references); $getRoot().selectEnd(); }); }, [editor], ); const readPromptText = useCallback( () => directCodexContentToPromptText(liveDraftRef.current.content, assets), [assets], ); const resolvePolishContext = useCallback( () => projectPath || null, [projectPath], ); const promptPolishState = usePromptPolish({ readPrompt: readPromptText, applyPrompt: applyPromptText, resolveContext: resolvePolishContext, }); const { polishing, error: polishError, originalText: polishedOriginalText, polish: runPolish, restoreOriginal: restoreOriginalPrompt, clearError: clearPolishError, reset: resetPromptPolish, } = promptPolishState; const polishPrompt = useCallback(async () => { await runPolish(); }, [runPolish]); const closeReminder = useCallback(() => { setReminderOpen(false); clearPolishError(); }, [clearPolishError]); // 用户已在提醒面板里做出选择:记下本轮草稿指纹,再真正提交表单。 // 输入区不在表单里时(例如单独渲染的单元测试)没有可提交的表单事件,仅取消提醒状态。 const submitCurrentDraft = useCallback(() => { setReminderOpen(false); acknowledgedDraftKeyRef.current = chatPromptDraftKey( liveDraftRef.current.content, ); rootRef.current?.closest('form')?.requestSubmit(); }, [rootRef]); const useOriginalAndSubmit = useCallback(() => { clearPolishError(); submitCurrentDraft(); }, [clearPolishError, submitCurrentDraft]); const polishAndSubmitFromReminder = useCallback(async () => { if (reminderPolishing) return; setReminderPolishing(true); try { const polished = await runPolish({ failureMessage: 'AI 润色失败,可重试或使用原文提交', }); // 失败时留在提醒面板里,用户可以选择重试或直接使用原文提交。 if (!polished) return; submitCurrentDraft(); } finally { setReminderPolishing(false); } }, [reminderPolishing, runPolish, submitCurrentDraft]); const setPromptPolishReminderDisabled = useCallback((disabled: boolean) => { writeChatPromptPolishReminderDisabled(disabled); setReminderDisabled(disabled); }, []); // 发送前提醒拦截:在捕获阶段拦下 form 的 submit,阻止 React 的表单提交处理器执行, // 等用户在独立面板里做出选择后再用 requestSubmit() 真正提交。 useEffect(() => { const form = rootRef.current?.closest('form'); if (!form) return; const handleFormSubmit = (event: Event) => { if ( !shouldRemindChatPromptPolish({ content: liveDraftRef.current.content, prompt: directCodexContentToPromptText( liveDraftRef.current.content, assetsRef.current, ), acknowledgedDraftKey: acknowledgedDraftKeyRef.current, reminderDisabled: reminderDisabledRef.current, }) ) { return; } event.preventDefault(); event.stopPropagation(); setReminderOpen(true); }; form.addEventListener('submit', handleFormSubmit, true); return () => form.removeEventListener('submit', handleFormSubmit, true); }, [rootRef]); // 草稿发出去或被清空后重新开始一轮:清掉润色结果与「本轮已确认」标记。 useEffect(() => { if (liveDraftRef.current.content.length > 0) return; resetPromptPolish(); acknowledgedDraftKeyRef.current = null; }, [resetPromptPolish]); const renderMentionMenu: MenuRenderFn< ResourceMentionOption | SkillMentionOption > = useCallback( (_anchorElementRef, itemProps) => { const inputRect = rootRef.current?.getBoundingClientRect(); if (!inputRect || itemProps.options.length === 0) { return null; } const viewportPadding = 12; const menuWidth = Math.min( Math.max(280, inputRect.width), Math.min(420, window.innerWidth - viewportPadding * 2), ); const left = Math.min( Math.max(viewportPadding, inputRect.left), Math.max( viewportPadding, window.innerWidth - menuWidth - viewportPadding, ), ); const availableAbove = Math.max(0, inputRect.top - viewportPadding - 8); const availableBelow = Math.max( 0, window.innerHeight - inputRect.bottom - viewportPadding - 8, ); const openAbove = availableBelow < 160 && availableAbove > availableBelow; const maxHeight = Math.max( 120, Math.min(240, openAbove ? availableAbove : availableBelow), ); const top = openAbove ? Math.max(viewportPadding, inputRect.top - maxHeight - 8) : inputRect.bottom + 8; return createPortal(
{itemProps.options.map((option, index) => ( ))}
, document.body, ); }, [rootRef], ); const pickerReferences = useMemo(() => { return scopeReferences.filter((reference) => { if (!resourceReferenceMatchesQuery(reference, pickerQuery)) return false; if (!resourceReferenceMatchesCategoryFilter(reference, pickerFilter)) { return false; } return resourceReferenceMatchesTagSelection(reference, selectedTags); }); }, [pickerFilter, pickerQuery, scopeReferences, selectedTags]); const visiblePickerReferences = pickerReferences.slice(0, 120); const updatePickerPosition = useCallback(() => { const rect = rootRef.current?.getBoundingClientRect(); if (!rect) return; const width = Math.min( Math.max(rect.width, 360), Math.max(280, window.innerWidth - 24), ); const left = Math.min( Math.max(12, rect.left), Math.max(12, window.innerWidth - width - 12), ); setPickerPosition({ left, bottom: Math.max(12, window.innerHeight - rect.top + 8), width, }); }, [rootRef]); useEffect(() => { if (!pickerOpen) { setPickerPosition(null); return; } updatePickerPosition(); window.addEventListener('resize', updatePickerPosition); window.addEventListener('scroll', updatePickerPosition, true); return () => { window.removeEventListener('resize', updatePickerPosition); window.removeEventListener('scroll', updatePickerPosition, true); }; }, [pickerOpen, updatePickerPosition]); return ( <>
{showTriggerButton ? ( ) : null} {showPolishAction ? ( <> {polishedOriginalText !== null ? ( ) : null} ) : null}
{/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */} {showPolishAction && !reminderOpen && (polishing || polishError) ? ( {polishing ? '润色中…' : polishError} ) : null} options={mentionOptions} triggerFn={triggerFn} onQueryChange={setQuery} onSelectOption={(option, textNode, closeMenu) => handleSelectMention(option, textNode, closeMenu) } menuRenderFn={ renderMentionMenu as unknown as MenuRenderFn } anchorClassName="resource-reference-menu-anchor" preselectFirstItem /> options={skillOptions} triggerFn={skillTriggerFn} onQueryChange={setSkillQuery} onSelectOption={(option, textNode, closeMenu) => handleSelectSkill(option, textNode, closeMenu) } menuRenderFn={ renderMentionMenu as unknown as MenuRenderFn } anchorClassName="resource-reference-menu-anchor" preselectFirstItem /> {pickerOpen && pickerPosition ? createPortal(
选择素材
{ setPickerScopeOverride(nextScope); }} gap="sm" frame="bare" surface="transparent" size="sm" semantics="tabs" ariaLabel="素材范围" className="platform-theme platform-theme--light resource-reference-picker-scopes" /> {/* 搜索 + 功能分类 + 标签用共享筛选条:与资源画布、参考图弹窗同一套筛选 UI, 标签口径也来自共享标签库;两个页签各自持有一份独立状态。 */} updatePickerScopeState({ query: value }), }} categoryItems={RESOURCE_REFERENCE_FILTERS} activeCategoryId={pickerFilter} onCategoryChange={(nextFilter) => updatePickerScopeState({ filter: nextFilter }) } tagItems={scopeTagItems} activeTags={selectedTags} onToggleTag={(tag) => updatePickerScopeState({ selectedTags: selectedTags.includes(tag) ? selectedTags.filter((item) => item !== tag) : [...selectedTags, tag], }) } className="platform-theme platform-theme--light resource-reference-picker-filters" />
{visiblePickerReferences.length === 0 ? (

{pickerEmptyMessage(scopeReferences.length, pickerScope)}

) : ( visiblePickerReferences.map((reference) => { const selected = selectedResourceIds.includes( reference.resourceId, ); return ( ); }) )}
已选择 {selectedResourceIds.length} 个 {pickerReferences.length > visiblePickerReferences.length ? `,当前显示前 ${visiblePickerReferences.length} 个` : ''}
, document.body, ) : null} {reminderOpen ? ( void polishAndSubmitFromReminder()} onUseOriginalAndSubmit={useOriginalAndSubmit} onClose={closeReminder} onReminderDisabledChange={setPromptPolishReminderDisabled} /> ) : null} { const nextDraft = readResourceReferenceDraft(editorState); liveDraftRef.current = nextDraft; setDraftText( directCodexContentToPromptText(nextDraft.content, assets), ); if (nextDraft.content.length === 0) { resetPromptPolish(); acknowledgedDraftKeyRef.current = null; } if (skipInitialDraftChangeRef.current) { skipInitialDraftChangeRef.current = false; return; } onEditorStateChange?.(editorState); onChange?.(nextDraft); }} /> ); } function ResourcePickerThumbnail({ asset, projectPath, }: { asset?: GameCreationAppAssetManifestEntry; projectPath: string; }) { const previewScopeIdRef = useRef(createProjectResourcePreviewScopeId()); const [previewUrl, setPreviewUrl] = useState(null); const [failed, setFailed] = useState(false); const mediaType = asset?.mediaType.toLowerCase() ?? ''; const kind = asset?.kind.toLowerCase() ?? ''; useEffect(() => { setPreviewUrl(null); setFailed(false); if (!asset || !projectPath || !mediaType.startsWith('image/')) { return; } const invoke = resolveTauriInvoke(); if (!invoke) return; let cancelled = false; void invoke<{ dataUrl: string }>('read_local_project_image_preview', { projectPath, relativePath: asset.localPath, scopeId: previewScopeIdRef.current, requestId: createProjectResourcePreviewRequestId(), }) .then((result) => { if (!cancelled) setPreviewUrl(result.dataUrl); }) .catch(() => { if (!cancelled) setFailed(true); }); return () => { cancelled = true; // 只靠 cancelled 标记只是忽略迟到的结果,请求本身还在跑;换素材 / 卸载时 // 主动作废上一个 scope,避免快速切换素材叠出一串在飞预览请求。 cancelLocalProjectResourcePreviewScope(previewScopeIdRef.current); previewScopeIdRef.current = createProjectResourcePreviewScopeId(); }; }, [asset, mediaType, projectPath]); if (previewUrl) { return ; } if (failed) { return