diff --git a/.env.example b/.env.example index ac31e1dd9..60dc47b7e 100644 --- a/.env.example +++ b/.env.example @@ -237,14 +237,10 @@ GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false" # 官网客户端下载检测渠道:dev、release 或自定义渠道;修改后重启 API 服务。 # Windows/macOS 是系统维度,不填写 dev-win/dev-mac。 +# 客户端埋点也复用该渠道:dev 对应 https://dev.genarrative.world,release 对应 https://www.genarrative.world。 +# 埋点不接受其它渠道;本地 dev 且 GENARRATIVE_ENV 为 development(默认)/test/container 时允许 loopback 地址及可变端口。 GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev" -# 客户端埋点接收绑定的公开 origin,由 API Server 运行时读取;修改后重启服务。 -# 必须与客户端登录地址一致,不带 /api、路径或尾部斜杠;未配置/非法时上传接口返回 503。 -# 本地端口按实际启动结果填写(端口漂移后需同步),localhost 与 127.0.0.1 不可混用。 -# dev 使用 https://dev.genarrative.world;release 使用 https://www.genarrative.world。 -GENARRATIVE_AGC_ANALYTICS_ORIGIN="http://127.0.0.1:8082" - # Optional: official VikingDB credentials for regenerating build-tag similarities # with the Python embedding script. The script auto-loads `.env.local` and uses # the fixed `bge-large-zh` embedding model. diff --git a/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx b/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx index 667e44af5..11c3f5493 100644 --- a/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx +++ b/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx @@ -339,7 +339,10 @@ test.each([409, 503])( await confirmWrite(); const message = await screen.findByRole('alert'); const feedback = message.parentElement!; - expect(document.activeElement).toBe(feedback); + // 聚焦发生在 React passive effect 里(AdminAgcTemplatesPage 的 feedback 聚焦 useEffect), + // 而 findByRole 在 alert 节点一挂上就返回,可能早于该 effect 执行;这里等聚焦落地, + // 避免在 CI 负载下抢跑。断言口径不变:焦点最终必须落在提示区而不是弹窗面板。 + await waitFor(() => expect(document.activeElement).toBe(feedback)); expect(feedback.tabIndex).toBe(-1); expect(focus).toHaveBeenCalledWith({ preventScroll: true }); expect(viewport.scrollTop).toBe(20); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index d40df5a92..9fe8fc245 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -47,6 +47,7 @@ import type { TauriInvoke, } from './app/types'; import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel'; +import { GamePublishBlockedDialog } from './components/game-distribution/GamePublishBlockedDialog'; import { GamePublishProgressDialog, type GamePublishProgressState, @@ -359,6 +360,9 @@ export function App({ const [publishPanelOpen, setPublishPanelOpen] = useState(false); const [publishProgress, setPublishProgress] = useState(null); + const [publishBlockedMessage, setPublishBlockedMessage] = useState< + string | null + >(null); // 发布灰度:只有命中的账号才把「发布到游戏广场」入口交给聊天头;读取失败按不开放处理。 const [gamePublishAllowed, setGamePublishAllowed] = useState(false); const [projectChatError, setProjectChatError] = useState(''); @@ -1166,27 +1170,16 @@ export function App({ * 发布不再走旧的聊天确认卡:点击后立即打开全屏进度弹窗并锁住工作区, * 导出失败在弹窗内回显;成功后才切换到发布资料面板。 */ - /** - * 发布相关提示同时写工作台状态与 DirectProject 对话。 - * - * 普通项目走 `DirectProjectChatView` 时并不渲染工作台状态行,只写 workspaceStatus - * 会让「点了发布没反应」;这里统一通过聊天容器的 announce 出口回话。 - */ - function announcePublishMessage(message: string) { - setWorkspaceStatus(message); - directProjectChatRef.current?.announce(message); - } - async function requestGamePublish() { const invoke = resolveTauriInvoke(); if (!invoke) { - announcePublishMessage('需要在 Tauri App 内发布'); + setPublishBlockedMessage('需要在 Tauri App 内发布'); return; } const nextProjectPath = resolveChatProjectPath(localProject) ?? projectPath.trim(); if (!nextProjectPath) { - announcePublishMessage('先打开一个项目再发布'); + setPublishBlockedMessage('先打开一个项目再发布'); return; } const publishManifest = manifestRef.current; @@ -1197,7 +1190,7 @@ export function App({ publishManifest.preview?.status === 'running' && Boolean(publishManifest.preview.url?.trim()); if (!hasCompletedPrototype && !hasRunningPreview) { - announcePublishMessage( + setPublishBlockedMessage( '首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。', ); return; @@ -2276,6 +2269,10 @@ export function App({ progress={publishProgress} onClose={() => setPublishProgress(null)} /> + setPublishBlockedMessage(null)} + /> ); } @@ -2431,6 +2428,10 @@ export function App({ progress={publishProgress} onClose={() => setPublishProgress(null)} /> + setPublishBlockedMessage(null)} + /> ); } diff --git a/apps/ai-game-creator-shell/src/app/tauri.ts b/apps/ai-game-creator-shell/src/app/tauri.ts index b1e3d4346..39daea483 100644 --- a/apps/ai-game-creator-shell/src/app/tauri.ts +++ b/apps/ai-game-creator-shell/src/app/tauri.ts @@ -1,3 +1,6 @@ -export function resolveTauriInvoke() { +import type { TauriInvoke } from './types'; + +/** 返回 undefined 表示不在 Tauri 环境(浏览器预览、测试壳),调用方必须先判空。 */ +export function resolveTauriInvoke(): TauriInvoke | undefined { return window.__TAURI__?.core?.invoke; } diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx index 5f11e3f37..54eaa2db1 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx @@ -1,6 +1,9 @@ +import { Eye, Trash2 } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { GAME_DISTRIBUTION_CATEGORIES } from '../../../../../packages/shared/src/contracts/gameDistribution'; +import { IMAGE_MODEL_GPT_IMAGE_2 } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel'; import { resolveTauriInvoke } from '../../app/tauri'; import type { LocalProjectExportPackageResult } from '../../app/types'; import { uploadPlatformMediaAsset } from '../../services/assetDirectUpload'; @@ -8,8 +11,11 @@ import { createGameDistributionPublishKey, type GameDistributionPublishMetadata, type GameDistributionPublishResult, + generateGameDistributionCover, MAX_AGC_GAME_SCREENSHOTS, publishLocalProjectGame, + readGameCoverGenerationPrice, + suggestGameDistributionPublishMetadata, } from '../../services/gameDistributionPublish'; import { ThemedModal } from '../modal/ThemedModal'; @@ -27,6 +33,16 @@ type PanelImageAsset = { previewUrl: string; }; +type PanelScreenshotEntry = { + id: string; + name: string; + previewUrl: string; + assetObjectId: string | null; + status: 'uploading' | 'ready' | 'failed'; + error: string | null; + file: File; +}; + function resolvePanelImageLabel(kind: PanelImageKind) { return kind === 'cover' ? '游戏封面' : '游戏截图'; } @@ -61,15 +77,59 @@ function buildPanelImagePreviewUrl(file: File) { } } -const CATEGORIES = [ - '休闲', - '益智', - '动作', - '冒险', - '模拟', - '策略', - '其他', -] as const; +function createPanelScreenshotId() { + const randomUuid = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `screenshot-${randomUuid}`; +} + +const COVER_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2; +const COVER_GENERATION_ASPECT_RATIO = '16:9'; +const COVER_GENERATION_IMAGE_SIZE = '2K'; + +function buildPublishProjectContext(manifest: GameCreationAppManifest) { + const taskSummary = (manifest.tasks ?? []) + .slice(0, 16) + .map((task) => `${task.title}(${task.status})`) + .join('、'); + const assetSummary = (manifest.assets ?? []) + .slice(0, 24) + .map((asset) => `${asset.kind}:${asset.localPath}`) + .join('、'); + const versionSummary = (manifest.versions ?? []) + .slice(-3) + .map((version) => version.editPrompt?.trim()) + .filter(Boolean) + .join(';'); + return [ + `当前任务与状态:${taskSummary || '暂无'}`, + `现有素材:${assetSummary || '暂无'}`, + manifest.preview?.status + ? `运行视图:${manifest.preview.status}` + : '运行视图:未运行', + versionSummary ? `最近编辑:${versionSummary}` : '', + ] + .filter(Boolean) + .join('\n') + .slice(0, 6_000); +} + +function buildCoverGenerationPrompt( + manifest: GameCreationAppManifest, + summary: string, +) { + const goal = manifest.goal?.trim() || summary.trim(); + return [ + `为游戏《${manifest.name.trim() || '未命名游戏'}》生成一张游戏广场发行封面。`, + goal ? `创作目标与核心体验:${goal}` : '', + '主体画面需要体现游戏的核心玩法、题材氛围与代表性元素,构图清晰,适合作为游戏卡片和详情页首图。', + '16:9 横版构图,主体居中偏上,边缘保留安全区;不要生成文字、水印、平台标识、边框或界面截图。', + ] + .filter(Boolean) + .join('\n'); +} export function GameDistributionPublishPanel({ open, @@ -96,9 +156,23 @@ export function GameDistributionPublishPanel({ null, ); const [cover, setCover] = useState(null); - const [screenshots, setScreenshots] = useState([]); + const [screenshots, setScreenshots] = useState([]); + const [previewScreenshot, setPreviewScreenshot] = + useState(null); const [uploadingLabel, setUploadingLabel] = useState(''); + const [metadataSuggestionPending, setMetadataSuggestionPending] = + useState(false); + const [metadataSuggestionReady, setMetadataSuggestionReady] = useState(false); + const [coverConfirmOpen, setCoverConfirmOpen] = useState(false); + const [generatingCover, setGeneratingCover] = useState(false); + const [coverGenerationPrice, setCoverGenerationPrice] = useState< + number | null + >(null); + const [coverGenerationDetail, setCoverGenerationDetail] = useState(''); const publishIdempotencyKeyRef = useRef(''); + const metadataSuggestionRequestRef = useRef(0); + const summaryTouchedRef = useRef(false); + const categoryTouchedRef = useRef(false); const coverInputRef = useRef(null); const screenshotInputRef = useRef(null); // 文件签名 → 素材 ID:同一次打开面板重复提交不会重复上传同一张图。 @@ -107,15 +181,51 @@ export function GameDistributionPublishPanel({ useEffect(() => { if (!open) return; + const fallbackSummary = (manifest.goal ?? '由陶泥儿创作的可在线游玩游戏') + .trim() + .slice(0, 120); setTitle(manifest.name.trim()); - setSummary( - (manifest.goal ?? '由陶泥儿创作的可在线游玩游戏').trim().slice(0, 120), - ); + setSummary(fallbackSummary); setCategory('其他'); setBusy(false); setError(''); setResult(null); - }, [manifest.goal, manifest.name, open, packageResult?.packageRelativePath]); + setMetadataSuggestionPending(false); + setMetadataSuggestionReady(false); + setCoverConfirmOpen(false); + setGeneratingCover(false); + setCoverGenerationPrice(null); + setCoverGenerationDetail(''); + setPreviewScreenshot(null); + summaryTouchedRef.current = false; + categoryTouchedRef.current = false; + + const requestId = ++metadataSuggestionRequestRef.current; + setMetadataSuggestionPending(true); + void suggestGameDistributionPublishMetadata({ + name: manifest.name, + goal: manifest.goal, + context: buildPublishProjectContext(manifest), + }) + .then((suggestion) => { + if (requestId !== metadataSuggestionRequestRef.current) return; + if (!summaryTouchedRef.current && suggestion.summary.trim()) { + setSummary(suggestion.summary.trim().slice(0, 120)); + } + if (!categoryTouchedRef.current && suggestion.category) { + setCategory(suggestion.category); + } + setMetadataSuggestionReady(true); + }) + .catch(() => { + // 免费增强能力失败时保留本地兜底,不阻断发布。 + }) + .finally(() => { + if (requestId === metadataSuggestionRequestRef.current) { + setMetadataSuggestionPending(false); + } + }); + }, [manifest, open, packageResult?.packageRelativePath]); useEffect(() => { if (open) { @@ -123,6 +233,24 @@ export function GameDistributionPublishPanel({ } }, [open, packageResult?.packageRelativePath]); + useEffect(() => { + if (!open) return; + let cancelled = false; + void readGameCoverGenerationPrice({ + model: COVER_GENERATION_MODEL, + imageSize: COVER_GENERATION_IMAGE_SIZE, + }) + .then((price) => { + if (!cancelled) setCoverGenerationPrice(price); + }) + .catch(() => { + if (!cancelled) setCoverGenerationPrice(null); + }); + return () => { + cancelled = true; + }; + }, [open]); + useEffect(() => { const objectUrls = objectUrlsRef.current; return () => { @@ -194,9 +322,77 @@ export function GameDistributionPublishPanel({ } } + async function handleGenerateCover() { + setCoverConfirmOpen(false); + setError(''); + setCoverGenerationDetail('正在根据项目内容生成封面…'); + setGeneratingCover(true); + try { + const generated = await generateGameDistributionCover({ + prompt: buildCoverGenerationPrompt(manifest, summary), + model: COVER_GENERATION_MODEL, + aspectRatio: COVER_GENERATION_ASPECT_RATIO, + imageSize: COVER_GENERATION_IMAGE_SIZE, + assetLabel: `${manifest.name.trim() || '游戏'}封面`, + }); + setCover({ + signature: generated.taskId + ? `generated:${generated.taskId}` + : `generated:${generated.assetObjectId}`, + name: 'AI 生成封面', + assetObjectId: generated.assetObjectId, + previewUrl: generated.previewUrl, + }); + setCoverGenerationDetail( + '封面已生成并自动设为发布封面;重新生成会再次消耗泥点。', + ); + } catch (generationError) { + setCoverGenerationDetail(''); + setError( + generationError instanceof Error + ? generationError.message + : '生成游戏封面失败,请重试', + ); + } finally { + setGeneratingCover(false); + } + } + + function createPanelScreenshotEntry(file: File): PanelScreenshotEntry { + const fileError = resolvePanelImageFileError(file, 'screenshot'); + return { + id: createPanelScreenshotId(), + name: file.name.trim() || '游戏截图', + previewUrl: buildPanelImagePreviewUrl(file), + assetObjectId: null, + status: fileError ? 'failed' : 'uploading', + error: fileError || null, + file, + }; + } + + function removeScreenshot(id: string) { + setScreenshots((current) => { + const target = current.find((item) => item.id === id); + if (target?.previewUrl && objectUrlsRef.current.has(target.previewUrl)) { + try { + URL.revokeObjectURL(target.previewUrl); + } catch { + // 预览地址释放失败不影响移除截图。 + } + objectUrlsRef.current.delete(target.previewUrl); + } + return current.filter((item) => item.id !== id); + }); + setPreviewScreenshot((current) => (current?.id === id ? null : current)); + } + async function handleScreenshotsSelected(files: File[]) { if (files.length === 0) return; - const remaining = MAX_AGC_GAME_SCREENSHOTS - screenshots.length; + const counted = screenshots.filter( + (item) => item.status !== 'failed', + ).length; + const remaining = MAX_AGC_GAME_SCREENSHOTS - counted; if (files.length > remaining) { setError( remaining > 0 @@ -207,35 +403,50 @@ export function GameDistributionPublishPanel({ return; } setError(''); - setUploadingLabel('正在上传截图…'); - try { - for (const file of files) { - const fileError = resolvePanelImageFileError(file, 'screenshot'); - if (fileError) { - setError(fileError); - return; + const entries = files.map(createPanelScreenshotEntry); + setScreenshots((current) => [...current, ...entries]); + + // 每张截图独立上传、独立失败;某一张失败只标记该张,不打断同批其它截图。 + await Promise.all( + entries.map(async (entry) => { + if (entry.status === 'failed') return; + try { + const assetObjectId = await resolveAssetObjectId( + entry.file, + 'screenshot', + ); + setScreenshots((current) => + current.map((item) => + item.id === entry.id + ? { + ...item, + status: 'ready', + assetObjectId, + error: null, + } + : item, + ), + ); + } catch (uploadError) { + setScreenshots((current) => + current.map((item) => + item.id === entry.id + ? { + ...item, + status: 'failed', + assetObjectId: null, + error: + uploadError instanceof Error + ? uploadError.message + : '截图上传失败,请删除后重试', + } + : item, + ), + ); } - const assetObjectId = await resolveAssetObjectId(file, 'screenshot'); - // 逐张入库:中途失败时已传好的截图保留,作者不用重新选择。 - setScreenshots((current) => - current.length >= MAX_AGC_GAME_SCREENSHOTS - ? current - : [ - ...current, - buildPanelImageAsset(file, 'screenshot', assetObjectId), - ], - ); - } - } catch (uploadError) { - setError( - uploadError instanceof Error - ? uploadError.message - : '截图上传失败,请重试', - ); - } finally { - setUploadingLabel(''); - if (screenshotInputRef.current) screenshotInputRef.current.value = ''; - } + }), + ); + if (screenshotInputRef.current) screenshotInputRef.current.value = ''; } async function handleSubmit() { @@ -252,7 +463,10 @@ export function GameDistributionPublishPanel({ setError('请先选择游戏封面(JPG/PNG/WebP)'); return; } - if (uploadingLabel) { + if ( + uploadingLabel || + screenshots.some((item) => item.status === 'uploading') + ) { setError('素材还在上传中,请稍候再发布'); return; } @@ -269,7 +483,15 @@ export function GameDistributionPublishPanel({ summary, category, coverAssetId: cover.assetObjectId, - screenshots: screenshots.map((item) => item.assetObjectId), + screenshots: screenshots + .filter( + ( + item, + ): item is PanelScreenshotEntry & { + assetObjectId: string; + } => item.status === 'ready' && Boolean(item.assetObjectId), + ) + .map((item) => item.assetObjectId), }, idempotencyKey: publishIdempotencyKeyRef.current, }); @@ -284,228 +506,375 @@ export function GameDistributionPublishPanel({ } } + const activeScreenshotCount = screenshots.filter( + (item) => item.status !== 'failed', + ).length; + const screenshotsUploading = screenshots.some( + (item) => item.status === 'uploading', + ); + const coverPriceLabel = + coverGenerationPrice === null ? '消耗泥点' : `${coverGenerationPrice} 泥点`; + return ( - undefined : onClose} - panelClassName="game-distribution-publish-panel" - closeOnBackdrop={!busy} - closeOnEscape={!busy} - > -
-
- - 发布到游戏广场 - -

让玩家现在就能试玩

-
- -
- {result ? ( -
- 已提交审核 -

版本已进入审核队列,审核通过后才会在游戏广场公开展示。

-

- 版本 {result.versionNumber} ·{' '} - {result.packageSizeBytes.toLocaleString()} B ·{' '} - {result.packageSha256.slice(0, 16)}… -

-
- -
-
- ) : ( - <> -

- 仅上传已导出的 ZIP 字节和摘要信息;不会上传本地路径或项目源码快照。 -

-
- {packageResult?.packageRelativePath ?? '未找到试玩包'} - - {packageResult - ? `${packageResult.fileCount} 个文件 · ${packageResult.totalBytes.toLocaleString()} B` - : '请先导出'} + <> + undefined : onClose} + panelClassName="game-distribution-publish-panel" + closeOnBackdrop={!busy && !generatingCover} + closeOnEscape={!busy && !generatingCover} + > +
+
+ + 发布到游戏广场 +

让玩家现在就能试玩

-
- -