diff --git a/CONTEXT.md b/CONTEXT.md index 724d95de3..59acab2ff 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -194,6 +194,34 @@ _Avoid_: 进度通知、快照轮询、第二套历史 把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。 _Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer +**引用候选**: +输入区可以命中的对象集合(`@` 素材、`$` Skill),由宿主按种类注入;输入区不判断候选属于哪一类。 +_Avoid_: 输入区自己读项目清单或应用目录、把候选取值写死在组件里 + +**引用 provider**: +一种引用种类向输入区提供的全部能力:触发符、候选、身份解析与正文文本形态;每种引用各一份,宿主按需选择性注入。 +_Avoid_: 一个总装对象决定所有种类、输入区按种类分叉、provider 之间互相知道对方 + +**静默 provider**: +不提供候选、只负责已有引用身份与文本形态的 provider;附件与运行画面区域属于这一类,只能由外部插入或草稿回填进入正文。 +_Avoid_: 给附件或运行画面区域造候选、为它们保留输入区内的专门分支 + +**引用文本语法**: +引用在正文文本里的形态(`@显示名` / `$名称` / `@附件名`)及其反解析;出站与解析必须同一口径,token 前后各留一个空白。 +_Avoid_: 出站与解析各写一套、在空白边界之外再补兼容别名、让解析依赖具体种类的字段 + +**引用输入区**: +只负责编辑与渲染引用的共享输入组件;候选、身份解析与文本语法都来自注入的 provider,它不持有项目清单、不访问后端。 +_Avoid_: 输入区自己拉 Skill 目录、把选择器面板塞在输入区内部 + +**引用选择器**: +宿主渲染的独立面板,自己拿数据与筛选状态,确认后把选中的引用交给输入区的插入缝。 +_Avoid_: 输入区自带面板、每个宿主各画一个、绕开插入缝另开第二条通道 + +**附件芯片**: +聊天附件在正文里的唯一表示;附件导入成功即以芯片进入正文,正文之外不存在第二份附件状态。 +_Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附件 + ## Relationships - 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。 @@ -209,6 +237,10 @@ _Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer - **个人历史成绩** 由最近记录列表和个人最佳摘要组成,只允许本人查看;排行榜只公开入榜胜利成绩。 - **正式作品入口闭环** 必须覆盖创作入口、作品详情 CTA、广场/作品卡片、我的作品/个人作品架、稳定作品 ID runtime 路由和 `work_play_start` 埋点。 - **Phase 2 实施顺序** 固定为:契约与领域规则 → SpacetimeDB 表/reducer 与 api-server BFF → 最小前端纵切 → 投影与列表体验 → 收口验证。 +- **引用输入区** 由宿主注入的若干 **引用 provider** 组成;**引用候选** 与 **引用文本语法** 都来自 provider,输入区不判断引用种类。 +- **引用选择器** 不属于 **引用输入区**:它自己拿数据,确认后只通过输入区的插入缝交付引用。 +- 只有带触发符的 **引用 provider** 会产生候选;**静默 provider** 没有触发符,只能由外部插入或草稿回填进入正文。 +- **附件芯片** 是本轮附件的唯一事实源;附件导入失败时不产生芯片。 ## Example dialogue diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 7884f201f..493f3ba80 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -170,7 +170,6 @@ type AppProps = { initialProjectManifest?: GameCreationAppManifest; initialProjectKind?: LocalProjectKind; planningStartMode?: boolean; - activeVersionId?: ProjectChatComponentProps['activeVersionId']; initialPlanningPrompt?: string; initialPlanningPromptClaimScope?: string; initialCreationType?: HomeCreationType | null; @@ -207,7 +206,6 @@ export function App({ initialProjectManifest, initialProjectKind = 'web', planningStartMode = false, - activeVersionId = null, initialPlanningPrompt = '', initialPlanningPromptClaimScope = '', initialCreationType = null, @@ -2199,11 +2197,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, @@ -2332,7 +2325,6 @@ export function App({ <> void handleDesignComposerUploadFiles(files)} - versions={chatProjectVersions} /> {runtimeConfigOpen ? ( void; planningStartMode?: boolean; - /** - * C7 当前游戏版本:由工作台壳持有,策划聊天里的 `@` 面板按它切「当前版本素材」。 - * `null` 表示回退到 manifest 中最新的版本。 - */ - activeVersionId?: string | null; playRequest?: { projectPath: string; requestId: number; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceChip.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceChip.tsx index f86fbc6b4..d1cbf408b 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceChip.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceChip.tsx @@ -2,13 +2,13 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext import { $getNodeByKey, type NodeKey } from 'lexical'; import { Paperclip, X } from 'lucide-react'; -import type { DirectCodexUserAttachmentReferencePart } from '../../view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart'; +import type { AttachmentReference } from './resourceReferences'; export function AttachmentReferenceChip({ attachment, nodeKey, }: { - attachment: DirectCodexUserAttachmentReferencePart; + attachment: AttachmentReference; nodeKey: NodeKey; }) { const [editor] = useLexicalComposerContext(); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceNode.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceNode.tsx deleted file mode 100644 index 5e4225c38..000000000 --- a/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceNode.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { - $applyNodeReplacement, - DecoratorNode, - type EditorConfig, - type LexicalNode, - type NodeKey, - type SerializedLexicalNode, - type Spread, -} from 'lexical'; -import type { ReactNode } from 'react'; - -import type { DirectCodexUserAttachmentReferencePart } from '../../view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart'; -import { AttachmentReferenceChip } from './AttachmentReferenceChip'; - -export type SerializedAttachmentReferenceNode = Spread< - { - attachment: DirectCodexUserAttachmentReferencePart; - type: 'attachment-reference'; - version: 1; - }, - SerializedLexicalNode ->; - -/** 附件在纯文本里的唯一占位字符:一个附件 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], + ); + + // 懒加载的唯一入口:菜单开合/查询变化经 effect 回调给 provider,`match` 始终保持纯函数, + // 渲染阶段(下面的 useMemo)不会替 provider 发起请求、写 ref 或读清单。 + useEffect(() => { + provider.onMenuQueryChange?.(query); + }, [provider, query]); + + 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 +867,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 +878,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