Files
Genarrative/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx
T
k88936 0caf998227
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m35s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m31s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m15s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m47s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m15s
Project CI / Repository checks (pull_request) Failing after 19s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m15s
Project CI / Frontend tests (pull_request) Failing after 5m44s
Project CI / AI game creator shell web tests (pull_request) Successful in 5m49s
Project CI / Native shell tests (pull_request) Successful in 10m42s
重新生成 ts-rs 前端绑定并让前端按 null 语义构造运行时区段
- 跑 export_bindings 后 prettier 格式化:DirectCodexUser* 5 个文件内容更新,新增 DirectCodexUserMessageEnvelope.ts 与 ui-editor 的 BindingChange/BindingDTO
- DirectCodexUserRuntimeRegionPart 的可选字段由 `?:` 变成 `T | null`(Rust Option 会显式序列化成 null,不是省略键)
- chatReferenceToContentPart 改用 `?? null`,消息条目的 satisfies 目标改为 DirectCodexUserItem(type:'message' 归到信封类型)
- ResourceReferenceInput 不再复制一份区段字面量,改为复用 chatReferenceToContentPart
- 验证:tsc --noEmit 通过;resourceReferences 等 5 个用例集 81 passed;appSurface 452 passed / 17 skipped(连跑两次)
2026-09-17 14:03:05 +08:00

1321 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
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, flushSync } 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 {
cancelLocalProjectResourcePreviewScope,
createProjectResourcePreviewRequestId,
createProjectResourcePreviewScopeId,
} from '../../services/projectResourcePreviewTransport';
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,
chatReferenceListKey,
chatReferenceToContentPart,
currentIterationVersionAssets,
dedupeChatReferences,
refreshResourceReference,
RESOURCE_REFERENCE_FILTERS,
RESOURCE_REFERENCE_SCOPES,
type ResourceReference,
resourceReferenceCategoryLabel,
type ResourceReferenceFilter,
resourceReferenceFromAsset,
resourceReferenceMatchesCategoryFilter,
resourceReferenceMatchesQuery,
resourceReferenceMatchesTagSelection,
type ResourceReferenceScope,
resourceReferenceTagLibrary,
} from './resourceReferences';
import { usePromptPolish } from './usePromptPolish';
type ResourceReferenceInputProps = {
value: string;
references: ChatReference[];
onChange: (draft: ChatComposerDraft) => void;
assets: GameCreationAppAssetManifestEntry[];
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<HTMLDivElement | null>;
};
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;
};
class ResourceMentionOption extends MenuOption {
reference: ResourceReference;
constructor(reference: ResourceReference) {
super(reference.resourceId);
this.reference = reference;
}
}
function referenceListKey(references: ChatReference[]) {
return chatReferenceListKey(references);
}
function sameDraft(left: ChatComposerDraft, right: ChatComposerDraft) {
return (
left.text === right.text &&
referenceListKey(left.references) === referenceListKey(right.references)
);
}
function collectDraftParts(
node: LexicalNode,
textParts: string[],
references: ChatReference[],
content: DirectCodexUserContentPart[],
) {
if ($isTextNode(node)) {
const text = node.getTextContent();
textParts.push(text);
if (text) content.push({ type: 'input_text', text });
return;
}
if ($isLineBreakNode(node)) {
textParts.push('\n');
content.push({ type: 'input_text', text: '\n' });
return;
}
if ($isResourceReferenceNode(node)) {
textParts.push(`@${node.__reference.label}`);
references.push(node.__reference);
content.push(chatReferenceToContentPart(node.__reference));
return;
}
if ($isElementNode(node)) {
node.getChildren().forEach((child, index) => {
if (index > 0 && node.getType() === 'root') {
textParts.push('\n');
content.push({ type: 'input_text', text: '\n' });
}
collectDraftParts(child, textParts, references, content);
});
}
}
/** 按编辑器自己的口径读草稿;只能在 Lexical 的读 / 更新上下文里调用。 */
function readDraftFromNodes(): ChatComposerDraft {
const textParts: string[] = [];
const references: ChatReference[] = [];
const content: DirectCodexUserContentPart[] = [];
collectDraftParts($getRoot(), textParts, references, content);
return {
text: textParts.join('').trim(),
references: dedupeChatReferences(references),
content,
};
}
function readDraftFromEditorState(editorState: EditorState): ChatComposerDraft {
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;
}
/**
* 把「文本 + 引用列表」这对外部表示切成编辑区要写的内容。
*
* 不变量:切完写进编辑器后,编辑器读回来的草稿必须与 props 等价。`collectDraftParts`
* 会把每个 chip 读成一段 `@显示名` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上,
* 不能另起一段堆在末尾——否则读回来的文本每重建一轮就多一段 `@显示名`,
* `sameDraft` 永远判定为不相等,编辑器就会一轮轮重建、文本一轮轮变长。
*
* 文本里已经没有对应 token 的引用(例如 AI 润色整体改写了文本)内联补到最后一段末尾:
* 引用不会凭空消失,而且只补一次——下一轮 props 里就带上这个 token,重建随即收敛。
*/
function buildDraftSegments(
value: string,
references: ChatReference[],
): DraftBuildSegment[][] {
const pending = references.map((reference) => ({
reference,
token: `@${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 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<string, GameCreationAppAssetManifestEntry>,
) {
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,
assets,
projectPath,
activeVersionId = null,
versions,
disabled,
placeholder,
ariaLabel,
multiline,
rows,
showTriggerButton = true,
showPolishAction = true,
inputRef,
composerRef,
}: ResourceReferenceInputProps & {
composerRef?: Ref<ResourceReferenceInputHandle>;
}) {
const [editor] = useLexicalComposerContext();
const lastEmittedDraftRef = useRef<ChatComposerDraft>({
text: '',
references: [],
});
const [query, setQuery] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
// Enter 提交要避开候选浮层。用 ref 让命令处理器读到最新状态,不必因为
// query / pickerOpen 变化反复重注册 HIGH 优先级命令。
const mentionMenuOpenRef = useRef(false);
const pickerVisibleRef = useRef(false);
useEffect(() => {
mentionMenuOpenRef.current = query !== null;
}, [query]);
useEffect(() => {
pickerVisibleRef.current = pickerOpen;
}, [pickerOpen]);
const [pickerScopeStates, setPickerScopeStates] = useState<
Record<ResourceReferenceScope, ResourcePickerScopeState>
>(createResourcePickerScopeStates);
const [pickerScopeOverride, setPickerScopeOverride] =
useState<ResourceReferenceScope | null>(null);
const [pickerPosition, setPickerPosition] = useState<{
left: number;
bottom: number;
width: number;
} | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const assetsContentSignature = assetsSignature(assets);
const versionsContentSignature = iterationsSignature(versions);
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<ResourcePickerScopeState>) => {
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 triggerFn = 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();
// 选区失效(跨会话恢复草稿后常见)时回落到草稿末尾,与 `insertReferences` 同口径。
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(),
}),
[editor, insertReferences, insertText, openPicker],
);
useEffect(() => {
editor.setEditable(!disabled);
}, [disabled, editor]);
useEffect(() => {
const nextDraft: ChatComposerDraft = {
text: value,
references,
};
if (sameDraft(lastEmittedDraftRef.current, nextDraft)) {
return;
}
editor.update(() => {
applyDraftToRoot(value, references);
// 立刻按编辑器自己的口径读一遍刚写进去的内容,并记为「已同步草稿」:
// `OnChangePlugin` 稍后读到的就是这一份,两边一致才不会触发下一轮重建。
lastEmittedDraftRef.current = readDraftFromNodes();
// 程序化重建草稿(切会话 / 重开会话恢复草稿)后把光标收回草稿末尾:
// 既保证恢复后光标落在文本末尾,也保证后续 @ 引用按顺序追加而不是插到旧位置。
$getRoot().selectEnd();
});
}, [editor, references, value]);
const assetsById = useMemo(
() => new Map(assets.map((asset) => [asset.id, asset])),
// 依赖内容签名:assets 数组身份每次渲染都会变,内容不变时没必要重建索引。
// eslint-disable-next-line react-hooks/exhaustive-deps
[assetsContentSignature],
);
// 资源改名后刷新已有引用 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();
},
[],
);
// —— C8 AI 润色与发送前提醒 ——
// 润色状态机抽到 `usePromptPolish`(资源侧两处入口共用同一份);这里只剩下
// 聊天特有的「发送前提醒」:提醒偏好、本轮已确认草稿指纹与表单拦截。
const [reminderOpen, setReminderOpen] = useState(false);
const [reminderPolishing, setReminderPolishing] = useState(false);
const [reminderDisabled, setReminderDisabled] = useState(() =>
readChatPromptPolishReminderDisabled(),
);
const acknowledgedDraftKeyRef = useRef<string | null>(null);
// 拦截表单提交需要读到最新草稿,用 ref 保存本次渲染的草稿与派生值,避免闭包读到旧值。
const liveDraftRef = useRef<ChatComposerDraft>({ text: value, references });
liveDraftRef.current = { text: value, references };
const reminderDisabledRef = useRef(reminderDisabled);
reminderDisabledRef.current = reminderDisabled;
const applyPromptText = useCallback(
(text: string) => {
flushSync(() => {
onChange({ text, references: liveDraftRef.current.references });
});
},
[onChange],
);
const readPromptText = useCallback(() => liveDraftRef.current.text, []);
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);
rootRef.current?.closest('form')?.requestSubmit();
}, []);
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({
draft: liveDraftRef.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);
}, []);
// 草稿发出去或被清空后重新开始一轮:清掉润色结果与「本轮已确认」标记。
useEffect(() => {
if (value.trim() !== '' || references.length > 0) return;
resetPromptPolish();
acknowledgedDraftKeyRef.current = null;
}, [references, resetPromptPolish, value]);
const renderMentionMenu: MenuRenderFn<ResourceMentionOption> = 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(
<div
className="resource-reference-menu"
role="listbox"
aria-label="候选素材"
style={{
position: 'fixed',
top: `${top}px`,
left: `${left}px`,
width: `${menuWidth}px`,
maxHeight: `${maxHeight}px`,
}}
>
{itemProps.options.map((option, index) => (
<button
type="button"
role="option"
key={option.key}
ref={(element) => option.setRefElement(element)}
aria-selected={itemProps.selectedIndex === index}
className={
itemProps.selectedIndex === index ? 'is-active' : undefined
}
onMouseEnter={() => itemProps.setHighlightedIndex(index)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => itemProps.selectOptionAndCleanUp(option)}
>
<span>{option.reference.label}</span>
<small>{option.reference.kind}</small>
</button>
))}
</div>,
document.body,
);
},
[],
);
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,
});
}, []);
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 (
<div
ref={rootRef}
className={`resource-reference-input${multiline ? '' : ' is-single-line'}`}
data-disabled={disabled ? 'true' : undefined}
>
<RichTextPlugin
contentEditable={
<ContentEditable
ref={inputRef}
aria-label={ariaLabel}
className="resource-reference-input-editor"
style={
multiline && rows
? { minHeight: `${Math.max(72, rows * 22)}px` }
: undefined
}
/>
}
placeholder={
<span className="resource-reference-input-placeholder">
{placeholder}
</span>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<div className="resource-reference-input-actions">
{showTriggerButton ? (
<button
type="button"
className="resource-reference-input-at"
aria-label="插入素材引用"
title="插入素材引用"
disabled={disabled}
onMouseDown={(event) => event.preventDefault()}
onClick={openPicker}
>
<AtSign size={15} aria-hidden="true" />
</button>
) : null}
{showPolishAction ? (
<>
<button
type="button"
className="resource-reference-input-polish"
aria-label="AI 润色"
title="AI 润色"
aria-busy={polishing}
disabled={disabled || polishing || value.trim() === ''}
onMouseDown={(event) => event.preventDefault()}
onClick={() => void polishPrompt()}
>
{polishing ? (
<Loader2
size={15}
className="animate-spin"
aria-hidden="true"
/>
) : (
<Sparkles size={15} aria-hidden="true" />
)}
</button>
{polishedOriginalText !== null ? (
<button
type="button"
className="resource-reference-input-restore"
aria-label="恢复原文"
title="恢复原文"
disabled={disabled || polishing}
onMouseDown={(event) => event.preventDefault()}
onClick={restoreOriginalPrompt}
>
<RotateCcw size={15} aria-hidden="true" />
</button>
) : null}
</>
) : null}
</div>
{/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */}
{showPolishAction && !reminderOpen && (polishing || polishError) ? (
<span
className="resource-reference-input-status"
role="status"
aria-live="polite"
>
{polishing ? '润色中…' : polishError}
</span>
) : null}
<LexicalTypeaheadMenuPlugin<ResourceMentionOption>
options={mentionOptions}
triggerFn={triggerFn}
onQueryChange={setQuery}
onSelectOption={(option, textNode, closeMenu) =>
handleSelectMention(option, textNode, closeMenu)
}
menuRenderFn={renderMentionMenu}
anchorClassName="resource-reference-menu-anchor"
preselectFirstItem
/>
{pickerOpen && pickerPosition
? createPortal(
<div
className="resource-reference-picker"
role="dialog"
aria-modal="false"
aria-label="选择素材"
style={{
left: `${pickerPosition.left}px`,
bottom: `${pickerPosition.bottom}px`,
right: 'auto',
width: `${pickerPosition.width}px`,
}}
>
<header>
<strong>选择素材</strong>
<button
type="button"
aria-label="关闭素材选择"
onClick={() => setPickerOpen(false)}
>
×
</button>
</header>
<PlatformSegmentedTabs
items={RESOURCE_REFERENCE_SCOPES}
activeId={pickerScope}
onChange={(nextScope) => {
setPickerScopeOverride(nextScope);
}}
gap="sm"
frame="bare"
surface="transparent"
size="sm"
semantics="tabs"
ariaLabel="素材范围"
className="platform-theme platform-theme--light resource-reference-picker-scopes"
/>
{/* 搜索 + 功能分类 + 标签用共享筛选条:与资源画布、参考图弹窗同一套筛选 UI,
标签口径也来自共享标签库;两个页签各自持有一份独立状态。 */}
<PlatformResourceFilterBar
ariaLabel="素材筛选"
search={{
value: pickerQuery,
label:
pickerScope === 'current-version'
? '搜索当前版本素材'
: '搜索全部画布素材',
placeholder: '搜索名称或资源 ID',
onChange: (value) => 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"
/>
<div className="resource-reference-picker-list" role="listbox">
{visiblePickerReferences.length === 0 ? (
<p className="resource-reference-picker-empty">
{pickerEmptyMessage(scopeReferences.length, pickerScope)}
</p>
) : (
visiblePickerReferences.map((reference) => {
const selected = selectedResourceIds.includes(
reference.resourceId,
);
return (
<button
type="button"
role="option"
aria-selected={selected}
key={`${reference.resourceId}:${reference.kind}`}
className={selected ? 'is-selected' : undefined}
onClick={() =>
updatePickerScopeState({
selectedResourceIds: selected
? selectedResourceIds.filter(
(resourceId) =>
resourceId !== reference.resourceId,
)
: [...selectedResourceIds, reference.resourceId],
})
}
>
<ResourcePickerThumbnail
asset={assetsById.get(reference.resourceId)}
projectPath={projectPath}
/>
<span>
<strong>{reference.label}</strong>
<small>
{resourceReferenceCategoryLabel(reference.category)}{' '}
· {reference.kind} · {reference.mediaType}
</small>
</span>
{selected ? (
<Check size={14} aria-hidden="true" />
) : null}
</button>
);
})
)}
</div>
<footer>
<span>
已选择 {selectedResourceIds.length}
{pickerReferences.length > visiblePickerReferences.length
? `,当前显示前 ${visiblePickerReferences.length} 个`
: ''}
</span>
<button
type="button"
disabled={selectedResourceIds.length === 0}
onClick={() => {
insertReferences(
scopeReferences.filter((reference) =>
selectedResourceIds.includes(reference.resourceId),
),
);
setPickerOpen(false);
}}
>
插入引用
</button>
</footer>
</div>,
document.body,
)
: null}
{reminderOpen ? (
<ChatPromptPolishReminder
busy={reminderPolishing}
error={polishError}
reminderDisabled={reminderDisabled}
onPolishAndSubmit={() => void polishAndSubmitFromReminder()}
onUseOriginalAndSubmit={useOriginalAndSubmit}
onClose={closeReminder}
onReminderDisabledChange={setPromptPolishReminderDisabled}
/>
) : null}
<OnChangePlugin
onChange={(editorState) => {
const nextDraft = readDraftFromEditorState(editorState);
if (sameDraft(lastEmittedDraftRef.current, nextDraft)) {
return;
}
lastEmittedDraftRef.current = nextDraft;
onChange(nextDraft);
}}
/>
</div>
);
}
function ResourcePickerThumbnail({
asset,
projectPath,
}: {
asset?: GameCreationAppAssetManifestEntry;
projectPath: string;
}) {
const previewScopeIdRef = useRef(createProjectResourcePreviewScopeId());
const [previewUrl, setPreviewUrl] = useState<string | null>(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 <img src={previewUrl} alt="" />;
}
if (failed) {
return <FileText size={18} aria-hidden="true" />;
}
if (mediaType.startsWith('video/')) {
return <Video size={18} aria-hidden="true" />;
}
if (mediaType.startsWith('audio/')) {
return <Music2 size={18} aria-hidden="true" />;
}
if (mediaType.startsWith('image/')) {
return <Loader2 size={18} className="animate-spin" aria-hidden="true" />;
}
if (kind === 'ui' || mediaType === 'application/json') {
return <FileText size={18} aria-hidden="true" />;
}
return <ImageIcon size={18} aria-hidden="true" />;
}
export const ResourceReferenceInput = forwardRef<
ResourceReferenceInputHandle,
ResourceReferenceInputProps
>(function ResourceReferenceInput(props, ref) {
return (
<LexicalComposer
initialConfig={{
namespace: 'agc-resource-reference-input',
nodes: [ResourceReferenceNode],
onError: (error) => {
throw error;
},
}}
>
<ResourceReferenceEditor {...props} composerRef={ref} />
</LexicalComposer>
);
});