From f9d814206831ca2300e7b56344940b60defda708 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Wed, 8 Jul 2026 02:25:31 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=92=8C=E9=85=8D=E5=A5=97=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E5=90=AF=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 启动客户端时先检查平台登录态,未登录展示登录页,登录后进入启动器首页 独立客户端登录使用平台 auth 接口并对 refresh 做并发去重 Tauri 开发启动改为短命令 agc:serve,并先拉起配套 SpacetimeDB 与 api-server AGC Vite 代理读取 dev-stack 状态并暴露本地 marker,复用时校验 API target 恢复启动器项目目录输入、最近项目刷新移除和非空文件夹提醒交互 补充启动器和登录 gate 测试,并更新 AI 游戏创作 App 开发文档 --- apps/ai-game-creator-shell/package.json | 1 + .../scripts/check-config.mjs | 6 +- .../scripts/start-dev-stack.mjs | 283 +++++++++ .../src-tauri/tauri.conf.json | 2 +- apps/ai-game-creator-shell/src/App.tsx | 564 +++++++++++++++--- apps/ai-game-creator-shell/src/main.tsx | 12 +- apps/ai-game-creator-shell/src/styles.css | 205 ++++++- .../tests/appSurface.test.ts | 106 +++- apps/ai-game-creator-shell/vite.config.ts | 57 +- .../shared-memory/development-workflow.md | 8 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 +- package.json | 8 + scripts/dev.mjs | 36 +- 13 files changed, 1188 insertions(+), 104 deletions(-) create mode 100644 apps/ai-game-creator-shell/scripts/start-dev-stack.mjs diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 468c3ed69..74f901d6f 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "npm --prefix ../.. exec tauri -- dev", "dev-server": "node scripts/start-dev-server.mjs", + "dev-stack": "node scripts/start-dev-stack.mjs", "build": "npm --prefix ../.. exec tauri -- build", "llm-status": "node scripts/run-cli-with-config.mjs --llm-status", "agent-run": "node scripts/run-cli-with-config.mjs --agent-run", diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index da05c1af0..2b723681a 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -452,8 +452,10 @@ if (!viteConfigSource.includes('strictPort: true')) { } if ( - !tauriConfig.build?.beforeDevCommand?.includes( - 'run ai-game-creator-shell:dev-server', + !( + tauriConfig.build?.beforeDevCommand?.includes( + 'run ai-game-creator-shell:dev-server', + ) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve') ) ) { throw new Error( diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs new file mode 100644 index 000000000..60de90438 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -0,0 +1,283 @@ +import { spawn } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import http from 'node:http'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = fileURLToPath(new URL('..', import.meta.url)); +const repoRoot = resolve(appRoot, '../..'); +const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json'); +const viteHost = '127.0.0.1'; +const vitePort = 3080; +const viteUrl = `http://${viteHost}:${vitePort}/`; +const viteMarkerUrl = `${viteUrl}__agc_dev_server.json`; +const defaultApiTarget = process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082'; +const backendDatabase = 'genarrative-game-creator-dev'; +const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + +function readJson(path) { + if (!existsSync(path)) { + return null; + } + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch { + return null; + } +} + +function httpGetText(url, timeout = 1000) { + return new Promise((resolveRequest) => { + const request = http.get(url, { timeout }, (response) => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => { + if (body.length < 4096) { + body += chunk; + } + }); + response.on('end', () => { + resolveRequest({ + statusCode: response.statusCode ?? 0, + body, + }); + }); + }); + request.on('timeout', () => { + request.destroy(); + resolveRequest(null); + }); + request.on('error', () => resolveRequest(null)); + }); +} + +async function isHttpReady(url) { + const response = await httpGetText(url); + return Boolean(response && response.statusCode >= 200 && response.statusCode < 300); +} + +function readBackendTargets({ requireAgcDatabase = false } = {}) { + const state = readJson(devStackStatePath); + const apiServer = state?.services?.['api-server']; + const spacetime = state?.services?.spacetime; + const isActive = (service) => + service && ['running', 'reused', 'starting'].includes(service.status ?? ''); + const database = typeof state?.database === 'string' ? state.database : ''; + const hasMatchingDatabase = database === backendDatabase; + const canReuseState = !requireAgcDatabase || hasMatchingDatabase; + const apiUrl = + canReuseState && isActive(apiServer) && apiServer.url + ? apiServer.url + : requireAgcDatabase + ? '' + : defaultApiTarget; + const spacetimeUrl = + canReuseState && isActive(spacetime) && spacetime.url + ? spacetime.url + : requireAgcDatabase + ? '' + : 'http://127.0.0.1:3101'; + return { + apiUrl, + spacetimeUrl, + database, + hasMatchingDatabase, + }; +} + +async function isBackendReady() { + const { apiUrl, spacetimeUrl, hasMatchingDatabase } = readBackendTargets({ + requireAgcDatabase: true, + }); + return ( + hasMatchingDatabase && + Boolean(apiUrl) && + Boolean(spacetimeUrl) && + (await isHttpReady(`${apiUrl}/healthz`)) && + (await isHttpReady(`${spacetimeUrl}/v1/ping`)) + ); +} + +async function readExistingViteServer() { + return httpGetText(viteUrl); +} + +function isAiGameCreatorServer(response) { + return ( + response && + response.statusCode >= 200 && + response.statusCode < 500 && + response.body.includes('AI 游戏创作') && + response.body.includes('/src/main.tsx') + ); +} + +async function isExistingViteProxyReady() { + const response = await httpGetText(`${viteUrl}api/auth/me`, 2000); + return Boolean( + response && + response.statusCode >= 200 && + response.statusCode < 500 && + !response.body.includes('AI 游戏创作') && + !response.body.includes('/src/main.tsx'), + ); +} + +async function readExistingViteMarker() { + const response = await httpGetText(viteMarkerUrl, 2000); + if (!response || response.statusCode !== 200) { + return null; + } + try { + return JSON.parse(response.body); + } catch { + return null; + } +} + +async function isExistingVitePairedWithBackend(apiTarget) { + const marker = await readExistingViteMarker(); + return Boolean( + marker && + marker.schemaVersion === 1 && + marker.app === 'ai-game-creator-shell' && + marker.apiTarget === apiTarget, + ); +} + +function spawnChild(command, args, options) { + return spawn(command, args, { + ...options, + shell: true, + stdio: 'inherit', + }); +} + +function stopChild(child, signal = 'SIGTERM') { + if (!child || child.exitCode != null || child.signalCode != null) { + return; + } + try { + child.kill(signal); + } catch { + // ignore cleanup races + } +} + +async function waitForBackendReady(backendChild, timeoutMs = 600_000) { + const startedAt = Date.now(); + let backendExit = null; + backendChild?.on('exit', (code, signal) => { + backendExit = signal ? `signal=${signal}` : `code=${code ?? 0}`; + }); + while (Date.now() - startedAt < timeoutMs) { + if (await isBackendReady()) { + return readBackendTargets(); + } + if (backendExit) { + throw new Error(`配套后端启动失败: ${backendExit}`); + } + await new Promise((resolveWait) => setTimeout(resolveWait, 1000)); + } + throw new Error('等待配套后端和数据库启动超时'); +} + +async function ensureBackend() { + if (await isBackendReady()) { + const targets = readBackendTargets(); + console.log(`[ai-game-creator-shell] reuse backend ${targets.apiUrl}`); + return { backendChild: null, targets }; + } + + console.log('[ai-game-creator-shell] starting backend stack'); + const backendChild = spawnChild( + npm, + [ + '--prefix', + '../..', + 'run', + 'agc:backend', + '--', + '--database', + backendDatabase, + '--no-interactive', + ], + { cwd: appRoot }, + ); + const targets = await waitForBackendReady(backendChild); + console.log(`[ai-game-creator-shell] backend ready ${targets.apiUrl}`); + return { backendChild, targets }; +} + +async function startVite(apiTarget) { + const { apiUrl } = readBackendTargets(); + if (apiUrl !== apiTarget) { + throw new Error( + `dev-stack state API target ${apiUrl} does not match paired backend ${apiTarget}.`, + ); + } + + const existing = await readExistingViteServer(); + if (existing) { + if ( + isAiGameCreatorServer(existing) && + (await isExistingVitePairedWithBackend(apiTarget)) && + (await isExistingViteProxyReady()) + ) { + console.log(`[ai-game-creator-shell] reuse existing Vite dev server ${viteUrl}`); + return null; + } + if (isAiGameCreatorServer(existing)) { + throw new Error( + `${viteUrl} is already running, but its /api proxy is not connected to the paired backend. Stop it before starting Tauri dev.`, + ); + } + throw new Error( + `${viteUrl} is already in use by another server. Stop it before starting Tauri dev.`, + ); + } + + return spawnChild( + npm, + ['--prefix', '../..', 'exec', 'vite', '--', '--config', 'vite.config.ts'], + { cwd: appRoot }, + ); +} + +let backendChild = null; +let viteChild = null; + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + stopChild(viteChild, signal); + stopChild(backendChild, signal); + }); +} + +try { + const backend = await ensureBackend(); + backendChild = backend.backendChild; + viteChild = await startVite(backend.targets.apiUrl); + + const children = [backendChild, viteChild].filter(Boolean); + if (children.length === 0) { + process.exit(0); + } + + await new Promise((resolveExit) => { + for (const child of children) { + child.on('exit', (code, signal) => { + stopChild(viteChild); + stopChild(backendChild); + resolveExit(signal ? 1 : code ?? 0); + }); + } + }).then((code) => process.exit(code)); +} catch (error) { + stopChild(viteChild); + stopChild(backendChild); + console.error( + `[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); +} diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index 1354d16b5..5ae277d35 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -4,7 +4,7 @@ "version": "0.1.0", "identifier": "world.genarrative.ai-game-creator", "build": { - "beforeDevCommand": "npm --prefix ../.. run ai-game-creator-shell:typecheck && npm --prefix ../.. run ai-game-creator-shell:dev-server", + "beforeDevCommand": "npm --prefix ../.. run agc:serve", "beforeBuildCommand": "npm --prefix ../.. run ai-game-creator-shell:typecheck && npm --prefix ../.. exec vite -- build --config vite.config.ts", "devUrl": "http://127.0.0.1:3080/", "frontendDist": "../dist" diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index cb398a920..49e0ff72a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -4,8 +4,10 @@ import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, + type ReactNode, type UIEvent, useEffect, + useLayoutEffect, useRef, useState, } from 'react'; @@ -21,6 +23,18 @@ import { Upload, } from 'lucide-react'; +import type { + AuthEntryResponse, + AuthRefreshResponse, + AuthMeResponse, + AuthUser, + LogoutResponse, +} from '../../../packages/shared/src/contracts/auth'; +import { + API_RESPONSE_ENVELOPE_HEADER, + API_RESPONSE_ENVELOPE_VERSION, + unwrapApiResponse, +} from '../../../packages/shared/src/http'; import { createGameCreationAppManifest, createGameCreationAppSeedTasks, @@ -60,6 +74,7 @@ const AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT = 20; const AGENT_RUN_HISTORY_VISIBLE_STEP = 20; const CONVERSATION_INITIAL_VISIBLE_COUNT = 20; const CONVERSATION_VISIBLE_STEP = 20; +const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; type LauncherView = 'home' | 'projects' | 'guide' | 'contact' | 'news'; interface InitLocalProjectResult { @@ -578,10 +593,14 @@ function removeRecentWorkspace(path: string) { (workspace) => workspace !== trimmedPath, ); try { - window.localStorage.setItem( - RECENT_WORKSPACES_STORAGE_KEY, - JSON.stringify(recent), - ); + if (recent.length > 0) { + window.localStorage.setItem( + RECENT_WORKSPACES_STORAGE_KEY, + JSON.stringify(recent), + ); + } else { + window.localStorage.removeItem(RECENT_WORKSPACES_STORAGE_KEY); + } } catch { // WebView storage can be unavailable in restricted test shells. } @@ -864,7 +883,7 @@ function closeDialogOnEscape(event: ReactKeyboardEvent, onClose: () => void) { } function useEscapeToClose(onClose: () => void, enabled = true) { - useEffect(() => { + useLayoutEffect(() => { if (!enabled) { return; } @@ -881,6 +900,294 @@ function useEscapeToClose(onClose: () => void, enabled = true) { }, [enabled, onClose]); } +function normalizeAuthPhoneInput(phone: string) { + return phone.replace(/[^\d+]/gu, '').trim(); +} + +function getStoredAuthAccessToken() { + return window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)?.trim() || ''; +} + +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); +} + +function clearStoredAuthAccessToken() { + window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY); +} + +let clientAuthRefreshPromise: Promise | null = null; + +async function readAuthErrorMessage(response: Response, fallback: string) { + const text = await response.text(); + if (!text.trim()) { + return fallback; + } + try { + const parsed = JSON.parse(text) as unknown; + unwrapApiResponse(parsed); + } catch (error) { + return error instanceof Error ? error.message : fallback; + } + return fallback; +} + +async function requestAuthJson( + 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}`); + } + } + const response = await fetch(url, { + ...init, + credentials: 'same-origin', + headers, + }); + if (!response.ok) { + throw new Error(await readAuthErrorMessage(response, fallbackMessage)); + } + const text = await response.text(); + return text ? unwrapApiResponse(JSON.parse(text) as T) : (null as T); +} + +async function getCurrentClientAuthUser() { + const response = await requestAuthJson( + '/api/auth/me', + { method: 'GET' }, + '读取当前用户失败', + ); + return response.user; +} + +async function refreshClientAuthAccessToken() { + if (!clientAuthRefreshPromise) { + clientAuthRefreshPromise = requestAuthJson( + '/api/auth/refresh', + { method: 'POST' }, + '刷新登录状态失败', + { skipAuth: true }, + ) + .then((response) => { + setStoredAuthAccessToken(response.token); + return response.token; + }) + .finally(() => { + clientAuthRefreshPromise = null; + }); + } + return clientAuthRefreshPromise; +} + +async function loginClientWithPassword(phone: string, password: string) { + const response = await requestAuthJson( + '/api/auth/entry', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phone: normalizeAuthPhoneInput(phone), + password: password.trim(), + }), + }, + '登录失败', + { skipAuth: true }, + ); + setStoredAuthAccessToken(response.token); + return response.user; +} + +async function logoutClientAuthSession() { + try { + if (!getStoredAuthAccessToken()) { + await refreshClientAuthAccessToken().catch(() => ''); + } + try { + await requestAuthJson( + '/api/auth/logout', + { method: 'POST' }, + '退出登录失败', + ); + } catch { + await refreshClientAuthAccessToken(); + await requestAuthJson( + '/api/auth/logout', + { method: 'POST' }, + '退出登录失败', + ); + } + } finally { + clearStoredAuthAccessToken(); + } +} + +function getAuthUserInitials(user: AuthUser) { + const displayName = user.displayName?.trim() || user.phoneNumberMasked || 'tn'; + return displayName.slice(0, 2); +} + +export function AuthenticatedClient({ + children, +}: { + children: (session: { + user: AuthUser; + logout: () => void; + }) => ReactNode; +}) { + const [authStatus, setAuthStatus] = useState< + 'checking' | 'authenticated' | 'unauthenticated' + >('checking'); + const [authUser, setAuthUser] = useState(null); + const [phone, setPhone] = useState(''); + const [password, setPassword] = useState(''); + const [loginStatus, setLoginStatus] = useState('请登录后继续'); + const [loginBusy, setLoginBusy] = useState(false); + + useEffect(() => { + let disposed = false; + async function hydrateAuth() { + try { + if (!getStoredAuthAccessToken()) { + await refreshClientAuthAccessToken(); + } + const user = await getCurrentClientAuthUser(); + if (disposed) { + return; + } + if (user) { + setAuthUser(user); + setAuthStatus('authenticated'); + return; + } + clearStoredAuthAccessToken(); + setAuthStatus('unauthenticated'); + } catch { + if (disposed) { + return; + } + if (getStoredAuthAccessToken()) { + try { + await refreshClientAuthAccessToken(); + const user = await getCurrentClientAuthUser(); + if (disposed) { + return; + } + if (user) { + setAuthUser(user); + setAuthStatus('authenticated'); + return; + } + } catch { + // fall through to local logout below + } + } + clearStoredAuthAccessToken(); + setAuthStatus('unauthenticated'); + } + } + void hydrateAuth(); + return () => { + disposed = true; + }; + }, []); + + async function handleLoginSubmit(event: FormEvent) { + event.preventDefault(); + if (loginBusy) { + return; + } + const normalizedPhone = normalizeAuthPhoneInput(phone); + if (!normalizedPhone || !password.trim()) { + setLoginStatus('请输入手机号和密码'); + return; + } + setLoginBusy(true); + setLoginStatus('正在登录'); + try { + const user = await loginClientWithPassword(normalizedPhone, password); + setAuthUser(user); + setAuthStatus('authenticated'); + setPassword(''); + } catch (error) { + setLoginStatus(error instanceof Error ? error.message : String(error)); + } finally { + setLoginBusy(false); + } + } + + async function logout() { + try { + await logoutClientAuthSession(); + } catch { + clearStoredAuthAccessToken(); + } + setAuthUser(null); + setAuthStatus('unauthenticated'); + setLoginStatus('已退出登录'); + } + + if (authStatus === 'checking') { + return ( +
+
+ tn +

正在检查登录状态

+
+
+ ); + } + + if (!authUser) { + return ( +
+
+ tn +
+

登录陶泥儿 GameAgent

+

登录后进入首页和本地项目工作区

+
+ + + +

{loginStatus}

+
+
+ ); + } + + return <>{children({ user: authUser, logout })}; +} + function closeDialogOnBackdropMouseDown( event: ReactMouseEvent, onClose: () => void, @@ -1396,7 +1703,13 @@ function RuntimeConfigDialog({ ); } -export function WorkspaceLauncher() { +export function WorkspaceLauncher({ + currentUser, + onLogout, +}: { + currentUser: AuthUser; + onLogout: () => void; +}) { const [projectPath, setProjectPath] = useState(defaultProjectPath); const [status, setStatus] = useState('请选择项目'); const [launcherView, setLauncherView] = useState('home'); @@ -1603,33 +1916,71 @@ export function WorkspaceLauncher() { } } + function handleRecentWorkspaceRefresh() { + if (recentWorkspaces.length === 0 || recentWorkspaceRefreshing) { + return; + } + setRecentWorkspaceRefreshKey((current) => current + 1); + } + + function handleRecentWorkspaceRemove(nextProjectPath: string) { + setRecentWorkspaces(removeRecentWorkspace(nextProjectPath)); + setRecentWorkspaceStatuses((current) => { + const { [nextProjectPath]: _removed, ...rest } = current; + return rest; + }); + } + + function handleRecentWorkspaceClear() { + setRecentWorkspaces(clearRecentWorkspaces()); + setRecentWorkspaceStatuses({}); + } + const projectRows = recentWorkspaces.map((workspace) => { const directoryStatus = recentWorkspaceStatuses[workspace]; - return { - path: workspace, - name: - directoryStatus?.projectName || - workspace.split(/[\\/]/).filter(Boolean).pop() || - workspace, - status: - directoryStatus === null - ? '检查失败' - : directoryStatus?.exists === false - ? '未找到' - : directoryStatus?.isDirectory === false - ? '不是文件夹' + const isPendingStatus = directoryStatus === undefined; + const projectName = + directoryStatus?.projectName || + workspace.split(/[\\/]/).filter(Boolean).pop() || + workspace; + const status = + recentWorkspaceRefreshing + ? '检查中' + : isPendingStatus + ? '检查中' + : directoryStatus === null + ? '检查失败' + : directoryStatus?.exists === false + ? '未找到' + : directoryStatus?.isDirectory === false + ? '不是文件夹' + : directoryStatus?.manifestError + ? '无法读取' : directoryStatus?.isGameCreatorProject === false ? '未初始化' : directoryStatus?.recentRunStatus - ? `最近运行:${directoryStatus.recentRunStatus}` - : recentWorkspaceRefreshing - ? '检查中' - : '本地项目', + ? `run: ${directoryStatus.recentRunStatus}${ + directoryStatus.recentRunStopReason + ? ` · ${directoryStatus.recentRunStopReason}` + : '' + }` + : '本地项目'; + const canReveal = + !recentWorkspaceRefreshing && + Boolean(directoryStatus) && + directoryStatus?.exists !== false && + directoryStatus?.isDirectory !== false; + return { + path: workspace, + name: projectName, + status, + canReveal, canOpen: !recentWorkspaceRefreshing && - directoryStatus !== null && + Boolean(directoryStatus) && directoryStatus?.exists !== false && directoryStatus?.isDirectory !== false && + !directoryStatus?.manifestError && directoryStatus?.isGameCreatorProject !== false, }; }); @@ -1744,8 +2095,13 @@ export function WorkspaceLauncher() { ☁ 同时出图放大 +91% - @@ -1784,47 +2140,83 @@ export function WorkspaceLauncher() { -
-
-

最近项目

- -
-
-
- - 新建项目 -
- {projectRows.slice(0, 4).map((project) => ( -
+ {projectRows.length > 0 ? ( +
+
+
+

最近项目

+

{status}

+
+
- {project.name} - {project.status} -
- ))} -
-
+ + + + +
+ {projectRows.map((project) => ( +
+ +
+ + +
+
+ ))} +
+ + ) : ( +
+

欢迎使用 AI 游戏创作

+ +
+ )} ) : launcherView === 'projects' ? (
@@ -1833,6 +2225,16 @@ export function WorkspaceLauncher() {

项目

{recentWorkspaceRefreshing ? '正在检查项目状态' : status}

+ +
+
+ +
- +
{projectRows.length > 0 ? ( projectRows.map((project) => (
-
+
+ {project.status} +
)) ) : ( diff --git a/apps/ai-game-creator-shell/src/main.tsx b/apps/ai-game-creator-shell/src/main.tsx index f9a3c8efd..17f2b9faa 100644 --- a/apps/ai-game-creator-shell/src/main.tsx +++ b/apps/ai-game-creator-shell/src/main.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; -import { App, WorkspaceLauncher } from './App'; +import { App, AuthenticatedClient, WorkspaceLauncher } from './App'; import './styles.css'; function shouldRenderMainApp() { @@ -13,6 +13,14 @@ function shouldRenderMainApp() { createRoot(document.getElementById('root') as HTMLElement).render( - {shouldRenderMainApp() ? : } + + {({ user, logout }) => + shouldRenderMainApp() ? ( + + ) : ( + + ) + } + , ); diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index b8ba84fef..8cd170084 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -19,6 +19,96 @@ textarea { font: inherit; } +.client-auth-shell { + display: grid; + min-height: 100vh; + padding: 24px; + background: + linear-gradient(180deg, #dfff4b 0 32px, transparent 32px), + #f8fafc; + color: #111827; + place-items: center; +} + +.client-auth-panel { + display: grid; + width: min(360px, 100%); + gap: 16px; + padding: 28px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; + box-shadow: 0 18px 52px rgb(15 23 42 / 12%); +} + +.client-auth-logo { + display: grid; + width: 34px; + height: 34px; + border-radius: 50%; + background: #101010; + color: #fff; + font-size: 12px; + font-weight: 800; + place-items: center; +} + +.client-auth-panel h1 { + margin: 0; + font-size: 24px; + letter-spacing: 0; +} + +.client-auth-panel p { + margin: 6px 0 0; + color: #6b7280; + font-size: 13px; +} + +.client-auth-panel label { + display: grid; + gap: 7px; + color: #374151; + font-size: 13px; + font-weight: 700; +} + +.client-auth-panel input { + height: 38px; + min-width: 0; + padding: 0 11px; + border: 1px solid #d1d5db; + border-radius: 8px; + background: #fff; + color: #111827; + font: inherit; +} + +.client-auth-panel input:focus { + border-color: #111827; + outline: 2px solid rgb(17 24 39 / 10%); +} + +.client-auth-panel button { + height: 38px; + border: 0; + border-radius: 8px; + background: #111827; + color: #fff; + font-weight: 700; + cursor: pointer; +} + +.client-auth-panel button:disabled { + cursor: default; + opacity: 0.62; +} + +.client-auth-status { + min-height: 18px; + overflow-wrap: anywhere; +} + .launcher-shell { display: grid; grid-template-columns: 48px minmax(0, 1fr); @@ -379,14 +469,26 @@ textarea { font-size: 15px; } +.launcher-project-status { + margin: 4px 0 0; + color: #8b8b8b; + font-size: 12px; +} + .launcher-project-list header button, -.launcher-project-list header button { +.launcher-project-list-empty > button { padding: 0; background: transparent; color: #888; font-size: 12px; } +.launcher-project-list-actions { + display: flex; + align-items: center; + gap: 10px; +} + .launcher-project-grid { display: grid; grid-template-columns: repeat(5, 1fr); @@ -419,6 +521,27 @@ textarea { opacity: 0.62; } +.launcher-project-card-main { + display: grid; + align-content: start; + justify-items: start; + gap: 6px; + padding: 12px; + text-align: left; +} + +.launcher-project-card-main > span { + display: grid; + width: 42px; + height: 42px; + border-radius: 12px; + background: #fff; + color: #f26d2d; + font-size: 16px; + font-weight: 700; + place-items: center; +} + .launcher-project-card strong { min-width: 0; color: #111; @@ -438,6 +561,27 @@ textarea { white-space: nowrap; } +.launcher-project-card-actions { + display: flex; + gap: 6px; +} + +.launcher-project-card-actions button { + flex: 1; + min-width: 0; + height: 28px; + border: 1px solid #d8dde5; + border-radius: 8px; + background: #fff; + color: #555; + font-size: 12px; +} + +.launcher-project-card-actions button:disabled { + color: #aaa; + cursor: default; +} + .launcher-project-create > button { display: grid; border-style: dashed; @@ -493,9 +637,51 @@ textarea { .launcher-page-actions { display: flex; + flex-wrap: wrap; gap: 8px; } +.launcher-project-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 10px; + margin: 16px 0 14px; + padding: 12px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; +} + +.launcher-project-form label { + display: grid; + gap: 6px; + min-width: 0; + color: #6b7280; + font-size: 12px; +} + +.launcher-project-list-empty { + justify-items: center; + gap: 10px; + padding: 18px 0 32px; +} + +.launcher-project-list-empty h2 { + color: #111827; + font-size: 16px; +} + +.launcher-project-form input { + min-width: 0; + height: 34px; + padding: 0 10px; + border: 1px solid #d8dde5; + border-radius: 8px; + color: #111827; + background: #fff; +} + .launcher-page-actions button, .launcher-project-table article > button, .launcher-empty-projects button { @@ -521,7 +707,7 @@ textarea { .launcher-project-table article { display: grid; - grid-template-columns: minmax(0, 1fr) auto auto auto; + grid-template-columns: minmax(0, 1fr) auto auto auto auto; align-items: center; gap: 10px; min-height: 62px; @@ -531,6 +717,17 @@ textarea { background: #fff; } +.launcher-project-table article > .launcher-project-row-main { + display: grid; + justify-items: start; + height: auto; + min-width: 0; + padding: 0; + border: 0; + background: transparent; + text-align: left; +} + .launcher-project-table article strong { display: block; color: #111827; @@ -745,6 +942,10 @@ textarea { overflow: visible; } + .launcher-project-form { + grid-template-columns: 1fr; + } + .launcher-project-card > div { height: 132px; } diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index ef96f51f6..86efb4ff7 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -19,7 +19,27 @@ import { GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, type GameCreationAgentRunTrace, } from '../../../packages/shared/src/contracts/gameCreationApp'; -import { App, WorkspaceLauncher, deriveAgentStatusCards } from '../src/App'; +import type { AuthUser } from '../../../packages/shared/src/contracts/auth'; +import { + App, + AuthenticatedClient, + WorkspaceLauncher, + deriveAgentStatusCards, +} from '../src/App'; + +const testAuthUser: AuthUser = { + id: 'user-test', + publicUserCode: 'tn-test', + displayName: '测试用户', + avatarUrl: null, + phoneNumber: null, + phoneNumberMasked: '138****0000', + loginMethod: 'password', + bindingStatus: 'active', + wechatBound: false, + wechatDisplayName: null, + wechatAccount: null, +}; function renderAppAt(path: string) { window.history.pushState({}, '', path); @@ -28,7 +48,17 @@ function renderAppAt(path: string) { function renderLauncherAt(path: string) { window.history.pushState({}, '', path); - render(React.createElement(WorkspaceLauncher)); + render( + React.createElement(WorkspaceLauncher, { + currentUser: testAuthUser, + onLogout: vi.fn(), + }), + ); +} + +function renderLauncherProjectsAt(path: string) { + renderLauncherAt(path); + fireEvent.click(screen.getByRole('button', { name: '项目' })); } function submitChat(value: string) { @@ -57,6 +87,47 @@ afterEach(() => { }); describe('AI 游戏创作 App 界面边界', () => { + it('deduplicates startup auth refresh when React StrictMode hydrates twice', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response(JSON.stringify({ token: 'fresh-token' }), { + status: 200, + }); + } + if (url === '/api/auth/me') { + return new Response( + JSON.stringify({ + user: testAuthUser, + availableLoginMethods: ['password'], + }), + { status: 200 }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + render( + React.createElement( + React.StrictMode, + null, + React.createElement(AuthenticatedClient, null, ({ user }) => + React.createElement('main', { 'aria-label': '已登录' }, user.displayName), + ), + ), + ); + + expect(await screen.findByLabelText('已登录')).not.toBeNull(); + expect( + fetchSpy.mock.calls.filter(([input]) => String(input) === '/api/auth/refresh'), + ).toHaveLength(1); + expect(window.localStorage.getItem('genarrative.auth.access-token.v1')).toBe( + 'fresh-token', + ); + }); + it('derives agent card status from the latest run trace step', () => { const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -470,11 +541,10 @@ describe('AI 游戏创作 App 界面边界', () => { }, ); window.__TAURI__ = { core: { invoke } }; - renderLauncherAt('/?launcher'); + renderLauncherProjectsAt('/?launcher'); expect(screen.getByLabelText('项目启动器')).not.toBeNull(); expect(screen.queryByLabelText('聊天')).toBeNull(); - expect(screen.queryByRole('button', { name: '帮助' })).toBeNull(); fireEvent.change(screen.getByLabelText('项目目录'), { target: { value: '/tmp/authorized-game' }, }); @@ -522,7 +592,7 @@ describe('AI 游戏创作 App 界面边界', () => { JSON.stringify(['/tmp/authorized-game']), ); window.__TAURI__ = { core: { invoke } }; - renderLauncherAt('/?launcher'); + renderLauncherProjectsAt('/?launcher'); expect(await screen.findByText('authorized-game')).not.toBeNull(); fireEvent.click(screen.getByText('authorized-game')); @@ -577,7 +647,7 @@ describe('AI 游戏创作 App 界面边界', () => { }, ); window.__TAURI__ = { core: { invoke } }; - renderLauncherAt('/?launcher'); + renderLauncherProjectsAt('/?launcher'); fireEvent.change(screen.getByLabelText('项目目录'), { target: { value: '/tmp/missing-game' }, @@ -623,7 +693,7 @@ describe('AI 游戏创作 App 界面边界', () => { throw new Error(`unexpected invoke ${command}`); }); window.__TAURI__ = { core: { invoke } }; - renderLauncherAt('/?launcher'); + renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '选择' })); @@ -639,7 +709,7 @@ describe('AI 游戏创作 App 界面边界', () => { throw new Error(`unexpected invoke ${command}`); }); window.__TAURI__ = { core: { invoke } }; - renderLauncherAt('/?launcher'); + renderLauncherProjectsAt('/?launcher'); fireEvent.change(screen.getByLabelText('项目目录'), { target: { value: '/tmp/manual-game' }, @@ -653,7 +723,7 @@ describe('AI 游戏创作 App 界面边界', () => { it('rejects launcher project paths with control characters before Tauri calls', () => { const invoke = vi.fn(async () => undefined); window.__TAURI__ = { core: { invoke } }; - renderLauncherAt('/?launcher'); + renderLauncherProjectsAt('/?launcher'); fireEvent.change(screen.getByLabelText('项目目录'), { target: { value: '/tmp/bad\u0007path' }, @@ -964,7 +1034,7 @@ describe('AI 游戏创作 App 界面边界', () => { throw new Error(`unexpected invoke ${command}`); }); window.__TAURI__ = { core: { invoke } }; - renderLauncherAt('/?launcher'); + renderLauncherProjectsAt('/?launcher'); fireEvent.change(screen.getByLabelText('项目目录'), { target: { value: '/tmp/typed-game' }, @@ -983,7 +1053,7 @@ describe('AI 游戏创作 App 界面边界', () => { it('rejects invalid typed launcher project directories before opening them', () => { const invoke = vi.fn(); window.__TAURI__ = { core: { invoke } }; - renderLauncherAt('/?launcher'); + renderLauncherProjectsAt('/?launcher'); fireEvent.change(screen.getByLabelText('项目目录'), { target: { value: 'relative-game' }, @@ -1019,7 +1089,7 @@ describe('AI 游戏创作 App 界面边界', () => { }, ); window.__TAURI__ = { core: { invoke } }; - renderLauncherAt('/?launcher'); + renderLauncherProjectsAt('/?launcher'); fireEvent.change(screen.getByLabelText('项目目录'), { target: { value: '/tmp/open-failed-game' }, @@ -1305,7 +1375,7 @@ describe('AI 游戏创作 App 界面边界', () => { throw new Error(`unexpected invoke ${command}`); }); window.__TAURI__ = { core: { invoke } }; - renderLauncherAt('/?launcher'); + renderLauncherProjectsAt('/?launcher'); fireEvent.change(screen.getByLabelText('项目目录'), { target: { value: '/tmp/typed-game' }, @@ -1338,7 +1408,7 @@ describe('AI 游戏创作 App 界面边界', () => { }, ); window.__TAURI__ = { core: { invoke } }; - renderLauncherAt('/?launcher'); + renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '选择' })); expect(await screen.findByText('已选择项目目录')).not.toBeNull(); @@ -1385,7 +1455,7 @@ describe('AI 游戏创作 App 界面边界', () => { ); const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); window.__TAURI__ = { core: { invoke } }; - renderLauncherAt('/?launcher'); + renderLauncherProjectsAt('/?launcher'); fireEvent.change(screen.getByLabelText('项目目录'), { target: { value: '/tmp/non-empty-game' }, @@ -1432,7 +1502,7 @@ describe('AI 游戏创作 App 界面边界', () => { ); const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); window.__TAURI__ = { core: { invoke } }; - renderLauncherAt('/?launcher'); + renderLauncherProjectsAt('/?launcher'); fireEvent.change(screen.getByLabelText('项目目录'), { target: { value: '/tmp/non-empty-game' }, @@ -1489,7 +1559,7 @@ describe('AI 游戏创作 App 界面边界', () => { }, ); window.__TAURI__ = { core: { invoke } }; - renderLauncherAt('/?launcher'); + renderLauncherProjectsAt('/?launcher'); fireEvent.change(screen.getByLabelText('项目目录'), { target: { value: '/tmp/folder-named-game/' }, @@ -1519,7 +1589,7 @@ describe('AI 游戏创作 App 界面边界', () => { throw new Error(`unexpected invoke ${command}`); }); window.__TAURI__ = { core: { invoke } }; - renderLauncherAt('/?launcher'); + renderLauncherProjectsAt('/?launcher'); fireEvent.change(screen.getByLabelText('项目目录'), { target: { value: '/tmp/new-game' }, diff --git a/apps/ai-game-creator-shell/vite.config.ts b/apps/ai-game-creator-shell/vite.config.ts index 239d2ccab..94caaba66 100644 --- a/apps/ai-game-creator-shell/vite.config.ts +++ b/apps/ai-game-creator-shell/vite.config.ts @@ -1,3 +1,4 @@ +import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -5,14 +6,68 @@ import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; const appRoot = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(appRoot, '../..'); + +function resolveDevApiTarget() { + const statePath = resolve(repoRoot, '.app/dev-stack.json'); + if (!existsSync(statePath)) { + return 'http://127.0.0.1:8082'; + } + try { + const state = JSON.parse(readFileSync(statePath, 'utf8')) as { + services?: { + 'api-server'?: { + status?: string; + url?: string; + }; + }; + }; + const apiServer = state.services?.['api-server']; + if ( + apiServer && + ['running', 'reused', 'starting'].includes(apiServer.status ?? '') && + apiServer.url + ) { + return apiServer.url; + } + return 'http://127.0.0.1:8082'; + } catch { + return 'http://127.0.0.1:8082'; + } +} + +const apiTarget = resolveDevApiTarget(); export default defineConfig({ root: appRoot, - plugins: [react()], + plugins: [ + react(), + { + name: 'genarrative-ai-game-creator-dev-marker', + configureServer(server) { + server.middlewares.use('/__agc_dev_server.json', (_request, response) => { + response.setHeader('Content-Type', 'application/json; charset=utf-8'); + response.end( + JSON.stringify({ + schemaVersion: 1, + app: 'ai-game-creator-shell', + apiTarget, + }), + ); + }); + }, + }, + ], server: { host: '127.0.0.1', port: 3080, strictPort: true, + proxy: { + '/api': { + target: apiTarget, + changeOrigin: true, + }, + }, }, build: { outDir: resolve(appRoot, 'dist'), diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index cdff5605e..e86bff5f3 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -51,6 +51,14 @@ npm install npm run dev ``` +AI 游戏创作独立客户端常用短命令: + +```bash +npm run agc +``` + +`npm run agc` 会启动 Tauri 开发客户端;其 `beforeDevCommand` 通过 `npm run agc:serve` 先完成壳 typecheck,再启动或复用配套 SpacetimeDB、`api-server` 和固定 `127.0.0.1:3080` Vite。只需要浏览器预览同一客户端时可用 `npm run agc:serve`;只启动配套后端和数据库时可用 `npm run agc:backend -- --database `。 + Linux 多用户共享同一台机器开发时,本地 dev 脚本会为当前 Linux 用户分配一个固定端口段并写入系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,自动分配从 `10000-10099` 开始,每段 100 个端口,四个 dev 服务依次使用 `start` 到 `start + 3`。可用 `GENARRATIVE_DEV_PORT_RANGE` 或 `npm run dev -- --port-range` 手动指定端口段用于特殊场景;注册表会阻止不同用户使用相同或重叠段,并让同一用户后续启动继续复用自己已占用的固定段。该机制只在 Linux 生效,Windows 仍沿用原有端口探测与漂移逻辑。 本地 `npm run dev`、`npm run dev:spacetime` 和 `npm run dev:api-server` 会在 Rust 子进程环境中绕过项目默认 `sccache` wrapper,避免损坏的本机 cache daemon 阻断 `spacetime publish` 或 `api-server` 启动;显式设置的非 sccache 自定义 wrapper 会被保留。生产 / Jenkins 构建仍按流水线自身的 sccache 策略执行。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 417c64002..1d8036505 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -6,8 +6,8 @@ ## 技术选择 -- 桌面壳:新建 `apps/ai-game-creator-shell`,与现有 `apps/desktop-shell` 分离,避免把游戏创作本地能力塞进主站宿主壳;正式用户窗口只加载聊天页,开发构建单独打开开发窗口承载任务、文件、预览和日志面板。 -- 平台后端:继续使用 `server-rs + Axum + SpacetimeDB`。 +- 桌面壳:新建 `apps/ai-game-creator-shell`,与现有 `apps/desktop-shell` 分离,避免把游戏创作本地能力塞进主站宿主壳;启动时先检查平台登录态,未登录只展示登录页,登录后才进入启动器 / 首页;正式用户窗口只加载聊天页,开发构建单独打开开发窗口承载任务、文件、预览和日志面板。 +- 平台后端:继续使用 `server-rs + Axum + SpacetimeDB`;本地开发启动独立客户端时,`agc` / Tauri dev 会先启动或复用配套 SpacetimeDB 与 `api-server`,再启动固定端口 Vite,并通过 `/api` 代理访问实际后端端口。 - 本地能力:使用 Tauri Rust command;正式用户 App 只启动 `127.0.0.1` 本地 HTTP preview 并交给外部浏览器,不在正式用户窗口内承载游戏预览画面。 - Agent Runtime:扩展 `server-rs/crates/platform-agent`,不引入 LangChain、AutoGen、Microsoft Agent Framework 或 OpenAI Agents SDK sidecar 作为核心。 - 设计参考:借鉴 OpenAI Agents SDK 的 Agent、Tools、Handoffs、Guardrails、Tracing 抽象,但运行时由 Genarrative 自己掌控。 diff --git a/package.json b/package.json index c18f5256c..b3a95b414 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,14 @@ "desktop-shell:stage-release-binary": "npm --prefix apps/desktop-shell run stage-release-binary", "desktop-shell:typecheck": "npm --prefix apps/desktop-shell run typecheck", "desktop-shell:test": "cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml", + "agc": "npm --prefix apps/ai-game-creator-shell run dev", + "agc:dev": "npm --prefix apps/ai-game-creator-shell run dev", + "agc:serve": "npm run agc:typecheck && npm --prefix apps/ai-game-creator-shell run dev-stack", + "agc:vite": "npm --prefix apps/ai-game-creator-shell run dev-server", + "agc:backend": "node scripts/dev.mjs backend", + "agc:build": "npm --prefix apps/ai-game-creator-shell run build --", + "agc:check": "npm run ai-game-creator-shell:check", + "agc:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck", "ai-game-creator-shell:dev": "npm --prefix apps/ai-game-creator-shell run dev", "ai-game-creator-shell:dev-server": "npm --prefix apps/ai-game-creator-shell run dev-server", "ai-game-creator-shell:build": "npm --prefix apps/ai-game-creator-shell run build --", diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 169ec23f6..e50c37ab9 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -134,12 +134,14 @@ const SERVICE_ALIASES = new Map([ ['api', 'api-server'], ['admin', 'admin-web'], ['adminWeb', 'admin-web'], + ['backend', 'backend'], ['all', 'all'], ]); function usage() { console.log(`用法: npm run dev [-- --watch] [-- --api-port 8090] + npm run dev backend [-- --watch] npm run dev:spacetime [-- --skip-publish] npm run dev:api-server [-- --database genarrative-dev] npm run dev:web [-- --api-port 8082] @@ -345,7 +347,7 @@ function parseArgs(argv, baseEnv) { function normalizeServiceName(rawName) { const alias = SERVICE_ALIASES.get(rawName); const name = alias ?? rawName; - if (name === 'all' || SERVICE_NAMES.includes(name)) { + if (name === 'all' || name === 'backend' || SERVICE_NAMES.includes(name)) { return name; } @@ -627,11 +629,16 @@ function ensureSpacetimeToolVersionMatchesWorkspace() { function ensureRequiredFiles(command) { const requiredFiles = []; - if (command === 'api-server' || command === 'spacetime' || command === 'all') { + if ( + command === 'api-server' || + command === 'spacetime' || + command === 'all' || + command === 'backend' + ) { requiredFiles.push([manifestPath, 'server-rs/Cargo.toml']); } - if (command === 'spacetime' || command === 'all') { + if (command === 'spacetime' || command === 'all' || command === 'backend') { requiredFiles.push([resolve(modulePath, 'Cargo.toml'), 'spacetime-module Cargo.toml']); } @@ -1062,12 +1069,13 @@ class DevRunner { this.command = command; ensureRequiredFiles(command); requireCommand('node'); - if (command === 'api-server' || command === 'all') { + if (command === 'api-server' || command === 'all' || command === 'backend') { requireCommand('cargo'); } if ( command === 'spacetime' || - (command === 'all' && (!this.options.skipSpacetime || !this.options.skipPublish)) + ((command === 'all' || command === 'backend') && + (!this.options.skipSpacetime || !this.options.skipPublish)) ) { requireCommand('spacetime'); } @@ -1134,6 +1142,9 @@ class DevRunner { if (command === 'all') { return !this.options.skipSpacetime || !this.options.skipPublish; } + if (command === 'backend') { + return !this.options.skipSpacetime || !this.options.skipPublish; + } if (command === 'api-server') { return isLoopbackSpacetimeServer(this.state.spacetimeServer); } @@ -1148,6 +1159,7 @@ class DevRunner { if ( this.options.spacetimeServerUrl && command !== 'all' && + command !== 'backend' && command !== 'spacetime' ) { return; @@ -1230,7 +1242,7 @@ class DevRunner { const portRangeFor = (optionName) => this.explicitOptions.has(optionName) ? null : this.state.portRange; - if (command === 'all' || command === 'spacetime') { + if (command === 'all' || command === 'backend' || command === 'spacetime') { if (!options.skipSpacetime && !this.state.spacetimeReused) { portConfig.spacetime = { host: options.spacetimeHost, @@ -1240,7 +1252,7 @@ class DevRunner { } } - if (command === 'all' || command === 'api-server') { + if (command === 'all' || command === 'backend' || command === 'api-server') { portConfig.api = { host: options.apiHost, preferredPort: options.apiPort, @@ -1298,7 +1310,7 @@ class DevRunner { this.state.apiTargetHost = resolveClientHost(options.apiHost); this.state.adminWebTargetHost = resolveClientHost(options.adminWebHost); - if (command === 'all' || command === 'spacetime') { + if (command === 'all' || command === 'backend' || command === 'spacetime') { this.state.spacetimeServer = `http://${options.spacetimeHost}:${options.spacetimePort}`; } this.state.apiTarget = `http://${this.state.apiTargetHost}:${options.apiPort}`; @@ -1392,6 +1404,14 @@ class DevRunner { return; } + if (command === 'backend') { + await this.startSpacetimeForFullStack(); + await this.services.get('api-server').start(); + await this.waitForApiServer(); + this.startWatchers(['spacetime', 'api-server']); + return; + } + if (command === 'spacetime') { await this.startSpacetimeForFullStack(); } else {