diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 2403d3913..caf994182 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -203,7 +203,6 @@ export function App({ initialProjectManifest, initialProjectKind = 'web', planningStartMode = false, - activeVersionId = null, initialPlanningPrompt = '', initialPlanningPromptClaimScope = '', initialCreationType = null, @@ -2132,11 +2131,6 @@ export function App({ const chatProjectAssets = manifest.assets.filter( (asset) => asset.localPath && !asset.localPath.startsWith('.agent/'), ); - // `@` 面板「当前版本素材」的版本来源:版本列表来自 manifest, - // 当前版本由工作台壳(`WorkspaceLauncherShell`)持有的那一份状态给出, - // 传 `null` 表示回退到 manifest 中最新的版本。 - const chatProjectVersions = manifest.versions ?? []; - const chatActiveVersionId = activeVersionId; const visibleMessages = latestVisibleItems( messages, @@ -2253,7 +2247,6 @@ export function App({ <> void handleDesignComposerUploadFiles(files)} - versions={chatProjectVersions} /> {runtimeConfigOpen ? ( ; - -/** 附件在纯文本里的唯一占位字符:一个附件 chip 就当一个字符。 */ -const OBJECT_REPLACEMENT = '\uFFFC'; - -/** - * Inline canonical attachment part rendered inside the Lexical composer. - * The node stores the complete part so read-back preserves the project path and status. - */ -export class AttachmentReferenceNode extends DecoratorNode { - __attachment: DirectCodexUserAttachmentReferencePart; - - static getType() { - return 'attachment-reference'; - } - - static clone(node: AttachmentReferenceNode) { - return new AttachmentReferenceNode(node.__attachment, node.__key); - } - - static importJSON(serializedNode: SerializedAttachmentReferenceNode) { - return $createAttachmentReferenceNode(serializedNode.attachment); - } - - constructor( - attachment: DirectCodexUserAttachmentReferencePart, - key?: NodeKey, - ) { - super(key); - this.__attachment = attachment; - } - - exportJSON(): SerializedAttachmentReferenceNode { - return { - ...super.exportJSON(), - attachment: this.__attachment, - type: 'attachment-reference', - version: 1, - }; - } - - createDOM(_config: EditorConfig) { - return document.createElement('span'); - } - - updateDOM() { - return false; - } - - getTextContent() { - return OBJECT_REPLACEMENT; - } - - getTextContentSize() { - return OBJECT_REPLACEMENT.length; - } - - isInline() { - return true; - } - - isKeyboardSelectable() { - return true; - } - - decorate() { - return ( - - ); - } -} - -export function $createAttachmentReferenceNode( - attachment: DirectCodexUserAttachmentReferencePart, -) { - return $applyNodeReplacement(new AttachmentReferenceNode(attachment)); -} - -export function $isAttachmentReferenceNode( - node: LexicalNode | null | undefined, -): node is AttachmentReferenceNode { - return node instanceof AttachmentReferenceNode; -} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceChip.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceChip.tsx index 80a5d81c3..c67090dc2 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceChip.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceChip.tsx @@ -11,9 +11,20 @@ function chipTitle(reference: ChatReference) { if (reference.type === 'skill') { return `${reference.name} · Skill`; } + if (reference.type === 'attachment') { + return `${reference.name} · 附件`; + } return `${reference.label} · 运行区域`; } +/** chip 显示的引用名:附件有自己的芯片(`AttachmentReferenceChip`),这里只是把联合收窄。 */ +function chipLabel(reference: ChatReference) { + if (reference.type === 'skill' || reference.type === 'attachment') { + return reference.name; + } + return reference.label; +} + export function ResourceReferenceChip({ reference, nodeKey, @@ -39,11 +50,11 @@ export function ResourceReferenceChip({ > - {reference.type === 'skill' ? reference.name : reference.label} + {chipLabel(reference)} + {polishedOriginalText !== null ? ( + + ) : null} + + ) : null} + + {/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */} + {/* 「与原文相同 / 已截断」这类提示也要可见:只报失败会让「润色没变化」看起来像按钮坏了。 */} + {showPolishAction && + !reminderOpen && + (polishing || polishError || polishNotice) ? ( + + {polishing ? '润色中…' : (polishError ?? polishNotice)} + + ) : null} + {providers.map((provider) => + provider.trigger ? ( + + ) : null, + )} + {reminderOpen ? ( + void polishAndSubmitFromReminder()} + onUseOriginalAndSubmit={useOriginalAndSubmit} + onClose={closeReminder} + onReminderDisabledChange={setPromptPolishReminderDisabled} + /> + ) : null} + { + const nextDraft = readResourceReferenceDraft(editorState); + liveDraftRef.current = nextDraft; + setDraftText( + promptTextFromContent(providersRef.current, nextDraft.content), + ); + if (nextDraft.content.length === 0) { + resetPromptPolish(); + acknowledgedDraftKeyRef.current = null; + } + if (skipInitialDraftChangeRef.current) { + skipInitialDraftChangeRef.current = false; + return; + } + onEditorStateChange?.(editorState); + onChange?.(nextDraft); + }} + /> + + ); +} + +/** + * 一种引用来源的候选菜单:触发符、候选与插入全按 provider 走。 + * + * 输入区不再写死 `@` / `$` 两条链,而是「有几个带触发符的 provider 就有几个菜单」。 + */ +function ProviderMentionMenu({ + provider, + rootRef, + onOpenChange, +}: { + provider: ReferenceProvider; + rootRef: RefObject; + onOpenChange: (trigger: string, open: boolean) => void; +}) { + const trigger = provider.trigger ?? ''; + const [query, setQuery] = useState(null); + const triggerFn = useBasicTypeaheadTriggerMatch(trigger, { + minLength: 0, + maxLength: 64, + allowWhitespace: false, + }); + + const handleQueryChange = useCallback( + (nextQuery: string | null) => { + setQuery(nextQuery); + onOpenChange(trigger, nextQuery !== null); + }, + [onOpenChange, trigger], + ); + + const options = useMemo(() => { + if (query === null || !provider.match) return []; + return provider + .match(query) + .map((reference) => new ReferenceMentionOption(reference)); + }, [provider, query]); + + const renderMenu: MenuRenderFn = useCallback( (_anchorElementRef, itemProps) => { const inputRect = rootRef.current?.getBoundingClientRect(); if (!inputRect || itemProps.options.length === 0) { @@ -1071,16 +858,8 @@ function ResourceReferenceEditor({ onMouseDown={(event) => event.preventDefault()} onClick={() => itemProps.selectOptionAndCleanUp(option)} > - - {'reference' in option - ? `@${option.reference.label}` - : `$${option.skill.name}`} - - - {'reference' in option - ? option.reference.kind - : (option.skill.description ?? 'Skill')} - + {option.label} + {option.hint} ))} , @@ -1090,425 +869,57 @@ function ResourceReferenceEditor({ [rootRef], ); - const pickerReferences = useMemo(() => { - return scopeReferences.filter((reference) => { - if (!resourceReferenceMatchesQuery(reference, pickerQuery)) return false; - if (!resourceReferenceMatchesCategoryFilter(reference, pickerFilter)) { - return false; + const handleSelectOption = useCallback( + ( + option: ReferenceMentionOption, + textNodeContainingQuery: TextNode | null, + closeMenu: () => void, + ) => { + textNodeContainingQuery?.remove(); + const selection = $getSelection(); + const node = $createResourceReferenceNode(option.reference); + if ($isRangeSelection(selection)) { + selection.insertNodes([node, $createTextNode(' ')]); + } else { + const paragraph = $createParagraphNode(); + paragraph.append(node, $createTextNode(' ')); + $getRoot().append(paragraph); } - 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, - }); - }, [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]); + closeMenu(); + }, + [], + ); return ( - <> -
- {showTriggerButton ? ( - - ) : null} - {showPolishAction ? ( - <> - - {polishedOriginalText !== null ? ( - - ) : null} - - ) : null} -
- {/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */} - {/* 「与原文相同 / 已截断」这类提示也要可见:只报失败会让「润色没变化」看起来像按钮坏了。 */} - {showPolishAction && - !reminderOpen && - (polishing || polishError || polishNotice) ? ( - - {polishing ? '润色中…' : (polishError ?? polishNotice)} - - ) : null} - - options={mentionOptions} - triggerFn={triggerFn} - onQueryChange={setQuery} - onSelectOption={(option, textNode, closeMenu) => - handleSelectMention(option, textNode, closeMenu) - } - menuRenderFn={ - renderMentionMenu as unknown as MenuRenderFn - } - anchorClassName="resource-reference-menu-anchor" - preselectFirstItem - /> - - options={skillOptions} - triggerFn={skillTriggerFn} - onQueryChange={setSkillQuery} - onSelectOption={(option, textNode, closeMenu) => - handleSelectSkill(option, textNode, closeMenu) - } - menuRenderFn={ - renderMentionMenu as unknown as MenuRenderFn - } - anchorClassName="resource-reference-menu-anchor" - preselectFirstItem - /> - {pickerOpen && pickerPosition - ? createPortal( -
-
- 选择素材 - -
- { - setPickerScopeOverride(nextScope); - }} - gap="sm" - frame="bare" - surface="transparent" - size="sm" - semantics="tabs" - ariaLabel="素材范围" - className="platform-theme platform-theme--light resource-reference-picker-scopes" - /> - {/* 搜索 + 功能分类 + 标签用共享筛选条:与资源画布、参考图弹窗同一套筛选 UI, - 标签口径也来自共享标签库;两个页签各自持有一份独立状态。 */} - updatePickerScopeState({ query: value }), - }} - categoryItems={RESOURCE_REFERENCE_FILTERS} - activeCategoryId={pickerFilter} - onCategoryChange={(nextFilter) => - updatePickerScopeState({ filter: nextFilter }) - } - tagItems={scopeTagItems} - activeTags={selectedTags} - onToggleTag={(tag) => - updatePickerScopeState({ - selectedTags: selectedTags.includes(tag) - ? selectedTags.filter((item) => item !== tag) - : [...selectedTags, tag], - }) - } - className="platform-theme platform-theme--light resource-reference-picker-filters" - /> -
- {visiblePickerReferences.length === 0 ? ( -

- {pickerEmptyMessage(scopeReferences.length, pickerScope)} -

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