chore: sync source from production

This commit is contained in:
FengLee
2026-05-20 09:06:52 +08:00
parent 6d6fdf286a
commit f87dab7284
42 changed files with 2007 additions and 674 deletions

View File

@@ -13,10 +13,12 @@ This file is the required entry point for Codex work in this repository. Its job
- Bug report or regression: `docs/codex-miaojing/bug-location-guide.md`
- API contract, route, auth, request body: `docs/codex-miaojing/api-reference.md`
- System boundaries, data flow, deployment: `docs/codex-miaojing/architecture.md`
- Custom integration keyword such as `元界`, `mozheAPI`, or `智能配置 API`: also read `docs/codex-miaojing/custom-integrations.md` and search long-term memory for the keyword before editing.
4. Verify the file paths against current source with `rg` or direct file reads.
5. Make the smallest scoped code change that fits the existing architecture.
6. For every adjustment or modification, check whether the change affects any project knowledge document. If it changes code location, UI behavior, API behavior, data shape, schema expectation, deployment flow, verification method, or bug-diagnosis path, update the corresponding document in the same commit.
7. Before finishing new development, classify the production delivery path: admin-console hot update package or admin-console cold update package. Record upgrade, backup, rollback, and health-check implications when the change affects deployable behavior.
6. For every adjustment or modification, check whether the change affects any project knowledge document. If it changes code location, UI behavior, API behavior, data shape, schema expectation, deployment flow, verification method, bug-diagnosis path, or provider/platform-specific integration logic, update the corresponding document in the same commit.
7. If a custom integration rule is durable across future sessions, write it to long-term memory instead of relying only on chat context.
8. Before finishing new development, classify the production delivery path: admin-console hot update package or admin-console cold update package. Record upgrade, backup, rollback, and health-check implications when the change affects deployable behavior.
## Repository Identity
@@ -38,6 +40,7 @@ This file is the required entry point for Codex work in this repository. Its job
- If server deployment is requested later, verify the active runtime tree and PM2 cwd before editing. Do not assume a production tree from memory.
- Production access verified on 2026-05-14 used `ssh -p 5238 root@124.174.9.29`; PM2 still served the live tree from `/opt/miaojingAI` through Node under `/data/miaojingAI/node/node-v24.15.0-linux-x64/bin`, with web/API/console ports `8000/8100/8200`. `/root/miaojingAI` may coexist and must not be treated as live without PM2 confirmation.
- When syncing source into `/opt/miaojingAI`, preserve production-only runtime files such as `.env.local`, `node_modules`, `.next`, `dist`, `backups`, local storage, and the production `ecosystem.config.cjs`. The repository copy may point at `/root/miaojingAI` and ports `5000/5100/5200`; overwriting production `ecosystem.config.cjs` breaks the live nginx upstream until restored.
- New-environment migrations must verify database table ownership as well as grants. If `LOCAL_DB_URL` uses the app user but restored tables are still owned by `postgres`, runtime compatibility checks can fail with `must be owner of table ...`, which can make `/api/model-config` return no `systemApis` even when backend default models exist.
## Fast Routing Map
@@ -47,15 +50,16 @@ 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/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. |
| 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. |
| 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` |
| Custom integrations (`元界`, `mozheAPI`, `智能配置 API`) | `docs/codex-miaojing/custom-integrations.md` first | Then use the feature/bug/API/architecture doc that matches the symptom. Search long-term memory for the exact keyword before changing code. |
| User auth/login/register/profile | `src/lib/session-auth.ts`, `src/lib/auth-store.ts` | `src/app/api/auth/*`, `src/app/api/profile/*` |
| Admin console | `src/app/console/page.tsx`, `src/app/console/dashboard/page.tsx`, `src/modules/console/pages/*` | `src/components/admin/*`, `src/app/api/admin/*` |
| Canvas (legacy, disabled in UI) | `src/app/canvas/page.tsx`, `src/components/canvas/infinite-canvas-workspace.tsx`, `src/components/canvas/react-flow-canvas.tsx` | `/canvas` intentionally returns 404 and navbar must not show `画布`; legacy source/API files remain only for future cleanup or explicit re-enable work. |
| Gallery and creation history | `src/app/gallery/page.tsx`, `src/app/profile/page.tsx`, `src/components/profile/creation-history-tab.tsx`, `src/components/image-metadata-badge.tsx` | `src/lib/creation-history-store.ts`, `src/app/api/gallery/*`, `src/app/api/creation-history/route.ts`. Gallery/detail image previews show actual ratio and natural resolution in the upper-right badge. History also refreshes on `miaojing_auth_updated` after login/account switch. |
| Local/object files/downloads | `src/lib/local-storage.ts`, `src/app/api/local-storage/[...path]/route.ts` | `src/app/api/download/route.ts`, `scripts/storage-sync-to-object.mjs`, `scripts/rainyun-ros-prepare.mjs`. Public URLs stay `/api/local-storage/<key>` while the backend can be `STORAGE_MODE=local`, `dual`, or `object`; dual mode writes local disk first and mirrors objects. Rainyun ROS API is a control-plane helper for bucket creation/config generation; runtime file IO still uses S3-compatible `OBJECT_STORAGE_*`. |
| Gallery and creation history | `src/app/gallery/page.tsx`, `src/app/profile/page.tsx`, `src/components/profile/creation-history-tab.tsx`, `src/components/image-metadata-badge.tsx` | `src/lib/creation-history-store.ts`, `src/lib/media-storage.ts`, `src/app/api/gallery/*`, `src/app/api/creation-history/route.ts`. Gallery is server-authoritative: do not merge browser localStorage published/history records into the public gallery feed and do not auto-sync historical local published records on gallery page load. The gallery page must not request the full gallery at once; it uses small `/api/gallery` pages, browser-visible lazy image loading, and an IntersectionObserver sentinel to append more works as the user scrolls. It keeps a bounded browser localStorage list cache for instant first paint, then revalidates page 0 in the background so new/deleted works replace cached rows quickly. Gallery/detail/history image previews show actual ratio and natural resolution in the upper-right badge and should render `thumbnailUrl || url`; fullscreen, download, copy, edit, share, and reuse actions keep using original `url`. Current thumbnails use the `m1280q86` WEBP profile, balancing smaller gallery payloads with clear detail previews, and fullscreen components should show thumbnail fallback while original object-storage images load. History also refreshes on `miaojing_auth_updated` after login/account switch. |
| Local/object files/downloads | `src/lib/local-storage.ts`, `src/lib/media-storage.ts`, `src/app/api/local-storage/[...path]/route.ts` | `src/app/api/download/route.ts`, `src/proxy.ts`, `scripts/storage-sync-to-object.mjs`, `scripts/rainyun-ros-prepare.mjs`. Public URLs stay `/api/local-storage/<key>` while the backend can be `STORAGE_MODE=local`, `dual`, or `object`; new image originals can be written object-only, while compressed high-quality WEBP thumbnails are local-only under `thumbnails/...` and must be served from local disk directly. Thumbnail filenames include the resize/quality profile and can be served with long immutable browser cache headers; `src/proxy.ts` must not override thumbnail or gallery cache headers with global `/api` no-store. Object-backed originals should redirect to short-lived signed object-storage URLs. When syncing production source, exclude only repo-root `/local-storage/`, not broad `local-storage/`, or this source route can be skipped. Rainyun ROS API is a control-plane helper for bucket creation/config generation; runtime file IO still uses S3-compatible `OBJECT_STORAGE_*`. |
| Email and policy pages | `src/lib/email-service.ts`, `src/components/site-policy-page.tsx` | `src/app/api/email/*`, `src/app/about/page.tsx`, `src/app/terms/page.tsx`, `src/app/privacy/page.tsx`, `src/app/help/page.tsx` |
| Upgrade/deploy/backup | `scripts/*`, `ecosystem.config.cjs` | `src/app/api/admin/upgrade/route.ts`, `src/components/admin/system-upgrade-tab.tsx` |
| Data backup/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`, `src/lib/local-storage.ts`, `scripts/migration-integrity-check.mjs`. Export includes `_media` for storage assets; import restores media through the active storage adapter, remaps custom IDs, runs in a transaction, dedupes works by URL/source URL/media SHA only within the same `user_id`, and preserves password hashes, encrypted API keys, Manifest paths, and API pricing fields. |

View File

@@ -12,14 +12,16 @@ This folder contains the permanent Codex routing documents for MiaoJing developm
| `bug-location-guide.md` | Symptom-to-code diagnostic manual. Use this first for bug reports and regressions. |
| `api-reference.md` | Route Handler reference for `/api/**`: method, auth, payload, response, storage side effects. |
| `architecture.md` | System architecture, runtime boundaries, data flow, persistence, deployment, and risk points. |
| `custom-integrations.md` | Named rules for non-generic integrations such as 元界, mozheAPI, and 智能配置 API. Use this whenever a request includes a custom integration keyword. |
## Required Workflow For Codex Agents
1. Read `AGENTS.md` and `CODEX_MIAOJING_MEMORY.md` at repo root.
2. Read the document in this folder that matches the task.
3. Use direct file reads or `rg` only after choosing likely files from the index.
4. For every adjustment, modification, or bug fix, check whether these docs need to change.
5. Update the corresponding doc in the same commit whenever code location, diagnosis path, API behavior, architecture, deployment, or verification knowledge changes.
3. If the request includes a custom integration keyword such as `元界`, `mozheAPI`, or `智能配置 API`, read `custom-integrations.md` and search long-term memory for that keyword before editing.
4. Use direct file reads or `rg` only after choosing likely files from the index.
5. For every adjustment, modification, or bug fix, check whether these docs need to change.
6. Update the corresponding doc in the same commit whenever code location, diagnosis path, API behavior, architecture, deployment, or verification knowledge changes.
## Quick Commands

View File

@@ -36,8 +36,8 @@ All routes are Next.js App Router route handlers under `src/app/api/**/route.ts`
| DELETE | `/api/announcements?id=...` | Admin | `src/app/api/announcements/route.ts` | Delete announcement. |
| GET | `/api/model-config` | Public, optional bearer token | `src/app/api/model-config/route.ts` | Read managed provider/model configuration for clients. System APIs are filtered to active platform-default models allowed for the current user's membership tier; anonymous users are treated as `free`. |
| GET | `/api/style-presets` | Public | `src/app/api/style-presets/route.ts` | Returns active image style presets from `image_style_presets`, sorted by usage count. |
| GET | `/api/local-storage/[...path]` | Public by URL | `src/app/api/local-storage/[...path]/route.ts` | Serve storage object by key from local disk or S3-compatible object storage. The URL shape remains stable across migration. |
| GET | `/api/download?url=...&filename=...` | Public by URL | `src/app/api/download/route.ts` | Download proxy for remote, same-origin, and `/api/local-storage/*` URLs, including object-backed storage keys. |
| GET | `/api/local-storage/[...path]` | Public by URL | `src/app/api/local-storage/[...path]/route.ts` | Serve storage object by key. Thumbnail keys under `thumbnails/...` are served from local disk with long immutable browser cache headers; object-backed originals return a short-lived signed object-storage redirect when configured. The public URL shape remains stable across migration. |
| GET | `/api/download?url=...&filename=...` | Public by URL | `src/app/api/download/route.ts` | Download proxy for remote, same-origin, and `/api/local-storage/*` URLs, including object-backed storage keys. Add `disposition=inline` or `inline=1` when the proxy is used as an image/video preview source instead of a forced download. |
## Auth And Account Routes
@@ -85,9 +85,9 @@ All email sends route through `src/lib/email-service.ts`, which renders HTML and
| Method | Path | Auth | Source | Request | Response |
| --- | --- | --- | --- | --- | --- |
| GET | `/api/admin/invitations` | Admin | `src/app/api/admin/invitations/route.ts` | Optional `search`, `page`, `pageSize` | Long-term invitation records joining inviter and invitee profile details. |
| POST | `/api/generate/image` | Trusted internal or resolved user/system API context | `src/app/api/generate/image/route.ts` | Image generation payload; supports prompt, negative prompt, reference images, model/system/custom API config, aspect/size/resolution/count/quality. | Calls SDK or OpenAI/New API-compatible endpoint, persists result through the storage adapter, updates job progress when headers include job ID. |
| POST | `/api/generate/image` | Trusted internal or resolved user/system API context | `src/app/api/generate/image/route.ts` | Image generation payload; supports prompt, negative prompt, reference images, model/system/custom API config, aspect/size/resolution/count/quality. | Calls SDK or OpenAI/New API-compatible endpoint, persists original images to object storage and local WEBP thumbnails to `thumbnails/generated/images`, returns `images` original URLs plus `thumbnails` map and `thumbnailUrls`, updates job progress when headers include job ID. |
| POST | `/api/generate/video` | Trusted internal or resolved user/system API context | `src/app/api/generate/video/route.ts` | Video generation payload; supports prompt, reference image, model/system/custom API config, ratio/duration/fps-like params. | Calls SDK or custom endpoint, persists media through the storage adapter. |
| POST | `/api/generate/reverse-prompt` | Uses supplied/resolved API config | `src/app/api/generate/reverse-prompt/route.ts` | `image`, `outputMode`, `language`, optional `customApiConfig`/system/custom IDs | Returns prompt fields and may persist reference image. |
| POST | `/api/generate/reverse-prompt` | Uses supplied/resolved API config; Bearer token required when resolving user custom or gated system API IDs | `src/app/api/generate/reverse-prompt/route.ts` | `image`, `outputMode`, `language`, optional `customApiConfig`/system/custom IDs | Returns prompt fields and may persist reference image. The create-panel caller must forward the stored access token in `Authorization` because server-side API resolution cannot read browser localStorage. |
| POST | `/api/generate/suggest-prompt` | Uses supplied/resolved API config | `src/app/api/generate/suggest-prompt/route.ts` | `prompt`, optional `customApiConfig`, `systemPrefix` | Returns optimized `prompt` and optional `negativePrompt`. |
Important generation helpers:
@@ -106,12 +106,12 @@ Important generation helpers:
| Method | Path | Auth | Source | Request | Response/Side Effects |
| --- | --- | --- | --- | --- | --- |
| GET | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | None | Latest 300 completed user works as `records`. |
| POST | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | Single record or `{ records: [...] }` | Inserts/deduplicates completed works into `works`. |
| GET | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | None | Latest 300 completed user works as `records`, including optional `thumbnailUrl`; missing image thumbnails are lazily generated into local `thumbnails/works`. |
| POST | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | Single record or `{ records: [...] }`; image records may include `thumbnailUrl` | Inserts/deduplicates completed works into `works`, storing `thumbnail_url` when supplied or generating it for image works. |
| DELETE | `/api/creation-history?id=...` | User | `src/app/api/creation-history/route.ts` | Optional `id`; omit to delete all user history | Deletes user's private history rows by `id` and `user_id`. Creation detail deletion waits for this server delete before refreshing local history. |
| GET | `/api/gallery` | Public | `src/app/api/gallery/route.ts` | Query `type=image|video`, `limit`, `offset`, `sort=newest|popular`, `q`/`search` | Public completed works and total. |
| GET | `/api/gallery` | Public | `src/app/api/gallery/route.ts` | Query `type=image|video`, `category=text2img|img2img|text2video|img2video`, `limit`, `offset`, `sort=newest|popular`, `q`/`search` | Public completed works with `thumbnailUrl`, `total`, `nextOffset`, and `hasMore`; missing public image thumbnails are lazily generated into local `thumbnails/gallery`. Responses allow short private browser caching while the gallery page also keeps a bounded localStorage cache for instant first paint. |
| DELETE | `/api/gallery` | Admin | `src/app/api/gallery/route.ts` | Query `id` or body `{ ids: [...] }` | Unpublishes up to 100 works by setting `is_public=false`. |
| POST | `/api/gallery/publish` | User | `src/app/api/gallery/publish/route.ts` | Work metadata, `resultUrl`, optional thumbnail/reference/model fields | Copies media into gallery folders and inserts public completed work. |
| POST | `/api/gallery/publish` | User | `src/app/api/gallery/publish/route.ts` | Work metadata, `resultUrl`, optional thumbnail/reference/model fields | Copies image originals to object-backed gallery storage, ensures local gallery thumbnails, and inserts public completed work. |
## Admin Routes
@@ -127,7 +127,7 @@ All routes in this section require admin unless noted.
| GET/POST/PUT/DELETE | `/api/admin/redeem-codes` | `src/app/api/admin/redeem-codes/route.ts` | Admin redeem-code management. GET lists codes by status/search, POST generates 1-500 unique single-use credit or membership codes, PUT enables/disables unused codes, and DELETE removes unused codes. Membership-code payloads include `membershipTier`, `membershipDurationValue`, and `membershipDurationUnit` (`day`, `month`, `year`). The redeem-code management UI also saves the shared external mall URL through `/api/site-config` as `redeemCodeMallUrl`. |
| GET/PUT | `/api/admin/payment-methods` | `src/app/api/admin/payment-methods/route.ts` | Payment config. |
| GET/POST/PUT/DELETE | `/api/admin/providers` | `src/app/api/admin/providers/route.ts` | Provider registry CRUD. GET is currently not guarded in source; mutations require admin. |
| GET/POST/PUT/DELETE | `/api/admin/system-apis` | `src/app/api/admin/system-apis/route.ts` | System API config CRUD with encrypted keys, pricing metadata, platform-default visibility, allowed membership tiers, default-model polling fields `pollingMode`/`pollingOrder`, and video entry usage modes `videoUsageModes`. |
| GET/POST/PUT/DELETE | `/api/admin/system-apis` | `src/app/api/admin/system-apis/route.ts` | System API config CRUD with encrypted keys, pricing metadata, platform-default visibility, allowed membership tiers, default-model polling fields `pollingMode`/`pollingOrder`, and video entry usage modes `videoUsageModes`. Successful generation jobs charge user credits from this selected row's pricing through `src/lib/generation-credit-service.ts`. |
| POST | `/api/admin/system-apis/smart-import` | `src/app/api/admin/system-apis/smart-import/route.ts` | Admin-only intelligent Manifest import. Creates one global `system_api_configs` row per imported profile/model, resolves the visible API request URL from the Manifest profile/provider, rejects configs without a resolvable relay API request URL, writes `system-api-manifests/<systemApiId>.json`, and leaves API Key as `待填写` for admin review. Optional `profile.capabilities` is returned through system model config for selected-model image option filtering. Imported rows also carry platform-default visibility, membership-tier allowlist, and default polling fields. |
| GET | `/api/admin/system-apis/yuanjie-capabilities` | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts` | Admin-only 元界 AI built-in image/video template preview retained for the system-default-model template path, not for the `智能配置 API` UI. Returns `capabilitiesText`, image templates from `src/lib/yuanjie-image-model-templates.ts`, and video templates from `src/lib/yuanjie-video-model-templates.ts`; it does not call 元界 `/v1/skills` or `/v1/skills/guide`. |
| POST | `/api/admin/system-apis/yuanjie-capabilities` | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts` | Admin-only 元界 AI built-in installer retained for system-default-model template management, not for the generic smart import UI. `{ syncModels: true }` resets only `provider = '元界 AI' AND type = 'image'` rows and installs 17 inactive image rows. `{ syncVideoModels: true }` resets only `provider = '元界 AI' AND type = 'video'` rows and installs inactive video rows with `videoUsageModes`. Rows have no API Key by default; admins must edit each model to set Key, pricing, visibility/member scope, polling, usage mode, and enable it before users can generate. |

View File

@@ -114,6 +114,7 @@ Create panel
-> POST /api/generate/image or /api/generate/video
-> SDK or custom/system API upstream call
-> src/lib/local-storage.ts persists result
-> src/lib/generation-credit-service.ts deducts selected system API credits on success
-> generation_jobs updated with result/error/progress
-> client polls GET /api/generation-jobs/[id]
-> history/gallery persistence via works APIs
@@ -159,6 +160,8 @@ At generation time, `src/lib/server-api-config.ts` returns `manifestPath` for us
Admin system intelligent API imports live in `src/components/admin/api-management-tab.tsx` and `src/app/api/admin/system-apis/smart-import/route.ts`. The `智能配置 API` section is generic Manifest import only: each imported profile/model becomes one global `system_api_configs` row with its own `manifest_path`, backed by `system-api-manifests/<systemApiId>.json`, and the visible `api_url` is resolved from the Manifest profile/provider. Incomplete configs without a resolvable relay API request URL are rejected. Optional `profile.capabilities` can constrain or hide create-page image/video parameter choices for system models. Provider-specific built-in templates such as 元界 AI are not exposed in this smart import UI; 元界 definitions remain in `src/lib/yuanjie-image-model-templates.ts` and `src/lib/yuanjie-video-model-templates.ts` for the system-default-model management path, where admins configure each model row's Key, pricing/member visibility/polling, `video_usage_modes`, and enablement before it is available to users. Admin Manifest files must remain separate from user-level files and must keep using the system pricing/credit deduction policy for the selected model. System API rows also own `is_default`, `allowed_membership_tiers`, `polling_mode`, and `polling_order`; `/api/model-config` returns only one active platform-default row per allowed media type plus admin display name so the create page shows a single default model label, and image generation expands the selected row back into all allowed supplier candidates with the same display name. The upstream `model_name` can differ between suppliers and is only used as that supplier's request model. Video model billing supports per-use count (`fixed`), per-second duration (`duration_price_per_second`), and token mode. Token billing prices shown in the admin console are credits per 1M tokens for both input and output; older storage/API field names containing `1k` remain compatibility names and must not be shown to admins as per-K pricing. If every supplier fails or returns no usable result, the user-facing error is the generic model-busy message. This polling fallback is only for admin default system models and must not be applied to user custom API keys.
After production migration, app runtime tables in `public` should be owned by the app DB user from `LOCAL_DB_URL`. Runtime compatibility helpers use `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` and index creation; if restored tables remain owned by `postgres`, public routes such as `/api/model-config`, profile refresh, or generation jobs can fail with `must be owner of table ...`.
Admin console navigation state is intentionally short-lived. `src/modules/console/pages/console-dashboard-page.tsx` stores the active console view in `sessionStorage`: page refresh stays on the current admin page, logout clears the stored view, and a new browser tab/session opens the dashboard first.
## Storage Architecture
@@ -177,8 +180,20 @@ Admin console navigation state is intentionally short-lived. `src/modules/consol
Generation routes persist generated media through the storage adapter. Gallery publish copies referenced result URLs into gallery folders when possible. Admin data export/import reads and restores through the same adapter, and import whitelists `manifest_path` plus system API pricing fields so intelligent API configurations survive server migration. Import preserves `auth.users.password_hash` and existing encrypted secret fields as encrypted values; production migrations must carry the same `DATA_ENCRYPTION_KEY`/JWT secret family or encrypted API/payment secrets and existing sessions cannot be decoded correctly. Work dedupe is scoped by `user_id` plus URL/source URL/media SHA to protect private data ownership when different users have identical media.
Image originals and previews have separate storage rules. `src/lib/media-storage.ts` persists new generated image originals through `localStorage.uploadFileObjectOnly(...)` so production originals live in the object bucket even while the app remains in `STORAGE_MODE=dual`. The same helper writes high-quality compressed WEBP thumbnails through `uploadFileLocalOnly(...)` under `thumbnails/...`; the current thumbnail profile uses 1280px max edge, WEBP quality 86, Lanczos resize, and light sharpening, with an `m1280q86` filename suffix so older thumbnail profiles can be replaced in the background. Create results, creation history, gallery cards, and gallery detail previews should render `thumbnailUrl`, while fullscreen preview, download, copy, edit, and share actions must continue to use the original `url`. Legacy rows without current `works.thumbnail_url` are queued for background thumbnail backfill by `/api/creation-history` and `/api/gallery` when image works are read; list responses must not wait for thumbnail generation. Thumbnail backfill should read object-only originals through short-lived signed object URLs instead of slow SDK buffering. The `/api/local-storage/*` route reads `thumbnails/...` from local disk directly instead of probing object storage first and serves them with long immutable browser cache headers because thumbnail filenames include the profile/hash; `src/proxy.ts` must explicitly preserve cacheable thumbnail/gallery routes instead of applying the default `/api` no-store header. Object-backed originals should return a short-lived 302 signed object-storage URL instead of buffering the full original through Next.js.
Gallery detail metadata must not load original images just to compute size. `ImageMetadataBadge` accepts stored `width`/`height`; gallery detail passes those values with `loadMetadata={false}` so preview surfaces stay thumbnail-only and original requests are reserved for fullscreen, download, copy, edit, and share.
The public gallery page should use server gallery rows only. It must not merge `miaojing_published_gallery` or `miaojing_creation_history` from browser localStorage into the gallery feed, and it must not auto-sync historical local published records into Supabase on page load. `/api/gallery` is the authority for all gallery views, including all/category filters and search, and should only return stable platform media URLs under `/api/local-storage/...`; legacy external import URLs are not public gallery candidates. To keep reopen latency low, `src/app/gallery/page.tsx` caches bounded page data in browser localStorage for instant first paint, prunes entries after 7 days or when the cache cap is exceeded, and immediately revalidates the first page in the background so published/deleted works replace cached rows. It should request small pages and append via IntersectionObserver as the user scrolls, not load the entire public gallery into the DOM.
Fullscreen image overlays should accept a thumbnail fallback and display it immediately while the original object-storage image loads. If object storage is slow or the original fails, the user still sees the high-quality local preview and the fullscreen controls stay usable; copy/download/share actions still receive the original URL.
`/api/health` caches storage health briefly and bounds object bucket probing, so health checks do not block page monitoring on a slow object-storage HEAD request. Optional runtime schema checks cache success or non-owner skips; production migrations should still apply schema changes explicitly, but request paths should not repeatedly run DDL.
For a production move from local disk to cloud server plus object storage, use this order: create a full DB/file backup, run `pnpm run migration:check` against the source runtime, prepare Rainyun ROS with `pnpm run rainyun:ros-prepare -- --create` if a bucket still needs to be created, copy reviewed `OBJECT_STORAGE_*` values into `.env.local` with `STORAGE_MODE=dual`, run `pnpm run storage:sync-object -- --dry-run`, run `pnpm run storage:sync-object`, run `pnpm run storage:sync-object -- --verify-only`, deploy/reload, run `pnpm run migration:check` again, and verify `/api/health`, gallery/history images, downloads, login, and API generation. Only switch to `STORAGE_MODE=object` after the object bucket and migration integrity checks have passed and a rollback plan exists.
When syncing source into production, exclude the repo-root runtime storage directory as `/local-storage/` only. A broad `local-storage/` rsync exclude also skips `src/app/api/local-storage/[...path]/route.ts`, leaving production on stale file-serving code while the local repo appears fixed.
## Database Architecture
Main DB entry:

View File

@@ -32,6 +32,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
| Footer content missing or not Markdown-rendered | `src/components/site-footer.tsx`, `src/components/site-policy-page.tsx`, `src/lib/site-config.ts`, `src/app/api/site-config/route.ts` | Config response fields, Markdown renderer, fallback defaults, PUT persistence. |
| Policy pages start mid-page after navigation | `src/components/site-policy-page.tsx`, `src/app/about/page.tsx`, `src/app/terms/page.tsx`, `src/app/privacy/page.tsx`, `src/app/help/page.tsx` | Scroll reset behavior and shared policy page wrapper. |
| Site name/logo/favicon not updating | `src/components/site-config-sync.tsx`, `src/components/site-brand.tsx`, `src/app/api/site-config/route.ts`, `src/lib/local-storage.ts` | `site_config` row, base64 image save, generated `/api/local-storage/*` URL. |
| Console reports CSP blocking `https://fonts.googleapis.cn/...` | `src/app/globals.css`, `src/proxy.ts` | `globals.css` imports Noto Serif SC from `fonts.googleapis.cn`; CSP must allow that stylesheet domain in `style-src` and the matching font CDN in `font-src`. |
| Page content leaves large unused horizontal margins, or wide screens look like the UI was simply enlarged | `src/components/app-shell.tsx`, `src/components/navbar.tsx`, `src/components/site-footer.tsx`, page-level wrappers under `src/app/*/page.tsx`, `src/components/site-policy-page.tsx` | The viewport/background can be `w-full`, but product content should keep the original component scale and readable containers such as `max-w-7xl`, `max-w-4xl`, or `max-w-3xl`. Do not fix this by removing all max widths or scaling controls up on wide monitors. |
| Scrollbars look native, stay visible when idle, or do not match glass UI in dialogs/pages | `src/app/globals.css`, `src/components/app-shell.tsx` | Global scrollbar styling is hidden by default and becomes visible only while wheel/touch scrolling through the `scrollbars-visible` class on `<html>`. `globals.css` owns both the hidden state and the rounded glass visible state for light/dark themes; `app-shell` owns the short-lived wheel/touch listener. Avoid adding one-off scrollbar styles to individual components unless there is a real exception. |
| Announcement not popping up | `src/components/announcement-popup.tsx`, `src/app/api/announcements/route.ts`, `src/components/app-shell.tsx` | App shell includes popup, active date range, local/session dismissal behavior, GET payload shape. |
@@ -45,19 +46,31 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
| Create button does nothing | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/lib/generation-job-client.ts` | Client validation, auth token, `/api/generation-jobs` POST response, UI disabled/loading state. |
| Refreshing `/create` resets to the wrong creation tab | `src/app/create/page.tsx` | Active tab should persist in `miaojing:create-active-tab` and mirror to `/create?type=...`. Verify all creation tabs (`text2img`, `img2img`, `text2video`, `img2video`, `reversePrompt`) restore after refresh and query-param links still override storage. |
| Cannot submit a new generation job while another job is running, or active job cards overflow horizontally | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/components/create/generation-task-list.tsx` | Create panels should keep the submit button enabled while models are available; active job cards should render inside the results column with wrapping vertical growth, not outside the result area. |
| Earlier completed image tasks disappear while later tasks are still running | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/generation-task-list.tsx` | The results column must not be a single `generating ? taskList : results` branch. Render active task cards and completed result cards together, and append each task's images as soon as that task succeeds instead of waiting for all submitted tasks to settle. |
| Job remains queued | `src/app/api/generation-jobs/route.ts`, `src/lib/generation-job-worker.ts`, `src/lib/generation-job-runner.ts` | `processNextGenerationJob()` invoked, stale job handling, DB locks/status, internal base URL. |
| Job remains running forever | `src/app/api/generation-jobs/[id]/route.ts`, `src/lib/generation-job-worker.ts`, `src/lib/generation-job-estimates.ts` | Stale timeout updates, `updated_at`, worker exceptions swallowed into error field. |
| Image generation returns upstream error | `src/app/api/generate/image/route.ts`, `src/lib/custom-api-fetch.ts`, `src/lib/server-api-config.ts` | Resolved custom/system API credentials, endpoint URL, New API normalization, timeout, stream/progress parser. |
| 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 `生成数量`. |
| Reference image upload too large or fails | `src/components/create/image-to-image.tsx`, `src/components/create/image-to-video.tsx`, `src/lib/browser-image-compression.ts`, `src/lib/server-image-compression.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Browser compression, `MAX_UPSTREAM_REFERENCE_IMAGE_BYTES`, data URL conversion. Uploaded reference thumbnails should single-click into the no-container `BareImagePreview`; blank area closes it. |
| Generated result previews but does not persist | `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts`, `src/lib/local-storage.ts`, `src/app/api/creation-history/route.ts` | Media copied through the storage adapter, stable `/api/local-storage/<key>` URL returned, history POST called. In object storage mode, verify `STORAGE_MODE` and `OBJECT_STORAGE_*` health. |
| Fullscreen/preview/download/right-click image actions broken | `src/components/fullscreen-preview.tsx`, `src/components/lightbox.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-actions-context-menu.tsx`, `src/components/image-metadata-badge.tsx`, `src/app/image-viewer/page.tsx`, `src/app/api/download/route.ts` | Dialog state, URL type, download proxy supports local/remote URL. Image result and history/detail previews should open on single click. Right-click copy, download, edit, and share actions must use the uncompressed original image URL, not a thumbnail, preview cache, or compressed reference blob. Share links should open `/image-viewer?url=...` as a standalone original-image fullscreen page. Image result and history/detail previews should show upper-right actual aspect ratio and natural resolution via `ImageMetadataBadge`. |
| Image preview cards load slowly, look blurry in detail, or fetch full originals | `src/lib/media-storage.ts`, `src/lib/local-storage.ts`, `src/app/api/local-storage/[...path]/route.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/creation-history/route.ts`, `src/app/api/gallery/route.ts`, `src/app/api/gallery/publish/route.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/profile/creation-history-tab.tsx`, `src/app/gallery/page.tsx` | New generated image originals should be object-only, while WEBP thumbnails should be local-only under `thumbnails/...`. Current thumbnails should have the `m1280q86` suffix and come from the 1280px/Lanczos/sharpened profile. Cards and detail preview surfaces use `thumbnailUrl || url`; fullscreen, right-click copy/download/edit, and share must use original `url`. `GET /api/creation-history` and `GET /api/gallery` should queue missing or old-profile legacy thumbnails in the background, not block the list response. `/api/local-storage/thumbnails/...` must read local disk directly in dual mode instead of checking object storage first; original image keys should 302 to a short-lived signed object-storage URL so fullscreen does not wait for Next.js to buffer the full file. |
| Gallery shows `加载中...` for seconds on every visit or loads too many images at once | `src/app/gallery/page.tsx`, `src/app/api/gallery/route.ts`, `src/app/api/local-storage/[...path]/route.ts`, `src/proxy.ts` | The page should show cached `miaojing:gallery:v3` rows immediately when fresh, revalidate page 0 in the background, debounce search, request small `limit/offset` pages, and append more rows only through the scroll sentinel. Do not restore the old `limit=300` full-gallery request. Thumbnail URLs under `/api/local-storage/thumbnails/...` should return long immutable cache headers so browser image cache is actually used; if curl still shows `no-store`, check `src/proxy.ts` because the global `/api` cache header can override the route response. |
| `/api/health` or page probes are slow after object migration | `src/app/api/health/route.ts`, `src/lib/local-storage.ts` | Health checks call `getStorageHealthStatus()`. Object bucket checks should be cached briefly and bounded with an abort timeout so a slow S3-compatible endpoint does not hold request threads for many seconds. |
| Logs repeatedly show `must be owner of table ...` on normal requests | `src/lib/generation-job-estimates.ts`, `src/lib/email-service.ts`, `src/lib/profile-preferences.ts`, `src/lib/user-profile-defaults.ts`, `src/lib/server-api-config.ts` | Optional runtime schema checks can hit `42501` when the production app user is not the table owner. Treat existing-schema `42501` as a one-time warning and cache the skip; apply real schema migrations through deployment/DB owner operations rather than request-time DDL. |
| Fullscreen/preview/download/right-click image actions broken | `src/components/fullscreen-preview.tsx`, `src/components/lightbox.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-actions-context-menu.tsx`, `src/components/image-metadata-badge.tsx`, `src/app/image-viewer/page.tsx`, `src/app/api/download/route.ts` | Dialog state, URL type, download proxy supports local/remote URL. Image result and history/detail previews should open on single click. Right-click copy, download, edit, and share actions must use the uncompressed original image URL, not a thumbnail, preview cache, or compressed reference blob. Fullscreen components should receive a thumbnail fallback so the preview appears immediately while the original object-storage image loads. Share links should open `/image-viewer?url=...` as a standalone original-image fullscreen page. Image result and history/detail previews should show upper-right actual aspect ratio and natural resolution via `ImageMetadataBadge`. |
| Gallery console shows `Image URL being set: /api/local-storage/generated/...` while preview should use thumbnails | `src/app/gallery/page.tsx`, `src/components/image-metadata-badge.tsx` | Check the actual `<img>` `src` and `/api/gallery` response first. The console line can be caused by metadata probing rather than the preview image. Gallery detail should pass stored `width`/`height` to `ImageMetadataBadge` and set `loadMetadata={false}` so the badge does not trigger an original-image request just to calculate dimensions. |
| Gallery shows many historical/imported works or thumbnail rules differ between all/category/search | `src/app/gallery/page.tsx`, `src/app/api/gallery/route.ts`, `src/lib/creation-history-store.ts` | The gallery page should render only `/api/gallery` rows, not merge browser localStorage published/history records and not call `syncPublishedToSupabase()` on load. `/api/gallery` should apply `category`/`q` filters server-side against public works with stable `/api/local-storage/...` result URLs so all, text-to-image, image-to-image, text-to-video, image-to-video, and search share the same thumbnail/original split. |
| Generated image preview zooms but cannot be dragged | `src/components/lightbox.tsx`, `src/components/fullscreen-preview.tsx` | Result-card previews use `ImageLightbox`; after zooming above 100%, panning should be bound to the whole preview stage as well as the image so mouse drag remains available even when the transformed image extends beyond its original element box. Keep wheel zoom, double-click zoom/reset, right-click actions, and ESC close intact. |
| Create page loads slowly and console shows CORS errors for historical `coze-codingproject.tos.coze.site` images | `src/components/create/cached-preview-image.tsx`, `src/components/create/text-to-image.tsx`, `src/app/api/download/route.ts` | Hidden mobile history must not mount desktop-side image effects, and cross-origin historical result images should render through the same-origin `/api/download?disposition=inline` proxy before canvas preview generation. Otherwise every hidden history image can issue a blocked browser request and slow the page. |
| Creation detail, gallery one-click reuse, or inspiration reuse buttons do not fill create forms or switch tabs | `src/components/creation-detail-dialog.tsx`, `src/app/gallery/page.tsx`, `src/components/create/inspiration-gallery-dialog.tsx`, `src/lib/creation-reuse.ts`, `src/app/create/page.tsx`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | Reuse actions should write the shared draft key/event for `text2img`, `img2img`, `text2video`, or `img2video`, route to the matching `/create?type=...` when leaving gallery, and already-mounted create panels should react to the event. Image-to-image and image-to-video reuse should include reference images from the work, falling back to the output image or thumbnail only when stored references are missing. |
| Image generation count dropdown too wide, options missing, or manual count input unavailable | `src/components/create/image-count-combobox.tsx`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | Use the shared compact combobox instead of browser `datalist`; verify manual numeric entry and dropdown options in both text-to-image and image-to-image panels. |
| Generated image result hover actions are unreadable in light theme | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | The result-card hover overlay owns the Preview/Share/Download buttons. These buttons should use fixed dark translucent backgrounds and white text/icons so light and dark themes have the same readable hover action style. |
| Generated image is pushed down by a long prompt in the desktop result column | `src/components/create/text-to-image.tsx` | The result prompt above new images should be a compact two-line summary with the full prompt only in the title tooltip/history detail. Do not render the entire prompt as an unbounded paragraph in the live result column. |
| Style presets are hardcoded, missing, or not ordered by usage | `src/components/create/style-preset-selector.tsx`, `src/lib/style-presets-client.ts`, `src/app/api/style-presets/route.ts`, `src/lib/style-preset-store.ts`, `src/app/api/generation-jobs/route.ts` | Presets should come from `image_style_presets`; `generation-jobs` increments `usage_count`; GET `/api/style-presets` should return active presets sorted by usage count. |
| Reverse prompt option missing | `src/components/create/reverse-prompt-panel.tsx`, `src/app/api/generate/reverse-prompt/route.ts` | UI option list and server `outputMode` handling both updated, app rebuilt/restarted if deployed. |
| Reverse prompt says `请先登录后再使用自定义 API` while the user is already logged in | `src/components/create/reverse-prompt-panel.tsx`, `src/lib/auth-store.ts`, `src/app/api/generate/reverse-prompt/route.ts`, `src/lib/server-api-config.ts` | The reverse-prompt fetch must send `Authorization: Bearer <accessToken>` from `readStoredAuth()`. The server resolves `customApiKeyId`/`systemApiId` through `getAuthenticatedUserId`, which reads the bearer token rather than browser localStorage. |
| Prompt optimization fails | `src/app/api/generate/suggest-prompt/route.ts`, `src/lib/server-api-config.ts`, `src/lib/custom-api-fetch.ts` | Text-capable system/custom API, chat response shape, JSON parsing fallback. |
## Models, Providers, API Keys
@@ -66,7 +79,9 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
| --- | --- | --- |
| Model list empty in create/profile | `src/app/api/model-config/route.ts`, `src/lib/model-config.ts`, `src/lib/managed-model-store.ts`, `src/lib/custom-api-store.ts` | Public model config response, admin recommendations, local client store mapping. |
| Default model group shows raw API model name instead of the admin display name | `src/lib/model-display.ts`, `src/app/api/model-config/route.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | Frontend system model labels should use `system_api_configs.name` first. `model_name` is the upstream request model identifier and should remain available for generation dispatch, but it should not override the admin-facing display name in the create-page default model group. |
| Backend default models are configured but `/api/model-config` returns only `{"providers":[],"recommendations":[]}` or no `systemApis` | `src/app/api/model-config/route.ts`, `src/lib/server-api-config.ts`, production database owner/grants | Check PM2 logs for `must be owner of table system_api_configs`. After migration, runtime tables must be owned by the app DB user, or optional schema checks should not be allowed to empty the public model-config response. Fix ownership/grants first, then verify `/api/model-config` includes `systemApis`. |
| System API saved but not used | `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | `systemApiId` in request payload, active config, decrypted key, type matches image/video/text, `is_default` is true, and `allowed_membership_tiers` includes the current user's normalized tier. For admin default image models, also verify same media type plus same admin display name (`system_api_configs.name`) polling candidates, `polling_mode`, and `polling_order`; `model_name` is only the upstream request model. User custom APIs should not enter this polling path. |
| System default model generates successfully but user credits do not decrease | `src/app/api/generation-jobs/route.ts`, `src/lib/generation-job-worker.ts`, `src/lib/generation-credit-service.ts`, `src/lib/server-api-config.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | Credit deduction must happen on the server after a successful system-default generation, using the selected frontend `systemApiId` row in `system_api_configs` for pricing. The create button should not show predicted credits; completed result cards should show the `creditsCost` returned in the generation job result, and the profile balance should refresh from `creditsBalance`. |
| User custom API saved but not used | `src/app/api/user-api-keys/route.ts`, `src/lib/custom-api-store.ts`, `src/lib/server-api-config.ts` | `customApiKeyId`, owner auth, encrypted key exists, `is_active`. |
| Intelligent API dialog is too narrow, clipped, or shows only JSON | `src/components/profile/api-key-manager.tsx`, `src/components/ui/dialog.tsx` | Smart import dialogs must override the shared dialog's `sm:max-w-lg` with explicit wide sizing such as `w-[min(...)] max-w-none sm:max-w-none`, cap height to the viewport, and keep the JSON editor inside an internal scrollable/flexible area so title, actions, and footer remain visible. |
| Intelligent API import creates wrong or mixed requests | `src/components/profile/api-key-manager.tsx`, `src/app/api/user-api-keys/smart-import/route.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/server-api-config.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Each imported profile/model must have its own `user_api_keys` row and `user-api-manifests/<userId>/<keyId>.json` file. Verify `manifest_path` on the selected `customApiKeyId`, not a user-level shared file. Imported edit forms should show a human-readable provider name and a non-empty API request URL derived from `profile.baseUrl + submit.path` only when the Manifest provides enough endpoint data; never invent an OpenAI default URL for a third-party relay document. Editing a key should preserve `manifest_path`; generation should execute the selected manifest before legacy custom API fallback. |
@@ -74,6 +89,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
| Admin intelligent API import is missing or generated system models ignore Manifest | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/smart-import/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts` | Admin imports must create one `system_api_configs` row per Manifest profile, write `system-api-manifests/<systemApiId>.json`, persist `manifest_path`, and resolve that path from the selected `systemApiId`. Imported rows still need API Key and pricing review before use. |
| 元界 AI 同步后出现大量接口/参数名模型或模型行反复显示 Key | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts`, `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/yuanjie-template-installer.ts`, `src/components/admin/api-management-tab.tsx` | 元界不应再从 `/v1/skills``/v1/skills/guide` 猜模型,也不应在 `智能配置 API` 页面暴露内置模板安装/同步入口。检查安装路由是否使用内置图片/视频模板、是否只删除当前媒体类型的 `provider = '元界 AI'` 行、是否创建 inactive rows and per-model Manifest files, and whether admins configure Key/pricing/usage modes/enablement per model through the system-default-model management flow. The admin list should not show repeated imported key placeholders, and the create page should show only documented controls from the selected template capabilities. |
| 元界任务在元界后台成功但妙境报模型繁忙或接口路径不存在 | `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/user-api-manifest-executor.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Check whether the Manifest poll endpoint uses `path: "v1/media/status"` plus `query: { task_id: "{task_id}" }`. If the path is stored as `v1/media/status?task_id={task_id}`, the executor can encode the query string into the pathname and 元界 will return a not-found error even though the create request already produced a task. Also verify 元界 media templates use `finalPath: "is_final"`, `finalValues: [true]`, `statusPath: "state"`, `successValues: ["success"]`, and `failureValues: ["failed"]`; `status` / `status_group` are display fields only. |
| 元界图生图提交后妙境报 `Manifest 未能从 ... 读取任务 ID` or generic `模型繁忙` while 元界 may have accepted the job | `src/lib/user-api-manifest-executor.ts`, `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts` | The submit response can put the task identifier inside nested `result` objects. The executor must normalize `task_id`, `taskId`, `id`, and nested `data/result/output` objects before polling. Template `taskIdPath` should include `result.task_id`, `result.taskId`, and `result.id` before the broad `result` fallback. |
| 视频系统模型出现在错误入口或缺少参数选项 | `src/lib/server-api-config.ts`, `src/components/admin/api-management-tab.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/lib/model-capabilities.ts` | Check `system_api_configs.video_usage_modes`. 文生视频 should only show rows including `text-to-video`; 图生视频 should only show rows including `image-to-video`. Selected system video models should read Manifest `capabilities` for aspect ratio, duration, and resolution controls. |
| 管理后台刷新后跳回仪表盘 | `src/modules/console/pages/console-dashboard-page.tsx` | The active view should be restored from `sessionStorage` on refresh and removed on logout. If it jumps to dashboard after a plain refresh, inspect the session key `miaojing_console_active_view` and whether the view is still allowed by the current membership/admin config. |
| 兑换码无法生成、重复、兑换后积分或会员不到账,或可重复兑换 | `src/components/admin/redeem-code-management-tab.tsx`, `src/app/api/admin/redeem-codes/route.ts`, `src/components/profile/credits-tab.tsx`, `src/app/api/redeem-codes/redeem/route.ts`, `src/lib/redeem-code-service.ts` | Codes should be generated server-side with unique `normalized_code`. Redemption must use a DB transaction with `FOR UPDATE` locks on `redeem_codes` and `profiles`, then mark `used_by/used_at`. Credit codes update `profiles.credits_balance` and insert a `credit_transactions` row. Membership codes update `profiles.membership_tier` plus `membership_expires_at`; duration units are `day`, `month`, and `year`. If the profile page is stale, inspect `/api/profile` refresh and `/api/credit-transactions`. |
@@ -92,7 +108,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
| Imported gallery images do not render after production data import | `src/app/api/admin/data-export/route.ts`, `src/app/api/admin/data-import/route.ts`, `src/lib/local-storage.ts`, `src/app/api/local-storage/[...path]/route.ts`, DB `works.result_url` | New exports should include `_media`; import should persist media through the active storage adapter. If using an older export without `_media`, DB rows alone cannot recreate missing `/api/local-storage/*` files. For object migration, run `pnpm run storage:sync-object -- --verify-only` before switching to `STORAGE_MODE=object`. |
| Rainyun ROS bucket created but object storage still fails | `scripts/rainyun-ros-prepare.mjs`, `.env.local`, `src/lib/local-storage.ts`, `scripts/storage-sync-to-object.mjs`, `/api/health` | The Rainyun API link is control-plane bucket creation, not the media upload path. Verify `.env.local` has reviewed `OBJECT_STORAGE_BUCKET`, `OBJECT_STORAGE_ENDPOINT`, `OBJECT_STORAGE_ACCESS_KEY_ID`, `OBJECT_STORAGE_SECRET_ACCESS_KEY`, `OBJECT_STORAGE_FORCE_PATH_STYLE=true`, and `STORAGE_MODE=dual`; then run `/api/health` and `pnpm run storage:sync-object -- --dry-run`. |
| Gallery delete does not remove public item | `src/app/api/gallery/route.ts`, admin UI route using it | DELETE unpublishes by setting `is_public = false`, not hard delete. |
| Search/filter/sort wrong | `src/app/api/gallery/route.ts`, `src/app/gallery/page.tsx` | Query params `type`, `limit`, `offset`, `sort`, `q/search`; SQL where/order. |
| Search/filter/sort wrong | `src/app/api/gallery/route.ts`, `src/app/gallery/page.tsx` | Query params `type`, `category`, `limit`, `offset`, `sort`, `q/search`; SQL where/order, browser cache signature, and pagination append state. |
| Gallery search box looks inconsistent with the rest of the UI | `src/app/gallery/page.tsx` | The search field is a custom glass panel with an inner focused input surface; avoid reverting it to a plain transparent input row. |
| Gallery hover makes images muddy, covers the image with prompt text, shows only a single-color/static glow, has transparent gaps, does not match image colors, misses the card corners, moves too fast, looks too hard-edged, or action buttons disappear on dark/light images | `src/app/gallery/page.tsx`, `src/app/globals.css` | Gallery cards should not use a full-image dark hover overlay, center prompt text, transparent border gaps, generated unrelated colors, broad square glow under the card, or a separate outer halo layer. Keep hover feedback on the card container with scale plus a real `gallery-card-border-frame` wrapper using 3-5 sampled image colors in a single blurred 3px continuous clockwise border around the full work-card container, including all four corners and the prompt/footer area, and keep like/download buttons legible through sampled image brightness inversion. |
@@ -113,6 +129,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
| Symptom | Check Files | What To Verify |
| --- | --- | --- |
| `/api/local-storage/...` 404 | `src/app/api/local-storage/[...path]`, `src/lib/local-storage.ts` | `STORAGE_MODE`, `LOCAL_STORAGE_DIR`, `OBJECT_STORAGE_*`, normalized key, and whether the object exists in local disk or bucket. Dual mode should fall back to local disk if object storage is missing a migrated key. |
| Production `/api/local-storage/...` still buffers originals after deployment | `src/app/api/local-storage/[...path]/route.ts`, deploy rsync command | Confirm the live production route contains `generateObjectReadUrl(...)` and `NextResponse.redirect(...)`. If local code is correct but production is stale, check whether rsync used a broad `local-storage/` exclude; use `/local-storage/` for the repo-root runtime directory so `src/app/api/local-storage/[...path]/route.ts` is not skipped. |
| Download says remote fetch failed | `src/app/api/download/route.ts`, `src/lib/remote-fetch.ts` | URL is http(s), same-origin, or local-storage; upstream reachable; timeout. |
| Path traversal/security concern | `src/lib/local-storage.ts`, `src/app/api/download/route.ts`, `src/app/api/local-storage/[...path]/route.ts` | Keep `normalizeKey`, `path.resolve`, and `..` guards. |

View File

@@ -0,0 +1,34 @@
# Custom Integrations
Use this document before changing non-generic provider/platform behavior. If a user request includes a custom keyword such as `元界`, `mozheAPI`, or `智能配置 API`, first check long-term memory and the relevant rows in the Codex docs, then verify the current source/runtime before editing.
## Required Workflow
1. Search existing long-term memory and this docs folder for the exact custom keyword.
2. Read the matching feature, bug, API, or architecture entry before touching code.
3. Treat custom provider/platform behavior as a named integration boundary, not as generic fallback logic.
4. Preserve the provider-specific contract unless the user explicitly changes that contract.
5. When a fix reveals a new reusable rule, update this file or the matching Codex doc in the same change set.
6. If the rule is durable across future sessions, write it to long-term memory instead of relying only on chat context.
## 元界 AI
- Start with `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/yuanjie-template-installer.ts`, `src/lib/user-api-manifest-executor.ts`, and the selected create panel.
- Built-in 元界 templates are not generic OpenAI-compatible models. Their manifests may map UI fields to provider-specific params such as `size`, `aspect_ratio`, `aspectRatio`, `imageSize`, `resolution`, `quality`, `images`, or task polling fields.
- Some image models expose orientation through a `size`/`resolution` value instead of a separate aspect-ratio field. In those cases the create panel must derive the ratio from the selected option label or pixel dimensions, rather than requiring the user to write the ratio in the prompt.
- 元界 media submit responses may return the task identifier under nested result objects such as `result.task_id`, `result.taskId`, or `result.id`. The Manifest executor must extract task IDs from those nested objects before polling `v1/media/status`.
- Do not add `自动` back to controls where the user explicitly asked for explicit manual choices. Image count should default to `1` when automatic inference is not part of the requested workflow.
- Admin default models must use `system_api_configs.name` as the frontend display name, while `model_name` remains the upstream request model.
- When 元界 is used as a system default model, credit deduction must still follow the selected `system_api_configs` row's pricing through the generation job backend. The create UI should display only the completed job's returned `creditsCost`, not a separate predicted button cost.
## mozheAPI
- Start with `src/proxy.ts` for iframe/embed failures before changing page components.
- Third-party embedding depends on CSP `frame-ancestors` and `X-Frame-Options`; `SAMEORIGIN` blocks external parents even if app pages render correctly.
- Keep the allowlist explicit. Do not globally weaken security headers for unrelated origins.
## 智能配置 API
- User-level manifests must stay one model/key row to one JSON Manifest file. Do not merge multiple request configs under one user-level shared file.
- Admin global default manifests can be shared through system API configuration, but generation must still resolve the selected model row and charge according to that model's pricing rules.
- Imported rows should preserve `manifest_path`, provider/model display metadata, and per-model request templates.

View File

@@ -50,30 +50,30 @@ Use this document to jump directly to code before broad searching.
| Feature | Primary Files | Server/API Files |
| --- | --- | --- |
| Tab container | `src/app/create/page.tsx` | Owns the five creation tabs. Active tab is persisted in localStorage and mirrored to `/create?type=...`, so refreshes and shared links stay on text-to-image, image-to-image, text-to-video, image-to-video, or reverse-prompt. On phones the mode switch is the single fixed icon row below the navbar; the page title and duplicate text mode strip are hidden. Mobile layout classes in this page and `src/app/globals.css` turn the create center into a chat-style flow: text-to-image sorts history from oldest to newest and auto-scrolls to the latest work above the fixed composer, hides the empty result placeholder until the user submits a prompt, renders generating tasks as the newest prompt-plus-progress message, and uses `src/components/create/mobile-creation-composer.tsx` as the fixed bottom composer with compact labeled ratio/resolution/count controls, optional style strip that expands the composer upward, prompt input, and right send button. |
| Text to image | `src/components/create/text-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`. The create button remains usable while jobs are running; active jobs render through `src/components/create/generation-task-list.tsx` inside the results column. Model select items use `src/components/create/grouped-model-select-items.tsx` so admin global system models appear under `默认模型` and user-added keys appear under `自定义模型`. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery text-to-image works can fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and guidance into the form. |
| Image to image | `src/components/create/image-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`. Reference thumbnails single-click into a bare image overlay, active jobs render through `src/components/create/generation-task-list.tsx`, and model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery image-to-image works can place reference images and fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and strength into the form. |
| Text to image | `src/components/create/text-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`. The create button remains usable while jobs are running; active jobs render through `src/components/create/generation-task-list.tsx` inside the results column. Model select items use `src/components/create/grouped-model-select-items.tsx` so admin global system models appear under `默认模型` and user-added keys appear under `自定义模型`. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery text-to-image works can fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and guidance into the form. The mobile conversation history should only mount on mobile viewports; CSS-hidden mobile history still runs image effects if mounted on desktop. |
| Image to image | `src/components/create/image-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`. Reference thumbnails single-click into a bare image overlay, active jobs render through `src/components/create/generation-task-list.tsx`, and model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. 图生图 removes `自动` from ratio/resolution/count controls, defaults count to `1`, and derives ratio from Yuanjie size labels or dimensions when the selected model hides the separate ratio control. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery image-to-image works can place reference images and fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and strength into the form. |
| Text to video | `src/components/create/text-to-video.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`. The create button remains usable while jobs are running; active jobs render through `src/components/create/generation-task-list.tsx`, and model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. It consumes video reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery text-to-video works can fill prompt, negative prompt, model, ratio, duration, camera movement, and style. |
| Image to video | `src/components/create/image-to-video.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`. Uploaded reference thumbnails single-click into the same bare image overlay used by image-to-image, active jobs render through `src/components/create/generation-task-list.tsx`, and model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. It consumes video reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery image-to-video works can place reference images and fill prompt, negative prompt, model, ratio, duration, and camera movement. |
| 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` |
| Prompt textarea | `src/components/create/expandable-prompt-textarea.tsx` | Shared prompt input. |
| Mobile creation composer | `src/components/create/mobile-creation-composer.tsx`, `src/app/globals.css` | Mobile-only fixed bottom composer used by text-to-image to match chat-style clients: top parameter strip with extra-compact labeled controls, optional style strip, prompt input, and right send button. Mode selection stays only in the sticky header tabs. Desktop creation forms remain the source for full advanced controls. |
| Mobile creation composer | `src/components/create/mobile-creation-composer.tsx`, `src/app/globals.css` | Mobile-only fixed bottom composer used by text-to-image to match chat-style clients: top parameter strip with compact dropdown buttons for ratio/resolution/count, optional style strip, prompt input, and right send button. The mobile creation center uses one 16px UI font size across selected values, style chips, composer input, and conversation prompts. The mobile text-to-image parameter strip hides the `画面比例`/`分辨率`/`生成数量` labels and removes `自动` from ratio, resolution, and count choices, defaulting to explicit values instead. The mobile style strip shows only one horizontal row when collapsed and expands upward for search/more presets after tapping `展开`. Mode selection stays only in the sticky header tabs. Desktop creation forms remain the source for full advanced controls. |
| Image count input/dropdown | `src/components/create/image-count-combobox.tsx` | Shared compact count control for manual image count entry and common dropdown options. |
| Style presets | `src/components/create/style-preset-selector.tsx`, `src/lib/style-presets-client.ts`, `src/app/api/style-presets/route.ts`, `src/lib/style-preset-store.ts`, `src/lib/model-config.ts` | Style presets are stored in `image_style_presets`, seeded from defaults, sorted by `usage_count`, and incremented from image generation jobs. The selector exposes stable `.style-preset-selector` and `.style-preset-list` classes so mobile create CSS can show a compact collapsed strip and an expanded list of at least several rows inside the bottom composer. |
| Loading/error panels | `src/components/create/generation-loading-panel.tsx`, `src/components/create/generation-task-list.tsx`, `src/components/create/generation-error-panel.tsx` | Shared generation status UI. `generation-task-list` keeps multiple active job cards constrained to the results column. |
| Style presets | `src/components/create/style-preset-selector.tsx`, `src/lib/style-presets-client.ts`, `src/app/api/style-presets/route.ts`, `src/lib/style-preset-store.ts`, `src/lib/model-config.ts` | Style presets are stored in `image_style_presets`, seeded from defaults, sorted by `usage_count`, and incremented from image generation jobs. The selector exposes stable `.style-preset-selector` and `.style-preset-list` classes so mobile create CSS can show a one-row collapsed strip and an expanded list of at least several rows inside the bottom composer. |
| Loading/error panels | `src/components/create/generation-loading-panel.tsx`, `src/components/create/generation-task-list.tsx`, `src/components/create/generation-error-panel.tsx` | Shared generation status UI. `generation-task-list` keeps multiple active job cards constrained to the results column, and image create panels render active tasks plus completed result cards together so earlier finished jobs do not disappear while later jobs keep running. |
| Creation reuse drafts | `src/lib/creation-reuse.ts`, `src/app/create/page.tsx`, `src/components/create/inspiration-gallery-dialog.tsx` | Shared localStorage/event bridge used by detail, reverse-prompt, gallery, and inspiration actions to prefill create panels. It supports `text2img`, `img2img`, `text2video`, and `img2video` draft keys/events; `/create?type=...` changes the active tab after navigation, so callers can route directly to the matching creation mode. The inspiration dialog filters to the current mode, keeps per-card mode labels hidden, and offers a fuzzy search box that animates leftward from the header search icon; empty searches auto-collapse after the pointer leaves the search control for 1 second, while non-empty searches stay open until the dialog closes. |
| Lightbox/fullscreen/detail actions | `src/components/lightbox.tsx`, `src/components/fullscreen-preview.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-actions-context-menu.tsx`, `src/components/image-metadata-badge.tsx`, `src/app/image-viewer/page.tsx` | Image cards, detail images, reference thumbnails, and generation results should enter fullscreen preview on single click, not double-click. Detail and fullscreen images use the shared right-click image action menu for copy, download, edit-to-image-to-image, and share; these actions must receive the original image URL, not thumbnails or cached display blobs. Share copies a `/image-viewer?url=...` full-display link for the original image. Delete work must use a confirmation dialog warning that deletion cannot be recovered before calling the server delete path. Image previews show actual natural resolution and computed aspect ratio in the upper-right metadata badge. `BareImagePreview` is the no-container overlay for uploaded reference image previews. |
| Lightbox/fullscreen/detail actions | `src/components/lightbox.tsx`, `src/components/fullscreen-preview.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-actions-context-menu.tsx`, `src/components/image-metadata-badge.tsx`, `src/app/image-viewer/page.tsx`, `src/components/create/cached-preview-image.tsx` | Image cards, detail images, reference thumbnails, and generation results should enter fullscreen preview on single click, not double-click. Detail and fullscreen images use the shared right-click image action menu for copy, download, edit-to-image-to-image, and share; these actions must receive the original image URL, not thumbnails or cached display blobs. Fullscreen/lightbox components can receive a thumbnail fallback to display immediately while the original object-storage URL loads. Share copies a `/image-viewer?url=...` full-display link for the original image. Delete work must use a confirmation dialog warning that deletion cannot be recovered before calling the server delete path. Image previews show actual natural resolution and computed aspect ratio in the upper-right metadata badge. `BareImagePreview` is the no-container overlay for uploaded reference image previews. `CachedPreviewImage` generates same-origin cached previews and proxies cross-origin historical URLs through `/api/download?disposition=inline` to avoid browser CORS failures. |
## Generation System
| Responsibility | Primary Files | Notes |
| --- | --- | --- |
| Client-side job polling | `src/lib/generation-job-client.ts` | Create/poll jobs from create panels. |
| Job creation API | `src/app/api/generation-jobs/route.ts` | Inserts `generation_jobs`, starts worker, and increments selected image style preset usage. |
| Job creation API | `src/app/api/generation-jobs/route.ts` | Inserts `generation_jobs`, starts worker, increments selected image style preset usage, and preflights system-default-model credit balance through `src/lib/generation-credit-service.ts`. |
| Job status API | `src/app/api/generation-jobs/[id]/route.ts` | Owner/admin visibility, stale running job handling. |
| Worker loop | `src/lib/generation-job-worker.ts` | Picks and processes queued jobs. |
| Worker loop | `src/lib/generation-job-worker.ts` | Picks and processes queued jobs. After successful system default 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. |
| 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` | SDK + custom/system API + New API image compatibility, persistence. For admin default system models, image generation resolves all same-type/same-display-name default API candidates and silently retries them by the configured polling mode before returning the generic busy message. User custom APIs remain single-config and do not use this polling fallback. |
| Image route | `src/app/api/generate/image/route.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. For admin default system models, image generation resolves all same-type/same-display-name default API candidates and silently retries them by the configured polling mode before returning the generic busy message. User custom APIs remain single-config and do not use this polling fallback. |
| Video route | `src/app/api/generate/video/route.ts` | SDK + custom/system API video, persistence. |
| Custom API transport | `src/lib/custom-api-fetch.ts` | Headers, retries, progress JSON parsing, upstream error parsing. |
| Server API resolution | `src/lib/server-api-config.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`). The upstream `model_name` remains the per-provider request model only. |
@@ -107,10 +107,10 @@ Use this document to jump directly to code before broad searching.
| Feature | Files | Notes |
| --- | --- | --- |
| Public gallery page | `src/app/gallery/page.tsx`, `src/app/globals.css` | Lists public works, search/sort/filter, preview/download, and one-click reuse. The search box is custom styled in-page to match the glass UI; gallery cards sample 3-5 distinct colors from the image and use a real `gallery-card-border-frame` wrapper with a single 3px blurred, continuous clockwise multicolor border around the full work-card container, including all four corners and the prompt/footer area. Avoid image-covering dark overlays, broad square glow blocks, or a separate outer halo layer. Hover like/download/reuse buttons invert against sampled image brightness. Gallery detail image previews use `ImageMetadataBadge` for actual ratio/resolution, and the detail footer writes a reuse draft before navigating to the matching `/create?type=...` mode. Mobile gallery must keep at least two masonry columns; `masonryColumnCount` bottoms out at 2 and `.gallery-masonry-grid`/card CSS trims spacing and metadata density on phones. |
| Public gallery API | `src/app/api/gallery/route.ts` | GET public works, admin DELETE unpublishes. Gallery author names use `profiles.display_nickname` first and never expose login username unless no display nickname exists. |
| Publish API | `src/app/api/gallery/publish/route.ts` | Copies media into gallery folders and inserts public work. |
| History persistence | `src/app/api/creation-history/route.ts`, `src/lib/creation-history-store.ts` | User-private completed works and published state. Single-record deletion is server-first when logged in; detail dialogs call the same store path and then refresh local history. |
| Public gallery page | `src/app/gallery/page.tsx`, `src/app/globals.css` | Lists public works, search/sort/filter, preview/download, and one-click reuse. It requests `/api/gallery` in small pages instead of fetching the full gallery, uses a bounded `miaojing:gallery:v3` browser localStorage cache for instant reopen, revalidates page 0 in the background, debounces search, and uses an IntersectionObserver sentinel to append the next page only when the user scrolls near it. Cached entries expire quickly for freshness and are pruned after 7 days or when the entry cap is exceeded. Image cards and detail display use `thumbnailUrl || url`, while fullscreen, download, copy/share, and reuse actions use original `url`. The search box is custom styled in-page to match the glass UI; gallery cards sample 3-5 distinct colors from the image and use a real `gallery-card-border-frame` wrapper with a single 3px blurred, continuous clockwise multicolor border around the full work-card container, including all four corners and the prompt/footer area. Avoid image-covering dark overlays, broad square glow blocks, or a separate outer halo layer. Hover like/download/reuse buttons invert against sampled image brightness. Gallery detail image previews use `ImageMetadataBadge` for actual ratio/resolution, and the detail footer writes a reuse draft before navigating to the matching `/create?type=...` mode. Mobile gallery must keep at least two masonry columns; `masonryColumnCount` bottoms out at 2 and `.gallery-masonry-grid`/card CSS trims spacing and metadata density on phones. |
| Public gallery API | `src/app/api/gallery/route.ts` | GET public works with `thumbnailUrl`, `total`, `nextOffset`, and `hasMore`, queues missing or old-profile image thumbnails for background backfill without delaying the response, admin DELETE unpublishes. Gallery author names use `profiles.display_nickname` first and never expose login username unless no display nickname exists. |
| Publish API | `src/app/api/gallery/publish/route.ts` | Copies image originals into object-backed gallery folders, stores local thumbnails, and inserts public work. |
| History persistence | `src/app/api/creation-history/route.ts`, `src/lib/creation-history-store.ts` | User-private completed works, `thumbnailUrl`, and published state. Missing image thumbnails are queued for background backfill instead of blocking the history response. Single-record deletion is server-first when logged in; detail dialogs call the same store path and then refresh local history. |
## Admin Console
@@ -136,7 +136,7 @@ Use this document to jump directly to code before broad searching.
| --- | --- | --- |
| Storage adapter | `src/lib/local-storage.ts` | Uses stable `/api/local-storage/<key>` URLs while the backend can be `STORAGE_MODE=local`, `dual`, or `object`. Object mode uses S3-compatible `OBJECT_STORAGE_*` config; dual mode writes local disk first and mirrors to object storage for safe migration. |
| Rainyun ROS object storage preparation | `scripts/rainyun-ros-prepare.mjs` | Uses the Rainyun control-plane API `POST /product/ros/bucket` to create a bucket from `RAINYUN_ROS_BUCKET_NAME` and `RAINYUN_ROS_INSTANCE_ID`, then writes a private `.env.rainyun-object.generated` file containing standard `OBJECT_STORAGE_*` variables. Do not use this control-plane API for runtime media reads/writes; runtime storage remains S3-compatible through `src/lib/local-storage.ts`. |
| Local/object file API | `src/app/api/local-storage/[...path]/route.ts` | Serves storage objects by key from local disk or object storage without changing existing frontend URLs. |
| Local/object file API | `src/app/api/local-storage/[...path]/route.ts`, `src/proxy.ts` | Serves storage objects by key without changing existing frontend URLs. Thumbnail keys under `thumbnails/...` are read from local disk and use long immutable browser cache headers because the filename contains the thumbnail profile; `src/proxy.ts` must preserve those cache headers instead of applying global `/api` no-store. Originals redirect to short-lived object-storage signed URLs when configured. |
| Download proxy | `src/app/api/download/route.ts` | Supports remote URL, same-origin URL, and `/api/local-storage/*`. |
| Remote fetch guard | `src/lib/remote-fetch.ts` | Use for server-side external fetches. |
@@ -161,7 +161,7 @@ Use this document to jump directly to code before broad searching.
| Build | `scripts/build.sh` |
| Start | `scripts/start.sh` |
| Dev | `scripts/dev.sh` |
| Deploy/upgrade | `scripts/deploy-or-upgrade.sh` |
| Deploy/upgrade | `scripts/deploy-or-upgrade.sh` | Sync excludes must target repo-root runtime artifacts only. Use root-anchored `/local-storage` so the source route `src/app/api/local-storage/[...path]/route.ts` is still deployed. |
| Backup | `scripts/backup-create.sh`, `scripts/backup-list.sh`, `scripts/backup-restore.sh`. Restore uses `pg_restore --single-transaction`, validates archive/dump contents, atomically swaps local storage, and keeps a pre-restore safety backup. |
| Object storage migration | `scripts/storage-sync-to-object.mjs` | Copies existing `LOCAL_STORAGE_DIR` files into the configured S3-compatible bucket, supports `--dry-run` and `--verify-only`, and should be run before switching production from local-only to object-backed storage. |
| Admin upgrade API/UI | `src/app/api/admin/upgrade/route.ts`, `src/components/admin/system-upgrade-tab.tsx` |

View File

@@ -604,7 +604,7 @@ function createSourceBackup(target) {
'--exclude=.next',
'--exclude=dist',
'--exclude=backups',
'--exclude=local-storage',
'--exclude=./local-storage',
'--exclude=upgrade-state',
'--exclude=tsconfig.tsbuildinfo',
'-C',

View File

@@ -558,7 +558,7 @@ sync_project_files() {
--exclude ".next" \
--exclude "dist" \
--exclude "backups" \
--exclude "local-storage" \
--exclude "/local-storage" \
--exclude ".env.local" \
--exclude ".codex_tmp" \
"${SOURCE_DIR}/" "${PROJECT_DIR}/" 2>&1 | log_pipe

View File

@@ -1,6 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDbClient } from '@/storage/database/local-db';
import { getAuthenticatedUserId } from '@/lib/session-auth';
import { ensureLocalImageThumbnail, isCurrentLocalImageThumbnail } from '@/lib/media-storage';
const workThumbnailQueue = new Map<string, Record<string, unknown>>();
let workThumbnailProcessing = false;
function toWorkType(type: string, params: Record<string, unknown>): string {
const explicitMode = params.creationMode || params.workType || params.mode;
@@ -26,6 +30,7 @@ function mapWork(row: Record<string, unknown>) {
id: row.id,
type: fromWorkType(String(row.type || 'text2img')),
url: row.result_url,
thumbnailUrl: row.thumbnail_url || undefined,
prompt: row.prompt || '',
negativePrompt: row.negative_prompt || undefined,
model: params.model || '',
@@ -38,25 +43,72 @@ function mapWork(row: Record<string, unknown>) {
: params.referenceImage
? [params.referenceImage]
: undefined,
creditsCost: Number(row.credits_cost || 0),
published: row.is_public === true,
createdAt: row.created_at,
};
}
async function ensureWorkThumbnail(client: Awaited<ReturnType<typeof getDbClient>>, row: Record<string, unknown>) {
if (isCurrentLocalImageThumbnail(row.thumbnail_url) || typeof row.result_url !== 'string') return row;
const type = String(row.type || '');
if (type !== 'text2img' && type !== 'img2img') return row;
try {
const thumbnailUrl = await ensureLocalImageThumbnail(row.result_url, 'thumbnails/works');
if (!thumbnailUrl) return row;
await client.query('UPDATE works SET thumbnail_url = $1 WHERE id = $2', [thumbnailUrl, row.id]);
return { ...row, thumbnail_url: thumbnailUrl };
} catch (error) {
console.warn('[creation-history] thumbnail generation failed:', error instanceof Error ? error.message : error);
return row;
}
}
function scheduleWorkThumbnail(row: Record<string, unknown>) {
if (isCurrentLocalImageThumbnail(row.thumbnail_url) || typeof row.result_url !== 'string') return;
const type = String(row.type || '');
if (type !== 'text2img' && type !== 'img2img') return;
const id = String(row.id || row.result_url);
workThumbnailQueue.set(id, row);
if (workThumbnailProcessing) return;
workThumbnailProcessing = true;
void (async () => {
try {
while (workThumbnailQueue.size > 0) {
const [nextId, nextRow] = workThumbnailQueue.entries().next().value as [string, Record<string, unknown>];
workThumbnailQueue.delete(nextId);
const client = await getDbClient();
try {
await ensureWorkThumbnail(client, nextRow);
} finally {
client.release();
}
}
} catch (error) {
console.warn('[creation-history] scheduled thumbnail generation failed:', error instanceof Error ? error.message : error);
} finally {
workThumbnailProcessing = false;
if (workThumbnailQueue.size > 0) scheduleWorkThumbnail(workThumbnailQueue.values().next().value as Record<string, unknown>);
}
})();
}
export async function GET(request: NextRequest) {
const userId = await getAuthenticatedUserId(request);
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
const client = await getDbClient();
try {
const result = await client.query(
`SELECT id, type, prompt, negative_prompt, params, result_url, is_public, status, created_at
`SELECT id, type, prompt, negative_prompt, params, result_url, thumbnail_url, is_public, status, credits_cost, created_at
FROM works
WHERE user_id = $1 AND status = 'completed'
ORDER BY created_at DESC
LIMIT 300`,
[userId],
);
return NextResponse.json({ records: result.rows.map(mapWork) });
for (const row of result.rows) scheduleWorkThumbnail(row);
const rows = result.rows;
return NextResponse.json({ records: rows.map(mapWork) });
} finally {
client.release();
}
@@ -82,25 +134,38 @@ export async function POST(request: NextRequest) {
};
const workType = toWorkType(String(record.type || 'image'), params);
let url = String(record.url || '').trim();
let thumbnailUrl = String(record.thumbnailUrl || '').trim() || null;
if (workType === 'reverse-prompt') {
url = url && !url.startsWith('data:') ? url : `[reverse-prompt:${record.id || Date.now()}]`;
}
if (!url || url.startsWith('data:')) continue;
if (!thumbnailUrl && (workType === 'text2img' || workType === 'img2img')) {
try {
thumbnailUrl = await ensureLocalImageThumbnail(url, 'thumbnails/works');
} catch (error) {
console.warn('[creation-history] thumbnail generation failed:', error instanceof Error ? error.message : error);
}
}
const existing = await client.query(
`SELECT id, type, prompt, negative_prompt, params, result_url, is_public, status, created_at
`SELECT id, type, prompt, negative_prompt, params, result_url, thumbnail_url, is_public, status, credits_cost, created_at
FROM works
WHERE user_id = $1 AND result_url = $2
LIMIT 1`,
[userId, url],
);
if (existing.rows[0]) {
saved.push(mapWork(existing.rows[0]));
const existingRow = existing.rows[0];
if (thumbnailUrl && !existingRow.thumbnail_url) {
await client.query('UPDATE works SET thumbnail_url = $1 WHERE id = $2', [thumbnailUrl, existingRow.id]);
existingRow.thumbnail_url = thumbnailUrl;
}
saved.push(mapWork(existingRow));
continue;
}
const result = await client.query(
`INSERT INTO works (user_id, type, prompt, negative_prompt, params, result_url, is_public, status, credits_cost, created_at)
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, 'completed', $8, COALESCE($9::timestamptz, NOW()))
RETURNING id, type, prompt, negative_prompt, params, result_url, is_public, status, created_at`,
`INSERT INTO works (user_id, type, prompt, negative_prompt, params, result_url, thumbnail_url, is_public, status, credits_cost, created_at)
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, 'completed', $9, COALESCE($10::timestamptz, NOW()))
RETURNING id, type, prompt, negative_prompt, params, result_url, thumbnail_url, is_public, status, credits_cost, created_at`,
[
userId,
workType,
@@ -108,6 +173,7 @@ export async function POST(request: NextRequest) {
record.negativePrompt || null,
JSON.stringify(params),
url,
thumbnailUrl,
Boolean(record.published),
Number(record.creditsCost || 0),
record.createdAt || null,

View File

@@ -16,6 +16,10 @@ export async function GET(request: NextRequest) {
const filename = sanitizeFilename(
request.nextUrl.searchParams.get('filename') || 'download',
);
const disposition = request.nextUrl.searchParams.get('disposition') === 'inline'
|| request.nextUrl.searchParams.get('inline') === '1'
? 'inline'
: 'attachment';
if (!url) {
return NextResponse.json({ error: '缺少 url 参数' }, { status: 400 });
@@ -24,7 +28,7 @@ export async function GET(request: NextRequest) {
try {
const localKey = getLocalStorageKey(url);
if (localKey) {
return await downloadLocalStorageFile(localKey, filename);
return await downloadLocalStorageFile(localKey, filename, disposition);
}
const targetUrl = resolveDownloadUrl(url, request.nextUrl.origin);
@@ -54,6 +58,7 @@ export async function GET(request: NextRequest) {
contentType,
filename,
body.byteLength,
disposition,
);
} catch (err) {
const msg = err instanceof Error ? err.message : '下载失败';
@@ -99,7 +104,7 @@ function resolveDownloadUrl(url: string, origin: string): string | null {
return null;
}
async function downloadLocalStorageFile(key: string, filename: string) {
async function downloadLocalStorageFile(key: string, filename: string, disposition: 'attachment' | 'inline') {
if (!await localStorage.fileExistsAsync(key)) {
return NextResponse.json({ error: '文件不存在' }, { status: 404 });
}
@@ -115,6 +120,7 @@ async function downloadLocalStorageFile(key: string, filename: string) {
contentType,
filename,
fileBuffer.byteLength,
disposition,
);
}
@@ -123,14 +129,17 @@ function buildDownloadResponse(
contentType: string,
filename: string,
length: number,
disposition: 'attachment' | 'inline',
) {
return new NextResponse(body, {
status: 200,
headers: {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Disposition': `${disposition}; filename="${filename}"`,
'Content-Length': String(length),
'Cache-Control': 'no-cache',
'Cache-Control': disposition === 'inline'
? 'public, max-age=86400, stale-while-revalidate=604800'
: 'no-cache',
},
});
}

View File

@@ -2,6 +2,7 @@
import { getDbClient } from '@/storage/database/local-db';
import { localStorage } from '@/lib/local-storage';
import { getAuthenticatedUserId } from '@/lib/session-auth';
import { ensureLocalImageThumbnail } from '@/lib/media-storage';
export async function POST(request: NextRequest) {
try {
@@ -54,18 +55,21 @@ export async function POST(request: NextRequest) {
const safeUserId = tokenUserId;
const id = crypto.randomUUID();
let galleryResultUrl = resultUrl;
let galleryThumbnailUrl = thumbnailUrl || null;
try {
const folder = type === 'video' ? 'gallery/videos' : 'gallery/images';
galleryResultUrl = await localStorage.copyPublicUrlToFolder(resultUrl, folder);
if (thumbnailUrl) {
galleryThumbnailUrl = await localStorage.copyPublicUrlToFolder(thumbnailUrl, 'gallery/thumbnails');
}
} catch (copyError) {
console.warn('[gallery/publish] copy to gallery folder failed, using original URL:', copyError);
}
const id = crypto.randomUUID();
let galleryResultUrl = resultUrl;
let galleryThumbnailUrl = thumbnailUrl || null;
try {
const folder = type === 'video' ? 'gallery/videos' : 'gallery/images';
galleryResultUrl = await localStorage.copyPublicUrlToFolder(resultUrl, folder, { storageTarget: 'object' });
if (type === 'image') {
galleryThumbnailUrl = await ensureLocalImageThumbnail(galleryResultUrl, 'thumbnails/gallery')
|| galleryThumbnailUrl;
} else if (thumbnailUrl) {
galleryThumbnailUrl = await localStorage.copyPublicUrlToFolder(thumbnailUrl, 'gallery/thumbnails', { storageTarget: 'local' });
}
} catch (copyError) {
console.warn('[gallery/publish] copy to gallery folder failed, using original URL:', copyError);
}
await client.query(
`INSERT INTO works (id, user_id, type, title, prompt, negative_prompt, result_url, thumbnail_url, width, height, duration, is_public, likes_count, credits_cost, status, params)

View File

@@ -1,6 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/admin-auth';
import { getDbClient } from '@/storage/database/local-db';
import { ensureLocalImageThumbnail, isCurrentLocalImageThumbnail } from '@/lib/media-storage';
const galleryThumbnailQueue = new Map<string, Record<string, unknown>>();
let galleryThumbnailProcessing = false;
function getReferenceImages(params: Record<string, unknown>) {
const referenceImages = Array.isArray(params.referenceImages)
@@ -12,11 +16,58 @@ function getReferenceImages(params: Record<string, unknown>) {
return { referenceImage, referenceImages };
}
async function ensureGalleryThumbnail(client: Awaited<ReturnType<typeof getDbClient>>, row: Record<string, unknown>) {
if (isCurrentLocalImageThumbnail(row.thumbnail_url) || typeof row.result_url !== 'string') return row;
const type = String(row.type || '');
if (type !== 'text2img' && type !== 'img2img') return row;
try {
const thumbnailUrl = await ensureLocalImageThumbnail(row.result_url, 'thumbnails/gallery');
if (!thumbnailUrl) return row;
await client.query('UPDATE works SET thumbnail_url = $1 WHERE id = $2', [thumbnailUrl, row.id]);
return { ...row, thumbnail_url: thumbnailUrl };
} catch (error) {
console.warn('[gallery] thumbnail generation failed:', error instanceof Error ? error.message : error);
return row;
}
}
function scheduleGalleryThumbnail(row: Record<string, unknown>) {
if (isCurrentLocalImageThumbnail(row.thumbnail_url) || typeof row.result_url !== 'string') return;
const type = String(row.type || '');
if (type !== 'text2img' && type !== 'img2img') return;
const id = String(row.id || row.result_url);
galleryThumbnailQueue.set(id, row);
if (galleryThumbnailProcessing) return;
galleryThumbnailProcessing = true;
void (async () => {
try {
while (galleryThumbnailQueue.size > 0) {
const [nextId, nextRow] = galleryThumbnailQueue.entries().next().value as [string, Record<string, unknown>];
galleryThumbnailQueue.delete(nextId);
const client = await getDbClient();
try {
await ensureGalleryThumbnail(client, nextRow);
} finally {
client.release();
}
}
} catch (error) {
console.warn('[gallery] scheduled thumbnail generation failed:', error instanceof Error ? error.message : error);
} finally {
galleryThumbnailProcessing = false;
if (galleryThumbnailQueue.size > 0) scheduleGalleryThumbnail(galleryThumbnailQueue.values().next().value as Record<string, unknown>);
}
})();
}
export async function GET(request: NextRequest) {
const url = request.nextUrl.searchParams;
const type = url.get('type');
const limit = Math.min(parseInt(url.get('limit') || '50', 10), 300);
const offset = parseInt(url.get('offset') || '0', 10);
const category = url.get('category');
const requestedLimit = parseInt(url.get('limit') || '50', 10);
const requestedOffset = parseInt(url.get('offset') || '0', 10);
const limit = Number.isFinite(requestedLimit) ? Math.max(1, Math.min(requestedLimit, 300)) : 50;
const offset = Number.isFinite(requestedOffset) ? Math.max(0, requestedOffset) : 0;
const sort = url.get('sort') || 'newest';
const search = (url.get('q') || url.get('search') || '').trim().toLowerCase();
@@ -24,7 +75,11 @@ export async function GET(request: NextRequest) {
const client = await getDbClient();
try {
const where: string[] = ['w.is_public = true', 'w.status = $1'];
const where: string[] = [
'w.is_public = true',
'w.status = $1',
"w.result_url LIKE '/api/local-storage/%'",
];
const params: unknown[] = ['completed'];
if (type === 'image') {
@@ -35,6 +90,15 @@ export async function GET(request: NextRequest) {
where.push(`w.type IN ($${params.length - 1}, $${params.length})`);
}
if (category === 'text2img' || category === 'img2img' || category === 'text2video' || category === 'img2video') {
params.push(category);
const idx = params.length;
where.push(`(
w.type = $${idx}
OR COALESCE(w.params->>'creationMode', w.params->>'workType', w.params->>'mode') = $${idx}
)`);
}
if (search) {
params.push(`%${search}%`);
const idx = params.length;
@@ -76,7 +140,9 @@ export async function GET(request: NextRequest) {
params,
);
const works = (result.rows || []).map((w: Record<string, unknown>) => {
for (const row of result.rows || []) scheduleGalleryThumbnail(row);
const rows = result.rows || [];
const works = rows.map((w: Record<string, unknown>) => {
const workParams = (w.params || {}) as Record<string, unknown>;
const references = getReferenceImages(workParams);
return {
@@ -102,7 +168,22 @@ export async function GET(request: NextRequest) {
};
});
return NextResponse.json({ works, total: parseInt(countResult.rows[0]?.total || '0', 10) });
const total = parseInt(countResult.rows[0]?.total || '0', 10);
const nextOffset = offset + works.length;
return NextResponse.json(
{
works,
total,
nextOffset,
hasMore: nextOffset < total,
},
{
headers: {
'Cache-Control': 'private, max-age=30, stale-while-revalidate=120',
},
},
);
} finally {
client.release();
}

View File

@@ -31,6 +31,12 @@ import {
dataUrlToImageBuffer,
} from '@/lib/server-image-compression';
import { executeUserApiManifest } from '@/lib/user-api-manifest-executor';
import {
getImageExtension as getMediaImageExtension,
parseImageDataUrl as parseMediaImageDataUrl,
persistOriginalImageWithThumbnail,
readImageBufferFromUrl,
} from '@/lib/media-storage';
interface CustomApiConfig {
apiUrl: string;
@@ -52,6 +58,7 @@ interface TargetImageSize {
interface PersistedImageResult {
url: string;
thumbnailUrl: string;
width: number;
height: number;
bytes: number;
@@ -59,6 +66,7 @@ interface PersistedImageResult {
interface QualifiedImageResult {
url: string;
thumbnailUrl: string;
width: number;
height: number;
bytes: number;
@@ -157,83 +165,27 @@ function imageMeetsTargetSize(width: number, height: number, targetSize: TargetI
}
function getImageExtension(mimeType: string | null | undefined, fallbackUrl?: string): string {
const normalized = mimeType?.split(';')[0].trim().toLowerCase();
const mimeExt: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/webp': 'webp',
};
if (normalized && mimeExt[normalized]) return mimeExt[normalized];
const urlExt = fallbackUrl?.split('?')[0].match(/\.([a-z0-9]+)$/i)?.[1];
return urlExt || 'png';
return getMediaImageExtension(mimeType || 'image/png', fallbackUrl || '');
}
function parseImageDataUrl(dataUrl: string): { buffer: Buffer; mimeType: string; ext: string } | null {
const match = dataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/);
if (!match) return null;
const [, mimeType, base64Data] = match;
return {
buffer: Buffer.from(base64Data, 'base64'),
mimeType,
ext: getImageExtension(mimeType),
};
return parseMediaImageDataUrl(dataUrl);
}
async function persistImageWithMetadata(url: string, prefix: string): Promise<PersistedImageResult | null> {
let buffer: Buffer;
let mimeType = 'image/png';
let ext = 'png';
if (url.startsWith('data:')) {
const parsed = parseImageDataUrl(url);
if (!parsed) return null;
buffer = parsed.buffer;
mimeType = parsed.mimeType;
ext = parsed.ext;
} else {
const existingKey = localStorage.getKeyFromPublicUrl(url);
if (existingKey && await localStorage.fileExistsAsync(existingKey)) {
buffer = await localStorage.readFileAsync(existingKey);
ext = existingKey.split('.').pop() || ext;
} else if (url.startsWith('http')) {
const response = await withTimeout(fetchPublicHttpUrl(url), 30_000, 'Fetch generated image');
if (!response.ok) throw new Error(`下载生成图片失败: ${response.status}`);
mimeType = response.headers.get('content-type')?.split(';')[0] || mimeType;
buffer = Buffer.from(await response.arrayBuffer());
ext = getImageExtension(mimeType, url);
} else {
return null;
}
}
const metadata = await sharp(buffer, { failOn: 'none' }).metadata();
if (!metadata.width || !metadata.height) {
throw new Error('无法读取生成图片尺寸');
}
const fileName = `${prefix}/${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const fileKey = await withTimeout(
localStorage.uploadFile({
fileContent: buffer,
fileName,
contentType: mimeType,
const source = await readImageBufferFromUrl(url);
if (!source) return null;
return withTimeout(
persistOriginalImageWithThumbnail({
buffer: source.buffer,
mimeType: source.mimeType,
ext: source.ext,
originalPrefix: prefix,
thumbnailPrefix: 'thumbnails/generated/images',
}),
30_000,
'Local uploadFile',
45_000,
'Persist generated image media',
);
const presignedUrl = await withTimeout(
localStorage.generatePresignedUrl({ key: fileKey, expireTime: 2592000 }),
10_000,
'Local generatePresignedUrl',
);
return {
url: presignedUrl,
width: metadata.width,
height: metadata.height,
bytes: buffer.length,
};
}
async function persistQualifiedImageUrls(
@@ -241,7 +193,7 @@ async function persistQualifiedImageUrls(
prefix: string,
targetSize: TargetImageSize | null,
context: string,
): Promise<{ images: string[]; rejected: string[] }> {
): Promise<{ images: string[]; thumbnails: Record<string, string>; rejected: string[] }> {
const images: QualifiedImageResult[] = [];
const rejected: string[] = [];
@@ -266,7 +218,19 @@ async function persistQualifiedImageUrls(
}
images.sort((a, b) => (b.width * b.height) - (a.width * a.height) || b.bytes - a.bytes);
return { images: images.map(image => image.url), rejected };
return {
images: images.map(image => image.url),
thumbnails: Object.fromEntries(images.map(image => [image.url, image.thumbnailUrl])),
rejected,
};
}
function imageResponsePayload(result: { images: string[]; thumbnails: Record<string, string> }) {
return {
images: result.images,
thumbnails: result.thumbnails,
thumbnailUrls: result.images.map(url => result.thumbnails[url] || url),
};
}
async function fetchCustomImageGeneration(
@@ -307,8 +271,9 @@ async function requestQualifiedCustomImages(
targetCount: number,
targetSize: TargetImageSize | null,
onProgress?: (progress: Record<string, unknown>) => void | Promise<void>,
): Promise<{ images: string[]; rejected: string[]; upstreamError?: { status: number; text: string } }> {
): Promise<{ images: string[]; thumbnails: Record<string, string>; rejected: string[]; upstreamError?: { status: number; text: string } }> {
const accepted: string[] = [];
const thumbnails: Record<string, string> = {};
const rejected: string[] = [];
const maxAttempts = 1;
@@ -327,6 +292,7 @@ async function requestQualifiedCustomImages(
if (!response.ok) {
return {
images: accepted,
thumbnails,
rejected,
upstreamError: { status: response.response.status, text: response.errorText },
};
@@ -344,10 +310,16 @@ async function requestQualifiedCustomImages(
`Custom API Image attempt ${attempt}`,
);
accepted.push(...persisted.images);
Object.assign(thumbnails, persisted.thumbnails);
rejected.push(...persisted.rejected);
}
return { images: accepted.slice(0, targetCount), rejected };
const images = accepted.slice(0, targetCount);
return {
images,
thumbnails: Object.fromEntries(images.map(url => [url, thumbnails[url] || url])),
rejected,
};
}
function lowResolutionError(targetSize: TargetImageSize | null, rejected: string[]): string {
@@ -379,7 +351,7 @@ async function uploadDataUrlAndGetPublicUrl(dataUrl: string): Promise<string | n
const buffer = Buffer.from(base64Data, 'base64');
const fileName = `img2img-ref/${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const fileKey = await localStorage.uploadFile({
const fileKey = await localStorage.uploadFileObjectOnly({
fileContent: buffer,
fileName,
contentType: mimeType,
@@ -812,7 +784,7 @@ async function customApiImageToImage(
);
if (result1.success && result1.images) {
const persisted = await persistQualifiedImageUrls(result1.images, 'generated/images', targetSize, 'Custom API img2img strategy1');
if (persisted.images.length > 0) return NextResponse.json({ images: persisted.images });
if (persisted.images.length > 0) return NextResponse.json(imageResponsePayload(persisted));
result1 = { ...result1, success: false, error: lowResolutionError(targetSize, persisted.rejected) };
}
}
@@ -828,7 +800,7 @@ async function customApiImageToImage(
);
if (result2.success && result2.images) {
const persisted = await persistQualifiedImageUrls(result2.images, 'generated/images', targetSize, 'Custom API img2img strategy2');
if (persisted.images.length > 0) return NextResponse.json({ images: persisted.images });
if (persisted.images.length > 0) return NextResponse.json(imageResponsePayload(persisted));
result2.success = false;
result2.error = lowResolutionError(targetSize, persisted.rejected);
}
@@ -844,7 +816,7 @@ async function customApiImageToImage(
);
if (result3.success && result3.images) {
const persisted = await persistQualifiedImageUrls(result3.images, 'generated/images', targetSize, 'Custom API img2img strategy3');
if (persisted.images.length > 0) return NextResponse.json({ images: persisted.images });
if (persisted.images.length > 0) return NextResponse.json(imageResponsePayload(persisted));
result3.success = false;
result3.error = lowResolutionError(targetSize, persisted.rejected);
}
@@ -1009,7 +981,7 @@ export async function POST(request: NextRequest) {
if (persisted.images.length === 0) {
return NextResponse.json({ error: lowResolutionError(targetSize, persisted.rejected) }, { status: 502 });
}
return NextResponse.json({ images: persisted.images });
return NextResponse.json(imageResponsePayload(persisted));
}
}
@@ -1121,7 +1093,7 @@ export async function POST(request: NextRequest) {
}
console.log('[Custom API Image] Persisted', customGenerationResult.images.length, '/', n, 'qualified images',
'| target:', customTargetSize ? formatTargetSize(customTargetSize) : 'none');
return NextResponse.json({ images: customGenerationResult.images });
return NextResponse.json(imageResponsePayload(customGenerationResult));
} catch (customError: unknown) {
const msg = customError instanceof Error ? customError.message : '自定义API请求异常';
console.error('[Custom API Image Exception]', msg);
@@ -1257,7 +1229,7 @@ export async function POST(request: NextRequest) {
if (persistedImages.images.length === 0) {
return NextResponse.json({ error: lowResolutionError(targetSize, persistedImages.rejected) }, { status: 502 });
}
return NextResponse.json({ images: persistedImages.images });
return NextResponse.json(imageResponsePayload(persistedImages));
} catch (error: unknown) {
const message = error instanceof Error ? error.message : '图片生成失败';
console.error('[Image Generation Error]', message, error instanceof Error ? error.stack : '');

View File

@@ -14,6 +14,7 @@ import {
} from '@/lib/generation-job-estimates';
import { writePlatformLog } from '@/lib/platform-logs';
import { incrementImageStylePresetUsage } from '@/lib/style-preset-store';
import { ensureGenerationCreditsAvailable } from '@/lib/generation-credit-service';
export async function POST(request: NextRequest) {
try {
@@ -45,6 +46,12 @@ export async function POST(request: NextRequest) {
await ensureGenerationJobRuntimeSchema(client);
const identity = await resolveGenerationJobIdentity(client, userId, payload);
jobIdentity = identity;
try {
await ensureGenerationCreditsAvailable(client, userId, { type, payload });
} catch (error) {
const message = error instanceof Error ? error.message : '积分不足';
return NextResponse.json({ error: message }, { status: 402 });
}
const estimate = await getGenerationJobEstimate(client, type, identity.provider, identity.modelName);
estimateSeconds = estimate.estimateSeconds;
etaSource = estimate.source;

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useMemo, useEffect, useCallback, type CSSProperties, type SyntheticEvent } from 'react';
import { useState, useMemo, useEffect, useCallback, useRef, type CSSProperties, type SyntheticEvent } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -25,7 +25,6 @@ import {
Search,
} from 'lucide-react';
import { copyTextToClipboard, downloadFile } from '@/lib/utils';
import { usePublishedWorks, useCreationHistory, syncPublishedToSupabase } from '@/lib/creation-history-store';
import { useAuth } from '@/lib/auth-store';
import { FullscreenPreview } from '@/components/fullscreen-preview';
import { ImageMetadataBadge } from '@/components/image-metadata-badge';
@@ -41,6 +40,14 @@ const CATEGORIES = [
{ value: 'img2video', label: '图生视频', icon: Film },
];
const GALLERY_PAGE_SIZE = 18;
const GALLERY_CACHE_KEY = 'miaojing:gallery:v3';
const GALLERY_CACHE_VERSION = 3;
const GALLERY_CACHE_TTL_MS = 5 * 60 * 1000;
const GALLERY_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const GALLERY_CACHE_MAX_ENTRIES = 12;
const GALLERY_CACHE_MAX_WORKS_PER_ENTRY = 72;
/* ---------- Gallery Work (from API) ---------- */
interface GalleryWork {
id: string;
@@ -64,6 +71,126 @@ interface GalleryWork {
publishedAt: string;
}
interface GalleryCacheEntry {
works: GalleryWork[];
total: number;
nextOffset: number;
hasMore: boolean;
savedAt: number;
}
interface GalleryCacheStore {
version: number;
savedAt: number;
entries: Record<string, GalleryCacheEntry>;
}
interface GalleryPageResponse {
works?: GalleryWork[];
total?: number;
nextOffset?: number;
hasMore?: boolean;
}
function buildGalleryCacheSignature(category: string, sortBy: string, searchQuery: string): string {
return JSON.stringify({
category,
sortBy,
search: searchQuery.trim().toLowerCase(),
});
}
function readGalleryCacheStore(): GalleryCacheStore | null {
if (typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem(GALLERY_CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as GalleryCacheStore;
if (parsed?.version !== GALLERY_CACHE_VERSION || !parsed.entries || typeof parsed.entries !== 'object') {
window.localStorage.removeItem(GALLERY_CACHE_KEY);
return null;
}
return parsed;
} catch {
try {
window.localStorage.removeItem(GALLERY_CACHE_KEY);
} catch { /* ignore */ }
return null;
}
}
function writeGalleryCacheStore(store: GalleryCacheStore) {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(GALLERY_CACHE_KEY, JSON.stringify(store));
} catch {
try {
window.localStorage.removeItem(GALLERY_CACHE_KEY);
} catch { /* ignore */ }
}
}
function cleanupGalleryCache(store: GalleryCacheStore | null = readGalleryCacheStore()): GalleryCacheStore | null {
if (!store) return null;
const now = Date.now();
const validEntries = Object.entries(store.entries)
.filter(([, entry]) => now - Number(entry.savedAt || 0) <= GALLERY_CACHE_MAX_AGE_MS)
.sort((a, b) => Number(b[1].savedAt || 0) - Number(a[1].savedAt || 0))
.slice(0, GALLERY_CACHE_MAX_ENTRIES);
const nextStore: GalleryCacheStore = {
version: GALLERY_CACHE_VERSION,
savedAt: now,
entries: Object.fromEntries(validEntries),
};
writeGalleryCacheStore(nextStore);
return nextStore;
}
function getGalleryCacheEntry(signature: string): GalleryCacheEntry | null {
const store = cleanupGalleryCache();
const entry = store?.entries?.[signature];
if (!entry || Date.now() - Number(entry.savedAt || 0) > GALLERY_CACHE_TTL_MS) return null;
return entry;
}
function saveGalleryCacheEntry(signature: string, entry: GalleryCacheEntry) {
const now = Date.now();
const store = cleanupGalleryCache(readGalleryCacheStore()) || {
version: GALLERY_CACHE_VERSION,
savedAt: now,
entries: {},
};
const cachedWorks = entry.works.slice(0, GALLERY_CACHE_MAX_WORKS_PER_ENTRY);
store.entries[signature] = {
...entry,
works: cachedWorks,
nextOffset: entry.hasMore ? Math.min(entry.nextOffset, cachedWorks.length) : entry.nextOffset,
savedAt: now,
};
store.savedAt = now;
cleanupGalleryCache(store);
}
function removeGalleryWorksFromCache(ids: Set<string>) {
const store = readGalleryCacheStore();
if (!store) return;
const now = Date.now();
const entries = Object.fromEntries(
Object.entries(store.entries).map(([key, entry]) => [
key,
{
...entry,
works: entry.works.filter(work => !ids.has(work.id)),
total: Math.max(0, Number(entry.total || 0) - entry.works.filter(work => ids.has(work.id)).length),
nextOffset: Math.min(entry.nextOffset, entry.works.filter(work => !ids.has(work.id)).length),
savedAt: now,
},
]),
);
writeGalleryCacheStore({ version: GALLERY_CACHE_VERSION, savedAt: now, entries });
}
function getCategoryFromWork(work: GalleryWork): string {
const mode = work.params?.creationMode || work.params?.workType || work.params?.mode;
if (
@@ -402,19 +529,32 @@ const galleryMenuItemActiveClass =
export default function GalleryPage() {
const [apiWorks, setApiWorks] = useState<GalleryWork[]>([]);
const [loading, setLoading] = useState(true);
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'done'>('idle');
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [nextOffset, setNextOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [totalWorks, setTotalWorks] = useState(0);
const [category, setCategory] = useState('all');
const [likedIds, setLikedIds] = useState<Set<string>>(new Set());
const [selectedWork, setSelectedWork] = useState<GalleryWork | null>(null);
const [fullscreenSrc, setFullscreenSrc] = useState<string | null>(null);
const [fullscreenFallbackSrc, setFullscreenFallbackSrc] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<'newest' | 'popular'>('newest');
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
const [masonryColumnCount, setMasonryColumnCount] = useState(4);
const [measuredMediaSizes, setMeasuredMediaSizes] = useState<Record<string, MediaSize>>({});
const [cardPalettes, setCardPalettes] = useState<Record<string, GalleryCardPalette>>({});
const [selectedGalleryIds, setSelectedGalleryIds] = useState<Set<string>>(new Set());
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const galleryRequestSeqRef = useRef(0);
const { openImageMenu, ImageActionsContextMenu } = useImageActionsContextMenu();
const openFullscreenPreview = useCallback((src: string, fallbackSrc?: string | null) => {
setFullscreenFallbackSrc(fallbackSrc || null);
setFullscreenSrc(src);
}, []);
useEffect(() => {
const updateColumnCount = () => {
const width = window.innerWidth;
@@ -445,106 +585,118 @@ export default function GalleryPage() {
}
return () => { document.body.style.overflow = ''; };
}, [selectedWork]);
const { works: localPublished } = usePublishedWorks();
const { records: creationHistory } = useCreationHistory();
const { user, accessToken, isAdmin } = useAuth();
const { accessToken, isAdmin } = useAuth();
useEffect(() => {
const timer = window.setTimeout(() => {
setDebouncedSearchQuery(searchQuery.trim());
}, 300);
return () => window.clearTimeout(timer);
}, [searchQuery]);
const activeCacheSignature = useMemo(
() => buildGalleryCacheSignature(category, sortBy, debouncedSearchQuery),
[category, sortBy, debouncedSearchQuery],
);
const fetchGalleryPage = useCallback(async (offset: number, options?: { append?: boolean; background?: boolean }) => {
const requestSeq = ++galleryRequestSeqRef.current;
const append = Boolean(options?.append);
const background = Boolean(options?.background);
if (append) setLoadingMore(true);
else if (background) setRefreshing(true);
else setLoading(true);
// Fetch works from API, after syncing localStorage to Supabase
const fetchWorks = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({ sort: sortBy, limit: '300' });
if (searchQuery.trim()) params.set('q', searchQuery.trim());
const res = await fetch(`/api/gallery?${params.toString()}`);
if (res.ok) {
const data = await res.json();
setApiWorks(data.works || []);
}
} catch { /* ignore */ }
setLoading(false);
}, [sortBy, searchQuery]);
const params = new URLSearchParams({
sort: sortBy,
limit: String(GALLERY_PAGE_SIZE),
offset: String(offset),
});
if (debouncedSearchQuery.trim()) params.set('q', debouncedSearchQuery.trim());
if (category !== 'all') params.set('category', category);
// Sync localStorage to Supabase on first mount only
useEffect(() => {
setSyncStatus('syncing');
syncPublishedToSupabase().then(synced => {
setSyncStatus('done');
if (synced > 0) {
// Re-fetch after sync to show newly synced works
fetchWorks();
const res = await fetch(`/api/gallery?${params.toString()}`, {
cache: background ? 'no-cache' : 'default',
});
if (!res.ok) return;
const data = (await res.json()) as GalleryPageResponse;
if (requestSeq !== galleryRequestSeqRef.current) return;
const incomingWorks = Array.isArray(data.works) ? data.works : [];
const incomingTotal = Number(data.total ?? 0);
const incomingNextOffset = Number(data.nextOffset ?? offset + incomingWorks.length);
const incomingHasMore = Boolean(data.hasMore ?? incomingNextOffset < incomingTotal);
setApiWorks(prev => {
const next = append
? [...prev, ...incomingWorks.filter(work => !prev.some(item => item.id === work.id))]
: incomingWorks;
saveGalleryCacheEntry(activeCacheSignature, {
works: next,
total: incomingTotal,
nextOffset: incomingNextOffset,
hasMore: incomingHasMore,
savedAt: Date.now(),
});
return next;
});
setTotalWorks(incomingTotal);
setNextOffset(incomingNextOffset);
setHasMore(incomingHasMore);
} catch {
// Keep any cached or already loaded rows on transient network failures.
} finally {
if (requestSeq === galleryRequestSeqRef.current) {
if (append) setLoadingMore(false);
else if (background) setRefreshing(false);
else setLoading(false);
}
}).catch(() => {
setSyncStatus('done');
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
}, [activeCacheSignature, category, sortBy, debouncedSearchQuery]);
useEffect(() => {
fetchWorks();
}, [fetchWorks]);
const cached = getGalleryCacheEntry(activeCacheSignature);
setSelectedGalleryIds(new Set());
setMeasuredMediaSizes({});
setCardPalettes({});
// Merge API works with localStorage published works + published creation history
// This ensures previously shared works are visible even if not yet in Supabase
const works = useMemo(() => {
const apiUrls = new Set(apiWorks.map(w => w.url));
if (cached) {
setApiWorks(cached.works);
setTotalWorks(cached.total);
setNextOffset(cached.nextOffset);
setHasMore(cached.hasMore);
setLoading(false);
void fetchGalleryPage(0, { background: true });
return;
}
// From localStorage published gallery
const localAsGallery: GalleryWork[] = localPublished
.filter(w => !apiUrls.has(w.url))
.map(w => ({
id: w.id,
type: w.type === 'video' ? (w.referenceImage ? 'img2video' : 'text2video') : (w.referenceImage ? 'img2img' : 'text2img'),
title: null,
prompt: w.prompt,
negativePrompt: w.negativePrompt,
url: w.url,
thumbnailUrl: null,
width: null,
height: null,
duration: null,
likes: w.likes || 0,
creditsCost: null,
params: { model: w.model, modelLabel: w.modelLabel, ...w.params },
referenceImage: w.referenceImage,
referenceImages: w.referenceImages,
publisherId: w.publisherId,
publisherNickname: w.publisherNickname,
publisherAvatarUrl: null,
publishedAt: w.publishedAt,
}));
setApiWorks([]);
setTotalWorks(0);
setNextOffset(0);
setHasMore(false);
void fetchGalleryPage(0);
}, [activeCacheSignature, fetchGalleryPage]);
// From creation history records marked as published
const existingUrls = new Set([...apiUrls, ...localAsGallery.map(w => w.url)]);
const historyPublished: GalleryWork[] = creationHistory
.filter(r => r.published && r.url && !existingUrls.has(r.url) && !r.url.startsWith('data:') && !r.url.startsWith('['))
.map(r => ({
id: r.id,
type: r.type === 'video' ? (r.referenceImage ? 'img2video' : 'text2video') : (r.referenceImage ? 'img2img' : 'text2img'),
title: null,
prompt: r.prompt,
negativePrompt: r.negativePrompt,
url: r.url,
thumbnailUrl: null,
width: null,
height: null,
duration: null,
likes: 0,
creditsCost: null,
params: { model: r.model, modelLabel: r.modelLabel, ...r.params },
referenceImage: r.referenceImage,
referenceImages: r.referenceImages,
publisherId: user?.id || 'anonymous',
publisherNickname: user?.nickname || user?.username || user?.email?.split('@')[0] || '匿名用户',
publisherAvatarUrl: user?.avatarUrl || null,
publishedAt: r.createdAt,
}));
useEffect(() => {
const sentinel = loadMoreRef.current;
if (!sentinel || !hasMore || loading || loadingMore || refreshing) return;
return [...apiWorks, ...localAsGallery, ...historyPublished];
}, [apiWorks, localPublished, creationHistory, user]);
const observer = new IntersectionObserver(
(entries) => {
if (entries.some(entry => entry.isIntersecting)) {
void fetchGalleryPage(nextOffset, { append: true });
}
},
{ root: null, rootMargin: '720px 0px', threshold: 0.01 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [fetchGalleryPage, hasMore, loading, loadingMore, nextOffset, refreshing]);
const filteredWorks = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
return works.filter(work => {
const query = debouncedSearchQuery.trim().toLowerCase();
return apiWorks.filter(work => {
if (category !== 'all' && getCategoryFromWork(work) !== category) return false;
if (!query) return true;
const haystack = [
@@ -558,7 +710,7 @@ export default function GalleryPage() {
].map(value => String(value || '').toLowerCase()).join('\n');
return haystack.includes(query);
});
}, [works, category, searchQuery]);
}, [apiWorks, category, debouncedSearchQuery]);
const apiWorkIds = useMemo(() => new Set(apiWorks.map(work => work.id)), [apiWorks]);
@@ -667,6 +819,8 @@ export default function GalleryPage() {
}
const removedIds = new Set<string>((data.ids || targetIds) as string[]);
setApiWorks(prev => prev.filter(work => !removedIds.has(work.id)));
removeGalleryWorksFromCache(removedIds);
setTotalWorks(prev => Math.max(0, prev - removedIds.size));
setSelectedGalleryIds(prev => new Set([...prev].filter(id => !removedIds.has(id))));
if (selectedWork && removedIds.has(selectedWork.id)) {
setSelectedWork(null);
@@ -684,9 +838,6 @@ export default function GalleryPage() {
<div className="mb-8">
<div className="flex items-center gap-3">
<h1 className="font-serif text-3xl font-bold"></h1>
{syncStatus === 'syncing' && (
<span className="text-xs text-muted-foreground animate-pulse">...</span>
)}
</div>
<p className="mt-2 text-muted-foreground"></p>
</div>
@@ -777,14 +928,16 @@ export default function GalleryPage() {
</Button>
</div>
) : (
<div
className="gallery-masonry-grid grid gap-4"
style={{ gridTemplateColumns: `repeat(${masonryColumnCount}, minmax(0, 1fr))` }}
>
{masonryColumns.map((columnWorks, columnIndex) => (
<div key={columnIndex} className="flex min-w-0 flex-col gap-4">
{columnWorks.map((work) => {
const mediaPreviewUrl = work.thumbnailUrl || (work.url && !work.url.startsWith('data:') ? work.url : '');
<>
<div
className="gallery-masonry-grid grid gap-4"
style={{ gridTemplateColumns: `repeat(${masonryColumnCount}, minmax(0, 1fr))` }}
>
{masonryColumns.map((columnWorks, columnIndex) => (
<div key={columnIndex} className="flex min-w-0 flex-col gap-4">
{columnWorks.map((work, columnItemIndex) => {
const mediaPreviewUrl = work.thumbnailUrl || '';
const shouldLoadEagerly = columnItemIndex < 2;
return (
<div
key={work.id}
@@ -802,9 +955,10 @@ export default function GalleryPage() {
src={mediaPreviewUrl}
alt={(work.prompt || '').slice(0, 30)}
className="block h-auto w-full object-contain"
loading="lazy"
loading={shouldLoadEagerly ? 'eager' : 'lazy'}
decoding="async"
onLoad={(e) => handleCardImageLoad(work.id, e)}
onClick={(e) => { e.stopPropagation(); setFullscreenSrc(work.url); }}
onClick={(e) => { e.stopPropagation(); openFullscreenPreview(work.url, work.thumbnailUrl); }}
onContextMenu={(e) => openImageMenu(e, work.url)}
/>
) : (
@@ -884,6 +1038,7 @@ export default function GalleryPage() {
alt={work.publisherNickname}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
) : (
getAvatarText(work.publisherNickname)
@@ -906,10 +1061,20 @@ export default function GalleryPage() {
</div>
</div>
);
})}
</div>
))}
</div>
})}
</div>
))}
</div>
<div ref={loadMoreRef} className="flex h-20 items-center justify-center text-sm text-muted-foreground">
{loadingMore ? (
<span>...</span>
) : hasMore ? (
<span className="sr-only"></span>
) : totalWorks > filteredWorks.length ? (
<span></span>
) : null}
</div>
</>
)}
</div>
@@ -942,20 +1107,26 @@ export default function GalleryPage() {
/>
) : (
<img
src={selectedWork.url}
src={selectedWork.thumbnailUrl || selectedWork.url}
alt={(selectedWork.prompt || '').slice(0, 30)}
className="relative z-10 h-full w-full cursor-zoom-in object-contain"
onClick={() => setFullscreenSrc(selectedWork.url)}
onClick={() => openFullscreenPreview(selectedWork.url, selectedWork.thumbnailUrl)}
onContextMenu={(event) => openImageMenu(event, selectedWork.url)}
/>
)}
{selectedWork.type !== 'video' && selectedWork.type !== 'text2video' && selectedWork.type !== 'img2video' && (
<ImageMetadataBadge src={selectedWork.url} className="absolute right-4 top-4 z-20" />
<ImageMetadataBadge
src={selectedWork.url}
width={selectedWork.width}
height={selectedWork.height}
loadMetadata={false}
className="absolute right-4 top-4 z-20"
/>
)}
{/* Fullscreen button overlay */}
{selectedWork.type !== 'video' && selectedWork.type !== 'text2video' && selectedWork.type !== 'img2video' && (
<button
onClick={() => setFullscreenSrc(selectedWork.url)}
onClick={() => openFullscreenPreview(selectedWork.url, selectedWork.thumbnailUrl)}
className="absolute bottom-4 right-4 z-20 flex h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white shadow-lg backdrop-blur-md transition-colors hover:bg-black/70"
>
<Maximize2 className="h-5 w-5 text-white" />
@@ -1081,13 +1252,13 @@ export default function GalleryPage() {
src={url}
alt={`参考图 ${index + 1}`}
className="aspect-square w-full cursor-zoom-in object-cover"
onClick={() => setFullscreenSrc(url)}
onClick={() => openFullscreenPreview(url)}
onContextMenu={(event) => openImageMenu(event, url)}
/>
<div className="absolute inset-x-0 bottom-0 flex justify-end gap-1 bg-black/35 p-1 opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100">
<button
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/90 text-black"
onClick={() => setFullscreenSrc(url)}
onClick={() => openFullscreenPreview(url)}
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
@@ -1199,9 +1370,13 @@ export default function GalleryPage() {
{/* Fullscreen image preview overlay */}
<FullscreenPreview
src={fullscreenSrc || ''}
fallbackSrc={fullscreenFallbackSrc}
alt="全屏预览"
open={!!fullscreenSrc}
onClose={() => setFullscreenSrc(null)}
onClose={() => {
setFullscreenSrc(null);
setFullscreenFallbackSrc(null);
}}
/>
{ImageActionsContextMenu}
</div>

View File

@@ -572,6 +572,7 @@
}
.create-mobile-page {
--create-mobile-ui-font-size: 16px;
min-height: calc(100dvh - env(safe-area-inset-bottom));
}
@@ -796,6 +797,7 @@
}
.create-mobile-dialog-composer {
--create-mobile-ui-font-size: 16px;
position: fixed;
right: 0.75rem;
bottom: calc(0.75rem + env(safe-area-inset-bottom));
@@ -855,21 +857,21 @@
.create-mobile-param-label {
color: hsl(var(--muted-foreground));
font-size: 0.7rem;
line-height: 1.1;
font-size: var(--create-mobile-ui-font-size);
line-height: 1.2;
white-space: nowrap;
}
.create-mobile-param-trigger {
width: 3.1rem !important;
width: 4.05rem !important;
max-width: 100% !important;
height: 1.58rem !important;
min-height: 1.58rem !important;
padding: 0 0.3rem !important;
padding: 0 0.38rem !important;
gap: 0.1rem !important;
border-radius: 999px !important;
font-size: 0.64rem !important;
line-height: 1 !important;
font-size: var(--create-mobile-ui-font-size) !important;
line-height: 1.1 !important;
}
.create-mobile-count-combobox {
@@ -884,8 +886,8 @@
min-height: 1.58rem !important;
padding: 0 0.72rem 0 0.18rem !important;
border-radius: 999px !important;
font-size: 0.64rem !important;
line-height: 1 !important;
font-size: var(--create-mobile-ui-font-size) !important;
line-height: 1.1 !important;
}
.create-mobile-count-combobox button[aria-label="选择生成数量"] {
@@ -909,8 +911,8 @@
}
.create-mobile-param-trigger [data-slot="select-value"] {
font-size: 0.64rem !important;
line-height: 1 !important;
font-size: var(--create-mobile-ui-font-size) !important;
line-height: 1.1 !important;
}
.create-mobile-param-select-content {
@@ -933,8 +935,8 @@
gap: 0.25rem !important;
padding: 0.3rem 1.45rem 0.3rem 0.55rem !important;
border-radius: 0.55rem !important;
font-size: 0.75rem !important;
line-height: 1.1 !important;
font-size: var(--create-mobile-ui-font-size) !important;
line-height: 1.2 !important;
}
.create-mobile-param-select-item [data-slot="select-item-indicator"] {
@@ -960,8 +962,8 @@
width: max-content !important;
min-width: 2.25rem !important;
padding: 0.25rem 0.45rem !important;
font-size: 0.72rem !important;
line-height: 1 !important;
font-size: var(--create-mobile-ui-font-size) !important;
line-height: 1.1 !important;
}
.create-mobile-style-strip {
@@ -974,8 +976,25 @@
min-height: 0;
}
.create-mobile-style-strip .style-preset-selector span,
.create-mobile-style-strip .style-preset-selector button,
.create-mobile-style-strip .style-preset-selector input,
.create-mobile-style-strip .style-preset-selector [data-slot="badge"] {
font-size: var(--create-mobile-ui-font-size) !important;
line-height: 1.2 !important;
}
.create-mobile-style-strip .style-preset-list.is-collapsed {
max-height: 1.85rem;
flex-wrap: nowrap;
max-height: 2.05rem !important;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 0.1rem;
scrollbar-width: none;
}
.create-mobile-style-strip .style-preset-list.is-collapsed::-webkit-scrollbar {
display: none;
}
.create-mobile-style-strip .style-preset-list.is-expanded {
@@ -1007,7 +1026,7 @@
max-height: 7.5rem;
resize: none;
border-radius: 0.9rem;
font-size: 16px;
font-size: var(--create-mobile-ui-font-size);
line-height: 1.35;
}
@@ -1047,7 +1066,7 @@
.create-mobile-conversation-prompt {
display: block;
color: var(--foreground);
font-size: 0.92rem;
font-size: var(--create-mobile-ui-font-size);
font-weight: 600;
line-height: 1.45;
}

View File

@@ -30,6 +30,27 @@ const MAX_CACHE_ITEMS = 180;
let dbPromise: Promise<IDBDatabase> | null = null;
function getImageExtension(src: string): string {
try {
const pathname = new URL(src, typeof window !== 'undefined' ? window.location.href : undefined).pathname;
const extension = pathname.split('.').pop()?.toLowerCase() || '';
return /^(jpe?g|png|webp|gif)$/.test(extension) ? extension : 'jpg';
} catch {
return 'jpg';
}
}
function getPreviewSource(src: string): string {
if (!/^https?:\/\//i.test(src) || typeof window === 'undefined') return src;
try {
const url = new URL(src);
if (url.origin === window.location.origin) return src;
} catch {
return src;
}
return `/api/download?url=${encodeURIComponent(src)}&filename=${encodeURIComponent(`preview.${getImageExtension(src)}`)}&disposition=inline`;
}
function gcd(a: number, b: number): number {
let x = Math.abs(a);
let y = Math.abs(b);
@@ -197,7 +218,7 @@ export function CachedPreviewImage({
return;
}
const created = await createPreview(src);
const created = await createPreview(getPreviewSource(src));
if (cancelled) return;
usePreview(created);
void setCachedPreview(src, created).catch(() => undefined);
@@ -216,7 +237,7 @@ export function CachedPreviewImage({
};
}, [src]);
const displaySrc = useMemo(() => previewUrl || (previewFailed ? src : ''), [previewFailed, previewUrl, src]);
const displaySrc = useMemo(() => previewUrl || (previewFailed ? getPreviewSource(src) : ''), [previewFailed, previewUrl, src]);
const metadataLabel = useMemo(() => {
if (!size) return '';
return `${getAspectLabel(size.width, size.height)} · ${size.width}×${size.height}`;

View File

@@ -6,7 +6,6 @@ import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
const IMAGE_COUNT_OPTIONS = [
{ value: 'auto', label: '自动' },
{ value: '1', label: '1 张' },
{ value: '2', label: '2 张' },
{ value: '3', label: '3 张' },
@@ -15,7 +14,7 @@ const IMAGE_COUNT_OPTIONS = [
function normalizeCountValue(value: string): string {
const numeric = value.replace(/[^\d]/g, '');
if (!numeric) return 'auto';
if (!numeric) return '1';
return String(Math.min(10, Math.max(1, Math.floor(Number(numeric)))));
}
@@ -52,9 +51,9 @@ export function ImageCountCombobox({ value, onChange, className }: ImageCountCom
className="h-10 pr-9 text-center"
inputMode="numeric"
maxLength={2}
placeholder="自动"
placeholder="1"
role="combobox"
value={value === 'auto' ? '' : value}
value={value === 'auto' ? '1' : value}
onBlur={event => onChange(normalizeCountValue(event.currentTarget.value))}
onChange={event => {
setOpen(true);

View File

@@ -16,11 +16,10 @@ import {
IMG2IMG_STYLE_PRESETS,
isCustomModel,
isSystemModel,
getCustomKeyId,
getSystemApiId,
buildCustomModelId,
buildSystemModelId,
calcImageCredits,
getCustomKeyId,
getSystemApiId,
buildCustomModelId,
buildSystemModelId,
inferImageParamsFromPrompt,
resolveImageSize,
resolveCustomApiImageSize,
@@ -31,12 +30,10 @@ import {
import { getImageCapabilityOptions, keepSelectedOptionVisible } from '@/lib/model-capabilities';
import { getCustomApiModelLabel, getSystemApiModelLabel } from '@/lib/model-display';
import { GroupedModelSelectItems } from '@/components/create/grouped-model-select-items';
import { Sparkles, Loader2, Download, Upload, Wand2, Image as ImageIcon, History, ChevronDown, ChevronUp, Plus, X, KeyRound, Share2 } from 'lucide-react';
import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
import { addCreditRecord } from '@/lib/credit-records-store';
import { downloadFile } from '@/lib/utils';
import { Sparkles, Loader2, Download, Upload, Wand2, Image as ImageIcon, History, ChevronDown, ChevronUp, Plus, X, KeyRound, Share2 } from 'lucide-react';
import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
import { downloadFile } from '@/lib/utils';
import { GenerationJobStillRunningError, runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
import { useSiteConfig } from '@/lib/site-config';
import { toast } from 'sonner';
import Link from 'next/link';
import { BareImagePreview, ImageLightbox } from '@/components/lightbox';
@@ -64,6 +61,36 @@ function resolveImageOptionValue(selected: string, options: readonly { value: st
return options.find(option => option.value === fallback)?.value || options[0]?.value || selected;
}
function removeAutoOption<T extends { value: string }>(options: readonly T[]): T[] {
return options.filter(option => option.value !== 'auto');
}
function getAspectRatioFromResolutionOption(
resolution: string,
options: readonly { value: string; label: string }[],
): string | undefined {
const selected = options.find(option => option.value === resolution);
const ratioFromLabel = selected?.label.match(/\((\d{1,2}:\d{1,2})\)/)?.[1];
if (ratioFromLabel) return ratioFromLabel;
const dimensionMatch = resolution.trim().match(/^(\d{2,5})x(\d{2,5})$/i);
if (!dimensionMatch) return undefined;
const width = Number(dimensionMatch[1]);
const height = Number(dimensionMatch[2]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return undefined;
const knownRatios = ['1:1', '16:9', '9:16', '4:3', '3:4', '3:2', '2:3', '4:5', '5:4', '21:9'];
const actual = width / height;
const closest = knownRatios
.map(value => {
const [ratioWidth, ratioHeight] = value.split(':').map(Number);
return { value, delta: Math.abs(actual - ratioWidth / ratioHeight) };
})
.sort((a, b) => a.delta - b.delta)[0];
return closest && closest.delta < 0.02 ? closest.value : undefined;
}
function parseStreamUnsupportedSyncMessage(error: unknown): string | null {
const message = error instanceof Error ? error.message : String(error || '');
if (!message.includes(STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX)) return null;
@@ -80,19 +107,17 @@ interface RefImage {
}
export function ImageToImagePanel() {
const { user, accessToken } = useAuth();
const { config: siteConfig } = useSiteConfig();
const membershipEnabled = siteConfig.membershipEnabled !== false;
const { imageKeys, textKeys } = useCustomApiKeys();
const { user, accessToken, updateProfile } = useAuth();
const { imageKeys, textKeys } = useCustomApiKeys();
const managedSystemApis = useManagedSystemApis();
// Form state
const [prompt, setPrompt] = useState('');
const [negativePrompt, setNegativePrompt] = useState('');
const [aspectRatio, setAspectRatio] = useState('auto');
const [resolution, setResolution] = useState('auto');
const [aspectRatio, setAspectRatio] = useState('original');
const [resolution, setResolution] = useState('1080P');
const [strength, setStrength] = useState(0.5);
const [count, setCount] = useState('auto');
const [count, setCount] = useState('1');
const [outputFormat, setOutputFormat] = useState<ImageOutputFormat>('png');
const [imageQuality, setImageQuality] = useState<ImageQuality>('auto');
const [selectedStyleLabel, setSelectedStyleLabel] = useState('');
@@ -101,6 +126,8 @@ export function ImageToImagePanel() {
// Generation state
const [activeTasks, setActiveTasks] = useState<ActiveGenerationTask[]>([]);
const [results, setResults] = useState<string[]>([]);
const [resultThumbnails, setResultThumbnails] = useState<Record<string, string>>({});
const [resultCredits, setResultCredits] = useState<Record<string, number>>({});
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
const [optimizing, setOptimizing] = useState(false);
const [inspirationOpen, setInspirationOpen] = useState(false);
@@ -264,24 +291,35 @@ export function ImageToImagePanel() {
outputFormats: IMAGE_OUTPUT_FORMAT_OPTIONS,
}, { keepOriginalAspectRatio: true }), [selectedModelCapabilities]);
const manualImageParamOptions = useMemo(() => ({
aspectRatios: removeAutoOption(imageParamOptions.aspectRatios),
resolutions: removeAutoOption(imageParamOptions.resolutions),
}), [imageParamOptions.aspectRatios, imageParamOptions.resolutions]);
const visibleImageParamOptions = useMemo(() => ({
aspectRatios: keepSelectedOptionVisible(imageParamOptions.aspectRatios, aspectRatio),
resolutions: keepSelectedOptionVisible(imageParamOptions.resolutions, resolution),
aspectRatios: keepSelectedOptionVisible(manualImageParamOptions.aspectRatios, aspectRatio).filter(option => option.value !== 'auto'),
resolutions: keepSelectedOptionVisible(manualImageParamOptions.resolutions, resolution).filter(option => option.value !== 'auto'),
outputFormats: keepSelectedOptionVisible(imageParamOptions.outputFormats || IMAGE_OUTPUT_FORMAT_OPTIONS, outputFormat),
qualities: keepSelectedOptionVisible(imageParamOptions.qualities, imageQuality),
}), [aspectRatio, imageParamOptions, imageQuality, outputFormat, resolution]);
const imageParamColumnCount = (imageParamOptions.supportsAspectRatio ? 1 : 0)
+ (imageParamOptions.supportsResolution ? 1 : 0)
}), [aspectRatio, imageParamOptions, imageQuality, manualImageParamOptions, outputFormat, resolution]);
const imageParamColumnCount = (imageParamOptions.supportsAspectRatio && visibleImageParamOptions.aspectRatios.length > 0 ? 1 : 0)
+ (imageParamOptions.supportsResolution && visibleImageParamOptions.resolutions.length > 0 ? 1 : 0)
+ (imageParamOptions.supportsOutputFormat ? 1 : 0)
+ (imageParamOptions.supportsQuality ? 1 : 0);
useEffect(() => {
if (count === 'auto') {
setCount('1');
}
if (imageParamOptions.supportsAspectRatio) {
setAspectRatio(prev => resolveImageOptionValue(prev, manualImageParamOptions.aspectRatios, 'original'));
}
if (imageParamOptions.supportsResolution) {
setResolution(prev => resolveImageOptionValue(prev, imageParamOptions.resolutions));
setResolution(prev => resolveImageOptionValue(prev, manualImageParamOptions.resolutions, '1080P'));
}
if (imageParamOptions.supportsQuality) {
setImageQuality(prev => resolveImageOptionValue(prev, imageParamOptions.qualities) as ImageQuality);
}
}, [imageParamOptions]);
}, [count, imageParamOptions, manualImageParamOptions]);
// Prompt optimization
const handleOptimizePrompt = useCallback(async () => {
@@ -414,12 +452,14 @@ export function ImageToImagePanel() {
() => stylePresets.find(preset => preset.label === selectedStyleLabel),
[stylePresets, selectedStyleLabel],
);
const creditCount = count === 'auto' ? (inferredImageParams.count ?? 1) : (Number(count) || 1);
const credits = calcImageCredits(selectedModel, resolution, aspectRatio, creditCount);
const resolveGenerationParams = useCallback((): { aspectRatio: string; resolution: string; count: number } | null => {
const resolvedAspectRatio = aspectRatio === 'auto' ? inferredImageParams.aspectRatio : aspectRatio;
const resolvedResolution = resolution === 'auto' ? inferredImageParams.resolution : resolution;
const resolutionAspectRatio = resolvedResolution
? getAspectRatioFromResolutionOption(resolvedResolution, visibleImageParamOptions.resolutions)
: undefined;
const resolvedAspectRatio = imageParamOptions.supportsAspectRatio
? (aspectRatio === 'auto' ? inferredImageParams.aspectRatio : aspectRatio)
: (resolutionAspectRatio || (aspectRatio === 'auto' ? inferredImageParams.aspectRatio : aspectRatio) || 'original');
const parsedCount = count === 'auto' ? inferredImageParams.count : Number(count);
const resolvedCount = Number.isFinite(parsedCount) ? Math.min(10, Math.max(1, Math.floor(Number(parsedCount)))) : undefined;
const missing: string[] = [];
@@ -432,7 +472,7 @@ export function ImageToImagePanel() {
}
if (!resolvedAspectRatio || !resolvedResolution || !resolvedCount) return null;
return { aspectRatio: resolvedAspectRatio, resolution: resolvedResolution, count: resolvedCount };
}, [aspectRatio, resolution, count, inferredImageParams]);
}, [aspectRatio, count, imageParamOptions.supportsAspectRatio, inferredImageParams, resolution, visibleImageParamOptions.resolutions]);
const updateActiveTask = useCallback((taskId: string, update: Partial<ActiveGenerationTask>) => {
setActiveTasks(prev => prev.map(task => task.id === taskId ? { ...task, ...update } : task));
@@ -533,12 +573,12 @@ export function ImageToImagePanel() {
requestBody = { ...requestBody, model: api.modelName, customApiConfig: { systemApiId: api.id, modelName: api.modelName } };
}
}
const runJob = (payload: Record<string, unknown>) => runGenerationJob<{ images?: string[]; error?: string }>(
const runJob = (payload: Record<string, unknown>) => runGenerationJob<{ images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; error?: string; creditsCost?: number; creditsBalance?: number }>(
'image',
payload,
{ timeoutMs: 900_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status }) },
);
let data: { images?: string[]; error?: string };
let data: { images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; error?: string; creditsCost?: number; creditsBalance?: number };
try {
data = await runJob({ ...requestBody, stream: true });
} catch (error) {
@@ -561,11 +601,28 @@ export function ImageToImagePanel() {
}
await runGenerationFinalCountdown((seconds) => updateActiveTask(taskId, { finalCountdownSeconds: seconds }), 3);
if (data.images && data.images.length > 0) {
const thumbnails = Object.fromEntries(data.images.map((url, imageIndex) => [
url,
data.thumbnails?.[url] || data.thumbnailUrls?.[imageIndex] || url,
]));
const creditsCost = Math.max(0, Number(data.creditsCost || 0));
const creditsPerImage = creditsCost > 0 ? Math.ceil(creditsCost / Math.max(1, data.images.length)) : 0;
setResults(prev => [...data.images!, ...prev]);
setGenerationError(null);
for (const url of data.images) {
addRecord({
type: 'image', url, prompt: prompt.trim(),
setResultThumbnails(prev => ({ ...prev, ...thumbnails }));
if (creditsPerImage > 0) {
setResultCredits(prev => Object.fromEntries([
...Object.entries(prev),
...data.images!.map(url => [url, creditsPerImage] as const),
]));
}
if (typeof data.creditsBalance === 'number') {
updateProfile({ creditsBalance: data.creditsBalance });
}
setGenerationError(null);
for (const url of data.images) {
addRecord({
type: 'image', url, prompt: prompt.trim(),
thumbnailUrl: thumbnails[url],
negativePrompt: negativePrompt.trim() || undefined,
model: selectedModel,
modelLabel: getCurrentModelLabel(),
@@ -583,21 +640,13 @@ export function ImageToImagePanel() {
strength,
refImageCount: refImages.length,
},
creditsCost: creditsPerImage,
});
}
toast.success(`生成 ${data.images.length} 张图片`);
if (membershipEnabled && credits > 0 && user) {
const currentCredits = typeof user.creditsBalance === 'number' ? user.creditsBalance : 0;
addCreditRecord({
type: 'consume',
amount: -credits,
balanceAfter: Math.max(0, currentCredits - credits),
description: `图生图 - ${getCurrentModelLabel()}`,
});
}
} else {
setGenerationError(createGenerationError(data.error || '图片生成失败'));
}
toast.success(`生成 ${data.images.length} 张图片`);
} else {
setGenerationError(createGenerationError(data.error || '图片生成失败'));
}
} catch (err: unknown) {
if (err instanceof GenerationJobStillRunningError) {
setGenerationError(null);
@@ -613,7 +662,7 @@ export function ImageToImagePanel() {
syncConfirmationResolversRef.current.delete(taskId);
removeActiveTask(taskId);
}
}, [prompt, negativePrompt, selectedModel, outputFormat, imageQuality, selectedStylePreset, strength, refImages, user, imageKeys, systemImageApis, getCurrentModelLabel, addRecord, credits, membershipEnabled, resolveGenerationParams, removeActiveTask, updateActiveTask, requestSyncConfirmation]);
}, [prompt, negativePrompt, selectedModel, outputFormat, imageQuality, selectedStylePreset, strength, refImages, user, imageKeys, systemImageApis, getCurrentModelLabel, addRecord, updateProfile, resolveGenerationParams, removeActiveTask, updateActiveTask, requestSyncConfirmation]);
const handleDownload = useCallback(async (url: string, index: number) => {
const result = await downloadFile(url, `miaojing-img2img-${Date.now()}-${index}.png`);
@@ -631,13 +680,15 @@ export function ImageToImagePanel() {
prompt: prompt.trim(),
model: selectedModel,
modelLabel: getCurrentModelLabel(),
creditsCost: resultCredits[url] || 0,
thumbnailUrl: resultThumbnails[url],
params: {
creationMode: 'img2img',
styleLabel: selectedStylePreset?.label,
},
});
toast.success('已分享到画廊');
}, [prompt, selectedModel, selectedStylePreset, getCurrentModelLabel]);
}, [prompt, selectedModel, selectedStylePreset, getCurrentModelLabel, resultCredits, resultThumbnails]);
return (
<>
@@ -768,7 +819,7 @@ export function ImageToImagePanel() {
{/* Image Params */}
<div className={`grid grid-cols-2 gap-x-3 gap-y-3 ${imageParamColumnCount >= 4 ? 'lg:grid-cols-[minmax(7.75rem,1.2fr)_minmax(5.75rem,0.9fr)_minmax(5.75rem,0.9fr)_minmax(5.75rem,0.9fr)]' : imageParamColumnCount === 3 ? 'lg:grid-cols-3' : 'lg:grid-cols-2'}`}>
{imageParamOptions.supportsAspectRatio && <div className="min-w-0 space-y-2">
{imageParamOptions.supportsAspectRatio && visibleImageParamOptions.aspectRatios.length > 0 && <div className="min-w-0 space-y-2">
<Label></Label>
<Select value={aspectRatio} onValueChange={setAspectRatio}>
<SelectTrigger className="w-full min-w-0 gap-1.5 px-3 [&_svg]:size-4"><SelectValue /></SelectTrigger>
@@ -779,7 +830,7 @@ export function ImageToImagePanel() {
</SelectContent>
</Select>
</div>}
{imageParamOptions.supportsResolution && <div className="min-w-0 space-y-2">
{imageParamOptions.supportsResolution && visibleImageParamOptions.resolutions.length > 0 && <div className="min-w-0 space-y-2">
<Label></Label>
<Select value={resolution} onValueChange={setResolution}>
<SelectTrigger className="w-full min-w-0 gap-1.5 px-3 [&_svg]:size-4"><SelectValue /></SelectTrigger>
@@ -832,24 +883,31 @@ export function ImageToImagePanel() {
{/* Generate */}
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={!hasModels}>
{generating ? (<><Plus className="h-4 w-4" /></>) : (<><Sparkles className="h-4 w-4" /> {membershipEnabled && credits > 0 && `(${credits} 积分)`}</>)}
{generating ? (<><Plus className="h-4 w-4" /></>) : (<><Sparkles className="h-4 w-4" /></>)}
</Button>
</div>
{/* Right: Results + History */}
<div className="create-chat-thread min-w-0 space-y-4">
{generating ? (
{generating && (
<GenerationTaskList tasks={activeTasks} onConfirmSync={handleConfirmSync} onCancelSync={handleCancelSync} />
) : generationError ? (
)}
{!generating && generationError && (
<GenerationErrorPanel error={generationError} />
) : results.length > 0 ? (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium"><ImageIcon className="h-4 w-4" /></div>
<div className="grid grid-cols-2 gap-3">
)}
{results.length > 0 ? (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium"><ImageIcon className="h-4 w-4" /></div>
<div className="grid grid-cols-2 gap-3">
{results.map((url, i) => (
<div key={i} className="liquid-glass-soft group relative overflow-hidden rounded-2xl">
<div key={url || i} className="liquid-glass-soft group relative overflow-hidden rounded-2xl">
{resultCredits[url] > 0 && (
<div className="absolute left-2 top-2 z-10 rounded-full border border-black/10 bg-black/70 px-2.5 py-1 text-xs font-medium text-white shadow-lg backdrop-blur-sm">
-{resultCredits[url]}
</div>
)}
<CachedPreviewImage
src={url}
src={resultThumbnails[url] || url}
alt={`生成结果 ${i + 1}`}
className="w-full aspect-square object-cover cursor-zoom-in"
onClick={() => setLightboxSrc(url)}
@@ -859,18 +917,18 @@ export function ImageToImagePanel() {
<Button size="sm" variant="secondary" className="gap-1 border-white/15 bg-black/70 text-white shadow-lg backdrop-blur-sm hover:border-white/25 hover:bg-black/85 hover:text-white [&_svg]:text-white" onClick={() => handleShareToGallery(url)}><Share2 className="h-3.5 w-3.5" /></Button>
<Button size="sm" variant="secondary" className="gap-1 border-white/15 bg-black/70 text-white shadow-lg backdrop-blur-sm hover:border-white/25 hover:bg-black/85 hover:text-white [&_svg]:text-white" onClick={() => handleDownload(url, i)}><Download className="h-3.5 w-3.5" /></Button>
</div>
</div>
))}
</div>
</div>
) : (
<div className="liquid-glass flex min-h-[300px] flex-col items-center justify-center rounded-2xl border-dashed py-24 text-muted-foreground">
<ImageIcon className="h-14 w-14 mb-3 opacity-20" />
<p className="text-sm"></p>
</div>
)}
{imageHistory.length > 0 && (
</div>
))}
</div>
</div>
) : !generating && !generationError ? (
<div className="liquid-glass flex min-h-[300px] flex-col items-center justify-center rounded-2xl border-dashed py-24 text-muted-foreground">
<ImageIcon className="h-14 w-14 mb-3 opacity-20" />
<p className="text-sm"></p>
</div>
) : null}
{imageHistory.length > 0 && (
<div className="space-y-2">
<button className="flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors" onClick={() => setShowHistory(!showHistory)}>
<History className="h-4 w-4" /> ({imageHistory.length})
@@ -888,7 +946,7 @@ export function ImageToImagePanel() {
<div className="w-full aspect-square flex items-center justify-center"><ImageIcon className="h-6 w-6 text-muted-foreground/30" /></div>
) : (
<CachedPreviewImage
src={record.url}
src={record.thumbnailUrl || record.url}
alt={record.prompt?.slice(0, 20) || '历史记录'}
className="w-full aspect-square object-cover"
badgeClassName="absolute right-1.5 top-1.5 z-10 scale-75 origin-top-right"
@@ -906,7 +964,12 @@ export function ImageToImagePanel() {
</div>
{/* Lightbox */}
<ImageLightbox src={lightboxSrc || ''} open={!!lightboxSrc} onClose={() => setLightboxSrc(null)} />
<ImageLightbox
src={lightboxSrc || ''}
fallbackSrc={lightboxSrc ? resultThumbnails[lightboxSrc] : null}
open={!!lightboxSrc}
onClose={() => setLightboxSrc(null)}
/>
<BareImagePreview src={referencePreviewSrc || ''} open={!!referencePreviewSrc} onClose={() => setReferencePreviewSrc(null)} />
{/* History Detail Dialog */}

View File

@@ -248,7 +248,7 @@ export function InspirationGalleryDialog({
<video src={selectedWork.url} controls className="h-full w-full object-contain" />
) : (
<img
src={selectedWork.url}
src={selectedWork.thumbnailUrl || selectedWork.url}
alt={selectedWork.prompt || '作品详情'}
className="h-full w-full object-contain"
onContextMenu={(event) => openImageMenu(event, selectedWork.url)}

View File

@@ -6,6 +6,7 @@ import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useCustomApiKeys } from '@/lib/custom-api-store';
import { useManagedSystemApis } from '@/lib/managed-model-store';
import { readStoredAuth } from '@/lib/auth-store';
import { useCreationHistory, getCreationMode, isPlaceholder, type CreationRecord } from '@/lib/creation-history-store';
import { toast } from 'sonner';
import { ChevronDown, ChevronUp, Copy, FileSearch, Grid3X3, History, Image as ImageIcon, Loader2, Sparkles, Wand2, X } from 'lucide-react';
@@ -183,9 +184,13 @@ export default function ReversePromptPanel({ onUseForTextToImage, onUseForImageT
setGenerationStartedAt(Date.now());
setLoading(true);
try {
const accessToken = readStoredAuth().accessToken;
const response = await fetch('/api/generate/reverse-prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
body: JSON.stringify({ image: reverseImage, outputMode: selectedOutputMode, language, customApiConfig: selectedConfig }),
});
const data = await response.json().catch(() => ({}));

View File

@@ -20,7 +20,6 @@ import {
getSystemApiId,
buildCustomModelId,
buildSystemModelId,
calcImageCredits,
inferImageParamsFromPrompt,
resolveImageSize,
resolveCustomApiImageSize,
@@ -32,10 +31,8 @@ import { getCustomApiModelLabel, getSystemApiModelLabel } from '@/lib/model-disp
import { GroupedModelSelectItems } from '@/components/create/grouped-model-select-items';
import { Sparkles, Loader2, Download, Wand2, Image as ImageIcon, History, ChevronDown, ChevronUp, Plus, KeyRound, Share2 } from 'lucide-react';
import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
import { addCreditRecord } from '@/lib/credit-records-store';
import { downloadFile } from '@/lib/utils';
import { GenerationJobStillRunningError, runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
import { useSiteConfig } from '@/lib/site-config';
import { toast } from 'sonner';
import Link from 'next/link';
import { ImageLightbox } from '@/components/lightbox';
@@ -50,6 +47,7 @@ import { CachedPreviewImage } from '@/components/create/cached-preview-image';
import { InspirationGalleryDialog } from '@/components/create/inspiration-gallery-dialog';
import { TEXT_TO_IMAGE_DRAFT_EVENT, TEXT_TO_IMAGE_DRAFT_KEY, type ImageCreationReuseDraft } from '@/lib/creation-reuse';
import { MobileCreationComposer } from '@/components/create/mobile-creation-composer';
import { useIsMobile } from '@/hooks/use-mobile';
const STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX = 'MIAOJING_STREAM_UNSUPPORTED_SYNC_CONFIRM:';
const TEXT_TO_IMAGE_SELECTED_MODEL_KEY = 'miaojing_create_text_to_image_selected_model';
@@ -63,6 +61,36 @@ function resolveImageOptionValue(selected: string, options: readonly { value: st
return options.find(option => option.value === fallback)?.value || options[0]?.value || selected;
}
function removeAutoOption<T extends { value: string }>(options: readonly T[]): T[] {
return options.filter(option => option.value !== 'auto');
}
function getAspectRatioFromResolutionOption(
resolution: string,
options: readonly { value: string; label: string }[],
): string | undefined {
const selected = options.find(option => option.value === resolution);
const ratioFromLabel = selected?.label.match(/\((\d{1,2}:\d{1,2})\)/)?.[1];
if (ratioFromLabel) return ratioFromLabel;
const dimensionMatch = resolution.trim().match(/^(\d{2,5})x(\d{2,5})$/i);
if (!dimensionMatch) return undefined;
const width = Number(dimensionMatch[1]);
const height = Number(dimensionMatch[2]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return undefined;
const knownRatios = ['1:1', '16:9', '9:16', '4:3', '3:4', '3:2', '2:3', '4:5', '5:4', '21:9'];
const actual = width / height;
const closest = knownRatios
.map(value => {
const [ratioWidth, ratioHeight] = value.split(':').map(Number);
return { value, delta: Math.abs(actual - ratioWidth / ratioHeight) };
})
.sort((a, b) => a.delta - b.delta)[0];
return closest && closest.delta < 0.02 ? closest.value : undefined;
}
function parseStreamUnsupportedSyncMessage(error: unknown): string | null {
const message = error instanceof Error ? error.message : String(error || '');
if (!message.includes(STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX)) return null;
@@ -71,18 +99,17 @@ function parseStreamUnsupportedSyncMessage(error: unknown): string | null {
}
export function TextToImagePanel() {
const { user, accessToken } = useAuth();
const { config: siteConfig } = useSiteConfig();
const membershipEnabled = siteConfig.membershipEnabled !== false;
const { user, accessToken, updateProfile } = useAuth();
const { imageKeys, textKeys } = useCustomApiKeys();
const managedSystemApis = useManagedSystemApis();
const isMobileViewport = useIsMobile();
// Form state
const [prompt, setPrompt] = useState('');
const [negativePrompt, setNegativePrompt] = useState('');
const [aspectRatio, setAspectRatio] = useState('auto');
const [resolution, setResolution] = useState('auto');
const [count, setCount] = useState('auto');
const [aspectRatio, setAspectRatio] = useState('1:1');
const [resolution, setResolution] = useState('1080P');
const [count, setCount] = useState('1');
const [outputFormat, setOutputFormat] = useState<ImageOutputFormat>('png');
const [imageQuality, setImageQuality] = useState<ImageQuality>('auto');
const [selectedStyleLabel, setSelectedStyleLabel] = useState('');
@@ -91,6 +118,8 @@ export function TextToImagePanel() {
// Generation state
const [activeTasks, setActiveTasks] = useState<ActiveGenerationTask[]>([]);
const [results, setResults] = useState<string[]>([]);
const [resultThumbnails, setResultThumbnails] = useState<Record<string, string>>({});
const [resultCredits, setResultCredits] = useState<Record<string, number>>({});
const [resultPrompt, setResultPrompt] = useState('');
const [activeGenerationPrompt, setActiveGenerationPrompt] = useState('');
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
@@ -247,24 +276,35 @@ export function TextToImagePanel() {
outputFormats: IMAGE_OUTPUT_FORMAT_OPTIONS,
}), [selectedModelCapabilities]);
const manualImageParamOptions = useMemo(() => ({
aspectRatios: removeAutoOption(imageParamOptions.aspectRatios),
resolutions: removeAutoOption(imageParamOptions.resolutions),
}), [imageParamOptions.aspectRatios, imageParamOptions.resolutions]);
const visibleImageParamOptions = useMemo(() => ({
aspectRatios: keepSelectedOptionVisible(imageParamOptions.aspectRatios, aspectRatio),
resolutions: keepSelectedOptionVisible(imageParamOptions.resolutions, resolution),
aspectRatios: keepSelectedOptionVisible(manualImageParamOptions.aspectRatios, aspectRatio).filter(option => option.value !== 'auto'),
resolutions: keepSelectedOptionVisible(manualImageParamOptions.resolutions, resolution).filter(option => option.value !== 'auto'),
outputFormats: keepSelectedOptionVisible(imageParamOptions.outputFormats || IMAGE_OUTPUT_FORMAT_OPTIONS, outputFormat),
qualities: keepSelectedOptionVisible(imageParamOptions.qualities, imageQuality),
}), [aspectRatio, imageParamOptions, imageQuality, outputFormat, resolution]);
const imageParamColumnCount = (imageParamOptions.supportsAspectRatio ? 1 : 0)
+ (imageParamOptions.supportsResolution ? 1 : 0)
}), [aspectRatio, imageParamOptions, imageQuality, manualImageParamOptions, outputFormat, resolution]);
const imageParamColumnCount = (imageParamOptions.supportsAspectRatio && visibleImageParamOptions.aspectRatios.length > 0 ? 1 : 0)
+ (imageParamOptions.supportsResolution && visibleImageParamOptions.resolutions.length > 0 ? 1 : 0)
+ (imageParamOptions.supportsOutputFormat ? 1 : 0)
+ (imageParamOptions.supportsQuality ? 1 : 0);
useEffect(() => {
if (count === 'auto') {
setCount('1');
}
if (imageParamOptions.supportsAspectRatio) {
setAspectRatio(prev => resolveImageOptionValue(prev, manualImageParamOptions.aspectRatios, '1:1'));
}
if (imageParamOptions.supportsResolution) {
setResolution(prev => resolveImageOptionValue(prev, imageParamOptions.resolutions));
setResolution(prev => resolveImageOptionValue(prev, manualImageParamOptions.resolutions, '1080P'));
}
if (imageParamOptions.supportsQuality) {
setImageQuality(prev => resolveImageOptionValue(prev, imageParamOptions.qualities) as ImageQuality);
}
}, [imageParamOptions]);
}, [count, imageParamOptions, manualImageParamOptions]);
// Prompt optimization
const handleOptimizePrompt = useCallback(async () => {
@@ -315,14 +355,14 @@ export function TextToImagePanel() {
() => stylePresets.find(preset => preset.label === selectedStyleLabel),
[stylePresets, selectedStyleLabel],
);
const creditCount = count === 'auto' ? (inferredImageParams.count ?? 1) : (Number(count) || 1);
const creditResolution = resolution === 'auto' ? inferredImageParams.resolution : resolution;
const creditAspectRatio = aspectRatio === 'auto' ? inferredImageParams.aspectRatio : aspectRatio;
const credits = calcImageCredits(selectedModel, creditResolution, creditAspectRatio, creditCount);
const resolveGenerationParams = useCallback((): { aspectRatio: string; resolution: string; count: number } | null => {
const resolvedAspectRatio = aspectRatio === 'auto' ? inferredImageParams.aspectRatio : aspectRatio;
const resolvedResolution = resolution === 'auto' ? inferredImageParams.resolution : resolution;
const resolutionAspectRatio = resolvedResolution
? getAspectRatioFromResolutionOption(resolvedResolution, visibleImageParamOptions.resolutions)
: undefined;
const resolvedAspectRatio = imageParamOptions.supportsAspectRatio
? (aspectRatio === 'auto' ? inferredImageParams.aspectRatio : aspectRatio)
: (resolutionAspectRatio || (aspectRatio === 'auto' ? inferredImageParams.aspectRatio : aspectRatio) || '1:1');
const parsedCount = count === 'auto' ? inferredImageParams.count : Number(count);
const resolvedCount = Number.isFinite(parsedCount) ? Math.min(10, Math.max(1, Math.floor(Number(parsedCount)))) : undefined;
const missing: string[] = [];
@@ -335,7 +375,7 @@ export function TextToImagePanel() {
}
if (!resolvedAspectRatio || !resolvedResolution || !resolvedCount) return null;
return { aspectRatio: resolvedAspectRatio, resolution: resolvedResolution, count: resolvedCount };
}, [aspectRatio, resolution, count, inferredImageParams]);
}, [aspectRatio, count, imageParamOptions.supportsAspectRatio, inferredImageParams, resolution, visibleImageParamOptions.resolutions]);
const updateActiveTask = useCallback((taskId: string, update: Partial<ActiveGenerationTask>) => {
setActiveTasks(prev => prev.map(task => task.id === taskId ? { ...task, ...update } : task));
@@ -456,12 +496,12 @@ export function TextToImagePanel() {
const runSingleTask = async (taskId: string, index: number) => {
try {
const runJob = (payload: Record<string, unknown>) => runGenerationJob<{ images?: string[]; error?: string }>(
const runJob = (payload: Record<string, unknown>) => runGenerationJob<{ images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; error?: string; creditsCost?: number; creditsBalance?: number }>(
'image',
payload,
{ timeoutMs: 900_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status }) },
);
let data: { images?: string[]; error?: string };
let data: { images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; error?: string; creditsCost?: number; creditsBalance?: number };
try {
data = await runJob({ ...requestBodyBase, count: 1, clientRequestId: `${batchId}-${index + 1}`, stream: true });
} catch (error) {
@@ -487,7 +527,49 @@ export function TextToImagePanel() {
if (!data.images || data.images.length === 0) {
throw new Error(data.error || '图片生成失败');
}
return data.images;
const taskImages = data.images;
const thumbnails = Object.fromEntries(taskImages.map((url, imageIndex) => [
url,
data.thumbnails?.[url] || data.thumbnailUrls?.[imageIndex] || url,
]));
const creditsCost = Math.max(0, Number(data.creditsCost || 0));
const creditsPerImage = creditsCost > 0 ? Math.ceil(creditsCost / Math.max(1, taskImages.length)) : 0;
setResults(prev => [...taskImages, ...prev]);
setResultThumbnails(prev => ({ ...prev, ...thumbnails }));
if (creditsPerImage > 0) {
setResultCredits(prev => Object.fromEntries([
...Object.entries(prev),
...taskImages.map(url => [url, creditsPerImage] as const),
]));
}
if (typeof data.creditsBalance === 'number') {
updateProfile({ creditsBalance: data.creditsBalance });
}
setResultPrompt(submittedPrompt);
setGenerationError(null);
for (const url of taskImages) {
addRecord({
type: 'image', url, prompt: submittedPrompt,
thumbnailUrl: thumbnails[url],
negativePrompt: negativePrompt.trim() || undefined,
model: selectedModel,
modelLabel: getCurrentModelLabel(),
isCustomModel: isCustomModel(selectedModel) || isSystemModel(selectedModel),
params: {
creationMode: 'text2img',
aspectRatio: resolvedParams.aspectRatio,
resolution: resolvedParams.resolution,
count: 1,
batchCount: taskCount,
outputFormat,
imageQuality,
styleLabel: selectedStylePreset?.label,
guidanceScale,
},
creditsCost: creditsPerImage,
});
}
return taskImages;
} finally {
syncConfirmationResolversRef.current.delete(taskId);
removeActiveTask(taskId);
@@ -499,41 +581,8 @@ export function TextToImagePanel() {
const failedResults = settled.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
if (generatedImages.length > 0) {
setResults(prev => [...generatedImages, ...prev]);
setResultPrompt(submittedPrompt);
setGenerationError(null);
for (const url of generatedImages) {
addRecord({
type: 'image', url, prompt: submittedPrompt,
negativePrompt: negativePrompt.trim() || undefined,
model: selectedModel,
modelLabel: getCurrentModelLabel(),
isCustomModel: isCustomModel(selectedModel) || isSystemModel(selectedModel),
params: {
creationMode: 'text2img',
aspectRatio: resolvedParams.aspectRatio,
resolution: resolvedParams.resolution,
count: 1,
batchCount: taskCount,
outputFormat,
imageQuality,
styleLabel: selectedStylePreset?.label,
guidanceScale,
},
});
}
toast.success(`生成 ${generatedImages.length} 张图片`);
const chargedCredits = calcImageCredits(selectedModel, resolvedParams.resolution, resolvedParams.aspectRatio, generatedImages.length);
if (membershipEnabled && chargedCredits > 0 && user) {
const currentCredits = typeof user.creditsBalance === 'number' ? user.creditsBalance : 0;
addCreditRecord({
type: 'consume',
amount: -chargedCredits,
balanceAfter: Math.max(0, currentCredits - chargedCredits),
description: `文生图 - ${getCurrentModelLabel()}`,
});
}
}
if (failedResults.length > 0) {
@@ -559,7 +608,7 @@ export function TextToImagePanel() {
finally {
if (submissionSignature) activeSubmissionSignaturesRef.current.delete(submissionSignature);
}
}, [prompt, negativePrompt, selectedModel, outputFormat, imageQuality, selectedStylePreset, guidanceScale, user, imageKeys, systemImageApis, getCurrentModelLabel, addRecord, membershipEnabled, resolveGenerationParams, removeActiveTask, updateActiveTask, requestSyncConfirmation]);
}, [prompt, negativePrompt, selectedModel, outputFormat, imageQuality, selectedStylePreset, guidanceScale, user, imageKeys, systemImageApis, getCurrentModelLabel, addRecord, updateProfile, resolveGenerationParams, removeActiveTask, updateActiveTask, requestSyncConfirmation]);
// Download
const handleDownload = useCallback(async (url: string, index: number) => {
@@ -578,13 +627,15 @@ export function TextToImagePanel() {
prompt: prompt.trim(),
model: selectedModel,
modelLabel: getCurrentModelLabel(),
creditsCost: resultCredits[url] || 0,
thumbnailUrl: resultThumbnails[url],
params: {
creationMode: 'text2img',
styleLabel: selectedStylePreset?.label,
},
});
toast.success('已分享到画廊');
}, [prompt, selectedModel, selectedStylePreset, getCurrentModelLabel]);
}, [prompt, selectedModel, selectedStylePreset, getCurrentModelLabel, resultCredits, resultThumbnails]);
return (
<>
@@ -660,7 +711,7 @@ export function TextToImagePanel() {
{/* Image Params */}
<div className={`grid grid-cols-2 gap-x-3 gap-y-3 ${imageParamColumnCount >= 4 ? 'lg:grid-cols-[minmax(7.75rem,1.2fr)_minmax(5.75rem,0.9fr)_minmax(5.75rem,0.9fr)_minmax(5.75rem,0.9fr)]' : imageParamColumnCount === 3 ? 'lg:grid-cols-3' : 'lg:grid-cols-2'}`}>
{imageParamOptions.supportsAspectRatio && <div className="min-w-0 space-y-2">
{imageParamOptions.supportsAspectRatio && visibleImageParamOptions.aspectRatios.length > 0 && <div className="min-w-0 space-y-2">
<Label></Label>
<Select value={aspectRatio} onValueChange={setAspectRatio}>
<SelectTrigger className="w-full min-w-0 gap-1.5 px-3 [&_svg]:size-4"><SelectValue /></SelectTrigger>
@@ -671,7 +722,7 @@ export function TextToImagePanel() {
</SelectContent>
</Select>
</div>}
{imageParamOptions.supportsResolution && <div className="min-w-0 space-y-2">
{imageParamOptions.supportsResolution && visibleImageParamOptions.resolutions.length > 0 && <div className="min-w-0 space-y-2">
<Label></Label>
<Select value={resolution} onValueChange={setResolution}>
<SelectTrigger className="w-full min-w-0 gap-1.5 px-3 [&_svg]:size-4"><SelectValue /></SelectTrigger>
@@ -724,7 +775,7 @@ export function TextToImagePanel() {
{/* Generate Button */}
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={!hasModels}>
{generating ? (<><Plus className="h-4 w-4" /></>) : (<><Sparkles className="h-4 w-4" /> {membershipEnabled && credits > 0 && `(${credits} 积分)`}</>)}
{generating ? (<><Plus className="h-4 w-4" /></>) : (<><Sparkles className="h-4 w-4" /></>)}
</Button>
</div>
@@ -732,38 +783,53 @@ export function TextToImagePanel() {
<div className="create-chat-thread min-w-0 space-y-4">
{/* Results area */}
<div className="create-desktop-results">
{generating ? (
<GenerationTaskList tasks={activeTasks} onConfirmSync={handleConfirmSync} onCancelSync={handleCancelSync} />
) : generationError ? (
<GenerationErrorPanel error={generationError} />
) : results.length > 0 ? (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium"><ImageIcon className="h-4 w-4" /></div>
<p className="text-sm font-medium">{resultPrompt || '图片生成'}</p>
<div className="grid grid-cols-2 gap-3">
{results.map((url, i) => (
<div key={i} className="liquid-glass-soft group relative overflow-hidden rounded-2xl">
<CachedPreviewImage
src={url}
alt={`生成结果 ${i + 1}`}
className="w-full aspect-square object-cover cursor-zoom-in"
onClick={() => setLightboxSrc(url)}
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
<Button size="sm" variant="secondary" className="gap-1 border-white/15 bg-black/70 text-white shadow-lg backdrop-blur-sm hover:border-white/25 hover:bg-black/85 hover:text-white [&_svg]:text-white" onClick={() => setLightboxSrc(url)}><ImageIcon className="h-3.5 w-3.5" /></Button>
<Button size="sm" variant="secondary" className="gap-1 border-white/15 bg-black/70 text-white shadow-lg backdrop-blur-sm hover:border-white/25 hover:bg-black/85 hover:text-white [&_svg]:text-white" onClick={() => handleShareToGallery(url)}><Share2 className="h-3.5 w-3.5" /></Button>
<Button size="sm" variant="secondary" className="gap-1 border-white/15 bg-black/70 text-white shadow-lg backdrop-blur-sm hover:border-white/25 hover:bg-black/85 hover:text-white [&_svg]:text-white" onClick={() => handleDownload(url, i)}><Download className="h-3.5 w-3.5" /></Button>
<div className="space-y-4">
{generating && (
<GenerationTaskList tasks={activeTasks} onConfirmSync={handleConfirmSync} onCancelSync={handleCancelSync} />
)}
{!generating && generationError && (
<GenerationErrorPanel error={generationError} />
)}
{results.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium"><ImageIcon className="h-4 w-4" /></div>
<p
className="line-clamp-2 break-words text-sm leading-6 text-muted-foreground"
title={resultPrompt || '图片生成'}
>
{resultPrompt || '图片生成'}
</p>
<div className="grid grid-cols-2 gap-3">
{results.map((url, i) => (
<div key={url || i} className="liquid-glass-soft group relative overflow-hidden rounded-2xl">
{resultCredits[url] > 0 && (
<div className="absolute left-2 top-2 z-10 rounded-full border border-black/10 bg-black/70 px-2.5 py-1 text-xs font-medium text-white shadow-lg backdrop-blur-sm">
-{resultCredits[url]}
</div>
)}
<CachedPreviewImage
src={resultThumbnails[url] || url}
alt={`生成结果 ${i + 1}`}
className="w-full aspect-square object-cover cursor-zoom-in"
onClick={() => setLightboxSrc(url)}
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
<Button size="sm" variant="secondary" className="gap-1 border-white/15 bg-black/70 text-white shadow-lg backdrop-blur-sm hover:border-white/25 hover:bg-black/85 hover:text-white [&_svg]:text-white" onClick={() => setLightboxSrc(url)}><ImageIcon className="h-3.5 w-3.5" /></Button>
<Button size="sm" variant="secondary" className="gap-1 border-white/15 bg-black/70 text-white shadow-lg backdrop-blur-sm hover:border-white/25 hover:bg-black/85 hover:text-white [&_svg]:text-white" onClick={() => handleShareToGallery(url)}><Share2 className="h-3.5 w-3.5" /></Button>
<Button size="sm" variant="secondary" className="gap-1 border-white/15 bg-black/70 text-white shadow-lg backdrop-blur-sm hover:border-white/25 hover:bg-black/85 hover:text-white [&_svg]:text-white" onClick={() => handleDownload(url, i)}><Download className="h-3.5 w-3.5" /></Button>
</div>
</div>
</div>
))}
))}
</div>
</div>
</div>
) : (
)}
{!generating && !generationError && results.length === 0 && (
<div className="create-empty-result liquid-glass flex min-h-[300px] flex-col items-center justify-center rounded-2xl border-dashed py-24 text-muted-foreground">
<ImageIcon className="h-14 w-14 mb-3 opacity-20" />
<p className="text-sm"></p>
</div>
)}
)}
</div>
</div>
{/* History */}
@@ -786,7 +852,7 @@ export function TextToImagePanel() {
<div className="w-full aspect-square flex items-center justify-center"><ImageIcon className="h-6 w-6 text-muted-foreground/30" /></div>
) : (
<CachedPreviewImage
src={record.url}
src={record.thumbnailUrl || record.url}
alt={record.prompt?.slice(0, 20) || '历史记录'}
className="w-full aspect-square object-cover"
badgeClassName="absolute right-1.5 top-1.5 z-10 scale-75 origin-top-right"
@@ -802,42 +868,44 @@ export function TextToImagePanel() {
</div>
</>
)}
<div className="create-mobile-history-flow">
{mobileImageHistory.slice(-40).map(record => (
<div key={record.id} className="create-mobile-conversation-card space-y-3">
<p className="create-mobile-conversation-prompt">{record.prompt || '历史创作'}</p>
{isPlaceholder(record.url) ? (
<button
type="button"
className="create-mobile-history-placeholder"
onClick={() => setSelectedHistoryRecord(record)}
>
<ImageIcon className="h-6 w-6" />
</button>
) : (
<CachedPreviewImage
src={record.url}
alt={record.prompt?.slice(0, 20) || '历史记录'}
className="create-mobile-history-image cursor-zoom-in"
badgeClassName="absolute right-1.5 top-1.5 z-10 scale-75 origin-top-right"
onClick={() => setLightboxSrc(record.url)}
/>
)}
</div>
))}
{generating && (
<div className="create-mobile-conversation-card create-mobile-active-task space-y-3">
<p className="create-mobile-conversation-prompt">{activeGenerationPrompt || prompt || '正在生成图片'}</p>
<GenerationTaskList tasks={activeTasks} onConfirmSync={handleConfirmSync} onCancelSync={handleCancelSync} />
</div>
)}
{!generating && generationError && (
<div className="create-mobile-conversation-card">
<GenerationErrorPanel error={generationError} />
</div>
)}
<div ref={mobileHistoryEndRef} className="create-mobile-history-end" aria-hidden="true" />
</div>
{isMobileViewport && (
<div className="create-mobile-history-flow">
{mobileImageHistory.slice(-40).map(record => (
<div key={record.id} className="create-mobile-conversation-card space-y-3">
<p className="create-mobile-conversation-prompt">{record.prompt || '历史创作'}</p>
{isPlaceholder(record.url) ? (
<button
type="button"
className="create-mobile-history-placeholder"
onClick={() => setSelectedHistoryRecord(record)}
>
<ImageIcon className="h-6 w-6" />
</button>
) : (
<CachedPreviewImage
src={record.thumbnailUrl || record.url}
alt={record.prompt?.slice(0, 20) || '历史记录'}
className="create-mobile-history-image cursor-zoom-in"
badgeClassName="absolute right-1.5 top-1.5 z-10 scale-75 origin-top-right"
onClick={() => setLightboxSrc(record.url)}
/>
)}
</div>
))}
{generating && (
<div className="create-mobile-conversation-card create-mobile-active-task space-y-3">
<p className="create-mobile-conversation-prompt">{activeGenerationPrompt || prompt || '正在生成图片'}</p>
<GenerationTaskList tasks={activeTasks} onConfirmSync={handleConfirmSync} onCancelSync={handleCancelSync} />
</div>
)}
{!generating && generationError && (
<div className="create-mobile-conversation-card">
<GenerationErrorPanel error={generationError} />
</div>
)}
<div ref={mobileHistoryEndRef} className="create-mobile-history-end" aria-hidden="true" />
</div>
)}
</div>
<MobileCreationComposer
@@ -856,22 +924,20 @@ export function TextToImagePanel() {
)}
params={(
<>
{imageParamOptions.supportsAspectRatio && (
{imageParamOptions.supportsAspectRatio && visibleImageParamOptions.aspectRatios.length > 0 && (
<div className="create-mobile-param-field">
<span className="create-mobile-param-label"></span>
<Select value={aspectRatio} onValueChange={setAspectRatio}>
<SelectTrigger className="create-mobile-param-trigger"><SelectValue /></SelectTrigger>
<SelectContent className="create-mobile-param-select-content">
{visibleImageParamOptions.aspectRatios.map(ar => (
<SelectItem className="create-mobile-param-select-item" key={ar.value} value={ar.value}>{ar.label}</SelectItem>
<SelectItem className="create-mobile-param-select-item" key={ar.value} value={ar.value}>{ar.value}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{imageParamOptions.supportsResolution && (
{imageParamOptions.supportsResolution && visibleImageParamOptions.resolutions.length > 0 && (
<div className="create-mobile-param-field">
<span className="create-mobile-param-label"></span>
<Select value={resolution} onValueChange={setResolution}>
<SelectTrigger className="create-mobile-param-trigger"><SelectValue /></SelectTrigger>
<SelectContent className="create-mobile-param-select-content">
@@ -883,7 +949,6 @@ export function TextToImagePanel() {
</div>
)}
<div className="create-mobile-param-field">
<span className="create-mobile-param-label"></span>
<ImageCountCombobox
value={count}
onChange={setCount}
@@ -895,7 +960,12 @@ export function TextToImagePanel() {
/>
{/* Lightbox */}
<ImageLightbox src={lightboxSrc || ''} open={!!lightboxSrc} onClose={() => setLightboxSrc(null)} />
<ImageLightbox
src={lightboxSrc || ''}
fallbackSrc={lightboxSrc ? resultThumbnails[lightboxSrc] : null}
open={!!lightboxSrc}
onClose={() => setLightboxSrc(null)}
/>
{/* History Detail Dialog */}
<CreationDetailDialog

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { type CreationRecord, deleteCreationRecord, isPlaceholder, shareToGallery, isUrlPublished } from '@/lib/creation-history-store';
import { buildImageCreationReuseDraft, writeImageCreationReuseDraft } from '@/lib/creation-reuse';
@@ -136,12 +136,18 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
const { user } = useAuth();
const [isPublished, setIsPublished] = useState(false);
const [fullscreenSrc, setFullscreenSrc] = useState<string | null>(null);
const [fullscreenFallbackSrc, setFullscreenFallbackSrc] = useState<string | null>(null);
const [mediaAspectRatio, setMediaAspectRatio] = useState<number | null>(null);
const [viewportSize, setViewportSize] = useState({ width: 1280, height: 900 });
const [deleting, setDeleting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const { openImageMenu, ImageActionsContextMenu } = useImageActionsContextMenu();
const openFullscreenPreview = useCallback((src: string, fallbackSrc?: string | null) => {
setFullscreenFallbackSrc(fallbackSrc || null);
setFullscreenSrc(src);
}, []);
useEffect(() => {
if (record) {
setIsPublished(record.published || isUrlPublished(record.url));
@@ -151,11 +157,13 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
useEffect(() => {
if (!open) {
setFullscreenSrc(null);
setFullscreenFallbackSrc(null);
}
}, [open]);
useEffect(() => {
setFullscreenSrc(null);
setFullscreenFallbackSrc(null);
setMediaAspectRatio(null);
}, [record?.url]);
@@ -339,7 +347,7 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
src={referenceImage}
alt="参考图片"
className="h-full w-full cursor-zoom-in object-contain"
onClick={() => setFullscreenSrc(referenceImage)}
onClick={() => openFullscreenPreview(referenceImage)}
onContextMenu={(event) => openImageMenu(event, referenceImage)}
/>
) : (
@@ -350,7 +358,7 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
)}
{referenceImage && (
<button
onClick={() => setFullscreenSrc(referenceImage)}
onClick={() => openFullscreenPreview(referenceImage)}
className="absolute bottom-3 right-3 flex h-9 w-9 items-center justify-center rounded-full bg-black/40 transition-colors hover:bg-black/60"
>
<Maximize2 className="h-4 w-4 text-white" />
@@ -415,9 +423,13 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
<FullscreenPreview
src={fullscreenSrc || ''}
fallbackSrc={fullscreenFallbackSrc}
alt="全屏预览"
open={!!fullscreenSrc}
onClose={() => setFullscreenSrc(null)}
onClose={() => {
setFullscreenSrc(null);
setFullscreenFallbackSrc(null);
}}
/>
{ImageActionsContextMenu}
{deleteConfirmDialog}
@@ -548,7 +560,7 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
) : record.type === 'image' ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={record.url}
src={record.thumbnailUrl || record.url}
alt={record.prompt}
className="h-full w-full cursor-zoom-in object-contain"
onLoad={(event) => {
@@ -557,7 +569,7 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
setMediaAspectRatio(img.naturalWidth / img.naturalHeight);
}
}}
onClick={() => setFullscreenSrc(record.url)}
onClick={() => openFullscreenPreview(record.url, record.thumbnailUrl)}
onContextMenu={(event) => openImageMenu(event, record.url)}
/>
) : (
@@ -581,7 +593,7 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
)}
{!isPlaceholderUrl && record.type === 'image' && (
<button
onClick={() => setFullscreenSrc(record.url)}
onClick={() => openFullscreenPreview(record.url, record.thumbnailUrl)}
className="absolute bottom-3 right-3 h-9 w-9 rounded-full bg-black/40 hover:bg-black/60 flex items-center justify-center transition-colors opacity-0 group-hover:opacity-100"
>
<Maximize2 className="h-4 w-4 text-white" />
@@ -604,12 +616,12 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
src={url}
alt={`参考图 ${index + 1}`}
className="aspect-square w-full cursor-zoom-in object-cover"
onClick={() => setFullscreenSrc(url)}
onClick={() => openFullscreenPreview(url)}
onContextMenu={(event) => openImageMenu(event, url)}
/>
<div className="absolute inset-x-0 bottom-0 flex justify-end gap-1 bg-black/35 p-1 opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100">
<button
onClick={() => setFullscreenSrc(url)}
onClick={() => openFullscreenPreview(url)}
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/90 text-black"
>
<Maximize2 className="h-3.5 w-3.5" />
@@ -768,9 +780,13 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
{/* Fullscreen image preview overlay */}
<FullscreenPreview
src={fullscreenSrc || ''}
fallbackSrc={fullscreenFallbackSrc}
alt="全屏预览"
open={!!fullscreenSrc}
onClose={() => setFullscreenSrc(null)}
onClose={() => {
setFullscreenSrc(null);
setFullscreenFallbackSrc(null);
}}
/>
{ImageActionsContextMenu}
{deleteConfirmDialog}

View File

@@ -8,6 +8,7 @@ import { useImageActionsContextMenu } from '@/components/image-actions-context-m
interface FullscreenPreviewProps {
src: string;
fallbackSrc?: string | null;
alt?: string;
images?: string[];
initialIndex?: number;
@@ -22,10 +23,12 @@ const inverseControlClass =
'border border-white/35 bg-black/48 text-white shadow-[0_8px_30px_rgba(0,0,0,0.45),inset_0_1px_0_rgba(255,255,255,0.20)] backdrop-blur-md';
const inverseIconClass = 'text-white drop-shadow-[0_1px_3px_rgba(0,0,0,0.95)]';
export function FullscreenPreview({ src, alt, images, initialIndex = 0, open, onClose }: FullscreenPreviewProps) {
export function FullscreenPreview({ src, fallbackSrc, alt, images, initialIndex = 0, open, onClose }: FullscreenPreviewProps) {
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageFailed, setImageFailed] = useState(false);
const [dragging, setDragging] = useState(false);
const overlayRef = useRef<HTMLDivElement | null>(null);
const { openImageMenu, ImageActionsContextMenu } = useImageActionsContextMenu();
@@ -114,6 +117,8 @@ export function FullscreenPreview({ src, alt, images, initialIndex = 0, open, on
useEffect(() => {
resetView();
setImageLoaded(false);
setImageFailed(false);
}, [currentSrc, resetView]);
const goToPrev = useCallback(() => {
@@ -212,7 +217,7 @@ export function FullscreenPreview({ src, alt, images, initialIndex = 0, open, on
</button>
</div>
<ImageMetadataBadge src={currentSrc} className="absolute right-4 top-16 z-10" />
{imageLoaded && <ImageMetadataBadge src={currentSrc} className="absolute right-4 top-16 z-10" />}
{images && images.length > 1 && (
<div className={`absolute top-4 left-4 z-10 rounded-full px-3 py-1.5 text-sm font-medium ${inverseControlClass}`}>
@@ -251,6 +256,28 @@ export function FullscreenPreview({ src, alt, images, initialIndex = 0, open, on
{Math.round(scale * 100)}% · / · ·
</div>
{!imageLoaded && !imageFailed && (
<div className={`absolute bottom-14 left-1/2 z-10 -translate-x-1/2 rounded-full px-3 py-1.5 text-xs font-medium ${inverseControlClass}`}>
</div>
)}
{imageFailed && (
<div className={`absolute bottom-14 left-1/2 z-10 -translate-x-1/2 rounded-full px-3 py-1.5 text-xs font-medium ${inverseControlClass}`}>
</div>
)}
{fallbackSrc && !imageLoaded && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={fallbackSrc}
alt={alt || 'Preview'}
draggable={false}
className="max-h-[90vh] max-w-[90vw] select-none object-contain"
/>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={currentSrc}
@@ -258,13 +285,21 @@ export function FullscreenPreview({ src, alt, images, initialIndex = 0, open, on
draggable={false}
className={`max-w-[90vw] max-h-[90vh] select-none object-contain will-change-transform ${
dragging ? 'cursor-grabbing' : canPan ? 'cursor-grab' : 'cursor-zoom-in'
}`}
} ${!imageLoaded && fallbackSrc ? 'absolute opacity-0' : ''}`}
style={{
transform: `translate3d(${offset.x}px, ${offset.y}px, 0) scale(${scale})`,
transition: dragging ? 'none' : 'transform 120ms ease-out',
transformOrigin: 'center center',
touchAction: 'none',
}}
onLoad={() => {
setImageLoaded(true);
setImageFailed(false);
}}
onError={() => {
setImageLoaded(false);
setImageFailed(true);
}}
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => openImageMenu(event, currentSrc)}
onWheel={(event) => {

View File

@@ -4,6 +4,9 @@ import { useEffect, useMemo, useState } from 'react';
type ImageMetadataBadgeProps = {
src: string;
width?: number | null;
height?: number | null;
loadMetadata?: boolean;
className?: string;
};
@@ -39,11 +42,27 @@ function getAspectLabel(width: number, height: number) {
return `${ratioWidth}:${ratioHeight}`;
}
export function ImageMetadataBadge({ src, className = '' }: ImageMetadataBadgeProps) {
function isValidSize(width: number | null | undefined, height: number | null | undefined): boolean {
return typeof width === 'number' && Number.isFinite(width) && width > 0
&& typeof height === 'number' && Number.isFinite(height) && height > 0;
}
export function ImageMetadataBadge({
src,
width,
height,
loadMetadata = true,
className = '',
}: ImageMetadataBadgeProps) {
const [size, setSize] = useState<ImageSize | null>(null);
useEffect(() => {
if (!src) {
if (isValidSize(width, height)) {
setSize({ width: width as number, height: height as number });
return;
}
if (!src || !loadMetadata) {
setSize(null);
return;
}
@@ -64,7 +83,7 @@ export function ImageMetadataBadge({ src, className = '' }: ImageMetadataBadgePr
return () => {
cancelled = true;
};
}, [src]);
}, [height, loadMetadata, src, width]);
const label = useMemo(() => {
if (!size) return '';

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef, type PointerEvent } from 'react';
import { X, Download, ZoomIn, ZoomOut } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { downloadFile } from '@/lib/utils';
@@ -10,6 +10,7 @@ import { useImageActionsContextMenu } from '@/components/image-actions-context-m
interface LightboxProps {
/** Image URL to display */
src: string;
fallbackSrc?: string | null;
/** Alt text */
alt?: string;
/** Whether the lightbox is open */
@@ -18,10 +19,12 @@ interface LightboxProps {
onClose: () => void;
}
export function ImageLightbox({ src, alt, open, onClose }: LightboxProps) {
export function ImageLightbox({ src, fallbackSrc, alt, open, onClose }: LightboxProps) {
const [zoom, setZoom] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageFailed, setImageFailed] = useState(false);
const dragRef = useRef({
pointerId: -1,
startX: 0,
@@ -30,6 +33,7 @@ export function ImageLightbox({ src, alt, open, onClose }: LightboxProps) {
originY: 0,
});
const { openImageMenu, ImageActionsContextMenu } = useImageActionsContextMenu();
const canPan = zoom > 1;
const setClampedZoom = useCallback((updater: number | ((current: number) => number)) => {
setZoom(current => {
@@ -46,6 +50,37 @@ export function ImageLightbox({ src, alt, open, onClose }: LightboxProps) {
setDragging(false);
}, []);
const beginPan = useCallback((event: PointerEvent<HTMLElement>) => {
event.stopPropagation();
if (!canPan || event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: offset.x,
originY: offset.y,
};
setDragging(true);
}, [canPan, offset.x, offset.y]);
const movePan = useCallback((event: PointerEvent<HTMLElement>) => {
if (!dragging || dragRef.current.pointerId !== event.pointerId) return;
event.stopPropagation();
const nextX = dragRef.current.originX + event.clientX - dragRef.current.startX;
const nextY = dragRef.current.originY + event.clientY - dragRef.current.startY;
setOffset({ x: nextX, y: nextY });
}, [dragging]);
const endPan = useCallback((event: PointerEvent<HTMLElement>) => {
if (dragRef.current.pointerId !== event.pointerId) return;
event.stopPropagation();
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
setDragging(false);
}, []);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === '+' || e.key === '=') setClampedZoom(z => z + 0.5);
@@ -65,6 +100,8 @@ export function ImageLightbox({ src, alt, open, onClose }: LightboxProps) {
useEffect(() => {
resetView();
setImageLoaded(false);
setImageFailed(false);
}, [src, resetView]);
if (!open) return null;
@@ -101,7 +138,7 @@ export function ImageLightbox({ src, alt, open, onClose }: LightboxProps) {
</Button>
</div>
<ImageMetadataBadge src={src} className="absolute right-4 top-16 z-10" />
{imageLoaded && <ImageMetadataBadge src={src} className="absolute right-4 top-16 z-10" />}
{/* Info bar */}
<div className="absolute bottom-4 left-4 z-10 rounded-full border border-white/20 bg-black/45 px-3 py-1.5 text-xs font-medium text-white/78 shadow-lg backdrop-blur-md light:border-amber-900/18 light:bg-white/52 light:text-foreground/70 light:shadow-[0_10px_32px_rgba(83,61,27,0.12)]" onClick={e => e.stopPropagation()}>
@@ -111,23 +148,57 @@ export function ImageLightbox({ src, alt, open, onClose }: LightboxProps) {
{/* Image */}
<div
className="flex items-center justify-center w-full h-full p-8"
className={`flex h-full w-full items-center justify-center p-8 ${
dragging ? 'cursor-grabbing' : canPan ? 'cursor-grab' : ''
}`}
onClick={e => e.stopPropagation()}
onPointerDown={beginPan}
onPointerMove={movePan}
onPointerUp={endPan}
onPointerCancel={endPan}
>
{!imageLoaded && !imageFailed && (
<div className="absolute bottom-14 left-1/2 z-10 -translate-x-1/2 rounded-full border border-white/20 bg-black/45 px-3 py-1.5 text-xs font-medium text-white/78 shadow-lg backdrop-blur-md light:border-amber-900/18 light:bg-white/52 light:text-foreground/70">
</div>
)}
{imageFailed && (
<div className="absolute bottom-14 left-1/2 z-10 -translate-x-1/2 rounded-full border border-white/20 bg-black/45 px-3 py-1.5 text-xs font-medium text-white/78 shadow-lg backdrop-blur-md light:border-amber-900/18 light:bg-white/52 light:text-foreground/70">
</div>
)}
{fallbackSrc && !imageLoaded && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={fallbackSrc}
alt={alt || '预览图片'}
draggable={false}
className="max-h-full max-w-full select-none object-contain"
/>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={alt || '预览图片'}
draggable={false}
className={`max-w-full max-h-full select-none object-contain will-change-transform ${
dragging ? 'cursor-grabbing' : zoom > 1 ? 'cursor-grab' : 'cursor-zoom-in'
}`}
dragging ? 'cursor-grabbing' : canPan ? 'cursor-grab' : 'cursor-zoom-in'
} ${!imageLoaded && fallbackSrc ? 'absolute opacity-0' : ''}`}
style={{
transform: `translate3d(${offset.x}px, ${offset.y}px, 0) scale(${zoom})`,
transition: dragging ? 'none' : 'transform 160ms ease-out',
transformOrigin: 'center center',
touchAction: 'none',
}}
onLoad={() => {
setImageLoaded(true);
setImageFailed(false);
}}
onError={() => {
setImageLoaded(false);
setImageFailed(true);
}}
onWheel={e => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
@@ -143,33 +214,11 @@ export function ImageLightbox({ src, alt, open, onClose }: LightboxProps) {
}}
onContextMenu={(event) => openImageMenu(event, src)}
onPointerDown={e => {
e.stopPropagation();
if (zoom <= 1 || e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
dragRef.current = {
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
originX: offset.x,
originY: offset.y,
};
setDragging(true);
beginPan(e);
}}
onPointerMove={e => {
if (!dragging || dragRef.current.pointerId !== e.pointerId) return;
e.stopPropagation();
setOffset({
x: dragRef.current.originX + e.clientX - dragRef.current.startX,
y: dragRef.current.originY + e.clientY - dragRef.current.startY,
});
}}
onPointerUp={e => {
if (dragRef.current.pointerId !== e.pointerId) return;
e.stopPropagation();
e.currentTarget.releasePointerCapture(e.pointerId);
setDragging(false);
}}
onPointerCancel={() => setDragging(false)}
onPointerMove={movePan}
onPointerUp={endPan}
onPointerCancel={endPan}
/>
</div>
{ImageActionsContextMenu}

View File

@@ -84,7 +84,7 @@ export default function CreationHistoryTab() {
) : record.type === 'image' ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={record.url}
src={record.thumbnailUrl || record.url}
alt={record.prompt}
className="w-full aspect-square object-cover"
/>

View File

@@ -9,6 +9,7 @@ export interface CreationRecord {
id: string;
type: 'image' | 'video' | 'reverse-prompt';
url: string; // 图片/视频地址(可以是 data URL 或远程 URL
thumbnailUrl?: string;
prompt: string; // 用户输入的提示词
negativePrompt?: string;
model: string; // 模型ID如 doubao-seedream-5-0-260128 或 custom:xxx
@@ -20,6 +21,7 @@ export interface CreationRecord {
referenceImage?: string; // For img2img: the reference image URL
referenceImages?: string[]; // Optional multiple reference image URLs
publisherNickname?: string; // Set when publishing
creditsCost?: number;
}
/* ---------- Published Work (shared gallery) ---------- */
@@ -27,6 +29,7 @@ export interface PublishedWork {
id: string;
type: 'image' | 'video';
url: string;
thumbnailUrl?: string;
prompt: string;
negativePrompt?: string;
model: string;
@@ -39,6 +42,7 @@ export interface PublishedWork {
publisherNickname: string;
publishedAt: string;
likes: number;
creditsCost?: number;
}
const STORAGE_KEY = 'miaojing_creation_history';
@@ -391,6 +395,7 @@ export async function shareToGallery(options: {
referenceImages?: string[];
params?: Record<string, unknown>;
creditsCost?: number;
thumbnailUrl?: string;
}): Promise<void> {
// Save to localStorage for immediate local display
const works = loadPublished();
@@ -401,6 +406,7 @@ export async function shareToGallery(options: {
id: `pub-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
type: options.type,
url: options.url,
thumbnailUrl: options.thumbnailUrl,
prompt: options.prompt || '',
negativePrompt: options.negativePrompt,
model: options.model || '',
@@ -413,6 +419,7 @@ export async function shareToGallery(options: {
publisherNickname: options.publisherNickname || '匿名用户',
publishedAt: new Date().toISOString(),
likes: 0,
creditsCost: options.creditsCost,
});
savePublished(works);
window.dispatchEvent(new CustomEvent('creation-history-updated'));
@@ -432,6 +439,7 @@ export async function shareToGallery(options: {
prompt: options.prompt,
negativePrompt: options.negativePrompt,
resultUrl: options.url,
thumbnailUrl: options.thumbnailUrl,
model: options.model,
modelLabel: options.modelLabel,
referenceImage: options.referenceImage,

View File

@@ -41,6 +41,8 @@ const SYSTEM_EMAIL_SETTINGS_ID = 1;
const DEFAULT_CODE_TTL_MINUTES = 5;
const DEFAULT_CODE_LENGTH = 6;
const EMAIL_REGEX = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
let emailSchemaReady = false;
let emailSchemaWarned = false;
export function normalizeEmail(value: unknown): string {
return typeof value === 'string' ? value.trim().toLowerCase() : '';
@@ -61,72 +63,86 @@ export function getClientIp(request: Request): string {
}
export async function ensureEmailSchema(client: PoolClient): Promise<void> {
await client.query('CREATE EXTENSION IF NOT EXISTS pgcrypto');
await client.query(`
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS email_bound_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS email_sender_domain VARCHAR(255)
`);
await client.query(`
CREATE TABLE IF NOT EXISTS email_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
smtp_host VARCHAR(255),
smtp_port INTEGER NOT NULL DEFAULT 465,
smtp_secure BOOLEAN NOT NULL DEFAULT TRUE,
smtp_user VARCHAR(255),
smtp_password_encrypted TEXT,
smtp_password_preview VARCHAR(64),
from_email VARCHAR(255),
from_name VARCHAR(255),
reply_to VARCHAR(255),
app_name VARCHAR(120),
app_base_url TEXT,
logo_url TEXT,
contact_email VARCHAR(255),
copyright TEXT,
code_length INTEGER NOT NULL DEFAULT 6,
code_charset VARCHAR(32) NOT NULL DEFAULT 'alphanumeric',
code_ttl_minutes INTEGER NOT NULL DEFAULT 5,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await client.query(`
CREATE TABLE IF NOT EXISTS email_verification_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL,
code_hash TEXT NOT NULL,
type VARCHAR(32) NOT NULL,
user_id UUID,
ip_address VARCHAR(64),
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 5,
is_used BOOLEAN NOT NULL DEFAULT FALSE,
locked_until TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await client.query(`
CREATE TABLE IF NOT EXISTS email_send_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL,
type VARCHAR(64) NOT NULL,
ip_address VARCHAR(64),
status VARCHAR(32) NOT NULL,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await client.query('CREATE INDEX IF NOT EXISTS email_codes_email_type_idx ON email_verification_codes (LOWER(email), type, created_at DESC)');
await client.query('CREATE INDEX IF NOT EXISTS email_codes_ip_created_idx ON email_verification_codes (ip_address, created_at DESC)');
await client.query('CREATE INDEX IF NOT EXISTS email_send_logs_email_created_idx ON email_send_logs (LOWER(email), created_at DESC)');
await client.query('CREATE INDEX IF NOT EXISTS email_send_logs_ip_created_idx ON email_send_logs (ip_address, created_at DESC)');
if (emailSchemaReady) return;
try {
await client.query('CREATE EXTENSION IF NOT EXISTS pgcrypto');
await client.query(`
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS email_bound_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS email_sender_domain VARCHAR(255)
`);
await client.query(`
CREATE TABLE IF NOT EXISTS email_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
smtp_host VARCHAR(255),
smtp_port INTEGER NOT NULL DEFAULT 465,
smtp_secure BOOLEAN NOT NULL DEFAULT TRUE,
smtp_user VARCHAR(255),
smtp_password_encrypted TEXT,
smtp_password_preview VARCHAR(64),
from_email VARCHAR(255),
from_name VARCHAR(255),
reply_to VARCHAR(255),
app_name VARCHAR(120),
app_base_url TEXT,
logo_url TEXT,
contact_email VARCHAR(255),
copyright TEXT,
code_length INTEGER NOT NULL DEFAULT 6,
code_charset VARCHAR(32) NOT NULL DEFAULT 'alphanumeric',
code_ttl_minutes INTEGER NOT NULL DEFAULT 5,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await client.query(`
CREATE TABLE IF NOT EXISTS email_verification_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL,
code_hash TEXT NOT NULL,
type VARCHAR(32) NOT NULL,
user_id UUID,
ip_address VARCHAR(64),
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 5,
is_used BOOLEAN NOT NULL DEFAULT FALSE,
locked_until TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await client.query(`
CREATE TABLE IF NOT EXISTS email_send_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL,
type VARCHAR(64) NOT NULL,
ip_address VARCHAR(64),
status VARCHAR(32) NOT NULL,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await client.query('CREATE INDEX IF NOT EXISTS email_codes_email_type_idx ON email_verification_codes (LOWER(email), type, created_at DESC)');
await client.query('CREATE INDEX IF NOT EXISTS email_codes_ip_created_idx ON email_verification_codes (ip_address, created_at DESC)');
await client.query('CREATE INDEX IF NOT EXISTS email_send_logs_email_created_idx ON email_send_logs (LOWER(email), created_at DESC)');
await client.query('CREATE INDEX IF NOT EXISTS email_send_logs_ip_created_idx ON email_send_logs (ip_address, created_at DESC)');
emailSchemaReady = true;
} catch (error) {
if (error && typeof error === 'object' && (error as { code?: string }).code === '42501') {
if (!emailSchemaWarned) {
console.warn('[email-schema] skipped optional schema check because the database user is not the table owner');
emailSchemaWarned = true;
}
emailSchemaReady = true;
return;
}
throw error;
}
}
function envBool(value: string | undefined, fallback: boolean): boolean {

View File

@@ -0,0 +1,140 @@
import type { PoolClient } from 'pg';
import { isUuid } from '@/lib/server-api-config';
type BillingMode = 'fixed' | 'ratio' | 'token' | 'duration';
export interface GenerationCreditCharge {
creditsCost: number;
balanceAfter: number;
description: string;
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function normalizePositiveInteger(value: unknown, fallback = 1): number {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return Math.max(1, Math.floor(parsed));
}
function resolveImageResultCount(payload: Record<string, unknown>, result: Record<string, unknown>): number {
const images = Array.isArray(result.images) ? result.images : [];
if (images.length > 0) return images.length;
return normalizePositiveInteger(payload.count, 1);
}
function resolvePerUseCredits(row: Record<string, unknown>): number {
const billingMode = String(row.billing_mode || 'fixed') as BillingMode;
const creditsPerUse = Number(row.credits_per_use || 0);
const fixedPrice = Number(row.fixed_price || 0);
if (billingMode === 'fixed') return Math.ceil(fixedPrice || creditsPerUse || 0);
return Math.ceil(creditsPerUse || fixedPrice || 0);
}
export async function resolveGenerationCreditCost(
client: PoolClient,
input: {
type: 'image' | 'video';
payload: Record<string, unknown>;
result?: Record<string, unknown>;
},
): Promise<{ creditsCost: number; description: string; systemApiId: string } | null> {
const config = asRecord(input.payload.customApiConfig);
const systemApiId = typeof config.systemApiId === 'string' ? config.systemApiId.trim() : '';
if (!isUuid(systemApiId)) return null;
const apiResult = await client.query(
`SELECT id, provider, name, model_name, type, credits_per_use, billing_mode, fixed_price,
duration_price_per_second
FROM system_api_configs
WHERE id = $1
LIMIT 1`,
[systemApiId],
);
const api = apiResult.rows[0] as Record<string, unknown> | undefined;
if (!api) return null;
const billingMode = String(api.billing_mode || 'fixed') as BillingMode;
let quantity = 1;
if (input.type === 'image') {
quantity = resolveImageResultCount(input.payload, input.result || {});
} else if (billingMode === 'duration') {
quantity = normalizePositiveInteger(input.payload.duration, 1);
}
const perUseCredits = resolvePerUseCredits(api);
const creditsCost = billingMode === 'duration' && input.type === 'video'
? Math.ceil(quantity * Number(api.duration_price_per_second || 0))
: Math.ceil(perUseCredits * quantity);
if (!Number.isFinite(creditsCost) || creditsCost <= 0) return null;
const modelLabel = String(api.name || api.model_name || config.modelName || '系统默认模型');
const provider = String(api.provider || '系统 API');
return {
creditsCost,
systemApiId,
description: `${input.type === 'video' ? '视频生成' : '图片生成'} - ${modelLabel}${provider}`,
};
}
export async function ensureGenerationCreditsAvailable(
client: PoolClient,
userId: string,
input: {
type: 'image' | 'video';
payload: Record<string, unknown>;
},
): Promise<void> {
const cost = await resolveGenerationCreditCost(client, input);
if (!cost) return;
const profileResult = await client.query(
'SELECT credits_balance FROM profiles WHERE id = $1 LIMIT 1',
[userId],
);
const balance = Number(profileResult.rows[0]?.credits_balance || 0);
if (balance < cost.creditsCost) {
throw new Error(`积分不足,本次生成需要 ${cost.creditsCost} 积分,当前余额 ${balance} 积分`);
}
}
export async function chargeGenerationCredits(
client: PoolClient,
input: {
userId: string | null;
type: 'image' | 'video';
payload: Record<string, unknown>;
result: Record<string, unknown>;
},
): Promise<GenerationCreditCharge | null> {
if (!input.userId || !isUuid(input.userId)) return null;
const cost = await resolveGenerationCreditCost(client, input);
if (!cost) return null;
const profileResult = await client.query(
'SELECT credits_balance FROM profiles WHERE id = $1 FOR UPDATE',
[input.userId],
);
const currentBalance = Number(profileResult.rows[0]?.credits_balance || 0);
const balanceAfter = Math.max(0, currentBalance - cost.creditsCost);
await client.query(
'UPDATE profiles SET credits_balance = $1, updated_at = NOW() WHERE id = $2',
[balanceAfter, input.userId],
);
await client.query(
`INSERT INTO credit_transactions (user_id, amount, balance_after, type, description)
VALUES ($1, $2, $3, 'consume', $4)`,
[input.userId, -cost.creditsCost, balanceAfter, cost.description],
);
return {
creditsCost: cost.creditsCost,
balanceAfter,
description: cost.description,
};
}

View File

@@ -27,6 +27,9 @@ const ESTIMATE_LIMITS: Record<GenerationJobType, { min: number; max: number }> =
video: { min: 60, max: 1800 },
};
let generationJobSchemaReady = false;
let generationJobSchemaWarned = false;
function safeString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
@@ -46,15 +49,29 @@ function getPayloadConfig(payload: Record<string, unknown>) {
}
export async function ensureGenerationJobRuntimeSchema(client: PoolClient): Promise<void> {
await client.query(`
ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS user_id UUID;
ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS provider VARCHAR(128);
ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS model_name VARCHAR(255);
ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS api_url TEXT;
ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS progress JSONB NOT NULL DEFAULT '{}'::jsonb;
CREATE INDEX IF NOT EXISTS generation_jobs_user_created_idx ON generation_jobs (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS generation_jobs_provider_model_created_idx ON generation_jobs (type, provider, model_name, created_at DESC);
`);
if (generationJobSchemaReady) return;
try {
await client.query(`
ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS user_id UUID;
ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS provider VARCHAR(128);
ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS model_name VARCHAR(255);
ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS api_url TEXT;
ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS progress JSONB NOT NULL DEFAULT '{}'::jsonb;
CREATE INDEX IF NOT EXISTS generation_jobs_user_created_idx ON generation_jobs (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS generation_jobs_provider_model_created_idx ON generation_jobs (type, provider, model_name, created_at DESC);
`);
generationJobSchemaReady = true;
} catch (error) {
if (error && typeof error === 'object' && (error as { code?: string }).code === '42501') {
if (!generationJobSchemaWarned) {
console.warn('[generation-job-schema] skipped optional schema check because the database user is not the table owner');
generationJobSchemaWarned = true;
}
generationJobSchemaReady = true;
return;
}
throw error;
}
}
export async function resolveGenerationJobIdentity(

View File

@@ -5,6 +5,7 @@ import {
} from './generation-job-runner';
import { ensureGenerationJobRuntimeSchema } from '@/lib/generation-job-estimates';
import { writePlatformLog } from '@/lib/platform-logs';
import { chargeGenerationCredits } from '@/lib/generation-credit-service';
const POLL_INTERVAL_MS = Number(process.env.GENERATION_WORKER_INTERVAL_MS || 5000);
const STALE_RUNNING_MINUTES = Number(process.env.GENERATION_JOB_TIMEOUT_MINUTES || 30);
@@ -112,6 +113,27 @@ async function claimNextJob() {
}
}
async function settleJobCredits(input: {
userId: string | null;
type: GenerationJobType;
payload: Record<string, unknown>;
result: Record<string, unknown>;
}) {
const client = await getDbClient();
try {
await ensureGenerationJobRuntimeSchema(client);
await client.query('BEGIN');
const charge = await chargeGenerationCredits(client, input);
await client.query('COMMIT');
return charge;
} catch (error) {
await client.query('ROLLBACK').catch(() => undefined);
throw error;
} finally {
client.release();
}
}
export async function processNextGenerationJob() {
if (processing) return false;
processing = true;
@@ -124,10 +146,24 @@ export async function processNextGenerationJob() {
const result = await runGenerationPayload(job.type, job.payload || {}, {
userId: job.user_id,
jobId: job.id,
}) as Record<string, unknown>;
const creditCharge = await settleJobCredits({
userId: job.user_id,
type: job.type,
payload: job.payload || {},
result,
});
const finalResult = creditCharge
? {
...result,
creditsCost: creditCharge.creditsCost,
creditsBalance: creditCharge.balanceAfter,
creditDescription: creditCharge.description,
}
: result;
await updateJob(job.id, {
status: 'succeeded',
result,
result: finalResult,
error: null,
});
void writePlatformLog({

View File

@@ -1,7 +1,10 @@
import fs from 'fs';
import path from 'path';
import { createHash, createHmac } from 'node:crypto';
import { Readable } from 'stream';
import {
GetObjectCommand,
type GetObjectCommandOutput,
HeadBucketCommand,
HeadObjectCommand,
PutObjectCommand,
@@ -102,6 +105,22 @@ function objectKey(config: ObjectStorageConfig, key: string): string {
return config.prefix ? `${config.prefix}/${key}` : key;
}
function encodePathname(value: string): string {
return value.split('/').map(segment => encodeURIComponent(segment)).join('/');
}
function encodeQueryValue(value: string): string {
return encodeURIComponent(value).replace(/[!'()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
}
function hmac(key: Buffer | string, value: string): Buffer {
return createHmac('sha256', key).update(value).digest();
}
function sha256Hex(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
async function streamToBuffer(body: unknown): Promise<Buffer> {
if (!body) return Buffer.alloc(0);
if (typeof (body as { transformToByteArray?: () => Promise<Uint8Array> }).transformToByteArray === 'function') {
@@ -114,10 +133,29 @@ async function streamToBuffer(body: unknown): Promise<Buffer> {
return Buffer.concat(chunks);
}
function toWebReadableStream(body: unknown): ReadableStream<Uint8Array> {
if (!body) return new ReadableStream<Uint8Array>();
if (typeof (body as { transformToWebStream?: () => ReadableStream<Uint8Array> }).transformToWebStream === 'function') {
return (body as { transformToWebStream: () => ReadableStream<Uint8Array> }).transformToWebStream();
}
if (body instanceof Readable) {
return Readable.toWeb(body) as ReadableStream<Uint8Array>;
}
if (Symbol.asyncIterator in Object(body)) {
return Readable.toWeb(Readable.from(body as AsyncIterable<Buffer | Uint8Array | string>)) as ReadableStream<Uint8Array>;
}
return Readable.toWeb(Readable.from([body as Buffer | Uint8Array | string])) as ReadableStream<Uint8Array>;
}
export function getRequestedStorageMode(): StorageMode {
return parseStorageMode(process.env.STORAGE_MODE, Boolean(getObjectStorageConfig()));
}
let cachedStorageHealth: { value: StorageHealthStatus; expiresAt: number } | null = null;
const OBJECT_HEAD_TIMEOUT_MS = Number(process.env.OBJECT_STORAGE_HEAD_TIMEOUT_MS || 10_000);
const OBJECT_GET_TIMEOUT_MS = Number(process.env.OBJECT_STORAGE_GET_TIMEOUT_MS || 60_000);
const OBJECT_PUT_TIMEOUT_MS = Number(process.env.OBJECT_STORAGE_PUT_TIMEOUT_MS || 60_000);
class LocalStorage {
private basePath: string;
private mode: StorageMode;
@@ -182,30 +220,43 @@ class LocalStorage {
private async putObject(key: string, fileContent: Buffer, contentType: string): Promise<void> {
if (!this.objectConfig || !this.s3) throw new Error('Object storage is not configured');
await this.s3.send(new PutObjectCommand({
Bucket: this.objectConfig.bucket,
Key: objectKey(this.objectConfig, key),
Body: fileContent,
ContentType: contentType,
}));
await this.s3.send(
new PutObjectCommand({
Bucket: this.objectConfig.bucket,
Key: objectKey(this.objectConfig, key),
Body: fileContent,
ContentType: contentType,
}),
{ abortSignal: AbortSignal.timeout(OBJECT_PUT_TIMEOUT_MS) },
);
}
private async getObject(key: string): Promise<Buffer> {
if (!this.objectConfig || !this.s3) throw new Error('Object storage is not configured');
const result = await this.s3.send(new GetObjectCommand({
Bucket: this.objectConfig.bucket,
Key: objectKey(this.objectConfig, key),
}));
const result = await this.getObjectResult(key);
return streamToBuffer(result.Body);
}
private async getObjectResult(key: string): Promise<GetObjectCommandOutput> {
if (!this.objectConfig || !this.s3) throw new Error('Object storage is not configured');
return this.s3.send(
new GetObjectCommand({
Bucket: this.objectConfig.bucket,
Key: objectKey(this.objectConfig, key),
}),
{ abortSignal: AbortSignal.timeout(OBJECT_GET_TIMEOUT_MS) },
);
}
private async objectExists(key: string): Promise<boolean> {
if (!this.objectConfig || !this.s3) return false;
try {
await this.s3.send(new HeadObjectCommand({
Bucket: this.objectConfig.bucket,
Key: objectKey(this.objectConfig, key),
}));
await this.s3.send(
new HeadObjectCommand({
Bucket: this.objectConfig.bucket,
Key: objectKey(this.objectConfig, key),
}),
{ abortSignal: AbortSignal.timeout(OBJECT_HEAD_TIMEOUT_MS) },
);
return true;
} catch {
return false;
@@ -238,6 +289,31 @@ class LocalStorage {
return key;
}
async uploadFileLocalOnly(input: {
fileContent: Buffer;
fileName: string;
contentType: string;
}): Promise<string> {
const { fileContent, fileName } = input;
const key = this.normalizeKey(fileName);
this.writeLocalFile(key, fileContent);
return key;
}
async uploadFileObjectOnly({ fileContent, fileName, contentType }: {
fileContent: Buffer;
fileName: string;
contentType: string;
}): Promise<string> {
const key = this.normalizeKey(fileName);
if (this.objectConfig && this.s3) {
await this.putObject(key, fileContent, contentType);
return key;
}
this.writeLocalFile(key, fileContent);
return key;
}
async uploadFromUrl({ url, timeout }: {
url: string;
timeout: number;
@@ -274,6 +350,56 @@ class LocalStorage {
return `/api/local-storage/${this.normalizeKey(key)}`;
}
generateObjectReadUrl(key: string, expiresInSeconds = 300): string | null {
if (!this.objectConfig?.endpoint || !this.objectConfig.accessKeyId || !this.objectConfig.secretAccessKey) return null;
const normalized = this.normalizeKey(key);
const endpoint = new URL(this.objectConfig.endpoint);
const objectPath = objectKey(this.objectConfig, normalized);
const pathname = this.objectConfig.forcePathStyle
? `/${this.objectConfig.bucket}/${encodePathname(objectPath)}`
: `/${encodePathname(objectPath)}`;
const host = this.objectConfig.forcePathStyle
? endpoint.host
: `${this.objectConfig.bucket}.${endpoint.host}`;
const now = new Date();
const iso = now.toISOString().replace(/[:-]|\.\d{3}/g, '');
const date = iso.slice(0, 8);
const scope = `${date}/${this.objectConfig.region}/s3/aws4_request`;
const params: Record<string, string> = {
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
'X-Amz-Credential': `${this.objectConfig.accessKeyId}/${scope}`,
'X-Amz-Date': iso,
'X-Amz-Expires': String(Math.max(1, Math.min(604800, Math.floor(expiresInSeconds)))),
'X-Amz-SignedHeaders': 'host',
};
const canonicalQuery = Object.keys(params)
.sort()
.map(param => `${encodeQueryValue(param)}=${encodeQueryValue(params[param])}`)
.join('&');
const canonicalRequest = [
'GET',
pathname,
canonicalQuery,
`host:${host}`,
'',
'host',
'UNSIGNED-PAYLOAD',
].join('\n');
const stringToSign = [
'AWS4-HMAC-SHA256',
iso,
scope,
sha256Hex(canonicalRequest),
].join('\n');
const signingKey = hmac(hmac(hmac(hmac(`AWS4${this.objectConfig.secretAccessKey}`, date), this.objectConfig.region), 's3'), 'aws4_request');
const signature = createHmac('sha256', signingKey).update(stringToSign).digest('hex');
const url = new URL(endpoint.toString());
url.host = host;
url.pathname = pathname;
url.search = `${canonicalQuery}&X-Amz-Signature=${signature}`;
return url.toString();
}
private getFileExtension(url: string): string {
const match = url.split('?')[0]?.match(/\.([^.]+)$/);
return match ? match[1] : 'bin';
@@ -288,6 +414,10 @@ class LocalStorage {
return this.usesLocalStorage() && this.localFileExists(normalized);
}
localFileExistsOnly(key: string): boolean {
return this.localFileExists(this.normalizeKey(key));
}
async fileExistsAsync(key: string): Promise<boolean> {
const normalized = this.normalizeKey(key);
if (this.usesObjectStorage() && await this.objectExists(normalized)) return true;
@@ -306,6 +436,35 @@ class LocalStorage {
return this.readLocalFile(normalized);
}
async openFileStreamAsync(key: string): Promise<{
body: ReadableStream<Uint8Array>;
contentLength?: number;
contentType?: string;
}> {
const normalized = this.normalizeKey(key);
if (this.usesObjectStorage()) {
try {
const result = await this.getObjectResult(normalized);
return {
body: toWebReadableStream(result.Body),
contentLength: result.ContentLength,
contentType: result.ContentType,
};
} catch (error) {
if (!this.usesLocalStorage()) throw error;
}
}
if (!this.usesLocalStorage() || !this.localFileExists(normalized)) {
throw new Error('File not found');
}
const filePath = this.getLocalFilePath(normalized);
const stat = fs.statSync(filePath);
return {
body: Readable.toWeb(fs.createReadStream(filePath)) as ReadableStream<Uint8Array>,
contentLength: stat.size,
};
}
readFile(key: string): Buffer {
const normalized = this.normalizeKey(key);
if (this.usesLocalStorage() && this.localFileExists(normalized)) {
@@ -321,7 +480,7 @@ class LocalStorage {
return decodeURIComponent(url.slice(index + marker.length).split('?')[0]);
}
async copyPublicUrlToFolder(url: string, folder: string): Promise<string> {
async copyPublicUrlToFolder(url: string, folder: string, options: { storageTarget?: 'default' | 'local' | 'object' } = {}): Promise<string> {
const existingKey = this.getKeyFromPublicUrl(url);
let buffer: Buffer;
let ext = this.getFileExtension(url);
@@ -339,11 +498,16 @@ class LocalStorage {
}
const key = `${folder}/${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext || 'bin'}`;
const savedKey = await this.uploadFile({
const uploadInput = {
fileContent: buffer,
fileName: key,
contentType: 'application/octet-stream',
});
};
const savedKey = options.storageTarget === 'object'
? await this.uploadFileObjectOnly(uploadInput)
: options.storageTarget === 'local'
? await this.uploadFileLocalOnly(uploadInput)
: await this.uploadFile(uploadInput);
return this.generatePresignedUrl({ key: savedKey, expireTime: 2592000 });
}
@@ -353,6 +517,10 @@ class LocalStorage {
}
export async function getStorageHealthStatus(): Promise<StorageHealthStatus> {
const now = Date.now();
if (cachedStorageHealth && cachedStorageHealth.expiresAt > now) {
return cachedStorageHealth.value;
}
const objectConfig = getObjectStorageConfig();
const requestedMode = parseStorageMode(process.env.STORAGE_MODE, Boolean(objectConfig));
const mode = objectConfig ? requestedMode : 'local';
@@ -392,7 +560,10 @@ export async function getStorageHealthStatus(): Promise<StorageHealthStatus> {
} else {
try {
const s3 = createS3Client(objectConfig);
await s3.send(new HeadBucketCommand({ Bucket: objectConfig.bucket }));
await s3.send(
new HeadBucketCommand({ Bucket: objectConfig.bucket }),
{ abortSignal: AbortSignal.timeout(6000) },
);
object.ok = true;
} catch (error) {
object.ok = false;
@@ -401,13 +572,15 @@ export async function getStorageHealthStatus(): Promise<StorageHealthStatus> {
}
}
return {
const status = {
ok: local.ok && object.ok,
mode,
requestedMode,
local,
object,
};
cachedStorageHealth = { value: status, expiresAt: now + 30_000 };
return status;
}
export const localStorage = new LocalStorage();

187
src/lib/media-storage.ts Normal file
View File

@@ -0,0 +1,187 @@
import crypto from 'crypto';
import path from 'path';
import sharp from 'sharp';
import { localStorage } from '@/lib/local-storage';
import { fetchPublicHttpUrl } from '@/lib/remote-fetch';
const THUMBNAIL_MAX_EDGE = Number(process.env.IMAGE_THUMBNAIL_MAX_EDGE || 1280);
const THUMBNAIL_WEBP_QUALITY = Number(process.env.IMAGE_THUMBNAIL_WEBP_QUALITY || 86);
const THUMBNAIL_PROFILE = `m${THUMBNAIL_MAX_EDGE}q${THUMBNAIL_WEBP_QUALITY}`;
export type PersistedImageMedia = {
url: string;
thumbnailUrl: string;
width: number;
height: number;
bytes: number;
};
type ImageBufferSource = {
buffer: Buffer;
mimeType: string;
ext: string;
};
export function parseImageDataUrl(dataUrl: string): ImageBufferSource | null {
const match = dataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/);
if (!match) return null;
const mimeType = match[1].split(';')[0] || 'image/png';
return {
buffer: Buffer.from(match[2], 'base64'),
mimeType,
ext: getImageExtension(mimeType, dataUrl),
};
}
export function getImageExtension(mimeType: string, url = ''): string {
const normalizedMime = mimeType.split(';')[0]?.trim().toLowerCase();
if (normalizedMime === 'image/jpeg' || normalizedMime === 'image/jpg') return 'jpg';
if (normalizedMime === 'image/png') return 'png';
if (normalizedMime === 'image/webp') return 'webp';
if (normalizedMime === 'image/gif') return 'gif';
const urlExt = path.extname(url.split('?')[0] || '').replace('.', '').toLowerCase();
return /^(jpe?g|png|webp|gif)$/i.test(urlExt) ? urlExt : 'png';
}
export async function readImageBufferFromUrl(url: string): Promise<ImageBufferSource | null> {
if (url.startsWith('data:')) return parseImageDataUrl(url);
const existingKey = localStorage.getKeyFromPublicUrl(url);
if (existingKey && localStorage.localFileExistsOnly(existingKey)) {
const buffer = await localStorage.readFileAsync(existingKey);
return {
buffer,
mimeType: getImageMimeType(existingKey),
ext: path.extname(existingKey).replace('.', '') || 'png',
};
}
const objectReadUrl = existingKey ? localStorage.generateObjectReadUrl(existingKey, 300) : null;
if (existingKey && objectReadUrl) {
const response = await fetchPublicHttpUrl(objectReadUrl, { signal: AbortSignal.timeout(45_000) });
if (!response.ok) throw new Error(`下载图片失败: ${response.status}`);
const mimeType = response.headers.get('content-type')?.split(';')[0] || getImageMimeType(existingKey);
return {
buffer: Buffer.from(await response.arrayBuffer()),
mimeType,
ext: getImageExtension(mimeType, existingKey),
};
}
if (existingKey && await localStorage.fileExistsAsync(existingKey)) {
const buffer = await localStorage.readFileAsync(existingKey);
return {
buffer,
mimeType: getImageMimeType(existingKey),
ext: path.extname(existingKey).replace('.', '') || 'png',
};
}
if (!url.startsWith('http')) return null;
const response = await fetchPublicHttpUrl(url, { signal: AbortSignal.timeout(30_000) });
if (!response.ok) throw new Error(`下载图片失败: ${response.status}`);
const mimeType = response.headers.get('content-type')?.split(';')[0] || 'image/png';
return {
buffer: Buffer.from(await response.arrayBuffer()),
mimeType,
ext: getImageExtension(mimeType, url),
};
}
export async function persistOriginalImageWithThumbnail(input: {
buffer: Buffer;
mimeType: string;
ext: string;
originalPrefix: string;
thumbnailPrefix?: string;
}): Promise<PersistedImageMedia> {
const metadata = await sharp(input.buffer, { failOn: 'none' }).metadata();
if (!metadata.width || !metadata.height) {
throw new Error('无法读取生成图片尺寸');
}
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const originalKey = await localStorage.uploadFileObjectOnly({
fileContent: input.buffer,
fileName: `${input.originalPrefix}/${suffix}.${input.ext || 'png'}`,
contentType: input.mimeType,
});
const thumbnailUrl = await createLocalImageThumbnail({
buffer: input.buffer,
sourceKey: originalKey,
thumbnailPrefix: input.thumbnailPrefix || 'thumbnails/images',
});
const url = await localStorage.generatePresignedUrl({ key: originalKey, expireTime: 2592000 });
return {
url,
thumbnailUrl,
width: metadata.width,
height: metadata.height,
bytes: input.buffer.length,
};
}
export async function ensureLocalImageThumbnail(url: string, thumbnailPrefix = 'thumbnails/images'): Promise<string | null> {
if (!url || url.startsWith('data:') || url.startsWith('[')) return null;
const source = await readImageBufferFromUrl(url);
if (!source) return null;
const existingKey = localStorage.getKeyFromPublicUrl(url) || url;
return createLocalImageThumbnail({
buffer: source.buffer,
sourceKey: existingKey,
thumbnailPrefix,
});
}
export function isCurrentLocalImageThumbnail(url: unknown): boolean {
return typeof url === 'string'
&& url.includes('/api/local-storage/thumbnails/')
&& url.includes(`-${THUMBNAIL_PROFILE}.webp`);
}
async function createLocalImageThumbnail(input: {
buffer: Buffer;
sourceKey: string;
thumbnailPrefix: string;
}): Promise<string> {
const hash = crypto.createHash('sha256')
.update(input.sourceKey)
.update(input.buffer.subarray(0, Math.min(input.buffer.length, 1024 * 1024)))
.digest('hex')
.slice(0, 32);
const key = `${input.thumbnailPrefix}/${hash}-${THUMBNAIL_PROFILE}.webp`;
if (localStorage.localFileExistsOnly(key)) {
return localStorage.generatePresignedUrl({ key, expireTime: 2592000 });
}
const thumbnail = await sharp(input.buffer, { failOn: 'none' })
.rotate()
.resize({
width: THUMBNAIL_MAX_EDGE,
height: THUMBNAIL_MAX_EDGE,
fit: 'inside',
withoutEnlargement: true,
kernel: sharp.kernel.lanczos3,
})
.sharpen({ sigma: 0.45, m1: 0.6, m2: 1.5 })
.webp({
quality: Math.max(85, Math.min(100, THUMBNAIL_WEBP_QUALITY)),
effort: 5,
smartSubsample: true,
})
.toBuffer();
const savedKey = await localStorage.uploadFileLocalOnly({
fileContent: thumbnail,
fileName: key,
contentType: 'image/webp',
});
return localStorage.generatePresignedUrl({ key: savedKey, expireTime: 2592000 });
}
function getImageMimeType(key: string): string {
const ext = path.extname(key).replace('.', '').toLowerCase();
if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg';
if (ext === 'webp') return 'image/webp';
if (ext === 'gif') return 'image/gif';
return 'image/png';
}

View File

@@ -2,19 +2,36 @@ import type { PoolClient } from 'pg';
export type PreferredTheme = 'dark' | 'light';
let profilePreferenceSchemaReady = false;
let profilePreferenceSchemaWarned = false;
export function normalizePreferredTheme(value: unknown): PreferredTheme {
return value === 'light' ? 'light' : 'dark';
}
export async function ensureProfilePreferenceSchema(client: PoolClient): Promise<void> {
await client.query(`
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS preferred_theme VARCHAR(16) NOT NULL DEFAULT 'dark'
`);
await client.query(`
UPDATE profiles
SET preferred_theme = 'dark'
WHERE preferred_theme IS NULL
OR preferred_theme NOT IN ('dark', 'light')
`);
if (profilePreferenceSchemaReady) return;
try {
await client.query(`
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS preferred_theme VARCHAR(16) NOT NULL DEFAULT 'dark'
`);
await client.query(`
UPDATE profiles
SET preferred_theme = 'dark'
WHERE preferred_theme IS NULL
OR preferred_theme NOT IN ('dark', 'light')
`);
profilePreferenceSchemaReady = true;
} catch (error) {
if (error && typeof error === 'object' && (error as { code?: string }).code === '42501') {
if (!profilePreferenceSchemaWarned) {
console.warn('[profile-preferences] skipped optional schema check because the database user is not the table owner');
profilePreferenceSchemaWarned = true;
}
profilePreferenceSchemaReady = true;
return;
}
throw error;
}
}

View File

@@ -93,6 +93,24 @@ export function isUuid(value: unknown): value is string {
return typeof value === 'string' && UUID_REGEX.test(value);
}
type DbClient = Awaited<ReturnType<typeof getDbClient>>;
function isDatabaseOwnershipError(err: unknown): boolean {
return !!err && typeof err === 'object' && (err as { code?: string }).code === '42501';
}
async function applyOptionalSystemApiSchemaChange(client: DbClient, sql: string): Promise<void> {
try {
await client.query(sql);
} catch (err) {
if (isDatabaseOwnershipError(err)) {
console.warn('[system-api-schema] skipped optional schema change because the database user is not the table owner');
return;
}
throw err;
}
}
export function getInternalGenerationSecret(): string {
const secret = process.env.GENERATION_INTERNAL_SECRET || process.env.JWT_SECRET || process.env.DATA_ENCRYPTION_KEY;
if (secret) return secret;
@@ -115,8 +133,8 @@ export function isTrustedInternalGenerationRequest(request: NextRequest): boolea
&& crypto.timingSafeEqual(providedBuffer, expectedBuffer);
}
export async function ensureSystemApiSchema(client: Awaited<ReturnType<typeof getDbClient>>): Promise<void> {
await client.query(`
export async function ensureSystemApiSchema(client: DbClient): Promise<void> {
await applyOptionalSystemApiSchemaChange(client, `
CREATE TABLE IF NOT EXISTS system_api_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider VARCHAR(128),
@@ -150,7 +168,7 @@ export async function ensureSystemApiSchema(client: Awaited<ReturnType<typeof ge
updated_at TIMESTAMPTZ
)
`);
await client.query(`
await applyOptionalSystemApiSchemaChange(client, `
ALTER TABLE system_api_configs
ADD COLUMN IF NOT EXISTS model_group VARCHAR(128) NOT NULL DEFAULT 'default',
ADD COLUMN IF NOT EXISTS billing_mode VARCHAR(24) NOT NULL DEFAULT 'fixed',
@@ -169,11 +187,11 @@ export async function ensureSystemApiSchema(client: Awaited<ReturnType<typeof ge
ADD COLUMN IF NOT EXISTS polling_order INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS video_usage_modes JSONB NOT NULL DEFAULT '["text-to-video","image-to-video"]'::jsonb
`);
await client.query('CREATE INDEX IF NOT EXISTS system_api_configs_active_type_sort_idx ON system_api_configs (is_active, type, sort_order)');
await client.query('CREATE INDEX IF NOT EXISTS system_api_configs_group_type_sort_idx ON system_api_configs (model_group, type, sort_order)');
await client.query('CREATE INDEX IF NOT EXISTS system_api_configs_default_sort_idx ON system_api_configs (is_default, is_active, sort_order)');
await client.query('CREATE INDEX IF NOT EXISTS system_api_configs_polling_idx ON system_api_configs (type, model_name, is_default, is_active, polling_order, sort_order)');
await client.query('CREATE INDEX IF NOT EXISTS system_api_configs_display_polling_idx ON system_api_configs (type, name, is_default, is_active, polling_order, sort_order)');
await applyOptionalSystemApiSchemaChange(client, 'CREATE INDEX IF NOT EXISTS system_api_configs_active_type_sort_idx ON system_api_configs (is_active, type, sort_order)');
await applyOptionalSystemApiSchemaChange(client, 'CREATE INDEX IF NOT EXISTS system_api_configs_group_type_sort_idx ON system_api_configs (model_group, type, sort_order)');
await applyOptionalSystemApiSchemaChange(client, 'CREATE INDEX IF NOT EXISTS system_api_configs_default_sort_idx ON system_api_configs (is_default, is_active, sort_order)');
await applyOptionalSystemApiSchemaChange(client, 'CREATE INDEX IF NOT EXISTS system_api_configs_polling_idx ON system_api_configs (type, model_name, is_default, is_active, polling_order, sort_order)');
await applyOptionalSystemApiSchemaChange(client, 'CREATE INDEX IF NOT EXISTS system_api_configs_display_polling_idx ON system_api_configs (type, name, is_default, is_active, polling_order, sort_order)');
}
function normalizeBillingMode(value: unknown): ServerManagedApiConfig['billingMode'] {

View File

@@ -92,6 +92,24 @@ function getPathValue(value: unknown, dottedPath: string): unknown {
}, value);
}
function getTaskIdFromValue(value: unknown): string | number | undefined {
if (typeof value === 'string' || typeof value === 'number') return value;
if (!value || typeof value !== 'object') return undefined;
const directKeys = ['task_id', 'taskId', 'taskID', 'taskid', 'id', 'task'];
for (const key of directKeys) {
const child = (value as Record<string, unknown>)[key];
if (typeof child === 'string' || typeof child === 'number') return child;
}
for (const key of ['data', 'result', 'output']) {
const nested = getTaskIdFromValue((value as Record<string, unknown>)[key]);
if (nested !== undefined) return nested;
}
return undefined;
}
function valuesAtPath(value: unknown, dottedPath: string): unknown[] {
const segments = dottedPath.split('.').filter(Boolean);
const walk = (current: unknown, index: number): unknown[] => {
@@ -336,8 +354,8 @@ export async function executeUserApiManifest(input: UserApiManifestExecutionInpu
if (!stored.provider.poll) {
throw new Error('Manifest 返回了任务 ID但缺少 poll 轮询配置');
}
const taskId = getPathValue(submitRaw, endpoint.taskIdPath);
if (typeof taskId !== 'string' && typeof taskId !== 'number') {
const taskId = getTaskIdFromValue(getPathValue(submitRaw, endpoint.taskIdPath));
if (taskId === undefined) {
throw new Error(`Manifest 未能从 ${endpoint.taskIdPath} 读取任务 ID`);
}
return pollManifestResult(stored.provider.poll, String(taskId), executionInput);

View File

@@ -118,15 +118,32 @@ export function generateDefaultAvatarDataUrl(seedValue: string, labelValue?: str
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
export async function ensureUserDisplayProfileSchema(client: PoolClient): Promise<void> {
await client.query(`
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS display_nickname VARCHAR(128)
`);
let userDisplayProfileSchemaReady = false;
let userDisplayProfileSchemaWarned = false;
await client.query(`
UPDATE profiles
SET display_nickname = COALESCE(NULLIF(display_nickname, ''), NULLIF(nickname, ''), split_part(email, '@', 1))
WHERE display_nickname IS NULL OR display_nickname = ''
`);
export async function ensureUserDisplayProfileSchema(client: PoolClient): Promise<void> {
if (userDisplayProfileSchemaReady) return;
try {
await client.query(`
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS display_nickname VARCHAR(128)
`);
await client.query(`
UPDATE profiles
SET display_nickname = COALESCE(NULLIF(display_nickname, ''), NULLIF(nickname, ''), split_part(email, '@', 1))
WHERE display_nickname IS NULL OR display_nickname = ''
`);
userDisplayProfileSchemaReady = true;
} catch (error) {
if (error && typeof error === 'object' && (error as { code?: string }).code === '42501') {
if (!userDisplayProfileSchemaWarned) {
console.warn('[user-profile-defaults] skipped optional schema check because the database user is not the table owner');
userDisplayProfileSchemaWarned = true;
}
userDisplayProfileSchemaReady = true;
return;
}
throw error;
}
}

View File

@@ -308,7 +308,7 @@ export function buildYuanjieSubmit(template: YuanjieImageModelTemplate): Manifes
images: '$inputImages.dataUrls',
base64Array: '$inputImages.dataUrls',
},
taskIdPath: 'task_id|data.task_id|data.id|id|result',
taskIdPath: 'task_id|taskId|data.task_id|data.taskId|data.id|id|result.task_id|result.taskId|result.id|result',
result: {
imageUrlPaths: [
'data.*.url',

View File

@@ -146,7 +146,7 @@ export function buildYuanjieVideoSubmit(template: YuanjieVideoModelTemplate): Ma
images: '$inputImages.dataUrls',
image: '$inputImages.dataUrls',
},
taskIdPath: 'task_id|data.task_id|data.id|id|result',
taskIdPath: 'task_id|taskId|data.task_id|data.taskId|data.id|id|result.task_id|result.taskId|result.id|result',
result: {
videoUrlPaths: ['result_url', 'data.result_url', 'data.*.url', 'url', 'video_url', 'result.video_url', 'result.url', 'output.*.url'],
b64VideoPaths: ['data.*.b64_json', 'b64_json', 'video_base64'],

View File

@@ -39,9 +39,9 @@ function buildContentSecurityPolicy(request: NextRequest): string {
const directives = [
["default-src", "'self'"],
["script-src", ...scriptSrc],
["style-src", "'self'", "'unsafe-inline'"],
["style-src", "'self'", "'unsafe-inline'", 'https://fonts.googleapis.cn'],
["img-src", "'self'", 'data:', 'blob:', 'https:', 'http:'],
["font-src", "'self'", 'data:'],
["font-src", "'self'", 'data:', 'https://fonts.gstatic.com', 'https://fonts.gstatic.cn'],
["connect-src", "'self'", 'https:', 'http:', 'ws:', 'wss:'],
["media-src", "'self'", 'data:', 'blob:', 'https:', 'http:'],
["frame-src", "'self'"],
@@ -75,7 +75,11 @@ function applySecurityHeaders(response: NextResponse, request: NextRequest): Nex
response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
}
if (path.startsWith('/api/')) {
if (path.startsWith('/api/local-storage/thumbnails/')) {
response.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
} else if (path === '/api/gallery') {
response.headers.set('Cache-Control', 'private, max-age=30, stale-while-revalidate=120');
} else if (path.startsWith('/api/')) {
response.headers.set('Cache-Control', 'no-store');
}