Compare commits

..

22 Commits

Author SHA1 Message Date
6053271352 同步封面和标题更新后的画布版本
自动封面和标题更新会推进画布版本,但页面未同步,导致后续节点保存持续冲突。元数据写入返回前一版本,客户端仅在与本地版本衔接时推进版本,并与画布保存共用队列;处理结果在合并成功后才更新本地版本。视频画布导入也按节点保存、标题更新顺序执行。

验证:17 项画布能力与同步测试通过,包含封面更新后的保存、不覆盖未同步的远端改动、结果合并冲突和切换画布;TypeScript 检查通过。
2026-11-03 14:39:48 +08:00
6b7b24a86e 修复图片工具提交前的画布同步与结果合并
新导入或新生成的图片在自动保存前点击去水印,会因服务端缺少节点而失败。工具栏统一先保存当前画布,保存成功后再调用处理能力;处理与自动保存共用队列,结果合并保留等待期间的本地编辑并更新保存版本。适用于去水印、抠图、增强、扩图和图层拆分。

验证:14 项画布能力与提交同步测试通过,覆盖保存失败不提交、执行期间编辑保留、上游失败和切换画布;TypeScript 检查通过。
2026-11-03 14:23:59 +08:00
c431a574c8 避免同名模型导致精选图片重复和计数偏差
同一 modelId 配置多个上游时,精选接口的模型关联会把一张图片返回多次,导致数量和分页偏大。改为为每张图片选择一个模型名称,保证图片记录和总数一致。

验证:生产只读数据包含同名模型两条,修正查询返回 6 个不同图片 ID、总数 6;TypeScript 检查通过。
2026-11-03 13:51:58 +08:00
1daf08a964 停用旧图片存储并更新主题 Logo
s3.1080p.hk 已永久停用且文件不可恢复。首页查询排除旧源并统一分页统计,历史图册、聊天和画布保留记录并显示失效说明;下载和代理返回 410,停止旧源上传与归档。使用本地黑白 Logo 适配主题。

提供保存原配置后停用旧存储、取消失效精选、替换 Logo 的整理脚本。七牛 10 月 14 日上传核查记录见 docs/retired-storage.md。

验证:7 项旧存储检查、41 项下载和归档回归测试、TypeScript 检查通过。
2026-11-03 13:36:44 +08:00
348cf4cf5d 按已锁定 SDK 的完成回调结束风格化等待
风格化请求 6 张图片时,98K 接入仍提交 n=1 且仅保留第一张结果,页面继续等待剩余图片。现在传递实际张数、保留全部返回结果,请求结束数量不足时结束等待并提示;刷新后也能从历史结果恢复结束状态。阿里通道参考图改为并行准备。

验证:46 项针对性测试通过,包括实际 Provider 流程提交一次并交付 6 张;TypeScript 检查通过。
2026-11-03 13:16:08 +08:00
a43a51adfc 保留连接中断时的生图恢复状态
风格化请求 6 张图片时,98K 接入仍提交 n=1 且仅保留第一张结果,页面继续等待剩余图片。现在传递实际张数、保留全部返回结果,请求结束数量不足时结束等待并提示;刷新后也能从历史结果恢复结束状态。阿里通道参考图改为并行准备。

验证:46 项针对性测试通过,包括实际 Provider 流程提交一次并交付 6 张;TypeScript 检查通过。
2026-11-03 13:11:37 +08:00
6ba5d9606d 修复风格化多张生成和结束状态
风格化请求 6 张图片时,98K 接入仍提交 n=1 且仅保留第一张结果,页面继续等待剩余图片。现在传递实际张数、保留全部返回结果,请求结束数量不足时结束等待并提示;刷新后也能从历史结果恢复结束状态。阿里通道参考图改为并行准备。

验证:46 项针对性测试通过,包括实际 Provider 流程提交一次并交付 6 张;TypeScript 检查通过。
2026-11-03 13:08:33 +08:00
02b9575a47 Merge pull request 'fix(home): deduplicate featured image cards' (#10) from codex/home-image-recovery into main 2026-11-03 09:55:56 +08:00
349735601c fix(home): deduplicate featured image cards 2026-11-03 09:55:32 +08:00
e837788d7a Merge pull request '恢复首页旧备用存储域名的可用缩略图' (#9) from codex/home-image-recovery into main 2026-11-03 09:27:39 +08:00
Codex
dfab95a654 Recover home previews through the documented storage alias 2026-11-03 09:26:31 +08:00
c35f5fc5a8 Merge pull request '修复首页失效封面与 Logo 的破图显示' (#8) from codex/home-image-recovery into main 2026-11-03 09:18:33 +08:00
Codex
e6c6e19e5a Handle unavailable home gallery images and brand logo 2026-11-03 09:17:59 +08:00
bb9d02a053 Merge pull request '修复结果文件链接加载时闪现字符' (#7) from codex/sku-stream-links into main 2026-11-02 19:10:55 +08:00
Codex
d9b06cf0b3 Keep streamed result file links intact 2026-11-02 19:10:16 +08:00
086c58fcc3 Merge pull request '修复生成结果加载时图片链接闪现异常' (#6) from codex/sku-stream-atomic into main 2026-11-02 18:37:28 +08:00
Codex
5a343b6392 Avoid partial image markup during streamed responses 2026-11-02 18:36:50 +08:00
e6f07ed2ce Merge pull request '修复原图下载及 SKU 结果展示' (#5) from codex/sku-original-image-display into main 2026-11-02 17:47:03 +08:00
Codex
db46983725 Fix original image downloads and SKU message rendering 2026-11-02 17:46:31 +08:00
ae4605d479 Merge pull request 'fix(deploy): 切换时重建单个 PM2 应用定义并保留回滚日志' (#4) from codex/production-48-pm2-definition into main 2026-10-30 19:16:50 +08:00
Codex
36efdfae53 fix(deploy): replace only the target PM2 process definition 2026-10-30 19:15:56 +08:00
10efb4c1e7 Merge pull request 'build: 独立封装发布依赖并用写入时复制控制空间' (#3) from codex/production-48-runtime-snapshot into main 2026-10-30 18:43:39 +08:00
51 changed files with 1489 additions and 200 deletions

View File

@ -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}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 折优惠",

View File

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

View File

@ -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.

View File

@ -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}

View 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);
}

View File

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

View File

@ -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;

View 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>
);
}

View File

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

View File

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

View File

@ -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 (
<>
{/* 缩略图(显示压缩后的缩略图)*/}

View File

@ -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
View 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 的差额。本次未删除或改动七牛对象。

View 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 };
}

View File

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

View File

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

View 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;
}

View 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;
}

View File

@ -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;

View File

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

View 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));
}

View File

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

View File

@ -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
View 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,
};
}

View File

@ -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;

View File

@ -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) {

View File

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

View File

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

View 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;
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

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

View File

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

View 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());

View File

@ -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: '更新画布失败' };

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

View 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[![第2行](${image})](${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(`[![a](${image})](${image})\n![b](https://example.com/b.png)`);
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 = `[![thumbnail](${image})](https://example.com/full.jpg)`;
const input = `[文档](https://example.com/doc) ${nested} ![plain](https://example.com/next.jpg)`;
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("![a](https://example.com/a\\(1\\).jpg)"), [
{ type: "image", alt: "a", src: "https://example.com/a(1).jpg" },
]);
});
test("does not extract examples in inline and fenced code", () => {
const input = `\`![code](${image})\`\n\`\`\`md\n[![a](${image})](${image})\n\`\`\`\n~~~md\n![c](${image})\n~~~\n![actual](${image})`;
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 [
`[![x](${image})](javascript:alert(1))`,
`[![x](${image})](`,
`\\![x](${image})`,
"![x](data:image/png;base64,AAAA)",
]) 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(`![${alt}](${image})`), [
{ type: "image", alt, src: image },
]);
}
});

View 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 = `[![01-样例.jpg](${url})](${url})`;
const other = `[![缩略图](${url})](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 ["[", "[!", "[![name](https://example.com/a.jpg)]("]) {
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 = "![图](https://example.com/a_(1).jpg)";
for (let n = 1; n < simple.length; n += 1) {
assert.equal(stableStreamingMarkdown("前文 " + simple.slice(0, n), true), "前文 ", `prefix ${n}`);
}
const literal = "`![code](https://example.com/a` and \\![literal] and [ordinary](https://example.com)";
assert.equal(stableStreamingMarkdown(literal, true), literal);
const fence = "~~~md\n[![sample](https://example.com\n~~~\n";
assert.equal(stableStreamingMarkdown(fence, true), fence);
assert.deepEqual(scanMarkdownImageStreaming(fence + simple).ranges, [{ start: fence.length, end: fence.length + simple.length }]);
const invalid = "![bad](https://example.com/a b) 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)", "[![x](https://example.com/x.jpg)](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);
});

View File

@ -0,0 +1,2 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({ testDir: './routes', testMatch: /retired-storage\.test\.ts/, workers: 1, reporter: 'list' });

View 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 });
});

View File

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

View 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);
});

View File

@ -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) => `![thumb|||https://assets.test/${i}.png|||2K](https://assets.test/${i}.png)`).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([

View File

@ -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):