feat: adapt image tuning by engine

This commit is contained in:
inman
2026-05-29 14:32:02 +08:00
parent 4b21d2999c
commit e36f28a668
12 changed files with 138 additions and 10 deletions

View File

@@ -31,6 +31,7 @@ export async function POST(request: Request) {
min_ratio?: number;
max_ratio?: number;
force_single?: boolean;
quality?: string;
}>(request);
const capability = body.capability || "image.generate";
const assembled = body.promptAssembly
@@ -49,7 +50,8 @@ export async function POST(request: Request) {
height: asNumber(body.height),
min_ratio: asNumber(body.min_ratio),
max_ratio: asNumber(body.max_ratio),
force_single: Boolean(body.force_single)
force_single: Boolean(body.force_single),
quality: typeof body.quality === "string" ? body.quality : undefined
}, requestOrigin(request));
return jsonOk({ job: await getGenerationJob(job.id) }, { status: 202 });
} catch (error) {

View File

@@ -120,8 +120,9 @@ export async function GET(request: Request) {
},
width: { type: "integer", example: 1440 },
height: { type: "integer", example: 2560 },
scale: { type: "number", minimum: 1, maximum: 100 },
scale: { type: "number", minimum: 1, maximum: 100, description: "Jimeng text influence for image.generate and detail strength for image.upscale." },
force_single: { type: "boolean" },
quality: { type: "string", enum: ["low", "medium", "high"], description: "EvoLink image quality for image.generate and image.inpaint." },
resolution: { type: "string", enum: ["4k", "8k"], description: "Upscale resolution for image.upscale." },
seed: { type: "integer" },
priority: { type: "integer", minimum: -100, maximum: 100 },

View File

@@ -13,12 +13,31 @@ import type { PromptMaterial } from "@/lib/prompt/assembler";
type GenerateMode = "image" | "video";
type StudioMode = GenerateMode | ImageEditMode;
type MaterialKind = PromptMaterial["type"];
type ImageGenerateEngine = "jimeng" | "evolink";
type MentionState = {
start: number;
query: string;
};
type HealthCapability = {
id: string;
engine?: string;
};
const jimengInfluenceOptions = [
{ id: "creative", label: "创意 35", scale: 35 },
{ id: "balanced", label: "均衡 50", scale: 50 },
{ id: "precise", label: "贴合 70", scale: 70 },
{ id: "strict", label: "严格 85", scale: 85 }
];
const evolinkQualityOptions = [
{ id: "low", label: "快速", quality: "low" },
{ id: "medium", label: "标准", quality: "medium" },
{ id: "high", label: "精细", quality: "high" }
];
const imageSizePresets = [
{ label: "1:1", width: 2048, height: 2048 },
{ label: "4:3", width: 2304, height: 1728 },
@@ -41,7 +60,9 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [imageSize, setImageSize] = useState(imageSizePresets[0]);
const [imageScale, setImageScale] = useState(50);
const [imageEngine, setImageEngine] = useState<ImageGenerateEngine>("jimeng");
const [jimengInfluence, setJimengInfluence] = useState(jimengInfluenceOptions[1].id);
const [evolinkQuality, setEvolinkQuality] = useState(evolinkQualityOptions[1].id);
const [forceSingle, setForceSingle] = useState(true);
const [videoRatio, setVideoRatio] = useState("9:16");
const [videoDuration, setVideoDuration] = useState(VIDEO_DURATION_DEFAULT);
@@ -59,6 +80,8 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
const isImageEditMode = mode === "inpaint" || mode === "upscale";
const generateMode: GenerateMode = mode === "video" ? "video" : "image";
const prompt = promptByMode[generateMode];
const selectedJimengInfluence = jimengInfluenceOptions.find((option) => option.id === jimengInfluence) || jimengInfluenceOptions[1];
const selectedEvolinkQuality = evolinkQualityOptions.find((option) => option.id === evolinkQuality) || evolinkQualityOptions[1];
const visibleMaterials = pageItems(materials, materialPage, MATERIAL_PAGE_SIZE);
const materialPageOffset = (clampPage(materialPage, materials.length, MATERIAL_PAGE_SIZE) - 1) * MATERIAL_PAGE_SIZE;
const mentionSuggestions = useMemo(() => {
@@ -90,6 +113,20 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
if (materialBoardRef.current) revealChildren(materialBoardRef.current, ".material-card");
}, [materials.length, materialPage]);
useEffect(() => {
let active = true;
void fetch("/api/health")
.then((response) => response.ok ? response.json() : null)
.then((payload: { capabilities?: HealthCapability[] } | null) => {
const engine = payload?.capabilities?.find((capability) => capability.id === "image.generate")?.engine;
if (active && (engine === "evolink" || engine === "jimeng")) setImageEngine(engine);
})
.catch(() => undefined);
return () => {
active = false;
};
}, []);
function setPrompt(value: string) {
setPromptByMode((items) => ({ ...items, [generateMode]: value }));
}
@@ -229,7 +266,9 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
materials: materials.filter((material) => material.type === "image"),
width: imageSize.width,
height: imageSize.height,
scale: imageScale,
...(imageEngine === "evolink"
? { quality: selectedEvolinkQuality.quality }
: { scale: selectedJimengInfluence.scale }),
force_single: forceSingle
}
: {
@@ -432,9 +471,17 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
{imageSizePresets.map((preset) => <option key={preset.label}>{preset.label}</option>)}
</select>
</div>
<div className="field inline-field range-field">
<label htmlFor="imageScale"> {imageScale}</label>
<input id="imageScale" type="range" min="1" max="100" value={imageScale} onChange={(event) => setImageScale(Number(event.target.value))} />
<div className="field inline-field">
<label htmlFor="imageEngineTuning">{imageEngine === "evolink" ? "生成质量" : "文本影响"}</label>
{imageEngine === "evolink" ? (
<select id="imageEngineTuning" value={evolinkQuality} onChange={(event) => setEvolinkQuality(event.target.value)}>
{evolinkQualityOptions.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
</select>
) : (
<select id="imageEngineTuning" value={jimengInfluence} onChange={(event) => setJimengInfluence(event.target.value)}>
{jimengInfluenceOptions.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
</select>
)}
</div>
<label className="toggle-line">
<input type="checkbox" checked={forceSingle} onChange={(event) => setForceSingle(event.target.checked)} />

View File

@@ -174,10 +174,14 @@ curl -X POST https://你的域名/api/v1/assets \
"imageUrls": ["https://example.com/reference.png"],
"width": 1440,
"height": 2560,
"scale": 50,
"quality": "medium",
"force_single": true
}
```
图片生成参数会按当前引擎生效:即梦使用 `scale` 控制文本影响EvoLink 使用 `quality` 控制生成质量。
智能超清:
```json

View File

@@ -136,3 +136,8 @@
- Uploaded and generated assets created through public API flows are tagged as `api-client:<clientId>` so integrations can query and download their own results later.
- OpenAPI is generated dynamically from the current deployment origin at `/api/v1/openapi.json`.
- Operations handoff docs live in `docs/DEPLOYMENT.md`; partner API docs live in `docs/API.md`.
## 2026-05-29 Image Tuning Findings
- Jimeng image generation supports the current `scale` parameter, so UI presets should submit numeric text-influence values for that engine.
- EvoLink image generation does not use Jimeng `scale`; the per-request engine-aware control should submit EvoLink `quality` instead.
- Current local `/api/health` reports `image.generate` using EvoLink, so `/create` should show `生成质量` options rather than `文本影响`.

View File

@@ -87,7 +87,8 @@ export function buildEvolinkImagePayload(
};
if (!payload.prompt) throw new Error("Prompt is required for image generation.");
if (settings.quality) payload.quality = settings.quality;
const quality = cleanOptional(typeof input.quality === "string" ? input.quality : undefined) || settings.quality;
if (quality) payload.quality = quality;
if (settings.resolution) payload.resolution = settings.resolution;
assignSize(payload, input);

View File

@@ -42,6 +42,7 @@ export type SubmitImageJobInput = {
max_ratio?: number;
force_single?: boolean;
resolution?: "4k" | "8k";
quality?: string;
seed?: number;
retryOf?: string;
idempotencyKey?: string;

View File

@@ -35,6 +35,7 @@ export type PublicJobCreateBody = {
max_ratio?: number;
force_single?: boolean;
resolution?: "4k" | "8k";
quality?: string;
seed?: number;
};
@@ -101,6 +102,7 @@ export async function createPublicGenerationJob(input: {
max_ratio: asNumber(input.body.max_ratio),
force_single: Boolean(input.body.force_single),
resolution: input.body.resolution,
quality: normalizeQuality(input.body.quality),
seed: asNumber(input.body.seed)
} satisfies SubmitImageJobInput, input.origin);
return { job, reused: false };
@@ -132,6 +134,12 @@ function asNumber(value: unknown): number | undefined {
return Number.isFinite(parsed) ? parsed : undefined;
}
function normalizeQuality(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.trim().toLowerCase();
return ["low", "medium", "high"].includes(normalized) ? normalized : undefined;
}
function fingerprintBody(body: PublicJobCreateBody): string {
const { idempotencyKey: _idempotencyKey, ...fingerprintSource } = body;
return createHash("sha256").update(stableStringify(fingerprintSource)).digest("hex");

View File

@@ -295,6 +295,27 @@
- Local HTTP smoke test uploaded a PNG through `/api/v1/assets`, fetched `/api/v1/assets/:id`, and downloaded matching binary bytes from `/api/v1/assets/:id/download`.
- Mock-provider task flow created a queued job through `/api/v1/jobs`, Worker processed it to `succeeded`, generated output asset carried the API client tag, and download returned an attachment response.
## Session: 2026-05-29 - Engine-Aware Image Tuning
### Implementation
- **Status:** complete
- Actions taken:
- Replaced the image generation text-influence range slider with select options.
- Added create-page engine detection from `/api/health`.
- For Jimeng image generation, the UI now shows `文本影响` options: `创意 35`, `均衡 50`, `贴合 70`, `严格 85`, and submits `scale`.
- For EvoLink image generation, the UI now shows `生成质量` options: `快速`, `标准`, `精细`, and submits `quality`.
- Added per-request EvoLink `quality` support in the payload builder.
- Updated public API request typing, OpenAPI schema, API docs, and focused tests.
### Verification
- **Status:** complete
- Results:
- `npm test`: 7 files / 22 tests passed.
- `npm run build`: production build succeeded.
- Agent-browser snapshot on `/create` showed current EvoLink mode rendering `生成质量` with `快速 / 标准 / 精细`.
- Desktop and mobile screenshots showed the create-page controls fitting cleanly.
- Browser page errors list was empty.
## Error Log - Server Deployment Support
| Timestamp | Error | Attempt | Resolution |
|-----------|-------|---------|------------|

View File

@@ -86,6 +86,14 @@ Complete - latest update: Task management and public API v1
- [x] Verify real HTTP API calls, Worker task processing, tests, and production build
- **Status:** complete
### Phase 12: Engine-Aware Image Tuning
- [x] Replace the free text-influence slider with user-facing option presets
- [x] Detect the active image generation engine from `/api/health`
- [x] Send Jimeng `scale` only when the active engine is Jimeng
- [x] Send EvoLink `quality` only when the active engine is EvoLink
- [x] Verify tests, production build, and desktop/mobile create-page layout
- **Status:** complete
## Key Questions
1. How should the selected image engine be stored and exposed in settings?
2. Which current capabilities should EvoLink handle first?

View File

@@ -34,15 +34,18 @@ describe("EvoLink image client helpers", () => {
it("maps inpainting original and mask URLs", () => {
const payload = buildEvolinkImagePayload("image.inpaint", {
prompt: "移除背景杂物",
quality: "high",
imageUrls: ["https://example.com/original.png", "https://example.com/mask.png"]
}, {
baseUrl: "https://api.evolink.ai",
model: "gpt-image-2"
model: "gpt-image-2",
quality: "medium"
});
expect(payload).toMatchObject({
image_urls: ["https://example.com/original.png"],
mask_url: "https://example.com/mask.png"
mask_url: "https://example.com/mask.png",
quality: "high"
});
});

View File

@@ -21,6 +21,9 @@ const envNames = [
"SUPABASE_SERVICE_ROLE_KEY",
"ZHINIAN_API_KEYS",
"JIMENG_VISUAL_MOCK",
"IMAGE_GENERATE_ENGINE",
"EVOLINK_MOCK",
"EVOLINK_API_KEY",
"VOLCENGINE_ACCESS_KEY_ID",
"VOLCENGINE_SECRET_ACCESS_KEY",
"ZHINIAN_WEBHOOK_SECRET"
@@ -33,6 +36,9 @@ describe("task management and public API helpers", () => {
process.env.ZHINIAN_RUNTIME_DIR = runtimeDir;
process.env.ZHINIAN_API_KEYS = "agent-a:secret-a,agent-b:secret-b";
process.env.JIMENG_VISUAL_MOCK = "true";
delete process.env.IMAGE_GENERATE_ENGINE;
delete process.env.EVOLINK_MOCK;
delete process.env.EVOLINK_API_KEY;
delete process.env.NEXT_PUBLIC_SUPABASE_URL;
delete process.env.SUPABASE_SERVICE_ROLE_KEY;
delete process.env.VOLCENGINE_ACCESS_KEY_ID;
@@ -91,6 +97,27 @@ describe("task management and public API helpers", () => {
})).rejects.toBeInstanceOf(PublicApiConflictError);
});
it("passes EvoLink quality through public image jobs", async () => {
process.env.IMAGE_GENERATE_ENGINE = "evolink";
process.env.EVOLINK_MOCK = "true";
process.env.EVOLINK_API_KEY = "test-key";
const result = await createPublicGenerationJob({
client: { id: "agent-a", key: "secret-a" },
request: new Request("http://local.test/api/v1/jobs"),
origin: "http://local.test",
body: {
capability: "image.generate",
prompt: "商品海报",
quality: "high"
}
});
expect(result.job.provider).toBe("mock");
expect(result.job.requestPayload.providerPayload).toMatchObject({
model: "gpt-image-2",
quality: "high"
});
});
it("claims local jobs without duplicate ownership", async () => {
await Promise.all(Array.from({ length: 6 }, (_, index) => createGenerationJob({
ownerId: DEFAULT_OWNER_ID,