合并最新 master 分支
保留 master 的画布交互、Agent、VectorEngine 与原生 CI 更新。 解决角色动画测试和项目决策日志冲突,不恢复按帧数放大 BgFilter 超时的旧逻辑。 同步修正融合文档中的 BgFilter worker、N/Q、重试与父侧降级边界。
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: gpt-image-2-apimart
|
||||
description: Generate or inspect project image assets through this repository's VectorEngine gpt-image-2 workflow. Use when Codex needs to create puzzle template sample images, reproduce the server-rs gpt-image-2 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 VectorEngine
|
||||
|
||||
Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine `gpt-image-2` path. 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
|
||||
|
||||
@@ -65,9 +65,9 @@ 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. 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 GPT-image-2 currently returns synchronously; do not poll APIMart task endpoints.
|
||||
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.
|
||||
|
||||
## Environment
|
||||
|
||||
|
||||
@@ -9,6 +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';
|
||||
const fallbackImageModel = 'gpt-image-2-c';
|
||||
|
||||
const prompts = [
|
||||
{
|
||||
@@ -165,6 +167,25 @@ function extractBase64Images(payload) {
|
||||
return values;
|
||||
}
|
||||
|
||||
function decodeStrictBase64Image(raw) {
|
||||
const normalized = String(raw || '').trim();
|
||||
if (
|
||||
!normalized ||
|
||||
normalized.length % 4 !== 0 ||
|
||||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
|
||||
normalized,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const bytes = Buffer.from(normalized, 'base64');
|
||||
return bytes.length > 0 &&
|
||||
bytes.toString('base64') === normalized &&
|
||||
inferExtensionFromBytes(bytes)
|
||||
? bytes
|
||||
: null;
|
||||
}
|
||||
|
||||
function inferExtensionFromContentType(contentType) {
|
||||
const normalized = contentType.split(';')[0]?.trim().toLowerCase();
|
||||
if (normalized === 'image/png') {
|
||||
@@ -192,7 +213,13 @@ function inferExtensionFromBytes(bytes) {
|
||||
) {
|
||||
return 'webp';
|
||||
}
|
||||
return 'png';
|
||||
if (
|
||||
bytes.subarray(0, 6).toString('ascii') === 'GIF87a' ||
|
||||
bytes.subarray(0, 6).toString('ascii') === 'GIF89a'
|
||||
) {
|
||||
return 'gif';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchJson(url, options, timeoutMs) {
|
||||
@@ -205,9 +232,20 @@ async function fetchJson(url, options, timeoutMs) {
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
|
||||
const error = new Error(
|
||||
`VectorEngine ${response.status}: ${text.slice(0, 600)}`,
|
||||
);
|
||||
error.vectorEngineStatus = response.status;
|
||||
error.vectorEngineBody = text;
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
error.vectorEngineResponseParse = true;
|
||||
error.vectorEngineBody = text;
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
|
||||
@@ -218,6 +256,83 @@ 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, fallbackImageModel]) {
|
||||
const requestBody = {
|
||||
model,
|
||||
prompt: buildPrompt(entry),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
};
|
||||
try {
|
||||
const payload = await fetchJson(
|
||||
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
env.timeoutMs,
|
||||
);
|
||||
const base64Image = decodeStrictBase64Image(extractBase64Images(payload)[0]);
|
||||
if (
|
||||
extractImageUrls(payload)[0] ||
|
||||
base64Image
|
||||
) {
|
||||
return payload;
|
||||
}
|
||||
const error = new Error(`VectorEngine returned no image for ${entry.id}`);
|
||||
error.vectorEngineResponseParse = true;
|
||||
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
|
||||
throw error;
|
||||
} catch (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}`);
|
||||
}
|
||||
|
||||
async function downloadUrl(url, timeoutMs) {
|
||||
const abortController = new AbortController();
|
||||
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
||||
@@ -244,25 +359,7 @@ async function downloadUrl(url, timeoutMs) {
|
||||
}
|
||||
|
||||
async function generateOne(env, entry, outDir) {
|
||||
const requestBody = {
|
||||
model: 'gpt-image-2',
|
||||
prompt: buildPrompt(entry),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
};
|
||||
const payload = await fetchJson(
|
||||
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
env.timeoutMs,
|
||||
);
|
||||
const payload = await requestImagePayload(env, entry);
|
||||
|
||||
const urls = extractImageUrls(payload);
|
||||
const b64Images = extractBase64Images(payload);
|
||||
@@ -271,7 +368,10 @@ async function generateOne(env, entry, outDir) {
|
||||
if (urls[0]) {
|
||||
image = await downloadUrl(urls[0], env.timeoutMs);
|
||||
} else if (b64Images[0]) {
|
||||
const bytes = Buffer.from(b64Images[0], 'base64');
|
||||
const bytes = decodeStrictBase64Image(b64Images[0]);
|
||||
if (!bytes) {
|
||||
throw new Error(`VectorEngine returned invalid base64 image for ${entry.id}`);
|
||||
}
|
||||
image = {
|
||||
bytes,
|
||||
extension: inferExtensionFromBytes(bytes),
|
||||
@@ -304,8 +404,9 @@ if (dryRun) {
|
||||
requests: selectedPrompts.map((entry) => ({
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
fallbackModel: fallbackImageModel,
|
||||
body: {
|
||||
model: 'gpt-image-2',
|
||||
model: preferredImageModel,
|
||||
prompt: buildPrompt(entry),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
|
||||
@@ -14,6 +14,8 @@ const promptsPath = path.join(
|
||||
);
|
||||
const defaultOutDir = path.join(repoRoot, 'public', 'puzzle-creation-templates');
|
||||
const defaultTimeoutMs = 1000000;
|
||||
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) {
|
||||
@@ -131,6 +133,25 @@ function extractBase64Images(payload) {
|
||||
return values;
|
||||
}
|
||||
|
||||
function decodeStrictBase64Image(raw) {
|
||||
const normalized = String(raw || '').trim();
|
||||
if (
|
||||
!normalized ||
|
||||
normalized.length % 4 !== 0 ||
|
||||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
|
||||
normalized,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const bytes = Buffer.from(normalized, 'base64');
|
||||
return bytes.length > 0 &&
|
||||
bytes.toString('base64') === normalized &&
|
||||
inferExtensionFromBytes(bytes)
|
||||
? bytes
|
||||
: null;
|
||||
}
|
||||
|
||||
function inferExtensionFromContentType(contentType) {
|
||||
const normalized = contentType.split(';')[0]?.trim().toLowerCase();
|
||||
if (normalized === 'image/png') {
|
||||
@@ -158,7 +179,13 @@ function inferExtensionFromBytes(bytes) {
|
||||
) {
|
||||
return 'webp';
|
||||
}
|
||||
return 'png';
|
||||
if (
|
||||
bytes.subarray(0, 6).toString('ascii') === 'GIF87a' ||
|
||||
bytes.subarray(0, 6).toString('ascii') === 'GIF89a'
|
||||
) {
|
||||
return 'gif';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchJson(url, options, timeoutMs) {
|
||||
@@ -171,9 +198,20 @@ async function fetchJson(url, options, timeoutMs) {
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
|
||||
const error = new Error(
|
||||
`VectorEngine ${response.status}: ${text.slice(0, 600)}`,
|
||||
);
|
||||
error.vectorEngineStatus = response.status;
|
||||
error.vectorEngineBody = text;
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
error.vectorEngineResponseParse = true;
|
||||
error.vectorEngineBody = text;
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
|
||||
@@ -184,6 +222,83 @@ 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, fallbackImageModel]) {
|
||||
const requestBody = {
|
||||
model,
|
||||
prompt: buildPrompt(template),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
};
|
||||
try {
|
||||
const payload = await fetchJson(
|
||||
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
env.timeoutMs,
|
||||
);
|
||||
const base64Image = decodeStrictBase64Image(extractBase64Images(payload)[0]);
|
||||
if (
|
||||
extractImageUrls(payload)[0] ||
|
||||
base64Image
|
||||
) {
|
||||
return payload;
|
||||
}
|
||||
const error = new Error(`VectorEngine returned no image for ${template.id}`);
|
||||
error.vectorEngineResponseParse = true;
|
||||
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
|
||||
throw error;
|
||||
} catch (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}`);
|
||||
}
|
||||
|
||||
async function downloadUrl(url, timeoutMs) {
|
||||
const abortController = new AbortController();
|
||||
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
||||
@@ -210,25 +325,7 @@ async function downloadUrl(url, timeoutMs) {
|
||||
}
|
||||
|
||||
async function generateOne(env, template, outDir) {
|
||||
const requestBody = {
|
||||
model: 'gpt-image-2',
|
||||
prompt: buildPrompt(template),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
};
|
||||
const payload = await fetchJson(
|
||||
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
env.timeoutMs,
|
||||
);
|
||||
const payload = await requestImagePayload(env, template);
|
||||
|
||||
const urls = extractImageUrls(payload);
|
||||
const b64Images = extractBase64Images(payload);
|
||||
@@ -237,7 +334,10 @@ async function generateOne(env, template, outDir) {
|
||||
if (urls[0]) {
|
||||
image = await downloadUrl(urls[0], env.timeoutMs);
|
||||
} else if (b64Images[0]) {
|
||||
const bytes = Buffer.from(b64Images[0], 'base64');
|
||||
const bytes = decodeStrictBase64Image(b64Images[0]);
|
||||
if (!bytes) {
|
||||
throw new Error(`VectorEngine returned invalid base64 image for ${template.id}`);
|
||||
}
|
||||
image = {
|
||||
bytes,
|
||||
extension: inferExtensionFromBytes(bytes),
|
||||
@@ -274,8 +374,9 @@ if (dryRun) {
|
||||
requests: selectedTemplates.map((template) => ({
|
||||
id: template.id,
|
||||
title: template.title,
|
||||
fallbackModel: fallbackImageModel,
|
||||
body: {
|
||||
model: 'gpt-image-2',
|
||||
model: preferredImageModel,
|
||||
prompt: buildPrompt(template),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
name: Project CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- codex/ai-game-creator-app
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CI: 'true'
|
||||
CARGO_INCREMENTAL: '0'
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTC_WRAPPER: ''
|
||||
CARGO_BUILD_RUSTC_WRAPPER: ''
|
||||
|
||||
jobs:
|
||||
repository-checks:
|
||||
name: Repository checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout full history
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install base tools
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v apt-get >/dev/null 2>&1 || {
|
||||
echo 'ubuntu-latest runner must provide an Ubuntu or Debian environment.' >&2
|
||||
exit 1
|
||||
}
|
||||
sudo_command=''
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo_command='sudo'
|
||||
fi
|
||||
${sudo_command} apt-get update
|
||||
${sudo_command} env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl
|
||||
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Resolve comparison base
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base_ref="$(node -e '
|
||||
const fs = require("node:fs");
|
||||
const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
|
||||
process.stdout.write(event.pull_request?.base?.sha ?? event.before ?? "");
|
||||
')"
|
||||
if [[ -n "${base_ref}" && ! "${base_ref}" =~ ^0+$ ]]; then
|
||||
git cat-file -e "${base_ref}^{commit}" 2>/dev/null || {
|
||||
echo "comparison base commit is unavailable: ${base_ref}" >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)"
|
||||
fi
|
||||
if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \
|
||||
&& ! git merge-base --is-ancestor "${base_ref}" HEAD; then
|
||||
echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "SPACETIME_SCHEMA_BASE_REF=${base_ref}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Set up repository Rust toolchain
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v rustup >/dev/null 2>&1; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --profile minimal --default-toolchain none
|
||||
fi
|
||||
echo "${HOME}/.cargo/bin" >> "${GITHUB_PATH}"
|
||||
export PATH="${HOME}/.cargo/bin:${PATH}"
|
||||
toolchain="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)"
|
||||
test -n "${toolchain}"
|
||||
rustup toolchain install "${toolchain}" --profile minimal --component rustfmt
|
||||
rustc --version
|
||||
cargo --version
|
||||
rustfmt --version
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run repository lint gates
|
||||
run: npm run lint
|
||||
|
||||
- name: Build web applications
|
||||
run: npm run build
|
||||
|
||||
- name: Validate content data
|
||||
run: npm run check:content
|
||||
|
||||
- name: Check committed whitespace
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base_ref="${SPACETIME_SCHEMA_BASE_REF:-}"
|
||||
test -n "${base_ref}"
|
||||
git cat-file -e "${base_ref}^{commit}"
|
||||
git diff --check "${base_ref}"...HEAD
|
||||
|
||||
frontend-tests:
|
||||
name: Frontend tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run frontend and script tests
|
||||
run: npm run test
|
||||
|
||||
backend-tests:
|
||||
name: Backend tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout full history
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install backend build dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v apt-get >/dev/null 2>&1 || {
|
||||
echo 'ubuntu-latest runner must provide an Ubuntu or Debian environment.' >&2
|
||||
exit 1
|
||||
}
|
||||
sudo_command=''
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo_command='sudo'
|
||||
fi
|
||||
${sudo_command} apt-get update
|
||||
${sudo_command} env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
clang \
|
||||
cmake \
|
||||
curl \
|
||||
ffmpeg \
|
||||
libclang-dev \
|
||||
libcurl4-openssl-dev \
|
||||
libssl-dev \
|
||||
lld \
|
||||
pkg-config
|
||||
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Resolve comparison base
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base_ref="$(node -e '
|
||||
const fs = require("node:fs");
|
||||
const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
|
||||
process.stdout.write(event.pull_request?.base?.sha ?? event.before ?? "");
|
||||
')"
|
||||
if [[ -n "${base_ref}" && ! "${base_ref}" =~ ^0+$ ]]; then
|
||||
git cat-file -e "${base_ref}^{commit}" 2>/dev/null || {
|
||||
echo "comparison base commit is unavailable: ${base_ref}" >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)"
|
||||
fi
|
||||
if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \
|
||||
&& ! git merge-base --is-ancestor "${base_ref}" HEAD; then
|
||||
echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "SPACETIME_SCHEMA_BASE_REF=${base_ref}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Set up repository Rust toolchain
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v rustup >/dev/null 2>&1; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --profile minimal --default-toolchain none
|
||||
fi
|
||||
echo "${HOME}/.cargo/bin" >> "${GITHUB_PATH}"
|
||||
export PATH="${HOME}/.cargo/bin:${PATH}"
|
||||
toolchain="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)"
|
||||
test -n "${toolchain}"
|
||||
rustup toolchain install "${toolchain}" --profile minimal --component rustfmt
|
||||
rustc --version
|
||||
cargo --version
|
||||
rustfmt --version
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check server-rs boundaries
|
||||
run: npm run check:server-rs-ddd
|
||||
|
||||
- name: Run server-rs workspace tests
|
||||
run: cargo test --locked --workspace --no-fail-fast --manifest-path server-rs/Cargo.toml
|
||||
|
||||
- name: Check api-server targets
|
||||
run: cargo check --locked -p api-server --all-targets --manifest-path server-rs/Cargo.toml
|
||||
|
||||
- name: Check SpacetimeDB module
|
||||
run: cargo check --locked -p spacetime-module --manifest-path server-rs/Cargo.toml
|
||||
|
||||
native-shell-tests:
|
||||
name: Native shell tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout full history
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install native shell build dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v apt-get >/dev/null 2>&1 || {
|
||||
echo 'ubuntu-latest runner must provide an Ubuntu or Debian environment.' >&2
|
||||
exit 1
|
||||
}
|
||||
sudo_command=''
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo_command='sudo'
|
||||
fi
|
||||
${sudo_command} apt-get update
|
||||
${sudo_command} env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
clang \
|
||||
cmake \
|
||||
curl \
|
||||
file \
|
||||
libayatana-appindicator3-dev \
|
||||
libssl-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libxdo-dev \
|
||||
librsvg2-dev \
|
||||
lld \
|
||||
patchelf \
|
||||
pkg-config \
|
||||
wget
|
||||
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Set up repository Rust toolchain
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v rustup >/dev/null 2>&1; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --profile minimal --default-toolchain none
|
||||
fi
|
||||
echo "${HOME}/.cargo/bin" >> "${GITHUB_PATH}"
|
||||
export PATH="${HOME}/.cargo/bin:${PATH}"
|
||||
toolchain="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)"
|
||||
test -n "${toolchain}"
|
||||
rustup toolchain install "${toolchain}" --profile minimal --component rustfmt
|
||||
rustc --version
|
||||
cargo --version
|
||||
rustfmt --version
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run native shell gates
|
||||
run: npm run check:native-shells
|
||||
|
||||
- name: Ensure native lockfile is unchanged
|
||||
run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock
|
||||
@@ -4379,3 +4379,16 @@
|
||||
## 2026-07-20 VectorEngine 图片任务预算收口到 worker deadline
|
||||
|
||||
- 决策:`editor_image_generation`、`editor_image_edit`、`editor_icon_spritesheet_generation` 和 `editor_ui_design_asset_extraction` 使用默认 `1800s` long job 预算。worker 从同一起点计算绝对 job deadline,并向 provider 提前保留 `min(60s, job 预算 / 2)` 作为审计、OSS 和终态写回窗口。deadline 只经进程内 `RequestContext` 传递;VectorEngine 单 attempt 取配置 timeout 与剩余预算的较小值,退避加下一次 attempt 无法落在同一 deadline 内时停止重试,参考图和响应图片下载也受同一 deadline 限制。普通 HTTP / `inline` 保持无 deadline 行为;`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 默认仍为 `1000000`,配置加载层允许显式值更低。lease 续租 / fencing、迟到写回仲裁、attempt 耗尽和原子退款语义不变。
|
||||
|
||||
## 2026-07-21 VectorEngine 图片首选 gpt-image-2 并以 gpt-image-2-c 兜底
|
||||
|
||||
- 决策:前端、DTO、计费配置、持久化和 `platform-image` 的 `/v1/images/generations` / `/v1/images/edits` provider 首选请求统一使用 `gpt-image-2`;符合条件时才回退到兜底模型 `gpt-image-2-c`。不在业务 handler、前端或价格表中新增平行模型。
|
||||
- 回退边界:明确模型不存在 / 不支持、408、非内容拒绝类 429、5xx、响应解析失败或非拒绝类缺图可以切模型;401 / 403、普通参数 / 内容安全拒绝、本地配置与参考图错误、发送 / 连接错误、request budget 耗尽和已生成图片下载失败不切模型。一次业务请求总发送上限仍为 5 次,两个模型共享同一 worker provider deadline 和 attempt 预算。
|
||||
- 观测边界:审计 `image_model` 记录实际 provider attempt;首选 `gpt-image-2` 失败但兜底 `gpt-image-2-c` 恢复成功时,首选失败仍写入 `external_api_call_failure`,最终成功运行摘要记录 `recoveredFailureCount`。日志用 `fallback_from_model` / `fallback_to_model` 标识切换,不改变业务模型、扣费、素材 metadata 或终态语义。
|
||||
- 脚本边界:仓库 `gpt-image-2-apimart` skill 的现役生成脚本采用同一首选 / 回退顺序;认证、请求发送不确定错误和下载失败不重新生图,避免重复上游成本。
|
||||
|
||||
## 2026-07-21 图片画布滚轮与中键平移统一为二维视口移动
|
||||
|
||||
- 背景:画布中键拖拽的平移模型已同时计算 X / Y,但普通滚轮分支只消费 `deltaY`,横向滚轮或触控板的 `deltaX` 被丢弃,且缺少中键横向拖动的状态机回归覆盖。
|
||||
- 决策:普通滚轮原样消费设备上报的 `deltaX / deltaY` 二维平移 viewport;当按住 Shift 且设备上报 `deltaX = 0` 时,输入适配层把 `deltaY` 映射为横向位移并将纵向位移置零,核心平移模型不感知修饰键。`Ctrl / Cmd + 滚轮` 继续只负责围绕指针缩放;中键和抓手拖拽继续同时更新 X / Y。
|
||||
- 验证:交互模型单测覆盖原始 `deltaX / deltaY` 和缩放边界;viewport hook 单测覆盖二维滚轮、Shift 横向适配与 Ctrl 缩放;stage 状态机单测覆盖中键水平、垂直同时移动。
|
||||
|
||||
@@ -269,6 +269,15 @@ DDD 边界检查:
|
||||
npm run check:server-rs-ddd
|
||||
```
|
||||
|
||||
## Gitea CI 与 PR 检查
|
||||
|
||||
- 仓库 CI 入口是 `.gitea/workflows/project-ci.yml`,向 `master`、`codex/ai-game-creator-app` 推送和所有 PR 创建、更新时必须运行,也允许手工触发。
|
||||
- CI 固定拆分为 `Repository checks`、`Frontend tests`、`Backend tests`、`Native shell tests` 四个 required job;对应 PR context 完整名称是 `Project CI / Repository checks (pull_request)`、`Project CI / Frontend tests (pull_request)`、`Project CI / Backend tests (pull_request)`、`Project CI / Native shell tests (pull_request)`,首次运行后仍须从 Gitea 最近一周 context 表复核。测试使用独立 job,不能只藏在综合检查 step 中;原生壳验收单独运行以便定位重型构建失败。
|
||||
- 四个 job 共同覆盖 `npm run check`,并追加 `npm run check:server-rs-ddd`、`cargo test --locked --workspace --no-fail-fast --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --all-targets --manifest-path server-rs/Cargo.toml` 和 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`。后端 runner 安装 `ffmpeg`,避免视频抽帧测试因工具缺失提前返回。`codex/ai-game-creator-app` 分支的原生壳入口还必须覆盖 `npm run ai-game-creator-shell:check` 和 release build smoke。
|
||||
- checkout 必须使用完整历史。PR 将 base SHA 写入 `SPACETIME_SCHEMA_BASE_REF`,直接推送 `master` 使用 before SHA;事件基线不可解析时直接失败。Gitea 检查的是 PR head 而非预合并 commit,workflow 必须拒绝不包含最新 base commit 的过期 PR,分支保护同时保持“PR 过期禁止合并”。
|
||||
- 普通 PR job 不读取业务 secret,不运行真实 API/SpacetimeDB/OSS/支付/生成/live smoke,也不执行会修改外部状态的维护、迁移、发布或备份命令。
|
||||
- Gitea 至少升级到 `1.26.4` 后才能注册执行 PR job 的 runner;`ubuntu-latest` 标签只映射到固定 digest 的 Ubuntu 24.04 级 Docker/临时隔离镜像,不使用浮动镜像 tag,不映射 host,不向 job 暴露 Docker socket、业务 secret 或不必要内网。runner 能访问 Gitea、GitHub Actions 与 `actions/node-versions`、nodejs.org、npm、Rust 分发和 crates.io;workflow 的官方 action 固定完整 commit,若内网禁用 GitHub,先在当前 Gitea 镜像对应 commit 并改用绝对 URL。受控镜像优先预装 rustup。Gitea 1.26 的任务超时由 runner 全局配置控制;首次运行成功后,`master` 分支保护必须要求上述四个 job 全部成功。
|
||||
|
||||
## 后端相关默认验证
|
||||
|
||||
后端修改后,按 DDD 文档中的验收命令执行。涉及 API smoke 时:
|
||||
|
||||
@@ -1510,7 +1510,7 @@
|
||||
|
||||
- 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY` 后,RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls`。
|
||||
- 原因:2026-05-21 后 GPT-image-2 图片生成按 VectorEngine 创建/编辑接口分流;2026-07-05 后创意 Agent 文本链路也改为 VectorEngine Chat Completions `gpt-5.4-mini`,APIMart 不再作为当前创意 Agent 来源。
|
||||
- 处理:为图片生成配置 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai`、`VECTOR_ENGINE_API_KEY`、`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;排查请求体时确认无参考图路径为 `/v1/images/generations`、有参考图路径为 `/v1/images/edits`,模型为 `gpt-image-2`。
|
||||
- 处理:为图片生成配置 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai`、`VECTOR_ENGINE_API_KEY`、`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;排查请求体时确认无参考图路径为 `/v1/images/generations`、有参考图路径为 `/v1/images/edits`,业务 / 计费与 provider 首发模型均为 `gpt-image-2`,仅在符合条件的 provider 失败后切到兜底模型 `gpt-image-2-c`。
|
||||
- 验证:运行 `cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml` 和相关玩法图片生成测试;真实联调只在本地私密环境放置 VectorEngine key。
|
||||
- 关联:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`、`server-rs/crates/api-server/src/openai_image_generation.rs`。
|
||||
|
||||
@@ -3234,6 +3234,14 @@
|
||||
- 验证:runner 回归测试必须同时覆盖“待确认工具只调用一次 LLM 并成功结束”和“普通连续工具仍会触发 max-turn 门禁”。
|
||||
- 关联:`server-rs/crates/platform-editor-agent/src/framework/run.rs`、`server-rs/crates/platform-editor-agent/src/framework/tool.rs`、`server-rs/crates/platform-editor-agent/src/agent/tools/`。
|
||||
|
||||
## 画布 Agent 的规划请求不能关闭瞬时失败重试
|
||||
|
||||
- 现象:美术 Agent 对话返回红色错误气泡 `completion error: LLM 请求超时,累计尝试 1 次`;HTTP 本身仍返回 200,前端 20 分钟 transport timeout 没有触发。
|
||||
- 原因:规划请求虽然有 Agent 专用单次 timeout,但 `editor_agent_llm_client` 把 `max_retries` 硬编码为 0;VectorEngine `gpt-5.4-mini` 的偶发长尾、连接超时或可重试上游状态会在第一次失败后直接持久化成 system error。framework 的英文 `completion error` 前缀也被原样暴露给用户。
|
||||
- 处理:120 秒改为前端软提示阈值:POST 仍 pending 时显示不入库的“仍在处理中,请耐心等待”;provider 明确断开/失败才写正式错误。专用 provider 单 attempt 使用 8 分钟 hard timeout,请求发起阶段读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次且重试退避最多 60 秒。不要只计算单次 complete 的最坏时间:runner 还可因非法 JSON/工具校验失败进入后续轮次,必须从 handler 入口开始计算 18 分钟总 deadline,进入 `agent.prompt(...)` 时扣除会话锁/上下文准备已用时间,为持久化和前端 20 分钟 timeout 留出余量。响应头后的体读取/解析错误按明确失败收口,必须使用真实 attempt 计数;规划、配置和定价错误对用户统一为中文,原始诊断只记后端日志。重试发生在任何生成工具执行前,不会重复提交生成任务或扣费,不要通过提高前端 timeout 或 runner `max_turns` 掩盖 provider 重试缺失。
|
||||
- 验证:`platform-editor-agent` 测试锁定 8 分钟 hard timeout 与中文错误;前端 fake timer 用例锁定 120 秒前只显示思考动画、到点后显示耐心等待、成功/失败后移除;`platform-llm` 回归用例锁定第二次 attempt 成功响应头后的 body timeout 仍报累计 2 次;`api-server` 测试锁定专用 client retry、18 分钟整体 deadline 与中文直达错误。运行态排障按同一 request id 对齐 `platform_llm` failure stage 与 `/messages` 总耗时,并确认仍 pending 的请求不再在 120 秒形成错误气泡。
|
||||
- 关联:`server-rs/crates/platform-editor-agent/src/agent/agent.rs`、`server-rs/crates/platform-editor-agent/src/framework/error.rs`、`server-rs/crates/api-server/src/state.rs`、`src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts`、`src/components/image-editor/EditorAgentConversation/MessageBubble.tsx`、`src/services/image-editor/editorAgentClient.ts`。
|
||||
|
||||
## 前端退役目录不能只靠扫描和 ignore 隔离
|
||||
|
||||
- 现象:Tailwind `@source`、TypeScript 根 `include`、ESLint ignore 和 Vitest include 都排除了旧创作目录,但干净打开新版页面时,Vite 仍转换 `services/rpg-entry/index.ts`,构建产物也包含旧作品库和旧 profile 逻辑。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -68,7 +68,7 @@
|
||||
## 第六阶段模块
|
||||
|
||||
- `ImageCanvasInteractionModel.ts`
|
||||
- 承载画布交互纯计算:适合视图、中心缩放、普通滚轮纵向滚动、Ctrl / Cmd 滚轮缩放、画布坐标换算、框选命中、平移、生成占位框拖拽、图层拖拽吸附、小地图投影、小地图点击定位和小地图拖拽视图移动。
|
||||
- 承载画布交互纯计算:适合视图、中心缩放、普通滚轮按原始 `deltaX / deltaY` 二维平移、Shift 且 `deltaX = 0` 时由输入适配层把 `deltaY` 映射为横向位移、Ctrl / Cmd 滚轮缩放、画布坐标换算、框选命中、平移、生成占位框拖拽、图层拖拽吸附、小地图投影、小地图点击定位和小地图拖拽视图移动。
|
||||
- 主视图继续负责 React 事件对象、pointer capture、history 快照、生成对象回写、选中态和 `setState`。
|
||||
- 该模块用独立单测覆盖小地图灵敏度、吸附、多选拖拽和滚轮缩放等之前容易回退的交互规则。
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
## 第十七阶段模块
|
||||
|
||||
- `useImageCanvasViewportControls.ts`
|
||||
- 承载画布视口控制:`viewport`、`canvasSize`、小地图投影、适合视图、中心缩放、普通滚轮纵向滚动、Ctrl / Cmd 滚轮缩放、屏幕点到画布 / 世界坐标换算和小地图点击 / 拖拽移动视图。
|
||||
- 承载画布视口控制:`viewport`、`canvasSize`、小地图投影、适合视图、中心缩放、普通滚轮按原始 `deltaX / deltaY` 二维平移、Shift 且 `deltaX = 0` 时的横向位移适配、Ctrl / Cmd 滚轮缩放、屏幕点到画布 / 世界坐标换算和小地图点击 / 拖拽移动视图。
|
||||
- 主视图继续负责图层拖拽、生成占位框拖拽、框选、多选、历史触发时机、上传 drop 分流和小地图 pointer down 事件;该 hook 只作为视口控制协调器,不接管画布完整 pointer 状态机。
|
||||
- 该 hook 用独立单测覆盖尺寸同步、适合视图、中心缩放、坐标换算、滚轮语义和小地图移动,为后续抽 `useImageCanvasStageInteractions` 预留更清晰的视口接口。
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -163,9 +163,9 @@ spacetime sql <database> "SELECT * FROM runtime_setting LIMIT 1" --server http:/
|
||||
|
||||
本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.6.1`。若版本错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为现役 settings、editor project 或 profile procedure 超时。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;遇到版本不匹配时直接执行 `spacetime version install <version> && spacetime version use <version>`,或在目标就是最新版本时执行 `spacetime version upgrade`,升级后重启 `npm run dev:spacetime` 再重试。当前 `scripts/dev.mjs` 会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`。2.6.1 修复了 procedure context 中调用者 `Identity` / `ConnectionId` 始终为空的回归,依赖 `ctx.sender` 鉴权时必须同时确认宿主已升级。
|
||||
|
||||
本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 VectorEngine 生成链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`;`api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。
|
||||
本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 VectorEngine 生成链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。业务模型和 VectorEngine provider 首选请求都使用 `gpt-image-2`,符合条件时才回退到兜底模型 `gpt-image-2-c`;图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`,`api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前按同一 GPT-image-2 family 规则归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。
|
||||
|
||||
VectorEngine 图片生成 / 编辑在 `request_send` 阶段出现 `timeout`、`connect`、libcurl 35 SSL connect reset、libcurl 56 receive error / `unexpected eof while reading`、recv failure 等临时传输错误,或在 `upstream_status` 阶段收到 408 / 429 / 5xx(例如 Nginx HTML `502 Bad Gateway`)时,`platform-image` 会对同一请求最多发送 5 次;multipart 图片编辑每次重试都会重新构造 form,避免复用已消费的 body。worker 从 job 开始的同一时钟起点计算绝对 deadline,常规保留最后 `60` 秒给审计、OSS 和终态写回;job 预算小于 `120` 秒时保留一半。VectorEngine 单次 attempt timeout 取配置值和剩余 provider 预算的较小值;退避后已没有下一次 attempt 的预算时立即停止重试。该 deadline 覆盖参考图、provider 请求 / 响应和响应图片下载的整次 provider future,但只在 worker 进程内通过 `RequestContext` 传递;普通 HTTP / `inline` 没有该 deadline,继续保持原有 timeout 和重试行为。日志中 `VectorEngine 图片请求发送失败,准备重试` 或 `VectorEngine 图片上游状态可重试,准备重试` 表示本次失败确有预算进入下一次尝试;预算耗尽或最终仍失败时才会写入 `external_api_call_failure` 并返回 504 / 502。排查生产失败时应同时统计 retry 前的尝试日志和最终 audit,避免把一次用户请求内的多次发送误判成多个用户请求。这项收口不修改 lease 续租 / fencing、迟到写回仲裁、attempt 耗尽与原子退款语义。
|
||||
VectorEngine 图片生成 / 编辑在 `request_send` 阶段出现 `timeout`、`connect`、libcurl 35 SSL connect reset、libcurl 56 receive error / `unexpected eof while reading`、recv failure 等临时传输错误,或在 `upstream_status` 阶段收到 408 / 429 / 5xx(例如 Nginx HTML `502 Bad Gateway`)时,`platform-image` 会在一次业务请求总上限 5 次内处理;multipart 图片编辑每次重试都会重新构造 form,避免复用已消费的 body。首个 provider attempt 使用 `gpt-image-2`;明确模型不可用、408 / 非拒绝类 429 / 5xx、响应解析失败或非拒绝类缺图时,下一 attempt 直接切兜底模型 `gpt-image-2-c`,之后只在剩余次数内重试兜底模型。发送 / 连接错误无法确认上游是否已受理,只重试同一首选模型,不切模型;认证、普通参数、安全拒绝、图片下载和 budget 错误同样不切。worker 从 job 开始的同一时钟起点计算绝对 deadline,常规保留最后 `60` 秒给审计、OSS 和终态写回;job 预算小于 `120` 秒时保留一半。VectorEngine 单次 attempt timeout 取配置值和剩余 provider 预算的较小值;退避或模型切换后已没有下一次 attempt 的预算时立即停止。该 deadline 覆盖参考图、provider 请求 / 响应和响应图片下载的整次 provider future,但只在 worker 进程内通过 `RequestContext` 传递;普通 HTTP / `inline` 没有该 deadline,继续保持原有 timeout 和重试行为。日志中 `VectorEngine 首选图片模型失败,切换兼容模型` 会携带 `fallback_from_model` / `fallback_to_model`;即使回退成功,首选模型错误仍写入 `external_api_call_failure`,成功运行摘要的 `recoveredFailureCount` 同时递增。排查生产失败时应同时统计 fallback / retry 日志和最终 audit,避免把一次用户请求内的多次发送误判成多个用户请求。这项收口不修改 lease 续租 / fencing、迟到写回仲裁、attempt 耗尽与原子退款语义。
|
||||
|
||||
图片编辑器生成属于持久队列长任务:提交接口返回 job 后,前端通过 `/api/runtime/external-generation/jobs/{jobId}` 与编辑器项目资源状态收敛。生产排查小程序或 WebView `Failed to fetch` 时,若 Nginx access log 为 `499`、`upstream_status=-`,先按提交请求的 `request_id`、job id、worker 日志和 `external_api_call_failure` 对齐真实任务,不把客户端断开直接判定为 provider 失败。
|
||||
|
||||
@@ -208,6 +208,23 @@ npm run check
|
||||
|
||||
`npm run build` 由 `scripts/build-gate.mjs` 串行构建主站和后台;该门禁会把 Vite warning 当成失败处理。若看到 `Build gate failed because warnings were emitted`,先看 warning 原文,例如 chunk 体积超过 `vite.config.ts` / `apps/admin-web/vite.config.ts` 的 `chunkSizeWarningLimit`,不要先按 Rust 编译失败排查。
|
||||
|
||||
### Gitea Actions PR 门禁
|
||||
|
||||
仓库级 Gitea Actions 工作流固定为 `.gitea/workflows/project-ci.yml`,在向 `master` 或 `codex/ai-game-creator-app` 推送、创建或更新 PR,以及手工触发时运行。工作流拆成四个必须通过的 job:
|
||||
|
||||
- `Repository checks`:执行 `npm run lint`、主站与后台生产构建、内容数据检查和提交差异空白检查。
|
||||
- `Frontend tests`:独立执行根 `npm run test`,让 Vitest 文件数和测试数在 Gitea job 列表中明确可见。
|
||||
- `Backend tests`:执行 `npm run check:server-rs-ddd`、`cargo test --locked --workspace --no-fail-fast`、`api-server --all-targets` 编译和 `spacetime-module` 编译;runner 安装 `ffmpeg`,避免视频抽帧测试因工具缺失提前返回。依赖真实服务或密钥的测试必须显式 `ignored`,不能让普通 PR job访问现场环境。
|
||||
- `Native shell tests`:独立执行 `npm run check:native-shells`,覆盖微信壳、Expo 和 Tauri 的完整验收,并确认 Tauri `Cargo.lock` 没有被构建过程改写,避免把重型原生壳或依赖锁漂移隐藏在基础检查末尾。`codex/ai-game-creator-app` 分支的同名脚本还会执行 `npm run ai-game-creator-shell:check` 和 AI 游戏创作壳 release build smoke。
|
||||
|
||||
四个 job 合起来覆盖根 `npm run check`,并补齐根检查没有包含的 server-rs DDD、正式 workspace Rust 测试与现役后端编译门禁。普通 PR CI 不注入业务密钥,不启动真实 API、SpacetimeDB、OSS、支付、图片生成或生产 live smoke;需要现场环境、可变外部状态、Docker 编排或发布凭据的 `check:*` 继续按对应专题和 Jenkins 发布流程执行,不能遍历所有同名前缀脚本冒充 PR 门禁。
|
||||
|
||||
PR checkout 必须保留完整 Git 历史,并把 PR base SHA 传给 `SPACETIME_SCHEMA_BASE_REF`。`check:spacetime-schema` 依赖该基线识别已有表字段删除、改名、重排和改类型;事件给出的基线缺失或本地不可解析时必须直接失败,不能退化为空差异检查。Gitea 的 PR checkout 是 PR head,不是与目标分支的预合并 commit,因此 workflow 还会验证 PR head 包含事件中的最新 base commit;分支保护必须继续开启“PR 过期禁止合并”,过期分支先更新再重跑。向 `master` 直接推送时使用 push before SHA,手工触发时回退到 `origin/master`。
|
||||
|
||||
启用或注册执行 PR job 的 runner 前,Gitea 服务端必须至少升级到 `1.26.4`;不得在 `1.26.2` 上执行不受信任 PR 代码。runner 必须提供 `ubuntu-latest` 标签,并将其映射到经验证且固定 digest 的 Ubuntu 24.04 级 Docker/临时隔离镜像;禁止使用浮动镜像 tag,禁止将该标签映射到 host 执行器,禁止向 job 暴露 Docker socket、业务环境变量、业务密钥或不必要的内网。workflow 会安装 Node 22、仓库 `rust-toolchain.toml` 固定的 Rust 1.96.0,以及 clang/lld 和 Tauri Linux 依赖;受控 runner 镜像应预装 rustup,fallback 下载只用于首次引导。runner 仍需能访问 Gitea、GitHub Actions 与 `actions/node-versions`、nodejs.org、npm registry、Rust 分发和 crates.io。workflow 中的 `actions/checkout` / `actions/setup-node` 固定到完整 commit;内网 runner 不允许访问 GitHub 时,先把对应 commit 镜像到当前 Gitea 并把 workflow 改为绝对 action URL。首版不使用 Actions cache,避免未配置 runner cache 网络时把缓存恢复错误变成 PR 失败。Gitea 1.26 不执行 workflow 的 `timeout-minutes`,任务最长运行时间在 runner 全局配置收口,不能只在 YAML 写一个不会生效的超时值。
|
||||
|
||||
workflow 首次成功运行后,在 Gitea `master` 分支保护中把 `Project CI / Repository checks (pull_request)`、`Project CI / Frontend tests (pull_request)`、`Project CI / Backend tests (pull_request)`、`Project CI / Native shell tests (pull_request)` 四个完整 context 都设为合并必需检查,并从最近一周已上报 context 表复核名称后再保存。不能只填裸 job 名,否则无法匹配 Gitea 实际上报的 `<workflow> / <job> (<event>)`。只提交 workflow 文件不会自动创建 runner,也不会自动修改分支保护;如果 Actions 长时间停留在等待状态,先到仓库或组织的 Actions runner 页面确认存在在线、带 `ubuntu-latest` 标签的 runner。
|
||||
|
||||
视觉小说负向扫描与验收门禁:
|
||||
|
||||
```bash
|
||||
@@ -622,7 +639,7 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日
|
||||
- debug exporter / Rider 转发都会同时接收 traces、metrics 和 logs。
|
||||
- api-server 会随 metrics 发送进程级指标:`process.memory.usage`、`process.memory.virtual`、`process.cpu.time`、`genarrative.process.cpu.usage_percent`、`process.thread.count`、`genarrative.process.memory.private`;Windows 额外发送 `process.windows.handle.count`,Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。
|
||||
- HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight` 与 `genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。
|
||||
- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出结构化日志字段,字段包括 provider、endpoint、failure_stage、status、source、source_chain、source_chain_depth、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model、request_params 和 raw_excerpt;图片编辑请求参数日志还会带 reference_image_bytes_total,并在 request_params.referenceImages 中记录每个 multipart `image` part 的 fileName、mimeType 和 bytes,不记录 API key 或原始图片 bytes;`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id` 和 `metadata_json.userId` / `metadata_json.profileId` / `metadata_json.requestId` / `metadata_json.errorSource` 中记录触发者、草稿 / 作品作用域、请求标识和传输错误链。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId,最后结合 request 日志、errorSource 和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。
|
||||
- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine 图片生成 / 编辑失败由 `platform-image` provider 输出结构化日志字段,字段包括 provider、endpoint、failure_stage、status、source、source_chain、source_chain_depth、timeout、retryable、latency_ms、prompt_chars、reference_image_count、实际 provider `image_model`、request_params 和 raw_excerpt;发生模型回退时另带 `fallback_from_model` / `fallback_to_model`。图片编辑请求参数日志还会带 reference_image_bytes_total,并在 request_params.referenceImages 中记录每个 multipart `image` part 的 fileName、mimeType 和 bytes,不记录 API key 或原始图片 bytes;`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id` 和 `metadata_json.userId` / `metadata_json.profileId` / `metadata_json.requestId` / `metadata_json.errorSource` 中记录触发者、草稿 / 作品作用域、请求标识和传输错误链。排障时先按 provider / failureStage / imageModel 聚合,再下钻 userId / profileId,最后结合 request 日志、errorSource 和上游响应 excerpt 判断是模型不可用、限流、超时、解析失败还是未返回图片。
|
||||
- OSS 平台适配器也输出结构化日志,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。排查资产签名、上传或确认失败时,先按 `provider=aliyun-oss` 与 `operation` 过滤,再看 `object_key` / `key_prefix`、`status`、`status_class`、`error_kind`、`content_length`、`content_type` 和 `elapsed_ms`;角色动画逐帧额外按 `frame_index`、`operation=source_put|final_put|final_head`、`attempt/max_attempts`、`will_retry`、`oss_code` 和 `oss_request_id` 对齐同一对象的请求尝试。`请求 OSS 失败` 时,`timeout/connect/transport=true` 表示传输类失败,OSS PutObject 的 `status=400, oss_code=RequestTimeout, timeout=true`、`status=429` 或 `500–599` 表示暂时性失败,PUT 的 `status=400`、`oss_code` 为空且 `timeout=true` 或 `transport=true`(message 含「错误响应体读取失败」,即 400 错误体读取超时/断流)也会重试;除这两类例外外,其他 400、401/403/404、配置、URL 和签名错误是确定性失败,不会重试。最终帧 HEAD 失败只会重试 HEAD,不会重复 PUT。日志不得包含 AccessKey、policy、signature、Authorization header、完整 signed URL 或 OSS 错误响应体;`oss_request_id` 只用于关联 OSS 服务端排障。排查 generated 图片重复下载时,先确认前端输入是否为 `/generated-*` legacy path 或可归一化的 `https://*.oss-*.aliyuncs.com/generated-*`;正确链路应先调 `/api/assets/read-url`,再由浏览器请求 signed URL,且同一路径、同一 `refreshKey` 版本和未临近过期的 signed URL 应复用。新上传 generated 私有对象应带 `Cache-Control: public, max-age=31536000, immutable`;旧对象若只有 `ETag` / `Last-Modified`,浏览器会走 304 协商缓存而不是长期强缓存,可通过刷新 OSS 元数据或 CDN 配置补齐。
|
||||
- SpacetimeDB 观测分为两类:procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*`。`read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。
|
||||
- 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。
|
||||
|
||||
@@ -95,12 +95,12 @@
|
||||
## LLM 与计费
|
||||
|
||||
- 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),画布 Agent 规划请求固定使用 VectorEngine `gpt-5.4-mini` Chat Completions;function-calling 注册八类工具。
|
||||
- 每个用户回合必须由 LLM 返回结构化计划;LLM 未配置、请求失败或返回格式不可解析时,后端写入正文为 `ERROR <错误内容>` 的 system 消息,不使用本地关键词或“收到:...”回显兜底。该错误消息与其它 system 消息一样进入后续 LLM memory,使 Agent 能看到上一轮失败上下文。
|
||||
- 每个用户回合必须由 LLM 返回结构化计划;LLM 未配置、连接已经断开、请求明确失败、达到最终安全上限或返回格式不可解析时,后端写入正文为 `ERROR <错误内容>` 的 system 消息,不使用本地关键词或“收到:...”回显兜底。面向用户的规划错误使用中文语义,不暴露 `completion error` 等 framework 内部前缀或原始配置/定价诊断;原始错误只记录在后端日志。该错误消息与其它 system 消息一样进入后续 LLM memory,使 Agent 能看到上一轮失败上下文。普通 JSON POST 尚未结束只表示 provider request future 仍在等待,不能伪装成已持久化失败。
|
||||
- 规划 prompt 必须自动带入上一条已完成生成结果的 `latestGeneratedImage` 引用,内容只包含上一轮 generation 的 `toolName` / `resourceId` / `objectKey` / `assetObjectId` 等轻量元数据,不把私有签名 URL 或大图内容塞进 prompt。
|
||||
- 工具参数中的图片 ID 是由真实 object key 或图片地址计算的稳定 SHA-256 标识;真实 data key 仅存于 api-server 的工具上下文映射,所有图片工具在执行时查表恢复,不能把 object key 或图片地址作为 LLM 可见的工具 ID。
|
||||
- 用户使用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代或编辑上一张结果图时,LLM 默认选择 `edit_image` 并引用 `latestGeneratedImage` 作为源图;除非用户明确要求全新生成,否则不能因为本轮没有重新上传附件而降级为 `generate_image`。
|
||||
- 规划 prompt 必须显式区分“规范展板”和“实际素材产出”:规范图、视觉规范图、风格规范图、素材规范展板、角色规范图等规范展板请求走 `generate_image`,并补齐统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等要求;实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`。
|
||||
- 画布 Agent 规划请求使用 Chat Completions、1024 `max_tokens` 和 60 秒 Agent 专用请求超时;生成图片/编辑图片仍走对应生成工具和模型计费。
|
||||
- 画布 Agent 规划请求使用 Chat Completions 和 1024 `max_tokens`。发送后 120 秒是前端软提示阈值,不是 provider 失败 deadline:若普通 JSON POST 仍 pending,消息流临时显示“仍在处理中,请耐心等待”并继续等待,提示不写入 OSS 消息历史;连接或请求明确失败则立即按正式错误收口。provider 单 attempt 保留 8 分钟 hard timeout;请求发起阶段的 timeout、连接失败、`408`、`429` 与 `5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,专用重试退避最多 60 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时使用扣除会话锁和上下文准备后的剩余预算;该 deadline 覆盖非法 JSON/工具校验失败触发的后续规划轮,并为错误持久化和 HTTP 返回保留约 2 分钟,不再让前端 20 分钟 transport timeout 先触发。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,错误计数/日志使用该响应所属的真实 attempt。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。
|
||||
- function-calling runner 必须把“等待用户确认”作为显式工具语义:当本批所有工具都校验成功并进入待确认状态时,立即以成功结果结束当前规划回合并持久化助手文本与待确认卡,不得继续依赖 LLM 自行停止;未知工具、参数错误、普通连续工具和不可解析响应仍受 `max_turns` 保护。
|
||||
- **对话回合免费**(聊天、分析回复不扣泥点),仅 Agent 实际触发生成工具时按对应模型定价扣泥点。
|
||||
- 工具调用前后端校验泥点余额;不足时该次生成失败并在对话中以明确错误气泡告知,对话本身可继续。
|
||||
@@ -115,7 +115,7 @@
|
||||
4. 消息内生成结果缩略图(纯预览,不显示名称,不点击聚焦图层);
|
||||
5. 生成中的进行中动画;
|
||||
6. 错误气泡(失败/余额不足,带原因);
|
||||
7. 普通消息请求等待期间禁用发送按钮,不提供客户端停止操作;前端持续等待后端响应,避免后端已持久化消息但前端中断请求后产生会话状态错位。
|
||||
7. 普通消息请求等待期间禁用发送按钮,不提供客户端停止操作;前端持续等待后端响应,超过 120 秒但 POST 仍 pending 时在思考气泡中显示“仍在处理中,请耐心等待”,最终成功或失败后自动移除,避免后端已持久化消息但前端中断请求后产生会话状态错位。
|
||||
|
||||
不做(明确排除,防止后人补齐):
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
use std::future::IntoFuture;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::{Extension, Json};
|
||||
use module_editor_agent::{
|
||||
@@ -75,8 +78,13 @@ use platform_editor_agent::agent::tools::generate_video::{
|
||||
GenerateVideoTool, GenerateVideoToolArgs,
|
||||
};
|
||||
use shared_kernel::{build_prefixed_uuid_id, normalize_optional_string, normalize_required_string};
|
||||
use tokio::time::{Instant, timeout};
|
||||
|
||||
const EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS: usize = 128;
|
||||
const EDITOR_AGENT_PROMPT_TIMEOUT_MS: u64 = 18 * 60_000;
|
||||
const EDITOR_AGENT_PROMPT_TIMEOUT_MESSAGE: &str = "规划总时长已达到 18 分钟安全上限";
|
||||
const EDITOR_AGENT_LLM_UNAVAILABLE_MESSAGE: &str = "美术 Agent 服务暂不可用,请稍后重试";
|
||||
const EDITOR_AGENT_PRICING_UNAVAILABLE_MESSAGE: &str = "美术 Agent 生成定价暂不可用,请稍后重试";
|
||||
|
||||
pub async fn editor_agent_message(
|
||||
State(state): State<AppState>,
|
||||
@@ -85,6 +93,7 @@ pub async fn editor_agent_message(
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<EditorAgentMessageRequest>,
|
||||
) -> Result<Json<EditorAgentMessageResponse>, AppError> {
|
||||
let message_started_at = Instant::now();
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?;
|
||||
let client_message_id = validate_editor_agent_message_request(&payload)?;
|
||||
@@ -211,12 +220,16 @@ pub async fn editor_agent_message(
|
||||
|
||||
// Build and run agent
|
||||
let Some(llm_client) = state.editor_agent_llm_client() else {
|
||||
tracing::warn!(
|
||||
conversation_id = %conversation.conversation_id,
|
||||
"美术 Agent LLM 客户端未配置"
|
||||
);
|
||||
return persist_editor_agent_planning_error(
|
||||
&state,
|
||||
&conversation,
|
||||
&mut document,
|
||||
conversation_summary,
|
||||
"Editor Agent LLM client not configured",
|
||||
EDITOR_AGENT_LLM_UNAVAILABLE_MESSAGE,
|
||||
)
|
||||
.await;
|
||||
};
|
||||
@@ -224,12 +237,17 @@ pub async fn editor_agent_message(
|
||||
let pricing = match state.editor_generation_pricing().await {
|
||||
Ok(pricing) => pricing,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
conversation_id = %conversation.conversation_id,
|
||||
error = %error,
|
||||
"读取美术 Agent 生成定价失败"
|
||||
);
|
||||
return persist_editor_agent_planning_error(
|
||||
&state,
|
||||
&conversation,
|
||||
&mut document,
|
||||
conversation_summary,
|
||||
format!("failed to load editor generation pricing: {error}"),
|
||||
EDITOR_AGENT_PRICING_UNAVAILABLE_MESSAGE,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -264,8 +282,12 @@ pub async fn editor_agent_message(
|
||||
.memory(memory)
|
||||
.build();
|
||||
|
||||
let agent_result = agent
|
||||
.prompt(LlmMessage::user(user_message.text.clone()))
|
||||
let remaining_prompt_duration =
|
||||
remaining_editor_agent_prompt_duration(message_started_at.elapsed());
|
||||
let agent_result = run_editor_agent_prompt_with_timeout(
|
||||
agent.prompt(LlmMessage::user(user_message.text.clone())),
|
||||
remaining_prompt_duration,
|
||||
)
|
||||
.await;
|
||||
|
||||
let assistant_now = now_rfc3339();
|
||||
@@ -303,6 +325,26 @@ pub async fn editor_agent_message(
|
||||
}
|
||||
}
|
||||
|
||||
fn remaining_editor_agent_prompt_duration(elapsed: Duration) -> Duration {
|
||||
Duration::from_millis(EDITOR_AGENT_PROMPT_TIMEOUT_MS).saturating_sub(elapsed)
|
||||
}
|
||||
|
||||
async fn run_editor_agent_prompt_with_timeout<F>(
|
||||
future: F,
|
||||
duration: Duration,
|
||||
) -> Result<Vec<PromptOutput>, PromptError>
|
||||
where
|
||||
F: IntoFuture<Output = Result<Vec<PromptOutput>, PromptError>>,
|
||||
{
|
||||
timeout(duration, future.into_future())
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
Err(PromptError::CompletionError(
|
||||
EDITOR_AGENT_PROMPT_TIMEOUT_MESSAGE.to_string(),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn build_editor_agent_error_message(
|
||||
message_id: usize,
|
||||
error: impl std::fmt::Display,
|
||||
@@ -512,6 +554,42 @@ mod tests {
|
||||
assert_eq!(message.text, "ERROR planning failed");
|
||||
assert!(message.tool_call.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_planning_failures_use_user_facing_chinese_copy() {
|
||||
assert_eq!(
|
||||
build_editor_agent_error_message(1, EDITOR_AGENT_LLM_UNAVAILABLE_MESSAGE).text,
|
||||
"ERROR 美术 Agent 服务暂不可用,请稍后重试"
|
||||
);
|
||||
assert_eq!(
|
||||
build_editor_agent_error_message(2, EDITOR_AGENT_PRICING_UNAVAILABLE_MESSAGE).text,
|
||||
"ERROR 美术 Agent 生成定价暂不可用,请稍后重试"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prompt_timeout_applies_to_the_whole_agent_run() {
|
||||
let error = run_editor_agent_prompt_with_timeout(
|
||||
std::future::pending::<Result<Vec<PromptOutput>, PromptError>>(),
|
||||
Duration::from_millis(1),
|
||||
)
|
||||
.await
|
||||
.expect_err("pending agent run should hit the prompt deadline");
|
||||
|
||||
assert_eq!(EDITOR_AGENT_PROMPT_TIMEOUT_MS, 1_080_000);
|
||||
assert_eq!(
|
||||
remaining_editor_agent_prompt_duration(Duration::from_secs(17 * 60)),
|
||||
Duration::from_secs(60)
|
||||
);
|
||||
assert_eq!(
|
||||
remaining_editor_agent_prompt_duration(Duration::from_secs(18 * 60)),
|
||||
Duration::ZERO
|
||||
);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"美术 Agent 规划失败:规划总时长已达到 18 分钟安全上限"
|
||||
);
|
||||
}
|
||||
}
|
||||
fn editor_agent_system_prompt() -> &'static str {
|
||||
r#"
|
||||
|
||||
@@ -1702,8 +1702,8 @@ mod tests {
|
||||
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
|
||||
.expect("worker 结果应是合法 JSON");
|
||||
|
||||
assert_eq!(payload["sourceModule"], json!("puzzle"));
|
||||
assert_eq!(payload["sourceEntityId"], json!("session-1:puzzle-level-1"));
|
||||
assert_eq!(payload["sourceModule"], json!("editor"));
|
||||
assert_eq!(payload["sourceEntityId"], json!("project-1"));
|
||||
assert_eq!(
|
||||
payload["warning"],
|
||||
json!({
|
||||
|
||||
@@ -434,6 +434,9 @@ async fn map_platform_image_result(
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
match result {
|
||||
Ok(value) => {
|
||||
for audit in &value.recovered_failure_audits {
|
||||
record_openai_image_failure_audit_if_configured(settings, audit).await;
|
||||
}
|
||||
if let Some(state) = settings.external_api_audit_state.as_ref() {
|
||||
record_external_generation_run_after_success(
|
||||
state,
|
||||
@@ -448,6 +451,7 @@ async fn map_platform_image_result(
|
||||
Some(json!({
|
||||
"imageCount": value.images.len(),
|
||||
"actualPromptChars": value.actual_prompt.as_ref().map(|prompt| prompt.chars().count()),
|
||||
"recoveredFailureCount": value.recovered_failure_audits.len(),
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
@@ -455,6 +459,9 @@ async fn map_platform_image_result(
|
||||
Ok(value)
|
||||
}
|
||||
Err(error) => {
|
||||
for audit in error.recovered_failure_audits() {
|
||||
record_openai_image_failure_audit_if_configured(settings, audit).await;
|
||||
}
|
||||
if let Some(state) = settings.external_api_audit_state.as_ref() {
|
||||
record_external_generation_run_after_success(
|
||||
state,
|
||||
@@ -478,14 +485,21 @@ async fn map_platform_image_result(
|
||||
pub(crate) async fn record_openai_image_failure_if_configured(
|
||||
settings: &OpenAiImageSettings,
|
||||
error: &PlatformImageError,
|
||||
) {
|
||||
let Some(audit) = error.audit() else {
|
||||
return;
|
||||
};
|
||||
record_openai_image_failure_audit_if_configured(settings, audit).await;
|
||||
}
|
||||
|
||||
async fn record_openai_image_failure_audit_if_configured(
|
||||
settings: &OpenAiImageSettings,
|
||||
audit: &platform_image::PlatformImageFailureAudit,
|
||||
) {
|
||||
let Some(state) = settings.external_api_audit_state.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(draft) = build_openai_image_failure_audit_draft(error) else {
|
||||
return;
|
||||
};
|
||||
let draft = draft
|
||||
let draft = build_external_api_failure_draft_from_platform_image_audit(audit)
|
||||
.with_user_id(settings.external_api_audit_user_id.clone())
|
||||
.with_profile_id(settings.external_api_audit_profile_id.clone())
|
||||
.with_request_id(settings.external_api_audit_request_id.clone());
|
||||
@@ -501,6 +515,7 @@ pub(crate) fn build_openai_image_failure_audit_draft(
|
||||
}
|
||||
|
||||
pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError {
|
||||
let error = error.into_final_error();
|
||||
let status = match error.status_hint() {
|
||||
PlatformImageStatusHint::BadRequest => StatusCode::BAD_REQUEST,
|
||||
PlatformImageStatusHint::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE,
|
||||
@@ -545,6 +560,9 @@ pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError {
|
||||
details["rawExcerpt"] = json!(raw_excerpt);
|
||||
}
|
||||
PlatformImageError::MissingImage { .. } => {}
|
||||
PlatformImageError::FallbackFailed { .. } => {
|
||||
unreachable!("fallback wrapper should be removed before HTTP error mapping")
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(audit) = error.audit() {
|
||||
|
||||
@@ -2682,7 +2682,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_play_stats_requires_authentication() {
|
||||
async fn retired_profile_play_stats_route_is_not_mounted() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
@@ -2696,7 +2696,7 @@ mod tests {
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -48,6 +48,8 @@ use crate::work_author::{
|
||||
};
|
||||
|
||||
const ADMIN_ROLE: &str = "admin";
|
||||
const EDITOR_AGENT_LLM_MAX_RETRIES: u32 = 1;
|
||||
const EDITOR_AGENT_LLM_MAX_RETRY_BACKOFF_MS: u64 = 60_000;
|
||||
pub(crate) const CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY: usize = 8;
|
||||
pub(crate) const BGFILTER_IMAGE_VALIDATION_MAX_CONCURRENCY: usize = 4;
|
||||
|
||||
@@ -2128,8 +2130,10 @@ fn build_editor_agent_llm_client(
|
||||
api_key.to_string(),
|
||||
platform_llm::EDITOR_AGENT_GPT5_MODEL.to_string(),
|
||||
config.llm_request_timeout_ms,
|
||||
0,
|
||||
config.llm_retry_backoff_ms,
|
||||
config.llm_max_retries.min(EDITOR_AGENT_LLM_MAX_RETRIES),
|
||||
config
|
||||
.llm_retry_backoff_ms
|
||||
.min(EDITOR_AGENT_LLM_MAX_RETRY_BACKOFF_MS),
|
||||
)?;
|
||||
|
||||
Ok(Some(LlmClient::new(llm_config)?))
|
||||
@@ -2422,6 +2426,8 @@ mod tests {
|
||||
fn app_state_builds_editor_agent_llm_client_from_vector_engine_settings() {
|
||||
let mut config = AppConfig::default();
|
||||
config.llm_api_key = None;
|
||||
config.llm_max_retries = 2;
|
||||
config.llm_retry_backoff_ms = 120_000;
|
||||
config.vector_engine_base_url = "https://api.vectorengine.test".to_string();
|
||||
config.vector_engine_api_key = Some("ve-key".to_string());
|
||||
|
||||
@@ -2439,6 +2445,8 @@ mod tests {
|
||||
"https://api.vectorengine.test/v1/chat/completions"
|
||||
);
|
||||
assert!(!client.config().official_fallback());
|
||||
assert_eq!(client.config().max_retries(), 1);
|
||||
assert_eq!(client.config().retry_backoff_ms(), 60_000);
|
||||
}
|
||||
|
||||
fn test_feature_gate(gate_key: &str) -> module_runtime::FeatureGateConfigSnapshot {
|
||||
|
||||
@@ -8,7 +8,7 @@ use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmClient, LlmMessage, LlmTextReques
|
||||
use serde_json::Value;
|
||||
|
||||
const EDITOR_AGENT_LLM_MAX_OUTPUT_TOKENS: u32 = 1024;
|
||||
const EDITOR_AGENT_LLM_REQUEST_TIMEOUT_MS: u64 = 60_000;
|
||||
const EDITOR_AGENT_LLM_HARD_REQUEST_TIMEOUT_MS: u64 = 480_000;
|
||||
|
||||
pub struct LlmCompletionModel {
|
||||
client: LlmClient,
|
||||
@@ -41,7 +41,7 @@ fn build_editor_agent_llm_request(messages: Vec<LlmMessage>) -> LlmTextRequest {
|
||||
LlmTextRequest::new(messages)
|
||||
.with_model(EDITOR_AGENT_GPT5_MODEL)
|
||||
.with_max_tokens(EDITOR_AGENT_LLM_MAX_OUTPUT_TOKENS)
|
||||
.with_request_timeout_ms(EDITOR_AGENT_LLM_REQUEST_TIMEOUT_MS)
|
||||
.with_request_timeout_ms(EDITOR_AGENT_LLM_HARD_REQUEST_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
pub struct LlmChatAgentBuilder {
|
||||
@@ -188,7 +188,7 @@ mod tests {
|
||||
|
||||
assert_eq!(request.model.as_deref(), Some(EDITOR_AGENT_GPT5_MODEL));
|
||||
assert_eq!(request.max_tokens, Some(1024));
|
||||
assert_eq!(request.request_timeout_ms, Some(60_000));
|
||||
assert_eq!(request.request_timeout_ms, Some(480_000));
|
||||
assert_eq!(request.messages.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,45 @@ pub enum PromptError {
|
||||
impl std::fmt::Display for PromptError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::CompletionError(msg) => write!(f, "completion error: {msg}"),
|
||||
Self::ToolError(msg) => write!(f, "tool error: {msg}"),
|
||||
Self::InternalError(msg) => write!(f, "internal error: {msg}"),
|
||||
Self::CompletionError(msg) => write!(f, "美术 Agent 规划失败:{msg}"),
|
||||
Self::ToolError(msg) => write!(f, "美术 Agent 工具执行失败:{msg}"),
|
||||
Self::InternalError(msg) => write!(f, "美术 Agent 内部错误:{msg}"),
|
||||
Self::MaxTurnsReached { max_turns } => {
|
||||
write!(f, "max turns reached: {max_turns}")
|
||||
write!(f, "美术 Agent 规划轮数已达上限:{max_turns}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PromptError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn completion_error_uses_user_facing_chinese_copy() {
|
||||
let error = PromptError::CompletionError("LLM 请求超时,累计尝试 2 次".to_string());
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"美术 Agent 规划失败:LLM 请求超时,累计尝试 2 次"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_errors_do_not_expose_framework_prefixes() {
|
||||
assert_eq!(
|
||||
PromptError::ToolError("参数无效".to_string()).to_string(),
|
||||
"美术 Agent 工具执行失败:参数无效"
|
||||
);
|
||||
assert_eq!(
|
||||
PromptError::InternalError("序列化失败".to_string()).to_string(),
|
||||
"美术 Agent 内部错误:序列化失败"
|
||||
);
|
||||
assert_eq!(
|
||||
PromptError::MaxTurnsReached { max_turns: 3 }.to_string(),
|
||||
"美术 Agent 规划轮数已达上限:3"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ pub mod generated_assets;
|
||||
pub mod vector_engine;
|
||||
|
||||
pub use vector_engine::{
|
||||
DownloadedImage, GPT_IMAGE_2_MODEL, GeneratedImages, PlatformImageError,
|
||||
DownloadedImage, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, GeneratedImages, PlatformImageError,
|
||||
PlatformImageFailureAudit, PlatformImageStatusHint, ReferenceImage,
|
||||
VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings,
|
||||
build_vector_engine_image_http_client, build_vector_engine_image_request_body,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::constants::{VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER};
|
||||
use super::constants::VECTOR_ENGINE_PROVIDER;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PlatformImageFailureAudit {
|
||||
@@ -33,6 +33,7 @@ pub(crate) fn build_failure_audit(
|
||||
latency_ms: Option<u64>,
|
||||
prompt_chars: Option<usize>,
|
||||
reference_image_count: Option<usize>,
|
||||
image_model: Option<&'static str>,
|
||||
) -> PlatformImageFailureAudit {
|
||||
PlatformImageFailureAudit {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
@@ -49,7 +50,7 @@ pub(crate) fn build_failure_audit(
|
||||
latency_ms,
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
image_model: Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL),
|
||||
image_model,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ fn retry_delay_fits_request_deadline_at(
|
||||
pub(crate) fn request_budget_exhausted_error(
|
||||
request_url: &str,
|
||||
operation: &str,
|
||||
image_model: Option<&'static str>,
|
||||
latency_ms: Option<u64>,
|
||||
prompt_chars: Option<usize>,
|
||||
reference_image_count: Option<usize>,
|
||||
@@ -73,6 +74,7 @@ pub(crate) fn request_budget_exhausted_error(
|
||||
latency_ms,
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
image_model,
|
||||
);
|
||||
tracing::warn!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
@@ -82,6 +84,7 @@ pub(crate) fn request_budget_exhausted_error(
|
||||
elapsed_ms = latency_ms,
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
image_model,
|
||||
operation,
|
||||
"VectorEngine 图片请求执行预算已耗尽"
|
||||
);
|
||||
@@ -162,6 +165,7 @@ mod tests {
|
||||
let error = request_budget_exhausted_error(
|
||||
"https://vector.example/v1/images/generations",
|
||||
"生成图片失败",
|
||||
None,
|
||||
Some(900),
|
||||
Some(12),
|
||||
Some(1),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
pub const GPT_IMAGE_2_MODEL: &str = "gpt-image-2";
|
||||
pub const GPT_IMAGE_2_C_MODEL: &str = "gpt-image-2-c";
|
||||
pub const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL;
|
||||
pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
|
||||
|
||||
@@ -148,6 +148,7 @@ pub(crate) fn map_curl_error(
|
||||
context: &str,
|
||||
request_url: &str,
|
||||
failure_stage: &'static str,
|
||||
image_model: Option<&'static str>,
|
||||
error: VectorEngineCurlError,
|
||||
latency_ms: u64,
|
||||
prompt_chars: Option<usize>,
|
||||
@@ -172,6 +173,7 @@ pub(crate) fn map_curl_error(
|
||||
Some(latency_ms),
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
image_model,
|
||||
);
|
||||
tracing::warn!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
@@ -189,6 +191,7 @@ pub(crate) fn map_curl_error(
|
||||
elapsed_ms = latency_ms,
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
image_model,
|
||||
request_params = %request_params
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_default(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user