Compare commits
22 Commits
codex/prod
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6053271352 | |||
| 6b7b24a86e | |||
| c431a574c8 | |||
| 1daf08a964 | |||
| 348cf4cf5d | |||
| a43a51adfc | |||
| 6ba5d9606d | |||
| 02b9575a47 | |||
| 349735601c | |||
| e837788d7a | |||
|
|
dfab95a654 | ||
| c35f5fc5a8 | |||
|
|
e6c6e19e5a | ||
| bb9d02a053 | |||
|
|
d9b06cf0b3 | ||
| 086c58fcc3 | |||
|
|
5a343b6392 | ||
| e6f07ed2ce | |||
|
|
db46983725 | ||
| ae4605d479 | |||
|
|
36efdfae53 | ||
| 10efb4c1e7 |
@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@ -9,7 +10,7 @@ import { Input } from "@/components/ui/input";
|
||||
import Image from "next/image";
|
||||
import { toast } from "sonner";
|
||||
import { requestLongTaskResult } from "@/lib/client/long-task";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||
import { formatBeijingTime } from "@/lib/utils";
|
||||
|
||||
@ -32,7 +33,7 @@ type Pagination = {
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
function buildGalleryDownloadFilename(url: string, alt: string) {
|
||||
function buildGalleryDownloadFilename(url: string, alt: string, contentType: string) {
|
||||
const rawUrl = String(url || "").trim();
|
||||
const rawAlt = String(alt || "").trim();
|
||||
const safeBase =
|
||||
@ -58,7 +59,7 @@ function buildGalleryDownloadFilename(url: string, alt: string) {
|
||||
}
|
||||
}
|
||||
|
||||
return `${safeBase}-${Date.now()}.${extension}`;
|
||||
return `${safeBase}-${Date.now()}.${getImageDownloadExtension(contentType) || extension}`;
|
||||
}
|
||||
|
||||
export default function GalleryPage() {
|
||||
@ -103,7 +104,7 @@ export default function GalleryPage() {
|
||||
};
|
||||
|
||||
const handleDownload = async (url: string, alt: string) => {
|
||||
const originalUrl = inferOriginalImageUrl(String(url || "").trim());
|
||||
const originalUrl = String(url || "").trim();
|
||||
if (!originalUrl) {
|
||||
toast.error("图片地址无效");
|
||||
return;
|
||||
@ -112,8 +113,14 @@ export default function GalleryPage() {
|
||||
try {
|
||||
toast.info("正在准备下载...");
|
||||
|
||||
// Validate the supplied URL before any legacy thumbnail inference can
|
||||
// remove signed storage transformation parameters.
|
||||
buildImageProxyUrl(originalUrl, { delivery: "original" });
|
||||
const parsed = new URL(originalUrl, window.location.origin);
|
||||
const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl);
|
||||
|
||||
const response = await fetch(
|
||||
buildImageProxyUrl(originalUrl, { delivery: "proxy" })
|
||||
buildImageProxyUrl(source, { delivery: "original" })
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`download failed: ${response.status}`);
|
||||
@ -122,7 +129,7 @@ export default function GalleryPage() {
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = buildGalleryDownloadFilename(originalUrl, alt);
|
||||
link.download = buildGalleryDownloadFilename(originalUrl, alt, blob.type);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
@ -130,14 +137,7 @@ export default function GalleryPage() {
|
||||
toast.success("图片已开始下载");
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
const fallbackLink = document.createElement("a");
|
||||
fallbackLink.href = originalUrl;
|
||||
fallbackLink.target = "_blank";
|
||||
fallbackLink.rel = "noopener noreferrer";
|
||||
document.body.appendChild(fallbackLink);
|
||||
fallbackLink.click();
|
||||
document.body.removeChild(fallbackLink);
|
||||
toast.error("下载失败,已为你打开原图,可直接另存为");
|
||||
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
|
||||
}
|
||||
};
|
||||
|
||||
@ -317,7 +317,12 @@ export default function GalleryPage() {
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex flex-wrap justify-center gap-6 px-4">
|
||||
{images.map((img) => (
|
||||
{images.map((img) => isRetiredStorageUrl(img.url) ? (
|
||||
<div key={img.id} className="flex h-[270px] w-[200px] shrink-0 flex-col justify-center rounded-lg border bg-muted p-4 text-center text-sm text-muted-foreground">
|
||||
<p>{RETIRED_STORAGE_MESSAGE}</p>
|
||||
<p className="mt-3 line-clamp-3 text-xs">{img.alt}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key={img.id}
|
||||
className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105"
|
||||
@ -326,7 +331,7 @@ export default function GalleryPage() {
|
||||
>
|
||||
<div className="relative w-full h-full">
|
||||
<Image
|
||||
src={img.thumbnailUrl || img.url}
|
||||
src={isRetiredStorageUrl(img.thumbnailUrl) ? img.url : img.thumbnailUrl || img.url}
|
||||
alt={img.alt}
|
||||
width={200}
|
||||
height={270}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
|
||||
|
||||
@ -12,7 +13,6 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
|
||||
"tripo3d.ai",
|
||||
"tripo3d.com",
|
||||
"worldlabs.ai",
|
||||
"s3.1080p.hk",
|
||||
"img.yydd.ai",
|
||||
"scqny.jiansun.vip",
|
||||
"blob.vercel-storage.com",
|
||||
@ -25,7 +25,6 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
|
||||
|
||||
const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [
|
||||
"bbyy.ai",
|
||||
"s3.1080p.hk",
|
||||
"img.yydd.ai",
|
||||
];
|
||||
|
||||
@ -165,6 +164,9 @@ async function handleRequest(request: Request, method: "GET" | "HEAD") {
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const rawUrl = String(searchParams.get("url") || "").trim();
|
||||
if (isRetiredStorageUrl(rawUrl)) {
|
||||
return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
||||
}
|
||||
if (!rawUrl) {
|
||||
return NextResponse.json({ success: false, error: "缺少 url 参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
@ -207,7 +207,11 @@ export async function PUT(
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return NextResponse.json({ success: true, data: saved });
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: saved,
|
||||
previousCanvasRevision: existingProject?.updatedAt.toISOString() ?? null,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("更新/创建画布失败:", error);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
|
||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||
import { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation";
|
||||
import { after, NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
@ -83,7 +84,11 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const requestOrigin = getRequestOrigin(request);
|
||||
const urls = resolveDownloadUrls(body, requestOrigin);
|
||||
const candidates = resolveDownloadUrls(body, requestOrigin);
|
||||
if (candidates.length && candidates.every(isRetiredStorageUrl)) {
|
||||
return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
||||
}
|
||||
const urls = candidates.filter(url => !isRetiredStorageUrl(url));
|
||||
const requestedUrls = [...urls];
|
||||
if (urls.length === 0) {
|
||||
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { generatedImage, user, aiModel } from "@/lib/db/schema";
|
||||
import { eq, desc, sql } from "drizzle-orm";
|
||||
import { and, eq, desc, sql } from "drizzle-orm";
|
||||
import { isRetiredStorageUrl, RETIRED_STORAGE_URL_PATTERN } from "@/lib/storage/retired-storage";
|
||||
import { cachedPublicJson } from "@/lib/public-api/performance";
|
||||
|
||||
const FEATURED_TIMEOUT_MS = 1800;
|
||||
@ -57,13 +58,17 @@ export async function GET(request: NextRequest) {
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
userDepartment: user.department,
|
||||
modelName: aiModel.displayName,
|
||||
modelName: sql<string | null>`(select ${aiModel.displayName} from ${aiModel}
|
||||
where ${aiModel.modelId} = ${generatedImage.modelId}
|
||||
order by ${aiModel.isEnabled} desc, ${aiModel.createdAt} desc, ${aiModel.id} asc limit 1)`,
|
||||
totalCount: sql<number>`count(*) over()::int`,
|
||||
})
|
||||
.from(generatedImage)
|
||||
.leftJoin(user, eq(generatedImage.userId, user.id))
|
||||
.leftJoin(aiModel, eq(generatedImage.modelId, aiModel.modelId))
|
||||
.where(eq(generatedImage.isFeatured, true))
|
||||
.where(and(
|
||||
eq(generatedImage.isFeatured, true),
|
||||
sql`${generatedImage.originalUrl} !~* ${RETIRED_STORAGE_URL_PATTERN}`,
|
||||
))
|
||||
.orderBy(desc(generatedImage.featuredAt))
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
@ -74,7 +79,7 @@ export async function GET(request: NextRequest) {
|
||||
return {
|
||||
images: featuredImages.map((img) => ({
|
||||
id: img.id,
|
||||
thumbnailUrl: img.thumbnailUrl,
|
||||
thumbnailUrl: isRetiredStorageUrl(img.thumbnailUrl) ? img.originalUrl : img.thumbnailUrl,
|
||||
originalUrl: img.originalUrl,
|
||||
imageSize: img.imageSize,
|
||||
prompt: img.prompt,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@ -127,13 +128,15 @@ function buildImageResponse(
|
||||
etag: string;
|
||||
vary?: string;
|
||||
cacheStatus?: "HIT" | "MISS";
|
||||
cacheControl?: string;
|
||||
attachment?: boolean;
|
||||
}
|
||||
) {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": params.contentType,
|
||||
"Content-Length": String(body.length),
|
||||
ETag: params.etag,
|
||||
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
|
||||
"Cache-Control": params.cacheControl || "public, max-age=31536000, immutable, stale-while-revalidate=86400",
|
||||
};
|
||||
if (params.vary) {
|
||||
headers.Vary = params.vary;
|
||||
@ -141,6 +144,11 @@ function buildImageResponse(
|
||||
if (params.cacheStatus) {
|
||||
headers["X-Image-Proxy-Cache"] = params.cacheStatus;
|
||||
}
|
||||
if (params.attachment) {
|
||||
headers["Content-Disposition"] = "attachment";
|
||||
headers["X-Content-Type-Options"] = "nosniff";
|
||||
headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
|
||||
}
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers,
|
||||
@ -259,6 +267,9 @@ function redirectToDirectImage(target: URL): NextResponse {
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const rawUrl = (searchParams.get("url") || "").trim();
|
||||
if (isRetiredStorageUrl(rawUrl)) {
|
||||
return NextResponse.json({ error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
||||
}
|
||||
if (!rawUrl) {
|
||||
return NextResponse.json({ error: "Missing url parameter" }, { status: 400 });
|
||||
}
|
||||
@ -285,8 +296,16 @@ export async function GET(request: NextRequest) {
|
||||
const directQuality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : null;
|
||||
const requestedFormat = searchParams.get("f");
|
||||
const forceProxy = searchParams.get("proxy") === "1";
|
||||
const original = searchParams.get("original") === "1";
|
||||
|
||||
if (!forceProxy) {
|
||||
if (original && (requestedWidth || requestedQuality || requestedFormat)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Original images cannot be transformed" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!forceProxy && !original) {
|
||||
const directImageTarget = resolveDirectImageTarget(
|
||||
request,
|
||||
target,
|
||||
@ -299,10 +318,12 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const cacheKey = `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`;
|
||||
const cacheKey = original
|
||||
? `${target.toString()}|original-v1`
|
||||
: `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`;
|
||||
const etag = buildEtag(cacheKey);
|
||||
const ifNoneMatch = request.headers.get("if-none-match");
|
||||
if (ifNoneMatch && ifNoneMatch === etag) {
|
||||
if (!original && ifNoneMatch && ifNoneMatch === etag) {
|
||||
return new NextResponse(null, {
|
||||
status: 304,
|
||||
headers: {
|
||||
@ -312,7 +333,7 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
const cached = getCachedImageResponse(cacheKey);
|
||||
const cached = original ? null : getCachedImageResponse(cacheKey);
|
||||
if (cached) {
|
||||
return buildImageResponse(cached.body, {
|
||||
contentType: cached.contentType,
|
||||
@ -327,7 +348,7 @@ export async function GET(request: NextRequest) {
|
||||
upstreamResponse = await fetch(target.toString(), {
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
headers: {
|
||||
Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@ -348,6 +369,14 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
||||
if (original) {
|
||||
return buildImageResponse(originalBuffer, {
|
||||
contentType,
|
||||
etag: `"${crypto.createHash("sha256").update(originalBuffer).digest("hex")}"`,
|
||||
cacheControl: "private, no-store",
|
||||
attachment: true,
|
||||
});
|
||||
}
|
||||
const isSvg = contentType.toLowerCase().includes("image/svg");
|
||||
const isGif = contentType.toLowerCase().includes("image/gif");
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { resolveSiteBranding } from "@/lib/site-branding";
|
||||
import { createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
@ -663,12 +664,12 @@ async function readScopedSiteSettings(keys: string[]) {
|
||||
}
|
||||
|
||||
function createBrandingSettings(settings: Record<string, unknown> | undefined) {
|
||||
return {
|
||||
return resolveSiteBranding({
|
||||
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
|
||||
siteLogo: settings?.siteLogo || null,
|
||||
siteLogoDark: settings?.siteLogoDark || null,
|
||||
favicon: settings?.favicon || null,
|
||||
};
|
||||
siteLogo: String(settings?.siteLogo || ""),
|
||||
siteLogoDark: String(settings?.siteLogoDark || ""),
|
||||
favicon: String(settings?.favicon || ""),
|
||||
});
|
||||
}
|
||||
|
||||
async function getScopedPublicSiteSettings(
|
||||
@ -1541,7 +1542,7 @@ export async function GET(request: NextRequest) {
|
||||
};
|
||||
|
||||
const response = NextResponse.json({
|
||||
...baseSettings,
|
||||
...resolveSiteBranding(baseSettings),
|
||||
lovartPromoText:
|
||||
baseSettings.lovartPromoText ||
|
||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
|
||||
import { CanvasRemoteOperationContext } from "@/components/canvas/CanvasRemoteOperationContext";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
@ -3946,20 +3947,18 @@ export default function CanvasEditorLegacyPage({
|
||||
loadCanvas(normalized.canvasData);
|
||||
setProjectTitle(nextTitle);
|
||||
|
||||
const [saveResult, updateResult] = await Promise.all([
|
||||
canvasService.saveCanvasState(
|
||||
const saveResult = await canvasService.saveCanvasState(
|
||||
id,
|
||||
normalized.canvasData,
|
||||
activeCanvasWorkspaceType
|
||||
),
|
||||
canvasService.updateProject(
|
||||
);
|
||||
const updateResult = await canvasService.updateProject(
|
||||
id,
|
||||
{
|
||||
title: nextTitle,
|
||||
},
|
||||
activeCanvasWorkspaceType
|
||||
),
|
||||
]);
|
||||
);
|
||||
|
||||
if (!saveResult.success) {
|
||||
throw new Error(saveResult.error || "导入后保存失败");
|
||||
@ -5080,9 +5079,10 @@ export default function CanvasEditorLegacyPage({
|
||||
if (!canvasSaveQueue.current) canvasSaveQueue.current = createCanvasSaveQueue();
|
||||
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const canvasPersistenceScope = useRef(id);
|
||||
const canvasRemoteOperationActive = useRef(false);
|
||||
canvasPersistenceScope.current = id;
|
||||
const persistCanvasState = useCallback(
|
||||
(reason: "autosave" | "delete" | "beforeunload") => canvasSaveQueue.current!.enqueue(async () => {
|
||||
const persistCanvasStateNow = useCallback(
|
||||
async (reason: "autosave" | "delete" | "beforeunload") => {
|
||||
if (canvasPersistenceScope.current !== id) return false;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
@ -5122,10 +5122,37 @@ export default function CanvasEditorLegacyPage({
|
||||
} finally {
|
||||
if (canvasPersistenceScope.current === id) setIsSaving(false);
|
||||
}
|
||||
}),
|
||||
},
|
||||
[activeCanvasWorkspaceType, buildPersistedCanvasData, id]
|
||||
);
|
||||
|
||||
const persistCanvasState = useCallback(
|
||||
(reason: "autosave" | "delete" | "beforeunload") =>
|
||||
canvasSaveQueue.current!.enqueue(() => persistCanvasStateNow(reason)),
|
||||
[persistCanvasStateNow]
|
||||
);
|
||||
|
||||
const runCanvasRemoteOperation = useCallback(
|
||||
(operation: () => Promise<void>) => {
|
||||
if (canvasAutosaveTimer.current) {
|
||||
clearTimeout(canvasAutosaveTimer.current);
|
||||
canvasAutosaveTimer.current = null;
|
||||
}
|
||||
return canvasSaveQueue.current!.enqueueOperation(
|
||||
() => persistCanvasStateNow("autosave"),
|
||||
async () => {
|
||||
canvasRemoteOperationActive.current = true;
|
||||
try {
|
||||
await operation();
|
||||
} finally {
|
||||
canvasRemoteOperationActive.current = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[persistCanvasStateNow]
|
||||
);
|
||||
|
||||
const persistCanvasBeforeAgent = useCallback(() => {
|
||||
// Flush the pending local edit once instead of racing its scheduled autosave.
|
||||
if (canvasAutosaveTimer.current) {
|
||||
@ -5168,6 +5195,7 @@ export default function CanvasEditorLegacyPage({
|
||||
const refreshIfExternallyUpdated = async () => {
|
||||
if (
|
||||
cancelled ||
|
||||
canvasRemoteOperationActive.current ||
|
||||
document.visibilityState !== "visible" ||
|
||||
isSaving ||
|
||||
externalCanvasConflictRef.current ||
|
||||
@ -5197,7 +5225,7 @@ export default function CanvasEditorLegacyPage({
|
||||
}
|
||||
|
||||
const result = await canvasService.getProject(id);
|
||||
if (cancelled || !result.success || !result.data) return;
|
||||
if (cancelled || canvasRemoteOperationActive.current || !result.success || !result.data) return;
|
||||
useCanvasStore.getState().markAgentOperationRefresh();
|
||||
hydrateCanvasProject(result.data);
|
||||
lastLocalCanvasMutationAtRef.current = Date.now();
|
||||
@ -5320,11 +5348,11 @@ export default function CanvasEditorLegacyPage({
|
||||
|
||||
try {
|
||||
if (autoThumbnailSyncedRef.current !== autoTitleKey) {
|
||||
await canvasService.updateProject(
|
||||
await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
|
||||
id,
|
||||
{ thumbnail: imageSrc },
|
||||
activeCanvasWorkspaceType
|
||||
);
|
||||
));
|
||||
autoThumbnailSyncedRef.current = autoTitleKey;
|
||||
}
|
||||
|
||||
@ -5340,11 +5368,11 @@ export default function CanvasEditorLegacyPage({
|
||||
const data = await res.json();
|
||||
if (data.title) {
|
||||
setProjectTitle(data.title);
|
||||
await canvasService.updateProject(
|
||||
await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
|
||||
id,
|
||||
{ title: data.title },
|
||||
activeCanvasWorkspaceType
|
||||
);
|
||||
));
|
||||
toast.success(`${workspaceLabel}已自动命名为:${data.title}`);
|
||||
}
|
||||
} catch (error) {
|
||||
@ -5395,11 +5423,11 @@ export default function CanvasEditorLegacyPage({
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await canvasService.updateProject(
|
||||
const result = await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
|
||||
id,
|
||||
{ title: editTitle.trim() },
|
||||
activeCanvasWorkspaceType
|
||||
);
|
||||
));
|
||||
if (result.success) {
|
||||
setProjectTitle(editTitle.trim());
|
||||
} else {
|
||||
@ -10281,6 +10309,7 @@ export default function CanvasEditorLegacyPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<CanvasRemoteOperationContext.Provider value={runCanvasRemoteOperation}>
|
||||
<TooltipProvider>
|
||||
<div
|
||||
data-canvas-panel-open={isPanelOpen ? "true" : "false"}
|
||||
@ -12079,5 +12108,6 @@ export default function CanvasEditorLegacyPage({
|
||||
{projectLoadingBadge}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</CanvasRemoteOperationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
|
||||
import { getSceneRunResult } from "@/lib/agents/scene-run-recovery";
|
||||
import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import {
|
||||
@ -4169,6 +4170,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
? "agent-anchor-materials"
|
||||
: "agent-anchor-generate";
|
||||
|
||||
const [finishedSceneRequestId, setFinishedSceneRequestId] = useState<string | null>(null);
|
||||
const {
|
||||
messages,
|
||||
setMessages,
|
||||
@ -4189,7 +4191,11 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
void refreshHistory();
|
||||
}
|
||||
},
|
||||
onFinish() { void refreshHistory(); },
|
||||
onFinish({ messages: finishedMessages }) {
|
||||
const lastUser = finishedMessages.filter((message) => message.role === "user").at(-1);
|
||||
setFinishedSceneRequestId(lastUser?.id || null);
|
||||
void refreshHistory();
|
||||
},
|
||||
experimental_throttle: 100,
|
||||
generateId: generateUUID,
|
||||
transport: new DefaultChatTransport({
|
||||
@ -5482,6 +5488,17 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
setActiveResultModeType(currentResultModeType);
|
||||
}, [currentResultModeType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingGeneration?.requestMessageId || pendingGeneration.modeType !== "scene_generation" || transportIsGenerating) return;
|
||||
const result = getSceneRunResult(messages, pendingGeneration.requestMessageId);
|
||||
const persisted = getSceneRunResult(historyMessages, pendingGeneration.requestMessageId);
|
||||
if (!result || (finishedSceneRequestId !== pendingGeneration.requestMessageId && !persisted?.count)) return;
|
||||
setPendingGeneration(null);
|
||||
if (result.count < pendingGeneration.expectedCount) {
|
||||
toast.warning(`本次生成已结束,收到 ${result.count}/${pendingGeneration.expectedCount} 张图片。缺少的图片未继续生成,可调整张数后重新提交。`);
|
||||
}
|
||||
}, [pendingGeneration, transportIsGenerating, finishedSceneRequestId, messages, historyMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return;
|
||||
// The backend has settled every requested card; an open SSE connection is no longer work.
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { advancePastAtomicRanges, scanMarkdownImageStreaming } from "@/lib/canvas/markdown-image-streaming";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
|
||||
@ -1917,6 +1918,7 @@ type CanvasTypewriterTextProps = {
|
||||
className?: string;
|
||||
renderMarkdown?: boolean;
|
||||
preserveMarkdownTables?: boolean;
|
||||
isMarkdownStreaming?: boolean;
|
||||
enablePdfDownload?: boolean;
|
||||
pdfFileName?: string;
|
||||
pdfSiteLogo?: string | null;
|
||||
@ -2357,20 +2359,6 @@ function getMarkdownTableBlockRanges(content: string): MarkdownTableBlockRange[]
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function advanceTypewriterCursor(
|
||||
contentLength: number,
|
||||
targetCursor: number,
|
||||
tableRanges: MarkdownTableBlockRange[]
|
||||
) {
|
||||
const clampedCursor = Math.min(contentLength, targetCursor);
|
||||
for (const range of tableRanges) {
|
||||
if (clampedCursor >= range.start && clampedCursor < range.end) {
|
||||
return Math.min(contentLength, range.end);
|
||||
}
|
||||
}
|
||||
return clampedCursor;
|
||||
}
|
||||
|
||||
function CanvasTypewriterCursor() {
|
||||
return (
|
||||
<span
|
||||
@ -2387,6 +2375,7 @@ function CanvasTypewriterText({
|
||||
className,
|
||||
renderMarkdown = false,
|
||||
preserveMarkdownTables = false,
|
||||
isMarkdownStreaming = false,
|
||||
enablePdfDownload = false,
|
||||
pdfFileName,
|
||||
pdfSiteLogo,
|
||||
@ -2413,12 +2402,15 @@ function CanvasTypewriterText({
|
||||
const tableRanges = preserveMarkdownTables
|
||||
? getMarkdownTableBlockRanges(content)
|
||||
: [];
|
||||
const atomicRanges = renderMarkdown
|
||||
? [...tableRanges, ...scanMarkdownImageStreaming(content).ranges]
|
||||
: tableRanges;
|
||||
activeRef.current = true;
|
||||
onActiveChange?.(typingKey, true);
|
||||
setDisplayText("");
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
cursor = advanceTypewriterCursor(content.length, cursor + 1, tableRanges);
|
||||
cursor = advancePastAtomicRanges(content.length, cursor + 1, atomicRanges);
|
||||
if (cursor >= content.length) {
|
||||
setDisplayText(content);
|
||||
window.clearInterval(timer);
|
||||
@ -2439,7 +2431,7 @@ function CanvasTypewriterText({
|
||||
onActiveChange?.(typingKey, false);
|
||||
}
|
||||
};
|
||||
}, [content, enabled, onActiveChange, onComplete, preserveMarkdownTables, typingKey]);
|
||||
}, [content, enabled, onActiveChange, onComplete, preserveMarkdownTables, renderMarkdown, typingKey]);
|
||||
|
||||
const showCursor = enabled && Boolean(content) && displayText.length < content.length;
|
||||
|
||||
@ -2448,6 +2440,7 @@ function CanvasTypewriterText({
|
||||
<div className="w-full min-w-0">
|
||||
<Response
|
||||
className={className}
|
||||
isMarkdownStreaming={isMarkdownStreaming}
|
||||
enablePdfDownload={enablePdfDownload}
|
||||
pdfFileName={pdfFileName}
|
||||
pdfSiteLogo={pdfSiteLogo}
|
||||
@ -16577,6 +16570,10 @@ export function CanvasChatPanel({
|
||||
(isClawdMode || isClawdChatMessage(message));
|
||||
const isLatestClawAssistantMessage =
|
||||
message.id === latestClawAssistantMessageId;
|
||||
const isMessageMarkdownStreaming =
|
||||
message.role === "assistant" &&
|
||||
index === renderableVisibleMessages.length - 1 &&
|
||||
(isLoading || (isClawAssistantMessage && isClawdSubmitting));
|
||||
const openClawArtifacts = isClawAssistantMessage
|
||||
? getOpenClawMessageArtifacts(message)
|
||||
: [];
|
||||
@ -16904,6 +16901,7 @@ export function CanvasChatPanel({
|
||||
<Response
|
||||
key={key}
|
||||
className="break-words [overflow-wrap:anywhere]"
|
||||
isMarkdownStreaming={isMessageMarkdownStreaming}
|
||||
>
|
||||
{text}
|
||||
</Response>
|
||||
@ -16965,6 +16963,7 @@ export function CanvasChatPanel({
|
||||
className="break-words [overflow-wrap:anywhere]"
|
||||
renderMarkdown
|
||||
preserveMarkdownTables={shouldTypeClawAssistantMessage}
|
||||
isMarkdownStreaming={isMessageMarkdownStreaming}
|
||||
enablePdfDownload={message.role === "assistant" && !shouldAnimateAnalysis}
|
||||
pdfFileName={`Claw分析报告-${message.id}`}
|
||||
pdfSiteLogo={siteSettings?.siteLogo || siteSettings?.siteLogoDark || null}
|
||||
@ -16987,6 +16986,7 @@ export function CanvasChatPanel({
|
||||
className="break-words [overflow-wrap:anywhere]"
|
||||
renderMarkdown
|
||||
preserveMarkdownTables={shouldTypeClawAssistantMessage}
|
||||
isMarkdownStreaming={isMessageMarkdownStreaming}
|
||||
enablePdfDownload={message.role === "assistant" && !shouldAnimatePlainText}
|
||||
pdfFileName={`Claw分析报告-${message.id}`}
|
||||
pdfSiteLogo={siteSettings?.siteLogo || siteSettings?.siteLogoDark || null}
|
||||
|
||||
11
components/canvas/CanvasRemoteOperationContext.tsx
Normal file
11
components/canvas/CanvasRemoteOperationContext.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export const CanvasRemoteOperationContext = createContext<
|
||||
((operation: () => Promise<void>) => Promise<void>) | null
|
||||
>(null);
|
||||
|
||||
export function useCanvasRemoteOperation() {
|
||||
return useContext(CanvasRemoteOperationContext);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||
|
||||
import {
|
||||
Box,
|
||||
@ -231,6 +232,8 @@ import {
|
||||
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
||||
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
||||
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||
import { executeCanvasToolbarCapability } from "@/lib/client/canvas-toolbar-capability";
|
||||
import { useCanvasRemoteOperation } from "@/components/canvas/CanvasRemoteOperationContext";
|
||||
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
|
||||
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
@ -2077,7 +2080,7 @@ export function ImageNode({
|
||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||
const projectId = useCanvasStore((state) => state.projectId);
|
||||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
||||
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
|
||||
const runCanvasRemoteOperation = useCanvasRemoteOperation();
|
||||
const addPendingChatAttachment = useCanvasStore(
|
||||
(state) => state.addPendingChatAttachment
|
||||
);
|
||||
@ -6707,7 +6710,7 @@ export function ImageNode({
|
||||
const executeSharedToolbarCapability = async (
|
||||
action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split"
|
||||
) => {
|
||||
if (!projectId) {
|
||||
if (!projectId || !runCanvasRemoteOperation) {
|
||||
toast.error("画布还在加载,请稍后再试");
|
||||
return;
|
||||
}
|
||||
@ -6726,21 +6729,7 @@ export function ImageNode({
|
||||
projectId,
|
||||
source: "toolbar",
|
||||
});
|
||||
const response = await fetch(`/api/canvas/${projectId}/capabilities`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(command),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.success !== true) {
|
||||
throw new Error(payload?.error || `${capability.title}失败`);
|
||||
}
|
||||
|
||||
const projectResult = await canvasService.getProject(projectId);
|
||||
if (!projectResult.success || !projectResult.data?.canvasData) {
|
||||
throw new Error(projectResult.error || "结果已生成,但画布同步失败");
|
||||
}
|
||||
loadCanvas(projectResult.data.canvasData);
|
||||
await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command));
|
||||
toast.success(`${capability.title}完成`);
|
||||
} catch (error) {
|
||||
const message =
|
||||
@ -8439,7 +8428,11 @@ export function ImageNode({
|
||||
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
|
||||
{data.src ? (
|
||||
<div className="relative h-full w-full">
|
||||
{!shouldRequestDisplayImage ? (
|
||||
{isRetiredStorageUrl(data.src) ? (
|
||||
<div role="status" className="flex h-full w-full items-center justify-center bg-muted p-4 text-center text-sm text-muted-foreground">
|
||||
{RETIRED_STORAGE_MESSAGE}
|
||||
</div>
|
||||
) : !shouldRequestDisplayImage ? (
|
||||
<ImageNodeDeferredLoadState />
|
||||
) : isCompareMode && hasCompareSource ? (
|
||||
<div
|
||||
|
||||
@ -16,6 +16,8 @@ import {
|
||||
} from "@/components/elements/openclaw-chart-card";
|
||||
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments";
|
||||
import { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@ -26,6 +28,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
type ResponseProps = ComponentProps<typeof Streamdown> & {
|
||||
collapsibleImageAnalysis?: boolean;
|
||||
isMarkdownStreaming?: boolean;
|
||||
enablePdfDownload?: boolean;
|
||||
pdfFileName?: string;
|
||||
pdfSiteLogo?: string | null;
|
||||
@ -362,43 +365,6 @@ function normalizeMarkdownContent(value: string) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function splitMarkdownImages(content: string) {
|
||||
const segments: Array<
|
||||
| { type: "text"; value: string }
|
||||
| { type: "image"; alt?: string; src: string }
|
||||
> = [];
|
||||
|
||||
const imageRegex = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = imageRegex.exec(content)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({
|
||||
type: "text",
|
||||
value: content.slice(lastIndex, match.index),
|
||||
});
|
||||
}
|
||||
|
||||
segments.push({
|
||||
type: "image",
|
||||
alt: match[1],
|
||||
src: match[2],
|
||||
});
|
||||
|
||||
lastIndex = imageRegex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < content.length) {
|
||||
segments.push({
|
||||
type: "text",
|
||||
value: content.slice(lastIndex),
|
||||
});
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function isLikelyImageUrl(value?: string | null) {
|
||||
return IMAGE_URL_REGEX.test(String(value || "").trim());
|
||||
}
|
||||
@ -797,6 +763,7 @@ function ResponseReportTableDownloadButton({
|
||||
export const Response = ({
|
||||
className,
|
||||
collapsibleImageAnalysis = false,
|
||||
isMarkdownStreaming = false,
|
||||
enablePdfDownload = false,
|
||||
pdfFileName,
|
||||
pdfSiteLogo,
|
||||
@ -1061,7 +1028,7 @@ export const Response = ({
|
||||
|
||||
const rawText =
|
||||
typeof props.children === "string"
|
||||
? normalizeMarkdownContent(props.children)
|
||||
? normalizeMarkdownContent(stableStreamingMarkdown(props.children, isMarkdownStreaming))
|
||||
: null;
|
||||
const shouldShowPdfDownload =
|
||||
Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText));
|
||||
@ -1091,7 +1058,7 @@ export const Response = ({
|
||||
);
|
||||
};
|
||||
|
||||
if (rawText) {
|
||||
if (rawText !== null) {
|
||||
const analysisSection = collapsibleImageAnalysis
|
||||
? extractImageAnalysisSection(rawText)
|
||||
: null;
|
||||
|
||||
68
components/home/lovart/home-image.tsx
Normal file
68
components/home/lovart/home-image.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { isRetiredStorageUrl } from "@/lib/storage/retired-storage";
|
||||
|
||||
type HomeImageProps = {
|
||||
sources: Array<string | null | undefined>;
|
||||
alt: string;
|
||||
className?: string;
|
||||
imageClassName?: string;
|
||||
fallback?: ReactNode;
|
||||
loading?: "lazy" | "eager";
|
||||
onUnavailable?: () => void;
|
||||
};
|
||||
|
||||
function imageCandidates(source: string) {
|
||||
// These two domains are documented aliases for the same gongxiangpan store.
|
||||
// Do not rewrite other buckets, providers, signed URLs or user-supplied hosts.
|
||||
try {
|
||||
const url = new URL(source);
|
||||
if (url.origin === "https://s3.yydd.ai" &&
|
||||
url.pathname.startsWith("/gongxiangpan/") && !url.search && !url.hash) {
|
||||
return [`https://s3.jiansun.vip${url.pathname}`, source];
|
||||
}
|
||||
} catch { /* Relative site assets need no host rewrite. */ }
|
||||
return [source];
|
||||
}
|
||||
|
||||
/** Keep failed image elements out of view, trying each distinct source once. */
|
||||
export function HomeImage(props: HomeImageProps) {
|
||||
const sources = [...new Set(props.sources
|
||||
.filter((url): url is string => Boolean(url?.trim()) && !isRetiredStorageUrl(url))
|
||||
.flatMap(imageCandidates))];
|
||||
return <HomeImageAttempt key={JSON.stringify(sources)} {...props} sources={sources} />;
|
||||
}
|
||||
|
||||
function HomeImageAttempt({
|
||||
sources, alt, className, imageClassName, fallback, loading = "lazy", onUnavailable,
|
||||
}: Omit<HomeImageProps, "sources"> & { sources: string[] }) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const source = sources[index];
|
||||
return (
|
||||
<span className={`relative block overflow-hidden ${className || ""}`}>
|
||||
{!loaded && (
|
||||
<span className={fallback ? "block" : "block aspect-[4/5] rounded-[12px] bg-[#e8e8e8] dark:bg-[#1b1c20]"}>
|
||||
{fallback}
|
||||
</span>
|
||||
)}
|
||||
{source ? (
|
||||
<img
|
||||
key={source}
|
||||
src={source}
|
||||
alt={alt}
|
||||
loading={loading}
|
||||
decoding="async"
|
||||
className={`${imageClassName || ""} ${loaded ? "" : "absolute inset-0 opacity-0"}`}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => {
|
||||
setLoaded(false);
|
||||
setIndex(index + 1);
|
||||
if (index + 1 >= sources.length) onUnavailable?.();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
||||
import { Gem, Heart, Loader2, PlayCircle } from "lucide-react";
|
||||
import type { Ref } from "react";
|
||||
import { useState, type Ref } from "react";
|
||||
import { HomeImage } from "./home-image";
|
||||
import type { FeaturedImage } from "./types";
|
||||
|
||||
type LovartFeaturedGalleryProps = {
|
||||
@ -30,6 +31,10 @@ function getFeaturedPreviewUrl(item: FeaturedImage) {
|
||||
return `${source}?imageView2/2/w/900/q/78`;
|
||||
}
|
||||
|
||||
function imageIdentity(item: FeaturedImage) {
|
||||
return JSON.stringify([item.id, item.thumbnailUrl, item.originalUrl]);
|
||||
}
|
||||
|
||||
export function LovartFeaturedGallery({
|
||||
onOpenProjects,
|
||||
onOpenMaterials,
|
||||
@ -42,6 +47,13 @@ export function LovartFeaturedGallery({
|
||||
isLoadingMoreFeatured,
|
||||
setActiveCategory,
|
||||
}: LovartFeaturedGalleryProps) {
|
||||
const [unavailable, setUnavailable] = useState<Set<string>>(() => new Set());
|
||||
const [retryVersion, setRetryVersion] = useState(0);
|
||||
const uniqueItems = [...new Map(featuredItems.map((item) => [item.id, item])).values()];
|
||||
const visibleItems = uniqueItems.filter((item) =>
|
||||
Boolean(item.thumbnailUrl || item.originalUrl),
|
||||
);
|
||||
const unavailableCount = uniqueItems.filter(item => unavailable.has(imageIdentity(item))).length;
|
||||
return (
|
||||
<section
|
||||
ref={featuredSectionRef}
|
||||
@ -88,44 +100,48 @@ export function LovartFeaturedGallery({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{featuredItems.length === 0 ? (
|
||||
{unavailableCount > 0 && (
|
||||
<div role="status" className="mt-4 flex items-center gap-3 text-xs text-black/50 dark:text-white/50">
|
||||
<span>{unavailableCount} 张灵感图片加载失败,请重试。</span>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 underline underline-offset-4 hover:text-black dark:hover:text-white"
|
||||
onClick={() => {
|
||||
setUnavailable(new Set());
|
||||
setRetryVersion((version) => version + 1);
|
||||
}}
|
||||
>
|
||||
重试加载
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{visibleItems.length === 0 ? (
|
||||
<div className="mt-[22px] rounded-[10px] border border-dashed border-black/15 bg-[#f7f7f7] p-10 text-center text-[14px] text-black/35 dark:border-white/12 dark:bg-[#141416] dark:text-white/35">
|
||||
{isLoadingFeatured
|
||||
? "灵感内容加载中..."
|
||||
: "当前没有可展示的灵感数据。"}
|
||||
: unavailableCount > 0 ? "图片暂时无法加载,请稍后重试。" : "当前没有可展示的灵感数据。"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 columns-1 gap-4 sm:columns-2 lg:columns-4">
|
||||
{featuredItems.map((item, index) => (
|
||||
{visibleItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
key={`${imageIdentity(item)}:${retryVersion}`}
|
||||
type="button"
|
||||
disabled={unavailable.has(imageIdentity(item))}
|
||||
onClick={() =>
|
||||
window.open(item.originalUrl || item.thumbnailUrl, "_blank")
|
||||
}
|
||||
className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left"
|
||||
>
|
||||
<img
|
||||
src={getFeaturedPreviewUrl(item)}
|
||||
{unavailable.has(imageIdentity(item)) ? (
|
||||
<div className="flex aspect-[4/5] items-center justify-center rounded-[12px] bg-muted p-4 text-sm text-muted-foreground">图片加载失败</div>
|
||||
) : <HomeImage
|
||||
sources={[getFeaturedPreviewUrl(item), item.thumbnailUrl, item.originalUrl]}
|
||||
alt={item.prompt || "灵感图"}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
const image = event.currentTarget;
|
||||
const fallbackUrl = item.originalUrl || item.thumbnailUrl;
|
||||
|
||||
if (
|
||||
!fallbackUrl ||
|
||||
image.dataset.fallbackApplied === "true"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
image.dataset.fallbackApplied = "true";
|
||||
image.src = fallbackUrl;
|
||||
}}
|
||||
className="block h-auto w-full rounded-[12px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
|
||||
/>
|
||||
onUnavailable={() => setUnavailable((previous) => new Set(previous).add(imageIdentity(item)))}
|
||||
imageClassName="block h-auto w-full rounded-[12px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
|
||||
/>}
|
||||
<div className="mt-[8px] flex h-[22px] items-center justify-between text-[11px] font-normal leading-none text-[#b8b8b8] dark:text-white/45">
|
||||
<div className="flex min-w-0 items-center gap-[6px] pr-2">
|
||||
<span className="flex h-[16px] w-[16px] shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#d8d8d8] text-[8px] font-medium text-white">
|
||||
@ -138,11 +154,11 @@ export function LovartFeaturedGallery({
|
||||
<div className="inline-flex shrink-0 items-center gap-[7px] text-[11px] leading-none">
|
||||
<span className="inline-flex items-center gap-[4px]">
|
||||
<PlayCircle className="h-[11px] w-[11px]" strokeWidth={2} />
|
||||
{(1200 + index * 37).toLocaleString()}
|
||||
{(1200 + featuredItems.indexOf(item) * 37).toLocaleString()}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-[4px]">
|
||||
<Heart className="h-[11px] w-[11px]" strokeWidth={2} />
|
||||
{(20 + (index % 9) * 11).toLocaleString()}
|
||||
{(20 + (featuredItems.indexOf(item) % 9) * 11).toLocaleString()}
|
||||
</span>
|
||||
<Gem className="h-[11px] w-[11px]" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { LovartHomeSidebarItemKey } from "@/lib/home/lovart-sidebar-config";
|
||||
import type { NavLink, TutorialVideo } from "./types";
|
||||
import { HomeImage } from "./home-image";
|
||||
|
||||
type SidebarItem = {
|
||||
iconUrl?: string | null;
|
||||
@ -183,17 +184,18 @@ export function LovartSidebar({
|
||||
onClick={onHome}
|
||||
title={siteName}
|
||||
>
|
||||
{siteLogo ? (
|
||||
<img
|
||||
src={siteLogo}
|
||||
<HomeImage
|
||||
sources={[siteLogo]}
|
||||
alt={siteName}
|
||||
loading="eager"
|
||||
className="h-7 w-7 rounded-full object-cover"
|
||||
imageClassName="h-7 w-7 rounded-full object-cover"
|
||||
fallback={
|
||||
<span aria-hidden="true" className="flex h-7 w-7 items-center justify-center rounded-full bg-black text-[12px] font-semibold text-white dark:bg-white dark:text-black">
|
||||
{siteName.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-black text-[12px] font-semibold text-white dark:bg-white dark:text-black">
|
||||
{siteName.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{siteName}</TooltipContent>
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D, Droplet } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||
|
||||
const ImageAnnotator = dynamic(
|
||||
() => import("./image-annotator").then((module) => module.ImageAnnotator),
|
||||
@ -237,34 +238,24 @@ export function ImagePreview({
|
||||
toast.error("下载失败,请重试");
|
||||
return;
|
||||
}
|
||||
const filename = `generated-image-${Date.now()}.jpg`;
|
||||
const filenameBase = `generated-image-${Date.now()}`;
|
||||
try {
|
||||
const response = await fetch(
|
||||
buildImageProxyUrl(safeOriginalUrl, { delivery: "proxy" })
|
||||
buildImageProxyUrl(safeOriginalUrl, { delivery: "original" })
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载失败(${response.status})`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const extension = getImageDownloadExtension(blob.type);
|
||||
const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
triggerBrowserDownload(url, filename);
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success("下载成功");
|
||||
} catch (error) {
|
||||
console.error("下载失败:", error);
|
||||
try {
|
||||
triggerBrowserDownload(safeOriginalUrl, filename);
|
||||
toast.message("代理下载失败,已切换为原图直链下载");
|
||||
} catch (fallbackError) {
|
||||
console.error("原图直链下载失败:", fallbackError);
|
||||
try {
|
||||
triggerBrowserDownload(safeOriginalUrl, undefined, { openInNewTab: true });
|
||||
toast.message("已打开原图,可直接另存为");
|
||||
} catch (openError) {
|
||||
console.error("打开原图失败:", openError);
|
||||
toast.error("下载失败,请重试");
|
||||
}
|
||||
}
|
||||
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
|
||||
}
|
||||
};
|
||||
|
||||
@ -316,6 +307,10 @@ export function ImagePreview({
|
||||
}
|
||||
};
|
||||
|
||||
if (isRetiredStorageUrl(src) || isRetiredStorageUrl(original)) {
|
||||
return <div role="status" className="my-2 rounded-lg border bg-muted p-4 text-sm text-muted-foreground">{RETIRED_STORAGE_MESSAGE}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 缩略图(显示压缩后的缩略图)*/}
|
||||
|
||||
@ -60,10 +60,22 @@ python scripts/production48.py rollback --release <发布ID>
|
||||
|
||||
`activate` 会重新核对生产版本和任务状态,拒绝在最近一小时仍有未结束生成任务时切换。
|
||||
更早的历史挂起记录只写入发布回执,不会自动修改数据库中的状态。
|
||||
它保存旧 PM2 配置和启动快照,然后仅更新 `ai-chatbot` 的工作目录并重启这个应用,
|
||||
它保存旧 PM2 配置和启动快照,然后仅重建 `ai-chatbot` 这一项 PM2 定义,
|
||||
让新版本的工作目录、可执行路径和环境变量全部生效,不改变其他应用,
|
||||
因此会有短暂切换窗口。健康检查同时核对进程目录、构建 ID、首页和路由。
|
||||
检查失败自动恢复旧配置并再次验活;通过后执行 `pm2 save`,让重启后使用已验收版本。
|
||||
|
||||
PM2 的普通 restart 会保留旧工作目录和程序路径,因此不能用它替代本流程的单应用定义重建。
|
||||
若一次切换已经明确回滚并验证原版本健康,修复发布工具后,可保留已校验制品并显式重试:
|
||||
|
||||
```powershell
|
||||
python scripts/production48.py activate --release <发布ID> --retry-rolled-back
|
||||
```
|
||||
|
||||
只有日志阶段为 `rolled-back` 且当前工作目录/构建目录仍匹配原版本才允许重试。
|
||||
旧尝试日志另外保留。运行制品的 `sourceCommit` 始终指向当时实际构建的 main 提交,
|
||||
后续工具修复不改写这个来源;`operatorCommit` 单独记录执行切换的维护工具版本。
|
||||
|
||||
## 运行资料与边界
|
||||
|
||||
每个发布目录包含构建机按锁文件安装的完整运行依赖和 public 源码资源。
|
||||
|
||||
33
docs/retired-storage.md
Normal file
33
docs/retired-storage.md
Normal file
@ -0,0 +1,33 @@
|
||||
# 旧存储停用与七牛上传核查
|
||||
|
||||
## s3.1080p.hk
|
||||
|
||||
该存储已永久停用,文件不可恢复。它与 2026 年 10 月 14 日迁入七牛的阿里云 OSS 资源不是同一套存储。
|
||||
|
||||
- 首页精选只展示原文件仍在有效存储上的记录,统计和分页按同一条件计算。
|
||||
- 历史生成记录、聊天、画布节点保留。旧图片显示永久失效说明,不再发起下载、归档或上传到旧桶。
|
||||
- 单图下载和代理返回 HTTP 410 / `storage_retired`;批量下载将对应文件记为失败,不重试旧源。
|
||||
- 其他存储的临时加载失败仍展示错误卡片和重试入口。
|
||||
- 黑色 Logo 为 `public/brand/logo-black.png`;白色版使用 SVG 色彩滤镜保留同一 PNG 的透明度和轮廓。两者随应用发布,不依赖对象存储。
|
||||
|
||||
一次性配置整理:在生产运行目录执行 `node scripts/storage/retire-1080p-storage.cjs` 预览,增加 `--apply` 停用旧配置、取消失效图片的精选标记并更新 Logo。执行前将原字段保存至 `/var/lib/ai-chatbot/retired-storage-20261103/`。该操作不删除图片记录或对象文件。
|
||||
|
||||
2026-11-03 核查:旧源生成记录 67,267 条;精选 65 条中 59 条来自旧源;旧源图片归档任务为 0。默认上传存储为七牛 `gongxiangpan-sc`。
|
||||
|
||||
## 2026-10-14 上传峰值
|
||||
|
||||
统计时区 UTC+8,GB 按十进制计算。数据来自七牛只读上传统计、对象清单和生产机迁移记录。
|
||||
|
||||
| 范围 | 对象数 | 字节数 | GB |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 七牛当天上传流量 | — | 825,113,404,030 | 825.11 |
|
||||
| 当天写入且仍存在的 `legacy-oss/demo-sparkai/` | 409,821 | 812,788,883,061 | 812.79 |
|
||||
| 当天写入且仍存在的 `legacy-s3/gongxiangpan/` | 539 | 865,360,590 | 0.87 |
|
||||
| 当天写入且仍存在的其他目录 | 3,382 | 9,735,597,543 | 9.74 |
|
||||
| 上传流量与当天现存对象大小差额 | — | 1,723,562,836 | 1.72 |
|
||||
|
||||
813.65 GB 位于本项目历史迁移目录。主要写入发生在 19:00–21:00。源分别为 `demo-sparkai.oss-cn-shanghai.aliyuncs.com` 和 `s3.jiansun.vip/gongxiangpan`,符合仓库迁移脚本的目标路径。
|
||||
|
||||
生产机 `/var/lib/ai-chatbot/aliyun-qiniu-20261014/verified.jsonl` 有 349,784 个去重后的目标对象、629,920,510,316 字节(629.92 GB)的逐对象验证记录。当前迁移目录比该回执多 60,576 个对象、183.73 GB;目录归属能够确认,现有回执没有覆盖这些对象。
|
||||
|
||||
桶由多个业务共用。其他目录中 `uploads/` 为 7.97 GB,其余包含 `dabi/`、`exports/`、`accio-ghost/` 和 `shared-drive/`,不能全部归为画布新生成资源。上传统计包含传输量,对象清单反映现存对象,不足以单独解释最后 1.72 GB 的差额。本次未删除或改动七牛对象。
|
||||
15
lib/agents/scene-run-recovery.ts
Normal file
15
lib/agents/scene-run-recovery.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
||||
import { messageText } from "./replacement-run-recovery";
|
||||
|
||||
type Message = { id: string; role: string; parts?: unknown[] };
|
||||
|
||||
export function getSceneRunResult(messages: Message[], requestMessageId: string) {
|
||||
const index = messages.findIndex((message) => message.id === requestMessageId && message.role === "user");
|
||||
if (index < 0) return null;
|
||||
const replies = messages.slice(index + 1);
|
||||
// A later user turn belongs to another run.
|
||||
if (replies.some((message) => message.role === "user")) return null;
|
||||
const urls = new Set(replies.filter((message) => message.role === "assistant")
|
||||
.flatMap((message) => extractImageUrlsFromText(messageText(message))));
|
||||
return { count: urls.size };
|
||||
}
|
||||
@ -769,16 +769,15 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
|
||||
}
|
||||
|
||||
const selectedImageUrls = selectAlibabaInputImageUrls(imageUrls, 3);
|
||||
const preparedImageUrls: string[] = [];
|
||||
for (const imageUrl of selectedImageUrls) {
|
||||
preparedImageUrls.push(
|
||||
await prepareAlibabaInputImageUrl(
|
||||
const preparedImageUrls = await Promise.all(
|
||||
selectedImageUrls.map((imageUrl) =>
|
||||
prepareAlibabaInputImageUrl(
|
||||
imageUrl,
|
||||
activeModelId,
|
||||
options.imageParams?.inputImageRoleByUrl?.[imageUrl],
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
|
||||
// qwen-image-3.0 同时支持文生图与图生图;旧编辑模型仍必须上传参考图。
|
||||
if (
|
||||
|
||||
@ -1438,7 +1438,7 @@ function normalizeDuration(value: unknown) {
|
||||
return Math.round(numeric);
|
||||
}
|
||||
|
||||
function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | null) {
|
||||
export function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | null) {
|
||||
const videoParams = params.videoParams || {};
|
||||
const imageParams = params.imageParams || {};
|
||||
const promptTitle = params.prompt.split(/\s+/).slice(0, 8).join(" ").slice(0, 80);
|
||||
@ -1448,6 +1448,9 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation |
|
||||
body.displayName = promptTitle || params.modelId;
|
||||
}
|
||||
if ("prompt" in body || !invocation) body.prompt = params.prompt;
|
||||
if (params.kind === "image" && invocationSupportsBodyField(invocation, "n")) {
|
||||
body.n = imageParams.imageCount ?? imageParams.n ?? 1;
|
||||
}
|
||||
|
||||
const aspectRatio =
|
||||
normalizeRatio(videoParams.ratio) ||
|
||||
@ -1697,7 +1700,7 @@ async function runTask(params: Vip98kTaskParams) {
|
||||
}
|
||||
const imageSize = String(params.imageParams?.imageSize || "2K").trim() || "2K";
|
||||
const archiveStartedAt = Date.now();
|
||||
const archived = await Promise.all(outputUrls.slice(0, 1).map(archiveOutputImage));
|
||||
const archived = await Promise.all(outputUrls.map(archiveOutputImage));
|
||||
logger.info("result archive completed", {
|
||||
modelId: params.modelId,
|
||||
outputCount: archived.length,
|
||||
|
||||
113
lib/canvas/markdown-image-segments.ts
Normal file
113
lib/canvas/markdown-image-segments.ts
Normal file
@ -0,0 +1,113 @@
|
||||
export type MarkdownImageSegment =
|
||||
| { type: "text"; value: string }
|
||||
| { type: "image"; alt?: string; src: string };
|
||||
|
||||
// Markdown destinations may include balanced parentheses and backslash escapes.
|
||||
function readDestination(content: string, open: number) {
|
||||
if (content[open] !== "(") return null;
|
||||
let depth = 1;
|
||||
for (let index = open + 1; index < content.length; index += 1) {
|
||||
const character = content[index];
|
||||
if (character === "\\") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === "(") depth += 1;
|
||||
if (character === ")" && --depth === 0) {
|
||||
const raw = content.slice(open + 1, index);
|
||||
if (!/^https?:\/\//i.test(raw) || /\s/.test(raw)) return null;
|
||||
return { url: raw.replace(/\\([()\\])/g, "$1"), end: index + 1 };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readImage(content: string, start: number) {
|
||||
if (!content.startsWith("![", start)) return null;
|
||||
let depth = 1;
|
||||
for (let index = start + 2; index < content.length; index += 1) {
|
||||
const character = content[index];
|
||||
if (character === "\\") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === "[") depth += 1;
|
||||
if (character !== "]" || --depth !== 0) continue;
|
||||
const destination = readDestination(content, index + 1);
|
||||
if (!destination) return null;
|
||||
return {
|
||||
alt: content.slice(start + 2, index).replace(/\\([\[\]\\])/g, "$1"),
|
||||
src: destination.url,
|
||||
end: destination.end,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function splitMarkdownImages(content: string): MarkdownImageSegment[] {
|
||||
const segments: MarkdownImageSegment[] = [];
|
||||
let textStart = 0;
|
||||
let cursor = 0;
|
||||
while (cursor < content.length) {
|
||||
// Leave Markdown examples in code spans and fences untouched.
|
||||
if (content[cursor] === "`") {
|
||||
let endOfRun = cursor + 1;
|
||||
while (content[endOfRun] === "`") endOfRun += 1;
|
||||
const marker = content.slice(cursor, endOfRun);
|
||||
const close = content.indexOf(marker, endOfRun);
|
||||
cursor = close < 0 ? content.length : close + marker.length;
|
||||
continue;
|
||||
}
|
||||
const lineStart = cursor === 0 || content[cursor - 1] === "\n";
|
||||
if (lineStart) {
|
||||
const fence = content.slice(cursor).match(/^ {0,3}(~{3,})[^\n]*(?:\n|$)/);
|
||||
if (fence) {
|
||||
const marker = fence[1];
|
||||
const closing = new RegExp(`^ {0,3}~{${marker.length},}\\s*$`);
|
||||
let line = cursor + fence[0].length;
|
||||
while (line < content.length) {
|
||||
const next = content.indexOf("\n", line);
|
||||
const end = next < 0 ? content.length : next;
|
||||
if (closing.test(content.slice(line, end))) {
|
||||
line = next < 0 ? end : end + 1;
|
||||
break;
|
||||
}
|
||||
line = next < 0 ? end : end + 1;
|
||||
}
|
||||
cursor = line;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (content[cursor] === "\\") {
|
||||
cursor += 2;
|
||||
continue;
|
||||
}
|
||||
const wrapped = content.startsWith("[![", cursor);
|
||||
const image = readImage(content, cursor + (wrapped ? 1 : 0));
|
||||
if (!image) {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
let end = image.end;
|
||||
if (wrapped) {
|
||||
const outer = content[end] === "]" ? readDestination(content, end + 1) : null;
|
||||
// Preserve incomplete, unsafe and different-target links as Markdown so
|
||||
// Streamdown applies its normal link sanitization and click behavior.
|
||||
if (!outer || outer.url !== image.src) {
|
||||
cursor = outer ? outer.end : end;
|
||||
continue;
|
||||
}
|
||||
end = outer.end;
|
||||
}
|
||||
if (cursor > textStart) {
|
||||
segments.push({ type: "text", value: content.slice(textStart, cursor) });
|
||||
}
|
||||
segments.push({ type: "image", alt: image.alt, src: image.src });
|
||||
cursor = end;
|
||||
textStart = end;
|
||||
}
|
||||
if (textStart < content.length) {
|
||||
segments.push({ type: "text", value: content.slice(textStart) });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
146
lib/canvas/markdown-image-streaming.ts
Normal file
146
lib/canvas/markdown-image-streaming.ts
Normal file
@ -0,0 +1,146 @@
|
||||
/** A half-open span containing a complete Markdown link or image. */
|
||||
export type MarkdownImageRange = { start: number; end: number };
|
||||
|
||||
type Parsed = { kind: "complete"; end: number } | { kind: "pending" } | { kind: "invalid" };
|
||||
|
||||
// This scanner recognizes syntax only; rendering and URL safety remain the
|
||||
// responsibility of the existing Markdown renderer and image splitter.
|
||||
function readDestination(text: string, start: number, allowWhitespace = false): Parsed {
|
||||
if (start >= text.length) return { kind: "pending" };
|
||||
if (text[start] !== "(") return { kind: "invalid" };
|
||||
let depth = 1;
|
||||
for (let i = start + 1; i < text.length; i += 1) {
|
||||
const char = text[i];
|
||||
if (char === "\\") {
|
||||
if (i + 1 >= text.length) return { kind: "pending" };
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "\n" || char === "\r" || (!allowWhitespace && /\s/.test(char))) return { kind: "invalid" };
|
||||
if (char === "(") depth += 1;
|
||||
if (char === ")" && --depth === 0) return { kind: "complete", end: i + 1 };
|
||||
}
|
||||
return { kind: "pending" };
|
||||
}
|
||||
|
||||
function readImage(text: string, start: number, wrapped: boolean): Parsed {
|
||||
const imageStart = start + (wrapped ? 1 : 0);
|
||||
if (imageStart + 1 >= text.length) return { kind: "pending" };
|
||||
if (text[imageStart] !== "!" || text[imageStart + 1] !== "[") return { kind: "invalid" };
|
||||
let depth = 1;
|
||||
for (let i = imageStart + 2; i < text.length; i += 1) {
|
||||
const char = text[i];
|
||||
if (char === "\n" || char === "\r") return { kind: "invalid" };
|
||||
if (char === "\\") {
|
||||
if (i + 1 >= text.length) return { kind: "pending" };
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "[") depth += 1;
|
||||
if (char !== "]" || --depth !== 0) continue;
|
||||
const inner = readDestination(text, i + 1);
|
||||
if (inner.kind !== "complete" || !wrapped) return inner;
|
||||
if (inner.end >= text.length) return { kind: "pending" };
|
||||
if (text[inner.end] !== "]") return { kind: "invalid" };
|
||||
return readDestination(text, inner.end + 1);
|
||||
}
|
||||
return { kind: "pending" };
|
||||
}
|
||||
|
||||
function readLink(text: string, start: number): Parsed {
|
||||
let depth = 1;
|
||||
for (let i = start + 1; i < text.length; i += 1) {
|
||||
const char = text[i];
|
||||
// An unfinished bracket on an earlier line must not hide later output.
|
||||
if (char === "\n" || char === "\r") return { kind: "invalid" };
|
||||
if (char === "\\") {
|
||||
if (i + 1 >= text.length) return { kind: "pending" };
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "[") depth += 1;
|
||||
if (char !== "]" || --depth !== 0) continue;
|
||||
if (text[i + 1] === "[") {
|
||||
for (let j = i + 2; j < text.length; j += 1) {
|
||||
if (text[j] === "\n" || text[j] === "\r") return { kind: "invalid" };
|
||||
if (text[j] === "\\") {
|
||||
if (j + 1 >= text.length) return { kind: "pending" };
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
if (text[j] === "]") return { kind: "complete", end: j + 1 };
|
||||
}
|
||||
return { kind: "pending" };
|
||||
}
|
||||
return readDestination(text, i + 1, true);
|
||||
}
|
||||
return { kind: "pending" };
|
||||
}
|
||||
|
||||
/** Locate complete Markdown links/images and an incomplete trailing construct.
|
||||
* Code spans/fences and escaped markers remain normal text. */
|
||||
export function scanMarkdownImageStreaming(text: string): {
|
||||
ranges: MarkdownImageRange[];
|
||||
pendingStart: number | null;
|
||||
} {
|
||||
const ranges: MarkdownImageRange[] = [];
|
||||
let cursor = 0;
|
||||
while (cursor < text.length) {
|
||||
if (text[cursor] === "\\") { cursor += 2; continue; }
|
||||
if (text[cursor] === "`") {
|
||||
let end = cursor + 1;
|
||||
while (text[end] === "`") end += 1;
|
||||
const marker = text.slice(cursor, end);
|
||||
const close = text.indexOf(marker, end);
|
||||
cursor = close < 0 ? text.length : close + marker.length;
|
||||
continue;
|
||||
}
|
||||
if (cursor === 0 || text[cursor - 1] === "\n") {
|
||||
const fence = text.slice(cursor).match(/^ {0,3}(~{3,})[^\n]*(?:\n|$)/);
|
||||
if (fence) {
|
||||
const closing = new RegExp(`^ {0,3}~{${fence[1].length},}\\s*$`);
|
||||
let line = cursor + fence[0].length;
|
||||
while (line < text.length) {
|
||||
const next = text.indexOf("\n", line);
|
||||
const end = next < 0 ? text.length : next;
|
||||
if (closing.test(text.slice(line, end))) { line = next < 0 ? end : end + 1; break; }
|
||||
line = next < 0 ? end : end + 1;
|
||||
}
|
||||
cursor = line;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const char = text[cursor];
|
||||
if (char !== "[" && char !== "!") { cursor += 1; continue; }
|
||||
const wrapped = char === "[";
|
||||
const parsed = wrapped && text[cursor + 1] !== "!"
|
||||
? readLink(text, cursor)
|
||||
: readImage(text, cursor, wrapped);
|
||||
if (parsed.kind === "pending") return { ranges, pendingStart: cursor };
|
||||
if (parsed.kind === "complete") { ranges.push({ start: cursor, end: parsed.end }); cursor = parsed.end; }
|
||||
else cursor += 1;
|
||||
}
|
||||
return { ranges, pendingStart: null };
|
||||
}
|
||||
|
||||
/** During active delivery, withhold only unfinished link/image syntax at the tail. */
|
||||
export function stableStreamingMarkdown(text: string, streaming: boolean): string {
|
||||
if (!streaming) return text;
|
||||
const { pendingStart } = scanMarkdownImageStreaming(text);
|
||||
return pendingStart === null ? text : text.slice(0, pendingStart);
|
||||
}
|
||||
|
||||
export function advancePastAtomicRanges(length: number, target: number, ranges: MarkdownImageRange[]) {
|
||||
let position = Math.min(length, target);
|
||||
// Overlapping table/link ranges can require more than one jump.
|
||||
for (let changed = true; changed;) {
|
||||
changed = false;
|
||||
for (const range of ranges) {
|
||||
if (position > range.start && position < range.end) {
|
||||
position = Math.min(length, range.end);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return position;
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||
import { fetchImageBytes, onceImageBytes } from "@/lib/storage/image-byte-cache";
|
||||
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
|
||||
|
||||
@ -296,6 +297,9 @@ export async function fetchCanvasDownloadAsset(params: {
|
||||
retryCount?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<CanvasDownloadAssetResult> {
|
||||
if (isRetiredStorageUrl(params.file.url)) {
|
||||
return { ok: false, url: params.file.url, name: params.file.name, error: RETIRED_STORAGE_MESSAGE };
|
||||
}
|
||||
let lastError = "";
|
||||
const timeoutMs = params.timeoutMs || DEFAULT_FETCH_TIMEOUT_MS;
|
||||
const retryCount = params.retryCount || DEFAULT_FETCH_RETRY_COUNT;
|
||||
|
||||
@ -1,11 +1,20 @@
|
||||
/** Serialize local saves so each request reads the revision returned by the last. */
|
||||
export function createCanvasSaveQueue() {
|
||||
let pending: Promise<unknown> = Promise.resolve();
|
||||
return {
|
||||
const queue = {
|
||||
enqueue<T>(save: () => Promise<T>): Promise<T> {
|
||||
const next = pending.then(save, save);
|
||||
pending = next.catch(() => undefined);
|
||||
return next;
|
||||
},
|
||||
enqueueOperation(save: () => Promise<boolean>, operation: () => Promise<void>) {
|
||||
return queue.enqueue(async () => {
|
||||
if (!(await save())) {
|
||||
throw new Error("画布尚未保存成功,请确认网络或画布同步状态后重试");
|
||||
}
|
||||
await operation();
|
||||
});
|
||||
},
|
||||
};
|
||||
return queue;
|
||||
}
|
||||
|
||||
40
lib/client/canvas-toolbar-capability.ts
Normal file
40
lib/client/canvas-toolbar-capability.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||
import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
/** Runs after the editor has saved and while its save queue is reserved. */
|
||||
export async function executeCanvasToolbarCapability(
|
||||
command: CanvasCapabilityClientCommand,
|
||||
) {
|
||||
const projectId = command.projectId!;
|
||||
const before = useCanvasStore.getState();
|
||||
if (before.projectId !== projectId) throw new Error("画布已切换,请重新选择图片");
|
||||
const base = JSON.stringify({ canvasData: before.getCanvasState() });
|
||||
const response = await fetch(`/api/canvas/${projectId}/capabilities`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(command),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.success !== true) {
|
||||
throw new Error(payload?.error || "图片处理失败");
|
||||
}
|
||||
|
||||
const projectResponse = await fetch(`/api/canvas/${projectId}`);
|
||||
const result = await projectResponse.json();
|
||||
if (!projectResponse.ok || !result.success || !result.data?.canvasData) {
|
||||
throw new Error(result.error || "结果已生成,但画布同步失败");
|
||||
}
|
||||
const current = useCanvasStore.getState();
|
||||
if (current.projectId !== projectId) return;
|
||||
const merged = mergeCanvasSnapshots(
|
||||
base,
|
||||
JSON.stringify({ canvasData: current.getCanvasState() }),
|
||||
JSON.stringify({ canvasData: result.data.canvasData }),
|
||||
);
|
||||
if (merged.conflicts.length > 0) {
|
||||
throw new Error("结果已生成,但画布有冲突;请保留本地改动后刷新查看结果");
|
||||
}
|
||||
current.loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
||||
current.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import "server-only";
|
||||
import { resolveSiteBranding } from "@/lib/site-branding";
|
||||
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
|
||||
|
||||
import {
|
||||
@ -4744,9 +4745,9 @@ export async function getSiteSettings() {
|
||||
? videoCanvasEnabledSetting.value !== false
|
||||
: videoCanvasSidebarEnabledSetting?.value !== false;
|
||||
|
||||
return { ...(settings || defaultSettings), videoCanvasEnabled };
|
||||
return resolveSiteBranding({ ...(settings || defaultSettings), videoCanvasEnabled });
|
||||
} catch (_error) {
|
||||
return defaultSettings;
|
||||
return resolveSiteBranding(defaultSettings);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,9 +6,24 @@ export type ImageProxyOptions = {
|
||||
width?: number;
|
||||
quality?: number;
|
||||
format?: ImageProxyFormat;
|
||||
delivery?: "display" | "proxy";
|
||||
delivery?: "display" | "proxy" | "original";
|
||||
};
|
||||
|
||||
/** Preserve the actual response format when naming an original download. */
|
||||
export function getImageDownloadExtension(contentType: string) {
|
||||
const mime = contentType.split(";")[0]?.trim().toLowerCase();
|
||||
const extensions: Record<string, string> = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
"image/avif": "avif",
|
||||
"image/gif": "gif",
|
||||
"image/svg+xml": "svg",
|
||||
};
|
||||
return extensions[mime] || "";
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
@ -141,11 +156,40 @@ function resolveAliyunOssDisplayUrl(
|
||||
}
|
||||
|
||||
export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {}): string {
|
||||
const source = String(url || "").trim();
|
||||
let source = String(url || "").trim();
|
||||
// Some messages retain our preview URL instead of its source URL. Rebuild
|
||||
// that local proxy request without the preview's resize/format parameters.
|
||||
if (options.delivery === "original") {
|
||||
const base = typeof window === "undefined" ? "https://image-proxy.local" : window.location.origin;
|
||||
try {
|
||||
const parsed = new URL(source, base);
|
||||
if (parsed.origin === base && parsed.pathname === IMAGE_PROXY_PATH) {
|
||||
const nestedSource = parsed.searchParams.get("url") || "";
|
||||
if (isAbsoluteHttpUrl(nestedSource)) source = nestedSource;
|
||||
}
|
||||
} catch {
|
||||
// Leave malformed and non-URL sources to the existing handling below.
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(source);
|
||||
if ([...parsed.searchParams.keys()].some((key) => /^(?:imageview2(?:\/|$)|x-oss-process$)/i.test(key))) {
|
||||
throw new Error("图片链接包含处理参数,无法确认原图");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "图片链接包含处理参数,无法确认原图") throw error;
|
||||
}
|
||||
}
|
||||
if (isBypassUrl(source)) {
|
||||
return source;
|
||||
}
|
||||
|
||||
// An original download must go through the same-origin byte-preserving
|
||||
// proxy even for trusted storage hosts: a direct fetch may fail CORS.
|
||||
if (options.delivery === "original") {
|
||||
const params = new URLSearchParams({ url: source, original: "1" });
|
||||
return `${IMAGE_PROXY_PATH}?${params.toString()}`;
|
||||
}
|
||||
|
||||
const trustedDirectStorageUrl = resolveTrustedDirectStorageDisplayUrl(
|
||||
source,
|
||||
options
|
||||
|
||||
13
lib/site-branding.ts
Normal file
13
lib/site-branding.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { isRetiredStorageUrl } from '@/lib/storage/retired-storage';
|
||||
|
||||
export const DEFAULT_SITE_LOGO = '/brand/logo-black.png';
|
||||
export const DEFAULT_SITE_LOGO_DARK = '/brand/logo-white.svg';
|
||||
|
||||
export function resolveSiteBranding<T extends { siteLogo?: string | null; siteLogoDark?: string | null; favicon?: string | null }>(settings: T) {
|
||||
return {
|
||||
...settings,
|
||||
siteLogo: !settings.siteLogo || isRetiredStorageUrl(settings.siteLogo) ? DEFAULT_SITE_LOGO : settings.siteLogo,
|
||||
siteLogoDark: !settings.siteLogoDark || isRetiredStorageUrl(settings.siteLogoDark) ? DEFAULT_SITE_LOGO_DARK : settings.siteLogoDark,
|
||||
favicon: !settings.favicon || isRetiredStorageUrl(settings.favicon) ? DEFAULT_SITE_LOGO : settings.favicon,
|
||||
};
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import "server-only";
|
||||
import { isRetiredStorageUrl } from "./retired-storage";
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
@ -38,7 +39,7 @@ export async function enqueueImageArchiveTask(params: {
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const sourceUrl = String(params.sourceUrl || "").trim();
|
||||
if (!isGeneratedImageSource(sourceUrl)) return null;
|
||||
if (!isGeneratedImageSource(sourceUrl) || isRetiredStorageUrl(sourceUrl)) return null;
|
||||
|
||||
const now = new Date();
|
||||
const archiveAfterAt = params.archiveAfterAt || now;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { fetchImageBytes, cacheImageBytes } from "@/lib/storage/image-byte-cache";
|
||||
import "server-only";
|
||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { isGeneratedImageSource } from "./image-archive-urls";
|
||||
@ -170,6 +171,7 @@ function toArrayBuffer(buffer: Buffer): ArrayBuffer {
|
||||
}
|
||||
|
||||
export async function downloadTemporaryImage(params: { sourceUrl: string; retries?: number }) {
|
||||
if (isRetiredStorageUrl(params.sourceUrl)) throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`);
|
||||
const retries = Math.max(0, Math.min(5, params.retries ?? 2));
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import "server-only";
|
||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile, rename, readdir, stat, statfs, unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@ -81,6 +82,7 @@ function isMediaResponse(data: ImageBytes) {
|
||||
return data.buffer.length > 0;
|
||||
}
|
||||
export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string } = {}) {
|
||||
if (isRetiredStorageUrl(url)) throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`);
|
||||
return onceImageBytes(`source:${options.scope || ""}:${url}`, async () => {
|
||||
if (url.startsWith("data:")) {
|
||||
const match = url.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import "server-only";
|
||||
import { isRetiredStorageUrl } from "./retired-storage";
|
||||
|
||||
import { and, asc, desc, eq, inArray, lte, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
@ -16,6 +17,8 @@ function isUsableS3Config(config: StorageConfig | null | undefined): config is S
|
||||
config &&
|
||||
config.type === "s3" &&
|
||||
config.isEnabled &&
|
||||
!isRetiredStorageUrl(config.endpoint) &&
|
||||
!isRetiredStorageUrl(config.publicUrl) &&
|
||||
String(config.endpoint || "").trim() &&
|
||||
String(config.bucket || "").trim() &&
|
||||
String(config.accessKeyId || "").trim() &&
|
||||
@ -80,7 +83,7 @@ export async function enqueueStorageMigrationTask({
|
||||
migrateAfterAt: Date;
|
||||
cleanupAfterAt: Date;
|
||||
}) {
|
||||
if (!sourceUrl || !sourcePath || !sourceStorageConfigId) return;
|
||||
if (!sourceUrl || !sourcePath || !sourceStorageConfigId || isRetiredStorageUrl(sourceUrl)) return;
|
||||
|
||||
await db
|
||||
.insert(storageMigrationTask)
|
||||
|
||||
16
lib/storage/retired-storage.ts
Normal file
16
lib/storage/retired-storage.ts
Normal file
@ -0,0 +1,16 @@
|
||||
/** Permanently decommissioned storage. Historical records remain for reference. */
|
||||
export const RETIRED_STORAGE_URL_PATTERN = '^https?://s3[.]1080p[.]hk([/:?#]|$)';
|
||||
export const RETIRED_STORAGE_MESSAGE = '旧存储已停用,源文件已无法恢复,请重新上传。';
|
||||
|
||||
export function isRetiredStorageUrl(value: string | null | undefined): boolean {
|
||||
if (!value) return false;
|
||||
try {
|
||||
const url = new URL(value, 'https://local.invalid');
|
||||
if (url.hostname === 's3.1080p.hk') return true;
|
||||
if (url.pathname === '/api/image-proxy' || url.pathname === '/api/canvas/3d/model-proxy') {
|
||||
const source = url.searchParams.get('url');
|
||||
return Boolean(source && new URL(source).hostname === 's3.1080p.hk');
|
||||
}
|
||||
} catch { /* Relative local assets and non-URL values are not retired storage. */ }
|
||||
return false;
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import "server-only";
|
||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
|
||||
|
||||
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { put } from "@vercel/blob";
|
||||
@ -149,6 +150,9 @@ export function getContentType(filename: string): string {
|
||||
}
|
||||
|
||||
export function createS3Client(config: S3UploadConfig): S3Client {
|
||||
if (isRetiredStorageUrl(config.endpoint) || isRetiredStorageUrl(config.publicUrl)) {
|
||||
throw new Error(RETIRED_STORAGE_MESSAGE);
|
||||
}
|
||||
const normalizedEndpoint = normalizeEndpoint(config.endpoint);
|
||||
const lowerEndpoint = normalizedEndpoint.toLowerCase();
|
||||
const isCloudS3 =
|
||||
@ -337,6 +341,8 @@ export function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null {
|
||||
!value ||
|
||||
!provider ||
|
||||
!value.isEnabled ||
|
||||
isRetiredStorageUrl(endpoint) ||
|
||||
isRetiredStorageUrl(String(value.publicUrl || "")) ||
|
||||
!endpoint ||
|
||||
!bucket ||
|
||||
!accessKeyId ||
|
||||
@ -364,7 +370,7 @@ function buildEnvS3Config(): S3UploadConfig | null {
|
||||
const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim() ?? "";
|
||||
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim() ?? "";
|
||||
|
||||
if (endpoint && bucket && accessKeyId && secretAccessKey) {
|
||||
if (endpoint && !isRetiredStorageUrl(endpoint) && !isRetiredStorageUrl(process.env.S3_PUBLIC_URL) && bucket && accessKeyId && secretAccessKey) {
|
||||
return {
|
||||
endpoint,
|
||||
region: process.env.S3_REGION?.trim() || "auto",
|
||||
|
||||
BIN
public/brand/logo-black.png
Normal file
BIN
public/brand/logo-black.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
1
public/brand/logo-white.svg
Normal file
1
public/brand/logo-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
@ -226,11 +226,13 @@ def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("action", choices=["status", "jobs", "build", "stage", "activate", "rollback"])
|
||||
parser.add_argument("--release")
|
||||
parser.add_argument("--retry-rolled-back", action="store_true")
|
||||
parser.add_argument("--key", type=Path, default=Path.home() / ".ssh/45.192.99.48.pem")
|
||||
parser.add_argument("--jump-key", type=Path, default=Path.home() / ".ssh/youyidian_prod_ed25519")
|
||||
parser.add_argument("--known-hosts", type=Path, default=Path.home() / ".ssh/known_hosts")
|
||||
parser.add_argument("--receipts", type=Path, default=ROOT / ".git/releases")
|
||||
args = parser.parse_args()
|
||||
require(not args.retry_rolled_back or args.action == "activate", "Retry flag only applies to activation")
|
||||
if args.action in {"stage", "activate", "rollback"}:
|
||||
require(args.release and re.fullmatch(r"\d{8}T\d{6}Z-[0-9a-f]{12}", args.release), "--release is required")
|
||||
args.receipts = args.receipts.resolve()
|
||||
@ -241,6 +243,10 @@ def main():
|
||||
stage(connections, args)
|
||||
else:
|
||||
kwargs = {"release": args.release} if args.release else {}
|
||||
if args.action == "activate":
|
||||
kwargs["operator-commit"] = git("rev-parse", "HEAD")
|
||||
if args.retry_rolled_back:
|
||||
kwargs["retry"] = "rolled-back"
|
||||
result = connections.helper(args.action, **kwargs)
|
||||
if args.release:
|
||||
write_json(args.receipts / args.release / (args.action + ".json"), result)
|
||||
|
||||
@ -384,21 +384,34 @@ def healthy(cwd, build_id):
|
||||
|
||||
|
||||
def restart(config):
|
||||
run(["pm2", "startOrRestart", str(config), "--only", NAME, "--update-env"], timeout=150)
|
||||
apps = json.loads(config.read_text())["apps"]
|
||||
require(len(apps) == 1 and apps[0]["name"] == NAME, "Refusing a multi-application PM2 definition")
|
||||
current = [app for app in json.loads(run(["pm2", "jlist"])) if app["name"] == NAME]
|
||||
require(len(current) <= 1, "Duplicate ai-chatbot definitions; operator required")
|
||||
# PM2 restart updates env but retains the old executable/cwd. Recreate only
|
||||
# this name so the new immutable release's process definition really applies.
|
||||
if current:
|
||||
run(["pm2", "delete", NAME], timeout=150)
|
||||
run(["pm2", "start", str(config), "--only", NAME], timeout=150)
|
||||
|
||||
|
||||
def activate(release_id):
|
||||
def activate(release_id, retry=False, operator_commit=None):
|
||||
destination = release_path(release_id)
|
||||
manifest = json.loads((destination / "release.json").read_text())
|
||||
staged = json.loads((destination / "staged.json").read_text())
|
||||
app, current, dist = app_state()
|
||||
require(str(current) == staged["previousCwd"] and dist == staged["previousDistDir"],
|
||||
"Production changed after staging")
|
||||
require(not (destination / "activation.json").exists(), "Already activated; inspect status")
|
||||
journal = destination / "activation.json"
|
||||
prior = json.loads(journal.read_text()) if journal.exists() else None
|
||||
require(not prior or (retry and prior.get("phase") == "rolled-back"),
|
||||
"Already activated; inspect status before explicitly retrying a rolled-back attempt")
|
||||
verify_runtime(manifest, destination)
|
||||
jobs = job_status()
|
||||
require(not any(row["recent"] for rows in jobs.values() for row in rows),
|
||||
"Recent active generation jobs: defer activation")
|
||||
if prior:
|
||||
json_write(destination / f"activation-history-{time.time_ns()}.json", prior)
|
||||
before = ecosystem(app, current, dist)
|
||||
previous_build = (current / dist / "BUILD_ID").read_text().strip()
|
||||
json_write(destination / "previous.pm2.json", before)
|
||||
@ -410,7 +423,7 @@ def activate(release_id):
|
||||
"previousBuildId": previous_build, "previousCwd": str(current),
|
||||
"activatedAtUtc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"historicalTaskStates": jobs, "artifactSha256": staged["artifactSha256"],
|
||||
"phase": "activating"}
|
||||
"phase": "activating", "operatorCommit": operator_commit}
|
||||
json_write(destination / "activation.json", receipt)
|
||||
try:
|
||||
restart(destination / "candidate.pm2.json")
|
||||
@ -453,6 +466,8 @@ def main():
|
||||
parser.add_argument("--release")
|
||||
parser.add_argument("--digest")
|
||||
parser.add_argument("--workspace", type=Path)
|
||||
parser.add_argument("--retry", choices=["rolled-back"])
|
||||
parser.add_argument("--operator-commit")
|
||||
args = parser.parse_args()
|
||||
os.umask(0o077)
|
||||
if args.action in {"pack", "smoke"}:
|
||||
@ -469,7 +484,8 @@ def main():
|
||||
if args.action == "stage":
|
||||
result = stage(args.release, args.digest)
|
||||
elif args.action == "activate":
|
||||
result = activate(args.release)
|
||||
result = activate(args.release, retry=args.retry == "rolled-back",
|
||||
operator_commit=args.operator_commit)
|
||||
else:
|
||||
result = rollback(args.release)
|
||||
print(json.dumps(result))
|
||||
|
||||
29
scripts/storage/retire-1080p-storage.cjs
Normal file
29
scripts/storage/retire-1080p-storage.cjs
Normal file
@ -0,0 +1,29 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
require('node:module').createRequire(require.resolve('next/package.json'))('@next/env').loadEnvConfig(process.cwd());
|
||||
const sql = require('postgres')(process.env.POSTGRES_URL, { max: 1, prepare: false });
|
||||
const pattern = '^https?://s3[.]1080p[.]hk([/:?#]|$)';
|
||||
|
||||
async function main() {
|
||||
const apply = process.argv.includes('--apply');
|
||||
const result = await sql.begin(async tx => {
|
||||
const storage = await tx`select id,"isEnabled","isDefault" from "StorageConfig" where endpoint ~* ${pattern} or "publicUrl" ~* ${pattern}`;
|
||||
const featured = await tx`select id,"isFeatured" from "GeneratedImage" where "isFeatured" and "originalUrl" ~* ${pattern}`;
|
||||
const branding = await tx`select id,"siteLogo","siteLogoDark",favicon from "SiteSettings" where "siteLogo" ~* ${pattern} or "siteLogoDark" ~* ${pattern} or favicon ~* ${pattern}`;
|
||||
if (!apply) return { apply, storage: storage.length, featured: featured.length, branding: branding.length };
|
||||
const directory = process.env.STORAGE_RETIREMENT_BACKUP_DIR || '/var/lib/ai-chatbot/retired-storage-20261103';
|
||||
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||
const backup = path.join(directory, `before-${Date.now()}.json`);
|
||||
fs.writeFileSync(backup, JSON.stringify({ storage, featured, branding }, null, 2), { flag: 'wx', mode: 0o600 });
|
||||
const disabled = await tx`update "StorageConfig" set "isEnabled"=false,"isDefault"=false,"updatedAt"=now() where (endpoint ~* ${pattern} or "publicUrl" ~* ${pattern}) and ("isEnabled" or "isDefault") returning id`;
|
||||
const unfeatured = await tx`update "GeneratedImage" set "isFeatured"=false where "isFeatured" and "originalUrl" ~* ${pattern} returning id`;
|
||||
const branded = await tx`update "SiteSettings" set
|
||||
"siteLogo"=case when "siteLogo" ~* ${pattern} then '/brand/logo-black.png' else "siteLogo" end,
|
||||
"siteLogoDark"=case when "siteLogoDark" ~* ${pattern} then '/brand/logo-white.svg' else "siteLogoDark" end,
|
||||
favicon=case when favicon ~* ${pattern} then '/brand/logo-black.png' else favicon end
|
||||
where "siteLogo" ~* ${pattern} or "siteLogoDark" ~* ${pattern} or favicon ~* ${pattern} returning id`;
|
||||
return { apply, backup, disabled: disabled.length, unfeatured: unfeatured.length, branded: branded.length };
|
||||
});
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
main().catch(error => { console.error(error.message); process.exitCode = 1; }).finally(() => sql.end());
|
||||
@ -248,6 +248,17 @@ export const canvasService = {
|
||||
),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data?.success && data?.previousCanvasRevision && data?.data?.updatedAt) {
|
||||
const current = useCanvasStore.getState();
|
||||
// Metadata-only writes advance the same revision used by canvas saves.
|
||||
// Adopt it only when no unseen canvas revision lies between them.
|
||||
if (
|
||||
current.projectId === id &&
|
||||
current.canvasRevision === data.previousCanvasRevision
|
||||
) {
|
||||
current.setCanvasRevision(String(data.data.updatedAt), String(data.data.updatedAt));
|
||||
}
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { success: false, error: '更新画布失败' };
|
||||
|
||||
2
tests/canvas-toolbar.config.ts
Normal file
2
tests/canvas-toolbar.config.ts
Normal file
@ -0,0 +1,2 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
export default defineConfig({ testDir: "./routes", testMatch: /canvas-toolbar-capability\.test\.ts|canvas-capability-runtime\.test\.ts/, workers: 1, reporter: "list" });
|
||||
72
tests/integration/markdown-image-segments.test.ts
Normal file
72
tests/integration/markdown-image-segments.test.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { splitMarkdownImages } from "../../lib/canvas/markdown-image-segments";
|
||||
|
||||
const image = "https://example.com/folder/test.jpg";
|
||||
|
||||
test("SKU output consumes the entire redundant linked-image wrapper", () => {
|
||||
const text = `生成图\n[](${image})\n下载完成`;
|
||||
assert.deepEqual(splitMarkdownImages(text), [
|
||||
{ type: "text", value: "生成图\n" },
|
||||
{ type: "image", alt: "第2行", src: image },
|
||||
{ type: "text", value: "\n下载完成" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("several linked and ordinary images do not leave broken wrapper text", () => {
|
||||
const parts = splitMarkdownImages(`[](${image})\n`);
|
||||
assert.deepEqual(parts, [
|
||||
{ type: "image", alt: "a", src: image },
|
||||
{ type: "text", value: "\n" },
|
||||
{ type: "image", alt: "b", src: "https://example.com/b.png" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("different outer links and ordinary links are preserved verbatim", () => {
|
||||
const nested = `[](https://example.com/full.jpg)`;
|
||||
const input = `[文档](https://example.com/doc) ${nested} `;
|
||||
assert.deepEqual(splitMarkdownImages(input), [
|
||||
{ type: "text", value: `[文档](https://example.com/doc) ${nested} ` },
|
||||
{ type: "image", alt: "plain", src: "https://example.com/next.jpg" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("supports balanced and escaped parentheses and nested/escaped alt text", () => {
|
||||
const url = "https://example.com/a_(1)/x.jpg";
|
||||
assert.deepEqual(splitMarkdownImages(`[![a [b] \\] c](${url})](${url})`), [
|
||||
{ type: "image", alt: "a [b] ] c", src: url },
|
||||
]);
|
||||
assert.deepEqual(splitMarkdownImages(".jpg)"), [
|
||||
{ type: "image", alt: "a", src: "https://example.com/a(1).jpg" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not extract examples in inline and fenced code", () => {
|
||||
const input = `\`\`\n\`\`\`md\n[](${image})\n\`\`\`\n~~~md\n\n~~~\n`;
|
||||
const parts = splitMarkdownImages(input);
|
||||
assert.equal(parts.filter((part) => part.type === "image").length, 1);
|
||||
assert.equal(parts[0].type, "text");
|
||||
assert.ok(parts[0].type === "text" && parts[0].value.includes("![code]"));
|
||||
assert.deepEqual(parts[1], { type: "image", alt: "actual", src: image });
|
||||
});
|
||||
|
||||
test("preserves unsafe, incomplete, and escaped markup for the existing renderer", () => {
|
||||
for (const content of [
|
||||
`[](javascript:alert(1))`,
|
||||
`[](`,
|
||||
`\\`,
|
||||
"",
|
||||
]) assert.deepEqual(splitMarkdownImages(content), [{ type: "text", value: content }]);
|
||||
});
|
||||
|
||||
test("retains existing thumbnail metadata in alt text", () => {
|
||||
for (const alt of [
|
||||
"thumb|||https://example.com/original.jpg|||",
|
||||
"thumb$$https://example.com/original.jpg$$",
|
||||
"thumb|https://example.com/original.jpg|",
|
||||
]) {
|
||||
assert.deepEqual(splitMarkdownImages(``), [
|
||||
{ type: "image", alt, src: image },
|
||||
]);
|
||||
}
|
||||
});
|
||||
104
tests/integration/markdown-image-streaming.test.ts
Normal file
104
tests/integration/markdown-image-streaming.test.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { advancePastAtomicRanges, scanMarkdownImageStreaming, stableStreamingMarkdown } from "../../lib/canvas/markdown-image-streaming";
|
||||
import { splitMarkdownImages } from "../../lib/canvas/markdown-image-segments";
|
||||
|
||||
const url = "https://scqny.jiansun.vip/ps-skill-api/results/job_test/01-%E6%A0%B7%E4%BE%8B.jpg";
|
||||
const wrapped = `[](${url})`;
|
||||
const other = `[](https://example.com/original.jpg)`;
|
||||
|
||||
test("every live network prefix withholds the entire unfinished linked image", () => {
|
||||
const before = "## 生成图\n";
|
||||
for (let n = 1; n < wrapped.length; n += 1) {
|
||||
assert.equal(stableStreamingMarkdown(before + wrapped.slice(0, n), true), before, `prefix ${n}`);
|
||||
}
|
||||
assert.equal(stableStreamingMarkdown(before + wrapped, true), before + wrapped);
|
||||
// An interrupted / completed response keeps malformed original text visible.
|
||||
for (const suffix of ["[", "[!", "[]("]) {
|
||||
assert.equal(stableStreamingMarkdown(before + suffix, false), before + suffix);
|
||||
}
|
||||
});
|
||||
|
||||
test("every synthetic typewriter cursor exposes complete image markup or none", () => {
|
||||
const content = `生成图\n${wrapped}\n${other}\n可编辑文件`;
|
||||
const ranges = scanMarkdownImageStreaming(content).ranges;
|
||||
assert.equal(ranges.length, 2);
|
||||
let cursor = 0;
|
||||
while (cursor < content.length) {
|
||||
cursor = advancePastAtomicRanges(content.length, cursor + 1, ranges);
|
||||
const visible = content.slice(0, cursor);
|
||||
for (const range of ranges) {
|
||||
assert.ok(cursor <= range.start || cursor >= range.end, `split at ${cursor}`);
|
||||
}
|
||||
if (cursor >= ranges[0].end) {
|
||||
assert.ok(splitMarkdownImages(visible).some((item) => item.type === "image"));
|
||||
}
|
||||
}
|
||||
assert.equal(cursor, content.length);
|
||||
assert.deepEqual(splitMarkdownImages(other), [{ type: "text", value: other }]);
|
||||
});
|
||||
|
||||
test("handles ordinary images, URL brackets, escaped text and code safely", () => {
|
||||
const simple = ".jpg)";
|
||||
for (let n = 1; n < simple.length; n += 1) {
|
||||
assert.equal(stableStreamingMarkdown("前文 " + simple.slice(0, n), true), "前文 ", `prefix ${n}`);
|
||||
}
|
||||
const literal = "`";
|
||||
assert.equal(stableStreamingMarkdown(literal, true), literal);
|
||||
const fence = "~~~md\n[, fence);
|
||||
assert.deepEqual(scanMarkdownImageStreaming(fence + simple).ranges, [{ start: fence.length, end: fence.length + simple.length }]);
|
||||
const invalid = " and later text";
|
||||
assert.equal(stableStreamingMarkdown(invalid, true), invalid);
|
||||
});
|
||||
|
||||
test("does not change normal links or security-sensitive complete markup", () => {
|
||||
const values = ["[普通链接](https://example.com/a)", "[](javascript:alert(1))", other];
|
||||
for (const value of values) assert.equal(stableStreamingMarkdown(value, true), value);
|
||||
});
|
||||
|
||||
test("coalesces overlapping typewriter table and image ranges without looping", () => {
|
||||
const ranges = [{ start: 0, end: 20 }, { start: 10, end: 35 }, { start: 35, end: 40 }];
|
||||
assert.equal(advancePastAtomicRanges(50, 1, ranges), 35);
|
||||
assert.equal(advancePastAtomicRanges(50, 36, ranges), 40);
|
||||
assert.equal(advancePastAtomicRanges(50, 50, ranges), 50);
|
||||
});
|
||||
|
||||
test("SKU result links are atomic across every typewriter cursor and live fragment", () => {
|
||||
const links = [
|
||||
"[beike-sku-template2-batch.json](https://scqny.jiansun.vip/ps-skill-api/results/job_test/795c72cb-batch.json)",
|
||||
"[beike-sku-template2-report.json](https://scqny.jiansun.vip/ps-skill-api/results/job_test/af588253-report.json)",
|
||||
];
|
||||
const text = `生成图\n${wrapped}\n可编辑文件\n- ${links[0]}\n- ${links[1]}\n完成`;
|
||||
const ranges = scanMarkdownImageStreaming(text).ranges;
|
||||
assert.equal(ranges.length, 3);
|
||||
for (const link of links) {
|
||||
const start = text.indexOf(link);
|
||||
for (let n = 1; n < link.length; n += 1) {
|
||||
const prefix = text.slice(0, start + n);
|
||||
assert.equal(stableStreamingMarkdown(prefix, true), text.slice(0, start), `link prefix ${n}`);
|
||||
}
|
||||
assert.equal(stableStreamingMarkdown(text.slice(0, start + link.length), true), text.slice(0, start + link.length));
|
||||
}
|
||||
let cursor = 0;
|
||||
while (cursor < text.length) {
|
||||
cursor = advancePastAtomicRanges(text.length, cursor + 1, ranges);
|
||||
assert.ok(ranges.every((range) => cursor <= range.start || cursor >= range.end), `split at ${cursor}`);
|
||||
}
|
||||
assert.equal(cursor, text.length);
|
||||
});
|
||||
|
||||
test("ordinary brackets, code, escaped markers and unfinished final links remain available", () => {
|
||||
for (const value of ["普通文字", "[状态] 已完成", "[引用][reference]", "\\[转义](https://example.com)", "`[code](https://example.com`", "~~~md\n[example](https://example.com\n~~~\n"]) {
|
||||
assert.equal(stableStreamingMarkdown(value, true), value);
|
||||
assert.equal(stableStreamingMarkdown(value, false), value);
|
||||
}
|
||||
for (const partial of ["[状态", "[文件](https://example.com/", "[文件]"]) {
|
||||
assert.equal(stableStreamingMarkdown(partial, false), partial);
|
||||
assert.equal(stableStreamingMarkdown(partial + "\n普通文字", true), partial + "\n普通文字");
|
||||
}
|
||||
const titleLink = '[文档](https://example.com/a "标题")';
|
||||
assert.deepEqual(scanMarkdownImageStreaming(titleLink).ranges, [{ start: 0, end: titleLink.length }]);
|
||||
const unsafeLink = "[危险链接](javascript:alert(1))";
|
||||
assert.equal(stableStreamingMarkdown(unsafeLink, false), unsafeLink);
|
||||
});
|
||||
2
tests/retired-storage.config.ts
Normal file
2
tests/retired-storage.config.ts
Normal file
@ -0,0 +1,2 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
export default defineConfig({ testDir: './routes', testMatch: /retired-storage\.test\.ts/, workers: 1, reporter: 'list' });
|
||||
116
tests/routes/canvas-toolbar-capability.test.ts
Normal file
116
tests/routes/canvas-toolbar-capability.test.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
|
||||
import { executeCanvasToolbarCapability } from "@/lib/client/canvas-toolbar-capability";
|
||||
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { canvasService } from "@/services/canvas";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const node = (id: string) => ({ id, type: "image", position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 100 }, data: { src: `https://example.com/${id}.png` } });
|
||||
const command = () => createCanvasCapabilityClientCommand({
|
||||
projectId: "project-a", capabilityId: "image.remove_watermark", nodeIds: ["source"], source: "toolbar",
|
||||
});
|
||||
test.beforeEach(() => {
|
||||
useCanvasStore.getState().clearCanvas();
|
||||
useCanvasStore.getState().setProject("project-a");
|
||||
useCanvasStore.getState().loadCanvas({ nodes: [node("source") as any], edges: [] });
|
||||
useCanvasStore.getState().setCanvasRevision("r1", "r1");
|
||||
});
|
||||
test.afterEach(() => { globalThis.fetch = originalFetch; });
|
||||
|
||||
test("waits for the selected image to be saved before submitting and keeps subsequent saves behind the result", async () => {
|
||||
const queue = createCanvasSaveQueue();
|
||||
const events: string[] = [];
|
||||
let finishSave!: () => void;
|
||||
const pendingSave = new Promise<void>(resolve => { finishSave = resolve; });
|
||||
const pending = queue.enqueueOperation(async () => {
|
||||
events.push("saving"); await pendingSave; events.push("saved"); return true;
|
||||
}, async () => { events.push("submitted"); });
|
||||
const autosave = queue.enqueue(async () => { events.push("autosaved"); });
|
||||
await Promise.resolve();
|
||||
expect(events).toEqual(["saving"]);
|
||||
finishSave();
|
||||
await Promise.all([pending, autosave]);
|
||||
expect(events).toEqual(["saving", "saved", "submitted", "autosaved"]);
|
||||
});
|
||||
|
||||
test("a failed save does not submit a paid image task or block later saves", async () => {
|
||||
const queue = createCanvasSaveQueue();
|
||||
let submissions = 0;
|
||||
await expect(queue.enqueueOperation(async () => false, async () => { submissions++; })).rejects.toThrow("尚未保存成功");
|
||||
expect(submissions).toBe(0);
|
||||
expect(await queue.enqueue(async () => "saved")).toBe("saved");
|
||||
});
|
||||
|
||||
test("merges the result with edits made while processing and advances the save revision", async () => {
|
||||
globalThis.fetch = (async (url) => {
|
||||
if (String(url).endsWith("/capabilities")) {
|
||||
useCanvasStore.getState().moveNode("source", { x: 50, y: 80 });
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
return Response.json({ success: true, data: {
|
||||
updatedAt: "r2", canvasData: { nodes: [node("source"), node("result")], edges: [] },
|
||||
} });
|
||||
}) as typeof fetch;
|
||||
await executeCanvasToolbarCapability(command());
|
||||
const state = useCanvasStore.getState();
|
||||
expect(state.nodes.map(n => n.id)).toEqual(["source", "result"]);
|
||||
expect(state.nodes[0].position).toEqual({ x: 50, y: 80 });
|
||||
expect(state.canvasRevision).toBe("r2");
|
||||
});
|
||||
|
||||
test("a provider failure is shown without reloading or changing the canvas", async () => {
|
||||
globalThis.fetch = (async () => Response.json({ success: false, error: "去水印服务繁忙" }, { status: 503 })) as typeof fetch;
|
||||
await expect(executeCanvasToolbarCapability(command())).rejects.toThrow("去水印服务繁忙");
|
||||
expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["source"]);
|
||||
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
|
||||
});
|
||||
|
||||
test("switching projects during processing does not load the old result into the new project", async () => {
|
||||
globalThis.fetch = (async (url) => {
|
||||
if (String(url).endsWith("/capabilities")) {
|
||||
useCanvasStore.getState().setProject("project-b");
|
||||
useCanvasStore.getState().loadCanvas({ nodes: [node("other") as any], edges: [] });
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
return Response.json({ success: true, data: { updatedAt: "r2", canvasData: { nodes: [node("result")], edges: [] } } });
|
||||
}) as typeof fetch;
|
||||
await executeCanvasToolbarCapability(command());
|
||||
expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["other"]);
|
||||
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
|
||||
});
|
||||
|
||||
test("cover updates advance the local revision before the next queued save", async () => {
|
||||
const queue = createCanvasSaveQueue();
|
||||
globalThis.fetch = (async () => Response.json({
|
||||
success: true, previousCanvasRevision: "r1", data: { updatedAt: "r2" },
|
||||
})) as typeof fetch;
|
||||
const cover = queue.enqueue(() => canvasService.updateProject("project-a", { thumbnail: "https://example.com/source.png" }));
|
||||
const nextSave = queue.enqueue(async () => useCanvasStore.getState().canvasRevision);
|
||||
await cover;
|
||||
expect(await nextSave).toBe("r2");
|
||||
});
|
||||
|
||||
test("metadata changes never adopt an unseen remote canvas revision", async () => {
|
||||
globalThis.fetch = (async () => Response.json({
|
||||
success: true, previousCanvasRevision: "external-edit", data: { updatedAt: "r3" },
|
||||
})) as typeof fetch;
|
||||
await canvasService.updateProject("project-a", { title: "Renamed" });
|
||||
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
|
||||
});
|
||||
|
||||
test("a result merge conflict retains both the local edit and its original revision", async () => {
|
||||
globalThis.fetch = (async (url) => {
|
||||
if (String(url).endsWith("/capabilities")) {
|
||||
useCanvasStore.getState().moveNode("source", { x: 50, y: 80 });
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
return Response.json({ success: true, data: { updatedAt: "r2", canvasData: {
|
||||
nodes: [{ ...node("source"), position: { x: 200, y: 80 } }, node("result")], edges: [],
|
||||
} } });
|
||||
}) as typeof fetch;
|
||||
await expect(executeCanvasToolbarCapability(command())).rejects.toThrow("画布有冲突");
|
||||
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
|
||||
expect(useCanvasStore.getState().nodes[0].position).toEqual({ x: 50, y: 80 });
|
||||
});
|
||||
@ -1,5 +1,8 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { NextRequest } from "next/server";
|
||||
import sharp from "sharp";
|
||||
import { GET } from "@/app/api/image-proxy/route";
|
||||
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||
|
||||
test.describe("Qiniu image delivery", () => {
|
||||
test("uses Qiniu image processing for direct canvas previews", () => {
|
||||
@ -38,4 +41,119 @@ test.describe("Qiniu image delivery", () => {
|
||||
});
|
||||
expect(result).toContain("/api/image-proxy?");
|
||||
});
|
||||
|
||||
test("original downloads use an explicit same-origin proxy even for trusted signed CDN URLs", () => {
|
||||
const source = "https://yybb.top/uploads/result.png?token=example&expires=123";
|
||||
const result = new URL(buildImageProxyUrl(source, { delivery: "original" }), "https://app.example");
|
||||
expect(result.pathname).toBe("/api/image-proxy");
|
||||
expect(result.searchParams.get("url")).toBe(source);
|
||||
expect(result.searchParams.get("original")).toBe("1");
|
||||
expect(result.searchParams.has("w")).toBe(false);
|
||||
expect(buildImageProxyUrl("data:image/png;base64,AA==", { delivery: "original" })).toBe("data:image/png;base64,AA==");
|
||||
expect(buildImageProxyUrl("blob:example", { delivery: "original" })).toBe("blob:example");
|
||||
expect(buildImageProxyUrl("/api/canvas/file/1", { delivery: "original" })).toBe("/api/canvas/file/1");
|
||||
const nested = `/api/image-proxy?url=${encodeURIComponent(source)}&w=320&f=webp`;
|
||||
expect(new URL(buildImageProxyUrl(nested, { delivery: "original" }), "https://app.example").searchParams.get("url")).toBe(source);
|
||||
expect(buildImageProxyUrl(nested, { delivery: "original" })).not.toContain("w=320");
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: { location: { origin: "https://ai.yydd.ai" } } });
|
||||
try {
|
||||
const sameOrigin = `https://ai.yydd.ai${nested}`;
|
||||
expect(new URL(buildImageProxyUrl(sameOrigin, { delivery: "original" }), "https://ai.yydd.ai").searchParams.get("url")).toBe(source);
|
||||
const external = `https://other.example${nested}`;
|
||||
expect(new URL(buildImageProxyUrl(external, { delivery: "original" }), "https://ai.yydd.ai").searchParams.get("url")).toBe(external);
|
||||
} finally {
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
}
|
||||
});
|
||||
|
||||
test("original proxy preserves JPEG bytes and ICC and does not reuse transformed or stale responses", async () => {
|
||||
const source = "https://public.example/original.jpg?signature=abc";
|
||||
const jpeg = await sharp({ create: { width: 12, height: 12, channels: 3, background: "#345678" } })
|
||||
.withIccProfile("srgb").jpeg().toBuffer();
|
||||
const newer = await sharp({ create: { width: 12, height: 12, channels: 3, background: "#123456" } })
|
||||
.withIccProfile("srgb").jpeg().toBuffer();
|
||||
expect((await sharp(jpeg).metadata()).icc).toBeTruthy();
|
||||
const previousFetch = globalThis.fetch;
|
||||
let body = jpeg;
|
||||
globalThis.fetch = async () => new Response(body, { headers: { "Content-Type": "image/jpeg" } });
|
||||
try {
|
||||
const transformedUrl = new URL("http://localhost/api/image-proxy");
|
||||
transformedUrl.searchParams.set("url", source);
|
||||
transformedUrl.searchParams.set("proxy", "1");
|
||||
const transformed = await GET(new NextRequest(transformedUrl));
|
||||
expect(Buffer.from(await transformed.arrayBuffer()).equals(jpeg)).toBe(false);
|
||||
|
||||
const rawUrl = new URL("http://localhost/api/image-proxy");
|
||||
rawUrl.searchParams.set("url", source);
|
||||
rawUrl.searchParams.set("original", "1");
|
||||
const first = await GET(new NextRequest(rawUrl));
|
||||
const downloaded = Buffer.from(await first.arrayBuffer());
|
||||
expect(downloaded.equals(jpeg)).toBe(true);
|
||||
expect((await sharp(downloaded).metadata()).icc).toEqual((await sharp(jpeg).metadata()).icc);
|
||||
expect(first.headers.get("content-type")).toBe("image/jpeg");
|
||||
expect(first.headers.get("cache-control")).toBe("private, no-store");
|
||||
expect(first.headers.get("content-disposition")).toBe("attachment");
|
||||
expect(first.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
|
||||
body = newer;
|
||||
const next = await GET(new NextRequest(rawUrl, { headers: { "If-None-Match": first.headers.get("etag") || "" } }));
|
||||
expect(next.status).toBe(200);
|
||||
expect(Buffer.from(await next.arrayBuffer()).equals(newer)).toBe(true);
|
||||
} finally {
|
||||
globalThis.fetch = previousFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects processed storage URLs without removing signatures", () => {
|
||||
const qiniu = "https://yybb.top/a.jpg?imageView2/2/w/320&token=signed";
|
||||
const aliyun = "https://bucket.oss-cn.aliyuncs.com/a.jpg?x-oss-process=image%2Fresize%2Cw_320&signature=signed";
|
||||
for (const source of [qiniu, aliyun, `/api/image-proxy?url=${encodeURIComponent(qiniu)}&w=300`]) {
|
||||
expect(() => buildImageProxyUrl(source, { delivery: "original" })).toThrow("无法确认原图");
|
||||
}
|
||||
const signed = "https://yybb.top/a.jpg?token=example%2Fvalue&expires=123";
|
||||
expect(new URL(buildImageProxyUrl(signed, { delivery: "original" }), "https://app.example").searchParams.get("url")).toBe(signed);
|
||||
});
|
||||
|
||||
test("original proxy rejects private hosts, conflicting transforms and non-image content", async () => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
let fetchCount = 0;
|
||||
globalThis.fetch = async () => { fetchCount++; return new Response("not an image", { headers: { "Content-Type": "text/html" } }); };
|
||||
try {
|
||||
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=http%3A%2F%2F192.168.1.1%2Fsecret&original=1"))).status).toBe(403);
|
||||
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fa.jpg&original=1&w=200"))).status).toBe(400);
|
||||
expect(fetchCount).toBe(0);
|
||||
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fa.jpg&original=1"))).status).toBe(400);
|
||||
} finally {
|
||||
globalThis.fetch = previousFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("original proxy returns PNG and WebP data without format conversion", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
try {
|
||||
for (const format of ["png", "webp"] as const) {
|
||||
const image = sharp({ create: { width: 4, height: 4, channels: 4, background: "#11447780" } });
|
||||
const data = await (format === "png" ? image.png() : image.webp()).toBuffer();
|
||||
globalThis.fetch = async () => new Response(data, { headers: { "Content-Type": `image/${format}` } });
|
||||
const url = new URL("http://localhost/api/image-proxy");
|
||||
url.searchParams.set("url", `https://public.example/original.${format}`);
|
||||
url.searchParams.set("original", "1");
|
||||
const response = await GET(new NextRequest(url));
|
||||
expect(response.headers.get("content-type")).toBe(`image/${format}`);
|
||||
expect(Buffer.from(await response.arrayBuffer()).equals(data)).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("download filename extension follows the original image MIME", () => {
|
||||
expect(getImageDownloadExtension("image/png")).toBe("png");
|
||||
expect(getImageDownloadExtension("image/jpeg; charset=binary")).toBe("jpg");
|
||||
expect(getImageDownloadExtension("image/webp")).toBe("webp");
|
||||
expect(getImageDownloadExtension("image/avif")).toBe("avif");
|
||||
expect(getImageDownloadExtension("application/octet-stream")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
76
tests/routes/retired-storage.test.ts
Normal file
76
tests/routes/retired-storage.test.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { serverLoader } from '../helpers/canvas-download-server-loader';
|
||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from '@/lib/storage/retired-storage';
|
||||
import { resolveSiteBranding, DEFAULT_SITE_LOGO, DEFAULT_SITE_LOGO_DARK } from '@/lib/site-branding';
|
||||
|
||||
const retired = 'https://s3.1080p.hk/jsxiangche/2025/01/image.png';
|
||||
const current = 'https://scqnycdn.jiansun.vip/uploads/current.png';
|
||||
|
||||
test('retired URLs include stored proxy URLs but do not include live storage', () => {
|
||||
for (const url of [retired, retired.replace('https:', 'http:'), '/api/image-proxy?url=' + encodeURIComponent(retired)]) {
|
||||
expect(isRetiredStorageUrl(url)).toBe(true);
|
||||
}
|
||||
for (const url of [current, '/brand/logo-black.png', 'https://s3.jiansun.vip/gongxiangpan/a.png', 'https://example.com/s3.1080p.hk/a.png']) {
|
||||
expect(isRetiredStorageUrl(url)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('original image proxy returns permanent failure without redirecting or fetching', async () => {
|
||||
let fetches = 0;
|
||||
const route = serverLoader({ 'next/server': { NextResponse }, fetch: () => { fetches++; throw Error('Unexpected network'); } })('app/api/image-proxy/route.ts');
|
||||
const response = await route.GET(new NextRequest('https://ai.yydd.ai/api/image-proxy?original=1&url=' + encodeURIComponent(retired), {headers:{'sec-fetch-dest':'image'}}));
|
||||
expect(response.status).toBe(410);
|
||||
expect(await response.json()).toMatchObject({code:'storage_retired',error:RETIRED_STORAGE_MESSAGE});
|
||||
expect(fetches).toBe(0);
|
||||
});
|
||||
|
||||
test('byte cache never fetches retired resources', async () => {
|
||||
let fetches = 0;
|
||||
const module = serverLoader({ fetch: () => { fetches++; throw Error('Unexpected network'); } })('lib/storage/image-byte-cache.ts');
|
||||
await expect(module.fetchImageBytes(retired)).rejects.toThrow('HTTP 410');
|
||||
expect(fetches).toBe(0);
|
||||
});
|
||||
|
||||
test('retired sources cannot enter the archive queue', async () => {
|
||||
let inserts = 0;
|
||||
const module = serverLoader({
|
||||
'@/lib/db': {db:{insert:()=>{inserts++;throw Error('Unexpected insert');}}},
|
||||
'@/lib/db/schema': {},
|
||||
})('lib/storage/image-archive-db.ts');
|
||||
expect(await module.enqueueImageArchiveTask({sourceUrl:retired})).toBeNull();
|
||||
expect(inserts).toBe(0);
|
||||
});
|
||||
|
||||
test('single downloads reject retired sources before async preparation, while live candidates remain usable', async () => {
|
||||
const fetched: string[] = [];
|
||||
const route = serverLoader({
|
||||
'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})},
|
||||
'next/server':{NextResponse,after:()=>{}},
|
||||
'@/lib/storage/canvas-cdn-download':{},
|
||||
'@/lib/storage/image-download-preparation':{},
|
||||
'@/lib/canvas/photoshop-safe-download':{
|
||||
fetchCanvasDownloadAsset:async({file}:any)=>{fetched.push(file.url);return {ok:true,bytes:Buffer.from('original'),filename:'original.png',contentType:'image/png'};},
|
||||
sanitizeCanvasDownloadFileName:()=> 'original', buildAttachmentContentDisposition:()=> 'attachment',
|
||||
},
|
||||
})('app/api/canvas/download-image/route.ts');
|
||||
const request = (body:unknown) => new NextRequest('https://ai.yydd.ai/api/canvas/download-image',{method:'POST',body:JSON.stringify(body)});
|
||||
const failure = await route.POST(request({url:retired,preparation:'async',preferDirect:true}));
|
||||
expect(failure.status).toBe(410);
|
||||
expect(fetched).toEqual([]);
|
||||
const success = await route.POST(request({urls:[retired,current]}));
|
||||
expect(success.status).toBe(200);
|
||||
expect(fetched).toEqual([current]);
|
||||
expect(await success.text()).toBe('original');
|
||||
});
|
||||
|
||||
test('ZIP asset preparation reports permanent failure without retrying the source', async () => {
|
||||
const module = serverLoader({})( 'lib/canvas/photoshop-safe-download.ts');
|
||||
const result = await module.fetchCanvasDownloadAsset({file:{url:retired},index:0,requestOrigin:'https://ai.yydd.ai',cookie:''});
|
||||
expect(result).toMatchObject({ok:false,error:RETIRED_STORAGE_MESSAGE});
|
||||
});
|
||||
|
||||
test('branding replaces unavailable storage with theme variants and preserves custom live branding', () => {
|
||||
expect(resolveSiteBranding({siteLogo:retired,siteLogoDark:retired,favicon:retired})).toEqual({siteLogo:DEFAULT_SITE_LOGO,siteLogoDark:DEFAULT_SITE_LOGO_DARK,favicon:DEFAULT_SITE_LOGO});
|
||||
expect(resolveSiteBranding({siteLogo:current,siteLogoDark:current}).siteLogo).toBe(current);
|
||||
});
|
||||
@ -1,5 +1,10 @@
|
||||
import { mergeGenerationHistory, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { resolve } from "node:path";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import ts from "typescript";
|
||||
import sharp from "sharp";
|
||||
import {
|
||||
isVip98kReferenceSizeAllowed,
|
||||
@ -34,6 +39,9 @@ import {
|
||||
resolveVip98kSubmissionTimeoutMs,
|
||||
} from "@/lib/ai/vip98k-runtime-config";
|
||||
import { BANANAIMAGE_MODEL_PRESETS } from "@/lib/ai/bananaimage-models";
|
||||
import { buildTaskBody } from "@/lib/ai/providers/vip98k";
|
||||
import { getSceneRunResult } from "@/lib/agents/scene-run-recovery";
|
||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
||||
|
||||
const textToImageInvocation: Vip98kTaskInvocation = {
|
||||
kind: "tool",
|
||||
@ -46,6 +54,69 @@ const textToImageInvocation: Vip98kTaskInvocation = {
|
||||
"/api/external/tools/result?tool_id=google%2Fnano-banana-2%2Ftext-to-image&taskId={task_id}",
|
||||
};
|
||||
|
||||
test("forwards six requested outputs independently of reference count", () => {
|
||||
const invocation = selectVip98kTaskInvocation({ modelId: "gpt-image-2.5", configuredInvocation: null, imageCount: 2 });
|
||||
const { body } = buildTaskBody({
|
||||
apiKey: "test", modelId: "gpt-image-2.5", kind: "image", prompt: "Style the desk",
|
||||
imageUrls: ["https://assets.test/product.png", "https://assets.test/model.png"],
|
||||
videoUrls: [], modelConfig: {}, imageParams: { imageCount: 6, imageSize: "2K", aspectRatio: "1:1" },
|
||||
}, invocation);
|
||||
expect(body.n).toBe(6);
|
||||
expect(body.image).toHaveLength(2);
|
||||
expect(body.size).toBe("2048x2048");
|
||||
});
|
||||
|
||||
test("does not inject output count into a task contract without n", () => {
|
||||
const { body } = buildTaskBody({
|
||||
apiKey: "test", modelId: "nano-banana-2.1", kind: "image", prompt: "Desk",
|
||||
imageUrls: [], videoUrls: [], modelConfig: {}, imageParams: { imageCount: 6 },
|
||||
}, textToImageInvocation);
|
||||
expect(body).not.toHaveProperty("n");
|
||||
});
|
||||
|
||||
test("scene completion counts the current run and preserves partial results", () => {
|
||||
const user = { id: "run", role: "user", parts: [] };
|
||||
const result = (count: number) => ({ id: "result", role: "assistant", parts: [{ type: "text", text:
|
||||
Array.from({ length: count }, (_, i) => ``).join("\n\n") }] });
|
||||
expect(getSceneRunResult([user, result(6)], "run")).toEqual({ count: 6 });
|
||||
expect(getSceneRunResult([user, result(1)], "run")).toEqual({ count: 1 });
|
||||
expect(getSceneRunResult([user, result(0)], "run")).toEqual({ count: 0 });
|
||||
expect(getSceneRunResult([result(6), user], "run")).toEqual({ count: 0 });
|
||||
expect(getSceneRunResult([user, result(6), { ...user, id: "next" }], "run")).toBeNull();
|
||||
expect(getSceneRunResult([user, result(6)], "other")).toBeNull();
|
||||
});
|
||||
|
||||
test("provider generation retains all six outputs and submits only once", async () => {
|
||||
const filename = resolve("lib/ai/providers/vip98k.ts");
|
||||
const localRequire = createRequire(filename);
|
||||
const module = { exports: {} as any };
|
||||
const archived: string[] = [];
|
||||
const requests: any[] = [];
|
||||
const code = ts.transpileModule(readFileSync(filename, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true },
|
||||
}).outputText;
|
||||
runInNewContext(code, {
|
||||
module, exports: module.exports, Buffer, URL, AbortSignal, setTimeout, clearTimeout, process, console,
|
||||
require(id: string) {
|
||||
if (id === "@/lib/db/queries") return { getSystemSetting: async () => null };
|
||||
if (id === "@/lib/storage/image-archive") return {
|
||||
createDeferredUploadedImage: async (url: string) => { archived.push(url); return { original: url, thumbnail: url }; },
|
||||
};
|
||||
return localRequire(id.startsWith("@/") ? resolve(id.slice(2)) : id);
|
||||
},
|
||||
fetch: async (_url: string, init: RequestInit) => {
|
||||
requests.push(JSON.parse(String(init.body)));
|
||||
return Response.json({ data: Array.from({ length: 6 }, (_, i) => ({ url: `https://assets.test/result-${i}.png` })) });
|
||||
},
|
||||
});
|
||||
const model = module.exports.createVip98k({ apiKey: "test", baseURL: "https://provider.test" }).chat("gpt-image-2.5");
|
||||
const result = await model.doGenerate({ messages: [{ role: "user", content: "Style a desk" }], imageParams: { imageCount: 6 } });
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0].n).toBe(6);
|
||||
expect(archived).toHaveLength(6);
|
||||
expect(extractImageUrlsFromText(result.text)).toHaveLength(6);
|
||||
});
|
||||
|
||||
test.describe("VIP98K reference-image routing", () => {
|
||||
test("keeps the provider-selected relay route header", () => {
|
||||
const [preset] = buildVip98kModelPresets([
|
||||
|
||||
@ -222,6 +222,59 @@ class ActivationTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(RuntimeError, "Rollback health check failed"):
|
||||
host.activate(self.release_id)
|
||||
|
||||
def test_explicit_retry_preserves_the_failed_attempt(self):
|
||||
host.json_write(self.target / "activation.json", {"phase": "rolled-back", "previousBuildId": "old-build"})
|
||||
with patch.object(host, "healthy", return_value=True):
|
||||
result = host.activate(self.release_id, retry=True, operator_commit="tool-commit")
|
||||
self.assertEqual(result["phase"], "active")
|
||||
self.assertEqual(result["operatorCommit"], "tool-commit")
|
||||
history = list(self.target.glob("activation-history-*.json"))
|
||||
self.assertEqual(len(history), 1)
|
||||
self.assertEqual(json.loads(history[0].read_text())["phase"], "rolled-back")
|
||||
|
||||
def test_retry_cannot_replace_an_active_attempt(self):
|
||||
host.json_write(self.target / "activation.json", {"phase": "active"})
|
||||
with self.assertRaisesRegex(RuntimeError, "Already activated"):
|
||||
host.activate(self.release_id, retry=True)
|
||||
self.restart.assert_not_called()
|
||||
|
||||
def test_failed_process_start_also_restores_previous_definition(self):
|
||||
self.restart.side_effect = [RuntimeError("candidate start failed"), None]
|
||||
with patch.object(host, "healthy", return_value=True):
|
||||
with self.assertRaisesRegex(RuntimeError, "candidate start failed"):
|
||||
host.activate(self.release_id)
|
||||
self.assertEqual(self.restart.call_args_list[-1].args[0].name, "previous.pm2.json")
|
||||
|
||||
|
||||
class ProcessDefinitionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.config = Path(self.temporary.name) / "candidate.json"
|
||||
self.config.write_text(json.dumps({"apps": [{"name": "ai-chatbot", "cwd": "/new/release"}]}))
|
||||
|
||||
def tearDown(self):
|
||||
self.temporary.cleanup()
|
||||
|
||||
def test_recreates_only_target_application(self):
|
||||
with patch.object(host, "run", side_effect=[json.dumps([{"name": "ai-chatbot"}, {"name": "other-service"}]), "", ""]) as run:
|
||||
host.restart(self.config)
|
||||
self.assertEqual([call.args[0] for call in run.call_args_list], [
|
||||
["pm2", "jlist"], ["pm2", "delete", "ai-chatbot"],
|
||||
["pm2", "start", str(self.config), "--only", "ai-chatbot"]])
|
||||
|
||||
def test_can_restore_after_failed_candidate_left_no_process(self):
|
||||
with patch.object(host, "run", side_effect=["[]", ""]) as run:
|
||||
host.restart(self.config)
|
||||
self.assertEqual(len(run.call_args_list), 2)
|
||||
self.assertEqual(run.call_args_list[-1].args[0][1], "start")
|
||||
|
||||
def test_refuses_definitions_that_include_other_applications(self):
|
||||
self.config.write_text(json.dumps({"apps": [{"name": "ai-chatbot"}, {"name": "other"}]}))
|
||||
with patch.object(host, "run") as run:
|
||||
with self.assertRaisesRegex(RuntimeError, "multi-application"):
|
||||
host.restart(self.config)
|
||||
run.assert_not_called()
|
||||
|
||||
|
||||
class HelperTests(unittest.TestCase):
|
||||
def test_production_e2e_switch_blocks_release(self):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user