Compare commits

..

20 Commits

Author SHA1 Message Date
00cef71be7 暂停欠费生图线路并修正 98K 文生图尺寸传递
## Summary

生图线路确认欠费后立即停止新请求,跳过同一上游账户的其他模型,保留已生成结果和已提交任务。欠费状态须经管理员恢复,不再定时自动解封,也不会被旧任务的成功回执清除。旧模型选择仅使用明确配置且尺寸、比例和参考图数量兼容的备用模型;固定模型模式仍要求用户自行选择。

98K 文生图优先使用其公开声明的任务接口,原生传递分辨率和比例,清除文生图请求中的示例参考图片。

## Scope

- [x] API / backend logic
- [x] Settings / admin configuration

## Required Verification

验证普通 Pro、Banana 2、两个 Plus 和 GPT Image 2 共 10 个真实文生图及参考图编辑用例,均获得可下载的 2048×2048 图片。625 项 helper 回归通过,1 项跳过;TypeScript、日志/品牌/CI/环境/凭据/元数据/测试/产物检查通过。全仓 Biome 仍有历史格式和 lint 诊断(1005 项)。构建机执行生产构建及路由烟测;生产切换前核对任务与请求,保留旧构建和模型配置回滚记录。

## Release Readiness

仅修改本次生图运行逻辑与测试,凭据和验证产物保存在仓库外或忽略目录中。内部生成接口的权限与访问方式保持兼容。
2026-11-03 19:20:18 +08:00
525e105876 整理上游搜索与编辑入口
上游搜索位于管理列表顶部,编辑弹窗仅保留配置字段;编辑和删除按钮补充可访问名称与悬停说明。保留已有上游预设、同步及编辑功能。

验证:TypeScript 与页面 lint 通过,发布后验收上游搜索和编辑弹窗。
2026-11-03 18:09:45 +08:00
5371ae2617 关联记录模型名称并处理预览下载超时
生成记录同时包含模型内部 ID 和上游模型 ID,名称显示和筛选统一关联模型目录。图片代理处理源响应体超时与传输中断,返回明确的 504/502;浏览器取消请求同步停止上游下载。小尺寸预览允许 45 秒读取窗口,前端保留 4 路并发和可见区域加载,失败提示展示接口原因。原图下载保持字节不变。

验证:模型 ID 筛选的生产只读查询返回正确记录,浏览器夹具验证内部 ID 名称和峰值并发 4;15 项相关测试覆盖取消、响应体超时、原图字节与 ICC 保留。TypeScript 与改动文件 lint 通过。
2026-11-03 18:04:14 +08:00
af8189908d 优化后台生成记录分页与模型管理
生成记录改为数据库分页,每页 40 条,默认最近 7 天;可见预览限制为 4 路并发并使用 192px 缩略图,翻页取消旧请求,视频在详情中按需播放。保留精选、分类和原图操作,新增画布任务失败记录,统一时间显示。

模型管理拆分模型、上游和运行健康视图,提供搜索、筛选、分页和完整故障详情;列表使用摘要,编辑按需获取完整配置。后台按当前设置页加载,模型 GET 不再自动修改健康状态,98K 同步先显示变更预览。

验证:600 项现有辅助测试和 3 项新增回归测试通过,TypeScript 和本次 API/组件 lint 通过;浏览器验证请求峰值 4、翻页、搜索、失败重试、详情、模型筛选与 390px 窄屏。只读生产查询:当天页约 29ms,历史视频页 122ms,第 7800 页 739ms。两个并发索引已在生产建成,SQL 随代码保留。

已知检查状态:全仓 lint 存量 1004 项诊断使 check:quality 停在 lint 阶段;日志、品牌、CI、环境、凭据、元数据、测试与制品检查单独通过。
2026-11-03 17:41:34 +08:00
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
72 changed files with 2838 additions and 1238 deletions

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@ import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-c
import type { ChatMessage } from "@/lib/types"; import type { ChatMessage } from "@/lib/types";
import { convertMessagesWithImages } from "./image-media"; import { convertMessagesWithImages } from "./image-media";
import { isProviderBalanceError } from "@/lib/request-error"; import { isProviderBalanceError } from "@/lib/request-error";
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown"; import { isImageModelBalanceBlocked } from "@/lib/ai/runtime/image-balance";
const chatApiLogger = createLogger("chat.imageFailover"); const chatApiLogger = createLogger("chat.imageFailover");
@ -326,7 +326,7 @@ export function buildImageFallbackCandidates(
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) { if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
return false; return false;
} }
if (isImageFallbackModelCoolingDown(item.model.id) || isImageFallbackModelBalanceCoolingDown(item.model)) { if (isImageFallbackModelCoolingDown(item.model.id) || isImageModelBalanceBlocked(item.model)) {
const cooldown = imageFailoverModelCooldown.get(item.model.id); const cooldown = imageFailoverModelCooldown.get(item.model.id);
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`); chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`);
return false; return false;
@ -488,7 +488,7 @@ export function getImageCrossProviderCandidates(
} }
if (m.model.id === options.currentModelId) return false; if (m.model.id === options.currentModelId) return false;
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false; if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
if (isImageFallbackModelCoolingDown(m.model.id) || isImageFallbackModelBalanceCoolingDown(m.model)) { if (isImageFallbackModelCoolingDown(m.model.id) || isImageModelBalanceBlocked(m.model)) {
const cooldown = imageFailoverModelCooldown.get(m.model.id); const cooldown = imageFailoverModelCooldown.get(m.model.id);
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`); chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`);
return false; return false;

View File

@ -336,6 +336,7 @@ import {
} from "./_lib/image-media"; } from "./_lib/image-media";
import { import {
buildImageFallbackCandidates, buildImageFallbackCandidates,
countChatMessageImageInputs,
generateImageWithFailoverCandidate, generateImageWithFailoverCandidate,
getImageCrossProviderCandidates, getImageCrossProviderCandidates,
IMAGE_FAILOVER_PROVIDER_ALLOWLIST, IMAGE_FAILOVER_PROVIDER_ALLOWLIST,
@ -344,6 +345,8 @@ import {
setImageFallbackModelCooldown, setImageFallbackModelCooldown,
shouldCooldownImageFallbackModel, shouldCooldownImageFallbackModel,
} from "./_lib/image-failover"; } from "./_lib/image-failover";
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
import { isProviderBalanceError } from "@/lib/request-error";
import { import {
buildAgentSceneWorkflowPrompt, buildAgentSceneWorkflowPrompt,
buildAgentSlotPrompt, buildAgentSlotPrompt,
@ -8769,6 +8772,19 @@ export async function POST(request: Request) {
} }
} }
if (/^[0-9a-f-]{36}$/i.test(finalModelId)) {
const requested = await getAiModelById({ id: finalModelId });
if (requested?.supportsImageGen && isImageModelBalanceBlocked(requested)) {
const replacement = resolveConfiguredImageBalanceFallback(requested, await getEnabledAiModelsWithProvider(), {
imageSize: effectiveImageGenerationParams.imageSize,
aspectRatio: effectiveImageGenerationParams.aspectRatio,
referenceImageCount: countChatMessageImageInputs(uiMessagesForModel.filter(item => item.role === "user").slice(-1)),
});
if (!replacement) return new ChatSDKError("bad_request:api", "当前生图线路余额不足,已暂停新请求;请切换可用模型,已有任务和图片会保留。").toResponse();
finalModelId = replacement.model.id;
}
}
// 检查是否是 UUID 格式(数据库中的模型 ID) // 检查是否是 UUID 格式(数据库中的模型 ID)
const isUUID = const isUUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
@ -10002,7 +10018,10 @@ export async function POST(request: Request) {
let fallbackSuccess = false; let fallbackSuccess = false;
const triedFallbackModelIds = new Set<string>(); const triedFallbackModelIds = new Set<string>();
const balanceBlockedProviders = new Set<string>();
if (isProviderBalanceError(primaryError) && currentModelEntry?.provider.id) balanceBlockedProviders.add(currentModelEntry.provider.id);
for (const candidate of fallbackCandidates) { for (const candidate of fallbackCandidates) {
if (balanceBlockedProviders.has(candidate.provider.id)) continue;
try { try {
triedFallbackModelIds.add(String(candidate.model.id || "")); triedFallbackModelIds.add(String(candidate.model.id || ""));
chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`); chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`);
@ -10025,6 +10044,7 @@ export async function POST(request: Request) {
chatApiLogger.info(`[Chat API] ✅ Alibaba fallback model ${fallbackResult.displayName} succeeded!`); chatApiLogger.info(`[Chat API] ✅ Alibaba fallback model ${fallbackResult.displayName} succeeded!`);
break; break;
} catch (fallbackError) { } catch (fallbackError) {
if (isProviderBalanceError(fallbackError)) balanceBlockedProviders.add(candidate.provider.id);
const fallbackErrorMessage = normalizeFailoverErrorMessage(fallbackError); const fallbackErrorMessage = normalizeFailoverErrorMessage(fallbackError);
if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) { if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) {
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage); setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
@ -10073,9 +10093,10 @@ export async function POST(request: Request) {
currentModelKey: actualModelId, currentModelKey: actualModelId,
currentProviderId: alibabaModelConfig?.providerId, currentProviderId: alibabaModelConfig?.providerId,
providerPriority, providerPriority,
}).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || ""))); }).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || "")) && !balanceBlockedProviders.has(candidate.provider.id));
for (const candidate of crossProviderCandidates) { for (const candidate of crossProviderCandidates) {
if (balanceBlockedProviders.has(candidate.provider.id)) continue;
try { try {
triedFallbackModelIds.add(String(candidate.model.id || "")); triedFallbackModelIds.add(String(candidate.model.id || ""));
chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`); chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`);
@ -10157,6 +10178,7 @@ export async function POST(request: Request) {
chatApiLogger.info(`[Chat API] ✅ Cross-provider failover succeeded: ${candidate.model.displayName} (${candidate.provider.type})`); chatApiLogger.info(`[Chat API] ✅ Cross-provider failover succeeded: ${candidate.model.displayName} (${candidate.provider.type})`);
break; break;
} catch (crossError) { } catch (crossError) {
if (isProviderBalanceError(crossError)) balanceBlockedProviders.add(candidate.provider.id);
const crossErrorMessage = normalizeFailoverErrorMessage(crossError); const crossErrorMessage = normalizeFailoverErrorMessage(crossError);
if (shouldCooldownImageFallbackModel(crossErrorMessage)) { if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage); setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage);

View File

@ -1,4 +1,5 @@
"use client"; "use client";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -316,7 +317,12 @@ export default function GalleryPage() {
<> <>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="flex flex-wrap justify-center gap-6 px-4"> <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 <div
key={img.id} key={img.id}
className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105" className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105"
@ -325,7 +331,7 @@ export default function GalleryPage() {
> >
<div className="relative w-full h-full"> <div className="relative w-full h-full">
<Image <Image
src={img.thumbnailUrl || img.url} src={isRetiredStorageUrl(img.thumbnailUrl) ? img.url : img.thumbnailUrl || img.url}
alt={img.alt} alt={img.alt}
width={200} width={200}
height={270} height={270}

View File

@ -1,8 +1,10 @@
import { z } from "zod"; import { z } from "zod";
import { requireAdmin } from "@/lib/middleware/admin"; import { requireAdmin } from "@/lib/middleware/admin";
import { ChatSDKError } from "@/lib/errors"; import { ChatSDKError } from "@/lib/errors";
import { db } from "@/lib/db";
import { aiModelHealthEvent } from "@/lib/db/schema";
import { and, desc, eq, sql } from "drizzle-orm";
import { import {
listAiModelHealthEvents,
restoreAiModelHealth, restoreAiModelHealth,
} from "@/lib/ai/runtime/model-health"; } from "@/lib/ai/runtime/model-health";
@ -15,14 +17,30 @@ export async function GET(request: Request) {
await requireAdmin(); await requireAdmin();
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const modelId = searchParams.get("modelId") || undefined; const modelId = searchParams.get("modelId");
const limit = Number(searchParams.get("limit") || 80); const providerId = searchParams.get("providerId");
const events = await listAiModelHealthEvents({ const rawLimit = Number(searchParams.get("limit") || 20);
modelId, const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(200, Math.floor(rawLimit))) : 20;
limit: Number.isFinite(limit) ? limit : 80, const rawPage = Number(searchParams.get("page") || 1);
}); const page = Number.isFinite(rawPage) ? Math.max(1, Math.floor(rawPage)) : 1;
const filters = [];
return Response.json({ events }); if (modelId) filters.push(eq(aiModelHealthEvent.modelId, modelId));
if (providerId) filters.push(eq(aiModelHealthEvent.providerId, providerId));
if (searchParams.get("eventType")) filters.push(eq(aiModelHealthEvent.eventType, searchParams.get("eventType")!));
const start = searchParams.get("startDate");
const end = searchParams.get("endDate");
if (start && /^\d{4}-\d{2}-\d{2}$/.test(start)) filters.push(sql`${aiModelHealthEvent.createdAt}>=${start}::date`);
if (end && /^\d{4}-\d{2}-\d{2}$/.test(end)) filters.push(sql`${aiModelHealthEvent.createdAt}<${end}::date+interval '1 day'`);
const events = await db.select({
id: aiModelHealthEvent.id, modelId: aiModelHealthEvent.modelId, modelDisplayName: aiModelHealthEvent.modelDisplayName,
modelRuntimeId: aiModelHealthEvent.modelRuntimeId, providerName: aiModelHealthEvent.providerName,
providerId: aiModelHealthEvent.providerId, eventType: aiModelHealthEvent.eventType,
errorMessage: aiModelHealthEvent.errorMessage, elapsedMs: aiModelHealthEvent.elapsedMs,
consecutiveFailures: aiModelHealthEvent.consecutiveFailures, autoDisabled: aiModelHealthEvent.autoDisabled,
counted: aiModelHealthEvent.counted, createdAt: aiModelHealthEvent.createdAt,
}).from(aiModelHealthEvent).where(and(...filters)).orderBy(desc(aiModelHealthEvent.createdAt), desc(aiModelHealthEvent.id))
.limit(limit + 1).offset((page - 1) * limit);
return Response.json({ events: events.slice(0, limit), hasMore: events.length > limit, page });
} catch (error) { } catch (error) {
if (error instanceof ChatSDKError) { if (error instanceof ChatSDKError) {
return error.toResponse(); return error.toResponse();

View File

@ -1,9 +1,10 @@
import { z } from "zod"; import { z } from "zod";
import { db } from "@/lib/db";
import { aiModel, aiProvider } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { requireAdmin } from "@/lib/middleware/admin"; import { requireAdmin } from "@/lib/middleware/admin";
import { import {
getAllAiModels,
getAiModelById, getAiModelById,
getAiModelsByProviderId,
createAiModel, createAiModel,
updateAiModel, updateAiModel,
deleteAiModel, deleteAiModel,
@ -154,8 +155,14 @@ export async function GET(request: Request) {
const id = searchParams.get("id"); const id = searchParams.get("id");
const providerId = searchParams.get("providerId"); const providerId = searchParams.get("providerId");
if (searchParams.get("view") === "options") {
const models = await db.select({ id: aiModel.id, modelId: aiModel.modelId, displayName: aiModel.displayName,
providerName: aiProvider.name }).from(aiModel).leftJoin(aiProvider, eq(aiModel.providerId, aiProvider.id));
return Response.json({ models: sortModelsForDisplay(models) });
}
if (id) { if (id) {
const model = await getAiModelById({ id }); const model = await db.query.aiModel.findFirst({ where: eq(aiModel.id, id) });
if (!model) { if (!model) {
return new ChatSDKError("bad_request:api", "Model not found").toResponse(); return new ChatSDKError("bad_request:api", "Model not found").toResponse();
} }
@ -163,12 +170,19 @@ export async function GET(request: Request) {
} }
if (providerId) { if (providerId) {
const models = await getAiModelsByProviderId({ providerId }); const models = await db.select().from(aiModel).where(eq(aiModel.providerId, providerId));
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) }); return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
} }
const models = await getAllAiModels(); const models = await db.select().from(aiModel);
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) }); const normalized = models.map(normalizeModel);
const output = searchParams.get("view") === "summary" ? normalized.map(model => {
const { capabilities, defaultParams, imageGenConfig, description, ...summary } = model;
const fields = ["kind", "mediaType", "modelType", "outputType", "supportedModes"];
return { ...summary, healthLastError: summary.healthLastError?.slice(0, 1200),
capabilities: Object.fromEntries(fields.filter(key => capabilities?.[key] !== undefined).map(key => [key, capabilities[key]])) };
}) : normalized;
return Response.json({ models: sortModelsForDisplay(output) });
} catch (error) { } catch (error) {
if (error instanceof ChatSDKError) { if (error instanceof ChatSDKError) {
return error.toResponse(); return error.toResponse();

View File

@ -1,237 +1,31 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { chat, generatedImage, message, user } from "@/lib/db/schema"; import { generatedImage, user } from "@/lib/db/schema";
import { eq, desc, sql, and, gte, lte, like, or, isNotNull } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { requireAdmin } from "@/lib/middleware/admin";
function extractVideoUrlFromParts(parts: unknown): string | null { import { ChatSDKError } from "@/lib/errors";
const text = Array.isArray(parts) import { mediaRecordsQuery, parseMediaQuery } from "@/lib/admin/media-records";
? parts import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
.map((part) => (part && typeof part === "object" && "text" in part ? String((part as any).text || "") : ""))
.join("\n")
: typeof parts === "string"
? parts
: "";
if (!text) return null;
const htmlMatch = text.match(/<video[^>]+src="([^"]+)"/i);
if (htmlMatch?.[1]) return htmlMatch[1];
const markdownMatch = text.match(/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/i);
if (markdownMatch?.[1]) return markdownMatch[1];
const plainMatch = text.match(/(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/i);
return plainMatch?.[1] || null;
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const session = await auth(); const session = await requireAdmin();
const operator = await db.query.user.findFirst({ where: eq(user.id, session.user.id) });
if (!session?.user?.id) { if (operator?.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const query = parseMediaQuery(request.nextUrl.searchParams);
if (query.startDate && query.endDate && query.startDate > query.endDate) {
return NextResponse.json({ error: "开始日期不能晚于结束日期" }, { status: 400 });
} }
const [result] = await db.execute(mediaRecordsQuery(query));
// 检查是否为管理员 const images = await resolveArchivedImageUrls(result.records);
const currentUser = await db.query.user.findFirst({ const total = Number(result.total);
where: eq(user.id, session.user.id), return NextResponse.json({ images, pagination: { page: query.page, pageSize: query.pageSize, total,
}); totalPages: Math.ceil(total / query.pageSize), totalImages: Number(result.totalImages), totalVideos: Number(result.totalVideos) } });
if (currentUser?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// 获取查询参数
const searchParams = request.nextUrl.searchParams;
const page = Number.parseInt(searchParams.get("page") || "1");
const pageSize = Number.parseInt(searchParams.get("pageSize") || "200");
const offset = (page - 1) * pageSize;
const search = searchParams.get("search") || "";
const startDate = searchParams.get("startDate");
const endDate = searchParams.get("endDate");
// 构建查询条件
const imageConditions = [];
const videoConditions = [
eq(message.role, "assistant"),
or(
isNotNull(message.temporaryVideoUrl),
isNotNull(message.permanentVideoUrl),
sql`${message.parts}::text ILIKE '%<video%'`,
sql`${message.parts}::text ILIKE '%.mp4%'`
),
];
if (search) {
// 搜索用户名或邮箱
const matchingUsers = await db
.select({ id: user.id })
.from(user)
.where(or(
like(user.name, `%${search}%`),
like(user.email, `%${search}%`)
));
if (matchingUsers.length > 0) {
const matchedUserIds = matchingUsers.map((u) => u.id);
imageConditions.push(
sql`${generatedImage.userId} IN (${sql.join(matchedUserIds.map((id) => sql`${id}`), sql`, `)})`
);
videoConditions.push(
sql`${chat.userId} IN (${sql.join(matchedUserIds.map((id) => sql`${id}`), sql`, `)})`
);
} else {
// 没有匹配的用户,返回空结果
return NextResponse.json({
images: [],
pagination: { page, pageSize, total: 0, totalPages: 0 }
});
}
}
if (startDate) {
const start = new Date(startDate);
imageConditions.push(gte(generatedImage.createdAt, start));
videoConditions.push(gte(message.createdAt, start));
}
if (endDate) {
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
imageConditions.push(lte(generatedImage.createdAt, end));
videoConditions.push(lte(message.createdAt, end));
}
const imageWhereClause = imageConditions.length > 0 ? and(...imageConditions) : undefined;
const videoWhereClause = videoConditions.length > 0 ? and(...videoConditions) : undefined;
// 查询图片总数
const imageCountResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(generatedImage)
.where(imageWhereClause);
// 查询视频总数
const videoCountResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(message)
.innerJoin(chat, eq(message.chatId, chat.id))
.where(videoWhereClause);
const totalImages = imageCountResult[0]?.count || 0;
const totalVideos = videoCountResult[0]?.count || 0;
const total = totalImages + totalVideos;
const totalPages = Math.ceil(total / pageSize);
const fetchLimit = Math.max(page * pageSize, pageSize);
// 查询图片列表(包含用户信息)
const imageRows = await db
.select({
id: generatedImage.id,
userId: generatedImage.userId,
userName: user.name,
userEmail: user.email,
userDepartment: user.department,
thumbnailUrl: generatedImage.thumbnailUrl,
originalUrl: generatedImage.originalUrl,
imageSize: generatedImage.imageSize,
prompt: generatedImage.prompt,
modelId: generatedImage.modelId,
isFeatured: generatedImage.isFeatured,
featuredCategory: generatedImage.featuredCategory,
featuredAt: generatedImage.featuredAt,
createdAt: generatedImage.createdAt,
chatId: generatedImage.chatId,
recordType: sql<"image">`'image'`,
videoUrl: sql<string | null>`null`,
})
.from(generatedImage)
.leftJoin(user, eq(generatedImage.userId, user.id))
.where(imageWhereClause)
.orderBy(desc(generatedImage.createdAt))
.limit(fetchLimit);
// 查询视频列表(来自消息表)
const videoRows = await db
.select({
id: message.id,
userId: chat.userId,
userName: user.name,
userEmail: user.email,
userDepartment: user.department,
temporaryVideoUrl: message.temporaryVideoUrl,
permanentVideoUrl: message.permanentVideoUrl,
parts: message.parts,
videoMetadata: message.videoMetadata,
createdAt: message.createdAt,
chatId: message.chatId,
})
.from(message)
.innerJoin(chat, eq(message.chatId, chat.id))
.leftJoin(user, eq(chat.userId, user.id))
.where(videoWhereClause)
.orderBy(desc(message.createdAt))
.limit(fetchLimit);
const normalizedVideos = videoRows
.map((item) => {
const metadata = (item.videoMetadata as Record<string, any> | null) || {};
const videoUrl =
item.permanentVideoUrl ||
item.temporaryVideoUrl ||
extractVideoUrlFromParts(item.parts);
if (!videoUrl) return null;
const resolution =
typeof metadata.resolution === "string" && metadata.resolution.trim()
? metadata.resolution.trim()
: null;
const prompt =
typeof metadata.prompt === "string" && metadata.prompt.trim()
? metadata.prompt.trim()
: null;
const modelId =
typeof metadata.model === "string" && metadata.model.trim()
? metadata.model.trim()
: null;
return {
id: `video-${item.id}`,
sourceMessageId: item.id,
userId: item.userId,
userName: item.userName,
userEmail: item.userEmail,
userDepartment: item.userDepartment,
thumbnailUrl: videoUrl,
originalUrl: videoUrl,
videoUrl,
imageSize: resolution,
prompt,
modelId,
isFeatured: false,
featuredCategory: null,
featuredAt: null,
createdAt: item.createdAt,
chatId: item.chatId,
recordType: "video" as const,
};
})
.filter((item): item is NonNullable<typeof item> => Boolean(item));
const merged = [...imageRows, ...normalizedVideos].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
const images = merged.slice(offset, offset + pageSize);
return NextResponse.json({
images,
pagination: { page, pageSize, total, totalPages, totalImages, totalVideos }
});
} catch (error) { } catch (error) {
console.error("[Admin Images API] Error:", error); if (error instanceof ChatSDKError) return error.toResponse();
return NextResponse.json({ error: "Failed to fetch images" }, { status: 500 }); console.error("[Admin Images API] GET failed", error);
return NextResponse.json({ error: "记录加载失败,请重试" }, { status: 500 });
} }
} }

View File

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { sql } from "drizzle-orm";
import { requireAdmin } from "@/lib/middleware/admin";
import { ChatSDKError } from "@/lib/errors";
import { parseMediaQuery } from "@/lib/admin/media-records";
export async function GET(request: NextRequest) {
try {
await requireAdmin();
const q = parseMediaQuery(request.nextUrl.searchParams);
const status = request.nextUrl.searchParams.get("status") || "all";
const filters = [sql`true`];
if (q.search)
filters.push(sql`(u.name ilike ${`%${q.search}%`} or u.email ilike ${`%${q.search}%`})`);
if (["pending", "running", "failed", "succeeded"].includes(status))
filters.push(sql`t.status=${status}`);
if (q.startDate) filters.push(sql`t."createdAt">=${q.startDate}::date - interval '8 hours'`);
if (q.endDate) filters.push(sql`t."createdAt"<${q.endDate}::date+interval '16 hours'`);
const [result] = await db.execute(sql`with filtered as not materialized (
select t.id,u.name as "userName",t.status,t."createdAt"+interval '8 hours' as "createdAt",t.error,coalesce(t.request->>'modelId',t.request->>'model') as model,
extract(epoch from (t."completedAt"-t."startedAt"))*1000 as "elapsedMs"
from "CanvasImageTask" t left join "User" u on u.id=t."userId" where ${sql.join(filters, sql` and `)}
) select (select count(*)::int from filtered) as total,
coalesce((select jsonb_agg(p) from (select * from filtered order by "createdAt" desc,id desc limit ${q.pageSize} offset ${(q.page - 1) * q.pageSize}) p),'[]'::jsonb) as tasks`);
return NextResponse.json(result);
} catch (error) {
if (error instanceof ChatSDKError) return error.toResponse();
return NextResponse.json({ error: "任务记录加载失败,请重试" }, { status: 500 });
}
}

View File

@ -106,6 +106,22 @@ export async function POST(request: Request) {
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId)) ? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
: catalogPresets; : catalogPresets;
if (body?.dryRun === true) {
const catalogIds = new Set(catalogPresets.map(preset => preset.modelId));
const fields = ["displayName", "description", "supportsVision", "supportsImageGen", "isHidden", "sortOrder", "capabilities", "defaultParams", "imageGenConfig"] as const;
const added = presets.filter(preset => !existingModelIds.has(preset.modelId)).map(preset => ({ modelId: preset.modelId, displayName: preset.displayName }));
const updated = presets.flatMap(preset => {
const existing = existingModels.find(model => model.modelId === preset.modelId);
if (!existing) return [];
const changedFields: string[] = fields.filter(field => preset[field] !== undefined && JSON.stringify(existing[field]) !== JSON.stringify(preset[field]));
if (existing.avatarUrl) changedFields.push("avatarUrl");
if (requestedModelIds !== null && !existing.isEnabled) changedFields.push("isEnabled");
return changedFields.length ? [{ modelId: preset.modelId, displayName: existing.displayName, changedFields }] : [];
});
const disabled = requestedModelIds === null ? existingModels.filter(model => !catalogIds.has(model.modelId) && (model.isEnabled || !model.isHidden)).map(model => ({ modelId: model.modelId, displayName: model.displayName })) : [];
return NextResponse.json({ preview: { added, updated, disabled }, total: catalogPresets.length });
}
const addedModels: AiModel[] = []; const addedModels: AiModel[] = [];
const updatedModels: AiModel[] = []; const updatedModels: AiModel[] = [];
const disabledModels: AiModel[] = []; const disabledModels: AiModel[] = [];

View File

@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url"; import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
@ -12,7 +13,6 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
"tripo3d.ai", "tripo3d.ai",
"tripo3d.com", "tripo3d.com",
"worldlabs.ai", "worldlabs.ai",
"s3.1080p.hk",
"img.yydd.ai", "img.yydd.ai",
"scqny.jiansun.vip", "scqny.jiansun.vip",
"blob.vercel-storage.com", "blob.vercel-storage.com",
@ -25,7 +25,6 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [ const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [
"bbyy.ai", "bbyy.ai",
"s3.1080p.hk",
"img.yydd.ai", "img.yydd.ai",
]; ];
@ -165,6 +164,9 @@ async function handleRequest(request: Request, method: "GET" | "HEAD") {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const rawUrl = String(searchParams.get("url") || "").trim(); 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) { if (!rawUrl) {
return NextResponse.json({ success: false, error: "缺少 url 参数" }, { status: 400 }); return NextResponse.json({ success: false, error: "缺少 url 参数" }, { status: 400 });
} }

View File

@ -207,7 +207,11 @@ export async function PUT(
), ),
) )
.returning(); .returning();
return NextResponse.json({ success: true, data: saved }); return NextResponse.json({
success: true,
data: saved,
previousCanvasRevision: existingProject?.updatedAt.toISOString() ?? null,
});
}); });
} catch (error) { } catch (error) {
console.error("更新/创建画布失败:", error); console.error("更新/创建画布失败:", error);

View File

@ -1,4 +1,5 @@
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download"; 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 { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation";
import { after, NextRequest, NextResponse } from "next/server"; import { after, NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; 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 body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const requestOrigin = getRequestOrigin(request); 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]; const requestedUrls = [...urls];
if (urls.length === 0) { if (urls.length === 0) {
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 }); return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });

View File

@ -1,7 +1,8 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { generatedImage, user, aiModel } from "@/lib/db/schema"; 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"; import { cachedPublicJson } from "@/lib/public-api/performance";
const FEATURED_TIMEOUT_MS = 1800; const FEATURED_TIMEOUT_MS = 1800;
@ -57,13 +58,17 @@ export async function GET(request: NextRequest) {
userName: user.name, userName: user.name,
userEmail: user.email, userEmail: user.email,
userDepartment: user.department, 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`, totalCount: sql<number>`count(*) over()::int`,
}) })
.from(generatedImage) .from(generatedImage)
.leftJoin(user, eq(generatedImage.userId, user.id)) .leftJoin(user, eq(generatedImage.userId, user.id))
.leftJoin(aiModel, eq(generatedImage.modelId, aiModel.modelId)) .where(and(
.where(eq(generatedImage.isFeatured, true)) eq(generatedImage.isFeatured, true),
sql`${generatedImage.originalUrl} !~* ${RETIRED_STORAGE_URL_PATTERN}`,
))
.orderBy(desc(generatedImage.featuredAt)) .orderBy(desc(generatedImage.featuredAt))
.limit(pageSize) .limit(pageSize)
.offset(offset); .offset(offset);
@ -74,7 +79,7 @@ export async function GET(request: NextRequest) {
return { return {
images: featuredImages.map((img) => ({ images: featuredImages.map((img) => ({
id: img.id, id: img.id,
thumbnailUrl: img.thumbnailUrl, thumbnailUrl: isRetiredStorageUrl(img.thumbnailUrl) ? img.originalUrl : img.thumbnailUrl,
originalUrl: img.originalUrl, originalUrl: img.originalUrl,
imageSize: img.imageSize, imageSize: img.imageSize,
prompt: img.prompt, prompt: img.prompt,

View File

@ -1,4 +1,5 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs"; export const runtime = "nodejs";
@ -9,6 +10,7 @@ const MIN_WIDTH = 64;
const MIN_QUALITY = 40; const MIN_QUALITY = 40;
const MAX_QUALITY = 100; const MAX_QUALITY = 100;
const FETCH_TIMEOUT_MS = 15000; const FETCH_TIMEOUT_MS = 15000;
const SMALL_PREVIEW_FETCH_TIMEOUT_MS = 45000;
const DEFAULT_MEMORY_CACHE_MAX_BYTES = 64 * 1024 * 1024; const DEFAULT_MEMORY_CACHE_MAX_BYTES = 64 * 1024 * 1024;
const DEFAULT_MEMORY_CACHE_MAX_ITEMS = 256; const DEFAULT_MEMORY_CACHE_MAX_ITEMS = 256;
@ -266,6 +268,9 @@ function redirectToDirectImage(target: URL): NextResponse {
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl; const { searchParams } = request.nextUrl;
const rawUrl = (searchParams.get("url") || "").trim(); const rawUrl = (searchParams.get("url") || "").trim();
if (isRetiredStorageUrl(rawUrl)) {
return NextResponse.json({ error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
}
if (!rawUrl) { if (!rawUrl) {
return NextResponse.json({ error: "Missing url parameter" }, { status: 400 }); return NextResponse.json({ error: "Missing url parameter" }, { status: 400 });
} }
@ -339,17 +344,28 @@ export async function GET(request: NextRequest) {
}); });
} }
const fetchSignal = AbortSignal.any([
request.signal,
AbortSignal.timeout(width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS),
]);
const upstreamFailure = (error: unknown) => {
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
const timedOut = (error instanceof Error && error.name === "TimeoutError") || fetchSignal.reason?.name === "TimeoutError";
return NextResponse.json(
{ error: timedOut ? "读取源图片超时,请重试" : "源图片传输中断,请重试" },
{ status: timedOut ? 504 : 502 },
);
};
let upstreamResponse: Response; let upstreamResponse: Response;
try { try {
upstreamResponse = await fetch(target.toString(), { upstreamResponse = await fetch(target.toString(), {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), signal: fetchSignal,
headers: { headers: {
Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
}, },
}); });
} catch (error) { } catch (error) {
console.error("[ImageProxy] Upstream fetch failed:", error); return upstreamFailure(error);
return NextResponse.json({ error: "Failed to fetch upstream image" }, { status: 502 });
} }
if (!upstreamResponse.ok) { if (!upstreamResponse.ok) {
@ -364,7 +380,12 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 }); return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
} }
const originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer()); let originalBuffer: Buffer;
try {
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
} catch (error) {
return upstreamFailure(error);
}
if (original) { if (original) {
return buildImageResponse(originalBuffer, { return buildImageResponse(originalBuffer, {
contentType, contentType,

View File

@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { resolveSiteBranding } from "@/lib/site-branding";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { z } from "zod"; import { z } from "zod";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
@ -663,12 +664,12 @@ async function readScopedSiteSettings(keys: string[]) {
} }
function createBrandingSettings(settings: Record<string, unknown> | undefined) { function createBrandingSettings(settings: Record<string, unknown> | undefined) {
return { return resolveSiteBranding({
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"), siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
siteLogo: settings?.siteLogo || null, siteLogo: String(settings?.siteLogo || ""),
siteLogoDark: settings?.siteLogoDark || null, siteLogoDark: String(settings?.siteLogoDark || ""),
favicon: settings?.favicon || null, favicon: String(settings?.favicon || ""),
}; });
} }
async function getScopedPublicSiteSettings( async function getScopedPublicSiteSettings(
@ -1541,7 +1542,7 @@ export async function GET(request: NextRequest) {
}; };
const response = NextResponse.json({ const response = NextResponse.json({
...baseSettings, ...resolveSiteBranding(baseSettings),
lovartPromoText: lovartPromoText:
baseSettings.lovartPromoText || baseSettings.lovartPromoText ||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠", "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue"; import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
import { CanvasRemoteOperationContext } from "@/components/canvas/CanvasRemoteOperationContext";
import { import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
@ -3946,20 +3947,18 @@ export default function CanvasEditorLegacyPage({
loadCanvas(normalized.canvasData); loadCanvas(normalized.canvasData);
setProjectTitle(nextTitle); setProjectTitle(nextTitle);
const [saveResult, updateResult] = await Promise.all([ const saveResult = await canvasService.saveCanvasState(
canvasService.saveCanvasState(
id, id,
normalized.canvasData, normalized.canvasData,
activeCanvasWorkspaceType activeCanvasWorkspaceType
), );
canvasService.updateProject( const updateResult = await canvasService.updateProject(
id, id,
{ {
title: nextTitle, title: nextTitle,
}, },
activeCanvasWorkspaceType activeCanvasWorkspaceType
), );
]);
if (!saveResult.success) { if (!saveResult.success) {
throw new Error(saveResult.error || "导入后保存失败"); throw new Error(saveResult.error || "导入后保存失败");
@ -5080,9 +5079,10 @@ export default function CanvasEditorLegacyPage({
if (!canvasSaveQueue.current) canvasSaveQueue.current = createCanvasSaveQueue(); if (!canvasSaveQueue.current) canvasSaveQueue.current = createCanvasSaveQueue();
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const canvasPersistenceScope = useRef(id); const canvasPersistenceScope = useRef(id);
const canvasRemoteOperationActive = useRef(false);
canvasPersistenceScope.current = id; canvasPersistenceScope.current = id;
const persistCanvasState = useCallback( const persistCanvasStateNow = useCallback(
(reason: "autosave" | "delete" | "beforeunload") => canvasSaveQueue.current!.enqueue(async () => { async (reason: "autosave" | "delete" | "beforeunload") => {
if (canvasPersistenceScope.current !== id) return false; if (canvasPersistenceScope.current !== id) return false;
setIsSaving(true); setIsSaving(true);
try { try {
@ -5122,10 +5122,37 @@ export default function CanvasEditorLegacyPage({
} finally { } finally {
if (canvasPersistenceScope.current === id) setIsSaving(false); if (canvasPersistenceScope.current === id) setIsSaving(false);
} }
}), },
[activeCanvasWorkspaceType, buildPersistedCanvasData, id] [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(() => { const persistCanvasBeforeAgent = useCallback(() => {
// Flush the pending local edit once instead of racing its scheduled autosave. // Flush the pending local edit once instead of racing its scheduled autosave.
if (canvasAutosaveTimer.current) { if (canvasAutosaveTimer.current) {
@ -5168,6 +5195,7 @@ export default function CanvasEditorLegacyPage({
const refreshIfExternallyUpdated = async () => { const refreshIfExternallyUpdated = async () => {
if ( if (
cancelled || cancelled ||
canvasRemoteOperationActive.current ||
document.visibilityState !== "visible" || document.visibilityState !== "visible" ||
isSaving || isSaving ||
externalCanvasConflictRef.current || externalCanvasConflictRef.current ||
@ -5197,7 +5225,7 @@ export default function CanvasEditorLegacyPage({
} }
const result = await canvasService.getProject(id); 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(); useCanvasStore.getState().markAgentOperationRefresh();
hydrateCanvasProject(result.data); hydrateCanvasProject(result.data);
lastLocalCanvasMutationAtRef.current = Date.now(); lastLocalCanvasMutationAtRef.current = Date.now();
@ -5320,11 +5348,11 @@ export default function CanvasEditorLegacyPage({
try { try {
if (autoThumbnailSyncedRef.current !== autoTitleKey) { if (autoThumbnailSyncedRef.current !== autoTitleKey) {
await canvasService.updateProject( await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
id, id,
{ thumbnail: imageSrc }, { thumbnail: imageSrc },
activeCanvasWorkspaceType activeCanvasWorkspaceType
); ));
autoThumbnailSyncedRef.current = autoTitleKey; autoThumbnailSyncedRef.current = autoTitleKey;
} }
@ -5340,11 +5368,11 @@ export default function CanvasEditorLegacyPage({
const data = await res.json(); const data = await res.json();
if (data.title) { if (data.title) {
setProjectTitle(data.title); setProjectTitle(data.title);
await canvasService.updateProject( await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
id, id,
{ title: data.title }, { title: data.title },
activeCanvasWorkspaceType activeCanvasWorkspaceType
); ));
toast.success(`${workspaceLabel}已自动命名为:${data.title}`); toast.success(`${workspaceLabel}已自动命名为:${data.title}`);
} }
} catch (error) { } catch (error) {
@ -5395,11 +5423,11 @@ export default function CanvasEditorLegacyPage({
return; return;
} }
const result = await canvasService.updateProject( const result = await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
id, id,
{ title: editTitle.trim() }, { title: editTitle.trim() },
activeCanvasWorkspaceType activeCanvasWorkspaceType
); ));
if (result.success) { if (result.success) {
setProjectTitle(editTitle.trim()); setProjectTitle(editTitle.trim());
} else { } else {
@ -10281,6 +10309,7 @@ export default function CanvasEditorLegacyPage({
} }
return ( return (
<CanvasRemoteOperationContext.Provider value={runCanvasRemoteOperation}>
<TooltipProvider> <TooltipProvider>
<div <div
data-canvas-panel-open={isPanelOpen ? "true" : "false"} data-canvas-panel-open={isPanelOpen ? "true" : "false"}
@ -12079,5 +12108,6 @@ export default function CanvasEditorLegacyPage({
{projectLoadingBadge} {projectLoadingBadge}
</div> </div>
</TooltipProvider> </TooltipProvider>
</CanvasRemoteOperationContext.Provider>
); );
} }

View File

@ -0,0 +1,30 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
export function AdminSurface({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let context: { revert: () => void } | undefined;
let disposed = false;
import("gsap").then(({ gsap }) => {
if (disposed) return;
context = gsap.context(() => {
gsap.fromTo(
ref.current,
{ y: 6, opacity: 0.85 },
{ y: 0, opacity: 1, duration: 0.25, ease: "power3.out", clearProps: "transform,opacity" },
);
}, ref);
});
return () => {
disposed = true;
context?.revert();
};
}, []);
return (
<div ref={ref} className="space-y-6">
{children}
</div>
);
}

View File

@ -0,0 +1,603 @@
"use client";
import { useEffect, useState } from "react";
import useSWR from "swr";
import {
RefreshCw,
Search,
ChevronLeft,
ChevronRight,
ExternalLink,
Download,
Star,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { toast } from "sonner";
import { MediaThumbnail } from "./media-thumbnail";
import { AdminSurface } from "./admin-surface";
import { formatRecordTime } from "@/lib/admin/record-time";
import { buildImageProxyUrl } from "@/lib/image-delivery";
export type MediaRecord = {
id: string;
recordType: "image" | "video";
userName: string | null;
userEmail: string | null;
userDepartment: string | null;
createdAt: string;
modelId: string | null;
chatId: string | null;
thumbnailUrl: string;
originalUrl: string;
videoUrl?: string | null;
prompt: string | null;
imageSize: string | null;
isFeatured: boolean;
featuredCategory: string | null;
featuredAt?: string | null;
};
type MediaResponse = {
images: MediaRecord[];
pagination: {
total: number;
totalPages: number;
totalImages: number;
totalVideos: number;
page: number;
};
};
export async function adminFetcher(url: string) {
const response = await fetch(url);
const data = await response.json();
if (!response.ok) throw new Error(data.error || data.message || "加载失败,请重试");
return data;
}
const selectClass =
"h-10 rounded-md border bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
function recentDate() {
const d = new Date();
d.setDate(d.getDate() - 6);
return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(d);
}
export function MediaRecordsPanel({ categories }: { categories: string[] }) {
const [view, setView] = useState("assets");
const [search, setSearch] = useState("");
const [filters, setFilters] = useState({
search: "",
type: "all",
startDate: recentDate(),
endDate: "",
model: "",
featured: "false",
status: "all",
});
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<MediaRecord | null>(null);
const [saving, setSaving] = useState(false);
const [originalFailed, setOriginalFailed] = useState(false);
const update = (changes: Partial<typeof filters>) => {
setFilters((f) => ({ ...f, ...changes }));
setPage(1);
};
useEffect(() => {
const timer = setTimeout(() => {
setFilters((f) => (f.search === search ? f : { ...f, search }));
setPage(1);
}, 350);
return () => clearTimeout(timer);
}, [search]);
const { data: options } = useSWR<{
models: Array<{ id: string; modelId: string; displayName: string; providerName: string }>;
}>("/api/admin/ai-models?view=options", adminFetcher, { revalidateOnFocus: false });
const modelNames = new Map<string, string>();
for (const model of options?.models || [])
if (!modelNames.has(model.modelId)) modelNames.set(model.modelId, model.displayName);
const recordModelNames = new Map(modelNames);
for (const model of options?.models || []) recordModelNames.set(model.id, model.displayName);
const openDetails = (item: MediaRecord) => {
setSelected(item);
setOriginalFailed(false);
};
const query = new URLSearchParams({ ...filters, page: String(page), pageSize: "40" });
const { data, error, isLoading, isValidating, mutate } = useSWR<MediaResponse>(
view === "assets" ? `/api/admin/images?${query}` : null,
adminFetcher,
{ keepPreviousData: true, revalidateOnFocus: false },
);
const tasks = useSWR<{
tasks: Array<{
id: string;
userName: string;
status: string;
createdAt: string;
error: string | null;
model: string | null;
elapsedMs: number | null;
}>;
total: number;
}>(view === "tasks" ? `/api/admin/media-tasks?${query}` : null, adminFetcher, {
keepPreviousData: true,
revalidateOnFocus: false,
});
const busy = view === "assets" ? isValidating : tasks.isValidating;
const failure = view === "assets" ? error : tasks.error;
const loading = view === "assets" ? isLoading : tasks.isLoading;
const total = view === "assets" ? data?.pagination.total : tasks.data?.total;
const pages = Math.max(1, Math.ceil((total || 0) / 40));
async function save(changes: Partial<MediaRecord>) {
if (!selected || saving) return;
setSaving(true);
try {
const response = await fetch("/api/admin/images", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageId: selected.id, ...changes }),
});
if (!response.ok) throw new Error("保存失败,请重试");
setSelected({ ...selected, ...changes });
toast.success("已保存");
await mutate();
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
}
return (
<AdminSurface>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold tracking-tight">生成记录</h2>
<p className="mt-2 text-sm text-muted-foreground">
查看图片与视频产物,追踪画布任务。时间统一按北京时间展示。
</p>
</div>
<Button
variant="outline"
disabled={busy}
onClick={() => (view === "assets" ? mutate() : tasks.mutate())}
>
<RefreshCw className={`mr-2 size-4 ${busy ? "motion-safe:animate-spin" : ""}`} />
刷新
</Button>
</div>
<div className="flex gap-2" role="tablist" aria-label="记录视图">
{[
["assets", "产物记录"],
["tasks", "画布任务"],
].map(([key, label]) => (
<Button
key={key}
role="tab"
aria-selected={view === key}
variant={view === key ? "default" : "ghost"}
onClick={() => {
setView(key);
setPage(1);
}}
>
{label}
</Button>
))}
</div>
<div className="flex flex-wrap items-end gap-3 rounded-xl border bg-background p-4">
<label className="min-w-48 flex-1 text-xs font-medium">
用户
<div className="relative mt-2">
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
<Input
className="h-10 pl-9"
placeholder="搜索姓名或邮箱"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</label>
{view === "assets" ? (
<label className="text-xs font-medium">
类型
<select
aria-label="产物类型"
className={`${selectClass} mt-2 block`}
value={filters.type}
onChange={(e) => update({ type: e.target.value })}
>
<option value="all">图片与视频</option>
<option value="image">只看图片</option>
<option value="video">只看视频</option>
</select>
</label>
) : (
<label className="text-xs font-medium">
任务状态
<select
aria-label="任务状态"
className={`${selectClass} mt-2 block`}
value={filters.status}
onChange={(e) => update({ status: e.target.value })}
>
<option value="all">全部状态</option>
<option value="failed">失败</option>
<option value="running">运行中</option>
<option value="pending">排队中</option>
<option value="succeeded">成功</option>
</select>
</label>
)}
<label className="text-xs font-medium">
开始日期
<Input
aria-label="开始日期"
className="mt-2 h-10 w-40"
type="date"
value={filters.startDate}
onChange={(e) => update({ startDate: e.target.value })}
/>
</label>
<label className="text-xs font-medium">
结束日期
<Input
aria-label="结束日期"
className="mt-2 h-10 w-40"
type="date"
value={filters.endDate}
onChange={(e) => update({ endDate: e.target.value })}
/>
</label>
<Button
variant="ghost"
onClick={() => {
setSearch("");
update({
search: "",
startDate: "",
endDate: "",
model: "",
featured: "false",
type: "all",
status: "all",
});
}}
>
全部时间 / 重置
</Button>
{view === "assets" && (
<div className="flex w-full flex-wrap items-center gap-3">
<select
aria-label="记录模型"
className={`${selectClass} max-w-full sm:max-w-96`}
value={filters.model}
onChange={(e) => update({ model: e.target.value })}
>
<option value="">全部模型</option>
{Array.from(modelNames, ([id, name]) => (
<option key={id} value={id}>
{name} · {id}
</option>
))}
</select>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={filters.featured === "true"}
onChange={(e) => update({ featured: String(e.target.checked) })}
/>
只看精选
</label>
</div>
)}
</div>
<div
aria-live="polite"
className="flex items-center justify-between text-sm text-muted-foreground"
>
<span>
{total === undefined ? "正在读取记录…" : `共 ${total.toLocaleString()} 条记录`}
{view === "assets" && data
? ` · 图片 ${data.pagination.totalImages.toLocaleString()} · 视频 ${data.pagination.totalVideos.toLocaleString()}`
: ""}
</span>
{busy && !loading && <span>正在更新…</span>}
</div>
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span>
第 {page} / {pages} 页 · 每页 40 条
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={busy || page === 1}
onClick={() => setPage((p) => p - 1)}
>
上一页
</Button>
<Button
variant="outline"
size="sm"
disabled={busy || page >= pages}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
</div>
{failure ? (
<div role="alert" className="rounded-xl border border-destructive/30 p-6 text-sm">
<p>{failure.message}</p>
<Button
className="mt-3"
variant="outline"
onClick={() => (view === "assets" ? mutate() : tasks.mutate())}
>
重试加载
</Button>
</div>
) : loading ? (
<div role="status" className="space-y-3 rounded-xl border p-6">
{[0, 1, 2, 3].map((x) => (
<div key={x} className="h-16 rounded-lg bg-muted motion-safe:animate-pulse" />
))}
<span className="sr-only">正在加载记录</span>
</div>
) : total === 0 ? (
<div className="rounded-xl border border-dashed p-12 text-center text-sm text-muted-foreground">
没有符合条件的记录,请调整筛选条件。
</div>
) : (
<div className="overflow-x-auto rounded-xl border bg-background" aria-busy={busy}>
{view === "assets" ? (
<table className="w-full min-w-[720px] text-left text-sm">
<thead className="bg-muted/50 text-xs text-muted-foreground">
<tr>
{["产物", "用户", "生成时间", "模型 / 尺寸", "精选", ""].map((v, i) => (
<th key={i} className="px-4 py-3 font-medium">
{v}
</th>
))}
</tr>
</thead>
<tbody className="divide-y">
{data?.images.map((item) => (
<tr key={item.id} className="hover:bg-muted/30">
<td className="p-4">
<div>
<MediaThumbnail
url={item.thumbnailUrl}
video={item.recordType === "video"}
onOpen={() => openDetails(item)}
/>
</div>
</td>
<td className="px-4 py-3">
<div className="font-medium">{item.userName || item.userEmail}</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.userDepartment || "未设置部门"}
</div>
</td>
<td className="whitespace-nowrap px-4 py-3 tabular-nums">
{formatRecordTime(item.createdAt)}
</td>
<td className="max-w-64 px-4 py-3">
<div className="truncate" title={item.modelId || ""}>
{recordModelNames.get(item.modelId || "") || item.modelId || "未记录模型"}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.recordType === "video" ? "视频" : "图片"} ·{" "}
{item.imageSize || "未知尺寸"}
</div>
</td>
<td className="px-4 py-3">
{item.isFeatured ? (
<span className="inline-flex items-center gap-1 text-amber-700 dark:text-amber-300">
<Star className="size-3" />
{item.featuredCategory || "精选"}
</span>
) : (
"—"
)}
</td>
<td className="px-4 py-3">
<Button variant="ghost" size="sm" onClick={() => openDetails(item)}>
详情
</Button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<table className="w-full min-w-[720px] text-left text-sm">
<thead className="bg-muted/50">
<tr>
{["用户 / 时间", "状态", "模型", "耗时", "失败原因 / 任务编号"].map((x) => (
<th key={x} className="px-4 py-3 font-medium">
{x}
</th>
))}
</tr>
</thead>
<tbody className="divide-y">
{tasks.data?.tasks.map((task) => (
<tr key={task.id}>
<td className="px-4 py-4">
{task.userName}
<div className="mt-1 text-xs text-muted-foreground">
{formatRecordTime(task.createdAt)}
</div>
</td>
<td className="px-4 py-4">
{{ failed: "失败", running: "运行中", succeeded: "成功", pending: "排队中" }[
task.status
] || task.status}
</td>
<td className="max-w-48 break-words px-4 py-4">{task.model || "自动选择"}</td>
<td className="px-4 py-4 tabular-nums">
{task.elapsedMs === null ? "—" : `${Math.round(task.elapsedMs / 1000)} 秒`}
</td>
<td className="max-w-xl break-words px-4 py-4">
<p className={task.error ? "text-destructive" : "text-muted-foreground"}>
{task.error || "—"}
</p>
<code className="mt-2 block text-xs text-muted-foreground">{task.id}</code>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">
每页 40 条 · 第 {page} / {pages} 页
</span>
<div className="flex items-center gap-2">
<Button variant="outline" disabled={busy || page === 1} onClick={() => setPage(1)}>
首页
</Button>
<Button
aria-label="上一页"
variant="outline"
disabled={busy || page === 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="size-4" />
</Button>
<Button
aria-label="下一页"
variant="outline"
disabled={busy || page >= pages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="size-4" />
</Button>
<Button variant="outline" disabled={busy || page >= pages} onClick={() => setPage(pages)}>
末页
</Button>
</div>
</div>
<Dialog
open={!!selected}
onOpenChange={(open) => {
if (!open) setSelected(null);
}}
>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto bg-background">
<DialogHeader>
<DialogTitle>{selected?.recordType === "video" ? "视频详情" : "图片详情"}</DialogTitle>
</DialogHeader>
{selected && (
<div className="space-y-6">
<div className="flex min-h-40 justify-center rounded-xl bg-muted">
{selected.recordType === "video" ? (
<video
src={selected.videoUrl || selected.originalUrl}
controls
preload="none"
className="max-h-[45vh] w-full"
/>
) : originalFailed ? (
<div role="alert" className="p-8 text-sm">
原图无法加载,源文件可能已过期或存储不可达。
<Button
className="ml-3"
variant="outline"
onClick={() => setOriginalFailed(false)}
>
重试
</Button>
</div>
) : (
<img
src={selected.originalUrl}
alt="生成原图"
className="max-h-[45vh] object-contain"
onError={() => setOriginalFailed(true)}
/>
)}
</div>
<dl className="grid gap-4 text-sm sm:grid-cols-2">
{[
["用户", selected.userName || selected.userEmail],
["部门", selected.userDepartment],
["生成时间", formatRecordTime(selected.createdAt)],
["尺寸", selected.imageSize],
["模型", recordModelNames.get(selected.modelId || "") || selected.modelId],
["精选时间", selected.featuredAt ? formatRecordTime(selected.featuredAt) : "—"],
].map(([k, v]) => (
<div key={k}>
<dt className="text-xs text-muted-foreground">{k}</dt>
<dd className="mt-1 break-words">{v || "未记录"}</dd>
</div>
))}
</dl>
<div>
<h3 className="text-sm font-medium">提示词</h3>
<p className="mt-2 whitespace-pre-wrap break-words text-sm text-muted-foreground">
{selected.prompt || "未记录提示词"}
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button variant="outline" asChild>
<a href={selected.originalUrl} target="_blank" rel="noreferrer">
<ExternalLink className="mr-2 size-4" />
打开原文件
</a>
</Button>
{selected.recordType === "image" && (
<Button variant="outline" asChild>
<a
href={buildImageProxyUrl(selected.originalUrl, { delivery: "original" })}
download
>
<Download className="mr-2 size-4" />
下载原图
</a>
</Button>
)}
</div>
{selected.recordType === "image" && (
<div className="flex flex-wrap items-center gap-3 rounded-xl border p-4">
<select
aria-label="精选分类"
className={selectClass}
value={selected.featuredCategory || ""}
onChange={(e) =>
setSelected({ ...selected, featuredCategory: e.target.value || null })
}
>
<option value="">未分类</option>
{[...new Set([...categories, selected.featuredCategory].filter(Boolean))].map(
(c) => (
<option key={c} value={c!}>
{c}
</option>
),
)}
</select>
<Button
variant="outline"
disabled={saving}
onClick={() => save({ featuredCategory: selected.featuredCategory })}
>
保存分类
</Button>
<Button
disabled={saving}
onClick={() => save({ isFeatured: !selected.isFeatured })}
>
<Star className="mr-2 size-4" />
{selected.isFeatured ? "取消精选" : "设为精选"}
</Button>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</AdminSurface>
);
}

View File

@ -0,0 +1,150 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ImageOff, Video, RotateCw } from "lucide-react";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { isRetiredStorageUrl } from "@/lib/storage/retired-storage";
import { queuePreview } from "@/lib/admin/preview-queue";
export function MediaThumbnail({
url,
video = false,
onOpen,
}: {
url: string;
video?: boolean;
onOpen?: () => void;
}) {
const container = useRef<HTMLDivElement>(null);
const [state, setState] = useState<"waiting" | "loading" | "ready" | "error">("waiting");
const [attempt, setAttempt] = useState(0);
const [source, setSource] = useState("");
const [failure, setFailure] = useState("");
const retired = isRetiredStorageUrl(url);
useEffect(() => {
setState("waiting");
setSource("");
setFailure("");
if (video || retired || !url || !container.current) return;
let dispose: (() => void) | undefined;
let abort = () => {};
let objectUrl = "";
let alive = true;
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting || dispose) return;
observer.disconnect();
dispose = queuePreview((release) => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 48000);
setState("loading");
// Fetch is abortable, so leaving a page also stops in-flight previews.
const parsed = new URL(url, window.location.origin);
const target =
parsed.hostname === "scqnycdn.jiansun.vip" && !parsed.search
? `${url}?imageView2/2/w/192/q/72/format/webp`
: buildImageProxyUrl(url, {
width: 192,
quality: 72,
format: "webp",
delivery: "proxy",
});
fetch(target, { signal: controller.signal })
.then(async (response) => {
if (!response.ok) {
const detail = await response.json().catch(() => null);
throw new Error(`${detail?.error || "预览请求失败"}(HTTP ${response.status})`);
}
const blob = await response.blob();
if (!blob.type.startsWith("image/")) throw new Error("Not an image");
if (alive) {
objectUrl = URL.createObjectURL(blob);
setSource(objectUrl);
setState("ready");
}
})
.catch((error) => {
if (alive) {
setFailure(
error.name === "AbortError" ? "预览加载超时" : error.message || "预览加载失败",
);
setState("error");
}
})
.finally(() => {
clearTimeout(timeout);
release();
});
abort = () => {
controller.abort();
clearTimeout(timeout);
};
});
},
{ rootMargin: "80px" },
);
observer.observe(container.current);
return () => {
alive = false;
observer.disconnect();
abort();
dispose?.();
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [url, video, retired, attempt]);
return (
<div
ref={container}
className="relative flex h-16 w-20 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted"
data-preview-state={state}
>
{video ? (
<button
type="button"
aria-label="查看视频详情"
className="flex h-full w-full items-center justify-center"
onClick={onOpen}
>
<Video className="size-5 text-muted-foreground" aria-label="视频,点击查看后播放" />
</button>
) : retired ? (
<span className="text-center text-xs text-muted-foreground">
旧存储
<br />
已停用
</span>
) : source && state === "ready" ? (
<button type="button" aria-label="查看图片详情" className="h-full w-full" onClick={onOpen}>
<img
src={source}
alt="生成结果预览"
className="h-full w-full object-contain"
onError={() => {
setFailure("图片内容无法解码");
setState("error");
}}
/>
</button>
) : state === "error" ? (
<button
type="button"
className="flex h-full w-full flex-col items-center justify-center gap-1 text-xs text-muted-foreground hover:text-foreground"
title={`${failure},点击重试`}
onClick={(e) => {
e.stopPropagation();
setAttempt((x) => x + 1);
}}
>
<ImageOff className="size-4" />
加载失败
</button>
) : (
<RotateCw
className={`size-4 text-muted-foreground ${state === "loading" ? "motion-safe:animate-spin" : ""}`}
aria-label={state === "loading" ? "正在加载预览" : "等待进入可见区域"}
/>
)}
</div>
);
}

View File

@ -0,0 +1,191 @@
"use client";
import { useState } from "react";
import useSWR from "swr";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { adminFetcher } from "./media-records-panel";
import { formatRecordTime } from "@/lib/admin/record-time";
type Event = {
id: string;
modelDisplayName: string;
modelRuntimeId: string;
providerName: string;
eventType: string;
createdAt: string;
errorMessage: string;
elapsedMs: number | null;
consecutiveFailures: number;
autoDisabled: boolean;
};
export function ModelHealthPanel({
providers,
models,
initialModelId = "",
}: {
initialModelId?: string;
providers: Array<{ id: string; name: string }>;
models: Array<{ id: string; displayName: string; providerId: string }>;
}) {
const [filters, setFilters] = useState({
providerId: "",
modelId: initialModelId,
eventType: "",
startDate: "",
endDate: "",
});
const [page, setPage] = useState(1);
const query = new URLSearchParams({ ...filters, page: String(page), limit: "20" });
const { data, error, isLoading, isValidating, mutate } = useSWR<{
events: Event[];
hasMore: boolean;
}>(`/api/admin/ai-model-health?${query}`, adminFetcher, {
keepPreviousData: true,
revalidateOnFocus: false,
});
const update = (change: Partial<typeof filters>) => {
setFilters((f) => ({ ...f, ...change }));
setPage(1);
};
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">运行健康</h3>
<p className="mt-2 text-sm text-muted-foreground">
按上游和模型追踪故障,查看完整原因与发生时间。
</p>
</div>
<div className="flex flex-wrap gap-3">
{[
["providerId", "全部上游", providers.map((p) => [p.id, p.name])],
[
"modelId",
"全部模型",
models
.filter((m) => !filters.providerId || m.providerId === filters.providerId)
.map((m) => [m.id, m.displayName]),
],
[
"eventType",
"全部错误",
[
["quota", "额度 / 余额"],
["timeout", "超时"],
["unavailable", "不可用"],
["invalid_model", "模型不可用"],
["download_error", "结果下载失败"],
["error", "其他错误"],
["success", "成功"],
],
],
].map(([key, label, options]) => (
<select
key={key as string}
aria-label={label as string}
className="h-10 max-w-64 rounded-md border bg-background px-3 text-sm"
value={filters[key as keyof typeof filters]}
onChange={(e) =>
update({
[key as string]: e.target.value,
...(key === "providerId" ? { modelId: "" } : {}),
})
}
>
<option value="">{label as string}</option>
{(options as string[][]).map(([value, text]) => (
<option key={value} value={value}>
{text}
</option>
))}
</select>
))}
<Input
aria-label="故障开始日期"
className="h-10 w-40"
type="date"
value={filters.startDate}
onChange={(e) => update({ startDate: e.target.value })}
/>
<Input
aria-label="故障结束日期"
className="h-10 w-40"
type="date"
value={filters.endDate}
onChange={(e) => update({ endDate: e.target.value })}
/>
<Button variant="outline" disabled={isValidating} onClick={() => mutate()}>
刷新日志
</Button>
</div>
{error ? (
<p role="alert" className="p-6 text-sm text-destructive">
日志加载失败,请点击刷新重试。
</p>
) : isLoading ? (
<p role="status" className="p-6 text-sm">
正在加载故障日志…
</p>
) : (
<div className="divide-y rounded-xl border">
{data?.events.map((event) => (
<div key={event.id} className="p-4">
<div className="flex flex-wrap items-center gap-3 text-sm">
<strong className="font-semibold">
{event.modelDisplayName || event.modelRuntimeId || "已删除模型"}
</strong>
<span className="text-muted-foreground">{event.providerName || "未知上游"}</span>
<span className="rounded bg-muted px-2 py-1 text-xs">
{{
quota: "额度 / 余额",
timeout: "超时",
unavailable: "不可用",
error: "服务错误",
invalid_model: "模型不可用",
download_error: "结果下载失败",
success: "成功",
}[event.eventType] || event.eventType}
</span>
<time className="ml-auto text-xs tabular-nums text-muted-foreground">
{formatRecordTime(event.createdAt)}
</time>
</div>
<p className="mt-3 whitespace-pre-wrap break-words text-sm">
{event.errorMessage || "未记录详细原因"}
</p>
<div className="mt-2 flex gap-4 text-xs text-muted-foreground">
<span>连续失败 {event.consecutiveFailures} 次</span>
{event.elapsedMs !== null && (
<span>耗时 {Math.round(event.elapsedMs / 1000)} 秒</span>
)}
{event.autoDisabled && <span>已自动停用</span>}
</div>
</div>
))}
{!data?.events.length && (
<p className="p-12 text-center text-sm text-muted-foreground">该范围内没有故障记录。</p>
)}
</div>
)}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
第 {page} 页{isValidating && " · 正在更新…"}
</span>
<div className="flex gap-2">
<Button
variant="outline"
disabled={page === 1 || isValidating}
onClick={() => setPage((p) => p - 1)}
>
上一页
</Button>
<Button
variant="outline"
disabled={!data?.hasMore || isValidating}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,326 @@
"use client";
import { useMemo, useState } from "react";
import { Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { AiModel } from "@/lib/db/schema";
import {
is3DModelDescriptor,
isVideoModelDescriptor,
isImageGenerationModelDescriptor,
} from "@/lib/ai/model-classification";
type Model = AiModel & { isAgentModel?: boolean; isMultimodal?: boolean };
type Provider = { id: string; name: string; type: string; isEnabled: boolean };
const selectClass = "h-10 rounded-md border bg-background px-3 text-sm";
export function ModelManagementList({
models,
providers,
error,
loading,
onEdit,
onDelete,
onRestore,
onRefresh,
onHealth,
}: {
models: Model[];
providers: Provider[];
error?: Error;
loading: boolean;
onEdit: (model: Model) => Promise<void>;
onDelete: (id: string) => void;
onRestore: (model: Model) => void;
onRefresh: () => void;
onHealth?: (id: string) => void;
}) {
const [search, setSearch] = useState("");
const [provider, setProvider] = useState("");
const [status, setStatus] = useState("enabled");
const [kind, setKind] = useState("");
const [page, setPage] = useState(1);
const [opening, setOpening] = useState<string | null>(null);
const rows = useMemo(
() =>
models
.map((model) => {
const upstream = providers.find((p) => p.id === model.providerId);
const descriptor = { ...model, providerType: upstream?.type };
const type = is3DModelDescriptor(descriptor)
? "3d"
: isVideoModelDescriptor(descriptor)
? "video"
: isImageGenerationModelDescriptor(descriptor)
? "image"
: "chat";
return { model, upstream, type };
})
.filter(({ model, upstream, type }) => {
const text =
`${model.displayName} ${model.modelId} ${upstream?.name || ""}`.toLowerCase();
return (
text.includes(search.trim().toLowerCase()) &&
(!provider || model.providerId === provider) &&
(!kind || kind === type) &&
(status === "all" ||
(status === "enabled" && model.isEnabled) ||
(status === "disabled" && !model.isEnabled) ||
(status === "unhealthy" && model.healthStatus !== "healthy") ||
(status === "visible" && !model.isHidden))
);
}),
[models, providers, search, provider, status, kind],
);
const pages = Math.max(1, Math.ceil(rows.length / 25));
const currentPage = Math.min(page, pages);
const change = (fn: () => void) => {
fn();
setPage(1);
};
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
<div className="relative min-w-56 flex-1">
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
<Input
className="h-10 pl-9"
aria-label="搜索模型"
placeholder="搜索模型名称、ID 或上游"
value={search}
onChange={(e) => change(() => setSearch(e.target.value))}
/>
</div>
<select
aria-label="模型上游"
className={selectClass}
value={provider}
onChange={(e) => change(() => setProvider(e.target.value))}
>
<option value="">全部上游</option>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<select
aria-label="模型类型"
className={selectClass}
value={kind}
onChange={(e) => change(() => setKind(e.target.value))}
>
<option value="">全部类型</option>
<option value="image">生图</option>
<option value="video">视频</option>
<option value="chat">对话</option>
<option value="3d">3D / CAD</option>
</select>
<select
aria-label="模型状态"
className={selectClass}
value={status}
onChange={(e) => change(() => setStatus(e.target.value))}
>
<option value="enabled">已启用</option>
<option value="all">全部模型</option>
<option value="disabled">已停用</option>
<option value="unhealthy">运行异常</option>
<option value="visible">前台可见</option>
</select>
<Button variant="outline" onClick={onRefresh} aria-label="刷新模型">
<RefreshCw className="size-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
{loading
? "正在加载模型…"
: `筛选结果 ${rows.length} 个 · 已启用 ${models.filter((m) => m.isEnabled).length} 个 · 全部 ${models.length} 个`}
</p>
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>
第 {currentPage} / {pages} 页
</span>
<Button
variant="outline"
size="sm"
disabled={currentPage === 1}
onClick={() => setPage(currentPage - 1)}
>
上一页
</Button>
<Button
variant="outline"
size="sm"
disabled={currentPage === pages}
onClick={() => setPage(currentPage + 1)}
>
下一页
</Button>
</div>
{error ? (
<div role="alert" className="rounded-xl border p-6 text-sm">
模型加载失败。
<Button variant="outline" onClick={onRefresh}>
重试
</Button>
</div>
) : loading ? (
<div role="status" className="h-48 rounded-xl bg-muted motion-safe:animate-pulse" />
) : (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full min-w-[850px] text-left text-sm">
<thead className="bg-muted/50 text-xs text-muted-foreground">
<tr>
{["模型", "上游 / 类型", "启用", "前台展示", "运行健康", "操作"].map((x) => (
<th key={x} className="px-4 py-3 font-medium">
{x}
</th>
))}
</tr>
</thead>
<tbody className="divide-y">
{rows
.slice((currentPage - 1) * 25, currentPage * 25)
.map(({ model, upstream, type }) => (
<tr key={model.id} className="hover:bg-muted/30">
<td className="max-w-64 px-4 py-4">
<div className="font-medium">{model.displayName}</div>
<div
className="mt-1 truncate text-xs text-muted-foreground"
title={model.modelId}
>
{model.modelId}
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
{model.isDefault && <span>默认模型</span>}
{model.isDefaultTextEditModel && <span>文字编辑默认</span>}
{model.isAgentModel && <span>Agent</span>}
{model.isMultimodal && <span>多模态</span>}
{model.isSubModel && <span>子模型</span>}
</div>
</td>
<td className="px-4 py-4">
{upstream?.name || "未知上游"}
<div className="mt-1 text-xs text-muted-foreground">
{{ image: "生图", video: "视频", chat: "对话", "3d": "3D / CAD" }[type]}
{upstream && !upstream.isEnabled ? " · 上游已停用" : ""}
</div>
</td>
<td className="whitespace-nowrap px-4 py-4">
<span
className={`rounded-md px-2 py-1 text-xs ${model.isEnabled ? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200" : "bg-muted text-muted-foreground"}`}
>
{model.isEnabled ? "已启用" : "已停用"}
</span>
</td>
<td className="px-4 py-4">
{model.isHidden || model.isSubModel ? "隐藏" : "可见"}
</td>
<td className="max-w-72 px-4 py-4">
<span
className={
model.healthStatus !== "healthy"
? "text-amber-700 dark:text-amber-300"
: "text-muted-foreground"
}
>
{model.healthStatus === "auto_disabled"
? "自动停用"
: model.healthStatus === "degraded"
? "观察中"
: "正常"}
</span>
{model.healthConsecutiveFailures > 0 && (
<span className="ml-2 text-xs text-muted-foreground">
连续失败 {model.healthConsecutiveFailures} 次
</span>
)}
{model.healthLastError && (
<details className="mt-2 text-xs">
<summary className="cursor-pointer text-muted-foreground">
查看最近原因
</summary>
<p className="mt-2 whitespace-pre-wrap break-words">
{model.healthLastError}
</p>
</details>
)}
<button
type="button"
className="mt-2 block text-xs underline underline-offset-4"
onClick={() => onHealth?.(model.id)}
>
故障记录
</button>
<div className="mt-2 text-xs text-muted-foreground">
{model.excludeFromHealthBan && "不自动封禁 "}
{model.excludeFromFailover && "不参与迁移"}
</div>
</td>
<td className="px-4 py-4">
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
disabled={opening !== null}
onClick={async () => {
setOpening(model.id);
try {
await onEdit(model);
} finally {
setOpening(null);
}
}}
>
{opening === model.id ? "加载中…" : "编辑"}
</Button>
{model.healthStatus === "auto_disabled" && (
<Button variant="outline" size="sm" onClick={() => onRestore(model)}>
恢复
</Button>
)}
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => onDelete(model.id)}
>
删除
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{rows.length === 0 && (
<p className="p-12 text-center text-sm text-muted-foreground">没有符合条件的模型。</p>
)}
</div>
)}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
每页 25 个 · 第 {currentPage} / {pages} 页
</span>
<div className="flex gap-2">
<Button
aria-label="上一页模型"
variant="outline"
disabled={currentPage === 1}
onClick={() => setPage(currentPage - 1)}
>
<ChevronLeft className="size-4" />
</Button>
<Button
aria-label="下一页模型"
variant="outline"
disabled={currentPage === pages}
onClick={() => setPage(currentPage + 1)}
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@ -2,6 +2,7 @@
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery"; 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 type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
import { import {
@ -4169,6 +4170,7 @@ export function AgentGenerationWorkspaceDarkV2({
? "agent-anchor-materials" ? "agent-anchor-materials"
: "agent-anchor-generate"; : "agent-anchor-generate";
const [finishedSceneRequestId, setFinishedSceneRequestId] = useState<string | null>(null);
const { const {
messages, messages,
setMessages, setMessages,
@ -4189,7 +4191,11 @@ export function AgentGenerationWorkspaceDarkV2({
void refreshHistory(); 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, experimental_throttle: 100,
generateId: generateUUID, generateId: generateUUID,
transport: new DefaultChatTransport({ transport: new DefaultChatTransport({
@ -5482,6 +5488,17 @@ export function AgentGenerationWorkspaceDarkV2({
setActiveResultModeType(currentResultModeType); setActiveResultModeType(currentResultModeType);
}, [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(() => { useEffect(() => {
if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return; if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return;
// The backend has settled every requested card; an open SSE connection is no longer work. // The backend has settled every requested card; an open SSE connection is no longer work.

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"; "use client";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { import {
Box, Box,
@ -231,6 +232,8 @@ import {
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors"; import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state"; import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command"; 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 { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types"; import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
import { cn, fetcher } from "@/lib/utils"; import { cn, fetcher } from "@/lib/utils";
@ -2077,7 +2080,7 @@ export function ImageNode({
const selectNode = useCanvasStore((state) => state.selectNode); const selectNode = useCanvasStore((state) => state.selectNode);
const projectId = useCanvasStore((state) => state.projectId); const projectId = useCanvasStore((state) => state.projectId);
const getCanvasState = useCanvasStore((state) => state.getCanvasState); const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const loadCanvas = useCanvasStore((state) => state.loadCanvas); const runCanvasRemoteOperation = useCanvasRemoteOperation();
const addPendingChatAttachment = useCanvasStore( const addPendingChatAttachment = useCanvasStore(
(state) => state.addPendingChatAttachment (state) => state.addPendingChatAttachment
); );
@ -6707,7 +6710,7 @@ export function ImageNode({
const executeSharedToolbarCapability = async ( const executeSharedToolbarCapability = async (
action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split" action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split"
) => { ) => {
if (!projectId) { if (!projectId || !runCanvasRemoteOperation) {
toast.error("画布还在加载,请稍后再试"); toast.error("画布还在加载,请稍后再试");
return; return;
} }
@ -6726,21 +6729,7 @@ export function ImageNode({
projectId, projectId,
source: "toolbar", source: "toolbar",
}); });
const response = await fetch(`/api/canvas/${projectId}/capabilities`, { await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command));
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);
toast.success(`${capability.title}完成`); toast.success(`${capability.title}完成`);
} catch (error) { } catch (error) {
const message = const message =
@ -8439,7 +8428,11 @@ export function ImageNode({
{/* 图片内容 - 使用 object-contain 保持原始比例 */} {/* 图片内容 - 使用 object-contain 保持原始比例 */}
{data.src ? ( {data.src ? (
<div className="relative h-full w-full"> <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 /> <ImageNodeDeferredLoadState />
) : isCompareMode && hasCompareSource ? ( ) : isCompareMode && hasCompareSource ? (
<div <div

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 { ReferenceIcon } from "@/components/lovart/reference-icon";
import { Gem, Heart, Loader2, PlayCircle } from "lucide-react"; 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"; import type { FeaturedImage } from "./types";
type LovartFeaturedGalleryProps = { type LovartFeaturedGalleryProps = {
@ -30,6 +31,10 @@ function getFeaturedPreviewUrl(item: FeaturedImage) {
return `${source}?imageView2/2/w/900/q/78`; return `${source}?imageView2/2/w/900/q/78`;
} }
function imageIdentity(item: FeaturedImage) {
return JSON.stringify([item.id, item.thumbnailUrl, item.originalUrl]);
}
export function LovartFeaturedGallery({ export function LovartFeaturedGallery({
onOpenProjects, onOpenProjects,
onOpenMaterials, onOpenMaterials,
@ -42,6 +47,13 @@ export function LovartFeaturedGallery({
isLoadingMoreFeatured, isLoadingMoreFeatured,
setActiveCategory, setActiveCategory,
}: LovartFeaturedGalleryProps) { }: 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 ( return (
<section <section
ref={featuredSectionRef} ref={featuredSectionRef}
@ -88,44 +100,48 @@ export function LovartFeaturedGallery({
))} ))}
</div> </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"> <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 {isLoadingFeatured
? "灵感内容加载中..." ? "灵感内容加载中..."
: "当前没有可展示的灵感数据。"} : unavailableCount > 0 ? "图片暂时无法加载,请稍后重试。" : "当前没有可展示的灵感数据。"}
</div> </div>
) : ( ) : (
<div className="mt-6 columns-1 gap-4 sm:columns-2 lg:columns-4"> <div className="mt-6 columns-1 gap-4 sm:columns-2 lg:columns-4">
{featuredItems.map((item, index) => ( {visibleItems.map((item) => (
<button <button
key={item.id} key={`${imageIdentity(item)}:${retryVersion}`}
type="button" type="button"
disabled={unavailable.has(imageIdentity(item))}
onClick={() => onClick={() =>
window.open(item.originalUrl || item.thumbnailUrl, "_blank") window.open(item.originalUrl || item.thumbnailUrl, "_blank")
} }
className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left" className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left"
> >
<img {unavailable.has(imageIdentity(item)) ? (
src={getFeaturedPreviewUrl(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 || "灵感图"} alt={item.prompt || "灵感图"}
loading="lazy" loading="lazy"
decoding="async" onUnavailable={() => setUnavailable((previous) => new Set(previous).add(imageIdentity(item)))}
onError={(event) => { 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]"
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]"
/>
<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="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"> <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"> <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"> <div className="inline-flex shrink-0 items-center gap-[7px] text-[11px] leading-none">
<span className="inline-flex items-center gap-[4px]"> <span className="inline-flex items-center gap-[4px]">
<PlayCircle className="h-[11px] w-[11px]" strokeWidth={2} /> <PlayCircle className="h-[11px] w-[11px]" strokeWidth={2} />
{(1200 + index * 37).toLocaleString()} {(1200 + featuredItems.indexOf(item) * 37).toLocaleString()}
</span> </span>
<span className="inline-flex items-center gap-[4px]"> <span className="inline-flex items-center gap-[4px]">
<Heart className="h-[11px] w-[11px]" strokeWidth={2} /> <Heart className="h-[11px] w-[11px]" strokeWidth={2} />
{(20 + (index % 9) * 11).toLocaleString()} {(20 + (featuredItems.indexOf(item) % 9) * 11).toLocaleString()}
</span> </span>
<Gem className="h-[11px] w-[11px]" strokeWidth={2} /> <Gem className="h-[11px] w-[11px]" strokeWidth={2} />
</div> </div>

View File

@ -13,6 +13,7 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import type { LovartHomeSidebarItemKey } from "@/lib/home/lovart-sidebar-config"; import type { LovartHomeSidebarItemKey } from "@/lib/home/lovart-sidebar-config";
import type { NavLink, TutorialVideo } from "./types"; import type { NavLink, TutorialVideo } from "./types";
import { HomeImage } from "./home-image";
type SidebarItem = { type SidebarItem = {
iconUrl?: string | null; iconUrl?: string | null;
@ -183,17 +184,18 @@ export function LovartSidebar({
onClick={onHome} onClick={onHome}
title={siteName} title={siteName}
> >
{siteLogo ? ( <HomeImage
<img sources={[siteLogo]}
src={siteLogo}
alt={siteName} alt={siteName}
loading="eager"
className="h-7 w-7 rounded-full object-cover" 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> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right">{siteName}</TooltipContent> <TooltipContent side="right">{siteName}</TooltipContent>

View File

@ -1,4 +1,5 @@
"use client"; "use client";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
@ -306,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 ( return (
<> <>
{/* 缩略图(显示压缩后的缩略图)*/} {/* 缩略图(显示压缩后的缩略图)*/}

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,83 @@
import { sql, type SQL } from "drizzle-orm";
export function parseMediaQuery(params: URLSearchParams) {
const integer = (key: string, fallback: number, max: number) => {
const value = Number(params.get(key));
return Number.isInteger(value) && value > 0 ? Math.min(value, max) : fallback;
};
const date = (key: string) => {
const value = params.get(key) || "";
return /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) ? value : "";
};
return {
page: integer("page", 1, 100000),
pageSize: integer("pageSize", 40, 100),
search: (params.get("search") || "").trim().slice(0, 128),
type: ["image", "video"].includes(params.get("type") || "") ? params.get("type")! : "all",
model: params.get("model") || "",
featured: params.get("featured") === "true",
startDate: date("startDate"),
endDate: date("endDate"),
};
}
export function mediaRecordsQuery(params: ReturnType<typeof parseMediaQuery>) {
const conditions: SQL[] = [sql`true`];
if (params.search)
conditions.push(
sql`exists (select 1 from "User" u where u.id=m."userId" and (u.name ilike ${`%${params.search}%`} or u.email ilike ${`%${params.search}%`}))`,
);
if (params.startDate) conditions.push(sql`m."createdAt" >= ${params.startDate}::date`);
if (params.endDate)
conditions.push(sql`m."createdAt" < ${params.endDate}::date + interval '1 day'`);
if (params.model) conditions.push(sql`(m."modelId" = ${params.model} or m."modelId" in (select id::text from "AiModel" where "modelId"=${params.model}))`);
const where = sql.join(conditions, sql` and `);
const videoFilters: SQL[] = [sql`true`];
if (params.startDate)
videoFilters.push(sql`v."createdAt">=${params.startDate}::date - interval '8 hours'`);
if (params.endDate)
videoFilters.push(sql`v."createdAt"<${params.endDate}::date+interval '16 hours'`);
if (params.model) videoFilters.push(sql`(v."video_metadata"->>'model'=${params.model} or v."video_metadata"->>'model' in (select id::text from "AiModel" where "modelId"=${params.model}))`);
if (params.search)
videoFilters.push(
sql`exists (select 1 from "User" u where u.id=c."userId" and (u.name ilike ${`%${params.search}%`} or u.email ilike ${`%${params.search}%`}))`,
);
return sql`
with video_candidates as materialized (
select v.id, v."chatId", v."createdAt" + interval '8 hours' as "createdAt", v."video_metadata", c."userId",
coalesce(nullif(v."permanent_video_url", ''), nullif(v."temporary_video_url", ''),
(regexp_match(t.body, '<video[^>]+src="([^"]+)"', 'i'))[1],
(regexp_match(t.body, '(https?://[^[:space:]"<>]+\\.mp4[^[:space:]"<>)]*)', 'i'))[1]) as url
from "Message_v2" v join "Chat" c on c.id=v."chatId"
cross join lateral (select case when jsonb_typeof(v.parts::jsonb)='array'
then (select string_agg(p->>'text', E'\\n') from jsonb_array_elements(v.parts::jsonb) p)
else v.parts::jsonb #>> '{}' end as body) t
where ${params.type !== "image" && !params.featured} and v.role='assistant'
and ${sql.join(videoFilters, sql` and `)}
and (v."permanent_video_url" is not null or v."temporary_video_url" is not null or v.parts::text ilike '%<video%' or v.parts::text ilike '%.mp4%')
), media as not materialized (
select g.id, 'image'::text as kind, g."createdAt", g."userId", g."modelId", null::text as url
from "GeneratedImage" g where ${params.type !== "video"} and (${!params.featured} or g."isFeatured")
union all
select id, 'video', "createdAt", "userId", "video_metadata"->>'model', url
from video_candidates where url is not null
), filtered as not materialized (select * from media m where ${where}),
totals as (select count(*)::int as total, count(*) filter(where kind='image')::int as images, count(*) filter(where kind='video')::int as videos from filtered),
page_rows as (
select * from filtered order by "createdAt" desc, kind, id desc
limit ${params.pageSize} offset ${(params.page - 1) * params.pageSize}
)
select t.total, t.images as "totalImages", t.videos as "totalVideos",
coalesce((select jsonb_agg(jsonb_build_object(
'id', case when p.kind='video' then 'video-'||p.id::text else p.id::text end,
'recordType',p.kind,'userId',p."userId",'userName',u.name,'userEmail',u.email,'userDepartment',u.department,
'createdAt',p."createdAt",'modelId',p."modelId",'chatId',coalesce(g."chatId",v."chatId"),
'thumbnailUrl',coalesce(p.url,g."thumbnailUrl"),'originalUrl',coalesce(p.url,g."originalUrl"),'videoUrl',p.url,
'prompt',coalesce(g.prompt,v."video_metadata"->>'prompt'),'imageSize',coalesce(g."imageSize",v."video_metadata"->>'resolution'),
'isFeatured',coalesce(g."isFeatured",false),'featuredCategory',g."featuredCategory",'featuredAt',g."featuredAt" + interval '8 hours'
) order by p."createdAt" desc,p.kind,p.id desc)
from page_rows p left join "User" u on u.id=p."userId"
left join "GeneratedImage" g on p.kind='image' and g.id=p.id
left join "Message_v2" v on p.kind='video' and v.id=p.id),'[]'::jsonb) as records
from totals t`;
}

View File

@ -0,0 +1,31 @@
// One queue for the records screen: visible previews get four network slots.
const waiting: Array<() => void> = [];
let active = 0;
function pump() {
while (active < 4 && waiting.length) waiting.shift()?.();
}
export function queuePreview(start: (release: () => void) => void) {
let released = false;
let started = false;
const release = () => {
if (released) return;
released = true;
if (started) active--;
else {
const index = waiting.indexOf(run);
if (index >= 0) waiting.splice(index, 1);
}
pump();
};
const run = () => {
started = true;
active++;
start(release);
};
waiting.push(run);
pump();
// Active fetches release their slot in finally, after abort has settled.
return () => {
if (!started) release();
};
}

7
lib/admin/record-time.ts Normal file
View File

@ -0,0 +1,7 @@
export function formatRecordTime(value: string) {
// The admin API normalizes each storage convention to Beijing wall-clock time.
return value
.replace("T", " ")
.replace(/(?:\.\d+)?Z$/, "")
.slice(0, 16);
}

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

@ -1,4 +1,5 @@
import "server-only"; import "server-only";
import { isImageModelBalanceBlocked } from "@/lib/ai/runtime/image-balance";
import { createOpenAI } from "@ai-sdk/openai"; import { createOpenAI } from "@ai-sdk/openai";
import { import {
@ -418,7 +419,7 @@ export async function getDynamicModel(
): Promise<LanguageModel | null> { ): Promise<LanguageModel | null> {
try { try {
const model = await getAiModelById({ id: modelId }); const model = await getAiModelById({ id: modelId });
if (!model || !model.isEnabled) { if (!model || !model.isEnabled || (model.supportsImageGen && isImageModelBalanceBlocked(model))) {
logger.warn("model unavailable", { modelId }); logger.warn("model unavailable", { modelId });
return null; return null;
} }

View File

@ -769,16 +769,15 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
} }
const selectedImageUrls = selectAlibabaInputImageUrls(imageUrls, 3); const selectedImageUrls = selectAlibabaInputImageUrls(imageUrls, 3);
const preparedImageUrls: string[] = []; const preparedImageUrls = await Promise.all(
for (const imageUrl of selectedImageUrls) { selectedImageUrls.map((imageUrl) =>
preparedImageUrls.push( prepareAlibabaInputImageUrl(
await prepareAlibabaInputImageUrl(
imageUrl, imageUrl,
activeModelId, activeModelId,
options.imageParams?.inputImageRoleByUrl?.[imageUrl], options.imageParams?.inputImageRoleByUrl?.[imageUrl],
), ),
); ),
} );
// qwen-image-3.0 同时支持文生图与图生图;旧编辑模型仍必须上传参考图。 // qwen-image-3.0 同时支持文生图与图生图;旧编辑模型仍必须上传参考图。
if ( if (

View File

@ -1261,6 +1261,7 @@ export async function submitVip98kExternalTask(params: {
const configuredInvocations = getVip98kInvocations(taskParams.modelConfig); const configuredInvocations = getVip98kInvocations(taskParams.modelConfig);
const invocation = selectVip98kTaskInvocation({ const invocation = selectVip98kTaskInvocation({
modelId: taskParams.modelId, modelId: taskParams.modelId,
kind: taskParams.kind,
configuredInvocation: configuredInvocations[0] || null, configuredInvocation: configuredInvocations[0] || null,
configuredInvocations, configuredInvocations,
imageCount: imageUrls.length, imageCount: imageUrls.length,
@ -1438,7 +1439,7 @@ function normalizeDuration(value: unknown) {
return Math.round(numeric); return Math.round(numeric);
} }
function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | null) { export function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | null) {
const videoParams = params.videoParams || {}; const videoParams = params.videoParams || {};
const imageParams = params.imageParams || {}; const imageParams = params.imageParams || {};
const promptTitle = params.prompt.split(/\s+/).slice(0, 8).join(" ").slice(0, 80); const promptTitle = params.prompt.split(/\s+/).slice(0, 8).join(" ").slice(0, 80);
@ -1448,6 +1449,9 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation |
body.displayName = promptTitle || params.modelId; body.displayName = promptTitle || params.modelId;
} }
if ("prompt" in body || !invocation) body.prompt = params.prompt; 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 = const aspectRatio =
normalizeRatio(videoParams.ratio) || normalizeRatio(videoParams.ratio) ||
@ -1697,7 +1701,7 @@ async function runTask(params: Vip98kTaskParams) {
} }
const imageSize = String(params.imageParams?.imageSize || "2K").trim() || "2K"; const imageSize = String(params.imageParams?.imageSize || "2K").trim() || "2K";
const archiveStartedAt = Date.now(); 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", { logger.info("result archive completed", {
modelId: params.modelId, modelId: params.modelId,
outputCount: archived.length, outputCount: archived.length,

View File

@ -0,0 +1,74 @@
import { isProviderBalanceError } from "@/lib/request-error";
type BalanceState = {
healthLastEventType?: unknown;
healthLastError?: unknown;
healthAutoDisabledReason?: unknown;
};
export function isImageModelBalanceBlocked(model: BalanceState | null | undefined) {
return Boolean(
model &&
((model.healthLastEventType === "quota" && isProviderBalanceError(model.healthLastError)) ||
isProviderBalanceError(model.healthAutoDisabledReason)),
);
}
export function resolveConfiguredImageBalanceFallback<
T extends {
model: BalanceState & {
id: string;
modelId: string;
isEnabled: boolean;
supportsImageGen: boolean;
imageGenConfig?: unknown;
};
provider: { isEnabled?: boolean };
},
>(
requested: BalanceState & { fallbackModelId1?: string | null; fallbackModelId2?: string | null },
entries: T[],
input: {
imageSize?: unknown;
aspectRatio?: unknown;
referenceImageCount?: number;
},
) {
if (!isImageModelBalanceBlocked(requested)) return undefined;
for (const ref of [requested.fallbackModelId1, requested.fallbackModelId2]) {
if (!ref) continue;
const entry = entries.find(({ model }) => model.id === ref || model.modelId === ref);
if (
!entry ||
!entry.model.isEnabled ||
entry.provider.isEnabled === false ||
!entry.model.supportsImageGen ||
isImageModelBalanceBlocked(entry.model)
)
continue;
const config = entry.model.imageGenConfig as Record<string, unknown> | null;
const size = String(input.imageSize || "").toUpperCase();
const ratio = String(input.aspectRatio || "");
if (
size &&
Array.isArray(config?.supportedImageSizes) &&
!config.supportedImageSizes.includes(size)
)
continue;
if (
ratio &&
ratio !== "auto" &&
Array.isArray(config?.supportedAspectRatios) &&
!config.supportedAspectRatios.includes(ratio)
)
continue;
if (
input.referenceImageCount &&
typeof config?.maxReferenceImages === "number" &&
input.referenceImageCount > config.maxReferenceImages
)
continue;
return entry;
}
return undefined;
}

View File

@ -1,18 +0,0 @@
import { isProviderBalanceError } from "@/lib/request-error";
const IMAGE_FAILOVER_BALANCE_COOLDOWN_MS = 2 * 60 * 60 * 1000;
export function isImageFallbackModelBalanceCoolingDown(
model: {
healthLastEventType?: unknown;
healthLastError?: unknown;
healthLastFailureAt?: unknown;
} | null | undefined,
now = Date.now(),
) {
if (model?.healthLastEventType !== "quota" || !isProviderBalanceError(model.healthLastError)) {
return false;
}
const failedAt = new Date(String(model.healthLastFailureAt || "")).getTime();
return Number.isFinite(failedAt) && now - failedAt < IMAGE_FAILOVER_BALANCE_COOLDOWN_MS && now >= failedAt;
}

View File

@ -499,6 +499,7 @@ export async function reviveAiModelsFromCooldown(params?: { modelId?: string })
const dueModels = candidates.filter((model) => { const dueModels = candidates.filter((model) => {
if (isCredentialFailureBan(model.healthAutoDisabledReason)) return false; if (isCredentialFailureBan(model.healthAutoDisabledReason)) return false;
if (isProviderBalanceError(model.healthAutoDisabledReason)) return false;
const cooldownStartedAt = const cooldownStartedAt =
model.healthAutoDisabledAt || model.healthAutoDisabledAt ||
model.healthLastFailureAt || model.healthLastFailureAt ||
@ -568,6 +569,7 @@ export async function recordAiModelHealthSuccess(params: {
healthStatus: aiModel.healthStatus, healthStatus: aiModel.healthStatus,
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak, healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
healthObservationStartedAt: aiModel.healthObservationStartedAt, healthObservationStartedAt: aiModel.healthObservationStartedAt,
healthAutoDisabledReason: aiModel.healthAutoDisabledReason,
}) })
.from(aiModel) .from(aiModel)
.where(eq(aiModel.id, modelId)) .where(eq(aiModel.id, modelId))
@ -575,6 +577,9 @@ export async function recordAiModelHealthSuccess(params: {
if (!existing) return; if (!existing) return;
// A previously accepted task can finish after the account runs out of credit.
if (isProviderBalanceError(existing.healthAutoDisabledReason)) return;
const now = new Date(); const now = new Date();
// A request that started before the circuit opened can still prove the // A request that started before the circuit opened can still prove the
// provider is reachable. Treat that success as the circuit's half-open // provider is reachable. Treat that success as the circuit's half-open
@ -662,6 +667,7 @@ export async function recordAiModelHealthFailure(params: {
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak, healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
healthObservationStartedAt: aiModel.healthObservationStartedAt, healthObservationStartedAt: aiModel.healthObservationStartedAt,
healthAutoDisabledAt: aiModel.healthAutoDisabledAt, healthAutoDisabledAt: aiModel.healthAutoDisabledAt,
healthAutoDisabledReason: aiModel.healthAutoDisabledReason,
}) })
.from(aiModel) .from(aiModel)
.where(eq(aiModel.id, modelId)) .where(eq(aiModel.id, modelId))
@ -678,13 +684,15 @@ export async function recordAiModelHealthFailure(params: {
: existing.healthConsecutiveFailures || 0; : existing.healthConsecutiveFailures || 0;
const shouldAutoDisable = const shouldAutoDisable =
classification.counted && classification.counted &&
!existing.excludeFromFailover && (classification.eventType === "quota" ||
!existing.excludeFromHealthBan && (!existing.excludeFromFailover &&
(classification.reason === "provider_authentication_failed" || !existing.excludeFromHealthBan &&
(isObservation && existing.isEnabled) || (classification.reason === "provider_authentication_failed" ||
(existing.healthStatus !== "auto_disabled" && (isObservation && existing.isEnabled) ||
nextConsecutiveFailures >= AUTO_DISABLE_FAILURE_THRESHOLD)); (existing.healthStatus !== "auto_disabled" &&
const nextStatus = shouldAutoDisable nextConsecutiveFailures >= AUTO_DISABLE_FAILURE_THRESHOLD))));
const balanceDisabled = isProviderBalanceError(existing.healthAutoDisabledReason);
const nextStatus = shouldAutoDisable || balanceDisabled
? "auto_disabled" ? "auto_disabled"
: classification.counted : classification.counted
? "degraded" ? "degraded"
@ -737,7 +745,7 @@ export async function recordAiModelHealthFailure(params: {
healthUpdatedAt: now, healthUpdatedAt: now,
updatedAt: now, updatedAt: now,
}; };
if (shouldAutoDisable) { if (shouldAutoDisable && !balanceDisabled) {
updateData.healthAutoDisabledAt = now; updateData.healthAutoDisabledAt = now;
updateData.healthAutoDisabledReason = autoDisabledReason || errorMessage; updateData.healthAutoDisabledReason = autoDisabledReason || errorMessage;
} else if (existing.healthStatus !== "auto_disabled" && !isObservation) { } else if (existing.healthStatus !== "auto_disabled" && !isObservation) {

View File

@ -238,7 +238,10 @@ export function bindVip98kImages(
imageUrls: string[], imageUrls: string[],
) { ) {
const bindingKeys: string[] = []; const bindingKeys: string[] = [];
if (imageUrls.length === 0) return bindingKeys; if (imageUrls.length === 0) {
for (const key of [...IMAGE_ARRAY_INPUT_KEYS, ...IMAGE_SINGLE_INPUT_KEYS, "video_clips"]) delete body[key];
return bindingKeys;
}
for (const key of IMAGE_ARRAY_INPUT_KEYS) { for (const key of IMAGE_ARRAY_INPUT_KEYS) {
if (!(key in body)) continue; if (!(key in body)) continue;
@ -305,6 +308,7 @@ function scoreReferenceInvocation(invocation: Vip98kTaskInvocation) {
export function selectVip98kTaskInvocation(params: { export function selectVip98kTaskInvocation(params: {
modelId: string; modelId: string;
kind?: string;
configuredInvocation: Vip98kTaskInvocation | null; configuredInvocation: Vip98kTaskInvocation | null;
configuredInvocations?: Vip98kTaskInvocation[]; configuredInvocations?: Vip98kTaskInvocation[];
imageCount?: number; imageCount?: number;
@ -334,6 +338,17 @@ export function selectVip98kTaskInvocation(params: {
} }
} }
if (params.kind === "image" && !params.imageCount) {
const textImageTask = candidates.find(candidate => {
const properties = candidate.inputSchema?.properties as Record<string, unknown> | undefined;
const required = candidate.inputSchema?.required as string[] | undefined;
return candidate.kind === "task" && candidate.path === "/extensions/v1/tasks" &&
invocationTargetsModel(candidate, params.modelId) && properties?.resolution && properties?.aspect_ratio &&
!required?.some(key => [...IMAGE_ARRAY_INPUT_KEYS, ...IMAGE_SINGLE_INPUT_KEYS, "video_clips"].includes(key));
});
if (textImageTask) return resolveVip98kTaskInvocation(params.modelId, textImageTask);
}
return resolveVip98kTaskInvocation( return resolveVip98kTaskInvocation(
params.modelId, params.modelId,
params.configuredInvocation || candidates[0] || null, params.configuredInvocation || candidates[0] || null,

View File

@ -6,7 +6,7 @@ import { agentImageCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-sto
import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing"; import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing";
import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover"; import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover";
import { agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error"; import { agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries"; import { getAiModelById, getEnabledAiModelsWithProvider } from "@/lib/db/queries";
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service"; import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { createGeminiImage } from "@/lib/ai/providers/gemini-image"; import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image"; import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
@ -43,7 +43,8 @@ import {
shouldFailoverAiModelError, shouldFailoverAiModelError,
} from "@/lib/ai/runtime/model-health"; } from "@/lib/ai/runtime/model-health";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown"; import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
import { isProviderBalanceError } from "@/lib/request-error";
export interface CanvasLocalEditCompositeRegion { export interface CanvasLocalEditCompositeRegion {
x: number; x: number;
@ -198,6 +199,7 @@ export interface CanvasGenerateImageRequest {
agentImageMaxCreditCost?: number; agentImageMaxCreditCost?: number;
internalFailoverTriedModelRefs?: string[]; internalFailoverTriedModelRefs?: string[];
internalFailoverTriedProviderTypes?: string[]; internalFailoverTriedProviderTypes?: string[];
internalBalanceBlockedProviderIds?: string[];
onQueueStatusChange?: ( onQueueStatusChange?: (
status: CanvasImageModelQueueStatus & { status: CanvasImageModelQueueStatus & {
modelName?: string | null; modelName?: string | null;
@ -4512,7 +4514,7 @@ function findCanvasFailoverModel(params: {
!item.model.isEnabled || !item.model.isEnabled ||
item.model.healthStatus === "auto_disabled" || item.model.healthStatus === "auto_disabled" ||
item.model.isSubModel || item.model.isSubModel ||
isImageFallbackModelBalanceCoolingDown(item.model) || isImageModelBalanceBlocked(item.model) ||
(item.model.excludeFromFailover && !isSameNameScaleAdjustFallback) (item.model.excludeFromFailover && !isSameNameScaleAdjustFallback)
) { ) {
return false; return false;
@ -4904,7 +4906,20 @@ export async function generateCanvasImage(
); );
const allModels = await getEnabledAiModelsWithProvider(); const allModels = await getEnabledAiModelsWithProvider();
let imageModels = allModels.filter((m) => m.model.supportsImageGen); const balanceBlockedProviderIds = new Set(request.internalBalanceBlockedProviderIds || []);
let imageModels = allModels.filter((m) => m.model.supportsImageGen && !isImageModelBalanceBlocked(m.model) && !balanceBlockedProviderIds.has(m.provider.id));
const requestedModel = allModels.find(entry => entry.model.id === model || entry.model.modelId === model)?.model ||
(/^[0-9a-f-]{36}$/i.test(String(model || "")) ? await getAiModelById({ id: model! }) : undefined);
let effectiveModel = model;
if (requestedModel?.supportsImageGen && isImageModelBalanceBlocked(requestedModel)) {
const replacement = request.allowFailover !== false && request.agentImageMode !== "pinned"
? resolveConfiguredImageBalanceFallback(requestedModel, imageModels, {
imageSize: request.imageSize, aspectRatio: request.aspectRatio,
referenceImageCount: new Set([...(referenceImages || []), referenceImage].filter(Boolean)).size,
}) : undefined;
if (!replacement) throw new Error("当前生图线路余额不足,已暂停新请求;请切换可用模型,已有任务和图片会保留。");
effectiveModel = replacement.model.id;
}
if (request.agentImageMode === "auto") { if (request.agentImageMode === "auto") {
const states = await Promise.all(imageModels.map(async entry => ({ entry, paused: await agentImageCircuitStore.isPaused(entry) }))); const states = await Promise.all(imageModels.map(async entry => ({ entry, paused: await agentImageCircuitStore.isPaused(entry) })));
imageModels = states.filter(item => !item.paused && item.entry.model.healthStatus !== "auto_disabled" && imageModels = states.filter(item => !item.paused && item.entry.model.healthStatus !== "auto_disabled" &&
@ -4916,8 +4931,8 @@ export async function generateCanvasImage(
throw new Error("暂无可用的图片生成模型"); throw new Error("暂无可用的图片生成模型");
} }
let selectedModel = model let selectedModel = effectiveModel
? imageModels.find((m) => m.model.modelId === model || m.model.id === model) ? imageModels.find((m) => m.model.modelId === effectiveModel || m.model.id === effectiveModel)
: imageModels.find((m) => m.model.isDefault) || imageModels[0]; : imageModels.find((m) => m.model.isDefault) || imageModels[0];
if (!selectedModel) { if (!selectedModel) {
@ -5654,7 +5669,7 @@ export async function generateCanvasImage(
? request.internalRetryVariantIndex + 1 ? request.internalRetryVariantIndex + 1
: 0; : 0;
const retryVariant = retryVariants[nextRetryVariantIndex]; const retryVariant = retryVariants[nextRetryVariantIndex];
if (retryVariant && !request.agentImageMode) { if (retryVariant && !request.agentImageMode && !isProviderBalanceError(caughtError)) {
const { const {
debugMeta: retryVariantDebugMeta, debugMeta: retryVariantDebugMeta,
label: retryVariantLabel, label: retryVariantLabel,
@ -5710,7 +5725,8 @@ export async function generateCanvasImage(
} else if (request.agentImageMode) { } else if (request.agentImageMode) {
const failure = agentImageFailurePolicy(caughtError); const failure = agentImageFailurePolicy(caughtError);
if (failure.pause) await agentImageCircuitStore.fail(selectedModel, failure.pause); if (failure.pause) await agentImageCircuitStore.fail(selectedModel, failure.pause);
} else { }
if (!scaleAdjustRejected && (!request.agentImageMode || isProviderBalanceError(caughtError))) {
await recordAiModelHealthFailure({ await recordAiModelHealthFailure({
model: selectedModel.model, model: selectedModel.model,
provider, provider,
@ -5756,17 +5772,18 @@ export async function generateCanvasImage(
...(Array.isArray(request.internalFailoverTriedProviderTypes) ...(Array.isArray(request.internalFailoverTriedProviderTypes)
? request.internalFailoverTriedProviderTypes ? request.internalFailoverTriedProviderTypes
: []), : []),
...(request.agentImageMode !== "auto" && shouldFailover && ...(request.agentImageMode !== "auto" && !isProviderBalanceError(caughtError) && shouldFailover &&
failureClassification.reason !== "non_provider_failure" && failureClassification.reason !== "non_provider_failure" &&
failureClassification.reason !== "user_cancelled" failureClassification.reason !== "user_cancelled"
? [provider.type] ? [provider.type]
: []), : []),
].filter(Boolean)) ].filter(Boolean))
); );
if (isProviderBalanceError(caughtError)) balanceBlockedProviderIds.add(provider.id);
const fallbackModel = allowFailover && shouldFailover const fallbackModel = allowFailover && shouldFailover
? findCanvasFailoverModel({ ? findCanvasFailoverModel({
selectedModel, selectedModel,
imageModels, imageModels: imageModels.filter(entry => !balanceBlockedProviderIds.has(entry.provider.id)),
failedModelRefs, failedModelRefs,
failedProviderTypes, failedProviderTypes,
preferLowCostSameNameProviders: Boolean( preferLowCostSameNameProviders: Boolean(
@ -5797,6 +5814,7 @@ export async function generateCanvasImage(
model: fallbackModel.model.id, model: fallbackModel.model.id,
internalFailoverTriedModelRefs: failedModelRefs, internalFailoverTriedModelRefs: failedModelRefs,
internalFailoverTriedProviderTypes: failedProviderTypes, internalFailoverTriedProviderTypes: failedProviderTypes,
internalBalanceBlockedProviderIds: [...balanceBlockedProviderIds],
debugMeta: { debugMeta: {
...debugMeta, ...debugMeta,
failoverFromModelId: selectedModel.model.modelId, failoverFromModelId: selectedModel.model.modelId,

View File

@ -1,11 +1,11 @@
/** A half-open span containing a complete Markdown image or linked image. */ /** A half-open span containing a complete Markdown link or image. */
export type MarkdownImageRange = { start: number; end: number }; export type MarkdownImageRange = { start: number; end: number };
type Parsed = { kind: "complete"; end: number } | { kind: "pending" } | { kind: "invalid" }; type Parsed = { kind: "complete"; end: number } | { kind: "pending" } | { kind: "invalid" };
// This scanner recognizes syntax only; rendering and URL safety remain the // This scanner recognizes syntax only; rendering and URL safety remain the
// responsibility of the existing Markdown renderer and image splitter. // responsibility of the existing Markdown renderer and image splitter.
function readDestination(text: string, start: number): Parsed { function readDestination(text: string, start: number, allowWhitespace = false): Parsed {
if (start >= text.length) return { kind: "pending" }; if (start >= text.length) return { kind: "pending" };
if (text[start] !== "(") return { kind: "invalid" }; if (text[start] !== "(") return { kind: "invalid" };
let depth = 1; let depth = 1;
@ -16,7 +16,7 @@ function readDestination(text: string, start: number): Parsed {
i += 1; i += 1;
continue; continue;
} }
if (/\s/.test(char)) return { kind: "invalid" }; if (char === "\n" || char === "\r" || (!allowWhitespace && /\s/.test(char))) return { kind: "invalid" };
if (char === "(") depth += 1; if (char === "(") depth += 1;
if (char === ")" && --depth === 0) return { kind: "complete", end: i + 1 }; if (char === ")" && --depth === 0) return { kind: "complete", end: i + 1 };
} }
@ -47,7 +47,37 @@ function readImage(text: string, start: number, wrapped: boolean): Parsed {
return { kind: "pending" }; return { kind: "pending" };
} }
/** Locate completed image syntax and a potentially incomplete trailing image. 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. */ * Code spans/fences and escaped markers remain normal text. */
export function scanMarkdownImageStreaming(text: string): { export function scanMarkdownImageStreaming(text: string): {
ranges: MarkdownImageRange[]; ranges: MarkdownImageRange[];
@ -83,8 +113,9 @@ export function scanMarkdownImageStreaming(text: string): {
const char = text[cursor]; const char = text[cursor];
if (char !== "[" && char !== "!") { cursor += 1; continue; } if (char !== "[" && char !== "!") { cursor += 1; continue; }
const wrapped = char === "["; const wrapped = char === "[";
if (wrapped && cursor + 1 < text.length && text[cursor + 1] !== "!") { cursor += 1; continue; } const parsed = wrapped && text[cursor + 1] !== "!"
const parsed = readImage(text, cursor, wrapped); ? readLink(text, cursor)
: readImage(text, cursor, wrapped);
if (parsed.kind === "pending") return { ranges, pendingStart: cursor }; if (parsed.kind === "pending") return { ranges, pendingStart: cursor };
if (parsed.kind === "complete") { ranges.push({ start: cursor, end: parsed.end }); cursor = parsed.end; } if (parsed.kind === "complete") { ranges.push({ start: cursor, end: parsed.end }); cursor = parsed.end; }
else cursor += 1; else cursor += 1;
@ -92,7 +123,7 @@ export function scanMarkdownImageStreaming(text: string): {
return { ranges, pendingStart: null }; return { ranges, pendingStart: null };
} }
/** During active delivery, withhold only unfinished image syntax at the tail. */ /** During active delivery, withhold only unfinished link/image syntax at the tail. */
export function stableStreamingMarkdown(text: string, streaming: boolean): string { export function stableStreamingMarkdown(text: string, streaming: boolean): string {
if (!streaming) return text; if (!streaming) return text;
const { pendingStart } = scanMarkdownImageStreaming(text); const { pendingStart } = scanMarkdownImageStreaming(text);
@ -101,7 +132,7 @@ export function stableStreamingMarkdown(text: string, streaming: boolean): strin
export function advancePastAtomicRanges(length: number, target: number, ranges: MarkdownImageRange[]) { export function advancePastAtomicRanges(length: number, target: number, ranges: MarkdownImageRange[]) {
let position = Math.min(length, target); let position = Math.min(length, target);
// Overlapping table/image ranges can require more than one jump. // Overlapping table/link ranges can require more than one jump.
for (let changed = true; changed;) { for (let changed = true; changed;) {
changed = false; changed = false;
for (const range of ranges) { for (const range of ranges) {

View File

@ -1,4 +1,5 @@
import { createHash } from "node:crypto"; 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 { fetchImageBytes, onceImageBytes } from "@/lib/storage/image-byte-cache";
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url"; import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
@ -296,6 +297,9 @@ export async function fetchCanvasDownloadAsset(params: {
retryCount?: number; retryCount?: number;
signal?: AbortSignal; signal?: AbortSignal;
}): Promise<CanvasDownloadAssetResult> { }): Promise<CanvasDownloadAssetResult> {
if (isRetiredStorageUrl(params.file.url)) {
return { ok: false, url: params.file.url, name: params.file.name, error: RETIRED_STORAGE_MESSAGE };
}
let lastError = ""; let lastError = "";
const timeoutMs = params.timeoutMs || DEFAULT_FETCH_TIMEOUT_MS; const timeoutMs = params.timeoutMs || DEFAULT_FETCH_TIMEOUT_MS;
const retryCount = params.retryCount || DEFAULT_FETCH_RETRY_COUNT; 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. */ /** Serialize local saves so each request reads the revision returned by the last. */
export function createCanvasSaveQueue() { export function createCanvasSaveQueue() {
let pending: Promise<unknown> = Promise.resolve(); let pending: Promise<unknown> = Promise.resolve();
return { const queue = {
enqueue<T>(save: () => Promise<T>): Promise<T> { enqueue<T>(save: () => Promise<T>): Promise<T> {
const next = pending.then(save, save); const next = pending.then(save, save);
pending = next.catch(() => undefined); pending = next.catch(() => undefined);
return next; 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,6 @@
import "server-only"; import "server-only";
import { isImageModelBalanceBlocked } from "@/lib/ai/runtime/image-balance";
import { resolveSiteBranding } from "@/lib/site-branding";
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db"; import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
import { import {
@ -4554,7 +4556,7 @@ export async function getEnabledAiModelsWithProvider(
return 3; return 3;
}; };
return [...models].sort((left, right) => { return models.filter(({ model }) => !model.supportsImageGen || !isImageModelBalanceBlocked(model)).sort((left, right) => {
const byHealth = const byHealth =
healthPriority(left.model.healthStatus) - healthPriority(left.model.healthStatus) -
healthPriority(right.model.healthStatus); healthPriority(right.model.healthStatus);
@ -4744,9 +4746,9 @@ export async function getSiteSettings() {
? videoCanvasEnabledSetting.value !== false ? videoCanvasEnabledSetting.value !== false
: videoCanvasSidebarEnabledSetting?.value !== false; : videoCanvasSidebarEnabledSetting?.value !== false;
return { ...(settings || defaultSettings), videoCanvasEnabled }; return resolveSiteBranding({ ...(settings || defaultSettings), videoCanvasEnabled });
} catch (_error) { } catch (_error) {
return defaultSettings; return resolveSiteBranding(defaultSettings);
} }
} }

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 "server-only";
import { isRetiredStorageUrl } from "./retired-storage";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { import {
@ -38,7 +39,7 @@ export async function enqueueImageArchiveTask(params: {
metadata?: Record<string, unknown> | null; metadata?: Record<string, unknown> | null;
}) { }) {
const sourceUrl = String(params.sourceUrl || "").trim(); const sourceUrl = String(params.sourceUrl || "").trim();
if (!isGeneratedImageSource(sourceUrl)) return null; if (!isGeneratedImageSource(sourceUrl) || isRetiredStorageUrl(sourceUrl)) return null;
const now = new Date(); const now = new Date();
const archiveAfterAt = params.archiveAfterAt || now; const archiveAfterAt = params.archiveAfterAt || now;

View File

@ -1,5 +1,6 @@
import { fetchImageBytes, cacheImageBytes } from "@/lib/storage/image-byte-cache"; import { fetchImageBytes, cacheImageBytes } from "@/lib/storage/image-byte-cache";
import "server-only"; import "server-only";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { isGeneratedImageSource } from "./image-archive-urls"; import { isGeneratedImageSource } from "./image-archive-urls";
@ -170,6 +171,7 @@ function toArrayBuffer(buffer: Buffer): ArrayBuffer {
} }
export async function downloadTemporaryImage(params: { sourceUrl: string; retries?: number }) { 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)); const retries = Math.max(0, Math.min(5, params.retries ?? 2));
for (let attempt = 0; attempt <= retries; attempt += 1) { for (let attempt = 0; attempt <= retries; attempt += 1) {

View File

@ -1,4 +1,5 @@
import "server-only"; import "server-only";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
import { createHash, randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, writeFile, rename, readdir, stat, statfs, unlink } from "node:fs/promises"; import { mkdir, readFile, writeFile, rename, readdir, stat, statfs, unlink } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
@ -81,6 +82,7 @@ function isMediaResponse(data: ImageBytes) {
return data.buffer.length > 0; return data.buffer.length > 0;
} }
export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string } = {}) { 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 () => { return onceImageBytes(`source:${options.scope || ""}:${url}`, async () => {
if (url.startsWith("data:")) { if (url.startsWith("data:")) {
const match = url.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i); const match = url.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);

View File

@ -1,4 +1,5 @@
import "server-only"; import "server-only";
import { isRetiredStorageUrl } from "./retired-storage";
import { and, asc, desc, eq, inArray, lte, sql } from "drizzle-orm"; import { and, asc, desc, eq, inArray, lte, sql } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
@ -16,6 +17,8 @@ function isUsableS3Config(config: StorageConfig | null | undefined): config is S
config && config &&
config.type === "s3" && config.type === "s3" &&
config.isEnabled && config.isEnabled &&
!isRetiredStorageUrl(config.endpoint) &&
!isRetiredStorageUrl(config.publicUrl) &&
String(config.endpoint || "").trim() && String(config.endpoint || "").trim() &&
String(config.bucket || "").trim() && String(config.bucket || "").trim() &&
String(config.accessKeyId || "").trim() && String(config.accessKeyId || "").trim() &&
@ -80,7 +83,7 @@ export async function enqueueStorageMigrationTask({
migrateAfterAt: Date; migrateAfterAt: Date;
cleanupAfterAt: Date; cleanupAfterAt: Date;
}) { }) {
if (!sourceUrl || !sourcePath || !sourceStorageConfigId) return; if (!sourceUrl || !sourcePath || !sourceStorageConfigId || isRetiredStorageUrl(sourceUrl)) return;
await db await db
.insert(storageMigrationTask) .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 "server-only";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { put } from "@vercel/blob"; import { put } from "@vercel/blob";
@ -149,6 +150,9 @@ export function getContentType(filename: string): string {
} }
export function createS3Client(config: S3UploadConfig): S3Client { 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 normalizedEndpoint = normalizeEndpoint(config.endpoint);
const lowerEndpoint = normalizedEndpoint.toLowerCase(); const lowerEndpoint = normalizedEndpoint.toLowerCase();
const isCloudS3 = const isCloudS3 =
@ -337,6 +341,8 @@ export function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null {
!value || !value ||
!provider || !provider ||
!value.isEnabled || !value.isEnabled ||
isRetiredStorageUrl(endpoint) ||
isRetiredStorageUrl(String(value.publicUrl || "")) ||
!endpoint || !endpoint ||
!bucket || !bucket ||
!accessKeyId || !accessKeyId ||
@ -364,7 +370,7 @@ function buildEnvS3Config(): S3UploadConfig | null {
const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim() ?? ""; const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim() ?? "";
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.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 { return {
endpoint, endpoint,
region: process.env.S3_REGION?.trim() || "auto", region: process.env.S3_REGION?.trim() || "auto",

View File

@ -132,6 +132,7 @@
"form-data": "^4.0.5", "form-data": "^4.0.5",
"framer-motion": "^11.18.2", "framer-motion": "^11.18.2",
"geist": "^1.3.1", "geist": "^1.3.1",
"gsap": "3.14.2",
"heic-convert": "^2.1.0", "heic-convert": "^2.1.0",
"immer": "^11.1.3", "immer": "^11.1.3",
"is-hotkey": "^0.2.0", "is-hotkey": "^0.2.0",

View File

@ -3,10 +3,12 @@ import { defineConfig } from "@playwright/test";
export default defineConfig({ export default defineConfig({
testDir: "./tests", testDir: "./tests",
testMatch: [ testMatch: [
/routes\/admin-media-loading\.test\.ts/,
/routes\/video-canvas-production\.test\.ts/, /routes\/video-canvas-production\.test\.ts/,
/routes\/video-canvas-media-import\.test\.ts/, /routes\/video-canvas-media-import\.test\.ts/,
/routes\/video-canvas-toolbar-jobs\.test\.ts/, /routes\/video-canvas-toolbar-jobs\.test\.ts/,
/routes\/image-provider-error-classification\.test\.ts/, /routes\/image-provider-error-classification\.test\.ts/,
/routes\/canvas-agent-runtime-image-failover\.test\.ts/,
/routes\/video-canvas-audio-remote\.test\.ts/, /routes\/video-canvas-audio-remote\.test\.ts/,
/routes\/video-canvas-groups-upscale\.test\.ts/, /routes\/video-canvas-groups-upscale\.test\.ts/,
/routes\/tvstar-creative-analysis\.test\.ts/, /routes\/tvstar-creative-analysis\.test\.ts/,

8
pnpm-lock.yaml generated
View File

@ -234,6 +234,9 @@ importers:
geist: geist:
specifier: ^1.3.1 specifier: ^1.3.1
version: 1.3.1(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)) version: 1.3.1(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021))
gsap:
specifier: 3.14.2
version: 3.14.2
heic-convert: heic-convert:
specifier: ^2.1.0 specifier: ^2.1.0
version: 2.1.0 version: 2.1.0
@ -460,6 +463,9 @@ importers:
packages: packages:
gsap@3.14.2:
resolution: {integrity: sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==}
'@ai-sdk/gateway@1.0.15': '@ai-sdk/gateway@1.0.15':
resolution: {integrity: sha512-xySXoQ29+KbGuGfmDnABx+O6vc7Gj7qugmj1kGpn0rW0rQNn6UKUuvscKMzWyv1Uv05GyC1vqHq8ZhEOLfXscQ==} resolution: {integrity: sha512-xySXoQ29+KbGuGfmDnABx+O6vc7Gj7qugmj1kGpn0rW0rQNn6UKUuvscKMzWyv1Uv05GyC1vqHq8ZhEOLfXscQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -6967,6 +6973,8 @@ packages:
snapshots: snapshots:
gsap@3.14.2: {}
'@ai-sdk/gateway@1.0.15(zod@3.25.76)': '@ai-sdk/gateway@1.0.15(zod@3.25.76)':
dependencies: dependencies:
'@ai-sdk/provider': 2.0.0 '@ai-sdk/provider': 2.0.0

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

@ -0,0 +1,10 @@
-- Run outside a transaction. Concurrent creation keeps normal inserts available.
CREATE INDEX CONCURRENTLY IF NOT EXISTS "GeneratedImage_created_id_idx"
ON public."GeneratedImage" ("createdAt" DESC, id DESC);
CREATE INDEX CONCURRENTLY IF NOT EXISTS "Message_v2_video_created_id_idx"
ON public."Message_v2" ("createdAt" DESC, id DESC)
WHERE role = 'assistant' AND (
permanent_video_url IS NOT NULL OR temporary_video_url IS NOT NULL
OR parts::text ILIKE '%<video%' OR parts::text ILIKE '%.mp4%'
);

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(); 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; return data;
} catch (error) { } catch (error) {
return { success: false, 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

@ -6,7 +6,7 @@ export function agentImageProviderHarness(options: { generate?: (id: string, cou
const models = options.models || ["primary", "backup"].map((id, i) => ({ model: { id, modelId: id, displayName: id, isEnabled: true, supportsImageGen: true, supportsVision: true, isDefault: !i, imageGenConfig: { maxImages: 12 }, capabilities: { kind: "image" } }, provider: { id: "same-provider", type: "openai-image", apiKey: "synthetic", isEnabled: true } })); const models = options.models || ["primary", "backup"].map((id, i) => ({ model: { id, modelId: id, displayName: id, isEnabled: true, supportsImageGen: true, supportsVision: true, isDefault: !i, imageGenConfig: { maxImages: 12 }, capabilities: { kind: "image" } }, provider: { id: "same-provider", type: "openai-image", apiKey: "synthetic", isEnabled: true } }));
const mocks: Record<string, any> = { const mocks: Record<string, any> = {
"@/lib/ai/runtime/image-generation-error": generationErrors, "@/lib/ai/runtime/image-generation-error": generationErrors,
"@/lib/db/queries": { getEnabledAiModelsWithProvider: async () => models }, "@/lib/db/queries": { getEnabledAiModelsWithProvider: async () => models.filter(entry => entry.model.isEnabled), getAiModelById: async ({ id }: { id: string }) => models.find(entry => entry.model.id === id)?.model },
"@/lib/ai/rmbg/service": {}, "@/lib/ai/rmbg/service": {},
"@/lib/net/url-access": { filterReachableRemoteUrls: async (urls: string[]) => ({ kept: urls, dropped: [] }) }, "@/lib/net/url-access": { filterReachableRemoteUrls: async (urls: string[]) => ({ kept: urls, dropped: [] }) },
"@/lib/canvas/image-concurrency": { acquireCanvasImageModelSlot: async () => ({ waitMs: 0, maxConcurrent: 999, release: async () => {} }), normalizeCanvasImageModelKey: (v: string) => v, recordCanvasImageModelExecutionDuration() {} }, "@/lib/canvas/image-concurrency": { acquireCanvasImageModelSlot: async () => ({ waitMs: 0, maxConcurrent: 999, release: async () => {} }), normalizeCanvasImageModelKey: (v: string) => v, recordCanvasImageModelExecutionDuration() {} },

View File

@ -63,3 +63,42 @@ test("coalesces overlapping typewriter table and image ranges without looping",
assert.equal(advancePastAtomicRanges(50, 36, ranges), 40); assert.equal(advancePastAtomicRanges(50, 36, ranges), 40);
assert.equal(advancePastAtomicRanges(50, 50, ranges), 50); 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,45 @@
import { expect, test } from "@playwright/test";
import { queuePreview } from "../../lib/admin/preview-queue";
import { formatRecordTime } from "../../lib/admin/record-time";
import { parseMediaQuery } from "../../lib/admin/media-records";
test("pagination rejects invalid values and bounds a requested page size", () => {
expect(parseMediaQuery(new URLSearchParams("page=-1&pageSize=200"))).toMatchObject({
page: 1,
pageSize: 100,
});
expect(
parseMediaQuery(new URLSearchParams("page=2&pageSize=40&search=%E9%99%88%E9%BE%99")),
).toMatchObject({ page: 2, pageSize: 40, search: "陈龙" });
expect(parseMediaQuery(new URLSearchParams("page=NaN&pageSize=0"))).toMatchObject({
page: 1,
pageSize: 40,
});
});
test("normalized Beijing record times do not receive a second timezone offset", () => {
expect(formatRecordTime("2026-11-03T23:55:00.123Z")).toBe("2026-11-03 23:55");
expect(formatRecordTime("2026-11-03T17:12:00.123456")).toBe("2026-11-03 17:12");
});
test("leaving a page cancels waiting previews and retains active slots until fetch settles", () => {
const started: number[] = [];
const releases: Array<() => void> = [];
const cancel = Array.from({ length: 7 }, (_, index) =>
queuePreview((release) => {
started.push(index);
releases[index] = release;
}),
);
expect(started).toEqual([0, 1, 2, 3]);
cancel[0]();
cancel[4]();
expect(started).toEqual([0, 1, 2, 3]);
releases[0]();
expect(started).toEqual([0, 1, 2, 3, 5]);
releases[0]();
expect(started).toHaveLength(5);
releases[1]();
expect(started).toEqual([0, 1, 2, 3, 5, 6]);
for (const release of releases) release?.();
});

View File

@ -5,6 +5,33 @@ import { ImageSubmissionRejectedError, agentImageGenerationError } from "@/lib/a
import * as generationErrors from "@/lib/ai/runtime/image-generation-error"; import * as generationErrors from "@/lib/ai/runtime/image-generation-error";
import { serverLoader } from "../helpers/canvas-download-server-loader"; import { serverLoader } from "../helpers/canvas-download-server-loader";
test("a balance rejection skips other models on the same account but permits another account of the same provider type", async () => {
const models = ["primary", "same-account", "backup"].map(id => ({
model: { id, modelId: id, displayName: id, isEnabled: true, supportsImageGen: true, supportsVision: true, imageGenConfig: { maxImages: 1 }, capabilities: { kind: "image" } },
provider: { id: id === "backup" ? "funded-account" : "exhausted-account", type: "openai-image", apiKey: "synthetic", isEnabled: true },
}));
const h = harness({ models, generate: id => {
if (id !== "backup") throw new Error("余额不足,请充值 (HTTP 429)");
return "![image](https://fixture.invalid/ok.png)";
} });
expect((await h.generate(h.request)).modelRef).toBe("backup");
expect(h.calls.map(call => call.id)).toEqual(["primary", "backup"]);
});
test("an old disabled balance selection uses its configured compatible replacement without calling the exhausted account", async () => {
const oldId = "11111111-1111-4111-8111-111111111111";
const models = [
{ model: { id: oldId, modelId: "old", displayName: "old", isEnabled: false, supportsImageGen: true, healthLastEventType: "quota", healthLastError: "insufficient balance", fallbackModelId1: "backup" }, provider: { id: "exhausted-account", type: "openai-image", isEnabled: true } },
{ model: { id: "backup", modelId: "backup", displayName: "backup", isEnabled: true, supportsImageGen: true, supportsVision: true, imageGenConfig: { maxImages: 1, maxReferenceImages: 8, supportedImageSizes: ["2K"], supportedAspectRatios: ["3:4"] } }, provider: { id: "funded-account", type: "openai-image", apiKey: "synthetic", isEnabled: true } },
];
const h = harness({ models });
expect((await h.generate({ ...h.request, model: oldId })).modelRef).toBe("backup");
expect(h.calls.map(call => call.id)).toEqual(["backup"]);
await expect(h.generate({ ...h.request, model: oldId, agentImageMode: "pinned" })).rejects.toThrow("余额不足");
await expect(h.generate({ ...h.request, model: oldId, referenceImages: Array.from({ length: 9 }, (_, i) => `https://fixture.invalid/${i}.png`) })).rejects.toThrow("余额不足");
expect(h.calls).toHaveLength(1);
});
test("automatic image generation switches within the same provider and skips a paused model on the next task", async () => { test("automatic image generation switches within the same provider and skips a paused model on the next task", async () => {
const h = harness({ generate: id => { if (id === "primary") throw Object.assign(new Error("channel unavailable"), { status: 503 }); return "![image](https://fixture.invalid/ok.png)"; } }); const h = harness({ generate: id => { if (id === "primary") throw Object.assign(new Error("channel unavailable"), { status: 503 }); return "![image](https://fixture.invalid/ok.png)"; } });

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

@ -5,6 +5,36 @@ import { GET } from "@/app/api/image-proxy/route";
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery"; import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
test.describe("Qiniu image delivery", () => { test.describe("Qiniu image delivery", () => {
test("reports a timeout while reading image bytes as a retryable response", async () => {
const previousFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(new ReadableStream({
start(controller) { controller.error(new DOMException("source stalled", "TimeoutError")); },
}), { headers: { "Content-Type": "image/png" } });
try {
const response = await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fslow.png&proxy=1&w=192"));
expect(response.status).toBe(504);
expect(await response.json()).toMatchObject({ error: "读取源图片超时,请重试" });
} finally { globalThis.fetch = previousFetch; }
});
test("stops the upstream image body when the preview request is cancelled", async () => {
const previousFetch = globalThis.fetch;
const controller = new AbortController();
let upstreamSignal: AbortSignal | undefined;
globalThis.fetch = async (_url, init) => {
upstreamSignal = init?.signal as AbortSignal;
return new Response(new ReadableStream({ start(stream) {
upstreamSignal!.addEventListener("abort", () => stream.error(upstreamSignal!.reason));
queueMicrotask(() => controller.abort());
} }), { headers: { "Content-Type": "image/png" } });
};
try {
const response = await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fcancel.png&proxy=1&w=192", { signal: controller.signal }));
expect(upstreamSignal?.aborted).toBe(true);
expect(response.status).toBe(499);
} finally { globalThis.fetch = previousFetch; }
});
test("uses Qiniu image processing for direct canvas previews", () => { test("uses Qiniu image processing for direct canvas previews", () => {
expect( expect(
buildImageProxyUrl("https://yybb.top/uploads/result.png", { buildImageProxyUrl("https://yybb.top/uploads/result.png", {

View File

@ -5,7 +5,8 @@ import {
isProviderBalanceError, isProviderBalanceError,
isRateLimitError, isRateLimitError,
} from "@/lib/request-error"; } from "@/lib/request-error";
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown"; import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
import { serverLoader } from "../helpers/canvas-download-server-loader";
test("a bare HTTP 429 is rate limiting, not insufficient balance", () => { test("a bare HTTP 429 is rate limiting, not insufficient balance", () => {
const error = Object.assign(new Error("Too many requests (HTTP 429)"), { status: 429 }); const error = Object.assign(new Error("Too many requests (HTTP 429)"), { status: 429 });
@ -34,14 +35,66 @@ test("nested billing details and generic quota limits are kept distinct", () =>
expect(isProviderBalanceError(new Error("该模型额度不足(HTTP 429)"))).toBe(true); expect(isProviderBalanceError(new Error("该模型额度不足(HTTP 429)"))).toBe(true);
}); });
test("a recent confirmed provider balance failure skips only fallback selection", () => { test("confirmed balance failures remain isolated until explicitly restored", () => {
const now = Date.now();
const model = { const model = {
healthLastEventType: "quota", healthLastEventType: "quota",
healthLastError: "余额不足(HTTP 429)", healthLastError: "余额不足(HTTP 429)",
healthLastFailureAt: new Date(now - 30_000),
}; };
expect(isImageFallbackModelBalanceCoolingDown(model, now)).toBe(true); expect(isImageModelBalanceBlocked(model)).toBe(true);
expect(isImageFallbackModelBalanceCoolingDown({ ...model, healthLastFailureAt: new Date(now - 3 * 60 * 60 * 1000) }, now)).toBe(false); expect(isImageModelBalanceBlocked({ healthLastEventType: "error", healthAutoDisabledReason: "provider_balance_or_billing_quota: 余额不足" })).toBe(true);
expect(isImageFallbackModelBalanceCoolingDown({ ...model, healthLastEventType: "success" }, now)).toBe(false); expect(isImageModelBalanceBlocked({ ...model, healthLastEventType: "success", healthLastError: null })).toBe(false);
});
test("old selections use only configured, enabled replacements that preserve the requested inputs", () => {
const requested = { healthLastEventType: "quota", healthLastError: "余额不足", fallbackModelId1: "verified" };
const verified = { model: { id: "verified", modelId: "same-family", isEnabled: true, supportsImageGen: true,
imageGenConfig: { supportedImageSizes: ["2K"], supportedAspectRatios: ["1:1"], maxReferenceImages: 8 } }, provider: { isEnabled: true } };
const input = { imageSize: "2K", aspectRatio: "1:1", referenceImageCount: 2 };
expect(resolveConfiguredImageBalanceFallback(requested, [verified], input)).toBe(verified);
expect(resolveConfiguredImageBalanceFallback(requested, [verified], { ...input, referenceImageCount: 9 })).toBeUndefined();
expect(resolveConfiguredImageBalanceFallback(requested, [verified], { ...input, imageSize: "4K" })).toBeUndefined();
expect(resolveConfiguredImageBalanceFallback(requested, [verified], { ...input, aspectRatio: "9:16" })).toBeUndefined();
expect(resolveConfiguredImageBalanceFallback(requested, [{ ...verified, provider: { isEnabled: false } }], input)).toBeUndefined();
expect(resolveConfiguredImageBalanceFallback({ ...requested, fallbackModelId1: null }, [verified], input)).toBeUndefined();
});
function healthHarness() {
let model: any = { id: "test", modelId: "test", isEnabled: true, excludeFromFailover: true,
excludeFromHealthBan: true, healthStatus: "healthy", healthConsecutiveFailures: 0 };
const events: any[] = [];
const query = (rows: () => any[]) => ({ from() { return this; }, where() { return this; },
limit: async () => rows(), then: (resolve: any) => Promise.resolve(rows()).then(resolve), returning: async () => rows() });
const db = {
select: () => query(() => [model]),
insert: () => ({ values: async (value: any) => { events.push(value); } }),
update: () => ({ set: (value: any) => { model = { ...model, ...value }; return query(() => [model]); } }),
};
const health = serverLoader({ "@/lib/db": { db } })("lib/ai/runtime/model-health.ts");
return { health, events, get model() { return model; }, age() { model.healthAutoDisabledAt = new Date(Date.now() - 4 * 60 * 60_000); } };
}
test("quota pauses an exempt model, survives cooldown and late completion, and can be restored", async () => {
const h = healthHarness();
await h.health.recordAiModelHealthFailure({ model: h.model, error: new Error("余额不足(HTTP 429)") });
expect(h.model.isEnabled).toBe(false);
expect(h.model.healthStatus).toBe("auto_disabled");
expect(h.events[0].eventType).toBe("quota");
await h.health.recordAiModelHealthFailure({ model: h.model, error: new Error("HTTP 503 unavailable") });
await h.health.recordAiModelHealthFailure({ model: h.model, error: new Error("Too many requests (HTTP 429)") });
expect(h.model.healthStatus).toBe("auto_disabled");
expect(isImageModelBalanceBlocked(h.model)).toBe(true);
h.age();
expect(await h.health.reviveAiModelsFromCooldown()).toEqual([]);
await h.health.recordAiModelHealthSuccess({ model: h.model });
expect(h.model.isEnabled).toBe(false);
await h.health.restoreAiModelHealth(h.model.id);
expect(h.model.isEnabled).toBe(true);
expect(isImageModelBalanceBlocked(h.model)).toBe(false);
});
test("ordinary rate limiting does not trigger billing isolation", async () => {
const h = healthHarness();
await h.health.recordAiModelHealthFailure({ model: h.model, error: new Error("Too many requests (HTTP 429)") });
expect(h.model.isEnabled).toBe(true);
expect(isImageModelBalanceBlocked(h.model)).toBe(false);
}); });

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 { mergeGenerationHistory, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
import { randomBytes } from "node:crypto"; 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 sharp from "sharp";
import { import {
isVip98kReferenceSizeAllowed, isVip98kReferenceSizeAllowed,
@ -34,6 +39,9 @@ import {
resolveVip98kSubmissionTimeoutMs, resolveVip98kSubmissionTimeoutMs,
} from "@/lib/ai/vip98k-runtime-config"; } from "@/lib/ai/vip98k-runtime-config";
import { BANANAIMAGE_MODEL_PRESETS } from "@/lib/ai/bananaimage-models"; 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 = { const textToImageInvocation: Vip98kTaskInvocation = {
kind: "tool", kind: "tool",
@ -46,6 +54,84 @@ const textToImageInvocation: Vip98kTaskInvocation = {
"/api/external/tools/result?tool_id=google%2Fnano-banana-2%2Ftext-to-image&taskId={task_id}", "/api/external/tools/result?tool_id=google%2Fnano-banana-2%2Ftext-to-image&taskId={task_id}",
}; };
test("text-to-image uses the advertised task contract with native size and ratio, without example reference images", () => {
const configured: Vip98kTaskInvocation = { ...textToImageInvocation, path: "/v1/images/generations", requestExample: { model: "nano-banana-pro", prompt: "sample" } };
const task: Vip98kTaskInvocation = { ...configured, kind: "task", path: "/extensions/v1/tasks",
requestExample: { model: "nano-banana-pro", prompt: "sample", file_urls: ["https://provider.test/logo.png"] },
inputSchema: { required: ["model"], properties: { resolution: { type: "string" }, aspect_ratio: { type: "string" }, file_urls: { type: "array" } } },
};
const invocation = selectVip98kTaskInvocation({ modelId: "nano-banana-pro", kind: "image", configuredInvocation: configured, configuredInvocations: [configured, task], imageCount: 0 });
expect(invocation?.path).toBe("/extensions/v1/tasks");
const result = buildTaskBody({ apiKey: "test", modelId: "nano-banana-pro", kind: "image", prompt: "a blue mug", imageUrls: [], videoUrls: [], modelConfig: {}, imageParams: { imageSize: "2K", aspectRatio: "3:4" } }, invocation);
expect(result.body).toMatchObject({ prompt: "a blue mug", resolution: "2K", aspect_ratio: "3:4" });
expect(result.body).not.toHaveProperty("file_urls");
expect(result.outputConstraintFallbacks).toEqual([]);
expect(selectVip98kTaskInvocation({ modelId: "nano-banana-pro", kind: "image", configuredInvocation: configured, configuredInvocations: [configured, { ...task, inputSchema: { ...task.inputSchema, required: ["model", "file_urls"] } }], imageCount: 0 })?.path).toBe("/v1/images/generations");
});
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.describe("VIP98K reference-image routing", () => {
test("keeps the provider-selected relay route header", () => { test("keeps the provider-selected relay route header", () => {
const [preset] = buildVip98kModelPresets([ const [preset] = buildVip98kModelPresets([