Files
Genarrative/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx
T
k88936 1e27cd229d 接入客户端已启用Skill候选
从扩展索引读取启用状态并合并内置 Skill 清单

输入提示只展示可用 Skill 并保持取消安全
2026-09-18 14:26:49 +08:00

1611 lines
54 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 { 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<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;
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<string, GameCreationAppAssetManifestEntry>,
): 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<string, GameCreationAppAssetManifestEntry>,
) {
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<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,
onEditorStateChange,
initialContent,
assets,
skills = [],
projectPath,
activeVersionId = null,
versions,
disabled,
multiline,
showTriggerButton = true,
showPolishAction = true,
composerRef,
rootRef,
}: ResourceReferenceInputProps & {
composerRef?: Ref<ResourceReferenceInputHandle>;
rootRef: RefObject<HTMLDivElement | null>;
}) {
const [editor] = useLexicalComposerContext();
const skipInitialDraftChangeRef = useRef(false);
const [query, setQuery] = useState<string | null>(null);
const [skillQuery, setSkillQuery] = useState<string | null>(null);
const [clientSkills, setClientSkills] = useState<SkillReference[]>([]);
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<ResourceReferenceScope, ResourcePickerScopeState>
>(createResourcePickerScopeStates);
const [pickerScopeOverride, setPickerScopeOverride] =
useState<ResourceReferenceScope | null>(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<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 skillOptions = useMemo(() => {
if (skillQuery === null) return [];
const normalized = skillQuery.trim().toLowerCase();
const allSkills = [...BUILTIN_SKILL_REFERENCES, ...clientSkills, ...skills];
const seen = new Set<string>();
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<string | null>(null);
// 拦截表单提交需要读到最新草稿,用 ref 保存本次渲染的草稿与派生值,避免闭包读到旧值。
const liveDraftRef = useRef<ChatComposerDraft>({
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(
<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>
{'reference' in option
? `@${option.reference.label}`
: `$${option.skill.name}`}
</span>
<small>
{'reference' in option
? option.reference.kind
: (option.skill.description ?? 'Skill')}
</small>
</button>
))}
</div>,
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 (
<>
<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 || draftText.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 as unknown as MenuRenderFn<ResourceMentionOption>
}
anchorClassName="resource-reference-menu-anchor"
preselectFirstItem
/>
<LexicalTypeaheadMenuPlugin<SkillMentionOption>
options={skillOptions}
triggerFn={skillTriggerFn}
onQueryChange={setSkillQuery}
onSelectOption={(option, textNode, closeMenu) =>
handleSelectSkill(option, textNode, closeMenu)
}
menuRenderFn={
renderMentionMenu as unknown as MenuRenderFn<SkillMentionOption>
}
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 = 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<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) {
const rootRef = useRef<HTMLDivElement | null>(null);
return (
<RichTextInput
namespace="agc-resource-reference-input"
nodes={[ResourceReferenceNode, AttachmentReferenceNode]}
containerRef={rootRef}
containerClassName={`resource-reference-input${props.multiline ? '' : ' is-single-line'}`}
disabled={props.disabled}
contentEditable={
<ContentEditable
aria-label={props.ariaLabel}
className="resource-reference-input-editor"
ref={props.inputRef}
style={
props.multiline && props.rows
? { minHeight: `${Math.max(72, props.rows * 22)}px` }
: undefined
}
/>
}
placeholder={
<span className="resource-reference-input-placeholder">
{props.placeholder}
</span>
}
>
<ResourceReferenceEditor {...props} composerRef={ref} rootRef={rootRef} />
</RichTextInput>
);
});