接入客户端登录和配套后端启动

启动客户端时先检查平台登录态,未登录展示登录页,登录后进入启动器首页

独立客户端登录使用平台 auth 接口并对 refresh 做并发去重

Tauri 开发启动改为短命令 agc:serve,并先拉起配套 SpacetimeDB 与 api-server

AGC Vite 代理读取 dev-stack 状态并暴露本地 marker,复用时校验 API target

恢复启动器项目目录输入、最近项目刷新移除和非空文件夹提醒交互

补充启动器和登录 gate 测试,并更新 AI 游戏创作 App 开发文档
This commit is contained in:
AIGameCreator App
2026-07-08 02:25:31 +08:00
parent 3b3d9dcf8e
commit f9d8142068
13 changed files with 1188 additions and 104 deletions
+1
View File
@@ -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
+10 -2
View File
@@ -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>,
);
+203 -2
View File
@@ -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' },
+56 -1
View File
@@ -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 自己掌控。
+8
View File
@@ -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
View File
@@ -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 {