diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index a646241ab..4d7563c1a 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -18,7 +18,8 @@ "lucide-react": "^0.546.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "vite": "^6.2.0" + "vite": "^6.2.0", + "zustand": "^5.0.14" }, "devDependencies": { "@tailwindcss/vite": "^4.1.14", diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index df134d4ff..907962747 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -28,11 +28,16 @@ import { Zap, } from 'lucide-react'; import HomeView, { + type HomeDraft, type HomeAgentMode, type HomeAgentModeItem, type HomeAttachmentDraft, - type HomeShowcaseResource, } from './view/home'; +import { useLauncherHomeDraftStore } from './stores/useHomeDraftStore'; +import { + getClientProfileDashboard, + getClientProfileWalletLedger, +} from './services/clientApi'; import type { AuthEntryResponse, @@ -51,7 +56,6 @@ import { import type { ProfileDashboardSummary, ProfileWalletLedgerEntry, - ProfileWalletLedgerResponse, } from '../../../packages/shared/src/contracts/runtime'; import { createGameCreationAppManifest, @@ -144,26 +148,6 @@ type PendingNonEmptyProject = projectPath: string; }; -type EditorShowcaseResource = HomeShowcaseResource & { - resourceId: string; - showcaseId?: string | null; - label?: string | null; - imageSrc: string; - width?: number; - height?: number; - prompt?: string | null; - actualPrompt?: string | null; - assetKind?: string | null; - authorDisplayName?: string | null; - authorPublicUserCode?: string | null; - likeCount?: number | null; -}; - -type EditorShowcaseResourceListResponse = { - resources: EditorShowcaseResource[]; - nextCursor?: string | null; -}; - interface InitLocalProjectResult { projectPath: string; manifestPath: string; @@ -1237,31 +1221,6 @@ async function logoutClientAuthSession() { } } -function getClientProfileDashboard() { - return requestAuthJson( - '/api/profile/dashboard', - { method: 'GET' }, - '读取泥点余额失败', - ); -} - -function getClientProfileWalletLedger() { - return requestAuthJson( - '/api/profile/wallet-ledger', - { method: 'GET' }, - '读取泥点明细失败', - ); -} - -function listClientShowcaseResources() { - return requestAuthJson( - '/api/editor/showcase/resources', - { method: 'GET' }, - '读取灵感推荐失败', - { skipAuth: true }, - ); -} - const homeAgentModeItems: HomeAgentModeItem[] = [ { mode: 'game', @@ -1320,10 +1279,6 @@ function formatLocalDateTime(value: string | number | null | undefined) { }); } -function createHomeAttachmentId(file: File, index: number) { - return `${file.name}:${file.size}:${file.lastModified}:${index}:${Date.now()}`; -} - function buildHomeConversationContent( mode: HomeAgentMode, prompt: string, @@ -2153,12 +2108,6 @@ export function WorkspaceLauncher({ const [projectPath, setProjectPath] = useState(defaultProjectPath); const [status, setStatus] = useState('请选择项目'); const [launcherView, setLauncherView] = useState(initialView); - const [homeAgentMode, setHomeAgentMode] = useState('game'); - const [homePrompt, setHomePrompt] = useState(''); - const [homeAttachments, setHomeAttachments] = useState( - [], - ); - const [homeCreationBusy, setHomeCreationBusy] = useState(false); const [currentProjectContext, setCurrentProjectContext] = useState(null); const [recentWorkspaces, setRecentWorkspaces] = @@ -2186,13 +2135,11 @@ export function WorkspaceLauncher({ title: string; message: string; } | null>(null); - const [showcaseResources, setShowcaseResources] = useState< - EditorShowcaseResource[] - >([]); - const [showcaseStatus, setShowcaseStatus] = useState('正在读取灵感'); - const homeAttachmentInputRef = useRef(null); const accountMenuRef = useRef(null); const helpMenuRef = useRef(null); + const resetLauncherHomeDraft = useLauncherHomeDraftStore( + (state) => state.reset, + ); const launcherAgentChatAgents = deriveAgentStatusCards(seedManifest, null); const [agentChatProjectPath, setAgentChatProjectPath] = useState(defaultProjectPath); @@ -2258,23 +2205,8 @@ export function WorkspaceLauncher({ return; } setProfileDashboard(null); - setWalletLedgerStatus(error instanceof Error ? error.message : '泥点读取失败'); - }); - void listClientShowcaseResources() - .then((response) => { - if (disposed) { - return; - } - setShowcaseResources(response.resources.slice(0, 6)); - setShowcaseStatus(response.resources.length > 0 ? '已读取灵感' : '暂无灵感'); - }) - .catch((error) => { - if (disposed) { - return; - } - setShowcaseResources([]); - setShowcaseStatus( - error instanceof Error ? error.message : '灵感读取失败', + setWalletLedgerStatus( + error instanceof Error ? error.message : '泥点读取失败', ); }); return () => { @@ -2433,14 +2365,13 @@ export function WorkspaceLauncher({ ) { const trimmedProjectPath = validateProjectPath(nextProjectPath); if (!trimmedProjectPath) { - return; + return '项目目录无效'; } const invoke = resolveTauriInvoke(); if (!invoke) { setStatus('需要在 Tauri App 内运行'); - return; + return '需要在 Tauri App 内运行'; } - setHomeCreationBusy(true); setStatus('正在创建项目'); try { if (!skipNonEmptyCheck) { @@ -2457,7 +2388,7 @@ export function WorkspaceLauncher({ attachments, }); setStatus('目标文件夹不是空的'); - return; + return '目标文件夹不是空的,请确认是否继续新建'; } } const result = await invoke( @@ -2503,12 +2434,11 @@ export function WorkspaceLauncher({ recentRunStopReason: null, createdAt: Date.now(), }); - setHomePrompt(''); - setHomeAttachments([]); + return '已创建项目并进入项目开发'; } catch (error) { - setStatus(error instanceof Error ? error.message : String(error)); - } finally { - setHomeCreationBusy(false); + const message = error instanceof Error ? error.message : String(error); + setStatus(message); + throw error; } } @@ -2649,43 +2579,51 @@ export function WorkspaceLauncher({ void openProject(projectPath, 'open'); } - async function handleHomeSubmit(event: FormEvent) { - event.preventDefault(); - const activeMode = homeAgentModeItems.find( - (item) => item.mode === homeAgentMode, - ); - if (!homePrompt.trim() && homeAttachments.length === 0) { - setStatus(activeMode?.emptyPrompt ?? '请输入需求或上传附件'); - return; - } + async function createHomeDraft(draft: HomeDraft) { const invoke = resolveTauriInvoke(); if (!invoke) { - setStatus('需要在 Tauri App 内运行'); - return; + throw new Error('需要在 Tauri App 内运行'); } - setHomeCreationBusy(true); - setStatus('请选择项目目录'); + const selectedPath = await invoke( + 'pick_local_project_directory', + ); + if (!selectedPath) { + return '已取消'; + } + return createHomeProjectFromDirectory( + selectedPath, + draft.mode, + draft.prompt, + draft.attachments, + ); + } + + async function loadWalletLedger() { + setWalletLedgerStatus('正在读取明细'); try { - const selectedPath = await invoke( - 'pick_local_project_directory', - ); - if (!selectedPath) { - setStatus('已取消'); - return; - } - await createHomeProjectFromDirectory( - selectedPath, - homeAgentMode, - homePrompt, - homeAttachments, + const response = await getClientProfileWalletLedger(); + setWalletLedger(response.entries.slice(0, 5)); + setWalletLedgerStatus( + response.entries.length > 0 ? '已读取明细' : '暂无泥点明细', ); } catch (error) { - setStatus(error instanceof Error ? error.message : String(error)); - } finally { - setHomeCreationBusy(false); + setWalletLedger([]); + setWalletLedgerStatus( + error instanceof Error ? error.message : '明细读取失败', + ); } } + function showLauncherNotice(title: string) { + setAccountMenuOpen(false); + setHelpMenuOpen(false); + setWalletPanelOpen(false); + setLauncherNotice({ + title, + message: `${title}正在接入中,当前版本会先保留入口。`, + }); + } + async function handlePickProjectDirectory() { const invoke = resolveTauriInvoke(); if (!invoke) { @@ -2733,53 +2671,6 @@ export function WorkspaceLauncher({ } } - function handleHomeAttachmentSelection(event: ChangeEvent) { - const files = Array.from(event.currentTarget.files ?? []); - event.currentTarget.value = ''; - if (files.length === 0) { - return; - } - setHomeAttachments((current) => [ - ...current, - ...files.map((file, index) => ({ - id: createHomeAttachmentId(file, index), - file, - })), - ]); - } - - function removeHomeAttachment(attachmentId: string) { - setHomeAttachments((current) => - current.filter((attachment) => attachment.id !== attachmentId), - ); - } - - async function loadWalletLedger() { - setWalletLedgerStatus('正在读取明细'); - try { - const response = await getClientProfileWalletLedger(); - setWalletLedger(response.entries.slice(0, 5)); - setWalletLedgerStatus( - response.entries.length > 0 ? '已读取明细' : '暂无泥点明细', - ); - } catch (error) { - setWalletLedger([]); - setWalletLedgerStatus( - error instanceof Error ? error.message : '明细读取失败', - ); - } - } - - function showLauncherNotice(title: string) { - setAccountMenuOpen(false); - setHelpMenuOpen(false); - setWalletPanelOpen(false); - setLauncherNotice({ - title, - message: `${title}正在接入中,当前版本会先保留入口。`, - }); - } - function handleRecentWorkspaceRefresh() { if (recentWorkspaces.length === 0 || recentWorkspaceRefreshing) { return; @@ -3241,7 +3132,9 @@ export function WorkspaceLauncher({
{currentUser.displayName || '陶泥儿用户'} - {currentUser.phoneNumberMasked || currentUser.publicUserCode} + {currentUser.phoneNumberMasked || + currentUser.publicUserCode || + '账号信息读取中'} - @@ -3394,21 +3294,9 @@ export function WorkspaceLauncher({ {launcherView === 'home' ? ( 0} - homeAgentMode={homeAgentMode} homeAgentModeItems={homeAgentModeItems} - homePrompt={homePrompt} - homeAttachments={homeAttachments} - homeAttachmentInputRef={homeAttachmentInputRef} - homeCreationBusy={homeCreationBusy} - status={status} recentProjectRows={recentProjectRows} - showcaseResources={showcaseResources} - showcaseStatus={showcaseStatus} - onHomeAgentModeChange={setHomeAgentMode} - onHomePromptChange={setHomePrompt} - onHomeAttachmentSelection={handleHomeAttachmentSelection} - onHomeAttachmentRemove={removeHomeAttachment} - onHomeSubmit={handleHomeSubmit} + onCreateDraft={createHomeDraft} onProjectsOpen={() => setLauncherView('projects')} onProjectOpen={(path) => { setProjectPath(path); diff --git a/apps/ai-game-creator-shell/src/services/clientApi.ts b/apps/ai-game-creator-shell/src/services/clientApi.ts new file mode 100644 index 000000000..1684e04b9 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/clientApi.ts @@ -0,0 +1,152 @@ +import { + API_RESPONSE_ENVELOPE_HEADER, + API_RESPONSE_ENVELOPE_VERSION, + ProfileDashboardSummary, + ProfileWalletLedgerResponse, + unwrapApiResponse, +} from '../../../../packages/shared/src'; + +const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; +const DEFAULT_CLIENT_AUTH_API_BASE_URL = 'http://127.0.0.1:8082'; + +export type EditorShowcaseResource = { + resourceId: string; + showcaseId?: string | null; + label?: string | null; + imageSrc: string; + width?: number; + height?: number; + prompt?: string | null; + actualPrompt?: string | null; + assetKind?: string | null; + authorDisplayName?: string | null; + authorPublicUserCode?: string | null; + likeCount?: number | null; +}; + +type EditorShowcaseResourceListResponse = { + resources: EditorShowcaseResource[]; + nextCursor?: string | null; +}; + +export class ClientAuthRequestError extends Error { + readonly status: number | null; + readonly networkError: boolean; + + constructor( + message: string, + options: { status?: number | null; networkError?: boolean } = {}, + ) { + super(message); + this.status = options.status ?? null; + this.networkError = options.networkError ?? false; + } +} + +export function getStoredAuthAccessToken() { + return window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)?.trim() || ''; +} + +export function setStoredAuthAccessToken(token: string) { + const nextToken = token.trim(); + if (nextToken) { + window.localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, nextToken); + return; + } + window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY); +} + +export function clearStoredAuthAccessToken() { + window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY); +} + +function resolveClientApiUrl(url: string) { + if (/^https?:\/\//iu.test(url)) { + return url; + } + if (import.meta.env.DEV) { + return url; + } + const isHttpPage = + window.location.protocol === 'http:' || + window.location.protocol === 'https:'; + if (!window.__TAURI__ && isHttpPage) { + return url; + } + return `${DEFAULT_CLIENT_AUTH_API_BASE_URL}${url}`; +} + +async function readApiErrorMessage(response: Response, fallback: string) { + const text = await response.text(); + if (!text.trim()) { + return fallback; + } + try { + unwrapApiResponse(JSON.parse(text) as unknown); + } catch (error) { + return error instanceof Error ? error.message : fallback; + } + return fallback; +} + +export async function requestClientApi( + url: string, + init: RequestInit, + fallbackMessage: string, + options: { skipAuth?: boolean } = {}, +) { + const headers = new Headers(init.headers); + headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION); + if (!options.skipAuth) { + const token = getStoredAuthAccessToken(); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + } + let response: Response; + try { + response = await fetch(resolveClientApiUrl(url), { + ...init, + credentials: 'same-origin', + headers, + }); + } catch { + throw new ClientAuthRequestError( + '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + { networkError: true }, + ); + } + if (!response.ok) { + throw new ClientAuthRequestError( + await readApiErrorMessage(response, fallbackMessage), + { status: response.status }, + ); + } + const text = await response.text(); + return text ? unwrapApiResponse(JSON.parse(text) as T) : (null as T); +} + +export function getClientProfileDashboard() { + return requestClientApi( + '/api/profile/dashboard', + { method: 'GET' }, + '读取泥点余额失败', + ); +} + +export function getClientProfileWalletLedger() { + return requestClientApi( + '/api/profile/wallet-ledger', + { method: 'GET' }, + '读取泥点明细失败', + ); +} + +export function listClientShowcaseResources() { + return requestClientApi( + '/api/editor/showcase/resources', + { method: 'GET' }, + '读取灵感推荐失败', + { skipAuth: true }, + ); +} diff --git a/apps/ai-game-creator-shell/src/stores/useHomeDraftStore.ts b/apps/ai-game-creator-shell/src/stores/useHomeDraftStore.ts new file mode 100644 index 000000000..4773a84c3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/stores/useHomeDraftStore.ts @@ -0,0 +1,44 @@ +import { create } from 'zustand'; + +export type HomeAgentMode = 'game' | 'art' | 'doc'; + +export type HomeAttachmentDraft = { + id: string; + file: File; +}; + +export type HomeDraft = { + mode: HomeAgentMode; + prompt: string; + attachments: HomeAttachmentDraft[]; +}; + +type UseHomeDraftStore = HomeDraft & { + setMode: (mode: HomeAgentMode) => void; + setPrompt: (prompt: string) => void; + addAttachments: (attachments: HomeAttachmentDraft[]) => void; + removeAttachment: (attachmentId: string) => void; + reset: () => void; +}; + +const initialHomeDraft: HomeDraft = { + mode: 'game', + prompt: '', + attachments: [], +}; + +// Keep non-serializable File objects available while the Home view is unmounted. +export const useLauncherHomeDraftStore = create((set) => ({ + ...initialHomeDraft, + setMode: (mode) => set({ mode }), + setPrompt: (prompt) => set({ prompt }), + addAttachments: (attachments) => + set((state) => ({ attachments: [...state.attachments, ...attachments] })), + removeAttachment: (attachmentId) => + set((state) => ({ + attachments: state.attachments.filter( + (attachment) => attachment.id !== attachmentId, + ), + })), + reset: () => set(initialHomeDraft), +})); 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 2240a5488..4291ea093 100644 --- a/apps/ai-game-creator-shell/src/view/home/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/index.tsx @@ -7,17 +7,23 @@ import { X, } from 'lucide-react'; import type { - ChangeEventHandler, - FormEventHandler, - RefObject, + FormEvent, } from 'react'; +import { useRef, useState } from 'react'; -export type HomeAgentMode = 'game' | 'art' | 'doc'; +import { + useLauncherHomeDraftStore, + type HomeAgentMode, + type HomeAttachmentDraft, + type HomeDraft, +} from '../../stores/useHomeDraftStore'; +import { useHomeShowcase } from './useHomeShowcase'; -export type HomeAttachmentDraft = { - id: string; - file: File; -}; +export type { + HomeAgentMode, + HomeAttachmentDraft, + HomeDraft, +} from '../../stores/useHomeDraftStore'; export type HomeAgentModeItem = { mode: HomeAgentMode; @@ -46,45 +52,39 @@ export type HomeShowcaseResource = { type HomeViewProps = { hasPromo: boolean; - homeAgentMode: HomeAgentMode; homeAgentModeItems: readonly HomeAgentModeItem[]; - homePrompt: string; - homeAttachments: readonly HomeAttachmentDraft[]; - homeAttachmentInputRef: RefObject; - homeCreationBusy: boolean; - status: string; recentProjectRows: readonly HomeProjectRow[]; - showcaseResources: readonly HomeShowcaseResource[]; - showcaseStatus: string; - onHomeAgentModeChange: (mode: HomeAgentMode) => void; - onHomePromptChange: (value: string) => void; - onHomeAttachmentSelection: ChangeEventHandler; - onHomeAttachmentRemove: (attachmentId: string) => void; - onHomeSubmit: FormEventHandler; + onCreateDraft: (draft: HomeDraft) => Promise; onProjectsOpen: () => void; onProjectOpen: (path: string) => void; }; export default function HomeView({ hasPromo, - homeAgentMode, homeAgentModeItems, - homePrompt, - homeAttachments, - homeAttachmentInputRef, - homeCreationBusy, - status, recentProjectRows, - showcaseResources, - showcaseStatus, - onHomeAgentModeChange, - onHomePromptChange, - onHomeAttachmentSelection, - onHomeAttachmentRemove, - onHomeSubmit, + onCreateDraft, onProjectsOpen, onProjectOpen, }: HomeViewProps) { + const homeAgentMode = useLauncherHomeDraftStore((state) => state.mode); + const homePrompt = useLauncherHomeDraftStore((state) => state.prompt); + const homeAttachments = useLauncherHomeDraftStore( + (state) => state.attachments, + ); + const setHomeAgentMode = useLauncherHomeDraftStore((state) => state.setMode); + const setHomePrompt = useLauncherHomeDraftStore((state) => state.setPrompt); + const addHomeAttachments = useLauncherHomeDraftStore( + (state) => state.addAttachments, + ); + const removeHomeAttachment = useLauncherHomeDraftStore( + (state) => state.removeAttachment, + ); + const [homeCreationBusy, setHomeCreationBusy] = useState(false); + const [homeStatus, setHomeStatus] = useState('请选择创作模式'); + const { resources: showcaseResources, status: showcaseStatus } = + useHomeShowcase(); + const homeAttachmentInputRef = useRef(null); const activeHomeMode = homeAgentModeItems.find((item) => item.mode === homeAgentMode) ?? homeAgentModeItems[0]; @@ -95,6 +95,48 @@ 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) { + setHomeStatus( + homeAgentModeItems.find((item) => item.mode === homeAgentMode) + ?.emptyPrompt ?? '请输入需求或上传附件', + ); + return; + } + setHomeCreationBusy(true); + setHomeStatus('请选择项目目录'); + try { + setHomeStatus( + await onCreateDraft({ + mode: homeAgentMode, + prompt: homePrompt, + attachments: homeAttachments, + }), + ); + } catch (error) { + setHomeStatus(error instanceof Error ? error.message : String(error)); + } finally { + setHomeCreationBusy(false); + } + } + return ( <>
onHomeAgentModeChange(item.mode)} + onClick={() => setHomeAgentMode(item.mode)} >