Compare commits

...

4 Commits

Author SHA1 Message Date
k88936 9426ac3a16 实现 Raw GPT Image 2 图片编辑代理
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 2m42s
Project CI / Native shell tests (pull_request) Successful in 18m59s
新增受 Bearer 保护的 /api/raw/v1/images/edit JSON 路由

按单图 image 与可选 mask 转发 GPT Image 2 参数

固定 model 与 n 并返回仅含 data[].b64_json 的响应

复用钱包计费与退款边界并补充单元测试
2026-09-08 16:26:58 +08:00
k88936 e999602f66 补充UI编辑器自动分离工作流合同
新增自动分离、临时 sidecar 与视觉绑定设计合同

明确前端资产登记职责与恢复 TODO
2026-09-08 15:12:56 +08:00
suzmii e15e3b455f 修复AGC大上下文请求体限制 (#295)
Project CI / Repository checks (push) Successful in 3m12s
Project CI / Frontend tests (push) Successful in 3m21s
Project CI / Backend tests (push) Successful in 7m42s
Project CI / Native shell tests (push) Successful in 20m14s
Project CI / Repository checks (pull_request) Successful in 20m21s
Project CI / Backend tests (pull_request) Successful in 6m18s
Project CI / Native shell tests (pull_request) Successful in 18m19s
Project CI / Frontend tests (pull_request) Successful in 50m43s
## 背景与问题

AGC(AI 游戏创作智能体 App)Direct Codex 模式在携带图片工具结果等大上下文调用 LLM 时,`/api/llm/responses` 与 `/api/llm/chat/completions` 会命中 Axum 默认 **2 MiB** 请求体上限,直接被 413 拒绝,大上下文任务无法执行;同时 Codex app-server 的 failed turn 未把上游 413 映射为稳定错误分类,前端只能显示笼统的“其他错误”,无法引导用户处理。

## 改动内容

**1. api-server(server-rs)**
- 新增常量 `LLM_REQUEST_MAX_BODY_BYTES = 32 MiB`,作为两个 LLM 代理路由的正式请求体上限;
- 两个路由显式配置 `DefaultBodyLimit::max(32 MiB)`,避免 Axum 默认 2 MiB 提前拒绝;handler 内保留超限检查,超过 32 MiB 仍返回 `413 PAYLOAD_TOO_LARGE`;
- 补充回归测试:>2 MiB 大上下文请求不再命中默认限制;超过 32 MiB 仍返回 413。

**2. AGC 壳(apps/ai-game-creator-shell)**
- `codex_app_server` 错误映射:上游/连接层 HTTP 413、`PAYLOAD_TOO_LARGE`、`provider request too large` 等统一映射为稳定分类 `codex-app-server-error:request-too-large`,不再落入 `other` 或误判为权限/安全策略错误;
- 前端新增用户可见文案“模型请求体过大,请减少参考图或上下文后重试”,并补充单测断言。

**3. 文档**
- 【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md 补充“2026-09-06 AGC LLM 代理请求体合同”:明确 32 MiB 上限、必须显式配置 `DefaultBodyLimit`、413 映射规则与用户文案。

## 验证

- `cargo test -p api-server llm_routes_accept_large_context_bodies_beyond_axum_default` 通过
- `cargo test -p api-server llm_responses_rejects_bodies_above_explicit_limit` 通过
- `cargo test ... ai-game-creator-shell ... codex_app_server_failed_turn_maps_request_too_large_details` 通过
- `npm run test -- apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts`(27 通过)

全量 workspace 测试与 CI 门禁待推送后由 CI 覆盖。

Reviewed-on: #295
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: 董羽秦 <suzmii@qq.com>
Co-committed-by: 董羽秦 <suzmii@qq.com>
2026-09-07 17:05:42 +08:00
k88936 964290c641 优化错误报告 (#286)
Project CI / Repository checks (push) Successful in 2m37s
Project CI / Frontend tests (push) Successful in 2m41s
Project CI / Backend tests (push) Successful in 7m2s
Project CI / Native shell tests (push) Successful in 21m3s
样式

before:
![shotmd-1788605870.jpg](/attachments/0470de00-944f-466e-a339-cf9e6cc0b780)
![shotmd-1788605755.jpg](/attachments/3e7be68e-31c5-41ba-9f8b-6dc98ec08868)

after:
![shotmd-1788607435.jpg](/attachments/165c878e-5c1c-4517-b7d5-74ce97a1a99b)
![shotmd-1788608364.jpg](/attachments/4dd20baf-879c-4aa8-ab51-bcc588d48dbc)

逻辑:
每次打开报告modal会再次查询队列中的错误
<video src="attachments/6d9d9bd7-5b7b-4a05-a7c3-ca64b6fb77f3" title="2026-09-05 19-46-02.mp4" controls></video>

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/286
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-09-07 15:17:21 +08:00
24 changed files with 844 additions and 62 deletions
@@ -266,6 +266,7 @@ fn game_creator_codex_app_server_connection_error(
) -> platform_llm::LlmError {
match game_creator_codex_app_server_error_http_status(info, field) {
Some(401 | 403) => game_creator_codex_app_server_error_kind("unauthorized"),
Some(413) => game_creator_codex_app_server_error_kind("request-too-large"),
Some(status_code) => platform_llm::LlmError::Upstream {
status_code,
message: "Codex app-server 连接上游失败".to_string(),
@@ -303,6 +304,30 @@ fn game_creator_codex_app_server_error_detail_indicates_auth_failure(
|| detail.contains("http 403")
}
fn game_creator_codex_app_server_error_detail_indicates_request_too_large(
error: &serde_json::Value,
) -> bool {
let Some(error) = error.as_object() else {
return false;
};
let detail = ["message", "additionalDetails", "code"]
.into_iter()
.filter_map(|field| error.get(field).and_then(serde_json::Value::as_str))
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase();
if detail.is_empty() {
return false;
}
detail.contains("413 payload too large")
|| detail.contains("http 413")
|| detail.contains("status 413")
|| detail.contains("payload_too_large")
|| detail.contains("payload too large")
|| detail.contains("request too large")
|| detail.contains("provider request too large")
}
fn game_creator_codex_app_server_error_detail_indicates_insufficient_mud_points(
error: &serde_json::Value,
) -> bool {
@@ -333,6 +358,9 @@ fn game_creator_codex_app_server_failed_turn_error(
message: "泥点余额不足".to_string(),
};
}
if game_creator_codex_app_server_error_detail_indicates_request_too_large(error) {
return game_creator_codex_app_server_error_kind("request-too-large");
}
if game_creator_codex_app_server_error_detail_indicates_auth_failure(error) {
return game_creator_codex_app_server_error_kind("unauthorized");
}
@@ -4787,6 +4815,12 @@ mod tests {
"codex-app-server-error:unauthorized".to_string(),
),
),
(
serde_json::json!({"httpConnectionFailed":{"httpStatusCode":413}}),
platform_llm::LlmError::InvalidRequest(
"codex-app-server-error:request-too-large".to_string(),
),
),
(
serde_json::json!({"httpConnectionFailed":{"httpStatusCode":429}}),
platform_llm::LlmError::Upstream {
@@ -4847,6 +4881,31 @@ mod tests {
}
}
#[test]
fn codex_app_server_failed_turn_maps_request_too_large_details() {
for detail in [
"HTTP 413 Payload Too Large",
"status 413",
"PAYLOAD_TOO_LARGE",
"provider request too large",
] {
let error = game_creator_codex_app_server_failed_turn_error(&serde_json::json!({
"status": "failed",
"error": {
"message": detail,
"additionalDetails": "private upstream diagnostics",
"codexErrorInfo": "other"
}
}));
assert_eq!(
error,
platform_llm::LlmError::InvalidRequest(
"codex-app-server-error:request-too-large".to_string(),
)
);
}
}
#[test]
fn codex_app_server_failed_turn_maps_insufficient_mud_points_to_stable_upstream_error() {
for detail in [
@@ -4,6 +4,7 @@ import {
ackClientErrorEventsWithRetry,
type ClientErrorEvent,
type DiagnosticLogFile,
getPendingClientErrorEvents,
getStableErrorReportSubmissionId,
readApplicationDiagnosticLogs,
submitErrorReportBatch,
@@ -12,10 +13,12 @@ import { ThemedModal } from '../modal/ThemedModal';
type ErrorReportDialogProps = {
open: boolean;
events: ClientErrorEvent[];
onClose: () => void;
fallbackEvents?: ClientErrorEvent[];
};
const emptyFallbackEvents: ClientErrorEvent[] = [];
function diagnosticLogLabel(
logsReady: boolean,
logsError: boolean,
@@ -28,9 +31,12 @@ function diagnosticLogLabel(
export function ErrorReportDialog({
open,
events,
onClose,
fallbackEvents = emptyFallbackEvents,
}: ErrorReportDialogProps) {
const [events, setEvents] = useState<ClientErrorEvent[]>([]);
const [eventsReady, setEventsReady] = useState(false);
const [eventsError, setEventsError] = useState(false);
const [selectedFingerprints, setSelectedFingerprints] = useState<Set<string>>(
new Set(),
);
@@ -45,29 +51,59 @@ export function ErrorReportDialog({
useEffect(() => {
if (!open) return;
setSelectedFingerprints(
new Set(events.slice(0, 1).map((event) => event.fingerprint)),
);
let disposed = false;
setEvents([]);
setEventsReady(false);
setEventsError(false);
setSelectedFingerprints(new Set());
setStatus('');
setLogsReady(false);
setLogsError(false);
void getPendingClientErrorEvents()
.then((nextEvents) => {
if (disposed) return;
setEvents(nextEvents);
setSelectedFingerprints(
new Set(nextEvents.map((event) => event.fingerprint)),
);
setEventsReady(true);
})
.catch(() => {
if (disposed) return;
if (fallbackEvents.length) {
setEvents(fallbackEvents);
setSelectedFingerprints(
new Set(fallbackEvents.map((event) => event.fingerprint)),
);
setEventsReady(true);
setStatus('最新错误读取失败,已使用打开通知时的快照');
return;
}
setEventsError(true);
setEventsReady(true);
});
void readApplicationDiagnosticLogs()
.then((nextLogs) => {
if (disposed) return;
setLogs(nextLogs);
setLogsReady(true);
})
.catch(() => {
if (disposed) return;
setLogs([]);
setLogsError(true);
setLogsReady(true);
});
}, [events, open]);
return () => {
disposed = true;
};
}, [fallbackEvents, open]);
const submit = useCallback(async () => {
const selectedEvents = events.filter((event) =>
selectedFingerprints.has(event.fingerprint),
);
if (!selectedEvents.length || busy || !logsReady) return;
if (!selectedEvents.length || busy || !eventsReady || !logsReady) return;
if (includeLogs && logsError) return;
setBusy(true);
setStatus('正在提交…');
@@ -92,6 +128,7 @@ export function ErrorReportDialog({
}, [
busy,
description,
eventsReady,
events,
includeLogs,
logs,
@@ -124,7 +161,11 @@ export function ErrorReportDialog({
<div className="my-5 grid gap-4">
<section>
<strong>错误事件({events.length})</strong>
{events.length ? (
{!eventsReady && <p>正在读取当前错误…</p>}
{eventsReady && eventsError && (
<p role="alert">错误事件暂不可用,请关闭后重试。</p>
)}
{eventsReady && !eventsError && events.length > 0 && (
<ul className="m-0 mt-2 grid list-none gap-2 p-0">
{events.map((event) => (
<li
@@ -154,7 +195,8 @@ export function ErrorReportDialog({
</li>
))}
</ul>
) : (
)}
{eventsReady && !eventsError && !events.length && (
<p>当前没有待报告的错误。</p>
)}
</section>
@@ -191,11 +233,12 @@ export function ErrorReportDialog({
取消
</button>
<button
className="cursor-pointer rounded-[9px] border-0 bg-[var(--platform-button-primary-fill)] px-3.5 py-[9px] text-[var(--platform-button-primary-text)]"
className="cursor-pointer rounded-[9px] border border-(--platform-button-primary-border) bg-(image:--platform-button-primary-fill) px-3.5 py-[9px] text-(--platform-button-primary-text)"
type="button"
onClick={() => void submit()}
disabled={
busy ||
!eventsReady ||
!selectedFingerprints.size ||
!logsReady ||
(includeLogs && logsError)
@@ -12,7 +12,10 @@ export function ErrorReportNotice() {
const [notificationEvents, setNotificationEvents] = useState<
ClientErrorEvent[]
>([]);
const [reportEvents, setReportEvents] = useState<ClientErrorEvent[]>([]);
const [reportFallbackEvents, setReportFallbackEvents] = useState<
ClientErrorEvent[]
>([]);
const [reportOpen, setReportOpen] = useState(false);
const requestId = useRef(0);
const retryTimer = useRef<number | null>(null);
@@ -65,31 +68,37 @@ export function ErrorReportNotice() {
}, [loadEvents]);
const openReport = () => {
setReportEvents(notificationEvents);
setReportFallbackEvents(notificationEvents);
setReportOpen(true);
setNotificationEvents([]);
};
const closeReport = () => {
setReportOpen(false);
setReportFallbackEvents([]);
};
return (
<>
{notificationEvents.length ? (
<aside
className="fixed top-[calc(var(--window-chrome-height)+14px)] right-4 z-40 flex max-w-[min(420px,calc(100vw-2rem))] items-center gap-2.5 rounded-xl border border-[var(--platform-surface-border)] bg-[var(--platform-subpanel-fill)] px-3.5 py-3 text-[var(--platform-text-strong)] shadow-[0_12px_30px_rgb(36_20_12_/_18%)] max-[560px]:top-[calc(var(--window-chrome-height)+8px)] max-[560px]:right-2 max-[560px]:left-2 max-[560px]:max-w-none"
className="fixed top-[calc(var(--window-chrome-height)+12px)] right-4 z-40 flex max-w-[min(520px,calc(100vw-2rem))] items-center gap-4 rounded-xl border border-[#efc9ae] bg-[#fffaf5] px-3.5 py-3 shadow-[0_8px_24px_rgb(100_49_26_/_16%)]"
role="status"
aria-live="polite"
>
<span className="flex-[1_1_auto]">
发现 {notificationEvents.length} 个问题
</span>
<div className="grid min-w-0 gap-[3px]">
<strong className="text-[13px] text-[#4a220f]">发现问题</strong>
</div>
<button
className="min-h-9 cursor-pointer rounded-lg border-0 bg-[var(--platform-button-primary-fill)] px-2.5 text-[var(--platform-button-primary-text)] last:w-9 last:bg-transparent last:px-0 last:text-xl last:text-[var(--platform-text-soft)]"
className="flex-[0_0_auto] cursor-pointer rounded-lg border-0 bg-[#c7653d] px-3 py-[7px] text-[12px] font-bold text-white disabled:cursor-wait disabled:opacity-[0.65]"
type="button"
onClick={openReport}
>
查看并报告
</button>
<button
className="min-h-9 cursor-pointer rounded-lg border-0 bg-[var(--platform-button-primary-fill)] px-2.5 text-[var(--platform-button-primary-text)] last:w-9 last:bg-transparent last:px-0 last:text-xl last:text-[var(--platform-text-soft)]"
type="button"
className="inline-flex h-7 w-7 flex-[0_0_auto] cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 text-[#8d6a58] hover:bg-[rgb(199_101_61_/_12%)] hover:text-[#4a220f] disabled:cursor-wait disabled:opacity-50"
onClick={() => {
const dismissed = notificationEvents;
setNotificationEvents([]);
@@ -102,9 +111,9 @@ export function ErrorReportNotice() {
</aside>
) : null}
<ErrorReportDialog
open={reportEvents.length > 0}
events={reportEvents}
onClose={() => setReportEvents([])}
open={reportOpen}
fallbackEvents={reportFallbackEvents}
onClose={closeReport}
/>
</>
);
@@ -1951,6 +1951,7 @@ export function projectRuntimeVisibleError(
'session-budget-exceeded': '本次会话预算已耗尽,请缩小任务范围或新建任务',
'usage-limit-exceeded': '智能创作用量已达上限,请检查账户额度后重试',
unauthorized: '智能服务鉴权失败,请重新登录后重试',
'request-too-large': '模型请求体过大,请减少参考图或上下文后重试',
'bad-request': '智能创作请求无效,请稍后重试',
'cyber-policy': '智能创作安全策略拒绝了本次请求,请调整任务内容',
'sandbox-error': '智能创作隔离环境启动失败,请重试或检查本机环境',
@@ -1972,6 +1973,7 @@ export function projectRuntimeVisibleError(
'session-budget-exceeded': '本次会话预算已耗尽,请缩小任务范围或新建任务',
'usage-limit-exceeded': '用量已达上限,请检查账户额度后重试',
unauthorized: '鉴权失败,请重新登录后重试',
'request-too-large': '模型请求体过大,请减少参考图或上下文后重试',
'bad-request': '请求无效,请稍后重试',
'cyber-policy': '安全策略拒绝了本次请求,请调整任务内容',
'sandbox-error': '工作区隔离启动失败,请检查项目目录后重试',
@@ -0,0 +1,81 @@
/** @vitest-environment jsdom */
import { cleanup, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
const mockState = vi.hoisted(() => ({
getPending: vi.fn(),
readLogs: vi.fn(),
}));
vi.mock('../src/services/errorReporting', () => ({
ackClientErrorEventsWithRetry: vi.fn(),
getPendingClientErrorEvents: mockState.getPending,
getStableErrorReportSubmissionId: vi.fn(),
readApplicationDiagnosticLogs: mockState.readLogs,
submitErrorReportBatch: vi.fn(),
}));
vi.mock('../src/components/modal/ThemedModal', () => ({
ThemedModal: ({ open, children }: { open: boolean; children: ReactNode }) =>
open ? <div role="dialog">{children}</div> : null,
}));
import { ErrorReportDialog } from '../src/components/error-report/ErrorReportDialog';
const event = {
eventId: 'rust-error-1',
fingerprint: 'fingerprint-1',
source: 'test',
message: '打开面板时读取的错误',
occurredAt: '1',
count: 1,
};
describe('ErrorReportDialog', () => {
afterEach(() => {
cleanup();
mockState.getPending.mockReset();
mockState.readLogs.mockReset();
});
it('只在面板打开时读取最新错误快照', async () => {
mockState.getPending.mockResolvedValue([event]);
mockState.readLogs.mockResolvedValue([]);
const view = render(<ErrorReportDialog open={false} onClose={vi.fn()} />);
expect(mockState.getPending).not.toHaveBeenCalled();
view.rerender(<ErrorReportDialog open onClose={vi.fn()} />);
expect(await screen.findByText(event.message)).toBeTruthy();
expect(mockState.getPending).toHaveBeenCalledTimes(1);
});
it('读取错误事件失败时显示不可用状态而不是空列表', async () => {
mockState.getPending.mockRejectedValue(new Error('读取失败'));
mockState.readLogs.mockResolvedValue([]);
render(<ErrorReportDialog open onClose={vi.fn()} />);
expect(
await screen.findByText('错误事件暂不可用,请关闭后重试。'),
).toBeTruthy();
expect(screen.queryByText('当前没有待报告的错误。')).toBeNull();
});
it('最新快照读取失败时使用打开通知时的事件快照', async () => {
mockState.getPending.mockRejectedValue(new Error('瞬时读取失败'));
mockState.readLogs.mockResolvedValue([]);
render(
<ErrorReportDialog open fallbackEvents={[event]} onClose={vi.fn()} />,
);
expect(await screen.findByText(event.message)).toBeTruthy();
expect(
screen.getByText('最新错误读取失败,已使用打开通知时的快照'),
).toBeTruthy();
});
});
@@ -1,6 +1,12 @@
/** @vitest-environment jsdom */
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
const mockState = vi.hoisted(() => ({
@@ -8,6 +14,10 @@ const mockState = vi.hoisted(() => ({
listeners: [] as Array<() => void>,
}));
const dialogState = vi.hoisted(() => ({
fallbackEvents: [] as (typeof event)[],
}));
vi.mock('../src/services/errorReporting', () => ({
getPendingClientErrorEvents: mockState.getPending,
subscribeClientErrorEvents: (listener: () => void) => {
@@ -21,7 +31,16 @@ vi.mock('../src/services/errorReporting', () => ({
}));
vi.mock('../src/components/error-report/ErrorReportDialog', () => ({
ErrorReportDialog: () => null,
ErrorReportDialog: ({
fallbackEvents,
open,
}: {
fallbackEvents?: (typeof event)[];
open: boolean;
}) => {
dialogState.fallbackEvents = fallbackEvents ?? [];
return open ? <div data-testid="error-report-dialog" /> : null;
},
}));
import { ErrorReportNotice } from '../src/components/error-report/ErrorReportNotice';
@@ -47,7 +66,7 @@ describe('ErrorReportNotice', () => {
render(<ErrorReportNotice />);
expect(await screen.findByText('发现 1 个问题')).toBeTruthy();
expect(await screen.findByText('发现问题')).toBeTruthy();
expect(mockState.getPending).toHaveBeenCalledTimes(1);
});
@@ -61,7 +80,19 @@ describe('ErrorReportNotice', () => {
mockState.listeners[0]?.();
expect(await screen.findByText('发现 1 个问题')).toBeTruthy();
expect(await screen.findByText('发现问题')).toBeTruthy();
expect(mockState.getPending).toHaveBeenCalledTimes(2);
});
it('打开报告时保留通知快照作为对话框 fallback', async () => {
mockState.getPending.mockResolvedValue([event]);
render(<ErrorReportNotice />);
await screen.findByText('发现问题');
fireEvent.click(screen.getByRole('button', { name: '查看并报告' }));
expect(await screen.findByTestId('error-report-dialog')).toBeTruthy();
expect(dialogState.fallbackEvents).toEqual([event]);
});
});
@@ -695,6 +695,13 @@ describe('Agent Runtime Provider 状态投影', () => {
true,
),
).toBe('陶泥儿智能创作 用量已达上限,请检查账户额度后重试');
expect(
projectRuntimeVisibleError(
'codex-app-server-error:request-too-large',
'陶泥儿智能创作',
true,
),
).toBe('陶泥儿智能创作 模型请求体过大,请减少参考图或上下文后重试');
expect(
projectRuntimeVisibleError(
'codex-app-server-terminal-unknown: 等待 turn/completed 超时',
@@ -11,9 +11,11 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事
- 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。
- 客户端 API 自动采集只覆盖网络错误、408 和 5xx;预期的 4xx 登录/鉴权失败不进入错误报告池。
- Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`;WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log,并在客户端桥接处再次脱敏;`read_diagnostic_logs` 只读取应用级日志。
- 报告面板只由自动诊断通知中的“查看并报告”打开,不提供聊天命令、崩溃页按钮或其他手动入口;默认选中最新事件,其他事件可勾选。允许填写最多 2,000 字中文描述并取消日志附件;本版本不支持截图或任意文件附件。
- 报告面板只由自动诊断通知中的“查看并报告”打开,不提供聊天命令、崩溃页按钮或其他手动入口;默认选中当前快照中的全部事件,用户可取消不想提交的事件。允许填写最多 2,000 字中文描述并取消日志附件;本版本不支持截图或任意文件附件。
- 报告面板读取当前错误快照失败时,必须明确显示“错误事件暂不可用,请关闭后重试”,不能把失败误显示为“当前没有待报告的错误”。
- 通知中的“查看并报告”打开面板时必须保留该次通知快照;最新快照读取瞬时失败时使用这份 fallback 继续展示和提交,不能因先清空通知而丢失用户刚看到的事件。
- Rust 是结构化错误队列的唯一真相源:`src-tauri/src/error_report/` 负责脱敏、调用点指纹、eventId、计数、100 条上限、5 秒聚合和 ack 生命周期;WebView 仅通过 Tauri bridge 上报、读取快照并保存短暂 React 展示状态,不维护第二份事件 Map。Runtime 错误上报统一使用 `source=agent-runtime`、`action=agent-runtime` 和 Agent ID 作为 page;包含 `kind=codex-app-server-*` 或 `codex-app-server-error:*` 的消息在入池前归一为稳定类别(例如 `codex-app-server-error:other`),不把 public summary 中的动态 fingerprint/长度作为分桶输入。Rust emit 只作为无状态唤醒,携带单调递增的 generation,不携带错误正文或事件 ID。
- 错误事件先在当前进程内存池按 fingerprint 合并,经过 5 秒聚合后只发出一次非阻塞存在性唤醒;WebView 挂载、收到唤醒、重新获得焦点或恢复可见时都查询完整未 ack 快照,并在桥接暂时失败时做有限退避重试。通知支持“查看并报告”和“忽略”,同一 fingerprint 仅在新增时唤醒一次。通知不直接打开阻塞式报告面板。忽略只关闭当前 UI,不删除事件;提交成功后由 bridge ack/delete 选中事件。
- 错误事件先在当前进程内存池按 fingerprint 合并,经过 5 秒聚合后只发出一次非阻塞存在性唤醒;WebView 挂载、收到唤醒、重新获得焦点或恢复可见时都查询完整未 ack 快照,并在桥接暂时失败时做有限退避重试。通知支持“查看并报告”和“忽略”,同一 fingerprint 仅在新增时唤醒一次。通知不直接打开阻塞式报告面板;用户打开报告面板后,面板再读取一次完整未 ack 快照,确保提交使用打开时的最新事件。忽略只关闭当前 UI,不删除事件;提交成功后由 bridge ack/delete 选中事件。
- 上传失败只在当前进程显示失败并允许用户再次提交,不跨重启恢复事件池,不后台自动重试。
## HTTP 与存储
@@ -1282,3 +1282,8 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
- 配置文件新增 `schemaVersion: \"game-creator-config.v2\"`。新默认配置开启 `stream` 与受控联网;无版本旧配置在启动时补写 v2,旧 `codex_app_server` 路由仅在省略 `webSearchEnabled` 时按历史默认补为开启,显式 `false` 保留;Provider / Anthropic 路由未提供搜索覆盖时保持关闭,避免继承 DirectProject 默认。主配置按完整配置迁移;本地覆盖只补 schema 版本,不凭不完整 overlay 推断或写入 `agentMode` / 搜索布尔值。
- `/llm-status`、开发单 Agent 状态和项目 Agent 状态卡对 Codex 模式显示“流式开启 / 关闭”“受控联网开启 / 关闭”“Codex 原生 web_search 关闭”,不显示 API Key、URL、请求头、绝对路径或 Provider 原始错误正文。
- BDD 验收场景与测试映射:DirectProject 默认工具目录包含 `agc_web_search` 且原生搜索仍 disabled;未审核字段、越界数量和非公开 URL 在桥接端失败关闭;旧无版本配置迁移为 v2 且按路由得到正确默认;状态卡显示三态安全摘要。对应 Rust `configuration`、`direct_tools_mcp`、`direct_tool_bridge`、`codex_app_server` 定向测试及前端状态格式化 / AppSurface 测试。
## 2026-09-06 AGC LLM 代理请求体合同
- `/api/llm/responses` 与 `/api/llm/chat/completions` 的正式请求体上限为 `32 MiB`。两个路由必须显式配置 Axum `DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)`;不能依赖 handler 内的 `Bytes / Json` 后置检查,否则 Axum 默认 `2 MiB` 会先拒绝 Direct Codex 携带图片工具结果的大上下文请求。超过 `32 MiB` 仍返回 `413 PAYLOAD_TOO_LARGE`。
- Codex app-server 的 failed turn 需要把上游 / 连接层 HTTP 413、`PAYLOAD_TOO_LARGE` 和 provider proxy 的 `provider request too large` 映射为稳定分类 `codex-app-server-error:request-too-large`;用户可见文案固定为“模型请求体过大,请减少参考图或上下文后重试”,不得落入 `other` 或泛化成权限 / 安全策略错误。
@@ -0,0 +1,75 @@
# UI 编辑器自动分离工作流
更新时间:`2026-09-08`
## 目标
将 UI 编辑器现有“用户先提供独立图片/图标,再执行组件绑定”的入口替换为自动分离:结构识别阶段直接返回可渲染组件草稿,分离阶段按整页叶节点批次调用图片编辑模型,再由视觉模型确认处理图中的区域与目标节点。
## 识别结果
- `recognize` 返回完整 `Node.components` 草稿,不再要求用户先导入独立素材。
- `components` 为空表示纯节点。
- `ImageComponent.target_graphic = None` 表示图片组件等待分离结果回填;它不是“明确没有图片”。
- 当前约束:需要分离的节点最多包含一个 `ImageComponent`,回填暂使用该节点的第一个图片组件。
- 组件容器“一种组件类型最多一个”的正式重构列为 TODO;当前 `Vec<Component>` 仅按上述约束使用。
- 组件草稿直接保存在正式 UI Node 中;临时 separation tree 不复制组件。
## Separation tree
- recognition 完成后由 UI tree 构造临时 separation tree。
- 纯节点、纯 Text 节点和不需要切图的节点在构造时过滤;被过滤节点的可处理 children 向上透传。
- separation tree 只保留真实待处理节点。
- 一个 batch 是整页当前所有互不重叠叶节点。
- 一个 batch 的最小处理单元是:一次 image-edit + 一次 visual binding。
- batch 成功后从 pending tree 移除对应叶节点,并把结果放入 bound 容器;失败节点移入 problematic 容器,流程继续消费剩余树。
- 不额外维护节点状态枚举;节点是否仍在 pending tree、`rework_count` 和 problematic 容器共同表达状态。
## 图片编辑与视觉绑定
- image-edit 使用源 UI design 图片及由 Rust 生成的绿色标记/紫色重建输入。
- 请求尺寸始终使用源 UI design 尺寸;Raw GPT Image 2 API 保证返回相同尺寸,客户端不额外做尺寸拒绝检查。
- 视觉 binding 输入源图与处理图,必须为当前 batch 每个节点恰好返回一次 `Ok` 或 `NeedRework`。
- `Ok` 返回 `NodeId + BindingArea`;Rust 仅校验 NodeId、区域边界和非零尺寸,不检查与原节点框的偏差,也不要求区域不重叠。
- `NeedRework` 携带短问题描述。结构化工具调用失败时使用可复用 repair harness,把错误反馈给模型并额外请求一次;image-edit 不使用该 harness。
- 达到模块级重做常量后,节点移入 problematic;不中断整条工作流,最终统一通知用户。
- 父节点背景重建由 image-edit 模型完成,不由 Rust 硬编码重建算法完成。
## 临时 sidecar
- separation 状态不写入 UI JSON,也不进入 manifest。
- sidecar 目录按 UI manifest `asset_id` 生成,复用 `generated_file_stem(asset_id)` 的安全字符替换和 SHA-256 摘要规则,位于项目 `ui/` 下。
- 目录只保存一份当前 separation state,而不是每 batch 一个状态文件。
- state 文件只保留 `schema_version`、pending tree、bound 结果和 problematic 节点,不重复保存 `projectId / assetId / uiStateRevision`。
- sidecar 只在 separation 未完成期间存在;完成后删除 state JSON。
- 当前只持久化已经完成的 batch;正在执行 batch 的恢复语义列 TODO。
- 临时图片可跨重启保留。raw image-edit 返回图、绿色/紫色标记图、处理图和 cut 图片当前都保留用于 debug;理论上只应在内存中,清理/归档策略列 TODO。
## bound 与 problematic
- bound 结果仅保存 `NodeId + cut_image_path`,不保存 `BindingArea` 或 component kind。
- problematic 记录原始 NodeId、问题描述和 `rework_count`;原始 UI Node 保留不变。
- `SeparationDTO` 不返回计数字段,只返回 `bound_nodes` 与 `problematic_nodes`。
- separation Rust 流程不自动登记项目级 SpriteAsset。
- 前端调用方消费 `SeparationDTO.bound_nodes`,复制/登记 cut 图片为项目级 SpriteAsset,再回填对应 Node 的第一个 Image component。
- 每次重做产生新的 SpriteAssetId,不假设 NodeId 到 SpriteAssetId 的稳定映射。
- sidecar 中的图片保留,正式 SpriteAsset 的最终清理策略列 TODO。
## 重启与 Raw GPT Image 2
- 已保存的 separation state 是跨重启继续工作的最小单位;重启后从上一个已保存 batch 的状态继续。
- 当前执行中的 batch 是否持久化、以及如何避免 image-edit 成功后在 patch 前崩溃导致重复调用,列为 TODO。
- Raw endpoint 每次 HTTP 调用都是一次新操作;客户端不保存或复用 raw operation ID,不实现第二套本地幂等账本。
- 后端 raw operation 的持久状态与扣费后崩溃恢复窗口,遵循 Raw GPT Image 2 方案中的独立 TODO。
## TODO
- `Vec<Component>` 重构为一种组件类型最多一个的容器。
- 当前第一个 Image component 回填规则的正式替代方案。
- 正在执行 batch 的持久化和恢复。
- 前端复制、登记 SpriteAsset、回填 State 的精确 IPC/提交合同。
- 临时图片清理/归档策略。
- 手动抠图能力。
- problematic 对更高层 workflow 完成门禁的最终定义。
- separation workflow 与 manifest/stage 的接入。
- Raw GPT Image 2 后端 raw operation 持久状态及恢复 worker。
+1
View File
@@ -50,6 +50,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(modules::platform::router(state.clone()))
.merge(modules::external_generation::router(state.clone()))
.merge(modules::platform_support::router(state.clone()))
.merge(modules::raw::router(state.clone()))
.merge(crate::error_reports::router(state.clone()))
.route(
"/api/profile/recharge/wechat/notify",
+74 -2
View File
@@ -28,6 +28,8 @@ use crate::{
platform_errors::map_llm_error, request_context::RequestContext, state::AppState,
};
pub(crate) const LLM_REQUEST_MAX_BODY_BYTES: usize = 32 * 1024 * 1024;
pub(crate) mod icon_specs;
#[cfg(test)]
@@ -222,8 +224,7 @@ pub async fn proxy_llm_responses(
headers: HeaderMap,
body: Bytes,
) -> Result<Response, Response> {
const MAX_REQUEST_BYTES: usize = 32 * 1024 * 1024;
if body.len() > MAX_REQUEST_BYTES {
if body.len() > LLM_REQUEST_MAX_BODY_BYTES {
return Err(llm_error_response(
&request_context,
AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE)
@@ -1187,6 +1188,77 @@ mod tests {
);
}
#[tokio::test]
async fn llm_routes_accept_large_context_bodies_beyond_axum_default() {
let large_input = "x".repeat(2 * 1024 * 1024 + 1024);
let (state, user_id) = seed_authenticated_state(AppConfig::default()).await;
install_test_provisioned_router_credential(
&user_id,
"http://127.0.0.1:1".to_string(),
"fixture-key",
);
let token = issue_access_token(&state, &user_id);
let app = build_router(state);
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/llm/responses")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(json!({ "input": large_input }).to_string()))
.expect("request should build"),
)
.await
.expect("response should not hit the default body limit");
assert_ne!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/llm/chat/completions")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"messages": [
{ "role": "user", "content": large_input }
]
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("response should not hit the default body limit");
assert_ne!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn llm_responses_rejects_bodies_above_explicit_limit() {
let (state, user_id) = seed_authenticated_state(AppConfig::default()).await;
let token = issue_access_token(&state, &user_id);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/llm/responses")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(vec![b'x'; LLM_REQUEST_MAX_BODY_BYTES + 1]))
.expect("request should build"),
)
.await
.expect("oversized response should be returned");
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn llm_chat_completions_streams_sse_payload() {
let server_url = spawn_mock_server(vec![MockResponse {
+1
View File
@@ -67,6 +67,7 @@ mod profile_identity;
mod profile_recharge_expiration_listener;
mod profile_recharge_refund_reconciliation;
mod prompt;
mod raw_image;
mod refresh_session;
mod registration_reward;
mod request_context;
@@ -10,3 +10,4 @@ pub mod internal;
pub mod platform;
pub mod platform_support;
pub mod profile;
pub mod raw;
@@ -1,11 +1,16 @@
use axum::{
Router, middleware,
Router,
extract::DefaultBodyLimit,
middleware,
routing::{get, post},
};
use crate::{
auth::require_bearer_auth,
llm::{list_llm_models, proxy_llm_chat_completions, proxy_llm_responses},
llm::{
LLM_REQUEST_MAX_BODY_BYTES, list_llm_models, proxy_llm_chat_completions,
proxy_llm_responses,
},
state::AppState,
volcengine_speech::{
get_volcengine_speech_config, stream_volcengine_asr, stream_volcengine_tts_bidirection,
@@ -24,17 +29,21 @@ pub fn router(state: AppState) -> Router<AppState> {
)
.route(
"/api/llm/chat/completions",
post(proxy_llm_chat_completions).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
post(proxy_llm_chat_completions)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
))
.layer(DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)),
)
.route(
"/api/llm/responses",
post(proxy_llm_responses).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
post(proxy_llm_responses)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
))
.layer(DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)),
)
.route(
"/api/speech/volcengine/config",
@@ -0,0 +1,11 @@
use axum::{Router, middleware, routing::post};
use crate::{auth::require_bearer_auth, raw_image::edit_raw_image, state::AppState};
pub fn router(state: AppState) -> Router<AppState> {
Router::new().route(
"/api/raw/v1/images/edit",
post(edit_raw_image)
.route_layer(middleware::from_fn_with_state(state, require_bearer_auth)),
)
}
@@ -414,7 +414,7 @@ impl OpenAiImageSettings {
self
}
fn provider_settings(&self) -> VectorEngineImageSettings {
pub(crate) fn provider_settings(&self) -> VectorEngineImageSettings {
VectorEngineImageSettings {
base_url: self.base_url.clone(),
api_key: self.api_key.clone(),
@@ -0,0 +1,219 @@
use axum::{
Json,
extract::{Extension, State},
http::StatusCode,
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use platform_image::{RawImageEditOptions, ReferenceImage, create_vector_engine_raw_image_edit};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{
asset_billing::{
execute_billable_asset_operation_with_cost, with_editor_generation_durable_billing_boundary,
},
auth::AuthenticatedAccessToken,
http_error::AppError,
openai_image_generation::{
build_openai_image_http_client, map_platform_image_error, require_openai_image_settings,
},
request_context::RequestContext,
state::AppState,
};
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct RawImageData {
pub(crate) data: String,
pub(crate) mime_type: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub(crate) struct RawImageEditRequest {
pub(crate) image: RawImageData,
pub(crate) mask: Option<RawImageData>,
pub(crate) prompt: String,
pub(crate) quality: Option<String>,
pub(crate) background: Option<String>,
pub(crate) output_format: Option<String>,
pub(crate) width: u32,
pub(crate) height: u32,
}
#[derive(Debug, Serialize)]
pub(crate) struct RawImageEditItem {
pub(crate) b64_json: String,
}
#[derive(Debug, Serialize)]
pub(crate) struct RawImageEditResponse {
pub(crate) data: Vec<RawImageEditItem>,
}
pub(crate) async fn edit_raw_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RawImageEditRequest>,
) -> Result<Json<Value>, AppError> {
let prepared = prepare_request(payload)?;
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
&request_context,
Some(authenticated.claims().user_id().to_string()),
None,
);
let http_client = build_openai_image_http_client(&settings)?;
let provider_settings = settings.provider_settings();
let user_id = authenticated.claims().user_id().to_string();
let request_id = request_context.request_id().to_string();
let points_cost = raw_image_edit_price(&state, prepared.width, prepared.height).await?;
let operation = async move {
let generated = create_vector_engine_raw_image_edit(
&http_client,
&provider_settings,
prepared.prompt.as_str(),
&prepared.image,
prepared.options,
"raw_image_edit",
)
.await
.map_err(map_platform_image_error)?;
let data = generated
.images
.into_iter()
.map(|image| RawImageEditItem {
b64_json: BASE64_STANDARD.encode(image.bytes),
})
.collect();
Ok::<_, AppError>(RawImageEditResponse { data })
};
let result = with_editor_generation_durable_billing_boundary(
execute_billable_asset_operation_with_cost(
&state,
user_id.as_str(),
"raw-image-edit",
request_id.as_str(),
u64::from(points_cost),
operation,
),
)
.await?;
Ok(Json(serde_json::to_value(result).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
})?))
}
struct PreparedRawImageEdit {
image: ReferenceImage,
prompt: String,
options: RawImageEditOptions,
width: u32,
height: u32,
}
fn prepare_request(payload: RawImageEditRequest) -> Result<PreparedRawImageEdit, AppError> {
if payload.prompt.trim().is_empty() {
return Err(bad_request("prompt 不能为空"));
}
if payload.width == 0 || payload.height == 0 {
return Err(bad_request("width 和 height 必须为正整数"));
}
let image = decode_image(payload.image, "image")?;
let mask = payload
.mask
.map(|value| decode_image(value, "mask"))
.transpose()?;
Ok(PreparedRawImageEdit {
image,
prompt: payload.prompt,
options: RawImageEditOptions {
quality: payload.quality,
background: payload.background,
output_format: payload.output_format,
width: payload.width,
height: payload.height,
mask,
},
width: payload.width,
height: payload.height,
})
}
fn decode_image(value: RawImageData, field: &str) -> Result<ReferenceImage, AppError> {
let mime_type = value.mime_type.trim().to_string();
if mime_type.is_empty() {
return Err(bad_request(format!("{field}.mimeType 不能为空")));
}
let bytes = BASE64_STANDARD
.decode(value.data.trim())
.map_err(|error| bad_request(format!("{field}.data 必须是有效 base64:{error}")))?;
Ok(ReferenceImage {
bytes,
file_name: format!("{field}.png"),
mime_type,
})
}
async fn raw_image_edit_price(state: &AppState, width: u32, height: u32) -> Result<u32, AppError> {
let tier = if width.max(height) > 1536 { "2K" } else { "1K" };
state
.editor_generation_pricing()
.await
.map(|pricing| {
pricing.image_generation_mud_points(Some("quick-edit"), Some("gpt-image-2"), Some(tier))
})
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
})
}
fn bad_request(message: impl Into<String>) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "raw-image-edit",
"message": message.into(),
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_uses_one_image_object_and_rejects_images_array() {
let payload = serde_json::json!({
"image": {"data": "aGVsbG8=", "mimeType": "image/png"},
"prompt": "edit",
"width": 1024,
"height": 1024
});
let parsed: RawImageEditRequest = serde_json::from_value(payload).expect("image object");
let prepared = prepare_request(parsed).expect("request should prepare");
assert_eq!(prepared.image.bytes, b"hello");
let array_payload = serde_json::json!({
"images": [{"data": "aGVsbG8=", "mimeType": "image/png"}],
"prompt": "edit",
"width": 1024,
"height": 1024
});
assert!(serde_json::from_value::<RawImageEditRequest>(array_payload).is_err());
}
#[test]
fn response_contains_only_data_b64_json() {
let response = serde_json::to_value(RawImageEditResponse {
data: vec![RawImageEditItem {
b64_json: "aGVsbG8=".to_string(),
}],
})
.expect("response should serialize");
assert_eq!(
response,
serde_json::json!({"data": [{"b64_json": "aGVsbG8="}]})
);
}
}
+6 -5
View File
@@ -10,14 +10,15 @@ pub use pixel_art_snapper::{
};
pub use vector_engine::{
DownloadedImage, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, GeneratedImages, NANOBANANA_2_MODEL,
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,
PlatformImageError, PlatformImageFailureAudit, PlatformImageStatusHint, RawImageEditOptions,
ReferenceImage, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER,
VectorEngineImageSettings, build_vector_engine_image_http_client,
build_vector_engine_image_request_body,
build_vector_engine_nanobanana_generate_content_request_body, create_vector_engine_image_edit,
create_vector_engine_image_edit_with_references,
create_vector_engine_image_edit_with_references_and_model,
create_vector_engine_image_generation, create_vector_engine_image_generation_with_model,
create_vector_engine_nanobanana_generate_content, download_remote_image,
vector_engine_images_edit_url, vector_engine_images_generation_url,
create_vector_engine_nanobanana_generate_content, create_vector_engine_raw_image_edit,
download_remote_image, vector_engine_images_edit_url, vector_engine_images_generation_url,
vector_engine_nanobanana_generate_content_url,
};
@@ -16,6 +16,7 @@ use super::{
curl_transport::{
map_curl_error, send_vector_engine_json_request_with_curl,
send_vector_engine_multipart_edit_request_with_curl,
send_vector_engine_multipart_edit_request_with_curl_options,
},
error::PlatformImageError,
image_source::resolve_reference_images,
@@ -28,7 +29,7 @@ use super::{
vector_engine_nanobanana_generate_content_url,
},
response::handle_vector_engine_response,
types::{GeneratedImages, ReferenceImage, VectorEngineImageSettings},
types::{GeneratedImages, RawImageEditOptions, ReferenceImage, VectorEngineImageSettings},
util::truncate_raw,
};
@@ -537,6 +538,34 @@ pub async fn create_vector_engine_image_edit_with_references_and_model(
candidate_count: u32,
reference_images: &[ReferenceImage],
failure_context: &str,
) -> Result<GeneratedImages, PlatformImageError> {
create_vector_engine_image_edit_with_references_and_model_and_options(
http_client,
settings,
model,
prompt,
negative_prompt,
size,
candidate_count,
reference_images,
None,
failure_context,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn create_vector_engine_image_edit_with_references_and_model_and_options(
http_client: &reqwest::Client,
settings: &VectorEngineImageSettings,
model: &str,
prompt: &str,
negative_prompt: Option<&str>,
size: &str,
candidate_count: u32,
reference_images: &[ReferenceImage],
options: Option<&RawImageEditOptions>,
failure_context: &str,
) -> Result<GeneratedImages, PlatformImageError> {
let requested_model = normalize_vector_engine_image_model(model);
if reference_images.is_empty() {
@@ -611,19 +640,37 @@ pub async fn create_vector_engine_image_edit_with_references_and_model(
&mut recovered_failure_audits,
));
};
let response = match send_vector_engine_multipart_edit_request_with_curl(
request_url.as_str(),
settings.api_key.as_str(),
upstream_model,
prompt,
negative_prompt,
normalized_size.as_str(),
candidate_count,
reference_images,
attempt_timeout_ms,
)
.await
{
let response = match match options {
Some(options) => {
send_vector_engine_multipart_edit_request_with_curl_options(
request_url.as_str(),
settings.api_key.as_str(),
upstream_model,
prompt,
negative_prompt,
normalized_size.as_str(),
candidate_count,
reference_images,
Some(options),
attempt_timeout_ms,
)
.await
}
None => {
send_vector_engine_multipart_edit_request_with_curl(
request_url.as_str(),
settings.api_key.as_str(),
upstream_model,
prompt,
negative_prompt,
normalized_size.as_str(),
candidate_count,
reference_images,
attempt_timeout_ms,
)
.await
}
} {
Ok(response) => {
if should_retry_vector_engine_upstream_response(
response.status,
@@ -7,8 +7,11 @@ use curl::{
use serde_json::Value;
use super::{
audit::build_failure_audit, constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError,
request::build_prompt_with_negative, types::ReferenceImage,
audit::build_failure_audit,
constants::VECTOR_ENGINE_PROVIDER,
error::PlatformImageError,
request::build_prompt_with_negative,
types::{RawImageEditOptions, ReferenceImage},
};
#[derive(Debug)]
@@ -119,6 +122,34 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl(
candidate_count: u32,
reference_images: &[ReferenceImage],
timeout_ms: u64,
) -> Result<VectorEngineCurlResponse, VectorEngineCurlError> {
send_vector_engine_multipart_edit_request_with_curl_options(
request_url,
api_key,
model,
prompt,
negative_prompt,
normalized_size,
candidate_count,
reference_images,
None,
timeout_ms,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl_options(
request_url: &str,
api_key: &str,
model: &str,
prompt: &str,
negative_prompt: Option<&str>,
normalized_size: &str,
candidate_count: u32,
reference_images: &[ReferenceImage],
options: Option<&RawImageEditOptions>,
timeout_ms: u64,
) -> Result<VectorEngineCurlResponse, VectorEngineCurlError> {
let request_url = request_url.to_string();
let api_key = api_key.to_string();
@@ -127,6 +158,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl(
let negative_prompt = negative_prompt.map(str::to_string);
let normalized_size = normalized_size.to_string();
let reference_images = reference_images.to_vec();
let options = options.cloned();
tokio::task::spawn_blocking(move || {
send_multipart_edit_request_with_curl_blocking(
request_url.as_str(),
@@ -137,6 +169,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl(
normalized_size.as_str(),
candidate_count,
reference_images.as_slice(),
options.as_ref(),
timeout_ms,
)
})
@@ -239,6 +272,7 @@ fn send_multipart_edit_request_with_curl_blocking(
normalized_size: &str,
candidate_count: u32,
reference_images: &[ReferenceImage],
options: Option<&RawImageEditOptions>,
timeout_ms: u64,
) -> Result<VectorEngineCurlResponse, VectorEngineCurlError> {
let mut form = Form::new();
@@ -253,6 +287,28 @@ fn send_multipart_edit_request_with_curl_blocking(
.contents(normalized_size.as_bytes())
.add()?;
if let Some(options) = options {
if let Some(quality) = options.quality.as_deref() {
form.part("quality").contents(quality.as_bytes()).add()?;
}
if let Some(background) = options.background.as_deref() {
form.part("background")
.contents(background.as_bytes())
.add()?;
}
if let Some(output_format) = options.output_format.as_deref() {
form.part("output_format")
.contents(output_format.as_bytes())
.add()?;
}
if let Some(mask) = options.mask.as_ref() {
form.part("mask")
.buffer(mask.file_name.as_str(), mask.bytes.clone())
.content_type(mask.mime_type.as_str())
.add()?;
}
}
for reference_image in reference_images {
form.part("image")
.buffer(
@@ -6,6 +6,7 @@ mod curl_transport;
mod error;
mod image_source;
mod payload;
mod raw_edit;
mod request;
mod response;
mod transport;
@@ -25,6 +26,7 @@ pub use constants::{
};
pub use error::{PlatformImageError, PlatformImageStatusHint};
pub use image_source::download_remote_image;
pub use raw_edit::create_vector_engine_raw_image_edit;
pub use request::{
build_vector_engine_image_request_body, build_vector_engine_image_request_body_with_model,
build_vector_engine_nanobanana_generate_content_request_body, normalize_image_size_for_model,
@@ -32,4 +34,7 @@ pub use request::{
vector_engine_nanobanana_generate_content_url,
};
pub use transport::build_vector_engine_image_http_client;
pub use types::{DownloadedImage, GeneratedImages, ReferenceImage, VectorEngineImageSettings};
pub use types::{
DownloadedImage, GeneratedImages, RawImageEditOptions, ReferenceImage,
VectorEngineImageSettings,
};
@@ -0,0 +1,33 @@
use super::{
client::create_vector_engine_image_edit_with_references_and_model_and_options,
constants::GPT_IMAGE_2_MODEL,
error::PlatformImageError,
types::{GeneratedImages, RawImageEditOptions, ReferenceImage, VectorEngineImageSettings},
};
/// Sends the raw GPT Image 2 edit contract while keeping VectorEngine's
/// multipart transport inside this crate.
#[allow(clippy::too_many_arguments)]
pub async fn create_vector_engine_raw_image_edit(
http_client: &reqwest::Client,
settings: &VectorEngineImageSettings,
prompt: &str,
image: &ReferenceImage,
options: RawImageEditOptions,
failure_context: &str,
) -> Result<GeneratedImages, PlatformImageError> {
let size = format!("{}x{}", options.width, options.height);
create_vector_engine_image_edit_with_references_and_model_and_options(
http_client,
settings,
GPT_IMAGE_2_MODEL,
prompt,
None,
size.as_str(),
1,
std::slice::from_ref(image),
Some(&options),
failure_context,
)
.await
}
@@ -29,3 +29,15 @@ pub struct ReferenceImage {
pub mime_type: String,
pub file_name: String,
}
/// Raw GPT Image 2 edit options. The API layer owns validation; this type only
/// carries values that must be forwarded to VectorEngine.
#[derive(Clone, Debug, Default)]
pub struct RawImageEditOptions {
pub quality: Option<String>,
pub background: Option<String>,
pub output_format: Option<String>,
pub width: u32,
pub height: u32,
pub mask: Option<ReferenceImage>,
}