From 7e009a4c948eef472dc358fcab29d174f4b482b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 16:01:54 +0800 Subject: [PATCH 01/10] =?UTF-8?q?=E5=BC=95=E7=94=A8=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E5=8C=BA=E6=94=B9=E4=B8=BA=E5=AE=BF=E4=B8=BB=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E6=9D=A5=E6=BA=90=EF=BC=8C=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=99=A8=E4=B8=8E=E8=BE=93=E5=85=A5=E5=8C=BA?= =?UTF-8?q?=E5=88=86=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 features/project-workspace/reference-source(types + 资源/Skill/附件/运行画面区域四个 provider 工厂),输入区按 providers 顺序取第一个非空回答,不再判断引用种类 - ResourceReferenceInput 删除 assets / versions / activeVersionId / skills / showTriggerButton / openPicker,改为 providers / inputActions / submitSuppressed / containerRef 四个通用接缝 - 新增 ResourceReferencePicker 与 ResourceReferencePickerAction:面板自己拿数据(素材、版本、缩略图预览 invoke)并自管筛选态,由宿主渲染,确认后只经 insertReferences 交回输入区 - 四个宿主改为按需注入 provider 并各自渲染选择器:DirectProject 注入资源+Skill+附件+运行区域,策划输入盒 / 画布生成面板 / 资源卡快速编辑只注入资源+两个静默 provider,非 DirectProject 宿主不再出现 $ Skill 候选 - 附件并入 ChatReference,编辑器收敛为单一 ResourceReferenceNode(删除 AttachmentReferenceNode),附件 chip 的 DOM 契约(data-attachment-reference / data-attachment-status / title / 移除按钮 aria-label)逐字保留 - 附件改为导入成功即以芯片进入正文、失败与异常一律不插入;控制器删除待发附件状态与 removeAttachment,ComposerPendingAttachments 与其提交时拼接一并删除,单次上限按草稿中的附件芯片数计算,导入进行中禁止发送 - directCodexContentToPromptText 的引用 token 前后各留一个空白(不重复),显示名反查改由调用方注入 resourceLabelResolver(assets),并补 TODO:后续改为按组件渲染引用 - 队列 chip 文案、快速编辑出站提示词、润色回填的引用恢复统一走 joinMentionText 的同一份 token 口径 - 新增 tests/referenceSourceProviders.test.ts 规则矩阵单测(触发符、候选过滤与截断、toReference、refresh、mentionToken、Skill 目录懒加载与失败重试),既有输入区/聊天用例迁移为注入 provider - styles.css 删除随面板外移与待发附件列表一起失效的规则 --- apps/ai-game-creator-shell/src/App.tsx | 8 - .../AttachmentReferenceChip.tsx | 4 +- .../AttachmentReferenceNode.tsx | 107 -- .../ResourceReferenceChip.tsx | 15 +- .../ResourceReferenceInput.tsx | 1243 +++++------------ .../ResourceReferenceNode.tsx | 11 +- .../ResourceReferencePicker.tsx | 573 ++++++++ .../attachmentReferenceProvider.ts | 43 + .../resourceReferenceProvider.ts | 78 ++ .../runtimeRegionReferenceProvider.ts | 45 + .../skillReferenceProvider.ts | 116 ++ .../reference-source/types.ts | 36 + .../project-workspace/resourceReferences.ts | 236 +++- ...ResourceCanvasAssetGenerationPanelView.tsx | 45 +- apps/ai-game-creator-shell/src/styles.css | 22 +- .../chat/DirectProjectChatView.tsx | 8 +- .../ComposerControls.tsx | 38 +- .../DirectProjectComposer.tsx | 130 +- .../chatComposerQueue.ts | 10 +- .../useDirectProjectChatController.ts | 83 +- .../src/view/project-development/index.tsx | 52 +- .../planning/PlanningChatView.tsx | 28 +- .../tests/appSurface/chat-composer.suite.ts | 5 +- .../tests/chatPromptPolish.test.tsx | 9 +- .../projectResourceLiveIntegration.test.tsx | 7 +- .../tests/referenceSourceProviders.test.ts | 434 ++++++ ...ceCanvasAssetGenerationReferences.test.tsx | 9 +- ...urceCanvasGenerationFloatingPanel.test.tsx | 14 +- .../tests/resourceReferenceInput.test.tsx | 170 ++- .../tests/resourceReferences.test.ts | 3 +- 30 files changed, 2306 insertions(+), 1276 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceNode.tsx create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferencePicker.tsx create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/reference-source/attachmentReferenceProvider.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/reference-source/runtimeRegionReferenceProvider.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts create mode 100644 apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts 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