From 3ad353b97fc75dfc518502745582a91bf4667dc0 Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 19 Aug 2026 16:00:38 +0800 Subject: [PATCH 1/6] fix: make chat layout dynamic to avoid broken home chunk artifacts --- app/(chat)/layout.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/(chat)/layout.tsx b/app/(chat)/layout.tsx index 646705092..8eca253ba 100644 --- a/app/(chat)/layout.tsx +++ b/app/(chat)/layout.tsx @@ -8,6 +8,8 @@ import { getTutorialVideos, } from "@/lib/db/queries"; +export const dynamic = "force-dynamic"; + export default function Layout({ children }: { children: React.ReactNode }) { return ( From 6180c5fc49391a747e526890c7cc3bcaa06adb08 Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 20 Aug 2026 19:28:51 +0800 Subject: [PATCH 2/6] fix: restore production model availability helpers --- lib/ai/model-visibility.ts | 19 +++++++++++ lib/style-pro/model-availability.ts | 51 +++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 lib/ai/model-visibility.ts create mode 100644 lib/style-pro/model-availability.ts diff --git a/lib/ai/model-visibility.ts b/lib/ai/model-visibility.ts new file mode 100644 index 000000000..7eb4e15be --- /dev/null +++ b/lib/ai/model-visibility.ts @@ -0,0 +1,19 @@ +export type HiddenModelAccessInput = { + sessionUserType?: string | null; + databaseUserRole?: string | null; + isHighVip?: boolean | null; +}; + +const HIDDEN_MODEL_ADMIN_ROLES = new Set(["admin", "agent_admin"]); + +export function canAccessHiddenAiModels({ + sessionUserType, + databaseUserRole, + isHighVip, +}: HiddenModelAccessInput) { + return ( + isHighVip === true || + HIDDEN_MODEL_ADMIN_ROLES.has(String(databaseUserRole || "").trim()) || + HIDDEN_MODEL_ADMIN_ROLES.has(String(sessionUserType || "").trim()) + ); +} diff --git a/lib/style-pro/model-availability.ts b/lib/style-pro/model-availability.ts new file mode 100644 index 000000000..89822ebdb --- /dev/null +++ b/lib/style-pro/model-availability.ts @@ -0,0 +1,51 @@ +import type { AiModel, AiProvider } from "@/lib/db/schema"; +import { + getAiModelFrontendAvailability, + type ModelFrontendAvailability, +} from "@/lib/ai/model-frontend-availability"; +import { modelSupportsReferenceImageInput } from "@/lib/ai/reference-image-capability"; + +type StyleProAvailabilityEntry = { + model: Pick< + AiModel, + | "displayName" + | "modelId" + | "supportsVision" + | "capabilities" + | "defaultParams" + | "isEnabled" + | "healthStatus" + | "healthAutoDisabledAt" + | "healthObservationStartedAt" + >; + provider: Pick; +}; + +export function getStyleProModelAvailability( + entry: StyleProAvailabilityEntry +): ModelFrontendAvailability { + if (!modelSupportsReferenceImageInput(entry.model, entry.provider)) { + return { + availabilityStatus: "maintenance", + availabilityLabel: "仅文生图", + selectable: false, + disabledReason: "风格化需要参考图,该模型仅支持文生图", + }; + } + + const availability = getAiModelFrontendAvailability(entry.model); + if (availability.availabilityStatus === "testing") { + return { + availabilityStatus: "testing", + availabilityLabel: "暂不可用", + selectable: false, + disabledReason: "模型近期生成异常,恢复健康后会自动开放", + }; + } + + return availability; +} + +export function isStyleProModelRunnable(entry: StyleProAvailabilityEntry) { + return getStyleProModelAvailability(entry).selectable; +} From 88dec71a41a49966623069ae35f8f30890e01c8f Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 20 Aug 2026 19:32:49 +0800 Subject: [PATCH 3/6] ops: make production builds atomic --- scripts/deploy-production-atomic.sh | 52 +++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100755 scripts/deploy-production-atomic.sh diff --git a/scripts/deploy-production-atomic.sh b/scripts/deploy-production-atomic.sh new file mode 100755 index 000000000..5b4dd4c17 --- /dev/null +++ b/scripts/deploy-production-atomic.sh @@ -0,0 +1,52 @@ +#!/bin/sh +set -eu + +app_dir="/srv/ai-chatbot" +compose_dir="/srv/ai-chatbot-deploy" +candidate_dir="$(mktemp -d "${app_dir}/.next-candidate.XXXXXX")" +previous_dir="${app_dir}/.next-previous-$(date +%Y%m%dT%H%M%S)" +build_container="ai-chatbot-build-$(date +%s)" + +cleanup_candidate() { + if [ -d "$candidate_dir" ]; then + rm -rf -- "$candidate_dir" + fi +} + +trap cleanup_candidate EXIT INT TERM + +docker run --rm \ + --name "$build_container" \ + --network host \ + -e NODE_OPTIONS=--max-old-space-size=6144 \ + -v "${app_dir}:/workspace" \ + -v "${candidate_dir}:/workspace/.next" \ + -w /workspace \ + node:20.20.0-bookworm-slim \ + sh -lc 'corepack enable && pnpm build' + +test -s "${candidate_dir}/BUILD_ID" + +docker stop ai-chatbot-app >/dev/null +if [ -d "${app_dir}/.next" ]; then + mv "${app_dir}/.next" "$previous_dir" +fi +mv "$candidate_dir" "${app_dir}/.next" +candidate_dir="" + +docker compose -f "${compose_dir}/docker-compose.yml" up -d app + +if ! curl -fsS --retry 12 --retry-delay 2 --max-time 10 \ + http://127.0.0.1:3125/login >/dev/null; then + docker stop ai-chatbot-app >/dev/null 2>&1 || true + failed_dir="${app_dir}/.next-failed-$(date +%Y%m%dT%H%M%S)" + mv "${app_dir}/.next" "$failed_dir" + if [ -d "$previous_dir" ]; then + mv "$previous_dir" "${app_dir}/.next" + docker compose -f "${compose_dir}/docker-compose.yml" up -d app + fi + echo "Deployment health check failed; previous build restored." >&2 + exit 1 +fi + +echo "Production build activated. Previous build: ${previous_dir}" From 3d12c508d37a1ba724dfc68d8cf89e8b3338f3cd Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 20 Aug 2026 19:34:56 +0800 Subject: [PATCH 4/6] fix: stop retrying expired image archives forever --- lib/storage/image-archive-db.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/storage/image-archive-db.ts b/lib/storage/image-archive-db.ts index 06f56fc44..bfef74a20 100644 --- a/lib/storage/image-archive-db.ts +++ b/lib/storage/image-archive-db.ts @@ -1,12 +1,13 @@ import "server-only"; -import { and, asc, eq, inArray, lte, sql } from "drizzle-orm"; +import { and, asc, eq, inArray, lt, lte, sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { imageArchiveTask, type ImageArchiveTask } from "@/lib/db/schema"; export type ImageArchiveTaskRecord = ImageArchiveTask; const DEFAULT_DISPLAY_HOLD_HOURS = 1; +const MAX_IMAGE_ARCHIVE_ATTEMPTS = 12; function getDisplayHoldHours() { const raw = Number( @@ -95,7 +96,8 @@ export async function getImageArchiveTasksToStore(params: { limit: number }) { .where( and( inArray(imageArchiveTask.status, ["pending", "failed"]), - lte(imageArchiveTask.archiveAfterAt, now) + lte(imageArchiveTask.archiveAfterAt, now), + lt(imageArchiveTask.attemptCount, MAX_IMAGE_ARCHIVE_ATTEMPTS) ) ) .orderBy(asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id)) @@ -139,11 +141,18 @@ export async function markImageArchiveTaskFailed(params: { const message = String( params.error instanceof Error ? params.error.message : params.error || "Unknown error" ).slice(0, 2000); + const isPermanentSourceError = /\bHTTP (?:404|410)\b/i.test(message); await db .update(imageArchiveTask) .set({ - status: "failed", + status: isPermanentSourceError + ? "abandoned" + : sql`case + when ${imageArchiveTask.attemptCount} >= ${MAX_IMAGE_ARCHIVE_ATTEMPTS} + then 'abandoned' + else 'failed' + end`, lastError: message, archiveAfterAt: new Date(Date.now() + 10 * 60 * 1000), updatedAt: new Date(), From f0a24a1a994d5229d0a725a5bf6c2c51810b0b63 Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 20 Aug 2026 19:47:02 +0800 Subject: [PATCH 5/6] fix(canvas): optimize Qiniu image previews --- lib/image-delivery.ts | 40 +++++++++++++++++++--- playwright.helpers.config.ts | 1 + scripts/deploy-production-atomic.sh | 2 ++ tests/routes/image-delivery-qiniu.test.ts | 41 +++++++++++++++++++++++ 4 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 tests/routes/image-delivery-qiniu.test.ts diff --git a/lib/image-delivery.ts b/lib/image-delivery.ts index db3bc49bc..1deac343a 100644 --- a/lib/image-delivery.ts +++ b/lib/image-delivery.ts @@ -53,12 +53,39 @@ function isTrustedDirectStorageImageHost(hostname: string): boolean { ); } -function resolveTrustedDirectStorageDisplayUrl(url: string): string | null { +function isFirstPartyQiniuAsset(target: URL) { + return ( + target.hostname.trim().toLowerCase() === "bbyy.ai" && + target.pathname.startsWith("/uploads/") + ); +} + +function resolveTrustedDirectStorageDisplayUrl( + url: string, + options: Pick +): string | null { try { const target = new URL(url); - return isTrustedDirectStorageImageHost(target.hostname) - ? target.toString() - : null; + if ( + !isTrustedDirectStorageImageHost(target.hostname) && + !isFirstPartyQiniuAsset(target) + ) { + return null; + } + + const width = normalizeWidth(options.width); + const quality = normalizeQuality(options.quality); + const format = options.format; + if ((!width && !quality && !format) || target.search) { + return target.toString(); + } + + const operations = ["imageView2", "2"]; + if (width) operations.push("w", String(width)); + if (format) operations.push("format", format === "jpeg" ? "jpg" : format); + if (quality) operations.push("q", String(quality)); + target.search = `?${operations.join("/")}`; + return target.toString(); } catch { return null; } @@ -116,7 +143,10 @@ export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {}) return source; } - const trustedDirectStorageUrl = resolveTrustedDirectStorageDisplayUrl(source); + const trustedDirectStorageUrl = resolveTrustedDirectStorageDisplayUrl( + source, + options + ); if (trustedDirectStorageUrl) { return trustedDirectStorageUrl; } diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index 382ff7f7b..f2cf47ad9 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ /routes\/canvas-agent-operations\.test\.ts/, /routes\/canvas-overlay-density\.test\.ts/, /routes\/image-message-contracts\.test\.ts/, + /routes\/image-delivery-qiniu\.test\.ts/, /routes\/production-local-fallbacks\.test\.ts/, /routes\/canvas-entry-performance\.test\.ts/, /routes\/settings-visual-consistency\.test\.ts/, diff --git a/scripts/deploy-production-atomic.sh b/scripts/deploy-production-atomic.sh index 5b4dd4c17..ca971a3e3 100755 --- a/scripts/deploy-production-atomic.sh +++ b/scripts/deploy-production-atomic.sh @@ -28,6 +28,7 @@ docker run --rm \ test -s "${candidate_dir}/BUILD_ID" docker stop ai-chatbot-app >/dev/null +docker rm ai-chatbot-app >/dev/null if [ -d "${app_dir}/.next" ]; then mv "${app_dir}/.next" "$previous_dir" fi @@ -39,6 +40,7 @@ docker compose -f "${compose_dir}/docker-compose.yml" up -d app if ! curl -fsS --retry 12 --retry-delay 2 --max-time 10 \ http://127.0.0.1:3125/login >/dev/null; then docker stop ai-chatbot-app >/dev/null 2>&1 || true + docker rm ai-chatbot-app >/dev/null 2>&1 || true failed_dir="${app_dir}/.next-failed-$(date +%Y%m%dT%H%M%S)" mv "${app_dir}/.next" "$failed_dir" if [ -d "$previous_dir" ]; then diff --git a/tests/routes/image-delivery-qiniu.test.ts b/tests/routes/image-delivery-qiniu.test.ts new file mode 100644 index 000000000..e4c8891fd --- /dev/null +++ b/tests/routes/image-delivery-qiniu.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "@playwright/test"; +import { buildImageProxyUrl } from "@/lib/image-delivery"; + +test.describe("Qiniu image delivery", () => { + test("uses Qiniu image processing for direct canvas previews", () => { + expect( + buildImageProxyUrl("https://yybb.top/uploads/result.png", { + width: 1200, + quality: 80, + format: "webp", + }) + ).toBe( + "https://yybb.top/uploads/result.png?imageView2/2/w/1200/format/webp/q/80" + ); + }); + + test("optimizes legacy first-party upload URLs without changing their identity", () => { + expect( + buildImageProxyUrl("https://bbyy.ai/uploads/result.png", { + width: 960, + quality: 88, + format: "webp", + }) + ).toBe( + "https://bbyy.ai/uploads/result.png?imageView2/2/w/960/format/webp/q/88" + ); + }); + + test("does not overwrite an existing storage query", () => { + const url = "https://yybb.top/uploads/result.png?imageView2/2/w/400"; + expect(buildImageProxyUrl(url, { width: 1200, format: "webp" })).toBe(url); + }); + + test("keeps unrelated first-party URLs out of the Qiniu path", () => { + const result = buildImageProxyUrl("https://bbyy.ai/logo.png", { + delivery: "proxy", + width: 320, + }); + expect(result).toContain("/api/image-proxy?"); + }); +}); From 1d84b1b1c08585e5833ddda08712fa133a10d47e Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 20 Aug 2026 19:52:05 +0800 Subject: [PATCH 6/6] fix(ops): wait for production container startup --- scripts/deploy-production-atomic.sh | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/deploy-production-atomic.sh b/scripts/deploy-production-atomic.sh index ca971a3e3..e200f40f5 100755 --- a/scripts/deploy-production-atomic.sh +++ b/scripts/deploy-production-atomic.sh @@ -37,8 +37,18 @@ candidate_dir="" docker compose -f "${compose_dir}/docker-compose.yml" up -d app -if ! curl -fsS --retry 12 --retry-delay 2 --max-time 10 \ - http://127.0.0.1:3125/login >/dev/null; then +health_ok=false +attempt=1 +while [ "$attempt" -le 15 ]; do + if curl -fsS --max-time 10 http://127.0.0.1:3125/login >/dev/null; then + health_ok=true + break + fi + sleep 2 + attempt=$((attempt + 1)) +done + +if [ "$health_ok" != true ]; then docker stop ai-chatbot-app >/dev/null 2>&1 || true docker rm ai-chatbot-app >/dev/null 2>&1 || true failed_dir="${app_dir}/.next-failed-$(date +%Y%m%dT%H%M%S)"