feat: add built-in layout composition skill
This commit is contained in:
@@ -50,7 +50,7 @@ Use this table before searching.
|
||||
| --- | --- | --- |
|
||||
| Home page, shell, navigation, footer, announcement popup | `src/app/page.tsx`, `src/components/app-shell.tsx`, `src/components/navbar.tsx`, `src/components/site-footer.tsx`, `src/components/announcement-popup.tsx` | `src/lib/site-config.ts`, `src/app/api/site-config/route.ts`, `src/app/api/announcements/route.ts` |
|
||||
| Create center tabs | `src/app/create/page.tsx` | `src/components/create/*` |
|
||||
| Text/image generation | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/generation-task-list.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/lib/generation-job-*`, `src/lib/generation-credit-service.ts`, `src/app/api/style-presets/route.ts`, `src/lib/style-preset-store.ts`. Image panels allow multiple active submissions and keep active job cards inside the results column while completed results remain visible. System default model credit deduction is server-side and tied to the selected `system_api_configs` pricing row. |
|
||||
| Text/image generation | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/generation-task-list.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/lib/generation-job-*`, `src/lib/generation-credit-service.ts`, `src/app/api/style-presets/route.ts`, `src/lib/style-preset-store.ts`, `src/lib/layout-composition-skill.ts`. Image panels allow multiple active submissions and keep active job cards inside the results column while completed results remain visible. System default model credit deduction is server-side and tied to the selected `system_api_configs` pricing row. The optional 100 Layout Compositions skill is controlled from admin settings and injects composition guidance into image prompts only when enabled. |
|
||||
| Video generation | `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/components/create/generation-task-list.tsx` | `src/app/api/generate/video/route.ts`. Video panels also allow multiple active submissions and keep active job cards inside the results column. |
|
||||
| Reverse prompt | `src/components/create/reverse-prompt-panel.tsx` | `src/app/api/generate/reverse-prompt/route.ts`, `src/app/api/generate/suggest-prompt/route.ts` |
|
||||
| Model/provider visibility | `src/lib/model-config.ts`, `src/lib/model-config-types.ts`, `src/lib/server-api-config.ts` | `src/app/api/model-config/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/app/api/admin/providers/route.ts`, `src/app/api/user-api-keys/route.ts` |
|
||||
|
||||
@@ -174,6 +174,8 @@ Primary SQL tables touched directly in API routes include:
|
||||
|
||||
`system_api_configs.polling_mode` and `system_api_configs.polling_order` control admin default-model supplier fallback for image generation. `system_api_configs.video_usage_modes` controls whether a video model appears in 文生视频, 图生视频, or both creation entries. `/api/model-config` deduplicates default system rows by media type plus admin display name (`system_api_configs.name`) for clients, while `/api/generate/image` expands the selected row back into allowed supplier candidates with the same media type and display name. System image candidates retry stream-timeout 524 responses once with `stream:false`, and shared custom API transport retries 502/503/504 once before surfacing a concise gateway error. `model_name` stays provider-specific and is used as the upstream request model value.
|
||||
|
||||
`site_config.image_composition_skill_enabled` controls the built-in 100 Layout Compositions image composition skill. `/api/site-config` returns and updates it as `imageCompositionSkillEnabled`, and `/api/generate/image` reads it through `src/lib/layout-composition-skill.ts` before calling SDK, Manifest, custom API, or system default polling providers. The skill source is `nevertoday/100-layout-compositions` under CC BY 4.0; prompt injection must preserve attribution internally and avoid adding literal text/logo/poster elements.
|
||||
|
||||
`redeem_codes` stores admin-generated single-use credit and membership redemption codes. Runtime code generation and redemption go through `src/lib/redeem-code-service.ts`; redemption must lock both the code row and profile row in one transaction before updating `profiles.credits_balance` for credit codes or `profiles.membership_tier`/`membership_expires_at` for membership codes. Credit-code redemptions also insert a `credit_transactions` record.
|
||||
|
||||
`src/lib/yuanjie-image-model-templates.ts` is the canonical source for built-in 元界 AI image model definitions. It maps each documented model to its Manifest request body and stores capability flags so the create page only shows the documented aspect ratio, resolution, image format, and quality controls for the selected model. For 元界 GPT Image 2 / GPT Image 2 官转 and other `size`-enum models, the create page hides the separate aspect-ratio control and shows the documented pixel size values as the resolution list. 元界媒体轮询 uses `is_final === true` as the final-state gate and `state` for success/failure, matching the documented media task contract.
|
||||
|
||||
@@ -225,6 +225,8 @@ Core data areas:
|
||||
- Orders: `orders`.
|
||||
- API credentials: `user_api_keys`, `system_api_configs`, `api_providers`.
|
||||
- Site content: `site_config`, `announcements`, `site_stats`.
|
||||
|
||||
`site_config.image_composition_skill_enabled` is a platform-wide feature switch for the built-in image composition skill. When enabled, image generation uses `src/lib/layout-composition-skill.ts` to deterministically select one of 100 `nevertoday/100-layout-compositions` references and append composition-only guidance to the prompt before any upstream image provider call. The source is CC BY 4.0, so admin UI and docs should keep attribution visible; the generation request should use it as layout guidance rather than as downloadable gallery/reference media.
|
||||
- Jobs/logs: `generation_jobs`, `platform_logs`.
|
||||
|
||||
Because several routes self-migrate compatibility columns, DB bugs often require checking both SQL scripts and route-level `ensure...Schema` functions.
|
||||
|
||||
@@ -55,6 +55,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| Image generation returns upstream error | `src/app/api/generate/image/route.ts`, `src/lib/custom-api-fetch.ts`, `src/lib/custom-image-fallback.ts`, `src/lib/server-api-config.ts` | Resolved custom/system API credentials, endpoint URL, New API normalization, timeout, stream/progress parser, and system-default stream timeout fallback. Gateway 502/503/504 errors are retried once; system default model failures should return the last actionable upstream timeout/gateway message instead of hiding everything behind the generic busy message. |
|
||||
| One submitted image task shows extra images, or the same generated URL appears twice in history | `src/app/api/generate/image/route.ts`, `src/app/api/creation-history/route.ts`, `src/lib/generation-job-worker.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | First check production API logs for `count:1` with upstream messages such as `Got 2 images`, then query `generation_jobs.result.images` and `works` grouped by `user_id,result_url`. The image route should cap persisted response images to the requested count because some upstream/custom providers can return more images than `n`; creation-history POST should serialize same-user same-URL inserts before the existing lookup so concurrent completion/local persistence cannot insert duplicate `works` rows. |
|
||||
| User selects JPEG/WebP but the returned generated image is PNG | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/lightbox.tsx`, `src/components/creation-detail-dialog.tsx`, `src/app/gallery/page.tsx`, `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/lib/media-storage.ts`, `src/lib/utils.ts` | First check PM2 logs for `[Image Generation] Params` and upstream request logs to confirm `outputFormat`/`output_format` reached the server/provider. Then query `works.params->>'outputFormat'` with `result_url` and inspect the object-storage response `Content-Type`/file magic for a recent key. Some providers may ignore `output_format` and still return PNG, so generated-image persistence must normalize the downloaded bytes to the selected format before `persistOriginalImageWithThumbnail(...)` uploads the object and writes history. If the object headers/magic bytes are already JPEG/WebP but the downloaded file still appears as PNG, check frontend `downloadFile(...)` callers and ensure filenames use `getImageDownloadExtension(...)` instead of a hard-coded `.png`. |
|
||||
| Admin enables 100 Layout Compositions but generated images ignore composition guidance, or prompts show unwanted text/logo/poster layout | `src/components/admin/settings-tab.tsx`, `src/app/api/site-config/route.ts`, `src/lib/site-config.ts`, `src/lib/layout-composition-skill.ts`, `src/app/api/generate/image/route.ts` | Verify `/api/site-config` returns `imageCompositionSkillEnabled: true` and `site_config.image_composition_skill_enabled` exists. The image route should call `applyLayoutCompositionSkillToPrompt(...)` after `buildReferenceImagePrompt(...)` and before `mergeStylePrompt(...)`. The skill should append composition-only instructions referencing `nevertoday/100-layout-compositions` CC BY 4.0, but it must explicitly say not to add text, logos, brand marks, or literal poster elements. |
|
||||
| Video generation returns upstream error | `src/app/api/generate/video/route.ts`, `src/lib/custom-api-fetch.ts`, `src/lib/server-api-config.ts` | Reference image upload/compression, endpoint URL, response parser, persistence timeout. |
|
||||
| Wrong image size, aspect ratio, or custom API says returned resolution is lower than requested | `src/lib/model-config.ts`, `src/app/api/generate/image/route.ts` | `resolveImageSize`, `resolveCustomApiImageSize`, New API/DALL-E size normalization, prompt aspect hint, and custom API result qualification. Exact or larger generated images pass normally; lower-resolution images with matching aspect ratio and at least 60% of the requested dimensions are accepted as degraded upstream output instead of failing the job, while wrong-ratio or much smaller images are still rejected. |
|
||||
| Text-to-image or image-to-image says `请在提示词中写明画面比例` even after selecting a Yuanjie resolution such as `4K 竖版 (3:4)` | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/lib/yuanjie-image-model-templates.ts` | Some Yuanjie image templates set `supportsAspectRatio: false` and encode orientation in `resolution`/`size` options. Generation validation must derive the ratio from the selected resolution label or dimensions instead of requiring a separate aspect-ratio control. Image-to-image should also default count to `1` rather than requiring prompt inference for `生成数量`. |
|
||||
|
||||
@@ -73,7 +73,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| Worker loop | `src/lib/generation-job-worker.ts` | Picks and processes queued jobs. After successful system default image/video generation, it calls `src/lib/generation-credit-service.ts` to deduct credits from `profiles.credits_balance`, insert `credit_transactions`, and add `creditsCost`/`creditsBalance` to the job result for frontend display. Failed generation jobs do not enter the charge path. |
|
||||
| Internal runner | `src/lib/generation-job-runner.ts` | Calls `/api/generate/image` or `/api/generate/video` with internal headers. |
|
||||
| ETA/progress | `src/lib/generation-job-estimates.ts` | Runtime schema, ETA samples, progress payload. |
|
||||
| Image route | `src/app/api/generate/image/route.ts`, `src/lib/reference-image-prompt.ts` | SDK + custom/system API + New API image compatibility, persistence. New image originals persist through `src/lib/media-storage.ts` into object storage, while local WEBP thumbnails are returned as `thumbnails`/`thumbnailUrls` for preview rendering and `dimensions` maps each original URL to persisted width/height so history detail metadata can avoid loading originals. Generated image originals are normalized to the user-selected output format before upload, so providers that ignore `output_format` and return PNG still produce `.jpg`/`.webp` objects when JPEG/WebP was requested. For admin default system models, image generation resolves all same-type/same-display-name default API candidates, automatically retries stream-timeout failures once with `stream:false`, and returns actionable upstream timeout/gateway messages when all candidates fail. If a Manifest provider such as 元界 returns result URLs but MiaoJing cannot download or save them, the route reports a platform download/save failure instead of a resolution mismatch. User custom APIs remain single-config and do not use this polling fallback. For image-to-image, optional `referenceImageAnnotations` are merged into the model prompt so `@参考图N` maps to the corresponding uploaded reference image. |
|
||||
| Image route | `src/app/api/generate/image/route.ts`, `src/lib/reference-image-prompt.ts`, `src/lib/layout-composition-skill.ts` | SDK + custom/system API + New API image compatibility, persistence. New image originals persist through `src/lib/media-storage.ts` into object storage, while local WEBP thumbnails are returned as `thumbnails`/`thumbnailUrls` for preview rendering and `dimensions` maps each original URL to persisted width/height so history detail metadata can avoid loading originals. Generated image originals are normalized to the user-selected output format before upload, so providers that ignore `output_format` and return PNG still produce `.jpg`/`.webp` objects when JPEG/WebP was requested. When `site_config.image_composition_skill_enabled` is true, `src/lib/layout-composition-skill.ts` deterministically selects one of the 100 CC BY 4.0 `nevertoday/100-layout-compositions` references and appends composition guidance before style prompts and upstream requests; it should not add text, logos, or literal poster elements. For admin default system models, image generation resolves all same-type/same-display-name default API candidates, automatically retries stream-timeout failures once with `stream:false`, and returns actionable upstream timeout/gateway messages when all candidates fail. If a Manifest provider such as 元界 returns result URLs but MiaoJing cannot download or save them, the route reports a platform download/save failure instead of a resolution mismatch. User custom APIs remain single-config and do not use this polling fallback. For image-to-image, optional `referenceImageAnnotations` are merged into the model prompt so `@参考图N` maps to the corresponding uploaded reference image. |
|
||||
| Video route | `src/app/api/generate/video/route.ts`, `src/lib/reference-image-prompt.ts` | SDK + custom/system API video, persistence. Generated video data URLs and upstream video URLs are persisted through `localStorage.uploadFileObjectOnly(...)` under `generated/videos`, so production video originals live in object storage when configured. Video create panels must use backend returned `creditsCost`/`creditsBalance` after job success; they should not locally predict or deduct credits. For image-to-video, optional `referenceImageAnnotations` are merged into the model prompt so `@参考图N` maps to the corresponding uploaded reference image. |
|
||||
| Custom API transport | `src/lib/custom-api-fetch.ts`, `src/lib/custom-image-fallback.ts` | Headers, one retry for 502/503/504 gateway failures, progress JSON parsing, upstream error parsing, stream-to-sync fallback policy for system image APIs. |
|
||||
| Server API resolution | `src/lib/server-api-config.ts`, `src/lib/yuanjie-system-manifest.ts` | Resolves user custom API and admin system API IDs into decrypted credentials, enforces system API default visibility plus membership-tier allowlists before generation, and builds default-model polling candidates by media type plus admin display name (`system_api_configs.name`). For known 元界 system rows with missing or stale `manifest_path`, both direct system API resolution and default-model polling candidates can rewrite the built-in Manifest and normalize `api_url` to the 元界 base URL before generation. The upstream `model_name` remains the per-provider request model only. |
|
||||
@@ -131,7 +131,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| Data import/export | `src/components/admin/data-management-tab.tsx` | `src/app/api/admin/data-export/route.ts`, `src/app/api/admin/data-import/route.ts`, `scripts/migration-integrity-check.mjs`, `scripts/migration-integrity-check-helpers.mjs`. Export bundles storage URLs from works/site config into `_media`; import restores those files through `src/lib/local-storage.ts`, maps old IDs, merges duplicate works only within the same `user_id`, and runs DB writes in a transaction. Import preserves password hashes, encrypted API keys, `manifest_path`, system API pricing fields, and `redeem_codes` state so users, credentials, works, intelligent API configs, and unused/used redemption state survive migration. Run `pnpm run migration:check` before and after production migration; the checker defaults to port 8000 and counts bounded media probe failures instead of aborting on the first slow URL. |
|
||||
| System upgrade | `src/components/admin/system-upgrade-tab.tsx` | `src/app/api/admin/upgrade/route.ts`, `scripts/admin-upgrade-runner.mjs` |
|
||||
| Logs/tasks | `src/components/admin/log-management-tab.tsx`, `src/components/admin/task-management-tab.tsx` | `src/lib/platform-logs.ts`, `src/app/api/admin/logs/route.ts`, `src/app/api/admin/generation-jobs/route.ts` |
|
||||
| Settings | `src/components/admin/settings-tab.tsx` | `src/app/api/site-config/route.ts`, `src/app/api/admin/email-settings/route.ts`, `src/app/api/admin/send-email/route.ts` |
|
||||
| Settings | `src/components/admin/settings-tab.tsx` | `src/app/api/site-config/route.ts`, `src/app/api/admin/email-settings/route.ts`, `src/app/api/admin/send-email/route.ts`. The feature toggles section includes membership enablement and the 100 Layout Compositions image composition skill switch. |
|
||||
|
||||
## Storage And Downloads
|
||||
|
||||
|
||||
@@ -921,6 +921,7 @@ CREATE INDEX IF NOT EXISTS generation_jobs_user_created_idx ON generation_jobs (
|
||||
CREATE INDEX IF NOT EXISTS generation_jobs_provider_model_created_idx ON generation_jobs (type, provider, model_name, created_at DESC);
|
||||
|
||||
ALTER TABLE site_config ADD COLUMN IF NOT EXISTS log_retention_days INTEGER NOT NULL DEFAULT 30;
|
||||
ALTER TABLE site_config ADD COLUMN IF NOT EXISTS image_composition_skill_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
UPDATE site_config SET log_retention_days = LEAST(90, GREATEST(1, log_retention_days));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform_log_settings (
|
||||
|
||||
@@ -296,6 +296,7 @@ CREATE TABLE IF NOT EXISTS site_config (
|
||||
public_security_filing_url TEXT NOT NULL DEFAULT '',
|
||||
redeem_code_mall_url TEXT NOT NULL DEFAULT '',
|
||||
log_retention_days INTEGER NOT NULL DEFAULT 30,
|
||||
image_composition_skill_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
@@ -483,7 +484,8 @@ ALTER TABLE site_config
|
||||
ADD COLUMN IF NOT EXISTS public_security_filing_info TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS public_security_filing_url TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS redeem_code_mall_url TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS log_retention_days INTEGER NOT NULL DEFAULT 30;
|
||||
ADD COLUMN IF NOT EXISTS log_retention_days INTEGER NOT NULL DEFAULT 30,
|
||||
ADD COLUMN IF NOT EXISTS image_composition_skill_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
ALTER TABLE generation_jobs
|
||||
ADD COLUMN IF NOT EXISTS user_id UUID,
|
||||
|
||||
51
scripts/test-layout-composition-skill.mjs
Normal file
51
scripts/test-layout-composition-skill.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
function read(path) {
|
||||
return readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||
}
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await runTest('built-in layout composition skill records source, license, and 100 layout references', () => {
|
||||
const source = read('src/lib/layout-composition-skill.ts');
|
||||
|
||||
assert.match(source, /100-layout-compositions/);
|
||||
assert.match(source, /CC BY 4\.0/);
|
||||
assert.match(source, /TOTAL_LAYOUT_COMPOSITION_COUNT\s*=\s*100/);
|
||||
assert.match(source, /layoutNumber\.toString\(\)\.padStart\(3,\s*'0'\)/);
|
||||
assert.match(source, /images\/\$\{id\}\.png/);
|
||||
assert.match(source, /thumbnails\/\$\{id\}\.jpg/);
|
||||
assert.match(source, /不要添加文字、Logo、品牌标识或海报排版/);
|
||||
});
|
||||
|
||||
await runTest('site config exposes an admin-controlled image composition skill toggle', () => {
|
||||
const route = read('src/app/api/site-config/route.ts');
|
||||
const client = read('src/lib/site-config.ts');
|
||||
const settings = read('src/components/admin/settings-tab.tsx');
|
||||
|
||||
assert.match(route, /image_composition_skill_enabled BOOLEAN NOT NULL DEFAULT FALSE/);
|
||||
assert.match(route, /imageCompositionSkillEnabled/);
|
||||
assert.match(client, /imageCompositionSkillEnabled: boolean/);
|
||||
assert.match(settings, /100 Layout Compositions/);
|
||||
assert.match(settings, /handleImageCompositionSkillToggle/);
|
||||
});
|
||||
|
||||
await runTest('image generation route applies the layout composition skill before upstream requests', () => {
|
||||
const route = read('src/app/api/generate/image/route.ts');
|
||||
|
||||
assert.match(route, /applyLayoutCompositionSkillToPrompt/);
|
||||
assert.match(route, /promptWithCompositionSkill/);
|
||||
assert.match(route, /promptForGeneration = mergeStylePrompt\(promptWithCompositionSkill, stylePrompt\)/);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
@@ -56,7 +56,7 @@ const UUID_ID_TABLES = new Set([
|
||||
|
||||
const TABLE_COLUMNS: Record<string, string[]> = {
|
||||
profiles: ['id', 'email', 'nickname', 'display_nickname', 'avatar_url', 'phone', 'role', 'membership_tier', 'membership_expires_at', 'credits_balance', 'daily_quota_used', 'daily_quota_limit', 'is_active', 'preferred_theme', 'watermark_disabled', 'invite_code', 'referred_by_user_id', 'created_at', 'updated_at'],
|
||||
site_config: ['id', 'site_name', 'site_tab_title', 'site_description', 'site_keywords', 'logo_url', 'favicon_url', 'announcement', 'membership_enabled', 'terms_of_service', 'privacy_policy', 'about_us', 'help_center', 'filing_info', 'filing_url', 'public_security_filing_info', 'public_security_filing_url', 'redeem_code_mall_url', 'log_retention_days', 'updated_at'],
|
||||
site_config: ['id', 'site_name', 'site_tab_title', 'site_description', 'site_keywords', 'logo_url', 'favicon_url', 'announcement', 'membership_enabled', 'terms_of_service', 'privacy_policy', 'about_us', 'help_center', 'filing_info', 'filing_url', 'public_security_filing_info', 'public_security_filing_url', 'redeem_code_mall_url', 'log_retention_days', 'image_composition_skill_enabled', 'updated_at'],
|
||||
site_stats: ['id', 'total_visits', 'total_users', 'total_generations', 'updated_at'],
|
||||
announcements: ['id', 'title', 'content', 'type', 'is_active', 'starts_at', 'expires_at', 'created_at', 'updated_at'],
|
||||
works: ['id', 'user_id', 'title', 'type', 'prompt', 'negative_prompt', 'params', 'result_url', 'thumbnail_url', 'width', 'height', 'duration', 'status', 'is_public', 'likes_count', 'views_count', 'credits_cost', 'created_at', 'updated_at'],
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
persistOriginalImageWithThumbnail,
|
||||
readImageBufferFromUrl,
|
||||
} from '@/lib/media-storage';
|
||||
import { applyLayoutCompositionSkillToPrompt } from '@/lib/layout-composition-skill';
|
||||
|
||||
interface CustomApiConfig {
|
||||
apiUrl: string;
|
||||
@@ -1073,7 +1074,14 @@ export async function POST(request: NextRequest) {
|
||||
const resolvedOutputFormat = normalizeImageOutputFormat(outputFormat);
|
||||
const resolvedImageQuality = normalizeImageQuality(imageQuality);
|
||||
const promptWithReferenceImages = buildReferenceImagePrompt(prompt, referenceImages.length, referenceImageAnnotations);
|
||||
const promptForGeneration = mergeStylePrompt(promptWithReferenceImages, stylePrompt);
|
||||
const layoutCompositionSkill = await applyLayoutCompositionSkillToPrompt({
|
||||
prompt: promptWithReferenceImages,
|
||||
aspectRatio: resolvedAutoParams.aspectRatio,
|
||||
resolution: resolvedAutoParams.resolution,
|
||||
hasReferenceImage: referenceImages.length > 0,
|
||||
});
|
||||
const promptWithCompositionSkill = layoutCompositionSkill.prompt;
|
||||
const promptForGeneration = mergeStylePrompt(promptWithCompositionSkill, stylePrompt);
|
||||
const requestedCustomSize = size && size !== 'auto'
|
||||
? size
|
||||
: resolveCustomApiImageSize(resolvedAutoParams.aspectRatio, resolvedAutoParams.resolution);
|
||||
@@ -1122,6 +1130,7 @@ export async function POST(request: NextRequest) {
|
||||
hasImage: referenceImages.length > 0,
|
||||
strength,
|
||||
promptLength: prompt.length,
|
||||
compositionSkill: layoutCompositionSkill.enabled ? layoutCompositionSkill.layoutId : undefined,
|
||||
stream: stream !== false,
|
||||
}));
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { localStorage } from '@/lib/local-storage';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { DEFAULT_ABOUT_US, DEFAULT_HELP_CENTER, DEFAULT_PRIVACY_POLICY, DEFAULT_TERMS_OF_SERVICE } from '@/lib/site-policy-defaults';
|
||||
import { cleanupExpiredPlatformLogs, setPlatformLogRetentionDays, writePlatformLog } from '@/lib/platform-logs';
|
||||
import { clearLayoutCompositionSkillCache } from '@/lib/layout-composition-skill';
|
||||
|
||||
const DEFAULT_RESPONSE = {
|
||||
siteName: '妙境',
|
||||
@@ -21,6 +22,7 @@ const DEFAULT_RESPONSE = {
|
||||
publicSecurityFilingUrl: '',
|
||||
redeemCodeMallUrl: '',
|
||||
logRetentionDays: 30,
|
||||
imageCompositionSkillEnabled: false,
|
||||
};
|
||||
|
||||
type SiteConfigRow = {
|
||||
@@ -39,6 +41,7 @@ type SiteConfigRow = {
|
||||
public_security_filing_url?: string | null;
|
||||
redeem_code_mall_url?: string | null;
|
||||
log_retention_days?: number | null;
|
||||
image_composition_skill_enabled?: boolean | null;
|
||||
};
|
||||
|
||||
let siteConfigColumnsReady = false;
|
||||
@@ -56,6 +59,7 @@ async function ensureSiteConfigColumns(client: Awaited<ReturnType<typeof getDbCl
|
||||
await client.query("ALTER TABLE site_config ADD COLUMN IF NOT EXISTS public_security_filing_url TEXT NOT NULL DEFAULT ''");
|
||||
await client.query("ALTER TABLE site_config ADD COLUMN IF NOT EXISTS redeem_code_mall_url TEXT NOT NULL DEFAULT ''");
|
||||
await client.query('ALTER TABLE site_config ADD COLUMN IF NOT EXISTS log_retention_days INTEGER NOT NULL DEFAULT 30');
|
||||
await client.query('ALTER TABLE site_config ADD COLUMN IF NOT EXISTS image_composition_skill_enabled BOOLEAN NOT NULL DEFAULT FALSE');
|
||||
await client.query('UPDATE site_config SET log_retention_days = LEAST(90, GREATEST(1, log_retention_days))');
|
||||
await client.query("UPDATE site_config SET terms_of_service = $1 WHERE terms_of_service = ''", [DEFAULT_TERMS_OF_SERVICE]);
|
||||
await client.query("UPDATE site_config SET privacy_policy = $1 WHERE privacy_policy = ''", [DEFAULT_PRIVACY_POLICY]);
|
||||
@@ -94,6 +98,7 @@ function normalizeResponse(data?: SiteConfigRow | null) {
|
||||
publicSecurityFilingUrl: data?.public_security_filing_url?.trim() || '',
|
||||
redeemCodeMallUrl: data?.redeem_code_mall_url?.trim() || '',
|
||||
logRetentionDays: Math.min(90, Math.max(1, Number(data?.log_retention_days || 30))),
|
||||
imageCompositionSkillEnabled: data?.image_composition_skill_enabled === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,7 +144,7 @@ export async function GET() {
|
||||
try {
|
||||
await ensureSiteConfigColumnsOnce(client);
|
||||
const result = await client.query(
|
||||
'SELECT site_name, site_tab_title, logo_url, favicon_url, membership_enabled, terms_of_service, privacy_policy, about_us, help_center, filing_info, filing_url, public_security_filing_info, public_security_filing_url, redeem_code_mall_url, log_retention_days FROM site_config WHERE id = 1'
|
||||
'SELECT site_name, site_tab_title, logo_url, favicon_url, membership_enabled, terms_of_service, privacy_policy, about_us, help_center, filing_info, filing_url, public_security_filing_info, public_security_filing_url, redeem_code_mall_url, log_retention_days, image_composition_skill_enabled FROM site_config WHERE id = 1'
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
@@ -165,7 +170,7 @@ export async function PUT(request: NextRequest) {
|
||||
return NextResponse.json({ error: '无效的请求体' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { siteName, siteTabTitle, membershipEnabled, logoBase64, faviconBase64, termsOfService, privacyPolicy, aboutUs, helpCenter, filingInfo, filingUrl, publicSecurityFilingInfo, publicSecurityFilingUrl, redeemCodeMallUrl, logRetentionDays } = body as {
|
||||
const { siteName, siteTabTitle, membershipEnabled, logoBase64, faviconBase64, termsOfService, privacyPolicy, aboutUs, helpCenter, filingInfo, filingUrl, publicSecurityFilingInfo, publicSecurityFilingUrl, redeemCodeMallUrl, logRetentionDays, imageCompositionSkillEnabled } = body as {
|
||||
siteName?: string;
|
||||
siteTabTitle?: string;
|
||||
membershipEnabled?: boolean;
|
||||
@@ -181,6 +186,7 @@ export async function PUT(request: NextRequest) {
|
||||
publicSecurityFilingUrl?: string;
|
||||
redeemCodeMallUrl?: string;
|
||||
logRetentionDays?: number;
|
||||
imageCompositionSkillEnabled?: boolean;
|
||||
};
|
||||
|
||||
const client = await getDbClient();
|
||||
@@ -216,6 +222,11 @@ export async function PUT(request: NextRequest) {
|
||||
await setPlatformLogRetentionDays(client, safeLogRetentionDays);
|
||||
await cleanupExpiredPlatformLogs(client);
|
||||
}
|
||||
if (typeof imageCompositionSkillEnabled === 'boolean') {
|
||||
updates.push(`image_composition_skill_enabled = $${paramIdx++}`);
|
||||
params.push(imageCompositionSkillEnabled);
|
||||
clearLayoutCompositionSkillCache();
|
||||
}
|
||||
const logoUrl = await saveImageDataUrl(logoBase64, 'logo');
|
||||
const faviconUrl = await saveImageDataUrl(faviconBase64, 'favicon');
|
||||
if (logoUrl) { updates.push(`logo_url = $${paramIdx++}`); params.push(logoUrl); }
|
||||
@@ -233,7 +244,7 @@ export async function PUT(request: NextRequest) {
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
'SELECT site_name, site_tab_title, logo_url, favicon_url, membership_enabled, terms_of_service, privacy_policy, about_us, help_center, filing_info, filing_url, public_security_filing_info, public_security_filing_url, redeem_code_mall_url, log_retention_days FROM site_config WHERE id = 1'
|
||||
'SELECT site_name, site_tab_title, logo_url, favicon_url, membership_enabled, terms_of_service, privacy_policy, about_us, help_center, filing_info, filing_url, public_security_filing_info, public_security_filing_url, redeem_code_mall_url, log_retention_days, image_composition_skill_enabled FROM site_config WHERE id = 1'
|
||||
);
|
||||
|
||||
void writePlatformLog({
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useSiteConfig } from '@/lib/site-config';
|
||||
import { useAuth } from '@/lib/auth-store';
|
||||
import { Crown, Globe, Loader2, Logs, Mail, Save, Send, ToggleLeft, Upload } from 'lucide-react';
|
||||
import { Crown, Globe, LayoutTemplate, Loader2, Logs, Mail, Save, Send, ToggleLeft, Upload } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
// ============================================================
|
||||
// Tab 6: Settings
|
||||
@@ -123,6 +123,7 @@ export default function SettingsTab() {
|
||||
const [formPublicSecurityFilingInfo, setFormPublicSecurityFilingInfo] = useState('');
|
||||
const [formPublicSecurityFilingUrl, setFormPublicSecurityFilingUrl] = useState('');
|
||||
const [formLogRetentionDays, setFormLogRetentionDays] = useState(30);
|
||||
const [formImageCompositionSkillEnabled, setFormImageCompositionSkillEnabled] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [emailSettings, setEmailSettings] = useState<EmailSettingsForm>(DEFAULT_EMAIL_SETTINGS);
|
||||
@@ -159,6 +160,7 @@ export default function SettingsTab() {
|
||||
setFormPublicSecurityFilingInfo(siteConfig.publicSecurityFilingInfo);
|
||||
setFormPublicSecurityFilingUrl(siteConfig.publicSecurityFilingUrl);
|
||||
setFormLogRetentionDays(siteConfig.logRetentionDays);
|
||||
setFormImageCompositionSkillEnabled(siteConfig.imageCompositionSkillEnabled);
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [siteConfig, siteConfigLoaded, initialized]);
|
||||
@@ -167,6 +169,10 @@ export default function SettingsTab() {
|
||||
setFormMembershipEnabled(siteConfig.membershipEnabled !== false);
|
||||
}, [siteConfig.membershipEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
setFormImageCompositionSkillEnabled(siteConfig.imageCompositionSkillEnabled === true);
|
||||
}, [siteConfig.imageCompositionSkillEnabled]);
|
||||
|
||||
const loadEmailSettings = useCallback(async () => {
|
||||
if (!accessToken) return;
|
||||
setEmailLoading(true);
|
||||
@@ -285,6 +291,7 @@ export default function SettingsTab() {
|
||||
publicSecurityFilingInfo: formPublicSecurityFilingInfo,
|
||||
publicSecurityFilingUrl: formPublicSecurityFilingUrl,
|
||||
logRetentionDays: formLogRetentionDays,
|
||||
imageCompositionSkillEnabled: formImageCompositionSkillEnabled,
|
||||
});
|
||||
// Clear pending uploads after save
|
||||
setFormLogoBase64(null);
|
||||
@@ -308,6 +315,17 @@ export default function SettingsTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageCompositionSkillToggle = async (checked: boolean) => {
|
||||
setFormImageCompositionSkillEnabled(checked);
|
||||
try {
|
||||
await saveSiteConfig({ imageCompositionSkillEnabled: checked });
|
||||
toast.success(checked ? '构图优化 Skill 已开启' : '构图优化 Skill 已关闭');
|
||||
} catch (err) {
|
||||
setFormImageCompositionSkillEnabled(!checked);
|
||||
toast.error(err instanceof Error ? err.message : '构图优化 Skill 保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailSettingChange = <K extends keyof EmailSettingsForm>(key: K, value: EmailSettingsForm[K]) => {
|
||||
setEmailSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
@@ -445,7 +463,7 @@ export default function SettingsTab() {
|
||||
{ value: 'logs', label: '日志设置', description: `保存 ${formLogRetentionDays} 天` },
|
||||
{ value: 'email', label: '邮箱服务', description: emailSettings.enabled ? '已启用' : '未启用' },
|
||||
{ value: 'mail', label: '用户邮件', description: mailMode === 'all' ? '全部用户' : `${selectedRecipients.length} 个收件人` },
|
||||
{ value: 'features', label: '功能开关', description: formMembershipEnabled ? '会员功能开启' : '会员功能关闭' },
|
||||
{ value: 'features', label: '功能开关', description: formImageCompositionSkillEnabled ? '构图 Skill 开启' : formMembershipEnabled ? '会员功能开启' : '会员功能关闭' },
|
||||
]}
|
||||
activeValue={activeSection}
|
||||
onChange={setActiveSection}
|
||||
@@ -1005,6 +1023,22 @@ export default function SettingsTab() {
|
||||
onCheckedChange={handleMembershipToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<LayoutTemplate className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">100 Layout Compositions 构图优化 Skill</p>
|
||||
<p className="text-xs text-muted-foreground">开启后,文生图/图生图任务会自动注入构图策略;来源 nevertoday/100-layout-compositions,CC BY 4.0。</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formImageCompositionSkillEnabled}
|
||||
onCheckedChange={handleImageCompositionSkillToggle}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
103
src/lib/layout-composition-skill.ts
Normal file
103
src/lib/layout-composition-skill.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
export const TOTAL_LAYOUT_COMPOSITION_COUNT = 100;
|
||||
const SOURCE_REPOSITORY = 'https://github.com/nevertoday/100-layout-compositions';
|
||||
const SOURCE_LICENSE = 'CC BY 4.0';
|
||||
|
||||
export type LayoutCompositionSkillInput = {
|
||||
prompt: string;
|
||||
aspectRatio?: string;
|
||||
resolution?: string;
|
||||
hasReferenceImage?: boolean;
|
||||
};
|
||||
|
||||
export type LayoutCompositionSkillResult = {
|
||||
enabled: boolean;
|
||||
prompt: string;
|
||||
layoutId?: string;
|
||||
attribution?: string;
|
||||
};
|
||||
|
||||
let cachedEnabled: { value: boolean; expiresAt: number } | null = null;
|
||||
|
||||
export function clearLayoutCompositionSkillCache() {
|
||||
cachedEnabled = null;
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function stableLayoutNumber(input: LayoutCompositionSkillInput): number {
|
||||
const hash = createHash('sha256')
|
||||
.update([
|
||||
normalizeText(input.prompt).slice(0, 500),
|
||||
normalizeText(input.aspectRatio),
|
||||
normalizeText(input.resolution),
|
||||
input.hasReferenceImage ? 'reference' : 'text',
|
||||
].join('|'))
|
||||
.digest();
|
||||
return (hash[0] % TOTAL_LAYOUT_COMPOSITION_COUNT) + 1;
|
||||
}
|
||||
|
||||
export function getLayoutCompositionReference(layoutNumber: number) {
|
||||
layoutNumber = Math.min(TOTAL_LAYOUT_COMPOSITION_COUNT, Math.max(1, Math.floor(layoutNumber)));
|
||||
const id = `layout-${layoutNumber.toString().padStart(3, '0')}`;
|
||||
return {
|
||||
id,
|
||||
sourceImage: `${SOURCE_REPOSITORY}/blob/main/images/${id}.png`,
|
||||
thumbnail: `${SOURCE_REPOSITORY}/blob/main/thumbnails/${id}.jpg`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLayoutCompositionInstruction(input: LayoutCompositionSkillInput): {
|
||||
layoutId: string;
|
||||
instruction: string;
|
||||
attribution: string;
|
||||
} {
|
||||
const reference = getLayoutCompositionReference(stableLayoutNumber(input));
|
||||
const ratio = normalizeText(input.aspectRatio) || '当前画面比例';
|
||||
const resolution = normalizeText(input.resolution) || '当前分辨率';
|
||||
return {
|
||||
layoutId: reference.id,
|
||||
attribution: `Composition reference inspired by nevertoday/100-layout-compositions ${reference.id}, ${SOURCE_LICENSE}: ${SOURCE_REPOSITORY}`,
|
||||
instruction: [
|
||||
`构图优化 Skill:参考 ${reference.id} 的版式构成规律组织画面。`,
|
||||
`保持用户原始主体、内容和风格不变,只借鉴画面重心、留白比例、前中后景层次、视觉动线、主体位置、裁切边界和节奏关系。`,
|
||||
`适配 ${ratio} 与 ${resolution},让主体更清晰、层次更稳定、边缘不过度拥挤。`,
|
||||
'不要添加文字、Logo、品牌标识或海报排版,不要复制参考图里的具体图形元素。',
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
export async function isLayoutCompositionSkillEnabled(): Promise<boolean> {
|
||||
if (process.env.IMAGE_COMPOSITION_SKILL_ENABLED === 'true') return true;
|
||||
if (process.env.IMAGE_COMPOSITION_SKILL_ENABLED === 'false') return false;
|
||||
if (cachedEnabled && cachedEnabled.expiresAt > Date.now()) return cachedEnabled.value;
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await client.query('ALTER TABLE site_config ADD COLUMN IF NOT EXISTS image_composition_skill_enabled BOOLEAN NOT NULL DEFAULT FALSE');
|
||||
const result = await client.query('SELECT image_composition_skill_enabled FROM site_config WHERE id = 1');
|
||||
const value = result.rows[0]?.image_composition_skill_enabled === true;
|
||||
cachedEnabled = { value, expiresAt: Date.now() + 30_000 };
|
||||
return value;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyLayoutCompositionSkillToPrompt(input: LayoutCompositionSkillInput): Promise<LayoutCompositionSkillResult> {
|
||||
const enabled = await isLayoutCompositionSkillEnabled();
|
||||
if (!enabled) return { enabled: false, prompt: input.prompt };
|
||||
const { layoutId, instruction, attribution } = buildLayoutCompositionInstruction(input);
|
||||
return {
|
||||
enabled: true,
|
||||
layoutId,
|
||||
attribution,
|
||||
prompt: `${input.prompt.trim()}\n\n${instruction}`,
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export interface SiteConfig {
|
||||
publicSecurityFilingUrl: string;
|
||||
redeemCodeMallUrl: string;
|
||||
logRetentionDays: number;
|
||||
imageCompositionSkillEnabled: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SITE_CONFIG: SiteConfig = {
|
||||
@@ -37,6 +38,7 @@ const DEFAULT_SITE_CONFIG: SiteConfig = {
|
||||
publicSecurityFilingUrl: '',
|
||||
redeemCodeMallUrl: '',
|
||||
logRetentionDays: 30,
|
||||
imageCompositionSkillEnabled: false,
|
||||
};
|
||||
|
||||
const CACHE_KEY = 'miaojing_site_config_cache';
|
||||
@@ -69,6 +71,7 @@ function normalizeSiteConfig(data?: Partial<SiteConfig> | null): SiteConfig {
|
||||
publicSecurityFilingUrl: data?.publicSecurityFilingUrl?.trim() || '',
|
||||
redeemCodeMallUrl: data?.redeemCodeMallUrl?.trim() || '',
|
||||
logRetentionDays: Math.min(90, Math.max(1, Number(data?.logRetentionDays || DEFAULT_SITE_CONFIG.logRetentionDays))),
|
||||
imageCompositionSkillEnabled: data?.imageCompositionSkillEnabled === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -206,6 +209,7 @@ export function useSiteConfig() {
|
||||
publicSecurityFilingUrl?: string;
|
||||
redeemCodeMallUrl?: string;
|
||||
logRetentionDays?: number;
|
||||
imageCompositionSkillEnabled?: boolean;
|
||||
}): Promise<SiteConfig | null> => {
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
|
||||
Reference in New Issue
Block a user