Compare commits

..

1 Commits

Author SHA1 Message Date
kdletters e3682fd06f 固定客户端dev服务并支持官网多平台下载
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m31s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m32s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m33s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m40s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m21s
Project CI / AI game creator shell Rust crates (push) Successful in 3m11s
Project CI / Native shell tests (push) Successful in 16m15s
Project CI / Frontend tests (push) Successful in 14m57s
Project CI / Repository checks (push) Successful in 14m48s
Project CI / Backend tests (push) Successful in 20m7s
Project CI / AI game creator shell web tests (push) Successful in 6m20s
移除服务器选择并按来源隔离登录凭据
新增官网客户端下载入口和匿名平台聚合接口
根据发布清单自动展示Windows与macOS首装包
补齐Mac首装元数据和上传顺序校验
同步定向测试与下载发布规范
2026-09-19 16:19:28 +08:00
110 changed files with 3730 additions and 2908 deletions
@@ -102,7 +102,7 @@ Use OpenAPI as the final authority; these common values are a routing aid:
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it.
- External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing.
- Image `model`: `gpt-image-2.5`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`. Persisted `gpt-image-2` / `gpt-image-2-c` are legacy values resolved only when submitting a new task.
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
- Image `imageSize`: `0.5K`, `1K`, `2K`.
- Video `model`: `seedance2.0`, `seedance2.0-fast`, `kling3.0`, `kling3.0-omni`, `veo3.1`, `veo3.1-fast`.
+6 -6
View File
@@ -1,11 +1,11 @@
---
name: gpt-image-2-apimart
description: Generate or inspect project image assets through this repository's image workflow using the GPT Image 2.5 business model. Use when Codex needs to create puzzle template sample images, reproduce the server-rs image request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical.
description: Generate or inspect project image assets through this repository's VectorEngine gpt-image-2 workflow with gpt-image-2-c fallback. Use when Codex needs to create puzzle template sample images, reproduce the server-rs image request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical.
---
# GPT Image 2.5 project image workflow
# gpt-image-2 VectorEngine
Use this skill for project-local image asset generation that must match the repository's image request contract. Use the business model identifier `gpt-image-2.5`; provider concrete model routing is owned by `server-rs`, and this client must not perform a cross-model fallback. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine image path. Keep the product/price model identifier and primary provider request as `gpt-image-2`, then fall back once to `gpt-image-2-c` for eligible provider failures. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
## Workflow
@@ -40,7 +40,7 @@ Default body:
```json
{
"model": "gpt-image-2.5",
"model": "gpt-image-2",
"prompt": "<prompt>",
"n": 1,
"size": "1024x1024"
@@ -58,14 +58,14 @@ Content-Type: multipart/form-data
Multipart fields:
```text
model=gpt-image-2.5
model=gpt-image-2
prompt=<prompt>
n=1
size=1024x1024
image=@reference.png
```
In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Both paths send the business model `gpt-image-2.5`; provider routing and retry policy remain server-owned. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part.
In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Both paths prefer `gpt-image-2`; on an eligible upstream/model failure they retry with `gpt-image-2-c`. Do not fall back for authentication, local validation, request-budget exhaustion, uncertain send/connection failure, content-safety rejection, or a generated image URL download failure. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part.
Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine image generation currently returns synchronously; do not poll APIMart task endpoints.
@@ -9,7 +9,8 @@ const skillRoot = path.resolve(__dirname, '..');
const repoRoot = path.resolve(skillRoot, '..', '..', '..');
const defaultOutDir = path.join(repoRoot, 'public', 'anthro-cat-illustrations');
const defaultTimeoutMs = 1000000;
const preferredImageModel = 'gpt-image-2.5';
const preferredImageModel = 'gpt-image-2';
const fallbackImageModel = 'gpt-image-2-c';
const prompts = [
{
@@ -255,8 +256,41 @@ async function fetchJson(url, options, timeoutMs) {
}
}
function shouldFallbackImageModel(error) {
const raw =
`${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
if (error?.vectorEngineResponseParse) {
return !containsContentRejection(raw);
}
const status = Number(error?.vectorEngineStatus || 0);
if (status === 408 || status >= 500) {
return true;
}
if (status === 429) {
return !containsContentRejection(raw);
}
const mentionsImageModel =
raw.includes('model') ||
raw.includes('模型') ||
raw.includes(preferredImageModel) ||
raw.includes(fallbackImageModel);
return (
[400, 404, 422].includes(status) &&
mentionsImageModel &&
/(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test(
raw,
)
);
}
function containsContentRejection(raw) {
return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test(
raw,
);
}
async function requestImagePayload(env, entry) {
for (const model of [preferredImageModel]) {
for (const model of [preferredImageModel, fallbackImageModel]) {
const requestBody = {
model,
prompt: buildPrompt(entry),
@@ -288,7 +322,12 @@ async function requestImagePayload(env, entry) {
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
throw error;
} catch (error) {
throw error;
if (model !== preferredImageModel || !shouldFallbackImageModel(error)) {
throw error;
}
console.warn(
`VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`,
);
}
}
throw new Error(`VectorEngine returned no image for ${entry.id}`);
@@ -369,7 +408,7 @@ if (dryRun) {
requests: selectedPrompts.map((entry) => ({
id: entry.id,
title: entry.title,
fallbackModel: null,
fallbackModel: fallbackImageModel,
body: {
model: preferredImageModel,
prompt: buildPrompt(entry),
@@ -18,7 +18,8 @@ const defaultOutDir = path.join(
'puzzle-creation-templates',
);
const defaultTimeoutMs = 1000000;
const preferredImageModel = 'gpt-image-2.5';
const preferredImageModel = 'gpt-image-2';
const fallbackImageModel = 'gpt-image-2-c';
const args = new Map();
for (let index = 2; index < process.argv.length; index += 1) {
@@ -225,8 +226,41 @@ async function fetchJson(url, options, timeoutMs) {
}
}
function shouldFallbackImageModel(error) {
const raw =
`${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
if (error?.vectorEngineResponseParse) {
return !containsContentRejection(raw);
}
const status = Number(error?.vectorEngineStatus || 0);
if (status === 408 || status >= 500) {
return true;
}
if (status === 429) {
return !containsContentRejection(raw);
}
const mentionsImageModel =
raw.includes('model') ||
raw.includes('模型') ||
raw.includes(preferredImageModel) ||
raw.includes(fallbackImageModel);
return (
[400, 404, 422].includes(status) &&
mentionsImageModel &&
/(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test(
raw,
)
);
}
function containsContentRejection(raw) {
return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test(
raw,
);
}
async function requestImagePayload(env, template) {
for (const model of [preferredImageModel]) {
for (const model of [preferredImageModel, fallbackImageModel]) {
const requestBody = {
model,
prompt: buildPrompt(template),
@@ -260,7 +294,12 @@ async function requestImagePayload(env, template) {
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
throw error;
} catch (error) {
throw error;
if (model !== preferredImageModel || !shouldFallbackImageModel(error)) {
throw error;
}
console.warn(
`VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`,
);
}
}
throw new Error(`VectorEngine returned no image for ${template.id}`);
@@ -345,7 +384,7 @@ if (dryRun) {
requests: selectedTemplates.map((template) => ({
id: template.id,
title: template.title,
fallbackModel: null,
fallbackModel: fallbackImageModel,
body: {
model: preferredImageModel,
prompt: buildPrompt(template),
-29
View File
@@ -44,35 +44,6 @@ _Avoid_: 把同一资源的全局元数据和某一次摆放坐标混在同一
由图片生成或图片修改流程产生的画布资源,必须记录来源资源、提示词、实际提示词、模型、provider、任务 ID 和生成时间;本期 `/editor` 的生成修改先允许 mock 生成资源,但仍按生成资源元数据形状保存。
_Avoid_: 无来源的静态素材、只显示在 UI 但不落工程资源记录的生成结果
**图片模型历史值与使用端解析**:
图片资源中已持久化的 `gpt-image-2` 是历史业务事实,读回时保持原值;新任务使用业务模型值 `gpt-image-2.5`。当用户基于历史资源再次发起生成或编辑任务时,服务端只在新任务的使用端把历史值解析为当前业务模型,不改写历史资源。provider route 属于服务端执行与审计边界,前端不接收、不持久化、不展示,也不据此分支。
_Avoid_: 读取数据库时改写历史模型值、把 provider route 暴露为前端模型选项或公开 DTO
**图片 provider 显式路由**:
api-server 在任务入口按业务语义显式选择具体 provider model name(生成或编辑),并把同一具体名传给图片平台适配器和后台定价解析;图片平台适配器不从参考图数量或前端字段猜测任务。具体 provider model name 只存在于服务端调用、定价配置和审计边界。
后台管理 Web/API 是明确例外,可以查看和编辑两个具体定价 key;主站普通前端与公开定价 API 不接收这些 key。
_Avoid_: 让图片适配器隐式猜路由、让主站前端携带 provider model name
**业务模型**:
面向任务与产品契约的稳定模型值;当前 GPT 图片新任务的业务模型是 `gpt-image-2.5`。业务模型不等同于 provider 的具体计费/请求 model,也不暴露 provider 凭证或 endpoint。
_Avoid_: 把 provider concrete model 当作前端业务选项、用业务模型值直接推断 provider 凭证
**具体模型**:
服务端发送请求和定价使用的 concrete model name。GPT Image 2.5 生成与编辑分别是 `gpt-image-2.5-flare-c``gpt-image-2.5-sunburst-c`nanobanana 仍使用 `gemini-3.1-flash-image-preview`。具体模型只在服务端执行、定价和审计边界出现。
_Avoid_: 把具体模型写入普通前端 DTO、让未知字符串自动选择 provider
**provider client**:
按具体模型选出的外部图片 provider 连接配置,包含 provider identity、base URL 和 API keyVectorEngine 与 Tiantoken client 共享图片协议执行器,不复制请求/响应业务逻辑。两套 required client 在 api-server 启动时构造。
_Avoid_: 在首次请求时才创建 client、在 provider client 中复制尺寸/重试/审计逻辑、跨 provider credential fallback
**历史模型值**:
已持久化的 `gpt-image-2``gpt-image-2-c` 字符串,只作为历史事实原样读取和审计;基于历史资源提交新任务时,在使用端解析为当前 GPT Image 2.5 业务任务,不回写历史记录,也不把旧值作为现役 provider route。
_Avoid_: 数据库批量改写历史值、把历史值重新路由到 VectorEngine、把兼容解析扩散到普通前端
**GPT Image 2.5 新生成展示名**:
`GPT Image 2.5` 是新生成任务的产品展示名;历史资源与既有编辑上下文不因新模型上线而改写展示语义。
_Avoid_: 把新生成展示名扩散到历史记录、历史生成器或旧编辑上下文
**系列素材图集生成**:
一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。
_Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO
@@ -501,6 +501,41 @@ export function selectReleaseArtifact(files, target = defaultTarget()) {
);
}
export function selectFirstInstallArtifact(
files,
{ target, version, artifact },
) {
validateReleaseTarget(target);
let selected;
if (target.includes('windows')) {
selected = artifact;
if (!selected?.endsWith('.exe')) {
throw new Error('Windows 首装包必须复用本次 NSIS .exe 更新包');
}
} else {
// Tauri DMG 文件名使用 aarch64 / x64,而 updater 的 Intel 平台键是 x86_64。
const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64';
const suffix = `_${version}_${architecture}.dmg`;
const candidates = files.filter((file) =>
path.basename(file).endsWith(suffix),
);
if (candidates.length !== 1) {
throw new Error(
`首装 DMG 必须唯一匹配本次版本 ${version} 和架构 ${architecture},找到 ${candidates.length}`,
);
}
selected = candidates[0];
}
if (
!fs.existsSync(selected) ||
!fs.statSync(selected).isFile() ||
fs.statSync(selected).size === 0
) {
throw new Error(`首装包不存在或为空:${selected}`);
}
return selected;
}
function readUpdaterSignature(artifactPath) {
const signaturePath = `${artifactPath}.sig`;
if (!fs.existsSync(signaturePath)) {
@@ -521,23 +556,32 @@ export function createUpdateManifest(
publishedAt = new Date().toISOString(),
notes = readReleaseNotes(),
commit = readHeadCommit(),
downloadArtifact,
} = {},
) {
validateReleaseTarget(target);
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }, target);
const signature = readUpdaterSignature(artifactPath);
const version = readPackageJson().version;
const firstInstallArtifact = selectFirstInstallArtifact(
downloadArtifact ? [downloadArtifact] : [],
{ target, version, artifact: artifactPath },
);
const fileName = path.basename(artifactPath);
const url = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`;
const downloadUrl = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(path.basename(firstInstallArtifact))}`;
const platforms = {};
const downloads = {};
for (const key of resolveManifestPlatformKeys(target)) {
platforms[key] = { signature, url };
downloads[key] = { url: downloadUrl };
}
return {
version,
...(notes ? { notes } : {}),
pub_date: publishedAt,
platforms,
downloads,
// 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。
...(commit ? { commit } : {}),
};
@@ -676,10 +720,16 @@ export async function generateUpdateManifest(
context = resolveReleaseContext(),
) {
const { channel, target, bundleRoot } = context;
const artifact = selectReleaseArtifact(listFiles(bundleRoot), target);
const files = listFiles(bundleRoot);
const artifact = selectReleaseArtifact(files, target);
if (!artifact) {
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
}
const downloadArtifact = selectFirstInstallArtifact(files, {
target,
version: readPackageJson().version,
artifact,
});
const manualNotes = readReleaseNotes();
const previousCommit = await resolvePreviousReleaseCommit(channel);
const commits = collectReleaseCommits(previousCommit);
@@ -693,7 +743,12 @@ export async function generateUpdateManifest(
`[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'}`,
);
}
const manifest = createUpdateManifest(artifact, { channel, target, notes });
const manifest = createUpdateManifest(artifact, {
channel,
target,
notes,
downloadArtifact,
});
const manifestPath = path.join(bundleRoot, 'latest.json');
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
const notesPath = path.join(bundleRoot, 'release-notes.txt');
@@ -718,6 +773,7 @@ export async function generateUpdateManifest(
`[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`,
);
console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
console.log(`[ai-game-creator-shell] 首装包:${downloadArtifact}`);
console.log(
manualNotes
? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案'
@@ -734,6 +790,7 @@ export async function generateUpdateManifest(
return {
channel,
artifact,
downloadArtifact,
manifest,
manifestPath,
notes,
@@ -32,12 +32,23 @@ import {
resolveReleaseContext,
resolveRemoteHighWaterVersion,
runTauriBuild,
selectFirstInstallArtifact,
selectReleaseArtifact,
updateManifestUrl,
} from './build-release.mjs';
const windowsTarget = 'x86_64-pc-windows-msvc';
const universalTarget = 'universal-apple-darwin';
const packageVersion = JSON.parse(
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
).version;
function createDmgFixture(root, target, version = packageVersion) {
const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64';
const dmg = path.join(root, `陶泥儿_${version}_${architecture}.dmg`);
writeFileSync(dmg, 'first installation disk image');
return dmg;
}
test('native sidecar builds reject universal targets and accept each macOS architecture', () => {
assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/);
@@ -254,7 +265,13 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
),
artifact,
);
const manifest = createUpdateManifest(artifact, context);
const manifest = createUpdateManifest(artifact, {
...context,
downloadArtifact: createDmgFixture(
path.dirname(artifact),
context.target,
),
});
assert.deepEqual(Object.keys(manifest.platforms), [
'darwin-aarch64',
]);
@@ -272,29 +289,118 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
assert.ok(seenContexts.every((context) => context === seenContexts[0]));
});
test('real manifest writer uses the resolved bundle root and does not emit Windows artifacts', async () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-'));
for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) {
test(`real manifest writer publishes the ${target} updater and first installer separately`, async () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-'));
try {
const artifact = path.join(root, '陶泥儿.app.tar.gz');
writeFileSync(artifact, 'mac package');
writeFileSync(`${artifact}.sig`, 'mac signature');
writeFileSync(path.join(root, 'windows.exe'), 'wrong platform');
const downloadArtifact = createDmgFixture(root, target);
const context = {
...resolveReleaseContext([`--target=${target}`], {}),
bundleRoot: root,
};
const result = await withStubbedFetch(
(url) => {
assert.match(url, /\/dev-mac\/latest\.json$/);
return jsonResponse({}, 404);
},
() => generateUpdateManifest(context),
);
assert.equal(result.artifact, artifact);
assert.equal(result.downloadArtifact, downloadArtifact);
assert.equal(result.manifestPath, path.join(root, 'latest.json'));
assert.equal(result.legacyManifestPath, null);
const key = target.startsWith('aarch64')
? 'darwin-aarch64'
: 'darwin-x86_64';
assert.deepEqual(Object.keys(result.manifest.platforms), [key]);
assert.deepEqual(Object.keys(result.manifest.downloads), [key]);
assert.match(
result.manifest.platforms[key].url,
/\/dev-mac\/.*\.app\.tar\.gz$/,
);
assert.equal(
decodeURIComponent(
new URL(result.manifest.downloads[key].url).pathname,
),
`/agc/dev-mac/${packageVersion}/${path.basename(downloadArtifact)}`,
);
assert.deepEqual(
JSON.parse(readFileSync(result.manifestPath, 'utf8')),
result.manifest,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
}
test('DMG selection ignores other versions and architectures but rejects missing, empty and ambiguous current packages', () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-dmg-selection-'));
try {
const target = 'aarch64-apple-darwin';
const options = {
target,
version: '2.3.4',
artifact: path.join(root, '陶泥儿.app.tar.gz'),
};
const oldVersion = createDmgFixture(root, target, '2.3.3');
const wrongArchitecture = createDmgFixture(
root,
'x86_64-apple-darwin',
'2.3.4',
);
assert.throws(() => selectFirstInstallArtifact([], options), /找到 0 个/u);
assert.throws(
() =>
selectFirstInstallArtifact([oldVersion, wrongArchitecture], options),
/找到 0 个/u,
);
const current = createDmgFixture(root, target, '2.3.4');
assert.equal(
selectFirstInstallArtifact(
[oldVersion, wrongArchitecture, current],
options,
),
current,
);
writeFileSync(current, '');
assert.throws(
() => selectFirstInstallArtifact([current], options),
/不存在或为空/u,
);
writeFileSync(current, 'valid dmg');
const second = path.join(root, '另一包_2.3.4_aarch64.dmg');
writeFileSync(second, 'ambiguous dmg');
assert.throws(
() => selectFirstInstallArtifact([current, second], options),
/找到 2 个/u,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('manifest writer refuses to create latest when the current Mac DMG is missing', async () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-missing-dmg-'));
try {
const artifact = path.join(root, '陶泥儿.app.tar.gz');
writeFileSync(artifact, 'mac package');
writeFileSync(`${artifact}.sig`, 'mac signature');
writeFileSync(path.join(root, 'windows.exe'), 'wrong platform');
writeFileSync(artifact, 'updater archive');
writeFileSync(`${artifact}.sig`, 'signature');
const context = {
...resolveReleaseContext(['--target=x86_64-apple-darwin'], {}),
...resolveReleaseContext(['--target=aarch64-apple-darwin'], {}),
bundleRoot: root,
};
const result = await withStubbedFetch(
(url) => {
assert.match(url, /\/dev-mac\/latest\.json$/);
return jsonResponse({}, 404);
},
await assert.rejects(
() => generateUpdateManifest(context),
/首装 DMG 必须唯一匹配/u,
);
assert.equal(result.artifact, artifact);
assert.equal(result.manifestPath, path.join(root, 'latest.json'));
assert.equal(result.legacyManifestPath, null);
assert.deepEqual(Object.keys(result.manifest.platforms), ['darwin-x86_64']);
assert.match(result.manifest.platforms['darwin-x86_64'].url, /\/dev-mac\//);
assert.throws(() => readFileSync(path.join(root, 'latest.json')), {
code: 'ENOENT',
});
} finally {
rmSync(root, { recursive: true, force: true });
}
@@ -393,6 +499,9 @@ test('channel manifest carries version, platform keys and signature', () => {
assert.equal(manifest.notes, '修复与改进');
assert.equal(manifest.pub_date, '2026-09-17T00:00:00.000Z');
assert.deepEqual(Object.keys(manifest.platforms), ['windows-x86_64']);
assert.deepEqual(manifest.downloads, {
'windows-x86_64': { url: manifest.platforms['windows-x86_64'].url },
});
assert.equal(
manifest.platforms['windows-x86_64'].signature,
'signature-content',
@@ -573,18 +682,18 @@ test('recent commit fallback marks that entries may repeat the previous release'
}
});
test('release upload forces overwrite for artifact, signature and channel pointers', () => {
test('release entry forwards the built artifacts and dry-run mode to the uploader', () => {
const source = readFileSync(
new URL('./release-upload.mjs', import.meta.url),
'utf8',
);
assert.equal(
(source.match(/runOssutil\(\[\s*'cp',\s*'--force'/gu) ?? []).length,
4,
assert.match(
source,
/const release = await buildRelease\(process\.argv\.slice\(2\)\)/u,
);
assert.match(source, /agc\/\$\{channel\}\/latest\.json/u);
assert.match(source, /agc\/latest\.json/u);
assert.match(source, /await buildRelease\(process\.argv\.slice\(2\)\)/u);
assert.match(source, /uploadReleaseArtifacts\(release, \{/u);
assert.match(source, /const dryRun = readReleaseDryRun\(\);/u);
assert.ok(source.includes('\n dryRun,\n'));
});
test('release notes list client commits with short sha and bound their size', () => {
@@ -1,3 +1,6 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
/**
* 发布上传的 OSS 命令行整理:把 ossutil 参数与凭据整理成可执行或可打印的形式,
* 便于在 dry-run 下核对将要执行的上传,同时保证任何输出都不回显凭据明文。
@@ -25,3 +28,89 @@ export function formatOssutilCommand({ binary, args, endpoint, credentials }) {
}
return parts.map(quoteArgument).join(' ');
}
export function createReleaseUploadPlan(
{
artifact,
downloadArtifact,
channel,
manifest,
manifestPath,
legacyManifestPath,
},
bucket,
) {
if (!artifact || !downloadArtifact || !manifestPath || !manifest?.version) {
throw new Error('发布结果缺少更新包、首装包或清单');
}
const prefix = `oss://${bucket}/agc/${channel}`;
const artifacts = [
...new Set(
[artifact, `${artifact}.sig`, downloadArtifact].map((file) =>
path.resolve(file),
),
),
];
const plan = artifacts.map((source) => ({
source,
destination: `${prefix}/${manifest.version}/${path.basename(source)}`,
}));
plan.push({ source: manifestPath, destination: `${prefix}/latest.json` });
if (legacyManifestPath) {
plan.push({
source: legacyManifestPath,
destination: `oss://${bucket}/agc/latest.json`,
});
}
return plan;
}
export function uploadReleaseArtifacts(
release,
{
bucket,
endpoint,
binary = 'ossutil',
accessKeyId,
accessKeySecret,
dryRun = false,
spawn = spawnSync,
log = console.log,
},
) {
if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) {
throw new Error('OSS AccessKey ID 和 Secret 必须同时提供');
}
const plan = createReleaseUploadPlan(release, bucket);
for (const { source, destination } of plan) {
// 全部安装对象成功后才执行 latest 指针;失败立即终止,不发布悬空链接。
const args = ['cp', '--force', source, destination];
if (dryRun) {
log(
`[dry-run] ${formatOssutilCommand({ binary, args, endpoint, credentials: Boolean(accessKeyId) })}`,
);
continue;
}
const credentials = accessKeyId
? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret]
: [];
const result = spawn(
binary,
[...args, '--endpoint', endpoint, ...credentials],
{
stdio: 'inherit',
shell: false,
},
);
if (result.error)
throw new Error(`无法执行 ${binary},请先安装并配置 ossutil`);
if (result.status !== 0) {
throw new Error(
`OSS 上传失败(退出码 ${result.status ?? 1}):${destination}`,
);
}
log(`[ai-game-creator-shell] 已上传 ${destination}`);
}
if (dryRun) log('[ai-game-creator-shell] dry-run:未写入任何 OSS 对象');
return plan;
}
@@ -1,8 +1,15 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs';
import {
createReleaseUploadPlan,
formatOssutilCommand,
readReleaseDryRun,
uploadReleaseArtifacts,
} from './release-oss.mjs';
test('dry run only accepts explicit truthy values', () => {
assert.equal(readReleaseDryRun({}), false);
@@ -33,12 +40,158 @@ test('printed upload command keeps arguments and hides credentials', () => {
);
});
test('uploader gates every ossutil call behind the dry run switch', () => {
const source = readFileSync(
new URL('./release-upload.mjs', import.meta.url),
'utf8',
);
assert.match(source, /const dryRun = readReleaseDryRun\(\);/u);
assert.match(source, /if \(dryRun\) \{/u);
assert.match(source, /dry-run:未写入任何 OSS 对象/u);
function withReleaseFixture(channel, architecture, run) {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-upload-plan-'));
try {
const artifact = path.join(
root,
channel === 'dev-win'
? '陶泥儿_1.2.3_x64-setup.exe'
: '陶泥儿.app.tar.gz',
);
const downloadArtifact =
channel === 'dev-win'
? artifact
: path.join(root, `陶泥儿_1.2.3_${architecture}.dmg`);
const manifestPath = path.join(root, 'latest.json');
const legacyManifestPath =
channel === 'dev-win' ? path.join(root, 'legacy-latest.json') : null;
for (const file of [
artifact,
`${artifact}.sig`,
downloadArtifact,
manifestPath,
legacyManifestPath,
].filter(Boolean)) {
writeFileSync(file, 'fixture');
}
return run({
artifact,
downloadArtifact,
channel,
manifest: { version: '1.2.3' },
manifestPath,
legacyManifestPath,
});
} finally {
rmSync(root, { recursive: true, force: true });
}
}
const uploadOptions = {
bucket: 'agc-dev',
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
log: () => {},
};
for (const architecture of ['aarch64', 'x64']) {
test(`uploads every ${architecture} Mac object before the channel pointer`, () => {
withReleaseFixture('dev-mac', architecture, (release) => {
const calls = [];
uploadReleaseArtifacts(release, {
...uploadOptions,
spawn: (binary, args, options) => {
assert.equal(binary, 'ossutil');
assert.equal(options.shell, false);
assert.deepEqual(args.slice(0, 2), ['cp', '--force']);
calls.push({ source: args[2], destination: args[3] });
return { status: 0 };
},
});
assert.deepEqual(
calls.map(({ source }) => source),
[
release.artifact,
`${release.artifact}.sig`,
release.downloadArtifact,
release.manifestPath,
],
);
assert.equal(
calls[2].destination,
`oss://agc-dev/agc/dev-mac/1.2.3/陶泥儿_1.2.3_${architecture}.dmg`,
);
assert.equal(
calls[3].destination,
'oss://agc-dev/agc/dev-mac/latest.json',
);
});
});
}
test('Windows uploads the shared installer once and publishes migration metadata last', () => {
withReleaseFixture('dev-win', 'x64', (release) => {
const plan = createReleaseUploadPlan(release, 'agc-dev');
assert.deepEqual(
plan.map(({ source }) => source),
[
release.artifact,
`${release.artifact}.sig`,
release.manifestPath,
release.legacyManifestPath,
],
);
assert.equal(plan.at(-1).destination, 'oss://agc-dev/agc/latest.json');
const calls = [];
uploadReleaseArtifacts(release, {
...uploadOptions,
spawn: (_binary, args) => {
assert.deepEqual(args.slice(0, 2), ['cp', '--force']);
calls.push(args[3]);
return { status: 0 };
},
});
assert.deepEqual(
calls,
plan.map(({ destination }) => destination),
);
});
});
for (const failedArtifactIndex of [0, 1, 2]) {
test(`failed Mac object ${failedArtifactIndex} prevents both later objects and latest publication`, () => {
withReleaseFixture('dev-mac', 'aarch64', (release) => {
const destinations = [];
assert.throws(
() =>
uploadReleaseArtifacts(release, {
...uploadOptions,
spawn: (_binary, args) => {
destinations.push(args[3]);
return {
status: destinations.length - 1 === failedArtifactIndex ? 1 : 0,
};
},
}),
/OSS 上传失败/u,
);
assert.equal(destinations.length, failedArtifactIndex + 1);
assert.ok(
destinations.every(
(destination) => !destination.endsWith('/latest.json'),
),
);
});
});
}
test('dry run prints the complete plan without spawning uploads or exposing credentials', () => {
withReleaseFixture('dev-mac', 'aarch64', (release) => {
const output = [];
uploadReleaseArtifacts(release, {
...uploadOptions,
dryRun: true,
accessKeyId: 'fixture-id',
accessKeySecret: 'fixture-secret',
spawn: () => assert.fail('dry run must never execute ossutil'),
log: (line) => output.push(line),
});
assert.equal(
output.filter((line) => line.startsWith('[dry-run]')).length,
4,
);
assert.match(output.join('\n'), /\.dmg/u);
assert.match(output.at(-1), /未写入任何 OSS 对象/u);
assert.doesNotMatch(output.join('\n'), /fixture-id|fixture-secret|已上传/u);
});
});
@@ -1,7 +1,4 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs';
import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs';
const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev';
const endpoint =
@@ -14,76 +11,12 @@ const dryRun = readReleaseDryRun();
const { buildRelease } = await import('./build-release.mjs');
function runOssutil(args) {
const binary = process.env.OSSUTIL_BIN?.trim() || 'ossutil';
const accessKeyId = process.env.AGC_OSS_ACCESS_KEY_ID?.trim();
const accessKeySecret = process.env.AGC_OSS_ACCESS_KEY_SECRET;
if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) {
throw new Error('OSS AccessKey ID 和 Secret 必须同时提供');
}
if (dryRun) {
// 演练:只打印将要执行的上传,凭据以占位符呈现,不写入 OSS。
console.log(
`[dry-run] ${formatOssutilCommand({
binary,
args,
endpoint,
credentials: Boolean(accessKeyId),
})}`,
);
return;
}
const credentialArgs = accessKeyId
? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret]
: [];
const result = spawnSync(
binary,
[...args, '--endpoint', endpoint, ...credentialArgs],
{
stdio: 'inherit',
shell: false,
},
);
if (result.error) {
throw new Error(`无法执行 ${binary},请先安装并配置 ossutil`);
}
if (result.status !== 0) process.exit(result.status ?? 1);
}
const { artifact, channel, legacyManifestPath, manifest, manifestPath } =
await buildRelease(process.argv.slice(2));
const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`;
// Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过;
// 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。
runOssutil(['cp', '--force', artifact, `oss://${bucket}/${artifactKey}`]);
runOssutil([
'cp',
'--force',
`${artifact}.sig`,
`oss://${bucket}/${artifactKey}.sig`,
]);
runOssutil([
'cp',
'--force',
manifestPath,
`oss://${bucket}/agc/${channel}/latest.json`,
]);
console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/${artifactKey}`);
console.log(
`[ai-game-creator-shell] 已上传 oss://${bucket}/agc/${channel}/latest.json`,
);
if (legacyManifestPath) {
// 迁移桥:让仍走旧 sha256 清单的已发布客户端升级到新协议,一个版本周期后删除。
runOssutil([
'cp',
'--force',
legacyManifestPath,
`oss://${bucket}/agc/latest.json`,
]);
console.log(
`[ai-game-creator-shell] 已上传迁移指针 oss://${bucket}/agc/latest.json`,
);
}
if (dryRun) {
console.log('[ai-game-creator-shell] dry-run:未写入任何 OSS 对象');
}
const release = await buildRelease(process.argv.slice(2));
uploadReleaseArtifacts(release, {
bucket,
endpoint,
binary: process.env.OSSUTIL_BIN?.trim() || 'ossutil',
accessKeyId: process.env.AGC_OSS_ACCESS_KEY_ID?.trim(),
accessKeySecret: process.env.AGC_OSS_ACCESS_KEY_SECRET,
dryRun,
});
@@ -23,14 +23,7 @@ import {
normalizeAuthPhoneInput,
sendClientPhoneLoginCode,
} from '../services/clientAuth';
import {
type ClientServerPreset,
type ClientServerSelection,
getClientServerBaseUrl,
getClientServerSelection,
normalizeClientServerBaseUrl,
setClientServerSelection,
} from '../services/clientHttp';
import { getClientServerBaseUrl } from '../services/clientHttp';
import {
captureClientError,
installWebviewLogBridge,
@@ -158,13 +151,6 @@ export function AuthenticatedClient({
const [loginBusy, setLoginBusy] = useState(false);
const [codeBusy, setCodeBusy] = useState(false);
const [codeCooldownSeconds, setCodeCooldownSeconds] = useState(0);
const initialServerSelection = getClientServerSelection();
const [serverSelection, setServerSelection] = useState<ClientServerSelection>(
initialServerSelection,
);
const [customServerUrl, setCustomServerUrl] = useState(
initialServerSelection.customBaseUrl,
);
useEffect(() => {
const uninstallWebviewLogBridge = installWebviewLogBridge();
const handleError = (event: ErrorEvent) => {
@@ -184,37 +170,6 @@ export function AuthenticatedClient({
};
}, []);
function persistServerSelection() {
try {
const next = setClientServerSelection({
preset: serverSelection.preset,
customBaseUrl: customServerUrl,
});
setServerSelection(next);
return next;
} catch (error) {
void captureClientError(error, {
source: 'auth-hydrate',
action: 'restore-session',
});
setLoginStatus(error instanceof Error ? error.message : String(error));
return null;
}
}
function handleServerPresetChange(preset: ClientServerPreset) {
if (preset === 'custom') {
setServerSelection((current) => ({ ...current, preset }));
return;
}
const next = setClientServerSelection({
preset,
customBaseUrl: customServerUrl,
});
setServerSelection(next);
setLoginStatus(`已选择 ${preset} 服务器`);
}
useEffect(() => {
let disposed = false;
async function hydrateAuth() {
@@ -430,11 +385,7 @@ export function AuthenticatedClient({
if (codeBusy || codeCooldownSeconds > 0) {
return;
}
const persistedSelection = persistServerSelection();
if (!persistedSelection) {
return;
}
const apiBaseUrl = getClientServerBaseUrl(persistedSelection);
const apiBaseUrl = getClientServerBaseUrl();
const normalizedPhone = normalizeAuthPhoneInput(phone);
if (!normalizedPhone) {
setLoginStatus('请输入手机号');
@@ -479,11 +430,7 @@ export function AuthenticatedClient({
setLoginStatus('请输入密码');
return;
}
const persistedSelection = persistServerSelection();
if (!persistedSelection) {
return;
}
const loginApiBaseUrl = getClientServerBaseUrl(persistedSelection);
const loginApiBaseUrl = getClientServerBaseUrl();
const loginAttempt = (loginAttemptRef.current += 1);
setLoginBusy(true);
setLoginStatus('正在登录');
@@ -635,50 +582,6 @@ export function AuthenticatedClient({
</button>
</div>
) : null}
<label>
<select
aria-label="服务器"
disabled={loginBusy || codeBusy}
value={serverSelection.preset}
onChange={(event) =>
handleServerPresetChange(
event.currentTarget.value as ClientServerPreset,
)
}
>
<option value="release">release</option>
<option value="dev">dev</option>
<option value="custom">custom</option>
</select>
</label>
{serverSelection.preset === 'custom' ? (
<label>
<input
aria-label="自定义服务器地址"
disabled={loginBusy || codeBusy}
inputMode="url"
placeholder="https://example.com"
value={customServerUrl}
onChange={(event) =>
setCustomServerUrl(event.currentTarget.value)
}
onBlur={() => {
if (customServerUrl.trim()) {
try {
normalizeClientServerBaseUrl(customServerUrl);
persistServerSelection();
} catch (error) {
setLoginStatus(
error instanceof Error ? error.message : String(error),
);
}
}
}}
/>
</label>
) : null}
<div className="client-auth-tabs" role="group" aria-label="登录方式">
<button
type="button"
@@ -9,6 +9,7 @@ import {
RedeemProfileRewardCodeResponse,
unwrapApiResponse,
} from '../../../../packages/shared/src';
import { getStoredAuthAccessToken } from './clientAuth';
import { fetchClientHttp, readClientHttpResponseText } from './clientHttp';
import { captureClientError } from './errorReporting';
import {
@@ -16,7 +17,11 @@ import {
requestPlatformSessionRefresh,
} from './platformSession';
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
export {
clearStoredAuthAccessToken,
getStoredAuthAccessToken,
setStoredAuthAccessToken,
} from './clientAuth';
export class ClientAuthRequestError extends Error {
readonly status: number | null;
@@ -32,23 +37,6 @@ export class ClientAuthRequestError extends Error {
}
}
export function getStoredAuthAccessToken() {
return window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)?.trim() || '';
}
export function setStoredAuthAccessToken(token: string) {
const nextToken = token.trim();
if (nextToken) {
window.localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, nextToken);
return;
}
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
}
export function clearStoredAuthAccessToken() {
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
}
async function readApiErrorMessage(
response: Response,
fallback: string,
@@ -29,6 +29,10 @@ import {
} from './clientOperation';
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
const ACCESS_TOKEN_ORIGIN_STORAGE_KEY =
'genarrative.auth.access-token-origin.v1';
const LEGACY_SERVER_SELECTION_STORAGE_KEY =
'genarrative.client.server-selection.v1';
export function normalizeAuthPhoneInput(phone: string) {
const compactPhone = phone.replace(/[^\d+]/gu, '').trim();
@@ -44,21 +48,44 @@ function buildClientAuthPhoneInput(phone: string): AuthPhoneNumberInput {
};
}
export function getStoredAuthAccessToken() {
return window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)?.trim() || '';
export function getStoredAuthAccessToken(
apiBaseUrl = getClientServerBaseUrl(),
) {
if (apiBaseUrl !== getClientServerBaseUrl()) return '';
const token =
window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)?.trim() || '';
if (!token) return '';
const storedOrigin = window.localStorage.getItem(
ACCESS_TOKEN_ORIGIN_STORAGE_KEY,
);
if (storedOrigin === apiBaseUrl) return token;
// Old preferences were editable independently of the token, so they cannot
// establish its origin. Recover an unmarked session through the dev cookie.
clearStoredAuthAccessToken();
window.localStorage.removeItem(LEGACY_SERVER_SELECTION_STORAGE_KEY);
return '';
}
function setStoredAuthAccessToken(token: string) {
export function setStoredAuthAccessToken(
token: string,
apiBaseUrl = getClientServerBaseUrl(),
) {
if (apiBaseUrl !== getClientServerBaseUrl()) {
throw new Error('登录凭据不属于客户端固定的 dev 服务');
}
const nextToken = token.trim();
if (nextToken) {
window.localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, nextToken);
window.localStorage.setItem(ACCESS_TOKEN_ORIGIN_STORAGE_KEY, apiBaseUrl);
window.localStorage.removeItem(LEGACY_SERVER_SELECTION_STORAGE_KEY);
return;
}
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
clearStoredAuthAccessToken();
}
export function clearStoredAuthAccessToken() {
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
window.localStorage.removeItem(ACCESS_TOKEN_ORIGIN_STORAGE_KEY);
}
const clientAuthRefreshPromises = new Map<string, Promise<string>>();
@@ -102,7 +129,7 @@ function getClientAuthNetworkErrorMessage(error: unknown) {
return '无法连接登录服务:服务器拒绝连接,请确认服务已启动并检查端口';
}
if (/dns|resolve|name or service not known|/iu.test(detail)) {
return '无法连接登录服务:服务器地址无法解析,请检查服务器选择';
return '无法连接登录服务:服务器地址无法解析,请检查网络后重试';
}
if (/certificate|tls|ssl|/iu.test(detail)) {
return '无法连接登录服务:安全连接失败,请检查服务器地址和证书';
@@ -202,7 +229,7 @@ async function requestAuthJson<T>(
const headers = new Headers(init.headers);
headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION);
if (!options.skipAuth) {
const token = getStoredAuthAccessToken();
const token = getStoredAuthAccessToken(options.apiBaseUrl);
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
@@ -284,7 +311,7 @@ export async function refreshClientAuthAccessToken(
apiBaseUrl,
transitionClientOperation(operation, 'success'),
);
setStoredAuthAccessToken(response.token);
setStoredAuthAccessToken(response.token, apiBaseUrl);
return response.token;
})
.catch((error) => {
@@ -322,7 +349,7 @@ export async function loginClientWithPassword(
'登录失败',
{ skipAuth: true, apiBaseUrl },
);
setStoredAuthAccessToken(response.token);
setStoredAuthAccessToken(response.token, apiBaseUrl);
return response.user;
}
@@ -365,7 +392,7 @@ export async function loginClientWithPhoneCode(
'登录失败',
{ skipAuth: true, apiBaseUrl },
);
setStoredAuthAccessToken(response.token);
setStoredAuthAccessToken(response.token, apiBaseUrl);
return response.user;
}
@@ -1,14 +1,13 @@
import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http';
export const AGC_DEVELOPMENT_API_BASE_URL = 'https://dev.genarrative.world';
export const AGC_RELEASE_API_BASE_URL = 'https://www.genarrative.world';
export const AGC_CLIENT_MARKER_HEADER = 'X-Genarrative-Client';
export const AGC_CLIENT_MARKER_VALUE = 'agc';
/**
* Upper bound for the initial network transaction (DNS/connect/response
* headers). Callers may override this for a request that legitimately needs
* more time; the default prevents auth/bootstrap requests from hanging
* forever when the selected server or proxy is unavailable.
* forever when the platform service is unavailable.
*/
export const CLIENT_HTTP_DEFAULT_TIMEOUT_MS = 15_000;
@@ -85,119 +84,12 @@ export async function readClientHttpResponseText(
}
}
export type ClientServerPreset = 'release' | 'dev' | 'custom';
export type ClientServerSelection = {
preset: ClientServerPreset;
customBaseUrl: string;
};
const CLIENT_SERVER_SELECTION_STORAGE_KEY =
'genarrative.client.server-selection.v1';
function defaultClientServerPreset(): Exclude<ClientServerPreset, 'custom'> {
return import.meta.env.DEV ? 'dev' : 'release';
}
function isClientServerPreset(value: unknown): value is ClientServerPreset {
return value === 'release' || value === 'dev' || value === 'custom';
}
export function normalizeClientServerBaseUrl(value: string) {
const normalized = value.trim().replace(/\/+$/u, '');
let parsed: URL;
try {
parsed = new URL(normalized);
} catch {
throw new Error('服务器地址无效');
}
if (
!['http:', 'https:'].includes(parsed.protocol) ||
parsed.username ||
parsed.password ||
parsed.pathname !== '/' ||
parsed.search ||
parsed.hash
) {
throw new Error('服务器地址必须是纯 HTTP(S) 地址');
}
const isLoopback = ['localhost', '127.0.0.1', '[::1]'].includes(
parsed.hostname,
);
if (parsed.protocol === 'http:' && !isLoopback) {
throw new Error('非本机服务器必须使用 HTTPS');
}
return normalized;
}
function readStoredClientServerSelection(): ClientServerSelection {
const fallback: ClientServerSelection = {
preset: defaultClientServerPreset(),
customBaseUrl: '',
};
if (typeof window === 'undefined') return fallback;
try {
const raw = window.localStorage.getItem(
CLIENT_SERVER_SELECTION_STORAGE_KEY,
);
if (!raw) return fallback;
const parsed = JSON.parse(raw) as {
preset?: unknown;
customBaseUrl?: unknown;
};
if (!isClientServerPreset(parsed.preset)) return fallback;
const customBaseUrl =
typeof parsed.customBaseUrl === 'string' ? parsed.customBaseUrl : '';
if (parsed.preset === 'custom') {
normalizeClientServerBaseUrl(customBaseUrl);
}
return { preset: parsed.preset, customBaseUrl };
} catch {
return fallback;
}
}
export function getClientServerSelection() {
return readStoredClientServerSelection();
}
export function setClientServerSelection(
selection: ClientServerSelection,
): ClientServerSelection {
const next: ClientServerSelection = {
preset: selection.preset,
customBaseUrl:
selection.preset === 'custom'
? normalizeClientServerBaseUrl(selection.customBaseUrl)
: selection.customBaseUrl.trim(),
};
if (typeof window !== 'undefined') {
window.localStorage.setItem(
CLIENT_SERVER_SELECTION_STORAGE_KEY,
JSON.stringify(next),
);
}
return next;
}
export function resetClientServerSelectionForTests() {
if (typeof window !== 'undefined') {
window.localStorage.removeItem(CLIENT_SERVER_SELECTION_STORAGE_KEY);
}
}
export function getClientServerBaseUrl(
selection: ClientServerSelection = getClientServerSelection(),
) {
if (selection.preset === 'release') return AGC_RELEASE_API_BASE_URL;
if (selection.preset === 'dev') return AGC_DEVELOPMENT_API_BASE_URL;
return normalizeClientServerBaseUrl(selection.customBaseUrl);
export function getClientServerBaseUrl() {
return AGC_DEVELOPMENT_API_BASE_URL;
}
type ClientHttpContext = {
isDevelopment: boolean;
isTauri: boolean;
pageProtocol: string;
mode?: string;
serverBaseUrl?: string;
};
@@ -215,9 +107,7 @@ function withAgcClientMarker(init: RequestInit): RequestInit {
function currentClientHttpContext(): ClientHttpContext {
return {
isDevelopment: import.meta.env.DEV,
isTauri: typeof window !== 'undefined' && Boolean(window.__TAURI__),
pageProtocol: typeof window === 'undefined' ? '' : window.location.protocol,
mode: import.meta.env.MODE,
};
}
@@ -226,20 +116,20 @@ export function resolveClientHttpTarget(
url: string,
context: ClientHttpContext = currentClientHttpContext(),
): ClientHttpTarget {
// Existing unit fixtures omit mode; retain the Vite-relative transport for
// them while real development/release clients use the selected server.
const serverBaseUrl = getClientServerBaseUrl();
const target = new URL(url, `${serverBaseUrl}/`);
if (
!context.serverBaseUrl &&
(context.mode === 'test' || (!context.mode && context.isDevelopment))
(context.serverBaseUrl && context.serverBaseUrl !== serverBaseUrl) ||
target.origin !== serverBaseUrl ||
target.username ||
target.password
) {
return { transport: 'web', url };
throw new Error('请求目标不在客户端固定的 dev 服务范围内');
}
const serverBaseUrl =
context.serverBaseUrl ?? getClientServerBaseUrl(getClientServerSelection());
const target = new URL(url, `${serverBaseUrl}/`);
if (target.origin !== serverBaseUrl) {
throw new Error('请求目标不在当前选择的服务器范围内');
// Unit fixtures use relative requests after the same origin validation.
if (context.mode === 'test') {
return { transport: 'web', url };
}
if (!context.isTauri) {
@@ -258,17 +148,10 @@ export async function fetchClientHttp(
} = {},
): Promise<Response> {
const currentContext = currentClientHttpContext();
const serverBaseUrl = options.serverBaseUrl
? normalizeClientServerBaseUrl(options.serverBaseUrl)
: undefined;
// Unit fixtures intentionally use the relative Vite transport. Real clients bind every
// auth transaction to the explicit origin captured before its first request.
const target = resolveClientHttpTarget(
url,
currentContext.mode === 'test'
? currentContext
: { ...currentContext, serverBaseUrl },
);
const target = resolveClientHttpTarget(url, {
...currentContext,
serverBaseUrl: options.serverBaseUrl,
});
const markedInit = withAgcClientMarker(init);
// Always use a private controller so an internal timeout cannot mutate a
@@ -1,10 +1,12 @@
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
import { resolveTauriInvoke } from '../app/tauri';
import {
clearStoredAuthAccessToken,
getCurrentClientAuthUser,
getStoredAuthAccessToken,
isClientAuthAuthorityFailure,
refreshClientAuthAccessToken,
setStoredAuthAccessToken,
} from './clientAuth';
import { getClientServerBaseUrl } from './clientHttp';
import {
@@ -13,10 +15,8 @@ import {
transitionClientOperation,
} from './clientOperation';
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
function readStoredAccessTokenOrThrow() {
const accessToken = getStoredAuthAccessToken();
function readStoredAccessTokenOrThrow(apiBaseUrl: string) {
const accessToken = getStoredAuthAccessToken(apiBaseUrl);
if (!accessToken) {
throw new Error('陶泥儿登录凭据缺失,请重新登录');
}
@@ -94,18 +94,18 @@ export function getPlatformSessionOperation() {
function restoreCommittedAccessToken() {
if (committedPlatformSession?.accessToken) {
window.localStorage.setItem(
ACCESS_TOKEN_STORAGE_KEY,
setStoredAuthAccessToken(
committedPlatformSession.accessToken,
committedPlatformSession.apiBaseUrl,
);
return;
}
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
clearStoredAuthAccessToken();
}
function restoreCurrentRendererAccessToken() {
if (!desiredPlatformSession) {
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
clearStoredAuthAccessToken();
return;
}
restoreCommittedAccessToken();
@@ -424,7 +424,7 @@ export async function commitAuthenticatedPlatformSession(
expectedGeneration: number,
apiBaseUrl = resolvePlatformApiBaseUrl(),
) {
const accessToken = readStoredAccessTokenOrThrow();
const accessToken = readStoredAccessTokenOrThrow(apiBaseUrl);
const operation = createClientOperation(
'auth-transition',
{ userId: user.id },
@@ -508,7 +508,7 @@ export function requestPlatformSessionRefresh(expectedUserId?: string) {
const committed = await enqueuePlatformSessionNativeMutation(() =>
commitPlatformCredentialRefresh(
user,
readStoredAccessTokenOrThrow(),
readStoredAccessTokenOrThrow(apiBaseUrl),
apiBaseUrl,
expectedGeneration,
),
@@ -1,11 +1,7 @@
import { afterEach } from 'vitest';
import {
AGC_DEVELOPMENT_API_BASE_URL,
AGC_RELEASE_API_BASE_URL,
resetClientServerSelectionForTests,
setClientServerSelection,
} from '../../src/services/clientHttp';
import { setStoredAuthAccessToken } from '../../src/services/clientAuth';
import { AGC_DEVELOPMENT_API_BASE_URL } from '../../src/services/clientHttp';
import {
beginPlatformSessionClearTransition,
beginPlatformSessionTransition,
@@ -33,7 +29,6 @@ import {
export function registerAuthTests() {
afterEach(() => {
resetPlatformSessionStateForTests();
resetClientServerSelectionForTests();
delete window.__TAURI__;
});
@@ -151,7 +146,6 @@ export function registerAuthTests() {
});
it('keeps login HTTP and native commit bound to the origin frozen before the request', async () => {
setClientServerSelection({ preset: 'dev', customBaseUrl: '' });
const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } };
let resolveLogin: ((response: Response) => void) | null = null;
@@ -184,11 +178,12 @@ export function registerAuthTests() {
});
fireEvent.click(screen.getByRole('button', { name: '登录' }));
await waitFor(() => expect(resolveLogin).not.toBeNull());
expect(
(screen.getByLabelText('服务器') as HTMLSelectElement).disabled,
).toBe(true);
expect(screen.queryByLabelText('服务器')).toBeNull();
setClientServerSelection({ preset: 'release', customBaseUrl: '' });
window.localStorage.setItem(
'genarrative.client.server-selection.v1',
JSON.stringify({ preset: 'release', customBaseUrl: '' }),
);
resolveLogin?.(
new Response(
JSON.stringify({
@@ -211,7 +206,7 @@ export function registerAuthTests() {
);
expect(invoke).not.toHaveBeenCalledWith(
'install_platform_account_session',
expect.objectContaining({ apiBaseUrl: AGC_RELEASE_API_BASE_URL }),
expect.objectContaining({ apiBaseUrl: 'https://www.genarrative.world' }),
);
});
@@ -257,10 +252,7 @@ export function registerAuthTests() {
const installFloor = nativeFloor.revision;
const installIdentityFloor = nativeFloor.identityGeneration;
const loginGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'renderer-reload-token',
);
setStoredAuthAccessToken('renderer-reload-token');
await commitAuthenticatedPlatformSession(testAuthUser, loginGeneration);
expect(mutations[0]?.command).toBe('install_platform_account_session');
expect(mutations[0]?.identityGeneration).toBeGreaterThan(
@@ -310,10 +302,7 @@ export function registerAuthTests() {
});
window.__TAURI__ = { core: { invoke } };
const firstGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'retry-floor-token',
);
setStoredAuthAccessToken('retry-floor-token');
await expect(
commitAuthenticatedPlatformSession(testAuthUser, firstGeneration),
@@ -321,10 +310,7 @@ export function registerAuthTests() {
// 瞬时读取失败不能被缓存成永久失败:第二次登录必须重新读取并成功。
const secondGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'retry-floor-token',
);
setStoredAuthAccessToken('retry-floor-token');
await expect(
commitAuthenticatedPlatformSession(testAuthUser, secondGeneration),
).resolves.toEqual(expect.any(Number));
@@ -358,10 +344,7 @@ export function registerAuthTests() {
window.__TAURI__ = { core: { invoke } };
const stalledGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'stalled-token',
);
setStoredAuthAccessToken('stalled-token');
const stalled = commitAuthenticatedPlatformSession(
testAuthUser,
stalledGeneration,
@@ -370,10 +353,7 @@ export function registerAuthTests() {
expect(installedTokens).toEqual(['stalled-token']);
const retryGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'retry-token',
);
setStoredAuthAccessToken('retry-token');
const retry = commitAuthenticatedPlatformSession(
testAuthUser,
retryGeneration,
@@ -477,17 +457,11 @@ export function registerAuthTests() {
});
window.__TAURI__ = { core: { invoke } };
const accountAGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
setStoredAuthAccessToken('account-a-token');
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
setStoredAuthAccessToken('account-b-token');
await expect(
commitAuthenticatedPlatformSession(accountB, accountBGeneration),
@@ -513,17 +487,11 @@ export function registerAuthTests() {
});
window.__TAURI__ = { core: { invoke } };
const accountAGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
setStoredAuthAccessToken('account-a-token');
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
setStoredAuthAccessToken('account-b-token');
const accountBCommit = commitAuthenticatedPlatformSession(
accountB,
accountBGeneration,
@@ -562,17 +530,11 @@ export function registerAuthTests() {
});
window.__TAURI__ = { core: { invoke } };
const accountAGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
setStoredAuthAccessToken('account-a-token');
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
setStoredAuthAccessToken('account-b-token');
const accountBCommit = commitAuthenticatedPlatformSession(
accountB,
accountBGeneration,
@@ -613,18 +575,12 @@ export function registerAuthTests() {
});
window.__TAURI__ = { core: { invoke } };
const accountAGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
setStoredAuthAccessToken('account-a-token');
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
setStoredAuthAccessToken('account-b-token');
const accountBCommit = commitAuthenticatedPlatformSession(
accountB,
accountBGeneration,
@@ -633,10 +589,7 @@ export function registerAuthTests() {
const accountC = { ...testAuthUser, id: 'user-c', displayName: '用户 C' };
const accountCGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-c-token',
);
setStoredAuthAccessToken('account-c-token');
const accountCCommit = commitAuthenticatedPlatformSession(
accountC,
accountCGeneration,
@@ -661,18 +614,12 @@ export function registerAuthTests() {
const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } };
const accountAGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
setStoredAuthAccessToken('account-a-token');
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
setStoredAuthAccessToken('account-b-token');
const accountBCommit = commitAuthenticatedPlatformSession(
accountB,
accountBGeneration,
@@ -694,10 +641,7 @@ export function registerAuthTests() {
const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } };
const initialGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
setStoredAuthAccessToken('account-a-token');
await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration);
let resolveRefresh: ((response: Response) => void) | null = null;
@@ -731,10 +675,7 @@ export function registerAuthTests() {
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
setStoredAuthAccessToken('account-b-token');
await commitAuthenticatedPlatformSession(accountB, accountBGeneration);
resolveRefresh?.(
new Response(JSON.stringify({ token: 'late-account-a-token' }), {
@@ -768,10 +709,7 @@ export function registerAuthTests() {
const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } };
const initialGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
setStoredAuthAccessToken('account-a-token');
await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration);
let rejectRefresh: ((error: Error) => void) | null = null;
@@ -788,10 +726,7 @@ export function registerAuthTests() {
const staleRefresh = requestPlatformSessionRefresh(testAuthUser.id);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
setStoredAuthAccessToken('account-b-token');
await commitAuthenticatedPlatformSession(accountB, accountBGeneration);
rejectRefresh?.(new Error('late account A refresh failed'));
@@ -805,10 +740,7 @@ export function registerAuthTests() {
const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } };
const generation = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'expired-token',
);
setStoredAuthAccessToken('expired-token');
await commitAuthenticatedPlatformSession(testAuthUser, generation);
let refreshCalls = 0;
vi.spyOn(globalThis, 'fetch').mockImplementation(
@@ -862,10 +794,7 @@ export function registerAuthTests() {
});
window.__TAURI__ = { core: { invoke } };
const generation = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'expired-token',
);
setStoredAuthAccessToken('expired-token');
await commitAuthenticatedPlatformSession(testAuthUser, generation);
const identityGenerationAfterLogin =
currentPlatformNativeIdentityGenerationForTests();
@@ -915,10 +844,7 @@ export function registerAuthTests() {
const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } };
const generation = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'still-valid-token',
);
setStoredAuthAccessToken('still-valid-token');
await commitAuthenticatedPlatformSession(testAuthUser, generation);
const sessionGeneration = currentPlatformSessionGeneration();
@@ -947,14 +873,10 @@ export function registerAuthTests() {
});
it('keeps refresh, current-user lookup, and native commit on the frozen origin', async () => {
setClientServerSelection({ preset: 'dev', customBaseUrl: '' });
const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } };
const generation = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'expired-token',
);
setStoredAuthAccessToken('expired-token');
await commitAuthenticatedPlatformSession(
testAuthUser,
generation,
@@ -983,7 +905,10 @@ export function registerAuthTests() {
},
);
const refresh = requestPlatformSessionRefresh(testAuthUser.id);
setClientServerSelection({ preset: 'release', customBaseUrl: '' });
window.localStorage.setItem(
'genarrative.client.server-selection.v1',
JSON.stringify({ preset: 'release', customBaseUrl: '' }),
);
resolveRefresh?.(
new Response(JSON.stringify({ token: 'replacement-token' }), {
status: 200,
@@ -1074,7 +999,7 @@ export function registerAuthTests() {
expect(screen.queryByLabelText('已登录')).toBeNull();
});
it('shows release, dev, and custom server choices on the login screen', async () => {
it('shows login without server selection or custom platform address', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
if (String(input) === '/api/auth/refresh') {
@@ -1091,22 +1016,74 @@ export function registerAuthTests() {
);
await screen.findByRole('main', { name: '登录' });
const server = screen.getByRole('combobox', { name: '服务器' });
expect(server).not.toBeNull();
expect(screen.getByRole('option', { name: 'release' })).not.toBeNull();
expect(screen.getByRole('option', { name: 'dev' })).not.toBeNull();
expect(screen.getByRole('option', { name: 'custom' })).not.toBeNull();
fireEvent.change(server, { target: { value: 'custom' } });
expect(screen.getByLabelText('自定义服务器地址')).not.toBeNull();
fireEvent.change(screen.getByLabelText('自定义服务器地址'), {
target: { value: 'https://staging.example.com' },
});
expect(
(screen.getByLabelText('自定义服务器地址') as HTMLInputElement).value,
).toBe('https://staging.example.com');
expect(screen.queryByRole('combobox', { name: '服务器' })).toBeNull();
expect(screen.queryByLabelText('自定义服务器地址')).toBeNull();
});
it.each(['release', 'dev'])(
'restores dev without forwarding a bare credential despite the %s preference',
async (preset) => {
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'release-token',
);
window.localStorage.setItem(
'genarrative.client.server-selection.v1',
JSON.stringify({ preset, customBaseUrl: '' }),
);
const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } };
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
expect(new Headers(init?.headers).get('Authorization')).not.toBe(
'Bearer release-token',
);
if (url === '/api/auth/refresh') {
expect(
new Headers(init?.headers).get('Authorization'),
).toBeNull();
return new Response(JSON.stringify({ token: 'dev-token' }), {
status: 200,
});
}
if (url === '/api/auth/me') {
expect(new Headers(init?.headers).get('Authorization')).toBe(
'Bearer dev-token',
);
return new Response(JSON.stringify({ user: testAuthUser }), {
status: 200,
});
}
throw new Error(`unexpected fetch ${url}`);
},
);
render(
React.createElement(AuthenticatedClient, null, () =>
React.createElement('main', { 'aria-label': '已登录' }),
),
);
expect(
await screen.findByRole('main', { name: '已登录' }),
).not.toBeNull();
expect(fetchSpy.mock.calls.map(([url]) => String(url))).toEqual([
'/api/auth/refresh',
'/api/auth/me',
]);
expect(invoke).toHaveBeenLastCalledWith(
'install_platform_account_session',
expect.objectContaining({
accessToken: 'dev-token',
apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL,
}),
);
},
);
it('logs in with a phone code and stores the returned token', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
@@ -1445,10 +1422,7 @@ export function registerAuthTests() {
});
it('keeps the stored token when startup auth check cannot reach the service', async () => {
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'existing-token',
);
setStoredAuthAccessToken('existing-token');
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(async (input: RequestInfo | URL) => {
@@ -1485,10 +1459,7 @@ export function registerAuthTests() {
});
it('shows the HTTP maintenance error when startup auth receives a 503', async () => {
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'existing-token',
);
setStoredAuthAccessToken('existing-token');
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
if (String(input) === '/api/auth/me') {
@@ -1516,10 +1487,7 @@ export function registerAuthTests() {
});
it('still calls logout when token refresh fails during logout retry', async () => {
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'existing-token',
);
setStoredAuthAccessToken('existing-token');
let logoutCalls = 0;
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
@@ -1581,10 +1549,7 @@ export function registerAuthTests() {
});
it('fails the renderer closed when native session clear is rejected during logout', async () => {
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'existing-token',
);
setStoredAuthAccessToken('existing-token');
window.__TAURI__ = {
core: {
invoke: vi.fn(async (command: string) => {
@@ -11,7 +11,10 @@ import {
getStoredAuthAccessToken,
refreshClientAuthAccessToken,
} from '../src/services/clientAuth';
import { CLIENT_HTTP_DEFAULT_TIMEOUT_MS } from '../src/services/clientHttp';
import {
AGC_DEVELOPMENT_API_BASE_URL,
CLIENT_HTTP_DEFAULT_TIMEOUT_MS,
} from '../src/services/clientHttp';
import {
cachedLlmModelCatalog,
refreshLlmModelCatalog,
@@ -266,16 +269,18 @@ it('响应体卡住超时后,下一次续期会重新发起请求', async () =
);
});
const first = refreshClientAuthAccessToken('http://localhost:3000');
const first = refreshClientAuthAccessToken(AGC_DEVELOPMENT_API_BASE_URL);
const firstAssertion = expect(first).rejects.toThrow();
await vi.advanceTimersByTimeAsync(15_000);
await firstAssertion;
expect(getClientAuthRefreshOperation('http://localhost:3000')).toMatchObject({
expect(
getClientAuthRefreshOperation(AGC_DEVELOPMENT_API_BASE_URL),
).toMatchObject({
kind: 'auth-refresh',
phase: 'retryable-failure',
});
const second = refreshClientAuthAccessToken('http://localhost:3000');
const second = refreshClientAuthAccessToken(AGC_DEVELOPMENT_API_BASE_URL);
const secondAssertion = expect(second).rejects.toThrow();
expect(refreshCalls).toBe(2);
await vi.advanceTimersByTimeAsync(15_000);
@@ -0,0 +1,121 @@
/** @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
getStoredAuthAccessToken as getApiAccessToken,
requestClientApi,
} from '../src/services/clientApi';
import {
clearStoredAuthAccessToken,
getStoredAuthAccessToken,
setStoredAuthAccessToken,
} from '../src/services/clientAuth';
import { AGC_DEVELOPMENT_API_BASE_URL } from '../src/services/clientHttp';
vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() }));
vi.mock('../src/services/errorReporting', () => ({
captureClientError: vi.fn(),
}));
const tokenKey = 'genarrative.auth.access-token.v1';
const originKey = 'genarrative.auth.access-token-origin.v1';
const selectionKey = 'genarrative.client.server-selection.v1';
describe('AGC platform credential origin', () => {
afterEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it('preserves a credential already marked as dev', () => {
window.localStorage.setItem(tokenKey, 'existing-dev-token');
window.localStorage.setItem(originKey, AGC_DEVELOPMENT_API_BASE_URL);
expect(getStoredAuthAccessToken()).toBe('existing-dev-token');
expect(getApiAccessToken()).toBe('existing-dev-token');
expect(window.localStorage.getItem(originKey)).toBe(
AGC_DEVELOPMENT_API_BASE_URL,
);
});
it.each([
JSON.stringify({ preset: 'dev', customBaseUrl: '' }),
JSON.stringify({
preset: 'custom',
customBaseUrl: `${AGC_DEVELOPMENT_API_BASE_URL}/`,
}),
JSON.stringify({ preset: 'release', customBaseUrl: '' }),
JSON.stringify({ preset: 'custom', customBaseUrl: 'https://example.com' }),
JSON.stringify({
preset: 'custom',
customBaseUrl: 'http://localhost:8082',
}),
JSON.stringify({ preset: 'unknown', customBaseUrl: '' }),
'invalid-json',
'null',
])(
'never infers a legacy credential origin from a saved preference: %s',
async (selection) => {
window.localStorage.setItem(tokenKey, 'other-server-token');
window.localStorage.setItem(selectionKey, selection);
vi.stubEnv('MODE', 'production');
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(
new Response(JSON.stringify({ result: true }), { status: 200 }),
);
await requestClientApi('/api/profile/dashboard', {}, '读取失败');
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe(`${AGC_DEVELOPMENT_API_BASE_URL}/api/profile/dashboard`);
expect(new Headers(init?.headers).get('Authorization')).toBeNull();
expect(window.localStorage.getItem(tokenKey)).toBeNull();
},
);
it.each([true, false])(
'clears an unmarked credential without a preference (development=%s)',
(development) => {
// Vitest 0.34 stores stubbed env values as strings; use a falsy value for DEV=false.
vi.stubEnv('DEV', development ? 'true' : '');
window.localStorage.setItem(tokenKey, 'legacy-token');
expect(getStoredAuthAccessToken()).toBe('');
},
);
it('does not relabel a credential that already belongs to another origin', () => {
window.localStorage.setItem(tokenKey, 'other-origin-token');
window.localStorage.setItem(originKey, 'https://www.genarrative.world');
window.localStorage.setItem(
selectionKey,
JSON.stringify({ preset: 'dev' }),
);
expect(getApiAccessToken()).toBe('');
expect(window.localStorage.getItem(tokenKey)).toBeNull();
expect(window.localStorage.getItem(originKey)).toBeNull();
});
it('stores new dev credentials with their origin and ignores old preferences', () => {
vi.stubEnv('DEV', false);
setStoredAuthAccessToken('new-token');
window.localStorage.setItem(
selectionKey,
JSON.stringify({ preset: 'release' }),
);
expect(getApiAccessToken()).toBe('new-token');
expect(getStoredAuthAccessToken('https://www.genarrative.world')).toBe('');
expect(() =>
setStoredAuthAccessToken('wrong-token', 'https://example.com'),
).toThrow('固定的 dev 服务');
expect(getStoredAuthAccessToken()).toBe('new-token');
clearStoredAuthAccessToken();
expect(window.localStorage.getItem(tokenKey)).toBeNull();
expect(window.localStorage.getItem(originKey)).toBeNull();
});
});
@@ -1,3 +1,4 @@
/** @vitest-environment jsdom */
import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -9,16 +10,11 @@ import {
AGC_CLIENT_MARKER_HEADER,
AGC_CLIENT_MARKER_VALUE,
AGC_DEVELOPMENT_API_BASE_URL,
AGC_RELEASE_API_BASE_URL,
ClientHttpTimeoutError,
fetchClientHttp,
getClientServerBaseUrl,
getClientServerSelection,
normalizeClientServerBaseUrl,
readClientHttpResponseText,
resetClientServerSelectionForTests,
resolveClientHttpTarget,
setClientServerSelection,
} from '../src/services/clientHttp';
vi.mock('@tauri-apps/plugin-http', () => ({
@@ -31,7 +27,7 @@ describe('AGC client HTTP transport', () => {
vi.clearAllMocks();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
resetClientServerSelectionForTests();
window.localStorage.clear();
});
it('adds the AGC marker while preserving and overriding request headers', async () => {
@@ -123,37 +119,25 @@ describe('AGC client HTTP transport', () => {
expect(forwardedHeaders.get('Authorization')).toBe('Bearer fixture-token');
});
it('keeps local development requests on the Vite API proxy', () => {
expect(
resolveClientHttpTarget('/api/auth/me', {
isDevelopment: true,
isTauri: true,
pageProtocol: 'http:',
}),
).toEqual({ transport: 'web', url: '/api/auth/me' });
});
it.each(['development', 'production'])(
'routes %s Tauri requests through fixed dev',
(mode) => {
expect(
resolveClientHttpTarget('/api/auth/me', {
isTauri: true,
mode,
}),
).toEqual({
transport: 'tauri-http',
url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`,
});
},
);
it('routes release Tauri requests through the scoped dev API transport', () => {
it('keeps test fixtures on relative requests after origin validation', () => {
expect(
resolveClientHttpTarget('/api/auth/me', {
isDevelopment: false,
isTauri: true,
pageProtocol: 'tauri:',
mode: 'production',
serverBaseUrl: AGC_RELEASE_API_BASE_URL,
}),
).toEqual({
transport: 'tauri-http',
url: `${AGC_RELEASE_API_BASE_URL}/api/auth/me`,
});
});
it('keeps ordinary web releases on same-origin relative requests', () => {
expect(
resolveClientHttpTarget('/api/auth/me', {
isDevelopment: false,
isTauri: false,
pageProtocol: 'https:',
mode: 'test',
}),
).toEqual({ transport: 'web', url: '/api/auth/me' });
@@ -162,97 +146,55 @@ describe('AGC client HTTP transport', () => {
it('rejects release Tauri requests outside the fixed dev API origin', () => {
expect(() =>
resolveClientHttpTarget('https://example.com/api/auth/me', {
isDevelopment: false,
isTauri: true,
pageProtocol: 'tauri:',
mode: 'production',
serverBaseUrl: AGC_RELEASE_API_BASE_URL,
}),
).toThrow('当前选择的服务范围');
).toThrow('固定的 dev 服务范围');
});
it('persists release, dev, and custom server selection', () => {
const release = setClientServerSelection({
preset: 'release',
customBaseUrl: '',
});
expect(release).toEqual({
preset: 'release',
customBaseUrl: '',
});
expect(getClientServerBaseUrl(release)).toBe(AGC_RELEASE_API_BASE_URL);
const dev = setClientServerSelection({ preset: 'dev', customBaseUrl: '' });
expect(getClientServerBaseUrl(dev)).toBe(AGC_DEVELOPMENT_API_BASE_URL);
it.each(['release', 'dev', 'custom'])(
'ignores persisted %s preference when resolving web requests',
(preset) => {
window.localStorage.setItem(
'genarrative.client.server-selection.v1',
JSON.stringify({
preset,
customBaseUrl: 'https://staging.example.com',
}),
);
vi.stubEnv('DEV', false);
expect(getClientServerBaseUrl()).toBe(AGC_DEVELOPMENT_API_BASE_URL);
expect(
resolveClientHttpTarget('/api/auth/me', {
isTauri: false,
mode: 'development',
}),
).toEqual({
transport: 'web',
url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`,
});
},
);
const custom = setClientServerSelection({
preset: 'custom',
customBaseUrl: 'https://staging.example.com/',
});
expect(custom).toEqual({
preset: 'custom',
customBaseUrl: 'https://staging.example.com',
});
expect(getClientServerSelection().preset).toBe('dev');
expect(getClientServerBaseUrl(custom)).toBe('https://staging.example.com');
});
it('accepts HTTPS custom servers and loopback HTTP only', () => {
expect(normalizeClientServerBaseUrl('https://example.com/')).toBe(
'https://example.com',
);
expect(normalizeClientServerBaseUrl('http://127.0.0.1:8080/')).toBe(
'http://127.0.0.1:8080',
);
expect(() => normalizeClientServerBaseUrl('http://example.com')).toThrow(
'必须使用 HTTPS',
);
expect(() =>
normalizeClientServerBaseUrl('https://example.com/api'),
).toThrow('纯 HTTP(S)');
});
it('routes selected custom servers for both web and Tauri clients', () => {
const serverBaseUrl = 'https://staging.example.com';
expect(
resolveClientHttpTarget('/api/auth/me', {
isDevelopment: true,
isTauri: false,
pageProtocol: 'http:',
mode: 'development',
serverBaseUrl,
}),
).toEqual({
transport: 'web',
url: `${serverBaseUrl}/api/auth/me`,
});
expect(
resolveClientHttpTarget('/api/auth/me', {
isDevelopment: false,
isTauri: true,
pageProtocol: 'tauri:',
mode: 'production',
serverBaseUrl,
}),
).toEqual({
transport: 'tauri-http',
url: `${serverBaseUrl}/api/auth/me`,
});
});
it('keeps Tauri HTTP transport when the WebView reports an http page protocol', () => {
expect(
resolveClientHttpTarget('/api/auth/me', {
isDevelopment: false,
isTauri: true,
pageProtocol: 'http:',
mode: 'production',
serverBaseUrl: AGC_DEVELOPMENT_API_BASE_URL,
}),
).toEqual({
transport: 'tauri-http',
url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`,
});
});
it.each(['development', 'production', 'test'])(
'rejects explicit origin overrides before transport in %s',
async (mode) => {
vi.stubEnv('MODE', mode);
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(
fetchClientHttp(
'/api/auth/me',
{},
{
serverBaseUrl: 'https://www.genarrative.world',
},
),
).rejects.toThrow('固定的 dev 服务范围');
expect(fetchMock).not.toHaveBeenCalled();
expect(tauriHttpFetch).not.toHaveBeenCalled();
},
);
it('aborts a stalled Web request at the configured timeout', async () => {
vi.useFakeTimers();
+1 -2
View File
@@ -42,7 +42,7 @@
- [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。
- [AGC Unity 编辑器插件接入](./technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md)DotCraft Attach 来源、Windows Mono 接入、项目身份、执行回执和分发边界。
- [AGC Cocos Creator 编辑器桥接模块](<./technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md>):独立 crate、feature 开关、目标校验与 Windows 注入边界。
- [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、OSS 清单格式和下载约定
- [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、固定 dev 服务、OSS 清单与官网最新客户端下载
- [AGC 模板库与模板建项](./technical/【技术方案】AGC模板库与模板建项-2026-09-17.md)`templates/` 前缀的模板库契约、下载安装与「用模板建项目」链路。
- [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。
- [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md)Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。
@@ -74,7 +74,6 @@
- [画板音乐生成入口](./【编辑器】画板音乐生成入口设计-2026-06-18.md):BGM/SFX 共享视图、独立业务规则和当前发布门禁。
- [画布 Agent 对话面板](./【编辑器】画布Agent对话面板-2026-07-03.md)
- [画布 Agent 会话消息存 OSS](./adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md)
- [GPT Image 2.5 模型路由与历史值兼容](./adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md)
- [编辑器模型定价配置](./【编辑器】模型定价配置管理方案-2026-06-22.md)
## 后端、运维与测试
@@ -1,19 +0,0 @@
# GPT Image 2.5 模型路由与历史值兼容
状态:accepted
新任务使用业务模型值 `gpt-image-2.5`api-server 按任务显式选择具体 model:生成使用 `gpt-image-2.5-flare-c`,编辑使用 `gpt-image-2.5-sunburst-c`。这两个 GPT Image 2.5 model 必须通过启动时构造的 Tiantoken client 发送;Tiantoken client 只读取显式配置的 `TIANTOKEN_BASE_URL`(部署值由环境设置为 `https://api.tiantoken.com`)和独立 `TIANTOKEN_API_KEY`,缺失即阻止 api-server 启动,不得回退到 VectorEngine 或其 API key。
图片协议执行逻辑保持 provider-neutral:请求 body、multipart、尺寸约束、重试、响应解码和审计由共享 image executor 承担;VectorEngine 与 Tiantoken client 只提供相同协议所需的 base URL、API key 和 provider identity。platform-image 根据 concrete model 做严格白名单路由:`gpt-image-2.5-flare-c``gpt-image-2.5-sunburst-c` 走 Tiantoken`gemini-3.1-flash-image-preview`nanobanana)走 VectorEngine;未知 model 直接拒绝。已持久化的 `gpt-image-2` / `gpt-image-2-c` 只在新任务提交边界按兼容规则解析为当前 GPT Image 2.5 任务,不改写历史资源,也不进入旧 VectorEngine 图片路由。
普通主站前端只接触业务模型和新生成展示名 `GPT Image 2.5`admin Web/API 可以查看和编辑两个具体定价 key;普通生成即使因参考图使用 edits multipart,仍按生成 concrete model。旧 `gpt-image-2-c` 审计记录原样保留,新代码不再跨模型或跨 provider fallback。
主站前端只把原本明确使用 GPT Image 2 的专用新任务改为业务模型值 `gpt-image-2.5`;普通图片、角色、场景、图标等原有 nanobanana 默认行为保持不变。画布参数或编辑布局读到 `gpt-image-2` / `gpt-image-2-c` 时,在使用端解析为 `gpt-image-2.5` 并触发参数迁移警告,原始资源、审计、metadata 和历史 fixture 不回写。AGC 现有资源生成界面与请求默认保持不变,不因本决策新增模型字段、选择器或尺寸行为。
## Consequences
- 定价配置的活动 key 是两个具体 provider model;旧单 key 配置只允许受控 backfill,并留下兼容 TODO。
- 新任务的同模型重试固定使用 api-server dispatch 的具体 model,不切换到另一个 model。
- 两套 provider client 在 api-server 启动阶段同时构造;任一 required provider 配置缺失,启动失败而不是延迟到首次图片请求。
- provider routing 只依据 concrete model 的白名单;provider client 不复制共享协议执行逻辑。
- 公开资源、`generationInputs` 和普通前端契约不包含具体 provider key;admin 定价管理是明确例外。
@@ -3017,7 +3017,7 @@
},
"model": {
"type": "string",
"description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-bananagpt-image-2 / gpt-image-2-c 仅作为历史值在新任务提交边界兼容解析。未传时沿用编辑器默认。"
"description": "支持 gpt-image-2、gemini-3.1-flash-image-preview、nanobanana2、nano-banana。未传时沿用编辑器默认。"
},
"aspectRatio": {
"type": "string",
@@ -3033,7 +3033,7 @@
"type": "array",
"items": {
"type": "string",
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS 再提交。禁止 Data URL / Blob URL。普通生成最多 5 张;kind=quick-edit 时 GPT Image 2.5 最多 5 张、nanobanana2 最多 9 张。超限返回 400,不会静默截断。"
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS 再提交。禁止 Data URL / Blob URL。普通生成最多 5 张;kind=quick-edit 时 gpt-image-2 最多 5 张、nanobanana2 最多 9 张。超限返回 400,不会静默截断。"
},
"maxItems": 9
},
@@ -3170,7 +3170,7 @@
},
"model": {
"type": "string",
"description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-bananagpt-image-2 / gpt-image-2-c 仅作为历史值兼容解析。"
"description": "支持 gpt-image-2、gemini-3.1-flash-image-preview、nanobanana2、nano-banana。"
},
"aspectRatio": {
"type": "string",
@@ -3184,7 +3184,7 @@
"type": "array",
"items": {
"type": "string",
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceReferenceId 对应的主来源原图占用 1 张 provider 容量,因此 GPT Image 2.5 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。"
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceReferenceId 对应的主来源原图占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。"
},
"maxItems": 8
},
@@ -3373,7 +3373,7 @@
"type": "array",
"items": {
"type": "string",
"description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。referenceId 占用 1 张 provider 容量,因此 GPT Image 2.5 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。"
"description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。referenceId 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。"
},
"maxItems": 8
},
@@ -3488,13 +3488,13 @@
"model": {
"type": "string",
"default": "gemini-3.1-flash-image-preview",
"description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-bananagpt-image-2 / gpt-image-2-c 仅作为历史值兼容解析。未传时默认使用 nanobanana。"
"description": "支持 gpt-image-2、gemini-3.1-flash-image-preview、nanobanana2、nano-banana。未传时默认使用 nanobanana。"
},
"referenceImageSrcs": {
"type": "array",
"items": {
"type": "string",
"description": "额外 UI 素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceImageSrc 占用 1 张 provider 容量,因此 GPT Image 2.5 最多再提交 4 张、nanobanana2 最多再提交 5 张;超限返回 400,不会静默截断。"
"description": "额外 UI 素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceImageSrc 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 5 张;超限返回 400,不会静默截断。"
},
"maxItems": 5
},
@@ -1,50 +0,0 @@
# GPT Image 2.5 provider 边界重构实施计划
- Version: 3
- Status: active
- Date: 2026-09-18
- Parent Milestone: `docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md`
## 实施边界
1. 先抽象 provider-neutral settings/client 与共享图片执行器接口;保留一套 body、multipart、尺寸、retry、响应和 audit 逻辑。
2. 在 api-server 配置/state 初始化阶段分别构造 VectorEngine 与 Tiantoken client;删除 Tiantoken 对 VectorEngine URL/key 的任何 fallback。
3. 在 platform-image 建立 concrete model 白名单路由:GPT Image 2.5 → Tiantokennanobanana → VectorEnginelegacy/unknown 按合同处理。
4. 重命名 provider-specific client/build/transport 符号,避免共享逻辑继续伪装成 `vector_engine_*`;仅保留确有 VectorEngine 语义的名称。
5. 迁移 api-server、Agent、raw edit、角色/图标/UI 入口和测试;核对 pricing/admin/public DTO 可见性。
6. 删除跨模型/跨 provider fallback 分支,保留同 concrete model retry。
## 前端同步边界(已确认)
1. 主站 `src/components/image-editor/` 中,凡是原本明确写死 `gpt-image-2` 的 GPT 专用新任务(快速编辑、UI 设计、宣发、规范及对应提交/锁定模型)统一改为业务模型值 `gpt-image-2.5`;普通图片、角色、场景、图标等原有 nanobanana 默认行为不变。
2. `normalizeEditorImageModel` 在编辑面板、画布生成参数和历史布局恢复的使用端把 `gpt-image-2``gpt-image-2-c` 解析为 `gpt-image-2.5`。该兼容命中必须触发既有参数回退警告;未知模型仍按原有无效参数处理。原始资源、审计、metadata 和历史 fixture 不回写。
3. 主站新请求的 `model` 字段只发送业务模型值或 nanobanana 业务值,不发送 `gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c` 等 concrete provider model。
4. AGC`apps/ai-game-creator-shell/src` 及其 Tauri 本地资源生成链路)本里程碑不增加模型字段、选择器或尺寸行为;继续使用现有请求默认值。AGC 代码只在确属历史兼容读取/测试证明的位置保留旧值,不借本次同步引入新功能。
5. OpenAPI、External editor skill reference、现役脚本和当前技术方案中的“新任务使用 GPT Image 2 / 2-c fallback”改为 GPT Image 2.5 口径;静态已生成 manifest、历史审计和兼容 fixture 保持旧值并补充历史语义断言。
## 前端实现顺序
1. 先在 `ImageCanvasGenerationModel` 集中定义历史别名解析与迁移警告结果,保持 nanobanana 默认值、尺寸矩阵和可选模型顺序不变。
2. 迁移 `ImageCanvasGenerationDialogModel``ImageCanvasGenerationSubmissionModel`、生成工作流、快速编辑弹窗、规范/宣发面板的 GPT 专用默认、锁定值和提交 payload。
3.`generationInputs` / 画布布局 / 资源历史恢复补齐旧值迁移警告测试;确认新请求字段为 `gpt-image-2.5`,而历史原文仍未被改写。
4. 更新 OpenAPI 与现役工具/专题文档,区分业务模型、concrete provider model、历史值和产品展示名;不得把 AGC 默认行为改成前端显式模型选择。
## 验证命令
- `cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check`
- `cargo test -p platform-image`
- `cargo test -p platform-editor-agent`
- api-server 定向测试/`cargo check -p api-server`
- `npm run typecheck`
- `npm run check:doc-index`
- `npm run check:encoding`
- `git diff --check`
- 主站图片编辑定向测试:模型选项/展示名、GPT 专用提交值、历史 `gpt-image-2``gpt-image-2-c` 恢复及警告、nanobanana 默认回归。
- AGC 定向测试:确认未新增 `model` IPC 入参、未改变原有尺寸/UI 行为,现有资源生成请求继续依赖服务端默认。
- OpenAPI/External editor 契约测试:模型说明、参考图容量说明和新业务模型值一致,provider concrete model 不进入普通前端契约。
## 风险与回滚
- 风险:启动阶段依赖变化、历史任务兼容解析遗漏、nanobanana 被误路由到 Tiantoken、provider key 泄露到公开 DTO。
- 风险:主站把 nanobanana 的默认路径误改成 GPT Image 2.5,或把历史兼容值静默吞掉导致用户无法识别参数迁移。
- 回滚:以 provider-neutral seam、启动配置、model route、主站前端同步四个局部提交边界回滚;不执行数据库历史迁移,也不回滚历史资源值。
@@ -1,52 +0,0 @@
# GPT Image 2.5 provider 边界重构
- Version: 2
- Status: active
- Date: 2026-09-18
- Parent Spec: `docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md`
## 目标
在保持图片协议执行逻辑共享的前提下,建立明确的 provider client 边界:GPT Image 2.5 通过 Tiantokennanobanana 通过 VectorEngine;路由依据 concrete model 严格白名单决定;两个 client 在 api-server 启动阶段构造。
## 范围
- `platform-image`provider-neutral 图片执行器、provider client 注入 seam、concrete model 路由和错误/审计 provider 标识。
- `api-server`:启动时构造 VectorEngine/Tiantoken 两个 client,分别读取各自环境变量;任务提交边界的历史模型兼容解析。
- 共享请求、multipart、尺寸、retry、响应和 audit 逻辑保持单一实现。
- Agent 与其它 server-side 图片调用方迁移到业务模型/concrete model 合同。
- 定价、公开 DTO、admin DTO 与 provider model 可见性保持既定 ADR 约束。
## 现役路由
| Concrete model | Provider client | 业务用途 |
| --- | --- | --- |
| `gpt-image-2.5-flare-c` | Tiantoken | Generate,包括带参考图的普通生成 |
| `gpt-image-2.5-sunburst-c` | Tiantoken | Edit,包括快速编辑、原位修改、raw edit |
| `gemini-3.1-flash-image-preview` | VectorEngine | nanobanana 生成/编辑能力 |
`gpt-image-2``gpt-image-2-c` 只保留为历史持久化/审计字符串;新任务不得 dispatch 到旧 GPT Image 2 路由。未知 model 直接拒绝。
## 必须成立的行为
1. api-server 启动时同时构造两个 required provider client;任一对应环境变量缺失,启动失败。
2. Tiantoken 只读取 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`VectorEngine 只读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY`,互不回退。
3. platform-image 根据 concrete model 选择已注入 client;共享执行器不复制 provider 协议逻辑。
4. 同 concrete model 可以 retry,但永不跨 concrete model 或跨 provider fallback。
5. 历史值读取不改写;新任务提交边界将旧值兼容为 GPT Image 2.5 业务任务。
6. 普通前端不接收 concrete provider modeladmin 定价界面可查看和编辑两个具体 pricing key。
## 非目标
- 不复制两套完整图片 client。
- 不新增 GPT Image 2 现役 VectorEngine 路由。
- 不修改历史数据库记录或旧审计字符串。
- 不把 provider client 选择下沉给普通前端。
## 验收证据
- provider routing 单元测试覆盖 flare/sunburst/nanobanana/legacy/unknown。
- 启动配置测试证明两套 client 独立读取环境变量,缺失任一配置即失败且无 VectorEngine/Tiantoken 回退。
- 请求审计测试证明 provider 与 concrete model 正确记录。
- platform-image 与 Agent 定向测试通过。
- api-server 类型/编译检查、前端类型检查、编码/文档/diff 门禁通过。
@@ -8940,31 +8940,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 验证:限速后 `Genarrative-Full-Build-And-Deploy` #289 / #290 SUCCESS;采样期 Jenkins 峰值 10.2~10.5 核、限流不足 2s(可忽略),runner 峰值 12.07 核且持续出现 throttling,整机回落到 2.6%~19.8%。
- 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)。
## 2026-09-18 GPT Image 2.5 业务模型与具体 provider 定价路由
- **决策**:新任务使用业务模型值 `gpt-image-2.5`api-server 按任务显式 dispatch 具体模型 `gpt-image-2.5-flare-c`(生成)或 `gpt-image-2.5-sunburst-c`(编辑),并把同一具体 key 交给 `platform-image` 与后台定价解析。普通生成即使因参考图使用 edits multipart,仍按生成 route;同模型重试不跨模型 fallback。
- **历史兼容**:已持久化 `gpt-image-2` 读回原值不改写;基于旧资源发起新任务时,在提交边界解析为 `gpt-image-2.5`,新任务/新产物按新业务值和当前 task price 处理。旧 `gpt-image-2-c` 仅保留历史审计,不再作为 fallback 或业务模型。
- **可见性**:普通主站前端和公开定价 API 不接收具体 provider key;新生成 UI label 为 `GPT Image 2.5`,历史资源/旧编辑上下文不扩散该 label。admin Web/API 是明确例外,可查看和编辑两个具体定价 key。旧单 key 定价配置允许受控 backfill,并加 compatibility TODO。
- **关联 ADR**[`docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md`](../../adr/【ADR】GPT%20Image%202.5模型路由与历史值兼容-2026-09-18.md)。
- **补充**GPT Image 2.5 的两个具体模型通过显式 `TIANTOKEN_BASE_URL` 与独立 `TIANTOKEN_API_KEY` 发送;环境变量缺失时必须失败,禁止使用 VectorEngine 配置或 API key 回退。
## 2026-09-18 GPT Image 2.5 provider-neutral 执行器与双 client 启动边界
- **决策**:图片协议执行逻辑保持单一共享实现;只抽出 provider client 的 identity、base URL、API key 和 client 构造,通过依赖注入复用请求 body、multipart、尺寸、retry、响应和 audit。
- **路由**`gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c` 走 Tiantoken`gemini-3.1-flash-image-preview`nanobanana)走 VectorEngine`gpt-image-2` / `gpt-image-2-c` 仅是历史字符串,新任务不再进入旧 GPT Image 2 路由;未知 model 拒绝。
- **启动**api-server 启动时同时构造 VectorEngine 与 Tiantoken 两个 required client;各自只读取自己的环境变量,任一配置缺失即启动失败,不延迟到首次请求。
- **重试**:只在同一个 concrete model 内 retry,禁止跨 model、跨 provider fallback。
- **关联文档**[`docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md`](../../adr/【ADR】GPT%20Image%202.5模型路由与历史值兼容-2026-09-18.md)、[`docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md`](../plans/【里程碑】GPT%20Image%202.5%20provider边界重构-2026-09-18.md)。
## 2026-09-19 Tiantoken 凭据落到 AppConfig 字段并修复编辑器 LLM 测试夹具
- **决策**Tiantoken 凭据仍然只从 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 读取,不回退 VectorEngine;读取点从 `AppState::new` 前移到 `AppConfig::from_env()`,落到 `AppConfig.tiantoken_base_url` / `AppConfig.tiantoken_api_key` 两个字段,与其它 provider 的配置形态一致,`AppState` 构造与测试构造复用同一条路径;删除 `config::tiantoken_base_url()` / `config::tiantoken_api_key()` 两个现读环境变量的访问器。
- **原因**2026-09-18 的「双 provider 启动边界」改成现读环境变量后,`editor_background_music_prompt_assist``editor_sound_effect_prompt_assist``vector_engine_audio_generation/sound_effect_translation` 三处 mock LLM 夹具仍用 `AppConfig.vector_engine_*` 构造状态,28 条用例拿到 503 `editor_llm_unavailable`。改用进程环境变量做夹具会让并行用例互相踩 `TIANTOKEN_*`,因此把凭据落到 config 字段而不是在测试里 set_var。
- **边界**ADR 的「不得回退 VectorEngine 或其 API key」仍然成立,`from_env` 只读 `TIANTOKEN_*`,并有用例固定该行为。
- **验证**`cargo test -p api-server` 1060 passed / 0 failed / 6 ignored`cargo fmt --all --check``npm run check:encoding``git diff --check` 通过。
- **关联文档**[`docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md`](../../adr/【ADR】GPT%20Image%202.5模型路由与历史值兼容-2026-09-18.md)、[`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`](../../【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md)。
## 2026-09-17 AGC 抠图接入本地资源编辑恢复闭环
- 背景:`agc_remove_background` 原先只提交 `/api/editor/images/background-removals` 并返回 `queued`,没有轮询远端任务、下载完成媒体或写入本地 manifestBgFilter 已成功处理但 Agent 因此永远只能看到受理回执。

Some files were not shown because too many files have changed in this diff Show More