Merge remote-tracking branch 'origin/codex/48-ai-template-integration-20260813' into codex/48-ai-template-integration-20260813

This commit is contained in:
root 2026-08-25 17:04:04 +08:00
commit 86c8e4995d
7 changed files with 223 additions and 8 deletions

View File

@ -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())
);
}

View File

@ -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<ImageProxyOptions, "width" | "quality" | "format">
): 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;
}

View File

@ -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(),

View File

@ -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<AiProvider, "type">;
};
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;
}

View File

@ -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/,

View File

@ -0,0 +1,64 @@
#!/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
docker rm 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
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)"
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}"

View File

@ -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?");
});
});