diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 4d7563c1a..5b245ebae 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -14,7 +14,10 @@ "typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs" }, "dependencies": { + "@lexical/react": "^0.47.0", + "@lexical/utils": "^0.47.0", "@vitejs/plugin-react": "^5.0.4", + "lexical": "^0.47.0", "lucide-react": "^0.546.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/apps/ai-game-creator-shell/src/stores/useHomeDraftStore.ts b/apps/ai-game-creator-shell/src/stores/useHomeDraftStore.ts index 4773a84c3..05480f284 100644 --- a/apps/ai-game-creator-shell/src/stores/useHomeDraftStore.ts +++ b/apps/ai-game-creator-shell/src/stores/useHomeDraftStore.ts @@ -1,5 +1,15 @@ import { create } from 'zustand'; +export type RichText = + | { + type: 'text'; + text: string; + } + | { + type: 'attachment'; + attachmentId: string; + }; + export type HomeAgentMode = 'game' | 'art' | 'doc'; export type HomeAttachmentDraft = { @@ -13,17 +23,23 @@ export type HomeDraft = { attachments: HomeAttachmentDraft[]; }; -type UseHomeDraftStore = HomeDraft & { +type UseHomeDraftStore = { + mode: HomeAgentMode; + richText: RichText[]; + attachments: HomeAttachmentDraft[]; setMode: (mode: HomeAgentMode) => void; - setPrompt: (prompt: string) => void; + setRichText: (richText: RichText[]) => void; addAttachments: (attachments: HomeAttachmentDraft[]) => void; removeAttachment: (attachmentId: string) => void; reset: () => void; }; -const initialHomeDraft: HomeDraft = { +const initialHomeDraft: Pick< + UseHomeDraftStore, + 'mode' | 'richText' | 'attachments' +> = { mode: 'game', - prompt: '', + richText: [], attachments: [], }; @@ -31,14 +47,20 @@ const initialHomeDraft: HomeDraft = { export const useLauncherHomeDraftStore = create((set) => ({ ...initialHomeDraft, setMode: (mode) => set({ mode }), - setPrompt: (prompt) => set({ prompt }), + setRichText: (richText) => set({ richText }), addAttachments: (attachments) => - set((state) => ({ attachments: [...state.attachments, ...attachments] })), + set((state) => ({ + attachments: [...state.attachments, ...attachments], + })), removeAttachment: (attachmentId) => set((state) => ({ attachments: state.attachments.filter( (attachment) => attachment.id !== attachmentId, ), + richText: state.richText.filter( + (part) => + part.type !== 'attachment' || part.attachmentId !== attachmentId, + ), })), reset: () => set(initialHomeDraft), })); diff --git a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/attachment.tsx b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/attachment.tsx new file mode 100644 index 000000000..ddb5f34f2 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/attachment.tsx @@ -0,0 +1,39 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { $getNodeByKey, type NodeKey } from 'lexical'; +import { X } from 'lucide-react'; +import { useContext } from 'react'; + +import AttachmentContext from './attachmentContext'; + +export function AttachmentToken({ + attachmentId, + nodeKey, +}: { + attachmentId: string; + nodeKey: NodeKey; +}) { + const [editor] = useLexicalComposerContext(); + const { attachments, removeAttachment } = useContext(AttachmentContext); + const attachment = attachments.get(attachmentId); + if (!attachment) { + return null; + } + return ( + + {attachment.file.name} + + + ); +} diff --git a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/attachmentContext.tsx b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/attachmentContext.tsx new file mode 100644 index 000000000..16c3334ed --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/attachmentContext.tsx @@ -0,0 +1,10 @@ +import { createContext } from 'react'; + +import type { HomeAttachmentDraft } from '../../../../stores/useHomeDraftStore'; + +const AttachmentContext = createContext({ + attachments: new Map(), + removeAttachment: (_attachmentId: string): void => undefined, +}); + +export default AttachmentContext; diff --git a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/attachmentNode.tsx b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/attachmentNode.tsx new file mode 100644 index 000000000..4baf62b90 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/attachmentNode.tsx @@ -0,0 +1,81 @@ +import { + $applyNodeReplacement, + DecoratorNode, + type EditorConfig, + type LexicalNode, + type NodeKey, + type SerializedLexicalNode, + type Spread, +} from 'lexical'; +import type { ReactNode } from 'react'; + +import { AttachmentToken } from './attachment'; + +type SerializedAttachmentNode = Spread< + { + attachmentId: string; + type: 'home-attachment'; + version: 1; + }, + SerializedLexicalNode +>; + +export class AttachmentNode extends DecoratorNode { + __attachmentId: string; + + static getType() { + return 'home-attachment'; + } + + static clone(node: AttachmentNode) { + return new AttachmentNode(node.__attachmentId, node.__key); + } + + static importJSON(serializedNode: SerializedAttachmentNode) { + return $createAttachmentNode(serializedNode.attachmentId); + } + + constructor(attachmentId: string, key?: NodeKey) { + super(key); + this.__attachmentId = attachmentId; + } + + exportJSON(): SerializedAttachmentNode { + return { + ...super.exportJSON(), + attachmentId: this.__attachmentId, + type: 'home-attachment', + version: 1, + }; + } + + createDOM(_config: EditorConfig) { + return document.createElement('span'); + } + + updateDOM() { + return false; + } + + getTextContent() { + return ''; + } + + isInline() { + return true; + } + + decorate() { + return ; + } +} + +export function $createAttachmentNode(attachmentId: string) { + return $applyNodeReplacement(new AttachmentNode(attachmentId)); +} + +export function $isAttachmentNode( + node: LexicalNode | null | undefined, +): node is AttachmentNode { + return node instanceof AttachmentNode; +} diff --git a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx new file mode 100644 index 000000000..6945626d3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx @@ -0,0 +1,204 @@ +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 { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isElementNode, + $isRangeSelection, + $isTextNode, + COMMAND_PRIORITY_EDITOR, + createCommand, + type LexicalCommand, + type LexicalNode, +} from 'lexical'; +import { Upload } from 'lucide-react'; +import { useEffect, useMemo, useRef } from 'react'; + +import type { + HomeAttachmentDraft, + RichText, +} from '../../../../stores/useHomeDraftStore'; +import AttachmentContext from './attachmentContext'; +import { + $createAttachmentNode, + $isAttachmentNode, + AttachmentNode, +} from './attachmentNode'; + +type RichInputAreaProps = { + attachments: readonly HomeAttachmentDraft[]; + value: readonly RichText[]; + placeholder: string; + onChange: (value: RichText[]) => void; + onAttachmentsAdd: (attachments: HomeAttachmentDraft[]) => void; + onAttachmentRemove: (attachmentId: string) => void; +}; +const INSERT_ATTACHMENTS_COMMAND: LexicalCommand = + createCommand('INSERT_HOME_ATTACHMENTS_COMMAND'); + +function EditorPlugins({ + // attachments, + onChange, +}: Pick) { + const [editor] = useLexicalComposerContext(); + + useEffect( + () => + editor.registerCommand( + INSERT_ATTACHMENTS_COMMAND, + (nextAttachments) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + const root = $getRoot(); + if (root.getChildrenSize() === 0) + root.append($createParagraphNode()); + root.selectEnd(); + } + const currentSelection = $getSelection(); + if ($isRangeSelection(currentSelection)) { + currentSelection.insertNodes( + nextAttachments.map((attachment) => + $createAttachmentNode(attachment.id), + ), + ); + } + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + [editor], + ); + + return ( + { + editorState.read(() => { + const parts: RichText[] = []; + collectRichText($getRoot(), parts); + onChange(parts); + }); + }} + /> + ); +} + +function collectRichText(node: LexicalNode, parts: RichText[]) { + if ($isTextNode(node)) { + parts.push({ type: 'text', text: node.getTextContent() }); + return; + } + if ($isAttachmentNode(node)) { + parts.push({ type: 'attachment', attachmentId: node.__attachmentId }); + return; + } + if ($isElementNode(node)) { + node.getChildren().forEach((child) => collectRichText(child, parts)); + } +} + +function UploadButton({ + onAttachmentsAdd, +}: Pick) { + const [editor] = useLexicalComposerContext(); + const inputRef = useRef(null); + return ( + <> + { + const files = Array.from(event.currentTarget.files ?? []); + event.currentTarget.value = ''; + if (files.length === 0) return; + const now = Date.now(); + const nextAttachments = files.map((file, index) => ({ + id: `${file.name}:${file.size}:${file.lastModified}:${index}:${now}`, + file, + })); + editor.dispatchCommand(INSERT_ATTACHMENTS_COMMAND, nextAttachments); + onAttachmentsAdd(nextAttachments); + }} + /> + + + ); +} + + +export default function RichInputArea(props: RichInputAreaProps) { + const attachmentMap = useMemo( + () => + new Map( + props.attachments.map((attachment) => [attachment.id, attachment]), + ), + [props.attachments], + ); + return ( + + { + const paragraph = $createParagraphNode(); + for (const part of props.value) { + paragraph.append( + part.type === 'text' + ? $createTextNode(part.text) + : $createAttachmentNode(part.attachmentId), + ); + } + $getRoot().append(paragraph); + }, + onError: (error) => { + throw error; + }, + }} + > +
+ + } + placeholder={ + + {props.placeholder} + + } + ErrorBoundary={LexicalErrorBoundary} + /> +
+ +
+
+ +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/richTextToPrompt.tsx b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/richTextToPrompt.tsx new file mode 100644 index 000000000..f0059ae6d --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/richTextToPrompt.tsx @@ -0,0 +1,31 @@ +import type { + HomeAttachmentDraft, + RichText, +} from '../../../../stores/useHomeDraftStore'; + +function ordinalSuffix(index: number) { + if (index % 100 >= 11 && index % 100 <= 13) return 'th'; + return ( + ({ 1: 'st', 2: 'nd', 3: 'rd' } as Record)[index % 10] ?? + 'th' + ); +} +export function richTextToPrompt( + parts: readonly RichText[], + attachments: readonly HomeAttachmentDraft[], +) { + const attachmentById = new Map( + attachments.map((attachment) => [attachment.id, attachment]), + ); + let attachmentIndex = 0; + return parts + .map((part) => { + if (part.type === 'text') return part.text; + const attachment = attachmentById.get(part.attachmentId); + if (!attachment) return ''; + attachmentIndex += 1; + return `{${attachmentIndex}${ordinalSuffix(attachmentIndex)} attachment: ${attachment.file.name}}`; + }) + .join('') + .trim(); +} diff --git a/apps/ai-game-creator-shell/src/view/home/index.tsx b/apps/ai-game-creator-shell/src/view/home/index.tsx index 035e6d3eb..7d5b23692 100644 --- a/apps/ai-game-creator-shell/src/view/home/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/index.tsx @@ -3,19 +3,19 @@ import { type LucideIcon, Plus, Sparkles, - Upload, - X, } from 'lucide-react'; import type { FormEvent, } from 'react'; -import { useRef, useState } from 'react'; +import { useState } from 'react'; import { type HomeAgentMode, type HomeDraft, useLauncherHomeDraftStore, } from '../../stores/useHomeDraftStore'; +import RichInputArea from './components/RichInputArea'; +import { richTextToPrompt } from './components/RichInputArea/richTextToPrompt'; import { useHomeShowcase } from './useHomeShowcase'; export type { @@ -71,12 +71,14 @@ export default function HomeView({ onProjectOpen, }: HomeViewProps) { const homeAgentMode = useLauncherHomeDraftStore((state) => state.mode); - const homePrompt = useLauncherHomeDraftStore((state) => state.prompt); + const homeRichText = useLauncherHomeDraftStore((state) => state.richText); const homeAttachments = useLauncherHomeDraftStore( (state) => state.attachments, ); const setHomeAgentMode = useLauncherHomeDraftStore((state) => state.setMode); - const setHomePrompt = useLauncherHomeDraftStore((state) => state.setPrompt); + const setHomeRichText = useLauncherHomeDraftStore( + (state) => state.setRichText, + ); const addHomeAttachments = useLauncherHomeDraftStore( (state) => state.addAttachments, ); @@ -86,7 +88,6 @@ export default function HomeView({ const [homeCreationBusy, setHomeCreationBusy] = useState(false); const { resources: showcaseResources, status: showcaseStatus } = useHomeShowcase(); - const homeAttachmentInputRef = useRef(null); const activeHomeMode = homeAgentModeItems.find((item) => item.mode === homeAgentMode) ?? homeAgentModeItems[0]; @@ -97,25 +98,10 @@ export default function HomeView({ const ActiveHomeModeIcon = activeHomeMode.icon; - function handleHomeAttachmentSelection( - event: React.ChangeEvent, - ) { - const files = Array.from(event.currentTarget.files ?? []); - event.currentTarget.value = ''; - if (files.length === 0) { - return; - } - addHomeAttachments( - files.map((file, index) => ({ - id: `${file.name}:${file.size}:${file.lastModified}:${index}:${Date.now()}`, - file, - })), - ); - } - async function handleHomeSubmit(event: FormEvent) { event.preventDefault(); - if (!homePrompt.trim() && homeAttachments.length === 0) { + const prompt = richTextToPrompt(homeRichText, homeAttachments); + if (!prompt) { onStatusChange( homeAgentModeItems.find((item) => item.mode === homeAgentMode) ?.emptyPrompt ?? '请输入需求或上传附件', @@ -128,7 +114,7 @@ export default function HomeView({ onStatusChange( await onCreateDraft({ mode: homeAgentMode, - prompt: homePrompt, + prompt, attachments: homeAttachments, }), ); @@ -185,55 +171,19 @@ export default function HomeView({ })}
-