接入客户端登录和配套后端启动
启动客户端时先检查平台登录态,未登录展示登录页,登录后进入启动器首页 独立客户端登录使用平台 auth 接口并对 refresh 做并发去重 Tauri 开发启动改为短命令 agc:serve,并先拉起配套 SpacetimeDB 与 api-server AGC Vite 代理读取 dev-stack 状态并暴露本地 marker,复用时校验 API target 恢复启动器项目目录输入、最近项目刷新移除和非空文件夹提醒交互 补充启动器和登录 gate 测试,并更新 AI 游戏创作 App 开发文档
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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('<title>AI 游戏创作</title>') &&
|
||||
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('<title>AI 游戏创作</title>') &&
|
||||
!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);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
<React.StrictMode>
|
||||
{shouldRenderMainApp() ? <App /> : <WorkspaceLauncher />}
|
||||
<AuthenticatedClient>
|
||||
{({ user, logout }) =>
|
||||
shouldRenderMainApp() ? (
|
||||
<App />
|
||||
) : (
|
||||
<WorkspaceLauncher currentUser={user} onLogout={logout} />
|
||||
)
|
||||
}
|
||||
</AuthenticatedClient>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 <name>`。
|
||||
|
||||
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 策略执行。
|
||||
|
||||
@@ -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 自己掌控。
|
||||
|
||||
@@ -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 --",
|
||||
|
||||
+28
-8
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user