1bd153d7f2
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m56s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 6m9s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m28s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m33s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m23s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m39s
Project CI / Repository checks (pull_request) Successful in 4m50s
Project CI / Backend tests (pull_request) Successful in 8m38s
Project CI / Frontend tests (pull_request) Successful in 8m2s
Project CI / AI game creator shell web tests (pull_request) Successful in 8m11s
Project CI / Native shell tests (pull_request) Successful in 15m27s
合入 master a2e6f205d,保留远端功能分支已有合并历史。 融合生成选项的参考素材、目标栏目与抠图背景色字段。 保留主线统一线程状态与重试策略,完成前端及原生定向验证。
1357 lines
46 KiB
TypeScript
1357 lines
46 KiB
TypeScript
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;
|
||
top: number;
|
||
width: number;
|
||
maxHeight: 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 viewportPadding = 12;
|
||
const gap = 8;
|
||
const width = Math.min(
|
||
Math.max(rect.width, 360),
|
||
Math.max(280, window.innerWidth - viewportPadding * 2),
|
||
);
|
||
const left = Math.min(
|
||
Math.max(viewportPadding, rect.left),
|
||
Math.max(viewportPadding, window.innerWidth - width - viewportPadding),
|
||
);
|
||
/**
|
||
* 上边界钳制:面板高度**必须**由输入框上下实际可用的空间决定。
|
||
*
|
||
* 之前只把底边钉在输入框上方(`bottom: 视口高 - rect.top + 8`)却让高度自由取到 480px,
|
||
* 输入框靠上时(居中弹层里的提示词输入、窄屏)整块面板的顶边会被顶出视口——顶部那一排
|
||
* 搜索与筛选既看不见也点不到。这里与 `@` 候选菜单同一套口径:先算上下各有多少空间,
|
||
* 空间不足就翻到下方,并把高度收在该侧可用空间内,再对 `top` 兜一次底。
|
||
*/
|
||
const availableAbove = Math.max(0, rect.top - viewportPadding - gap);
|
||
const availableBelow = Math.max(
|
||
0,
|
||
window.innerHeight - rect.bottom - viewportPadding - gap,
|
||
);
|
||
const openAbove = availableAbove >= 200 || availableAbove >= availableBelow;
|
||
const maxHeight = Math.max(
|
||
160,
|
||
Math.min(480, openAbove ? availableAbove : availableBelow),
|
||
);
|
||
const top = openAbove
|
||
? Math.max(viewportPadding, rect.top - gap - maxHeight)
|
||
: Math.min(
|
||
Math.max(
|
||
viewportPadding,
|
||
window.innerHeight - viewportPadding - maxHeight,
|
||
),
|
||
rect.bottom + gap,
|
||
);
|
||
setPickerPosition({
|
||
left,
|
||
top,
|
||
width,
|
||
maxHeight,
|
||
});
|
||
}, []);
|
||
|
||
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={{
|
||
// 与 `@` 候选菜单同一坐标系(fixed + top + 高度钳制):底边锚点在
|
||
// 输入框上方会被顶出视口,只有钉住顶边并收紧高度才能保证整块面板可见。
|
||
position: 'fixed',
|
||
top: `${pickerPosition.top}px`,
|
||
left: `${pickerPosition.left}px`,
|
||
right: 'auto',
|
||
bottom: 'auto',
|
||
width: `${pickerPosition.width}px`,
|
||
maxHeight: `${pickerPosition.maxHeight}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>
|
||
);
|
||
});
|