Merge remote-tracking branch 'origin/codex/48-ai-template-integration-20260813' into codex/48-ai-template-integration-20260813
This commit is contained in:
commit
86c8e4995d
19
lib/ai/model-visibility.ts
Normal file
19
lib/ai/model-visibility.ts
Normal 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())
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
51
lib/style-pro/model-availability.ts
Normal file
51
lib/style-pro/model-availability.ts
Normal 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;
|
||||
}
|
||||
@ -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/,
|
||||
|
||||
64
scripts/deploy-production-atomic.sh
Executable file
64
scripts/deploy-production-atomic.sh
Executable 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}"
|
||||
41
tests/routes/image-delivery-qiniu.test.ts
Normal file
41
tests/routes/image-delivery-qiniu.test.ts
Normal 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?");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user