impl attachment inline chatbox
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<UseHomeDraftStore>((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),
|
||||
}));
|
||||
|
||||
@@ -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 (
|
||||
<span className="mx-0.75 inline-flex min-h-6 max-w-full items-center gap-1 rounded-[7px] border border-(--platform-subpanel-border) bg-(--platform-neutral-bg) px-1.75 align-middle text-[11px] text-(--platform-neutral-text)">
|
||||
{attachment.file.name}
|
||||
<button
|
||||
className="grid size-4.5 cursor-pointer place-items-center border-0 bg-transparent p-0 text-(--platform-text-soft)"
|
||||
type="button"
|
||||
aria-label={`移除附件 ${attachment.file.name}`}
|
||||
onClick={() => {
|
||||
editor.update(() => {
|
||||
$getNodeByKey(nodeKey)?.remove();
|
||||
});
|
||||
removeAttachment(attachmentId);
|
||||
}}
|
||||
>
|
||||
<X size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
import type { HomeAttachmentDraft } from '../../../../stores/useHomeDraftStore';
|
||||
|
||||
const AttachmentContext = createContext({
|
||||
attachments: new Map<string, HomeAttachmentDraft>(),
|
||||
removeAttachment: (_attachmentId: string): void => undefined,
|
||||
});
|
||||
|
||||
export default AttachmentContext;
|
||||
@@ -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<ReactNode> {
|
||||
__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 <AttachmentToken attachmentId={this.__attachmentId} nodeKey={this.__key} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function $createAttachmentNode(attachmentId: string) {
|
||||
return $applyNodeReplacement(new AttachmentNode(attachmentId));
|
||||
}
|
||||
|
||||
export function $isAttachmentNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is AttachmentNode {
|
||||
return node instanceof AttachmentNode;
|
||||
}
|
||||
@@ -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<HomeAttachmentDraft[]> =
|
||||
createCommand('INSERT_HOME_ATTACHMENTS_COMMAND');
|
||||
|
||||
function EditorPlugins({
|
||||
// attachments,
|
||||
onChange,
|
||||
}: Pick<RichInputAreaProps, 'attachments' | 'onChange'>) {
|
||||
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 (
|
||||
<OnChangePlugin
|
||||
onChange={(editorState) => {
|
||||
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<RichInputAreaProps, 'onAttachmentsAdd'>) {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="launcher-file-input pointer-events-none absolute size-px opacity-0"
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="grid size-6 cursor-pointer place-items-center rounded-full border-0 bg-transparent p-0 text-(--platform-icon-text)"
|
||||
type="button"
|
||||
aria-label="上传素材"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<Upload size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default function RichInputArea(props: RichInputAreaProps) {
|
||||
const attachmentMap = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
props.attachments.map((attachment) => [attachment.id, attachment]),
|
||||
),
|
||||
[props.attachments],
|
||||
);
|
||||
return (
|
||||
<AttachmentContext.Provider
|
||||
value={{
|
||||
attachments: attachmentMap,
|
||||
removeAttachment: props.onAttachmentRemove,
|
||||
}}
|
||||
>
|
||||
<LexicalComposer
|
||||
initialConfig={{
|
||||
namespace: 'home-rich-input',
|
||||
nodes: [AttachmentNode],
|
||||
editorState: () => {
|
||||
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;
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="grid min-h-9 gap-2">
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<ContentEditable
|
||||
aria-label="创作想法"
|
||||
className="min-h-9 w-full whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 text-[13px] text-(--platform-text-strong) outline-0"
|
||||
/>
|
||||
}
|
||||
placeholder={
|
||||
<span className="pointer-events-none absolute text-[13px] text-(--platform-text-muted)">
|
||||
{props.placeholder}
|
||||
</span>
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<UploadButton onAttachmentsAdd={props.onAttachmentsAdd} />
|
||||
</div>
|
||||
</div>
|
||||
<EditorPlugins
|
||||
attachments={props.attachments}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
</LexicalComposer>
|
||||
</AttachmentContext.Provider>
|
||||
);
|
||||
}
|
||||
+31
@@ -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<number, string>)[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();
|
||||
}
|
||||
@@ -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<HTMLInputElement | null>(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<HTMLInputElement>,
|
||||
) {
|
||||
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<HTMLFormElement>) {
|
||||
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({
|
||||
})}
|
||||
</div>
|
||||
<form
|
||||
className="grid min-h-20 w-[min(488px,calc(100vw-110px))] grid-rows-[minmax(42px,auto)_auto_auto] rounded-[18px] border border-(--platform-surface-border) bg-(--platform-input-fill) px-3 pb-2 pt-3.25 shadow-(--platform-panel-shadow) focus-within:bg-(--platform-input-fill-focus) focus-within:ring-4 focus-within:ring-(--platform-input-focus-ring) max-[760px]:w-[min(100%,calc(100vw-76px))]"
|
||||
className="grid min-h-20 w-[min(488px,calc(100vw-110px))] grid-rows-[minmax(42px,auto)_auto] rounded-[18px] border border-(--platform-surface-border) bg-(--platform-input-fill) px-3 pb-2 pt-3.25 shadow-(--platform-panel-shadow) focus-within:bg-(--platform-input-fill-focus) focus-within:ring-4 focus-within:ring-(--platform-input-focus-ring) max-[760px]:w-[min(100%,calc(100vw-76px))]"
|
||||
onSubmit={handleHomeSubmit}
|
||||
>
|
||||
<textarea
|
||||
className="min-h-9 w-full resize-none border-0 bg-transparent p-0 text-[13px] text-(--platform-text-strong) outline-0 placeholder:text-(--platform-text-muted)"
|
||||
aria-label="创作想法"
|
||||
<RichInputArea
|
||||
attachments={homeAttachments}
|
||||
value={homeRichText}
|
||||
placeholder={activeHomeMode.placeholder}
|
||||
value={homePrompt}
|
||||
onChange={(event) => setHomePrompt(event.currentTarget.value)}
|
||||
onChange={setHomeRichText}
|
||||
onAttachmentsAdd={addHomeAttachments}
|
||||
onAttachmentRemove={removeHomeAttachment}
|
||||
/>
|
||||
{homeAttachments.length > 0 ? (
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5 py-2 pb-1"
|
||||
aria-label="附件队列"
|
||||
>
|
||||
{homeAttachments.map((attachment) => (
|
||||
<span
|
||||
className="inline-flex min-h-6 max-w-full items-center gap-1 rounded-[7px] border border-(--platform-subpanel-border) bg-(--platform-neutral-bg) px-1.75 text-[11px] text-(--platform-neutral-text)"
|
||||
key={attachment.id}
|
||||
>
|
||||
{attachment.file.name}
|
||||
<button
|
||||
className="size-4.5 cursor-pointer border-0 bg-transparent p-0 text-(--platform-text-soft)"
|
||||
type="button"
|
||||
aria-label={`移除附件 ${attachment.file.name}`}
|
||||
onClick={() => removeHomeAttachment(attachment.id)}
|
||||
>
|
||||
<X size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid grid-cols-[auto_1fr_auto] items-center gap-2.5 text-[12px] text-(--platform-text-soft)">
|
||||
<input
|
||||
ref={homeAttachmentInputRef}
|
||||
className="launcher-file-input pointer-events-none absolute size-px opacity-0"
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleHomeAttachmentSelection}
|
||||
/>
|
||||
<button
|
||||
className="grid size-6 cursor-pointer place-items-center rounded-full border-0 bg-transparent p-0 text-(--platform-icon-text)"
|
||||
type="button"
|
||||
aria-label="上传素材"
|
||||
onClick={() => homeAttachmentInputRef.current?.click()}
|
||||
>
|
||||
<Upload size={15} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-2.5 text-[12px] text-(--platform-text-soft)">
|
||||
<span>{status}</span>
|
||||
<button
|
||||
className="grid size-6 cursor-pointer place-items-center rounded-full border-0 bg-(image:--platform-button-primary-fill) p-0 text-(--platform-button-primary-text) shadow-(--platform-profile-action-shadow) disabled:cursor-not-allowed disabled:opacity-55"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
listClientShowcaseResources,
|
||||
type EditorShowcaseResource,
|
||||
listClientShowcaseResources,
|
||||
} from '../../services/clientApi';
|
||||
|
||||
export function useHomeShowcase() {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
@@ -8,23 +11,21 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import React from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
|
||||
import {
|
||||
createGameCreationAppManifest,
|
||||
createGameCreationAppSeedTasks,
|
||||
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
|
||||
type GameCreationAgentRunTrace,
|
||||
} from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
|
||||
import {
|
||||
App,
|
||||
AuthenticatedClient,
|
||||
WorkspaceLauncher,
|
||||
deriveAgentStatusCards,
|
||||
WorkspaceLauncher,
|
||||
} from '../src/App';
|
||||
|
||||
const testAuthUser: AuthUser = {
|
||||
@@ -93,7 +94,7 @@ function mockRoleAgentReply() {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.pushState({}, '', '/');
|
||||
window.localStorage.clear();
|
||||
window.localStorage?.clear();
|
||||
delete window.__TAURI__;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -1583,9 +1584,6 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
renderLauncherAt('/?launcher');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '做素材' }));
|
||||
fireEvent.change(screen.getByLabelText('创作想法'), {
|
||||
target: { value: '做一张像素风厨师角色图' },
|
||||
});
|
||||
const fileInput = document.querySelector(
|
||||
'.launcher-file-input',
|
||||
) as HTMLInputElement;
|
||||
@@ -1600,8 +1598,7 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
target: { files: [file] },
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('附件队列')).not.toBeNull();
|
||||
expect(screen.getByText('reference.png')).not.toBeNull();
|
||||
expect(await screen.findByText('reference.png')).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
|
||||
|
||||
@@ -1609,7 +1606,6 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
expect(screen.getByText('home-created-game')).not.toBeNull();
|
||||
expect(screen.getByText('/tmp/home-created-game')).not.toBeNull();
|
||||
expect(screen.getByText('做素材')).not.toBeNull();
|
||||
expect(screen.getByText('做一张像素风厨师角色图')).not.toBeNull();
|
||||
expect(screen.getByText('assets/uploads/reference.png')).not.toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith('init_local_game_project', {
|
||||
projectPath: '/tmp/home-created-game',
|
||||
@@ -1637,7 +1633,7 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
});
|
||||
expect(conversationCalls[0]?.[1]).toMatchObject({
|
||||
message: {
|
||||
content: expect.stringContaining('做一张像素风厨师角色图'),
|
||||
content: expect.stringContaining('{1st attachment: reference.png}'),
|
||||
},
|
||||
});
|
||||
expect(conversationCalls[0]?.[1]).toMatchObject({
|
||||
|
||||
@@ -159,7 +159,7 @@ game-project/
|
||||
- 短期记忆、长期记忆、项目黑板和角色私有记忆按授权本地项目路径读写;普通用户仍只通过聊天命令访问短期 / 长期 / 黑板记忆,角色私有记忆只在单 agent 对话和生成 loop 中按目标 agent 读取。
|
||||
- 结构化对话记录按授权本地项目路径追加 JSONL;普通聊天、`/history`、工作区历史和单 agent 对话都读取 `.agent/conversations/`,最近 project / agent 对话可进入生成 prompt 上下文,但 v1 不提供 fork、archive 或云端同步。
|
||||
- Agent 状态列表从 `.agent/manifest.json` 的任务 / 角色清单和 `.agent/run.latest.json` / `.agent/runs/<runId>.json` 的 step、taskGraph、passPlans、lifecycleStatus 派生;v1 不新增独立状态数据库,也不承诺完整后台 runner。
|
||||
- App 启动先检查平台登录态;登录后进入同一个客户端首页,不再有面向用户的启动器 / 主窗口切换概念。首页按 `做游戏` / `做素材` / `做方案` 保存 `game` / `art` / `doc` 初始意图,发送时弹出原生目录选择,目标目录存在且非空时必须二次确认;确认后只调用 `init_local_game_project` 初始化本地项目、`upload_local_asset` 导入附件、`append_local_conversation_message` 记录首条需求和接收回执,再写入最近项目并切到项目开发占位页,成功后清空首页草稿。取消和创建失败的状态必须回显到首页;首页响应式断点与应用外壳统一为 `760px`。本流程不调用 `generate_local_game_draft`、`generate_platform_art_asset` 或 LLM 聊天。
|
||||
- App 启动先检查平台登录态;登录后进入同一个客户端首页,不再有面向用户的启动器 / 主窗口切换概念。首页按 `做游戏` / `做素材` / `做方案` 保存 `game` / `art` / `doc` 初始意图,输入状态按文字与附件 token 的顺序保存,附件以文件名 token 内嵌在输入框中而非堆叠在下方;提交时才将 token 转为 LLM 可读的 `{1st attachment}` 引用。发送时弹出原生目录选择,目标目录存在且非空时必须二次确认;确认后只调用 `init_local_game_project` 初始化本地项目、`upload_local_asset` 导入附件、`append_local_conversation_message` 记录首条需求和接收回执,再写入最近项目并切到项目开发占位页,成功后清空首页草稿。取消和创建失败的状态必须回显到首页;首页响应式断点与应用外壳统一为 `760px`。本流程不调用 `generate_local_game_draft`、`generate_platform_art_asset` 或 LLM 聊天。
|
||||
- debug 构建启动后在用户 `client` 窗口之外额外打开 `developer` 窗口;该窗口用于开发者单独选择 Agent、切换时自动读取该 Agent 历史,并把用户消息和真实 Agent 回复持久化到 `.agent/conversations/agents/<agentId>.jsonl`,普通用户窗口不得出现 `Agent 聊天` 导航或入口。
|
||||
- 首页最近项目只展示最近 3 个有效项目;项目组页在同一窗口管理最近项目、打开项目、新建项目和显示目录。打开项目只读取已初始化项目并切到项目开发占位页,不打开第二窗口;新建项目仍沿用非空目录确认,不自动重建无效历史路径。
|
||||
- 项目开发占位页保留左侧栏和顶部栏,展示项目名、路径、创建模式、首条需求、附件导入结果、最近 run 状态和后续“项目开发画布”占位;本轮不落地真正画板 + Agent 双栏。
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
Reference in New Issue
Block a user